From c0337d19a84474b3216c042befb9579a6108de92 Mon Sep 17 00:00:00 2001 From: Sivert Gullberg Hansen Date: Tue, 27 Oct 2026 17:36:20 +0100 Subject: [PATCH 1/2] Client half of server-backed read state, unfinished Pairs with Gryt-chat/server#346. Pushed as a branch rather than a PR because it is incomplete, and handed over rather than carried on. Done: - useUnreadTracker gains setServerUnread(host, counts), which replaces what the window had counted. A conversation the server leaves out has no marker and reads as caught up, not as everything unread. The doc comment no longer says counting starts when the window connected, because it no longer does. - registerServerSocketEvents asks for read:list on the same two moments it asks for mentions -- a reload and a rejoin -- and applies the reply. Not done: - Nothing emits read:set yet, so nothing is ever recorded as read. The site is serverView's channel-opened effect, which calls markChannelRead; the newest message id has to come from useChat, which is destructured below it, so the emit needs its own effect further down rather than a line in that one. - check-unread-tracker still tests the old model and will need rewriting. - Mark as read, in MarkAsReadItem.tsx, has no message id to send. Either the server accepts read:set without one as "everything here", or the client finds the newest id for a conversation it may not have open. - Channels only, by the same argument as the server side: a DM's marker would be a read receipt on an encrypted message. Co-Authored-By: Claude Opus 5 --- .../common/src/hooks/useUnreadTracker.ts | 22 +++++++++++++++++-- .../src/hooks/registerServerSocketEvents.ts | 19 ++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/packages/common/src/hooks/useUnreadTracker.ts b/src/packages/common/src/hooks/useUnreadTracker.ts index 3d9551bee..3d7d842d6 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,24 @@ export function markChannelRead(host: string, channelId: string) { emitChange(); } +/** + * What the server says, which replaces what this window had counted. A conversation it + * leaves out has no marker, and reads as caught up rather than as everything unread. + */ +export function setServerUnread(host: string, counts: Record) { + const fresh = new Map(); + 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/hooks/registerServerSocketEvents.ts b/src/packages/socket/src/hooks/registerServerSocketEvents.ts index 9599435c6..bafcf81a7 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 }) => { + setServerUnread(host, payload?.counts ?? {}); + }); + 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. From 1b88b026da273d3378ef93634de8d4ad7367b83d Mon Sep 17 00:00:00 2001 From: Sivert <316918682+grytchat@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:41:54 +0100 Subject: [PATCH 2/2] feat: tell the server what you've read, so unread survives a restart Pairs with Gryt-chat/server#346. read:set goes out when a channel is opened, when a message lands in the open one, and from Mark as read. Without a message id the server reads up to its newest message, so opening a channel and marking it read need no id. It ignores a DM. The server's counts replace this window's only for conversations it has a marker for. A channel never opened keeps what the window counted, where before the reply wiped it. Co-Authored-By: Claude Opus 5.5 --- scripts/check-unread-tracker.ts | 17 +++++++++++++++++ .../common/src/hooks/useUnreadTracker.ts | 9 +++++---- .../socket/src/components/MarkAsReadItem.tsx | 3 +++ .../socket/src/components/serverView.tsx | 7 +++++-- .../src/hooks/registerServerSocketEvents.ts | 4 ++-- src/packages/socket/src/hooks/useChat.ts | 3 +++ 6 files changed, 35 insertions(+), 8 deletions(-) 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 3d7d842d6..927ef7537 100644 --- a/src/packages/common/src/hooks/useUnreadTracker.ts +++ b/src/packages/common/src/hooks/useUnreadTracker.ts @@ -57,11 +57,12 @@ export function markChannelRead(host: string, channelId: string) { } /** - * What the server says, which replaces what this window had counted. A conversation it - * leaves out has no marker, and reads as caught up rather than as everything unread. + * 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) { - const fresh = new Map(); +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); } 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 bafcf81a7..6c91d8868 100644 --- a/src/packages/socket/src/hooks/registerServerSocketEvents.ts +++ b/src/packages/socket/src/hooks/registerServerSocketEvents.ts @@ -228,8 +228,8 @@ export function registerServerSocketEvents(socket: Socket, host: string, ctx: Se /* 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 }) => { - setServerUnread(host, payload?.counts ?? {}); + 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; 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. */