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 = {
authorId: string;
authorNickname: string;
};
const AuthorComponent: FunctionComponent<AuthorComponentProps> = ({
authorId,
authorNickname,
}) => {
return <span className="message-author">{authorId}</span>;
return (
<span
className={
"message-author" + (authorId !== authorNickname ? " nickname" : "")
}
>
{authorNickname}
</span>
);
};
export default AuthorComponent;

View file

@ -5,7 +5,9 @@ import AuthorComponent from "./Author";
type MessageComponentProps = {
message: TextMessage;
authorNickname: string;
};
enum MONTH_NAMES {
"January",
"February",
@ -59,8 +61,8 @@ function timeAgo(dateParam: Date | number) {
const isYesterday = yesterday.toDateString() === date.toDateString();
const isThisYear = today.getFullYear() === date.getFullYear();
if (seconds < 5) {
return "now";
if (seconds < 2) {
return "just now";
} else if (seconds < 60) {
return `${seconds} seconds ago`;
} else if (seconds < 90) {
@ -80,6 +82,7 @@ function timeAgo(dateParam: Date | number) {
const MessageComponent: FunctionComponent<MessageComponentProps> = ({
message,
authorNickname,
}) => {
const date = new Date(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);
}, [message.date, timeAgoString]);
return (
<div className="message">
<>
<h2>
<AuthorComponent authorId={message.author} />
<AuthorComponent
authorNickname={authorNickname}
authorId={message.author}
/>
<sub>
<time className="message-date" dateTime={date.toISOString()}>
{timeAgoString}
@ -107,7 +113,7 @@ const MessageComponent: FunctionComponent<MessageComponentProps> = ({
</sub>
</h2>
<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, {
ReadyState,
ReadyState as WebSocketReadyState,
} from "react-use-websocket";
import MessageComponent from "src/components/Message";
import type { TextMessage } from "src/lib/ServerMessage";
import {
isCurrentlyTypingMessage,
TextMessage,
TypingMessage,
} from "src/lib/ServerMessage";
import {
isIdResponseMessage,
isServerMessage,
@ -12,17 +16,28 @@ import {
MessageType,
} from "src/lib/ServerMessage";
export default function Index(): JSX.Element {
const [count, setCount] = useState(0);
const [messageHistory, setMessageHistory] = useState<TextMessage[]>([]);
const [socketUrl, setSocketUrl] = useState("wss.tobot.tk:8085/");
const [authorId, setAuthorId] = useState("<???>");
let keepAliveIntervals: NodeJS.Timeout[] = [];
let shouldResendTyping = true;
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 {
const message = JSON.parse(event.data);
if (!isServerMessage(message)) {
console.log("DEBUG: ", message);
console.debug("DEBUG: ", message);
throw new Error(`Server sent unexpected message \`${event.data}}\``);
}
@ -30,6 +45,9 @@ export default function Index(): JSX.Element {
setAuthorId(message.authorId);
} else if (isTextMessage(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(),
});
}, 1000);
keepAliveIntervals.push(keepAliveInterval);
},
onClose() {
clearInterval(keepAliveInterval);
keepAliveIntervals = keepAliveIntervals.filter(
(interval) => interval !== keepAliveInterval
);
},
});
const handleClickSendMessage = useCallback(() => {
setCount(count + 1);
const message: TextMessage = {
if (!messageInput.current) {
return;
}
const messageText = messageInput.current?.value;
if (messageText === undefined || messageText.length === 0) {
return;
}
messageInput.current.value = "";
websocket.sendJsonMessage({
author: authorId,
type: MessageType.TEXT,
date: Date.now(),
content: `Hello, world! ${count}`,
};
websocket.sendJsonMessage(message);
}, [count, websocket, authorId]);
content: messageText,
} as TextMessage);
}, [authorId, websocket]);
const trySetSocketUrl = useCallback(
(url: string) => {
@ -77,36 +107,108 @@ export default function Index(): JSX.Element {
[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 (
<>
<main>
<ol id="messages-container">
{messageHistory.map((message, idx) => (
<li key={idx}>
<MessageComponent message={message} />
</li>
))}
</ol>
<div id="message-writing-area">
<span>Ready state: </span>
<span style={{ fontWeight: "bold" }}>
{ReadyState[websocket.readyState]} ({websocket.readyState})
</span>
<label htmlFor="ws-url">WebSocket URL:</label>
<main>
<header>
{authorId === "" ? (
<></>
) : (
<>
Your ID: <span style={{ fontWeight: "bold" }}>{authorId}</span>
</>
)}{" "}
<span>Ready state: </span>
<span style={{ fontWeight: "bold" }}>
{ReadyState[websocket.readyState]} ({websocket.readyState})
</span>
<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
id="ws-url"
type="text"
value={socketUrl}
onChange={(e) => trySetSocketUrl(e.target.value)}
placeholder="Type here..."
id="message-input"
onInput={handleInput}
ref={messageInput}
/>
<button
disabled={websocket.readyState !== WebSocketReadyState.OPEN}
onClick={handleClickSendMessage}
>
Click to send message.
</button>
</div>
</main>
</>
<button onClick={handleClickSendMessage}>Send</button>
</span>
</div>
</main>
);
}

View file

@ -6,19 +6,15 @@
text-align: center;
}
* {
font-family: unset;
}
p {
margin: 20px 0px;
}
button {
outline: none;
border: none;
border-radius: 0;
padding: 10px 35px;
background: #845ec2;
color: white;
&:hover:not(:disabled) {
filter: brightness(120%);
cursor: pointer;
@ -31,39 +27,43 @@ button {
body {
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 {
display: flex;
flex-direction: column;
height: 100vh;
height: calc(100vh - 20px);
width: calc(100vw - 20px);
justify-content: space-between;
padding: 10px;
.nickname {
font-style: italic;
}
#messages-container {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow-y: scroll;
overflow-anchor: none;
list-style: none;
padding-inline-start: 0;
> :nth-child(even) {
background-color: lightgray;
}
> li {
width: 100%;
}
max-width: 100%;
overflow-wrap: break-word;
.message {
display: block;
text-align: left;
width: 100%;
width: calc(100% - 2 * 5px);
padding: 5px;
white-space: pre-line;
&:nth-child(even) {
background-color: #f2f2f2;
}
> h2 {
width: 100%;
@ -72,10 +72,15 @@ main {
font-size: medium;
justify-content: space-between;
}
.message-date {
}
}
}
#message-writing-area {
display: flex;
flex-direction: column;
justify-content: center;
#message-input {
width: 80%;
}
}
}