v3: the chat stops dropping things and holds up to long messages — a message sent while the socket was down vanished with the draft already cleared, and a rejected one fared no better since the client ignored any error that wasn't about a vote or a like. Sends now carry a nonce and sit dimmed in the list until their echo retires them; the server's refusal, an offline socket or ten seconds of silence turns one into a failed row with retry, edit and discard, and a failed row that turns up in the history after a reconnect is retired as having landed after all. Failed edits and deletes show in a bar above the composer. The live stream only goes to signed-in sockets, matching the history behind auth; sends, edits and deletes share a per-user token bucket (typing and read markers get their own, and are just dropped past it); and moderation re-reads the role from the database on every check rather than trusting the one a week-old socket captured. Unread now lives on the server as users.chat_read_at (migration 0014, existing users start caught up), so the count survives a reload and every tab and device clears together; opening onto unread history lands on a "new" divider rather than the bottom, and marks it read only once you've scrolled down to it. After a reconnect the chat re-reads everything from its oldest loaded message and takes that as the truth, edits and deletions included, or reloads the latest page if too much happened. History pages on (created_at, id) so a shared millisecond can't hide a message at a boundary. In the modal: a run of messages from one author shares a header with the time in the gutter on hover, mentions and replies to you get an accent edge, a pill counts what arrived while you were scrolled up, the draft survives closing and reloading, relative times tick, reply previews are flattened from markdown, owners can delete their own messages, ↑ in an empty composer edits your last one, and there's a proper loading and retry state instead of a flash of "No messages yet". Long messages: the composer and edit box grow only so far and then scroll, instead of squeezing the list away; the counter shows only near the limit and sits above the box, since .comment-reply-textarea's padding shorthand had quietly cancelled the room kept for it inside; TextEditor sizes itself before paint, so opening an edit animates to the right height instead of dipping and snapping; and the list follows the bottom through any change in its content's height while pinned there, so ending an edit returns you to the end. A mention notification opens the chat over the page you're on rather than the home page.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,30 @@ export interface UserEvent {
|
||||
user: PublicUser;
|
||||
}
|
||||
|
||||
/** The replied-to message, as shown in the reply bar and on a pending send. */
|
||||
export interface ChatReplyTarget {
|
||||
id: string;
|
||||
author: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** A message this client sent that the server hasn't echoed back yet — shown
|
||||
* in the list as "sending", or "failed" with a way to retry or discard it. */
|
||||
export interface PendingChatMessage {
|
||||
nonce: string;
|
||||
body: string;
|
||||
replyTo?: ChatReplyTarget;
|
||||
createdAt: Date;
|
||||
status: "sending" | "failed";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** A failed chat edit or delete, surfaced in the chat until dismissed. */
|
||||
export interface ChatError {
|
||||
id: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface WSContextValue {
|
||||
wsStatus: "connecting" | "connected" | "disconnected";
|
||||
wsErrorMessage: string | null;
|
||||
@@ -64,7 +88,14 @@ export interface WSContextValue {
|
||||
/** Ids of chat messages deleted live this session, so fetched history can
|
||||
* filter them out. */
|
||||
deletedChatIds: Set<string>;
|
||||
/** Messages from others since the read marker — kept by the server, so it
|
||||
* survives reloads and agrees across tabs and devices. */
|
||||
unreadChatCount: number;
|
||||
/** The server's read marker: created_at of the newest message the user has
|
||||
* seen. Null until the socket's welcome. */
|
||||
chatReadAt: Date | null;
|
||||
pendingChatMessages: PendingChatMessage[];
|
||||
chatError: ChatError | null;
|
||||
/** Users currently composing a chat message (excludes nobody — the chatbox
|
||||
* filters out the current user itself). Shaped like presence for avatars. */
|
||||
typingUsers: OnlineUser[];
|
||||
@@ -76,10 +107,18 @@ export interface WSContextValue {
|
||||
removeCommentLike: (commentId: string) => void;
|
||||
injectDump: (dump: Dump) => void;
|
||||
clearUnreadNotifications: () => void;
|
||||
sendChatMessage: (body: string, replyToId?: string) => void;
|
||||
sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void;
|
||||
retryChatMessage: (nonce: string) => void;
|
||||
discardChatMessage: (nonce: string) => void;
|
||||
editChatMessage: (id: string, body: string) => void;
|
||||
deleteChatMessage: (id: string) => void;
|
||||
clearUnreadChat: () => void;
|
||||
dismissChatError: () => void;
|
||||
/** Moves the read marker up to the message created at `at`. */
|
||||
markChatRead: (at: Date) => void;
|
||||
/** Folds an authoritative fetch of the stream between `from` and `to` into
|
||||
* the live buffer after a reconnect: buffered messages in that range which
|
||||
* the server no longer has were deleted while the socket was down. */
|
||||
reconcileChat: (messages: ChatMessage[], from: Date, to: Date) => void;
|
||||
/** Inform the server whether the user is currently composing a message, so it
|
||||
* can show/hide the "is typing" indicator to other clients. */
|
||||
setChatTyping: (typing: boolean) => void;
|
||||
@@ -110,6 +149,9 @@ export const WSContext = createContext<WSContextValue>({
|
||||
chatMessages: [],
|
||||
deletedChatIds: new Set(),
|
||||
unreadChatCount: 0,
|
||||
chatReadAt: null,
|
||||
pendingChatMessages: [],
|
||||
chatError: null,
|
||||
typingUsers: [],
|
||||
connectionEpoch: 0,
|
||||
castVote: () => {},
|
||||
@@ -119,9 +161,13 @@ export const WSContext = createContext<WSContextValue>({
|
||||
injectDump: () => {},
|
||||
clearUnreadNotifications: () => {},
|
||||
sendChatMessage: () => {},
|
||||
retryChatMessage: () => {},
|
||||
discardChatMessage: () => {},
|
||||
editChatMessage: () => {},
|
||||
deleteChatMessage: () => {},
|
||||
clearUnreadChat: () => {},
|
||||
dismissChatError: () => {},
|
||||
markChatRead: () => {},
|
||||
reconcileChat: () => {},
|
||||
setChatTyping: () => {},
|
||||
setChatOpen: () => {},
|
||||
});
|
||||
|
||||
@@ -8,8 +8,11 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
type ChatError,
|
||||
type ChatReplyTarget,
|
||||
type CommentEvent,
|
||||
type LikeEvent,
|
||||
type PendingChatMessage,
|
||||
type PlaylistEvent,
|
||||
type UserEvent,
|
||||
type VoteEvent,
|
||||
@@ -41,6 +44,7 @@ import { t } from "@lingui/core/macro";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { setTitleBadge } from "../hooks/useDocumentTitle.ts";
|
||||
import { playChime } from "../utils/sound.ts";
|
||||
import { mentionsUser } from "../utils/chatText.ts";
|
||||
|
||||
interface WSProviderProps {
|
||||
children: ReactNode;
|
||||
@@ -56,17 +60,13 @@ const MAX_RECENT_DUMPS = 100;
|
||||
// Cap the live chat buffer so a long-lived session can't grow it without bound.
|
||||
// Older messages remain reachable via the /api/chat history endpoint.
|
||||
const MAX_CHAT_MESSAGES = 200;
|
||||
|
||||
// Mirrors MENTION_RE in api/services/notification-service.ts.
|
||||
const MENTION_RE = /(?<![[(\w])@(\w+)/g;
|
||||
|
||||
function mentionsUser(body: string, username: string): boolean {
|
||||
const target = username.toLowerCase();
|
||||
for (const m of body.matchAll(MENTION_RE)) {
|
||||
if (m[1].toLowerCase() === target) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// How long a sent chat message may go without its echo before it's shown as
|
||||
// failed, and how often the read marker may go out.
|
||||
const CHAT_SEND_TIMEOUT = 10_000;
|
||||
const CHAT_READ_THROTTLE = 1_000;
|
||||
// A failed send whose text turns up in the resynced history within this long
|
||||
// of being sent did reach the server — only its echo was lost.
|
||||
const CHAT_ECHO_WINDOW = 60_000;
|
||||
|
||||
interface PendingVote {
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
@@ -132,6 +132,11 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
||||
const [deletedChatIds, setDeletedChatIds] = useState<Set<string>>(new Set());
|
||||
const [unreadChatCount, setUnreadChatCount] = useState(0);
|
||||
const [chatReadAt, setChatReadAt] = useState<Date | null>(null);
|
||||
const [pendingChatMessages, setPendingChatMessages] = useState<
|
||||
PendingChatMessage[]
|
||||
>([]);
|
||||
const [chatError, setChatError] = useState<ChatError | null>(null);
|
||||
const [typingUsers, setTypingUsers] = useState<OnlineUser[]>([]);
|
||||
// Live avatar overrides for site-wide Avatar refresh (own narrow context).
|
||||
const [avatarOverrides, setAvatarOverrides] = useState<
|
||||
@@ -170,6 +175,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
setChatMessages([]);
|
||||
setDeletedChatIds(new Set());
|
||||
setUnreadChatCount(0);
|
||||
setChatReadAt(null);
|
||||
setPendingChatMessages([]);
|
||||
setChatError(null);
|
||||
setTypingUsers([]);
|
||||
setAvatarOverrides({});
|
||||
}
|
||||
@@ -205,6 +213,34 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
() => chatOpenRef.current && !document.hidden,
|
||||
[],
|
||||
);
|
||||
// Echo timeouts of in-flight chat sends, by nonce.
|
||||
const chatSendTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||
new Map(),
|
||||
);
|
||||
const chatErrorSeqRef = useRef(0);
|
||||
// Read marker bookkeeping: the newest marker sent (or learned from the
|
||||
// server), and a trailing timer so a burst of arrivals sends one marker.
|
||||
const chatReadSentRef = useRef(0);
|
||||
const chatReadQueuedRef = useRef(0);
|
||||
const chatReadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const chatReadLastSendRef = useRef(0);
|
||||
|
||||
const failPendingChat = useCallback((nonce: string, error: string) => {
|
||||
const timer = chatSendTimersRef.current.get(nonce);
|
||||
if (timer) clearTimeout(timer);
|
||||
chatSendTimersRef.current.delete(nonce);
|
||||
setPendingChatMessages((prev) =>
|
||||
prev.map((p) => p.nonce === nonce ? { ...p, status: "failed", error } : p)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const retirePendingChat = useCallback((nonce: string) => {
|
||||
const timer = chatSendTimersRef.current.get(nonce);
|
||||
if (timer) clearTimeout(timer);
|
||||
chatSendTimersRef.current.delete(nonce);
|
||||
setPendingChatMessages((prev) => prev.filter((p) => p.nonce !== nonce));
|
||||
}, []);
|
||||
|
||||
// Tracks pending optimistic votes: dumpId → pending rollback handler
|
||||
const pendingRef = useRef<Map<string, PendingVote>>(
|
||||
new Map(),
|
||||
@@ -321,6 +357,15 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
setMyVotes(new Set(msg.myVotes));
|
||||
setMyLikes(new Set(msg.myCommentLikes));
|
||||
setUnreadNotificationCount(msg.unreadNotificationCount);
|
||||
setUnreadChatCount(msg.unreadChatCount);
|
||||
if (msg.chatReadAt) {
|
||||
const readAt = new Date(msg.chatReadAt);
|
||||
setChatReadAt(readAt);
|
||||
chatReadSentRef.current = Math.max(
|
||||
chatReadSentRef.current,
|
||||
readAt.getTime(),
|
||||
);
|
||||
}
|
||||
// welcome provides authoritative server state — cancel any
|
||||
// in-flight revert timers, they are now superseded.
|
||||
clearAllPendingVotes();
|
||||
@@ -328,13 +373,37 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
// Reconnect (not the first welcome): signal pages to backfill the
|
||||
// dumps/comments they couldn't receive while disconnected.
|
||||
welcomeCount += 1;
|
||||
if (welcomeCount > 1) setConnectionEpoch((n) => n + 1);
|
||||
if (welcomeCount > 1) {
|
||||
setConnectionEpoch((n) => n + 1);
|
||||
// With the chatbox closed nothing will resync the live buffer,
|
||||
// and whatever was edited or deleted meanwhile would win over
|
||||
// the fresh history it fetches on open — so just drop it.
|
||||
if (!chatOpenRef.current) {
|
||||
setChatMessages([]);
|
||||
setDeletedChatIds(new Set());
|
||||
}
|
||||
}
|
||||
// A marker that was waiting on the socket goes out now.
|
||||
if (chatReadQueuedRef.current > chatReadSentRef.current) {
|
||||
ws.send(
|
||||
JSON.stringify(
|
||||
{
|
||||
type: "chat_read",
|
||||
at: new Date(chatReadQueuedRef.current).toISOString(),
|
||||
} satisfies OutgoingWSMessage,
|
||||
),
|
||||
);
|
||||
chatReadSentRef.current = chatReadQueuedRef.current;
|
||||
}
|
||||
// Restore the chat-open flag on the fresh connection so mention
|
||||
// suppression keeps working across reconnects.
|
||||
if (isViewingChat()) {
|
||||
ws.send(
|
||||
JSON.stringify(
|
||||
{ type: "chat_focus", open: true } satisfies OutgoingWSMessage,
|
||||
{
|
||||
type: "chat_focus",
|
||||
open: true,
|
||||
} satisfies OutgoingWSMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -528,9 +597,11 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
if (prev.some((m) => m.id === message.id)) return prev;
|
||||
return [...prev, message].slice(-MAX_CHAT_MESSAGES);
|
||||
});
|
||||
// Count as unread unless it's the current user's own message.
|
||||
if (msg.nonce) retirePendingChat(msg.nonce);
|
||||
// Count as unread unless it's the current user's own message, or
|
||||
// they're looking at it (the chatbox then moves the read marker).
|
||||
if (message.userId !== userIdRef.current) {
|
||||
setUnreadChatCount((prev) => prev + 1);
|
||||
if (!isViewingChat()) setUnreadChatCount((prev) => prev + 1);
|
||||
const username = usernameRef.current;
|
||||
if (username && mentionsUser(message.body, username)) {
|
||||
// Someone reading the chat gets no notification for a mention
|
||||
@@ -567,11 +638,28 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
setTypingUsers(msg.users);
|
||||
break;
|
||||
|
||||
case "chat_read_update": {
|
||||
const readAt = new Date(msg.readAt);
|
||||
chatReadSentRef.current = Math.max(
|
||||
chatReadSentRef.current,
|
||||
readAt.getTime(),
|
||||
);
|
||||
setChatReadAt(readAt);
|
||||
setUnreadChatCount(msg.unread);
|
||||
break;
|
||||
}
|
||||
|
||||
case "force_logout":
|
||||
onForceLogoutRef.current();
|
||||
break;
|
||||
|
||||
case "error": {
|
||||
if (msg.scope === "chat") {
|
||||
const message = msg.message ?? t`Something went wrong`;
|
||||
if (msg.nonce) failPendingChat(msg.nonce, message);
|
||||
else setChatError({ id: ++chatErrorSeqRef.current, message });
|
||||
break;
|
||||
}
|
||||
// When the server identifies the failed optimistic action, revert
|
||||
// its pending vote/like right away instead of waiting out the ACK
|
||||
// timeout. Without context, the per-action timer handles cleanup.
|
||||
@@ -601,6 +689,15 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
connectTimeout = null;
|
||||
}
|
||||
if (closed) return;
|
||||
// Sends still waiting on their echo won't get one on this socket.
|
||||
// They may have landed, though: the resync after reconnect retires
|
||||
// any whose text shows up in the history.
|
||||
for (const nonce of chatSendTimersRef.current.keys()) {
|
||||
failPendingChat(
|
||||
nonce,
|
||||
t`Connection lost — this message may not have been sent`,
|
||||
);
|
||||
}
|
||||
setWSStatus("disconnected");
|
||||
setWSErrorMessage(
|
||||
everConnected
|
||||
@@ -642,7 +739,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
clearPendingVote,
|
||||
clearAllPendingLikes,
|
||||
clearPendingLike,
|
||||
failPendingChat,
|
||||
isViewingChat,
|
||||
retirePendingChat,
|
||||
token,
|
||||
]);
|
||||
|
||||
@@ -798,21 +897,80 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
setUnreadNotificationCount(0);
|
||||
}, []);
|
||||
|
||||
const sendChatMessage = useCallback((body: string, replyToId?: string) => {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return;
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
JSON.stringify(
|
||||
{
|
||||
type: "chat_send",
|
||||
body: trimmed,
|
||||
...(replyToId ? { replyToId } : {}),
|
||||
} satisfies OutgoingWSMessage,
|
||||
),
|
||||
);
|
||||
// Sends (or resends) a pending message: it shows as "sending" until its echo
|
||||
// arrives, or as failed if the socket is down, the server rejects it, or no
|
||||
// echo comes back in time.
|
||||
const transmitPendingChat = useCallback((pending: PendingChatMessage) => {
|
||||
const socket = socketRef.current;
|
||||
if (socket?.readyState !== WebSocket.OPEN) {
|
||||
failPendingChat(pending.nonce, t`You're offline — not sent`);
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
socket.send(
|
||||
JSON.stringify(
|
||||
{
|
||||
type: "chat_send",
|
||||
body: pending.body,
|
||||
nonce: pending.nonce,
|
||||
...(pending.replyTo ? { replyToId: pending.replyTo.id } : {}),
|
||||
} satisfies OutgoingWSMessage,
|
||||
),
|
||||
);
|
||||
chatSendTimersRef.current.set(
|
||||
pending.nonce,
|
||||
setTimeout(
|
||||
() =>
|
||||
failPendingChat(
|
||||
pending.nonce,
|
||||
t`No reply from the server — this message may not have been sent`,
|
||||
),
|
||||
CHAT_SEND_TIMEOUT,
|
||||
),
|
||||
);
|
||||
}, [failPendingChat]);
|
||||
|
||||
const sendChatMessage = useCallback(
|
||||
(body: string, replyTo?: ChatReplyTarget) => {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return;
|
||||
const pending: PendingChatMessage = {
|
||||
nonce: crypto.randomUUID(),
|
||||
body: trimmed,
|
||||
replyTo,
|
||||
createdAt: new Date(),
|
||||
status: "sending",
|
||||
};
|
||||
setPendingChatMessages((prev) => [...prev, pending]);
|
||||
transmitPendingChat(pending);
|
||||
},
|
||||
[transmitPendingChat],
|
||||
);
|
||||
|
||||
const pendingChatRef = useRef(pendingChatMessages);
|
||||
useLayoutEffect(() => {
|
||||
pendingChatRef.current = pendingChatMessages;
|
||||
});
|
||||
|
||||
const retryChatMessage = useCallback((nonce: string) => {
|
||||
const pending = pendingChatRef.current.find((p) => p.nonce === nonce);
|
||||
if (!pending) return;
|
||||
// A fresh nonce: should the first attempt's echo still turn up, it mustn't
|
||||
// retire the retry.
|
||||
const retry: PendingChatMessage = {
|
||||
...pending,
|
||||
nonce: crypto.randomUUID(),
|
||||
status: "sending",
|
||||
error: undefined,
|
||||
};
|
||||
setPendingChatMessages((prev) =>
|
||||
prev.map((p) => (p.nonce === nonce ? retry : p))
|
||||
);
|
||||
transmitPendingChat(retry);
|
||||
}, [transmitPendingChat]);
|
||||
|
||||
const discardChatMessage = useCallback((nonce: string) => {
|
||||
retirePendingChat(nonce);
|
||||
}, [retirePendingChat]);
|
||||
|
||||
const editChatMessage = useCallback((id: string, body: string) => {
|
||||
const trimmed = body.trim();
|
||||
@@ -836,10 +994,71 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearUnreadChat = useCallback(() => {
|
||||
setUnreadChatCount(0);
|
||||
const dismissChatError = useCallback(() => setChatError(null), []);
|
||||
|
||||
const flushChatRead = useCallback(() => {
|
||||
chatReadTimerRef.current = null;
|
||||
const at = chatReadQueuedRef.current;
|
||||
if (at <= chatReadSentRef.current) return;
|
||||
const socket = socketRef.current;
|
||||
// Offline: stays queued, and the next welcome sends it.
|
||||
if (socket?.readyState !== WebSocket.OPEN) return;
|
||||
socket.send(
|
||||
JSON.stringify(
|
||||
{
|
||||
type: "chat_read",
|
||||
at: new Date(at).toISOString(),
|
||||
} satisfies OutgoingWSMessage,
|
||||
),
|
||||
);
|
||||
chatReadSentRef.current = at;
|
||||
chatReadLastSendRef.current = Date.now();
|
||||
}, []);
|
||||
|
||||
const markChatRead = useCallback((at: Date) => {
|
||||
const time = at.getTime();
|
||||
if (time <= Math.max(chatReadSentRef.current, chatReadQueuedRef.current)) {
|
||||
return;
|
||||
}
|
||||
chatReadQueuedRef.current = time;
|
||||
// The badge goes at once; the server's count follows with the marker.
|
||||
setUnreadChatCount(0);
|
||||
if (chatReadTimerRef.current) return;
|
||||
const wait = Math.max(
|
||||
0,
|
||||
CHAT_READ_THROTTLE - (Date.now() - chatReadLastSendRef.current),
|
||||
);
|
||||
chatReadTimerRef.current = setTimeout(flushChatRead, wait);
|
||||
}, [flushChatRead]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (chatReadTimerRef.current) clearTimeout(chatReadTimerRef.current);
|
||||
for (const timer of chatSendTimersRef.current.values()) clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const reconcileChat = useCallback(
|
||||
(messages: ChatMessage[], from: Date, to: Date) => {
|
||||
const ids = new Set(messages.map((m) => m.id));
|
||||
setChatMessages((prev) =>
|
||||
prev.filter((m) =>
|
||||
ids.has(m.id) || m.createdAt < from || m.createdAt > to
|
||||
)
|
||||
);
|
||||
// Failed sends that did land (only the echo was lost) are retired.
|
||||
const mine = messages.filter((m) => m.userId === userIdRef.current);
|
||||
for (const pending of pendingChatRef.current) {
|
||||
if (pending.status !== "failed") continue;
|
||||
const landed = mine.some((m) =>
|
||||
m.body === pending.body &&
|
||||
Math.abs(m.createdAt.getTime() - pending.createdAt.getTime()) <
|
||||
CHAT_ECHO_WINDOW
|
||||
);
|
||||
if (landed) retirePendingChat(pending.nonce);
|
||||
}
|
||||
},
|
||||
[retirePendingChat],
|
||||
);
|
||||
|
||||
const setChatTyping = useCallback((typing: boolean) => {
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
@@ -906,6 +1125,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
chatMessages,
|
||||
deletedChatIds,
|
||||
unreadChatCount,
|
||||
chatReadAt,
|
||||
pendingChatMessages,
|
||||
chatError,
|
||||
typingUsers,
|
||||
connectionEpoch,
|
||||
castVote,
|
||||
@@ -915,9 +1137,13 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
injectDump,
|
||||
clearUnreadNotifications,
|
||||
sendChatMessage,
|
||||
retryChatMessage,
|
||||
discardChatMessage,
|
||||
editChatMessage,
|
||||
deleteChatMessage,
|
||||
clearUnreadChat,
|
||||
dismissChatError,
|
||||
markChatRead,
|
||||
reconcileChat,
|
||||
setChatTyping,
|
||||
setChatOpen,
|
||||
}), [
|
||||
@@ -942,6 +1168,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
chatMessages,
|
||||
deletedChatIds,
|
||||
unreadChatCount,
|
||||
chatReadAt,
|
||||
pendingChatMessages,
|
||||
chatError,
|
||||
typingUsers,
|
||||
connectionEpoch,
|
||||
castVote,
|
||||
@@ -951,9 +1180,13 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
injectDump,
|
||||
clearUnreadNotifications,
|
||||
sendChatMessage,
|
||||
retryChatMessage,
|
||||
discardChatMessage,
|
||||
editChatMessage,
|
||||
deleteChatMessage,
|
||||
clearUnreadChat,
|
||||
dismissChatError,
|
||||
markChatRead,
|
||||
reconcileChat,
|
||||
setChatTyping,
|
||||
setChatOpen,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user