Add optimistic messages

remotes/1693176582716663532/tmp_refs/heads/watchparty
Outrun Colors 2022-09-27 19:06:44 -05:00
parent 2073ef9dbb
commit 8ac70df325
6 changed files with 128 additions and 35 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "chat", "name": "chat",
"version": "0.1.26", "version": "0.1.27",
"main": "./src/index.tsx", "main": "./src/index.tsx",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@ -9,6 +9,10 @@
border-bottom: 1px dashed var(--primary); border-bottom: 1px dashed var(--primary);
} }
.ChatMessage__isOptimistic {
opacity: 0.5;
}
.ChatMessage p { .ChatMessage p {
margin: 0; margin: 0;
} }

View File

@ -11,7 +11,12 @@ import key from "weak-key";
import humanizeDuration from "humanize-duration"; import humanizeDuration from "humanize-duration";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import { Username } from "./Username"; import { Username } from "./Username";
import { useChat, useRootContext } from "../../hooks"; import {
DIRECT_MESSAGE_ID,
OPTIMISTIC_MESSAGE_ID,
useChat,
useRootContext,
} from "../../hooks";
import { QuotedMessageLink } from "./QuotedMessageLink"; import { QuotedMessageLink } from "./QuotedMessageLink";
import "./ChatMessage.css"; import "./ChatMessage.css";
@ -34,6 +39,7 @@ export function ChatMessage({
}: ChatMessageProps) { }: ChatMessageProps) {
const { const {
id, id,
user_id,
avatar, avatar,
namecolor, namecolor,
username, username,
@ -68,6 +74,8 @@ export function ChatMessage({
(text_html.includes(`/id/${userId}`) && (text_html.includes(`/id/${userId}`) &&
userUsername && userUsername &&
username !== userUsername); username !== userUsername);
const isDirect = id === DIRECT_MESSAGE_ID;
const isOptimistic = id === OPTIMISTIC_MESSAGE_ID;
const timestamp = useMemo( const timestamp = useMemo(
() => formatTimeAgo(time), () => formatTimeAgo(time),
[time, timestampUpdates] [time, timestampUpdates]
@ -116,6 +124,7 @@ export function ChatMessage({
<div <div
className={cx("ChatMessage", { className={cx("ChatMessage", {
ChatMessage__isDm: dm, ChatMessage__isDm: dm,
ChatMessage__isOptimistic: isOptimistic,
})} })}
id={id} id={id}
style={ style={
@ -127,19 +136,8 @@ export function ChatMessage({
: {} : {}
} }
> >
{!actionsOpen && ( {!isDirect && !isOptimistic && !actionsOpen && (
<div className="ChatMessage-actions-button"> <div className="ChatMessage-actions-button">
<button
className="btn btn-secondary"
style={{
position: "relative",
top: 2,
left: -12,
}}
onClick={() => handleDirectMessage()}
>
📨
</button>
<button <button
className="btn btn-secondary" className="btn btn-secondary"
onClick={() => { onClick={() => {
@ -157,14 +155,16 @@ export function ChatMessage({
</button> </button>
</div> </div>
)} )}
{actionsOpen && ( {!isDirect && !isOptimistic && actionsOpen && (
<div className="ChatMessage-actions"> <div className="ChatMessage-actions">
<button {userId && parseInt(userId) !== parseInt(user_id) && (
className="btn btn-secondary ChatMessage-button" <button
onClick={() => handleDirectMessage(true)} className="btn btn-secondary ChatMessage-button"
> onClick={() => handleDirectMessage(true)}
📨 DM @{message.username} >
</button> 📨 DM @{message.username}
</button>
)}
<button <button
className="btn btn-secondary ChatMessage-button" className="btn btn-secondary ChatMessage-button"
onClick={handleQuoteMessageAction} onClick={handleQuoteMessageAction}
@ -206,7 +206,7 @@ export function ChatMessage({
<QuotedMessageLink message={quotedMessage} /> <QuotedMessageLink message={quotedMessage} />
</div> </div>
)} )}
{dm && ( {!isDirect && dm && (
<small className="ChatMessage-quoted-link text-primary"> <small className="ChatMessage-quoted-link text-primary">
<em>(Sent only to you)</em> <em>(Sent only to you)</em>
</small> </small>

View File

@ -58,9 +58,11 @@ const ChatContext = createContext<ChatProviderContext>({
}); });
const MINIMUM_TYPING_UPDATE_INTERVAL = 250; const MINIMUM_TYPING_UPDATE_INTERVAL = 250;
export const DIRECT_MESSAGE_ID = "DIRECT_MESSAGE";
export const OPTIMISTIC_MESSAGE_ID = "OPTIMISTIC";
export function ChatProvider({ children }: PropsWithChildren) { export function ChatProvider({ children }: PropsWithChildren) {
const { username, siteName } = useRootContext(); const { username, id, siteName, hat, avatar, nameColor } = useRootContext();
const socket = useRef<null | Socket>(null); const socket = useRef<null | Socket>(null);
const [online, setOnline] = useState<string[]>([]); const [online, setOnline] = useState<string[]>([]);
const [typing, setTyping] = useState<string[]>([]); const [typing, setTyping] = useState<string[]>([]);
@ -73,13 +75,69 @@ export function ChatProvider({ children }: PropsWithChildren) {
const [notifications, setNotifications] = useState<number>(0); const [notifications, setNotifications] = useState<number>(0);
const [messageLookup, setMessageLookup] = useState({}); const [messageLookup, setMessageLookup] = useState({});
const addMessage = useCallback((message: IChatMessage) => { const addMessage = useCallback((message: IChatMessage) => {
setMessages((prev) => [...prev.slice(-99), message]); if (message.id === OPTIMISTIC_MESSAGE_ID) {
setMessages((prev) => prev.concat(message));
} else {
// Are there any optimistic messages that have the same text?
setMessages((prev) => {
const matchingOptimisticMessage = prev.findIndex(
(prevMessage) =>
prevMessage.id === OPTIMISTIC_MESSAGE_ID &&
prevMessage.text.trim() === message.text.trim()
);
if (matchingOptimisticMessage === -1) {
return prev.slice(-99).concat(message);
} else {
const before = prev.slice(0, matchingOptimisticMessage);
const after = prev.slice(matchingOptimisticMessage + 1);
return [...before, message, ...after];
}
});
}
if (message.username !== username && !document.hasFocus()) { if (message.username !== username && !document.hasFocus()) {
setNotifications((prev) => prev + 1); setNotifications((prev) => prev + 1);
} }
}, []); }, []);
const sendMessage = useCallback(() => { const sendMessage = useCallback(() => {
if (userToDm) {
const directMessage = `<small class="text-primary"><em>(Sent to @${userToDm.username}):</em></small> ${draft}`;
addMessage({
id: DIRECT_MESSAGE_ID,
username,
user_id: id,
avatar,
hat,
namecolor: nameColor,
text: directMessage,
base_text_censored: directMessage,
text_censored: directMessage,
text_html: directMessage,
time: new Date().getTime() / 1000,
quotes: null,
dm: true,
});
} else {
addMessage({
id: OPTIMISTIC_MESSAGE_ID,
username,
user_id: id,
avatar,
hat,
namecolor: nameColor,
text: draft,
base_text_censored: draft,
text_censored: draft,
text_html: draft,
time: new Date().getTime() / 1000,
quotes: null,
dm: false,
});
}
socket.current?.emit(ChatHandlers.SPEAK, { socket.current?.emit(ChatHandlers.SPEAK, {
message: draft, message: draft,
quotes: quote?.id ?? null, quotes: quote?.id ?? null,

View File

@ -1,15 +1,30 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export function useRootContext() { export function useRootContext() {
const [{ admin, id, username, censored, themeColor, siteName }, setContext] = const [
useState({ {
id: "", admin,
username: "", id,
admin: false, username,
censored: true, censored,
themeColor: "#ff66ac", themeColor,
siteName: "", siteName,
}); nameColor,
avatar,
hat,
},
setContext,
] = useState({
id: "",
username: "",
admin: false,
censored: true,
themeColor: "#ff66ac",
siteName: "",
nameColor: "",
avatar: "",
hat: "",
});
useEffect(() => { useEffect(() => {
const root = document.getElementById("root"); const root = document.getElementById("root");
@ -21,8 +36,21 @@ export function useRootContext() {
censored: root.dataset.censored === "True", censored: root.dataset.censored === "True",
themeColor: root.dataset.themecolor, themeColor: root.dataset.themecolor,
siteName: root.dataset.sitename, siteName: root.dataset.sitename,
nameColor: root.dataset.namecolor,
avatar: root.dataset.avatar,
hat: root.dataset.hat,
}); });
}, []); }, []);
return { id, admin, username, censored, themeColor, siteName }; return {
id,
admin,
username,
censored,
themeColor,
siteName,
nameColor,
avatar,
hat,
};
} }

View File

@ -35,7 +35,10 @@
data-admin="{{v.admin_level > 1}}" data-admin="{{v.admin_level > 1}}"
data-censored="{{v.slurreplacer}}" data-censored="{{v.slurreplacer}}"
data-sitename="{{SITE_NAME}}" data-sitename="{{SITE_NAME}}"
data-themecolor="{{v.themecolor}}"> data-themecolor="{{v.themecolor}}"
data-namecolor="{{v.namecolor}}"
data-avatar="{{v.profile_url}}"
data-hat="{{v.hat_active}}">
</div> </div>
<script>window.global = window</script> <script>window.global = window</script>
<script defer src="{{'js/chat_done.js' | asset}}"></script> <script defer src="{{'js/chat_done.js' | asset}}"></script>