diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 468435e15ec..eb5ab5a95d8 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -34,6 +34,7 @@ import { useHideDmMutation, useOpenDmMutation, } from "@/features/channels/hooks"; +import { useDmResurfaceFromMessages } from "@/features/channels/useDmResurfaceFromMessages"; import { useUnreadChannels } from "@/features/channels/useUnreadChannels"; import { useMembershipNotifications } from "@/features/channels/useMembershipNotifications"; import { useFeedItemState } from "@/features/home/useFeedItemState"; @@ -505,6 +506,11 @@ export function AppShell() { const { applyCanvas, applyAgents } = useApplyTemplate(); const openDmMutation = useOpenDmMutation(); const hideDmMutation = useHideDmMutation(); + useDmResurfaceFromMessages({ + pubkey: identityQuery.data?.pubkey, + relayUrl: communitiesHook.activeCommunity?.relayUrl, + reopen: openDmMutation.mutateAsync, + }); const { browseDialogType, openBrowseChannels: handleOpenBrowseChannels, diff --git a/desktop/src/features/channels/dmResurface.test.mjs b/desktop/src/features/channels/dmResurface.test.mjs new file mode 100644 index 00000000000..81e09fa89ea --- /dev/null +++ b/desktop/src/features/channels/dmResurface.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + dmPeerPubkeysFromMembers, + isIncomingChannelMessageFromOther, + markHiddenDmFeedItems, +} from "./dmResurface.ts"; + +const SELF = "1".repeat(64); +const ALICE = "2".repeat(64); +const BOB = "3".repeat(64); + +function item(overrides = {}) { + return { + id: "event-1", + kind: 9, + pubkey: ALICE, + content: "hello", + createdAt: 10, + channelId: "dm-1", + channelName: "", + tags: [ + ["h", "dm-1"], + ["p", SELF], + ["p", BOB], + ], + category: "mention", + ...overrides, + }; +} + +function relayEvent(overrides = {}) { + return { + id: "event-1", + kind: 40002, + pubkey: ALICE, + content: "hello", + created_at: 10, + tags: [ + ["h", "dm-1"], + ["p", SELF], + ["p", BOB], + ], + sig: "", + ...overrides, + }; +} + +test("DM resurface derives peers from authoritative membership", () => { + const members = [{ pubkey: SELF }, { pubkey: ALICE }, { pubkey: BOB }]; + assert.deepEqual(dmPeerPubkeysFromMembers(members, SELF), [ALICE, BOB]); + assert.deepEqual(dmPeerPubkeysFromMembers([{ pubkey: ALICE }], SELF), []); +}); + +test("only external channel messages qualify, regardless of p tags", () => { + // #h-scoped delivery already guarantees relevance, so eligibility no longer + // requires a self `p` tag — an untagged DM from another sender still counts. + assert.equal(isIncomingChannelMessageFromOther(relayEvent(), SELF), true); + assert.equal( + isIncomingChannelMessageFromOther(relayEvent({ kind: 7 }), SELF), + false, + ); + assert.equal( + isIncomingChannelMessageFromOther(relayEvent({ pubkey: SELF }), SELF), + false, + ); + assert.equal( + isIncomingChannelMessageFromOther(relayEvent({ tags: [] }), SELF), + false, + ); + assert.equal( + isIncomingChannelMessageFromOther( + relayEvent({ tags: [["h", "dm-1"]] }), + SELF, + ), + true, + ); +}); + +test("hidden feed items are projected as DMs for Inbox presentation", () => { + const feed = { + feed: { + mentions: [item()], + needsAction: [], + activity: [], + agentActivity: [], + }, + meta: { since: 0, total: 1, generatedAt: 10 }, + }; + const marked = markHiddenDmFeedItems(feed, new Set(["dm-1"])); + assert.equal(marked.feed.mentions[0].channelType, "dm"); +}); diff --git a/desktop/src/features/channels/dmResurface.ts b/desktop/src/features/channels/dmResurface.ts new file mode 100644 index 00000000000..48e0c2e24c4 --- /dev/null +++ b/desktop/src/features/channels/dmResurface.ts @@ -0,0 +1,66 @@ +import type { + ChannelMember, + FeedItem, + HomeFeedResponse, + RelayEvent, +} from "@/shared/api/types"; +import { CHANNEL_MESSAGE_EVENT_KINDS } from "@/shared/constants/kinds"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +const CHANNEL_MESSAGE_KINDS = new Set(CHANNEL_MESSAGE_EVENT_KINDS); +const HEX_PUBKEY = /^[0-9a-f]{64}$/; + +export function dmPeerPubkeysFromMembers( + members: readonly Pick[], + currentPubkey: string | undefined, +): string[] { + const self = normalizePubkey(currentPubkey ?? ""); + const normalized = [ + ...new Set(members.map((member) => normalizePubkey(member.pubkey))), + ].filter((pubkey) => HEX_PUBKEY.test(pubkey)); + if (!HEX_PUBKEY.test(self) || !normalized.includes(self)) return []; + return normalized.filter((pubkey) => pubkey !== self); +} + +// The resurface subscription is `#h`-scoped to the hidden-DM set, so the relay +// only delivers events already addressed to a hidden channel the reader belongs +// to. Eligibility therefore drops the `#p` requirement — an untagged DM (a CLI +// or agent send that omits participant `p` tags) still resurfaces the row. +export function isIncomingChannelMessageFromOther( + event: RelayEvent, + currentPubkey: string | undefined, +): boolean { + const self = normalizePubkey(currentPubkey ?? ""); + return ( + self.length > 0 && + CHANNEL_MESSAGE_KINDS.has(event.kind) && + relayEventChannelId(event) !== null && + normalizePubkey(event.pubkey) !== self + ); +} + +export function relayEventChannelId(event: RelayEvent): string | null { + return event.tags.find((tag) => tag[0] === "h" && tag[1])?.[1] ?? null; +} + +export function markHiddenDmFeedItems( + feed: HomeFeedResponse, + hiddenDmIds: ReadonlySet, +): HomeFeedResponse { + if (hiddenDmIds.size === 0) return feed; + + const mark = (item: FeedItem): FeedItem => + item.channelId && hiddenDmIds.has(item.channelId) + ? { ...item, channelType: "dm" } + : item; + + return { + ...feed, + feed: { + mentions: feed.feed.mentions.map(mark), + needsAction: feed.feed.needsAction.map(mark), + activity: feed.feed.activity.map(mark), + agentActivity: feed.feed.agentActivity.map(mark), + }, + }; +} diff --git a/desktop/src/features/channels/hiddenDmResurfaceAction.test.mjs b/desktop/src/features/channels/hiddenDmResurfaceAction.test.mjs new file mode 100644 index 00000000000..78d8f0da992 --- /dev/null +++ b/desktop/src/features/channels/hiddenDmResurfaceAction.test.mjs @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resurfaceHiddenDmMessage } from "./hiddenDmResurfaceAction.ts"; + +const SELF = "1".repeat(64); +const ALICE = "2".repeat(64); +const BOB = "3".repeat(64); + +function event() { + return { + id: "event-1", + kind: 40002, + pubkey: ALICE, + content: "hello", + created_at: 10, + // Message p tags are intentionally incomplete for this group DM. + tags: [ + ["h", "hidden-dm"], + ["p", SELF], + ], + sig: "", + }; +} + +function member(pubkey) { + return { + pubkey, + role: "member", + isAgent: false, + joinedAt: "", + displayName: null, + }; +} + +test("reopens the source hidden group DM from authoritative membership", async () => { + const inputs = []; + assert.equal( + await resurfaceHiddenDmMessage({ + event: event(), + expectedRelayUrl: "wss://relay.example", + expectedSignerPubkey: SELF, + hiddenDmIds: new Set(["hidden-dm"]), + fetchMembers: async () => [member(SELF), member(ALICE), member(BOB)], + isCurrent: () => true, + reopen: async (input) => { + inputs.push(input); + return { id: "hidden-dm" }; + }, + }), + true, + ); + assert.deepEqual(inputs, [ + { + pubkeys: [ALICE, BOB], + expectedRelayUrl: "wss://relay.example", + expectedSignerPubkey: SELF, + }, + ]); +}); + +test("ignores an event for a channel outside the hidden set", async () => { + let reopenCount = 0; + assert.equal( + await resurfaceHiddenDmMessage({ + event: event(), + expectedRelayUrl: "wss://relay.example", + expectedSignerPubkey: SELF, + hiddenDmIds: new Set(["other-dm"]), + fetchMembers: async () => [member(SELF), member(ALICE)], + isCurrent: () => true, + reopen: async () => { + reopenCount += 1; + return { id: "hidden-dm" }; + }, + }), + false, + ); + assert.equal(reopenCount, 0); +}); + +test("a suspended old-community read cannot reopen a DM", async () => { + let current = true; + let resume; + const members = new Promise((resolve) => { + resume = resolve; + }); + let reopenCount = 0; + const result = resurfaceHiddenDmMessage({ + event: event(), + expectedRelayUrl: "wss://old.example", + expectedSignerPubkey: SELF, + hiddenDmIds: new Set(["hidden-dm"]), + fetchMembers: async () => members, + isCurrent: () => current, + reopen: async () => { + reopenCount += 1; + return { id: "hidden-dm" }; + }, + }); + await Promise.resolve(); + current = false; + resume([member(SELF), member(ALICE)]); + assert.equal(await result, false); + assert.equal(reopenCount, 0); +}); + +test("rejects a reopen result for any channel other than the source", async () => { + await assert.rejects( + resurfaceHiddenDmMessage({ + event: event(), + expectedRelayUrl: "wss://relay.example", + expectedSignerPubkey: SELF, + hiddenDmIds: new Set(["hidden-dm"]), + fetchMembers: async () => [member(SELF), member(ALICE)], + isCurrent: () => true, + reopen: async () => ({ id: "alternate-dm" }), + }), + /different DM conversation/, + ); +}); diff --git a/desktop/src/features/channels/hiddenDmResurfaceAction.ts b/desktop/src/features/channels/hiddenDmResurfaceAction.ts new file mode 100644 index 00000000000..78ee087475f --- /dev/null +++ b/desktop/src/features/channels/hiddenDmResurfaceAction.ts @@ -0,0 +1,49 @@ +import type { ChannelMember, RelayEvent } from "@/shared/api/types"; +import type { OpenDmInput } from "@/shared/api/tauriChannels"; +import { + dmPeerPubkeysFromMembers, + isIncomingChannelMessageFromOther, + relayEventChannelId, +} from "./dmResurface"; + +type HiddenDmResurfaceActionOptions = { + event: RelayEvent; + expectedRelayUrl: string; + expectedSignerPubkey: string; + hiddenDmIds: ReadonlySet; + fetchMembers: (channelId: string) => Promise; + isCurrent: () => boolean; + reopen: (input: OpenDmInput) => Promise<{ id: string }>; +}; + +export async function resurfaceHiddenDmMessage({ + event, + expectedRelayUrl, + expectedSignerPubkey, + hiddenDmIds, + fetchMembers, + isCurrent, + reopen, +}: HiddenDmResurfaceActionOptions): Promise { + if (!isIncomingChannelMessageFromOther(event, expectedSignerPubkey)) { + return false; + } + const channelId = relayEventChannelId(event); + if (!channelId || !hiddenDmIds.has(channelId)) return false; + + const members = await fetchMembers(channelId); + if (!isCurrent()) return false; + const pubkeys = dmPeerPubkeysFromMembers(members, expectedSignerPubkey); + if (pubkeys.length === 0) return false; + + const opened = await reopen({ + pubkeys, + expectedRelayUrl, + expectedSignerPubkey, + }); + if (!isCurrent()) return false; + if (opened.id !== channelId) { + throw new Error("Relay reopened a different DM conversation."); + } + return true; +} diff --git a/desktop/src/features/channels/hiddenDmResurfaceCoordinator.test.mjs b/desktop/src/features/channels/hiddenDmResurfaceCoordinator.test.mjs new file mode 100644 index 00000000000..b3ea41b4ce3 --- /dev/null +++ b/desktop/src/features/channels/hiddenDmResurfaceCoordinator.test.mjs @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createHiddenDmResurfaceCoordinator } from "./hiddenDmResurfaceCoordinator.ts"; + +function deferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +test("a follower arriving mid-attempt retries from the latest event after a failure", async () => { + const seen = []; + const gates = [deferred(), deferred()]; + const errors = []; + const coordinator = createHiddenDmResurfaceCoordinator({ + resurface: async (event) => { + const index = seen.length; + seen.push(event.id); + await gates[index].promise; + }, + isCurrent: () => true, + onError: (channelId, error) => errors.push([channelId, error]), + }); + + coordinator.handle("dm-1", { id: "event-a" }); + await Promise.resolve(); + // Follower B lands while attempt A is still in flight. + coordinator.handle("dm-1", { id: "event-b" }); + assert.deepEqual(seen, ["event-a"]); + + // A fails; the retry re-runs from the latest event (B), which succeeds. + gates[0].reject(new Error("boom")); + await Promise.resolve(); + await Promise.resolve(); + assert.deepEqual(seen, ["event-a", "event-b"]); + + gates[1].resolve(); + await gates[1].promise; + await Promise.resolve(); + + assert.equal(errors.length, 1); + assert.equal(errors[0][0], "dm-1"); +}); + +test("a stale generation's attempt does not delete the live generation's entry", async () => { + // Model two generations: each real subscription generation creates its own + // coordinator, so a torn-down generation's cleanup touches only its own map. + let staleCurrent = true; + const staleGate = deferred(); + const staleSeen = []; + const stale = createHiddenDmResurfaceCoordinator({ + resurface: async (event) => { + staleSeen.push(event.id); + await staleGate.promise; + }, + isCurrent: () => staleCurrent, + }); + + const liveSeen = []; + const live = createHiddenDmResurfaceCoordinator({ + resurface: async (event) => { + liveSeen.push(event.id); + }, + isCurrent: () => true, + }); + + // Attempt A begins on the stale generation and suspends. + stale.handle("dm-1", { id: "event-a" }); + await Promise.resolve(); + assert.deepEqual(staleSeen, ["event-a"]); + + // Generation flips; event B is delivered to the live coordinator and reopens. + staleCurrent = false; + live.handle("dm-1", { id: "event-b" }); + await Promise.resolve(); + assert.deepEqual(liveSeen, ["event-b"]); + + // Stale A now retires. Its cleanup cannot touch the live coordinator's map, + // so a subsequent live follower still starts a fresh attempt. + staleGate.resolve(); + await staleGate.promise; + await Promise.resolve(); + + live.handle("dm-1", { id: "event-c" }); + await Promise.resolve(); + assert.deepEqual(liveSeen, ["event-b", "event-c"]); +}); diff --git a/desktop/src/features/channels/hiddenDmResurfaceCoordinator.ts b/desktop/src/features/channels/hiddenDmResurfaceCoordinator.ts new file mode 100644 index 00000000000..ec3e08150c6 --- /dev/null +++ b/desktop/src/features/channels/hiddenDmResurfaceCoordinator.ts @@ -0,0 +1,60 @@ +import type { RelayEvent } from "@/shared/api/types"; + +type CoordinatorOptions = { + resurface: (event: RelayEvent) => Promise; + isCurrent: () => boolean; + onError?: (channelId: string, error: unknown) => void; +}; + +/** + * Per-channel coalescing for hidden-DM resurface attempts. + * + * The reopen action is idempotent, so concurrent messages for the same DM + * share one in-flight attempt. A follower that lands while an attempt is + * running flags it for retry (from the latest event) instead of being + * dropped, so a failed reopen re-runs rather than leaving the row hidden. + * + * A coordinator owns its own pending map, so callers create one per + * subscription generation: an attempt from a torn-down generation can never + * delete or coalesce into an entry owned by the live one. + */ +export function createHiddenDmResurfaceCoordinator({ + resurface, + isCurrent, + onError, +}: CoordinatorOptions) { + const pending = new Map(); + const latestEventByChannel = new Map(); + + const attempt = async (channelId: string) => { + const state = { retry: false }; + pending.set(channelId, state); + try { + do { + state.retry = false; + const event = latestEventByChannel.get(channelId); + if (!event) return; + try { + await resurface(event); + return; + } catch (error) { + onError?.(channelId, error); + } + } while (state.retry && isCurrent()); + } finally { + pending.delete(channelId); + } + }; + + return { + handle(channelId: string, event: RelayEvent) { + latestEventByChannel.set(channelId, event); + const existing = pending.get(channelId); + if (existing) { + existing.retry = true; + return; + } + void attempt(channelId); + }, + }; +} diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 9069b052da4..18eb2699d2e 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -53,6 +53,7 @@ import { CHANNEL_MEMBERS_STALE_TIME_MS, channelMembersQueryKey, } from "@/features/channels/rosterFreshness"; +import { dmVisibilityQueryKeyFor } from "@/features/channels/useHiddenDmIds"; export const channelsQueryKey = ["channels"] as const; /** Keeps focused polling at the established one-minute cadence. */ @@ -530,6 +531,12 @@ export function useCreateChannelMutation() { export function useOpenDmMutation() { const queryClient = useQueryClient(); + const { activeCommunity } = useCommunities(); + const identityQuery = useIdentityQuery(); + const dmVisibilityKey = dmVisibilityQueryKeyFor( + activeCommunity?.relayUrl, + identityQuery.data?.pubkey, + ); return useMutation({ mutationFn: (input: OpenDmInput) => openDm(input), @@ -537,6 +544,11 @@ export function useOpenDmMutation() { queryClient.setQueryData(channelsQueryKey, (current) => upsertCachedChannel(current, openedChannel), ); + queryClient.setQueryData>(dmVisibilityKey, (current) => { + const next = new Set(current); + next.delete(openedChannel.id); + return next; + }); }, onSettled: () => { // The relay-returned DM is already in the cache. Mark the list stale so @@ -546,6 +558,7 @@ export function useOpenDmMutation() { queryKey: channelsQueryKey, refetchType: "none", }); + void queryClient.invalidateQueries({ queryKey: dmVisibilityKey }); }, }); } @@ -575,6 +588,12 @@ export function useUpsertCachedChannel() { export function useHideDmMutation() { const queryClient = useQueryClient(); + const { activeCommunity } = useCommunities(); + const identityQuery = useIdentityQuery(); + const dmVisibilityKey = dmVisibilityQueryKeyFor( + activeCommunity?.relayUrl, + identityQuery.data?.pubkey, + ); return useMutation({ mutationFn: (channelId: string) => hideDm(channelId), @@ -591,8 +610,16 @@ export function useHideDmMutation() { queryClient.setQueryData(channelsQueryKey, context.previous); } }, + onSuccess: (_data, channelId) => { + queryClient.setQueryData>(dmVisibilityKey, (current) => + new Set(current).add(channelId), + ); + }, onSettled: async () => { - await queryClient.invalidateQueries({ queryKey: channelsQueryKey }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: channelsQueryKey }), + queryClient.invalidateQueries({ queryKey: dmVisibilityKey }), + ]); }, }); } diff --git a/desktop/src/features/channels/useDmResurfaceBatching.test.mjs b/desktop/src/features/channels/useDmResurfaceBatching.test.mjs new file mode 100644 index 00000000000..da426d8faff --- /dev/null +++ b/desktop/src/features/channels/useDmResurfaceBatching.test.mjs @@ -0,0 +1,278 @@ +/** + * Batching lifecycle for useDmResurfaceFromMessages. + * + * The relay rejects a REQ whose aggregate explicit `#h` values exceed + * MAX_EXPLICIT_CHANNEL_VALUES, so a hidden set larger than the cap must be + * split into multiple subscriptions or every hidden DM loses its resurface + * trigger. These tests exercise exactly that split and its teardown, which a + * pure helper test cannot reach because the batching only happens inside the + * mounted effect. + */ + +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + localStorage: dom.window.localStorage, + }); + globalThis.__TAURI_INTERNALS__ = { + invoke: (cmd) => { + if (cmd === "get_channel_members") { + return Promise.resolve({ + members: [ + { pubkey: VIEWER, role: "member", is_agent: false }, + { pubkey: "b".repeat(64), role: "member", is_agent: false }, + ], + }); + } + return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback: () => Math.random(), + }; + dom.window.__TAURI_INTERNALS__ = globalThis.__TAURI_INTERNALS__; +}); + +after(() => dom.window.close()); + +const RELAY_URL = "wss://relay.example"; +const VIEWER = "a".repeat(64); + +function seedCommunity() { + window.localStorage.setItem( + "buzz-communities", + JSON.stringify([ + { + id: "community-a", + name: "Community A", + relayUrl: RELAY_URL, + addedAt: "2026-01-01T00:00:00Z", + }, + ]), + ); + window.localStorage.setItem("buzz-active-community-id", "community-a"); +} + +function hiddenDmSnapshot(count) { + return { + id: "snapshot-1", + kind: 30622, + pubkey: VIEWER, + content: "", + created_at: 1, + tags: Array.from({ length: count }, (_, index) => [ + "h", + `dm-${String(index).padStart(4, "0")}`, + ]), + sig: "", + }; +} + +async function mount(hiddenCount, subscribeImpl, reopen) { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const React = (await import("react")).default; + const { QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + ); + const { CommunitiesProvider } = await import( + "@/features/communities/useCommunities.tsx" + ); + const { relayClient } = await import("@/shared/api/relayClient"); + const { useDmResurfaceFromMessages } = await import( + "./useDmResurfaceFromMessages.ts" + ); + + const originalFetchEvents = relayClient.fetchEvents; + const originalSubscribeLive = relayClient.subscribeLive; + + relayClient.fetchEvents = async () => + hiddenCount > 0 ? [hiddenDmSnapshot(hiddenCount)] : []; + relayClient.subscribeLive = subscribeImpl; + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + + const wrapper = ({ children }) => + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement(CommunitiesProvider, null, children), + ); + + const hook = renderHook( + () => + useDmResurfaceFromMessages({ + pubkey: VIEWER, + relayUrl: RELAY_URL, + reopen: reopen ?? (async () => ({ id: "x" })), + }), + { wrapper }, + ); + + return { + act, + async settle() { + for (let i = 0; i < 6; i++) { + await act(async () => { + await new Promise((r) => setTimeout(r, 5)); + }); + } + }, + unmount: hook.unmount, + restore() { + hook.unmount(); + queryClient.clear(); + queryClient.unmount(); + cleanup(); + relayClient.fetchEvents = originalFetchEvents; + relayClient.subscribeLive = originalSubscribeLive; + }, + }; +} + +test("128 hidden DMs use a single subscription within the cap", async () => { + seedCommunity(); + const batches = []; + const harness = await mount(128, async (filter) => { + batches.push(filter["#h"]); + return async () => {}; + }); + try { + await harness.settle(); + assert.equal(batches.length, 1); + assert.equal(batches[0].length, 128); + } finally { + harness.restore(); + } +}); + +test("129 hidden DMs split into two subscriptions, both within the cap", async () => { + seedCommunity(); + const batches = []; + const harness = await mount(129, async (filter) => { + batches.push(filter["#h"]); + return async () => {}; + }); + try { + await harness.settle(); + assert.equal(batches.length, 2); + assert.deepEqual( + batches.map((batch) => batch.length), + [128, 1], + ); + // Every hidden id appears exactly once across the batches. + const all = batches.flat(); + assert.equal(new Set(all).size, 129); + for (const batch of batches) { + assert.ok(batch.length <= 128); + } + } finally { + harness.restore(); + } +}); + +test("activity delivered on the final batch resurfaces its DM", async () => { + seedCommunity(); + const handlers = []; + const reopened = []; + const targetIdRef = { id: null }; + const harness = await mount( + 129, + async (filter, onEvent) => { + handlers.push({ ids: filter["#h"], onEvent }); + return async () => {}; + }, + async ({ pubkeys }) => { + reopened.push(pubkeys); + // The reopen contract returns the resurfaced channel id; the action + // rejects a mismatch, so echo the target the event carried. + return { id: targetIdRef.id }; + }, + ); + try { + await harness.settle(); + const finalBatch = handlers[1]; + const targetId = finalBatch.ids[0]; + targetIdRef.id = targetId; + // Deliver a peer message on the FINAL batch's subscription. If batching + // dropped that batch's handler, this event would never reach the + // coordinator and reopen would never fire. + await harness.act(async () => { + finalBatch.onEvent({ + id: "evt-1", + kind: 9, + pubkey: "b".repeat(64), + content: "hi", + created_at: 2, + tags: [["h", targetId]], + sig: "", + }); + await new Promise((r) => setTimeout(r, 5)); + }); + assert.equal(reopened.length, 1); + assert.deepEqual(reopened[0], ["b".repeat(64)]); + } finally { + harness.restore(); + } +}); + +test("a batch subscription failure does not abort the other batches", async () => { + seedCommunity(); + const batches = []; + let call = 0; + const harness = await mount(129, async (filter) => { + call += 1; + if (call === 1) throw new Error("relay rejected batch 1"); + batches.push(filter["#h"]); + return async () => {}; + }); + try { + await harness.settle(); + // The second batch still subscribed even though the first threw. + assert.equal(batches.length, 1); + assert.equal(batches[0].length, 1); + } finally { + harness.restore(); + } +}); + +test("teardown while batch setup is pending disposes every settled batch", async () => { + seedCommunity(); + let disposeCount = 0; + const releases = []; + const harness = await mount(129, async () => { + // Each subscribe parks until the test releases it, so the effect can be + // torn down while batch setup is still in flight. + await new Promise((resolve) => releases.push(resolve)); + return async () => { + disposeCount += 1; + }; + }); + try { + await harness.settle(); + // Unmount before any subscribe resolves. + await harness.act(async () => { + harness.unmount(); + }); + // Now let both subscribes resolve; each must be disposed since its owning + // generation is gone. + await harness.act(async () => { + for (const release of releases) release(); + await new Promise((r) => setTimeout(r, 5)); + }); + assert.equal(disposeCount, 2); + } finally { + harness.restore(); + } +}); diff --git a/desktop/src/features/channels/useDmResurfaceFromMessages.ts b/desktop/src/features/channels/useDmResurfaceFromMessages.ts new file mode 100644 index 00000000000..fddd056f25c --- /dev/null +++ b/desktop/src/features/channels/useDmResurfaceFromMessages.ts @@ -0,0 +1,141 @@ +import * as React from "react"; + +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import { MAX_EXPLICIT_CHANNEL_VALUES } from "@/shared/api/relayClientShared"; +import { + getChannelMembers, + type OpenDmInput, +} from "@/shared/api/tauriChannels"; +import { CHANNEL_MESSAGE_EVENT_KINDS } from "@/shared/constants/kinds"; +import { relayEventChannelId } from "./dmResurface"; +import { createHiddenDmResurfaceCoordinator } from "./hiddenDmResurfaceCoordinator"; +import { resurfaceHiddenDmMessage } from "./hiddenDmResurfaceAction"; +import { useHiddenDmIds } from "./useHiddenDmIds"; + +type UseDmResurfaceFromMessagesOptions = { + pubkey: string | undefined; + relayUrl: string | undefined; + reopen: (input: OpenDmInput) => Promise<{ id: string }>; +}; + +/** + * Resurfaces a hidden DM row the moment new activity lands in it. + * + * The subscription is `#h`-scoped to the current hidden-DM id set: channel + * messages carry a `channel_id`, and the relay only fans channel-scoped events + * to channel-scoped subscriptions (`fan_out_scoped`), so a community-global + * `#p` filter would never receive them. Scoping to `#h` also means every + * delivered event is already for a hidden DM the reader belongs to (hiding + * never drops membership), so no per-event visibility fetch is needed and + * untagged CLI/agent DMs resurface too. + * + * The relay rejects a REQ whose aggregate explicit `#h` values exceed + * {@link MAX_EXPLICIT_CHANNEL_VALUES}, so the hidden set is split into batches + * of at most that size, each its own subscription. Every batch is owned by the + * current effect generation and disposed together on hidden-set/relay/signer + * change, so an over-limit hidden set no longer silently disables resurfacing. + */ +export function useDmResurfaceFromMessages({ + pubkey, + relayUrl, + reopen, +}: UseDmResurfaceFromMessagesOptions) { + const hiddenDmIds = useHiddenDmIds(pubkey); + const generationRef = React.useRef(0); + const reopenLatest = React.useEffectEvent(reopen); + + // Stable dependency for the hidden-set membership, order-independent. + const hiddenDmKey = React.useMemo( + () => [...hiddenDmIds].sort().join(","), + [hiddenDmIds], + ); + + React.useEffect(() => { + const expectedSignerPubkey = pubkey?.trim().toLowerCase() ?? ""; + const expectedRelayUrl = relayUrl?.trim() ?? ""; + const channelIds = hiddenDmKey.length > 0 ? hiddenDmKey.split(",") : []; + const generation = ++generationRef.current; + if (!expectedSignerPubkey || !expectedRelayUrl || channelIds.length === 0) { + return; + } + + const hiddenDmIdSet = new Set(channelIds); + let disposed = false; + const unsubscribers: Array<() => Promise> = []; + const isCurrent = () => !disposed && generationRef.current === generation; + + // A coordinator owned by this generation: coalescing and cleanup touch only + // its private map, so a torn-down generation's in-flight attempt can never + // drop a follower coalesced onto the replacement subscription. One + // coordinator spans every batch — the map is keyed by channel id, so which + // batch delivered an event is irrelevant. + const coordinator = createHiddenDmResurfaceCoordinator({ + resurface: (event) => + resurfaceHiddenDmMessage({ + event, + expectedRelayUrl, + expectedSignerPubkey, + hiddenDmIds: hiddenDmIdSet, + fetchMembers: getChannelMembers, + isCurrent, + reopen: reopenLatest, + }), + isCurrent, + onError: (channelId, error) => { + if (isCurrent()) { + console.error("Failed to resurface hidden DM", channelId, error); + } + }, + }); + + const handleEvent = (event: RelayEvent) => { + if (!isCurrent()) return; + const channelId = relayEventChannelId(event); + if (!channelId || !hiddenDmIdSet.has(channelId)) return; + coordinator.handle(channelId, event); + }; + + const since = Math.floor(Date.now() / 1_000) - 5; + for ( + let start = 0; + start < channelIds.length; + start += MAX_EXPLICIT_CHANNEL_VALUES + ) { + const batch = channelIds.slice( + start, + start + MAX_EXPLICIT_CHANNEL_VALUES, + ); + void relayClient + .subscribeLive( + { + kinds: [...CHANNEL_MESSAGE_EVENT_KINDS], + "#h": batch, + since, + limit: 100, + }, + handleEvent, + ) + .then((dispose) => { + if (!isCurrent()) { + void dispose().catch(() => {}); + return; + } + unsubscribers.push(dispose); + }) + .catch((error) => { + if (isCurrent()) { + console.error("Failed to subscribe to hidden DM activity", error); + } + }); + } + + return () => { + disposed = true; + generationRef.current += 1; + for (const unsubscribe of unsubscribers) { + void unsubscribe().catch(() => {}); + } + }; + }, [pubkey, relayUrl, hiddenDmKey]); +} diff --git a/desktop/src/features/channels/useHiddenDmIds.ts b/desktop/src/features/channels/useHiddenDmIds.ts new file mode 100644 index 00000000000..865d434e45d --- /dev/null +++ b/desktop/src/features/channels/useHiddenDmIds.ts @@ -0,0 +1,62 @@ +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; + +import { useCommunities } from "@/features/communities/useCommunities"; +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_DM_VISIBILITY } from "@/shared/constants/kinds"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export const dmVisibilityQueryKey = ["dm-visibility"] as const; + +/** Exact query key for one relay+identity scope's DM-visibility snapshot. */ +export function dmVisibilityQueryKeyFor( + relayUrl: string | undefined, + pubkey: string | undefined, +) { + return [ + ...dmVisibilityQueryKey, + relayUrl ?? "", + normalizePubkey(pubkey ?? ""), + ] as const; +} + +export function extractHiddenDmIds(events: readonly RelayEvent[]): Set { + const latest = events.reduce( + (current, event) => + current === null || event.created_at > current.created_at + ? event + : current, + null, + ); + return new Set( + (latest?.tags ?? []) + .filter((tag) => tag[0] === "h" && tag[1]) + .map((tag) => tag[1]), + ); +} + +export async function fetchHiddenDmIds(pubkey: string): Promise> { + const normalizedPubkey = normalizePubkey(pubkey); + if (normalizedPubkey.length === 0) return new Set(); + const events = await relayClient.fetchEvents({ + kinds: [KIND_DM_VISIBILITY], + "#p": [normalizedPubkey], + limit: 1, + }); + return extractHiddenDmIds(events); +} + +export function useHiddenDmIds(pubkey: string | undefined) { + const { activeCommunity } = useCommunities(); + const normalizedPubkey = normalizePubkey(pubkey ?? ""); + const relayUrl = activeCommunity?.relayUrl ?? ""; + const query = useQuery({ + queryKey: dmVisibilityQueryKeyFor(relayUrl, normalizedPubkey), + queryFn: () => fetchHiddenDmIds(normalizedPubkey), + enabled: relayUrl.length > 0 && normalizedPubkey.length > 0, + staleTime: 30_000, + }); + + return React.useMemo(() => query.data ?? new Set(), [query.data]); +} diff --git a/desktop/src/features/home/hiddenDmInboxAction.test.mjs b/desktop/src/features/home/hiddenDmInboxAction.test.mjs new file mode 100644 index 00000000000..0e98a9a4cb9 --- /dev/null +++ b/desktop/src/features/home/hiddenDmInboxAction.test.mjs @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { openHiddenDmInboxContext } from "./hiddenDmInboxAction.ts"; + +const SELF = "1".repeat(64); +const ALICE = "2".repeat(64); +const BOB = "3".repeat(64); +const inboxItem = { + id: "event-1", + item: { + channelType: "dm", + // Incomplete message tags must not choose the recreated membership. + tags: [ + ["h", "hidden-dm"], + ["p", SELF], + ], + }, +}; + +function member(pubkey) { + return { + pubkey, + role: "member", + isAgent: false, + joinedAt: "", + displayName: null, + }; +} + +function options(overrides = {}) { + return { + item: inboxItem, + channelId: "hidden-dm", + messageId: "event-1", + availableChannelIds: new Set(), + expectedRelayUrl: "wss://relay.example", + expectedSignerPubkey: SELF, + pendingChannelIds: new Set(), + fetchMembers: async () => [member(SELF), member(ALICE), member(BOB)], + openDm: async () => ({ id: "hidden-dm" }), + isCurrent: () => true, + onOpenContext: () => {}, + onError: () => {}, + onPendingChange: () => {}, + ...overrides, + }; +} + +test("Inbox reopens the original hidden group DM from channel membership", async () => { + const inputs = []; + const navigations = []; + const result = await openHiddenDmInboxContext( + options({ + openDm: async (input) => { + inputs.push(input); + return { id: "hidden-dm" }; + }, + onOpenContext: (...args) => navigations.push(args), + }), + ); + assert.equal(result, true); + assert.deepEqual(inputs[0].pubkeys, [ALICE, BOB]); + assert.deepEqual(navigations, [["hidden-dm", "event-1", undefined]]); +}); + +test("double activation is deduplicated while a reopen is pending", async () => { + let resume; + const members = new Promise((resolve) => { + resume = resolve; + }); + let openCount = 0; + const shared = options({ + fetchMembers: async () => members, + openDm: async () => { + openCount += 1; + return { id: "hidden-dm" }; + }, + }); + const first = openHiddenDmInboxContext(shared); + const second = openHiddenDmInboxContext(shared); + resume([member(SELF), member(ALICE)]); + assert.equal(await second, false); + assert.equal(await first, true); + assert.equal(openCount, 1); +}); + +test("a failed reopen stays put, reports an error, and can be retried", async () => { + let attempts = 0; + let errors = 0; + let navigations = 0; + const shared = options({ + openDm: async () => { + attempts += 1; + if (attempts === 1) throw new Error("offline"); + return { id: "hidden-dm" }; + }, + onError: () => { + errors += 1; + }, + onOpenContext: () => { + navigations += 1; + }, + }); + assert.equal(await openHiddenDmInboxContext(shared), false); + assert.equal(navigations, 0); + assert.equal(errors, 1); + assert.equal(shared.pendingChannelIds.size, 0); + + assert.equal(await openHiddenDmInboxContext(shared), true); + assert.equal(attempts, 2); + assert.equal(navigations, 1); +}); + +test("an unmounted Inbox action cannot navigate after reopen settles", async () => { + let current = true; + let resume; + const reopened = new Promise((resolve) => { + resume = resolve; + }); + let navigations = 0; + let pendingChanges = 0; + const result = openHiddenDmInboxContext( + options({ + openDm: async () => reopened, + isCurrent: () => current, + onOpenContext: () => { + navigations += 1; + }, + onPendingChange: () => { + pendingChanges += 1; + }, + }), + ); + await Promise.resolve(); + current = false; + resume({ id: "hidden-dm" }); + assert.equal(await result, false); + assert.equal(navigations, 0); + assert.equal(pendingChanges, 1); +}); diff --git a/desktop/src/features/home/hiddenDmInboxAction.ts b/desktop/src/features/home/hiddenDmInboxAction.ts new file mode 100644 index 00000000000..60b2ef15d2f --- /dev/null +++ b/desktop/src/features/home/hiddenDmInboxAction.ts @@ -0,0 +1,76 @@ +import { dmPeerPubkeysFromMembers } from "@/features/channels/dmResurface"; +import type { InboxItem } from "@/features/home/lib/inbox"; +import type { ChannelMember } from "@/shared/api/types"; +import type { OpenDmInput } from "@/shared/api/tauriChannels"; + +type HiddenDmInboxActionOptions = { + item: InboxItem; + channelId: string; + messageId: string; + threadRootId?: string | null; + availableChannelIds: ReadonlySet; + expectedRelayUrl: string; + expectedSignerPubkey: string; + pendingChannelIds: Set; + fetchMembers: (channelId: string) => Promise; + openDm: (input: OpenDmInput) => Promise<{ id: string }>; + isCurrent: () => boolean; + onOpenContext: ( + channelId: string, + messageId: string, + threadRootId?: string | null, + ) => void; + onError: () => void; + onPendingChange: () => void; +}; + +export async function openHiddenDmInboxContext({ + item, + channelId, + messageId, + threadRootId, + availableChannelIds, + expectedRelayUrl, + expectedSignerPubkey, + pendingChannelIds, + fetchMembers, + openDm, + isCurrent, + onOpenContext, + onError, + onPendingChange, +}: HiddenDmInboxActionOptions): Promise { + if (availableChannelIds.has(channelId) || item.item.channelType !== "dm") { + if (isCurrent()) onOpenContext(channelId, messageId, threadRootId); + return true; + } + if (pendingChannelIds.has(channelId)) return false; + + pendingChannelIds.add(channelId); + onPendingChange(); + try { + const members = await fetchMembers(channelId); + if (!isCurrent()) return false; + const pubkeys = dmPeerPubkeysFromMembers(members, expectedSignerPubkey); + if (pubkeys.length === 0) { + throw new Error("Could not determine the DM membership."); + } + const opened = await openDm({ + pubkeys, + expectedRelayUrl, + expectedSignerPubkey, + }); + if (!isCurrent()) return false; + if (opened.id !== channelId) { + throw new Error("Relay reopened a different DM conversation."); + } + onOpenContext(channelId, messageId, threadRootId); + return true; + } catch { + if (isCurrent()) onError(); + return false; + } finally { + pendingChannelIds.delete(channelId); + if (isCurrent()) onPendingChange(); + } +} diff --git a/desktop/src/features/home/ui/HomeScreen.tsx b/desktop/src/features/home/ui/HomeScreen.tsx index b6512816e38..3dcfbbc3835 100644 --- a/desktop/src/features/home/ui/HomeScreen.tsx +++ b/desktop/src/features/home/ui/HomeScreen.tsx @@ -1,6 +1,8 @@ import * as React from "react"; import { useAppShell } from "@/app/AppShellContext"; +import { markHiddenDmFeedItems } from "@/features/channels/dmResurface"; +import { useHiddenDmIds } from "@/features/channels/useHiddenDmIds"; import { useHomeFeedQuery } from "@/features/home/hooks"; import { HomeView } from "@/features/home/ui/HomeView"; import type { HomeFeedResponse } from "@/shared/api/types"; @@ -26,24 +28,25 @@ export function HomeScreen({ }: HomeScreenProps) { const homeFeedQuery = useHomeFeedQuery(); const { threadActivityFeedItems } = useAppShell(); + const hiddenDmIds = useHiddenDmIds(currentPubkey); const augmentedFeed = React.useMemo((): HomeFeedResponse | undefined => { if (!homeFeedQuery.data) return undefined; - if (threadActivityFeedItems.length === 0) { - return homeFeedQuery.data; - } - - return { - ...homeFeedQuery.data, - feed: { - ...homeFeedQuery.data.feed, - activity: [ - ...homeFeedQuery.data.feed.activity, - ...threadActivityFeedItems, - ], - }, - }; - }, [homeFeedQuery.data, threadActivityFeedItems]); + const withThreadActivity = + threadActivityFeedItems.length === 0 + ? homeFeedQuery.data + : { + ...homeFeedQuery.data, + feed: { + ...homeFeedQuery.data.feed, + activity: [ + ...homeFeedQuery.data.feed.activity, + ...threadActivityFeedItems, + ], + }, + }; + return markHiddenDmFeedItems(withThreadActivity, hiddenDmIds); + }, [hiddenDmIds, homeFeedQuery.data, threadActivityFeedItems]); return (
diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 893b3c309c6..0a16f27c4d0 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -2,9 +2,8 @@ import * as React from "react"; import { RefreshCcw } from "lucide-react"; import { useAppShell } from "@/app/AppShellContext"; -import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; -import { useChannelsQuery, useOpenDmMutation } from "@/features/channels/hooks"; +import { useChannelsQuery } from "@/features/channels/hooks"; import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagementSheet"; import { @@ -28,6 +27,7 @@ import { useHomeInboxAutoSelection } from "@/features/home/useHomeInboxAutoSelec import { useHomeInboxContextMessages } from "@/features/home/useHomeInboxContextMessages"; import { useHomePersonalInbox } from "@/features/home/useHomePersonalInbox"; import { useInboxThreadContext } from "@/features/home/useInboxThreadContext"; +import { useHiddenDmInboxNavigation } from "@/features/home/useHiddenDmInboxNavigation"; import { type ProfilePanelTab, type ProfilePanelView, @@ -171,9 +171,6 @@ export function HomeView({ const [membersChannel, setMembersChannel] = React.useState( null, ); - const { goChannel } = useAppNavigation(); - const openDmMutation = useOpenDmMutation(); - const openDm = openDmMutation.mutateAsync; const handleUserSelectItem = React.useCallback( (itemId: string | null) => { setAutoSelectedEventId(null); @@ -219,13 +216,6 @@ export function HomeView({ const [emptyDeleteId, setEmptyDeleteId] = React.useState(null); const [editTargetId, setEditTargetId] = React.useState(null); const [isSendingReply, setIsSendingReply] = React.useState(false); - const handleOpenDm = React.useCallback( - async (pubkeys: string[]) => { - const dm = await openDm({ pubkeys }); - await goChannel(dm.id); - }, - [goChannel, openDm], - ); const { activeReminderEventIds, openReminder } = useRemindLater(); const [localRepliesByItemId, setLocalRepliesByItemId] = React.useState< Record @@ -460,6 +450,19 @@ export function HomeView({ } return null; }, [filteredItems, selectedConversationId, selectedEventId]); + const { + canOpenSelected, + handleOpenDirect, + handleOpenDm, + handleOpenSelectedContext, + isReopenPending, + isReopenErrored, + } = useHiddenDmInboxNavigation({ + availableChannelIds, + currentPubkey, + onOpenContext, + selectedItem, + }); const deleteInboxMessage = React.useCallback( async (eventId: string) => { const channelId = selectedItem?.item.channelId; @@ -700,17 +703,9 @@ export function HomeView({ onFilterChange={handleFilterChange} onMarkRead={markItemRead} onMarkUnread={markItemUnread} - onOpenDirect={(item) => { - const channelId = item.item.channelId; - if (!channelId) { - return; - } - onOpenContext( - channelId, - item.id, - getThreadReference(item.item.tags).rootId, - ); - }} + onOpenDirect={handleOpenDirect} + isReopenPending={isReopenPending} + isReopenErrored={isReopenErrored} onRemindLater={(item) => { const channelId = item.item.channelId; if (!channelId) { @@ -788,10 +783,7 @@ export function HomeView({ void; + /** True while the selected hidden DM is being reopened on the relay. */ + reopenPending?: boolean; + /** True when the last reopen of the selected hidden DM failed. */ + reopenErrored?: boolean; onSendReply: (input: { content: string; mediaTags?: string[][]; @@ -189,6 +193,8 @@ function InboxMessageDetailPane({ onRequestEmptyEditDelete, onManageChannel, onOpenContext, + reopenPending = false, + reopenErrored = false, onSendReply, onToggleReaction, }: InboxDetailPaneProps) { @@ -589,6 +595,47 @@ function InboxMessageDetailPane({
+ {reopenPending || reopenErrored ? ( +
+ {reopenPending ? ( + <> + + Reopening… + + ) : ( + <> + + Couldn’t reopen + {contextChannelId ? ( + + ) : null} + + )} +
+ ) : null} {canOpenChannel && contextChannelId ? ( diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index f2d4421081d..94ec11db59e 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,4 +1,12 @@ -import { Bell, Clock, Ellipsis, ExternalLink, MailOpen } from "lucide-react"; +import { + AlertCircle, + Bell, + Clock, + Ellipsis, + ExternalLink, + LoaderCircle, + MailOpen, +} from "lucide-react"; import * as React from "react"; import { @@ -215,6 +223,8 @@ type InboxListPaneProps = { onMarkRead: (itemId: string) => void; onMarkUnread: (itemId: string) => void; onOpenDirect: (item: InboxItem) => void; + isReopenPending?: (channelId: string | null | undefined) => boolean; + isReopenErrored?: (channelId: string | null | undefined) => boolean; onRemindLater: (item: InboxItem) => void; onSelect: (itemId: string) => void; onSelectDraft: (draftKey: string) => void; @@ -243,6 +253,8 @@ export function InboxListPane({ onMarkRead, onMarkUnread, onOpenDirect, + isReopenPending, + isReopenErrored, onRemindLater, onSelect, onSelectDraft, @@ -303,6 +315,14 @@ export function InboxListPane({ (eventId) => activeReminderEventIds?.has(eventId) ?? false, ); const hasChannelTarget = Boolean(item.item.channelId); + const isReopening = isReopenPending?.(item.item.channelId) ?? false; + const hasReopenError = isReopenErrored?.(item.item.channelId) ?? false; + const canOpen = hasChannelTarget && !isReopening; + const openLabel = !hasChannelTarget + ? "No channel link" + : isReopening + ? "Reopening…" + : "Open in channel"; const typeLabel = getInboxTypeLabel(item); const videoReviewCommentRootId = getInboxVideoReviewCommentRootId(item); const isSenderAgent = @@ -431,6 +451,47 @@ export function InboxListPane({
) : null} + {isReopening || hasReopenError ? ( +
+ {isReopening ? ( + <> + + Reopening… + + ) : ( + <> + + Couldn’t reopen + {canOpen ? ( + + ) : null} + + )} +
+ ) : null} +
)} onOpenDirect(item)} > @@ -509,15 +570,15 @@ export function InboxListPane({ )} { - if (hasChannelTarget) { + if (canOpen) { onOpenDirect(item); } }} > - {hasChannelTarget ? "Open in channel" : "No channel link"} + {openLabel} null;\n", + }; + } + if (url === "buzz-inbox-stub:UpdateIndicator") { + // The real UpdateIndicator pulls in UpdaterProvider's background-check + // setInterval, which keeps the event loop alive past the test. It has + // nothing to do with the reopen contract, so stub it to a null render. + return { + format: "module", + shortCircuit: true, + source: "export const UpdateIndicator = () => null;\n", + }; + } + return nextLoad(url, context); + }, +}); + +const SELF = "1".repeat(64); +const PEER = "2".repeat(64); +const HIDDEN_DM_ID = "hidden-dm-channel"; +const SOURCE_EVENT_ID = "e".repeat(64); +const RELAY_URL = "wss://relay.example"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +class NoopObserver { + disconnect() {} + observe() {} + unobserve() {} +} + +// Neither pane nor the reopen path needs a live relay socket; a real +// (undici) WebSocket would open a connection to the seeded relay URL and +// leak an open handle that keeps the test process alive. Stub it to a +// non-connecting shell. +class NoopWebSocket { + close() {} + send() {} + addEventListener() {} + removeEventListener() {} +} +globalThis.WebSocket = NoopWebSocket; +dom.window.WebSocket = NoopWebSocket; + +Object.assign(globalThis, { + IS_REACT_ACT_ENVIRONMENT: true, + IntersectionObserver: NoopObserver, + MutationObserver: dom.window.MutationObserver, + ResizeObserver: NoopObserver, + document: dom.window.document, + localStorage: dom.window.localStorage, + self: dom.window, + window: dom.window, +}); +// Bulk-copy DOM constructors Radix / React reference without a window prefix. +for (const key of Object.getOwnPropertyNames(dom.window)) { + if ( + !(key in globalThis) && + (key.startsWith("HTML") || + key.startsWith("SVG") || + [ + "Element", + "DOMRect", + "DOMRectReadOnly", + "Node", + "NodeFilter", + "NodeList", + "NamedNodeMap", + "Event", + "CustomEvent", + "MouseEvent", + "KeyboardEvent", + "FocusEvent", + "InputEvent", + "PointerEvent", + "Text", + "Comment", + "DocumentFragment", + "Range", + "Selection", + ].includes(key)) + ) { + const value = dom.window[key]; + if (value !== undefined) globalThis[key] = value; + } +} +Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, +}); +globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window); +dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, +}); +globalThis.matchMedia = dom.window.matchMedia; +dom.window.requestAnimationFrame = (callback) => setTimeout(callback, 0); +dom.window.cancelAnimationFrame = (id) => clearTimeout(id); +globalThis.requestAnimationFrame = dom.window.requestAnimationFrame; +globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame; + +// Radix DismissableLayer/FocusScope dispatch plain objects; JSDOM's strict +// Event validation throws on them. Drop non-Event objects silently. +const _origDispatch = dom.window.EventTarget.prototype.dispatchEvent; +dom.window.EventTarget.prototype.dispatchEvent = function dispatchEvent(event) { + if (!(event instanceof dom.window.Event)) return false; + return _origDispatch.call(this, event); +}; +globalThis.EventTarget = dom.window.EventTarget; + +// JSDOM does not perform a native