diff --git a/scripts/check-unread-tracker.ts b/scripts/check-unread-tracker.ts index c49019180..df7e06f45 100644 --- a/scripts/check-unread-tracker.ts +++ b/scripts/check-unread-tracker.ts @@ -12,6 +12,7 @@ const { markChannelRead, markChannelUnread, markServerRead, + setServerUnread, } = await import("../src/packages/common/src/hooks/useUnreadTracker.ts"); const counts = (host: string) => getUnreadSnapshot().get(host) ?? new Map(); @@ -65,4 +66,20 @@ assert.equal(total(HOST), 0); // And leaves the other server alone. assert.equal(total(OTHER), 1); +// The server's counts replace this window's for each conversation it has a marker for, +// and leave the rest alone. +markServerRead(OTHER); +markChannelUnread(HOST, "local-only"); +markChannelUnread(HOST, "marked"); +setServerUnread(HOST, { marked: 4 }, ["marked", "caught-up"]); +assert.equal(counts(HOST).get("marked"), 4); +assert.equal(counts(HOST).get("local-only"), 1); +assert.equal(counts(HOST).get("caught-up"), undefined); + +// A marker with no count means caught up, and the last one leaves no empty map behind. +markServerRead(HOST); +markChannelUnread(HOST, "marked"); +setServerUnread(HOST, {}, ["marked"]); +assert.equal(getUnreadSnapshot().has(HOST), false); + console.log("unread tracker: ok"); diff --git a/src/packages/common/src/hooks/useUnreadTracker.ts b/src/packages/common/src/hooks/useUnreadTracker.ts index 3d9551bee..927ef7537 100644 --- a/src/packages/common/src/hooks/useUnreadTracker.ts +++ b/src/packages/common/src/hooks/useUnreadTracker.ts @@ -1,8 +1,8 @@ import { useCallback, useSyncExternalStore } from "react"; /** - * How many messages have arrived in a conversation that nobody has looked at. - * **Counted from when this window connected**, not from a server-side marker. + * How many messages have arrived in a conversation that nobody has looked at. Seeded from + * the server's read markers when a connection opens, then counted here as more arrive. */ type UnreadMap = Map>; @@ -56,6 +56,25 @@ export function markChannelRead(host: string, channelId: string) { emitChange(); } +/** + * What the server says, for every conversation it holds a marker for. One with no marker + * keeps what this window counted, since the server has nothing to say about it. + */ +export function setServerUnread(host: string, counts: Record, marked: Iterable) { + const fresh = new Map(unreadMap.get(host)); + for (const channelId of marked) fresh.delete(channelId); + for (const [channelId, n] of Object.entries(counts)) { + if (n > 0) fresh.set(channelId, n); + } + + /* Dropped rather than left empty, so serverHasUnread stays a size check. */ + const next = new Map(unreadMap); + if (fresh.size === 0) next.delete(host); + else next.set(host, fresh); + unreadMap = next; + emitChange(); +} + export function markServerRead(host: string) { if (!unreadMap.has(host)) return; const next = new Map(unreadMap); diff --git a/src/packages/socket/src/components/MarkAsReadItem.tsx b/src/packages/socket/src/components/MarkAsReadItem.tsx index 877234fda..0a80ab65e 100644 --- a/src/packages/socket/src/components/MarkAsReadItem.tsx +++ b/src/packages/socket/src/components/MarkAsReadItem.tsx @@ -6,6 +6,7 @@ import { clearServerMentions, clearServerThreadMentions, clearServerThreadUnread, + getUnreadSnapshot, markChannelRead, markConversationThreadsRead, markServerRead, @@ -57,10 +58,12 @@ export function MarkAsReadItem({ host, scope }: { host: string; scope: MarkReadS /* Threads and all, which opening the channel deliberately does not do (GRYT-1014). This is somebody saying they are finished with it. */ sockets[host]?.emit("mentions:seen", { conversationId: id, includeThreads: true }); + sockets[host]?.emit("read:set", { conversationId: id }); }; const onClick = () => { if (scope.kind === "server") { + for (const id of getUnreadSnapshot().get(host)?.keys() ?? []) sockets[host]?.emit("read:set", { conversationId: id }); markServerRead(host); clearServerMentions(host); clearServerThreadUnread(host); diff --git a/src/packages/socket/src/components/serverView.tsx b/src/packages/socket/src/components/serverView.tsx index efb691637..4a7dd34e5 100644 --- a/src/packages/socket/src/components/serverView.tsx +++ b/src/packages/socket/src/components/serverView.tsx @@ -260,8 +260,11 @@ export const ServerView = ({ dmSpace = false }: { dmSpace?: boolean }) => { useEffect(() => { if (!currentlyViewingServer) return; const opened = visibleDmId || visibleChannelId; - if (opened) markChannelRead(currentlyViewingServer.host, opened); - }, [currentlyViewingServer, visibleChannelId, visibleDmId]); + if (!opened) return; + markChannelRead(currentlyViewingServer.host, opened); + // No message id: the server reads up to its newest. It ignores a DM. + sockets[currentlyViewingServer.host]?.emit("read:set", { conversationId: opened }); + }, [currentlyViewingServer, visibleChannelId, visibleDmId, sockets]); useEffect(() => { setOnScreenConversation(currentlyViewingServer?.host ?? null, visibleDmId || visibleChannelId); diff --git a/src/packages/socket/src/hooks/registerServerSocketEvents.ts b/src/packages/socket/src/hooks/registerServerSocketEvents.ts index 9599435c6..6c91d8868 100644 --- a/src/packages/socket/src/hooks/registerServerSocketEvents.ts +++ b/src/packages/socket/src/hooks/registerServerSocketEvents.ts @@ -22,6 +22,7 @@ import { setServerFileAccess, setServerNotice, setServerRefreshToken, + setServerUnread, setThreadMentionCounts, subscribeToPrefs, } from "@/common"; @@ -215,6 +216,22 @@ export function registerServerSocketEvents(socket: Socket, host: string, ctx: Se socket.emit("mentions:list"); } + /* Read markers, asked for on the same moments as mentions and for the same reason: a + reload, a reconnect and a rejoin are each a point the counts have gone stale. */ + let askedForRead = false; + socket.on("disconnect", () => { askedForRead = false; }); + + function askForRead() { + askedForRead = true; + socket.emit("read:list"); + } + + /* One name for the question and the answer, as mentions does, so a second window + cannot end up disagreeing about what is left. */ + socket.on("read:list", (payload: { counts?: Record; markers?: { conversation_id: string }[] }) => { + setServerUnread(host, payload?.counts ?? {}, (payload?.markers ?? []).map((m) => m.conversation_id)); + }); + const { nickname, userIdRef, servers, serversRef, lastInviteJoinAttemptRef, myVoiceStateByHostRef } = ctx; const { setServers, setNewServerInfo, setServerDetailsList, setFailedServerDetails } = ctx; const { setClients, setMemberLists, setMemberKeyStates, setServerProfiles, setIsServerMuted, setIsServerDeafened } = ctx; @@ -347,6 +364,7 @@ export function registerServerSocketEvents(socket: Socket, host: string, ctx: Se /* A reload never gets `server:joined`, so this was the only event left that says the session is back and the member is known (GRYT-1388). */ if (!askedForMentions) askForMentions(); + if (!askedForRead) askForRead(); // Not awaited: a key that never arrives means no encrypted messages rather // than a connection that failed. @@ -436,6 +454,7 @@ export function registerServerSocketEvents(socket: Socket, host: string, ctx: Se // Every join, not just the first: being away is when this accumulates, and it // is how a mention read on a phone stops showing here. askForMentions(); + askForRead(); // Read when the event fires, not at registration: the socket exists before // `useUserId` resolves, so a captured value was usually null. diff --git a/src/packages/socket/src/hooks/useChat.ts b/src/packages/socket/src/hooks/useChat.ts index 0e0e3c7fd..434d6bf6e 100644 --- a/src/packages/socket/src/hooks/useChat.ts +++ b/src/packages/socket/src/hooks/useChat.ts @@ -494,6 +494,9 @@ export function useChat({ warnUnreadOnScreen("useChat chat:new", { serverHost, activeConversationId }); } markChannelUnread(serverHost, msg.conversation_id); + } else if (msg.conversation_id === activeConversationId && msg.sender_server_id !== currentUserId) { + // Read as it arrives, so the server's marker keeps up with the screen. The server ignores a DM. + currentConnection.emit("read:set", { conversationId: msg.conversation_id, messageId: msg.message_id }); } /* The same question `useSocketEvents` asks for a background server, so the two cannot disagree about focus, a mention, the channel default or a mute. */