fix(desktop): drive unread badges from live subscription, not refetched lastMessageAt (#581)

Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
This commit is contained in:
tlongwell-block
2026-05-14 13:25:24 -04:00
committed by GitHub
parent 9e76a08a47
commit 1858e98d5e
6 changed files with 185 additions and 182 deletions
@@ -1,66 +0,0 @@
import type { QueryClient } from "@tanstack/react-query";
import type { Channel } from "@/shared/api/types";
import { channelsQueryKey } from "@/features/channels/hooks";
function parseTimestamp(value: string | null | undefined) {
if (!value) {
return null;
}
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? null : timestamp;
}
function isNewerTimestamp(
candidate: string | null | undefined,
current: string | null | undefined,
) {
const candidateTimestamp = parseTimestamp(candidate);
if (candidateTimestamp === null) {
return false;
}
const currentTimestamp = parseTimestamp(current);
return currentTimestamp === null || candidateTimestamp > currentTimestamp;
}
export function updateChannelLastMessageAt(
queryClient: QueryClient,
channelId: string,
lastMessageAt: string | null | undefined,
) {
const lastMessageTimestamp = parseTimestamp(lastMessageAt);
const normalizedLastMessageAt =
lastMessageTimestamp === null
? null
: new Date(lastMessageTimestamp).toISOString();
if (!normalizedLastMessageAt) {
return;
}
queryClient.setQueryData<Channel[]>(channelsQueryKey, (current) => {
if (!current) {
return current;
}
let didUpdate = false;
const nextChannels = current.map((channel) => {
if (
channel.id !== channelId ||
!isNewerTimestamp(normalizedLastMessageAt, channel.lastMessageAt)
) {
return channel;
}
didUpdate = true;
return {
...channel,
lastMessageAt: normalizedLastMessageAt,
};
});
return didUpdate ? nextChannels : current;
});
}
@@ -1,27 +1,45 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { updateChannelLastMessageAt } from "@/features/channels/lib/channelCache";
import { channelsQueryKey } from "@/features/channels/hooks";
import { mergeTimelineCacheMessages } from "@/features/messages/hooks";
import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys";
import { getChannelIdFromTags } from "@/features/messages/lib/threading";
import { relayClient } from "@/shared/api/relayClient";
import { CHANNEL_EVENT_KINDS } from "@/shared/constants/kinds";
import {
CHANNEL_EVENT_KINDS,
KIND_FORUM_COMMENT,
KIND_FORUM_POST,
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
} from "@/shared/constants/kinds";
import type { Channel, RelayEvent } from "@/shared/api/types";
export type UseLiveChannelUpdatesOptions = {
currentPubkey?: string;
onDmMessage?: (event: RelayEvent, channel: Channel) => void;
onLiveMention?: () => void;
/**
* Fired for live "new content" events in a member channel (chat messages,
* forum posts/comments) authored by someone other than the current user.
* Used to drive the in-session "latest message at" map that powers sidebar
* unread badges. See `UNREAD_TRIGGER_KINDS` for the exact kind set.
*/
onChannelMessage?: (channelId: string, event: RelayEvent) => void;
};
const LIVE_SUBSCRIPTION_RETRY_BASE_MS = 1_000;
const LIVE_SUBSCRIPTION_RETRY_MAX_MS = 30_000;
function getMessageTimestamp(event: RelayEvent) {
return new Date(event.created_at * 1_000).toISOString();
}
// Only "new content" kinds should bump unread state. Reactions, edits,
// diffs, deletions, and system messages can all arrive after the last
// human-visible message and would otherwise create phantom unreads.
const UNREAD_TRIGGER_KINDS = new Set<number>([
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
KIND_FORUM_POST,
KIND_FORUM_COMMENT,
]);
function isExternalMentionEvent(event: RelayEvent, currentPubkey: string) {
return (
@@ -135,12 +153,22 @@ export function useLiveChannelUpdates(
return;
}
// Always update the cache — even for the active channel.
// Notify the unread tracker. Restricted to human-visible message kinds
// and to events authored by someone other than the current user — your
// own outgoing messages should never make a channel unread, and
// reactions / edits / system messages aren't "new content".
if (
UNREAD_TRIGGER_KINDS.has(event.kind) &&
(normalizedCurrentPubkey.length === 0 ||
event.pubkey.toLowerCase() !== normalizedCurrentPubkey)
) {
options.onChannelMessage?.(channelId, event);
}
// Merge into the timeline cache for the active channel.
// useChannelSubscription also writes to this cache, but there's a
// race window where it hasn't connected yet. Writes are idempotent
// (mergeTimelineCacheMessages deduplicates by event ID).
const messageTimestamp = getMessageTimestamp(event);
updateChannelLastMessageAt(queryClient, channelId, messageTimestamp);
queryClient.setQueryData<RelayEvent[]>(
channelMessagesKey(channelId),
(current) => {
@@ -5,7 +5,7 @@ import {
} from "@/features/channels/useLiveChannelUpdates";
import { useReadState } from "@/features/channels/readState/useReadState";
import type { RelayClient } from "@/shared/api/relayClientSession";
import type { Channel } from "@/shared/api/types";
import type { Channel, RelayEvent } from "@/shared/api/types";
type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & {
pubkey?: string;
@@ -50,51 +50,115 @@ export function useUnreadChannels(
seedContextRead,
} = useReadState(pubkey, relayClient);
// In-session "latest message at" per channel (unix seconds), driven by the
// live subscription. The backend doesn't populate Channel.lastMessageAt, so
// unread state cannot rely on it; this map is the authoritative source for
// sidebar badges. Monotonic: only advances. Reset when the identity or
// relay changes. Stale entries for channels the user has left are silently
// ignored by the memo (it iterates the current channels list, not the map).
const latestByChannelRef = React.useRef(new Map<string, number>());
// Channels manually marked unread this session (e.g., right-click → "mark
// unread"). Tracked separately from latestByChannelRef so we don't have to
// synthesise a "latest message" timestamp and risk the corresponding read
// marker becoming sticky. Cleared when the user opens the channel.
const forcedUnreadRef = React.useRef(new Set<string>());
const [latestVersion, bumpLatestVersion] = React.useReducer(
(x: number) => x + 1,
0,
);
// Track whether channels have been initialized (for first-load seeding)
const hasInitializedChannelsRef = React.useRef(false);
// Reset the in-session state when the identity or relay changes.
// biome-ignore lint/correctness/useExhaustiveDependencies: pubkey/relayClient are intentional reset signals
React.useEffect(() => {
latestByChannelRef.current = new Map();
forcedUnreadRef.current = new Set();
hasInitializedChannelsRef.current = false;
bumpLatestVersion();
}, [pubkey, relayClient]);
const markChannelRead = React.useCallback(
(channelId: string, readAt: string | null | undefined) => {
const unixSeconds = toUnixSeconds(readAt);
if (unixSeconds === null) return;
// Reading clears any prior manual mark-unread.
if (forcedUnreadRef.current.delete(channelId)) {
bumpLatestVersion();
}
markContextRead(channelId, unixSeconds);
},
[markContextRead],
);
// Manually mark a channel unread (e.g., right-click → "mark unread"). Sets
// the in-session forced-unread flag so the sidebar badge appears immediately
// without us inventing a synthetic latest-message timestamp, and rolls the
// NIP-RS read marker back so the unread state syncs across devices. The
// forced flag is cleared when the user opens the channel (markChannelRead).
const markChannelUnread = React.useCallback(
(channelId: string, lastMessageAt: string | null | undefined) => {
if (!forcedUnreadRef.current.has(channelId)) {
forcedUnreadRef.current.add(channelId);
bumpLatestVersion();
}
const unixSeconds = toUnixSeconds(lastMessageAt);
if (unixSeconds === null) return;
markContextUnread(channelId, unixSeconds);
if (unixSeconds !== null) {
markContextUnread(channelId, unixSeconds);
}
},
[markContextUnread],
);
// Seed new channels so they don't flash as unread on first load.
// For channels the user hasn't read yet, initialize read-at to the
// channel's current lastMessageAt so they appear as "read."
// Opportunistic backfill: if Channel.lastMessageAt is ever populated by the
// backend (today it isn't), seed the in-session map. Strict max — never
// overwrites a more recent live value.
React.useEffect(() => {
if (channels.length === 0) return;
let didAdvance = false;
for (const channel of channels) {
const seedUnix = toUnixSeconds(channel.lastMessageAt);
if (seedUnix === null) continue;
const current = latestByChannelRef.current.get(channel.id) ?? 0;
if (seedUnix > current) {
latestByChannelRef.current.set(channel.id, seedUnix);
didAdvance = true;
}
}
if (didAdvance) bumpLatestVersion();
}, [channels]);
// Seed read state on first load so existing channels don't flash as unread
// when the backend reports a non-null Channel.lastMessageAt. We deliberately
// seed from the backend-provided value (not from the live map), so a live
// event that races ahead of NIP-RS readiness can't be silently swallowed as
// already-read. Today the backend always returns null and this is a no-op;
// it becomes meaningful once last_message_at is wired up server-side.
React.useEffect(() => {
if (!isReadStateReady) return;
if (channels.length === 0) return;
if (hasInitializedChannelsRef.current) return;
for (const channel of channels) {
const existing = getEffectiveTimestamp(channel.id);
if (existing !== null) continue;
// Only seed on first initialization, not when new channels appear later
if (hasInitializedChannelsRef.current) continue;
const lastMsgUnix = toUnixSeconds(channel.lastMessageAt);
if (lastMsgUnix !== null) {
seedContextRead(channel.id, lastMsgUnix);
const seedUnix = toUnixSeconds(channel.lastMessageAt);
if (seedUnix !== null) {
seedContextRead(channel.id, seedUnix);
}
}
hasInitializedChannelsRef.current = true;
}, [channels, getEffectiveTimestamp, isReadStateReady, seedContextRead]);
// Mark the active channel as read when it changes or new messages arrive
// Mark the active channel as read when it changes or new messages arrive.
// Honours the caller's contract that a null activeReadAt suppresses
// read-marking until the timeline reports a real position. Manual
// mark-unread state is cleared inside markChannelRead, not here.
React.useEffect(() => {
if (!isReadStateReady) return;
if (!activeChannelId) return;
@@ -106,14 +170,31 @@ export function useUnreadChannels(
markChannelRead,
]);
// Keep live channel updates (drives channel.lastMessageAt cache updates)
useLiveChannelUpdates(channels, activeChannelId, liveUpdateOptions);
// Feed the in-session "latest message at" map from live channel events.
// Composes with any caller-supplied onChannelMessage handler.
const callerOnChannelMessage = liveUpdateOptions.onChannelMessage;
const handleChannelMessage = React.useCallback(
(channelId: string, event: RelayEvent) => {
const current = latestByChannelRef.current.get(channelId) ?? 0;
if (event.created_at > current) {
latestByChannelRef.current.set(channelId, event.created_at);
bumpLatestVersion();
}
callerOnChannelMessage?.(channelId, event);
},
[callerOnChannelMessage],
);
// Compute unread channel IDs by comparing channel.lastMessageAt against
// the NIP-RS effective timestamp.
// readStateVersion is intentionally included to force recomputation when
// cross-device state arrives (getEffectiveTimestamp is referentially stable).
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion is an intentional invalidation signal
useLiveChannelUpdates(channels, activeChannelId, {
...liveUpdateOptions,
onChannelMessage: handleChannelMessage,
});
// Unread = channels (excluding active) that have either been manually
// marked unread this session, or whose in-session latest message timestamp
// is strictly newer than their NIP-RS read marker.
// readStateVersion and latestVersion are intentional invalidation signals.
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion and latestVersion are intentional invalidation signals
const unreadChannelIds = React.useMemo(() => {
if (!isReadStateReady) {
return new Set<string>();
@@ -123,11 +204,12 @@ export function useUnreadChannels(
channels
.filter((channel) => channel.id !== activeChannelId)
.filter((channel) => {
const lastMsgUnix = toUnixSeconds(channel.lastMessageAt);
if (lastMsgUnix === null) return false;
if (forcedUnreadRef.current.has(channel.id)) return true;
const latest = latestByChannelRef.current.get(channel.id);
if (latest === undefined) return false;
const readAt = getEffectiveTimestamp(channel.id);
return readAt === null || lastMsgUnix > readAt;
return readAt === null || latest > readAt;
})
.map((channel) => channel.id),
);
@@ -136,6 +218,7 @@ export function useUnreadChannels(
channels,
getEffectiveTimestamp,
isReadStateReady,
latestVersion,
readStateVersion,
]);
-14
View File
@@ -1,7 +1,6 @@
import { useEffect, useEffectEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { updateChannelLastMessageAt } from "@/features/channels/lib/channelCache";
import {
channelMessagesKey,
dedupeMessagesById,
@@ -179,11 +178,6 @@ export function useChannelSubscription(channel: Channel | null) {
return;
}
updateChannelLastMessageAt(
queryClient,
channelId,
new Date(event.created_at * 1_000).toISOString(),
);
queryClient.setQueryData<RelayEvent[]>(
channelMessagesKey(channelId),
(current = []) => mergeTimelineCacheMessages(current, event),
@@ -400,14 +394,6 @@ export function useSendMessageMutation(
queryClient.setQueryData(context.queryKey, context.previousMessages);
},
onSuccess: (message, _variables, context) => {
if (channel) {
updateChannelLastMessageAt(
queryClient,
channel.id,
new Date(message.created_at * 1_000).toISOString(),
);
}
if (!context) {
return;
}
+18 -3
View File
@@ -438,6 +438,7 @@ declare global {
content: string;
parentEventId?: string | null;
pubkey?: string;
kind?: number;
}) => RelayEvent;
__SPROUT_E2E_EMIT_MOCK_TYPING__?: (input: {
channelName: string;
@@ -1726,9 +1727,16 @@ function emitMockChannelMessage(
content: string,
parentEventId?: string | null,
pubkey?: string,
kind?: number,
) {
const eventKind = kind ?? 9;
if (!parentEventId) {
const event = createMockEvent(9, content, [["h", channelId]], pubkey);
const event = createMockEvent(
eventKind,
content,
[["h", channelId]],
pubkey,
);
recordMockMessage(channelId, event);
emitMockLiveEvent(channelId, event);
return event;
@@ -1746,7 +1754,7 @@ function emitMockChannelMessage(
const rootEventId = parentThread.rootEventId ?? parentEventId;
const authorPubkey = pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey;
const event = createMockEvent(
9,
eventKind,
content,
buildReplyMessageTags(
channelId,
@@ -4515,6 +4523,7 @@ export function maybeInstallE2eTauriMocks() {
content,
parentEventId,
pubkey,
kind,
}) => {
const channel = mockChannels.find(
(candidate) => candidate.name === channelName,
@@ -4523,7 +4532,13 @@ export function maybeInstallE2eTauriMocks() {
throw new Error(`Mock channel ${channelName} not found.`);
}
return emitMockChannelMessage(channel.id, content, parentEventId, pubkey);
return emitMockChannelMessage(
channel.id,
content,
parentEventId,
pubkey,
kind,
);
};
window.__SPROUT_E2E_EMIT_MOCK_TYPING__ = ({ channelName, pubkey }) => {
const channel = mockChannels.find(
+25 -68
View File
@@ -59,63 +59,6 @@ async function waitForMockLiveSubscription(
.toBe(true);
}
async function sendMockChannelMessage(
page: import("@playwright/test").Page,
{
channelName,
content,
kind,
mentionPubkeys,
}: {
channelName: string;
content: string;
kind?: number | null;
mentionPubkeys?: string[] | null;
},
) {
await page.evaluate(
async ({
channelName: targetChannelName,
content,
kind,
mentionPubkeys,
}) => {
const tauriWindow = window as Window & {
__TAURI_INTERNALS__?: {
invoke: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
};
};
const invoke = tauriWindow.__TAURI_INTERNALS__?.invoke;
if (!invoke) {
throw new Error("Tauri invoke bridge is unavailable.");
}
const channels = (await invoke("get_channels")) as Array<{
id: string;
name: string;
}>;
const channel = channels.find(({ name }) => name === targetChannelName);
if (!channel) {
throw new Error(`Channel not found: ${targetChannelName}`);
}
await invoke("send_channel_message", {
channelId: channel.id,
content,
kind: kind ?? null,
mediaTags: null,
mentionPubkeys: mentionPubkeys ?? null,
parentEventId: null,
});
},
{ channelName, content, kind, mentionPubkeys },
);
}
async function openMemberMenu(
page: import("@playwright/test").Page,
pubkey: string,
@@ -653,12 +596,19 @@ test("sidebar shows unread indicator for newly active channels", async ({
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await waitForMockLiveSubscription(page, "random");
await sendMockChannelMessage(page, {
channelName: "random",
content: "Unread update for #random",
kind: 40002,
mentionPubkeys: [MOCK_IDENTITY_PUBKEY],
});
// The unread tracker ignores the current user's own messages, so emit as
// alice — simulating a real "another user posted while I was elsewhere".
await page.evaluate(
({ pubkey }) => {
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: "Unread update for #random",
kind: 40002,
pubkey,
});
},
{ pubkey: TEST_IDENTITIES.alice.pubkey },
);
await expect(page.getByTestId("channel-unread-random")).toBeVisible();
@@ -676,11 +626,18 @@ test("sidebar shows unread indicator for new forum posts", async ({ page }) => {
await expect(page.getByTestId("channel-unread-watercooler")).toHaveCount(0);
await waitForMockLiveSubscription(page, "watercooler");
await sendMockChannelMessage(page, {
channelName: "watercooler",
content: "Unread update for the forum",
kind: 45001,
});
// Emit as alice — the unread tracker ignores self-authored messages.
await page.evaluate(
({ pubkey }) => {
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "watercooler",
content: "Unread update for the forum",
kind: 45001,
pubkey,
});
},
{ pubkey: TEST_IDENTITIES.alice.pubkey },
);
await expect(page.getByTestId("channel-unread-watercooler")).toBeVisible();