1
Fork 0

Typing indicator

Also better nicknames and styling
This commit is contained in:
Tobias Berger 2022-01-17 17:35:36 +00:00 committed by Tobias Berger
parent 9143443c27
commit 07d23d7efa
Signed by: toby
GPG key ID: 2D05EFAB764D6A88
5 changed files with 197 additions and 74 deletions

View file

@ -2,12 +2,22 @@ import { FunctionComponent } from "react";
type AuthorComponentProps = { type AuthorComponentProps = {
authorId: string; authorId: string;
authorNickname: string;
}; };
const AuthorComponent: FunctionComponent<AuthorComponentProps> = ({ const AuthorComponent: FunctionComponent<AuthorComponentProps> = ({
authorId, authorId,
authorNickname,
}) => { }) => {
return <span className="message-author">{authorId}</span>; return (
<span
className={
"message-author" + (authorId !== authorNickname ? " nickname" : "")
}
>
{authorNickname}
</span>
);
}; };
export default AuthorComponent; export default AuthorComponent;

View file

@ -5,7 +5,9 @@ import AuthorComponent from "./Author";
type MessageComponentProps = { type MessageComponentProps = {
message: TextMessage; message: TextMessage;
authorNickname: string;
}; };
enum MONTH_NAMES { enum MONTH_NAMES {
"January", "January",
"February", "February",
@ -59,8 +61,8 @@ function timeAgo(dateParam: Date | number) {
const isYesterday = yesterday.toDateString() === date.toDateString(); const isYesterday = yesterday.toDateString() === date.toDateString();
const isThisYear = today.getFullYear() === date.getFullYear(); const isThisYear = today.getFullYear() === date.getFullYear();
if (seconds < 5) { if (seconds < 2) {
return "now"; return "just now";
} else if (seconds < 60) { } else if (seconds < 60) {
return `${seconds} seconds ago`; return `${seconds} seconds ago`;
} else if (seconds < 90) { } else if (seconds < 90) {
@ -80,6 +82,7 @@ function timeAgo(dateParam: Date | number) {
const MessageComponent: FunctionComponent<MessageComponentProps> = ({ const MessageComponent: FunctionComponent<MessageComponentProps> = ({
message, message,
authorNickname,
}) => { }) => {
const date = new Date(message.date); const date = new Date(message.date);
const [timeAgoString, setTimeAgoString] = useState(timeAgo(message.date)); const [timeAgoString, setTimeAgoString] = useState(timeAgo(message.date));
@ -92,14 +95,17 @@ const MessageComponent: FunctionComponent<MessageComponentProps> = ({
} }
}; };
const interval = setInterval(checkTimeAgo, 5000); const interval = setInterval(checkTimeAgo, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [message.date, timeAgoString]); }, [message.date, timeAgoString]);
return ( return (
<div className="message"> <>
<h2> <h2>
<AuthorComponent authorId={message.author} /> <AuthorComponent
authorNickname={authorNickname}
authorId={message.author}
/>
<sub> <sub>
<time className="message-date" dateTime={date.toISOString()}> <time className="message-date" dateTime={date.toISOString()}>
{timeAgoString} {timeAgoString}
@ -107,7 +113,7 @@ const MessageComponent: FunctionComponent<MessageComponentProps> = ({
</sub> </sub>
</h2> </h2>
<div className="message-content">{message.content}</div> <div className="message-content">{message.content}</div>
</div> </>
); );
}; };

@ -1 +1 @@
Subproject commit 4c698768def503209283af5d7a04c0d21c49092f Subproject commit cde3d62b1c0a1c95643037ca03e95bac178118d2

View file

@ -1,10 +1,14 @@
import { useCallback, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import useWebSocket, { import useWebSocket, {
ReadyState, ReadyState,
ReadyState as WebSocketReadyState, ReadyState as WebSocketReadyState,
} from "react-use-websocket"; } from "react-use-websocket";
import MessageComponent from "src/components/Message"; import MessageComponent from "src/components/Message";
import type { TextMessage } from "src/lib/ServerMessage"; import {
isCurrentlyTypingMessage,
TextMessage,
TypingMessage,
} from "src/lib/ServerMessage";
import { import {
isIdResponseMessage, isIdResponseMessage,
isServerMessage, isServerMessage,
@ -12,17 +16,28 @@ import {
MessageType, MessageType,
} from "src/lib/ServerMessage"; } from "src/lib/ServerMessage";
export default function Index(): JSX.Element { let keepAliveIntervals: NodeJS.Timeout[] = [];
const [count, setCount] = useState(0); let shouldResendTyping = true;
const [messageHistory, setMessageHistory] = useState<TextMessage[]>([]);
const [socketUrl, setSocketUrl] = useState("wss.tobot.tk:8085/");
const [authorId, setAuthorId] = useState("<???>");
export default function Index(): JSX.Element {
const [messageHistory, setMessageHistory] = useState<TextMessage[]>([]);
const [currentlyTyping, setCurrentlyTyping] = useState<string[]>([]);
const [socketUrl, setSocketUrl] = useState("wss.tobot.tk:8085/");
const [authorId, setAuthorId] = useState("");
const messageInput = useRef<HTMLInputElement>(null);
const messageContainer = useRef<HTMLOListElement>(null);
const getNickname = useCallback(
(id: string) => {
return id === authorId ? "You" : id;
},
[authorId]
);
function onMessage(event: MessageEvent<string>): void { function onMessage(event: MessageEvent<string>): void {
const message = JSON.parse(event.data); const message = JSON.parse(event.data);
if (!isServerMessage(message)) { if (!isServerMessage(message)) {
console.log("DEBUG: ", message); console.debug("DEBUG: ", message);
throw new Error(`Server sent unexpected message \`${event.data}}\``); throw new Error(`Server sent unexpected message \`${event.data}}\``);
} }
@ -30,6 +45,9 @@ export default function Index(): JSX.Element {
setAuthorId(message.authorId); setAuthorId(message.authorId);
} else if (isTextMessage(message)) { } else if (isTextMessage(message)) {
setMessageHistory([...messageHistory, message]); setMessageHistory([...messageHistory, message]);
console.log("scrollheight", messageContainer.current?.scrollHeight);
} else if (isCurrentlyTypingMessage(message)) {
setCurrentlyTyping(message.currently);
} }
} }
@ -43,22 +61,34 @@ export default function Index(): JSX.Element {
date: Date.now(), date: Date.now(),
}); });
}, 1000); }, 1000);
keepAliveIntervals.push(keepAliveInterval);
}, },
onClose() { onClose() {
clearInterval(keepAliveInterval); clearInterval(keepAliveInterval);
keepAliveIntervals = keepAliveIntervals.filter(
(interval) => interval !== keepAliveInterval
);
}, },
}); });
const handleClickSendMessage = useCallback(() => { const handleClickSendMessage = useCallback(() => {
setCount(count + 1); if (!messageInput.current) {
const message: TextMessage = { return;
}
const messageText = messageInput.current?.value;
if (messageText === undefined || messageText.length === 0) {
return;
}
messageInput.current.value = "";
websocket.sendJsonMessage({
author: authorId, author: authorId,
type: MessageType.TEXT, type: MessageType.TEXT,
date: Date.now(), date: Date.now(),
content: `Hello, world! ${count}`, content: messageText,
}; } as TextMessage);
websocket.sendJsonMessage(message); }, [authorId, websocket]);
}, [count, websocket, authorId]);
const trySetSocketUrl = useCallback( const trySetSocketUrl = useCallback(
(url: string) => { (url: string) => {
@ -77,36 +107,108 @@ export default function Index(): JSX.Element {
[websocket] [websocket]
); );
function handleInput() {
if (
websocket.readyState === ReadyState.OPEN &&
(shouldResendTyping || !currentlyTyping.includes(authorId))
) {
websocket.sendJsonMessage({
type: MessageType.TYPING,
date: Date.now(),
} as TypingMessage);
shouldResendTyping = false;
setTimeout(() => (shouldResendTyping = true), 1000);
}
}
console.log(handleInput);
const typingIndicator = useMemo(() => {
if (currentlyTyping.length === 0) {
return <>Nobody is typing</>;
}
if (currentlyTyping.length === 1) {
if (currentlyTyping[0] === authorId) {
return (
<>
<span className="nickname">You</span> are typing...
</>
);
}
return (
<>
{getNickname(currentlyTyping[0]) === currentlyTyping[0] ? (
currentlyTyping[0]
) : (
<span className="nickname">{getNickname(currentlyTyping[0])}</span>
)}{" "}
is typing...
</>
);
}
if (currentlyTyping.length < 4) {
const result = currentlyTyping.map((id, idx, arr) => (
<>
{id === getNickname(id) ? (
id
) : (
<span className="nickname">{getNickname(id)}</span>
)}
{idx + 1 === arr.length ? "" : ", "}
</>
));
result.push(<> are typing...</>);
return <>{result}</>;
}
return <>Several people are typing...</>;
}, [authorId, currentlyTyping, getNickname]);
return ( return (
<> <main>
<main> <header>
<ol id="messages-container"> {authorId === "" ? (
{messageHistory.map((message, idx) => ( <></>
<li key={idx}> ) : (
<MessageComponent message={message} /> <>
</li> Your ID: <span style={{ fontWeight: "bold" }}>{authorId}</span>
))} </>
</ol> )}{" "}
<div id="message-writing-area"> <span>Ready state: </span>
<span>Ready state: </span> <span style={{ fontWeight: "bold" }}>
<span style={{ fontWeight: "bold" }}> {ReadyState[websocket.readyState]} ({websocket.readyState})
{ReadyState[websocket.readyState]} ({websocket.readyState}) </span>
</span> <label htmlFor="ws-url">WebSocket URL:</label>
<label htmlFor="ws-url">WebSocket URL:</label> <input
id="ws-url"
type="text"
value={socketUrl}
placeholder="wss://..."
onChange={(e) => trySetSocketUrl(e.target.value)}
/>
</header>
<ol ref={messageContainer} id="messages-container">
{messageHistory.map((message, idx) => (
<li className="message" key={idx}>
<MessageComponent
message={message}
authorNickname={getNickname(message.author)}
/>
</li>
))}
</ol>
<div id="message-writing-area">
<span id="typing-indicators">{typingIndicator}</span>
<span>
<input <input
id="ws-url"
type="text" type="text"
value={socketUrl} placeholder="Type here..."
onChange={(e) => trySetSocketUrl(e.target.value)} id="message-input"
onInput={handleInput}
ref={messageInput}
/> />
<button <button onClick={handleClickSendMessage}>Send</button>
disabled={websocket.readyState !== WebSocketReadyState.OPEN} </span>
onClick={handleClickSendMessage} </div>
> </main>
Click to send message.
</button>
</div>
</main>
</>
); );
} }

View file

@ -6,19 +6,15 @@
text-align: center; text-align: center;
} }
* {
font-family: unset;
}
p { p {
margin: 20px 0px; margin: 20px 0px;
} }
button { button {
outline: none;
border: none;
border-radius: 0;
padding: 10px 35px;
background: #845ec2;
color: white;
&:hover:not(:disabled) { &:hover:not(:disabled) {
filter: brightness(120%); filter: brightness(120%);
cursor: pointer; cursor: pointer;
@ -31,39 +27,43 @@ button {
body { body {
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
main { main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh;
height: calc(100vh - 20px);
width: calc(100vw - 20px);
justify-content: space-between; justify-content: space-between;
padding: 10px;
.nickname {
font-style: italic;
}
#messages-container { #messages-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 1;
overflow-y: scroll; overflow-y: scroll;
overflow-anchor: none; overflow-anchor: none;
list-style: none; list-style: none;
padding-inline-start: 0; padding-inline-start: 0;
max-width: 100%;
> :nth-child(even) { overflow-wrap: break-word;
background-color: lightgray;
}
> li {
width: 100%;
}
.message { .message {
display: block; display: block;
text-align: left; text-align: left;
width: 100%; width: calc(100% - 2 * 5px);
padding: 5px;
white-space: pre-line;
&:nth-child(even) {
background-color: #f2f2f2;
}
> h2 { > h2 {
width: 100%; width: 100%;
@ -72,10 +72,15 @@ main {
font-size: medium; font-size: medium;
justify-content: space-between; justify-content: space-between;
} }
.message-date {
}
} }
} }
#message-writing-area { #message-writing-area {
display: flex;
flex-direction: column;
justify-content: center;
#message-input {
width: 80%;
}
} }
} }