Add optimistic messages
parent
2073ef9dbb
commit
8ac70df325
|
@ -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": {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in New Issue