Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions scripts/check-unread-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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");
23 changes: 21 additions & 2 deletions src/packages/common/src/hooks/useUnreadTracker.ts
Original file line number Diff line number Diff line change
@@ -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<string, Map<string, number>>;

Expand Down Expand Up @@ -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<string, number>, marked: Iterable<string>) {
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);
Expand Down
3 changes: 3 additions & 0 deletions src/packages/socket/src/components/MarkAsReadItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
clearServerMentions,
clearServerThreadMentions,
clearServerThreadUnread,
getUnreadSnapshot,
markChannelRead,
markConversationThreadsRead,
markServerRead,
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 5 additions & 2 deletions src/packages/socket/src/components/serverView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions src/packages/socket/src/hooks/registerServerSocketEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
setServerFileAccess,
setServerNotice,
setServerRefreshToken,
setServerUnread,
setThreadMentionCounts,
subscribeToPrefs,
} from "@/common";
Expand Down Expand Up @@ -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<string, number>; 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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/packages/socket/src/hooks/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading