Typing indicator
Also better nicknames and styling
This commit is contained in:
parent
ec5c4f24db
commit
2080d58c06
5 changed files with 197 additions and 74 deletions
|
@ -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;
|
||||||
|
|
|
@ -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>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
2
src/lib
2
src/lib
|
@ -1 +1 @@
|
||||||
Subproject commit 4c698768def503209283af5d7a04c0d21c49092f
|
Subproject commit cde3d62b1c0a1c95643037ca03e95bac178118d2
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Reference in a new issue