diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index a930cec5f..eab4a3da8 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -107,6 +107,9 @@ export function AppShell() { identityQuery.data?.pubkey, selectedView === "home", ); + const refetchHomeFeedOnLiveMention = React.useEffectEvent(() => { + void homeFeedQuery.refetch(); + }); const channelsQuery = useChannelsQuery(); const { refetch: refetchChannels } = channelsQuery; const channels = channelsQuery.data ?? []; @@ -137,6 +140,10 @@ export function AppShell() { channels, activeChannel, activeReadAt, + { + currentPubkey: identityQuery.data?.pubkey, + onLiveMention: refetchHomeFeedOnLiveMention, + }, ); const { activeChannelTitle, activeDmPresenceStatus } = useActiveChannelHeader( activeChannel, diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts new file mode 100644 index 000000000..1538b72ad --- /dev/null +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -0,0 +1,227 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { + channelsQueryKey, + updateChannelLastMessageAt, +} 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 type { Channel, RelayEvent } from "@/shared/api/types"; + +export type UseLiveChannelUpdatesOptions = { + currentPubkey?: string; + onLiveMention?: () => void; +}; + +const LIVE_MENTION_SUBSCRIPTION_RETRY_MS = 1_000; + +function getMessageTimestamp(event: RelayEvent) { + return new Date(event.created_at * 1_000).toISOString(); +} + +function isExternalMentionEvent(event: RelayEvent, currentPubkey: string) { + return ( + currentPubkey.length > 0 && event.pubkey.toLowerCase() !== currentPubkey + ); +} + +function rememberMentionEvent( + seenMentionEventIds: Set, + eventId: string, +): boolean { + if (seenMentionEventIds.has(eventId)) { + return false; + } + + seenMentionEventIds.add(eventId); + if (seenMentionEventIds.size > 200) { + const oldestEventId = seenMentionEventIds.values().next().value; + if (oldestEventId) { + seenMentionEventIds.delete(oldestEventId); + } + } + + return true; +} + +async function disposeLiveSubscriptions( + subscriptions: Array<() => Promise>, +) { + await Promise.allSettled(subscriptions.map((dispose) => dispose())); +} + +export function useLiveChannelUpdates( + channels: Channel[], + activeChannelId: string | null, + options: UseLiveChannelUpdatesOptions = {}, +) { + const queryClient = useQueryClient(); + const normalizedCurrentPubkey = + options.currentPubkey?.trim().toLowerCase() ?? ""; + const seenMentionEventIdsRef = React.useRef(new Set()); + const liveChannelIds = React.useMemo( + () => + new Set( + channels + .filter((channel) => channel.channelType !== "forum") + .map((channel) => channel.id), + ), + [channels], + ); + const mentionChannelIds = React.useMemo( + () => [...new Set(channels.map((channel) => channel.id))].sort(), + [channels], + ); + + const handleIncomingMessage = React.useEffectEvent((event: RelayEvent) => { + const channelId = getChannelIdFromTags(event.tags); + if (!channelId || channelId === activeChannelId) { + return; + } + + if (!liveChannelIds.has(channelId)) { + void queryClient.invalidateQueries({ queryKey: channelsQueryKey }); + return; + } + + const messageTimestamp = getMessageTimestamp(event); + + updateChannelLastMessageAt(queryClient, channelId, messageTimestamp); + queryClient.setQueryData( + channelMessagesKey(channelId), + (current) => { + if (!current) { + return current; + } + + return mergeTimelineCacheMessages(current, event); + }, + ); + }); + + const handleMentionEvent = React.useEffectEvent((event: RelayEvent) => { + if (!isExternalMentionEvent(event, normalizedCurrentPubkey)) { + return; + } + + if (!rememberMentionEvent(seenMentionEventIdsRef.current, event.id)) { + return; + } + + options.onLiveMention?.(); + }); + + React.useEffect(() => { + return relayClient.subscribeToReconnects(() => { + void queryClient.invalidateQueries({ queryKey: channelsQueryKey }); + }); + }, [queryClient]); + + React.useEffect(() => { + if (liveChannelIds.size === 0) { + return; + } + + let isDisposed = false; + let cleanup: (() => Promise) | undefined; + + relayClient + .subscribeToAllStreamMessages((event) => { + if (!isDisposed) { + handleIncomingMessage(event); + } + }) + .then((dispose) => { + if (isDisposed) { + void dispose(); + return; + } + + cleanup = dispose; + }) + .catch((error) => { + console.error("Failed to subscribe to unread channel updates", error); + }); + + return () => { + isDisposed = true; + if (cleanup) { + void cleanup(); + } + }; + }, [liveChannelIds]); + + React.useEffect(() => { + if ( + !options.onLiveMention || + normalizedCurrentPubkey.length === 0 || + mentionChannelIds.length === 0 + ) { + return; + } + + let isDisposed = false; + let cleanup: Array<() => Promise> = []; + let retryTimeout: ReturnType | undefined; + + const subscribeToMentionChannels = async () => { + const settled = await Promise.allSettled( + mentionChannelIds.map((channelId) => + relayClient.subscribeToChannelMentionEvents( + channelId, + normalizedCurrentPubkey, + (event) => { + if (!isDisposed) { + handleMentionEvent(event); + } + }, + ), + ), + ); + + const nextCleanup = settled.flatMap((result) => + result.status === "fulfilled" ? [result.value] : [], + ); + + if (isDisposed) { + await disposeLiveSubscriptions(nextCleanup); + return; + } + + const firstFailure = settled.find( + (result) => result.status === "rejected", + ); + if (!firstFailure) { + cleanup = nextCleanup; + return; + } + + await disposeLiveSubscriptions(nextCleanup); + if (isDisposed) { + return; + } + + console.error( + "Failed to subscribe to all Home mention updates; retrying", + firstFailure.reason, + ); + retryTimeout = window.setTimeout(() => { + retryTimeout = undefined; + void subscribeToMentionChannels(); + }, LIVE_MENTION_SUBSCRIPTION_RETRY_MS); + }; + + void subscribeToMentionChannels(); + + return () => { + isDisposed = true; + if (retryTimeout !== undefined) { + window.clearTimeout(retryTimeout); + } + void disposeLiveSubscriptions(cleanup); + }; + }, [mentionChannelIds, normalizedCurrentPubkey, options.onLiveMention]); +} diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index ed4bbe551..2ab7fb424 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -1,19 +1,14 @@ import * as React from "react"; -import { useQueryClient } from "@tanstack/react-query"; - import { - channelsQueryKey, - updateChannelLastMessageAt, -} from "@/features/channels/hooks"; -import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys"; -import { getChannelIdFromTags } from "@/features/messages/lib/threading"; -import { mergeTimelineCacheMessages } from "@/features/messages/hooks"; -import { relayClient } from "@/shared/api/relayClient"; -import type { Channel, RelayEvent } from "@/shared/api/types"; + useLiveChannelUpdates, + type UseLiveChannelUpdatesOptions, +} from "@/features/channels/useLiveChannelUpdates"; +import type { Channel } from "@/shared/api/types"; const CHANNEL_READ_STATE_STORAGE_KEY = "sprout.channel-read-state.v1"; type ChannelReadState = Record; +type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions; function parseTimestamp(value: string | null | undefined) { if (!value) { @@ -71,16 +66,12 @@ function readStoredChannelReadState(): ChannelReadState { } } -function getMessageTimestamp(event: RelayEvent) { - return new Date(event.created_at * 1_000).toISOString(); -} - export function useUnreadChannels( channels: Channel[], activeChannel: Channel | null, activeReadAt?: string | null, + options: UseUnreadChannelsOptions = {}, ) { - const queryClient = useQueryClient(); const [lastReadByChannel, setLastReadByChannel] = React.useState(readStoredChannelReadState); const hasInitializedChannelsRef = React.useRef(false); @@ -171,82 +162,7 @@ export function useUnreadChannels( markChannelRead(activeChannelId, effectiveActiveReadAt); }, [activeChannelId, effectiveActiveReadAt, markChannelRead]); - - const liveChannelIds = React.useMemo( - () => - new Set( - channels - .filter((channel) => channel.channelType !== "forum") - .map((channel) => channel.id), - ), - [channels], - ); - - const handleIncomingMessage = React.useEffectEvent((event: RelayEvent) => { - const channelId = getChannelIdFromTags(event.tags); - if (!channelId || channelId === activeChannelId) { - return; - } - - if (!liveChannelIds.has(channelId)) { - void queryClient.invalidateQueries({ queryKey: channelsQueryKey }); - return; - } - - const messageTimestamp = getMessageTimestamp(event); - - updateChannelLastMessageAt(queryClient, channelId, messageTimestamp); - queryClient.setQueryData( - channelMessagesKey(channelId), - (current) => { - if (!current) { - return current; - } - - return mergeTimelineCacheMessages(current, event); - }, - ); - }); - - React.useEffect(() => { - return relayClient.subscribeToReconnects(() => { - void queryClient.invalidateQueries({ queryKey: channelsQueryKey }); - }); - }, [queryClient]); - - React.useEffect(() => { - if (liveChannelIds.size === 0) { - return; - } - - let isDisposed = false; - let cleanup: (() => Promise) | undefined; - - relayClient - .subscribeToAllStreamMessages((event) => { - if (!isDisposed) { - handleIncomingMessage(event); - } - }) - .then((dispose) => { - if (isDisposed) { - void dispose(); - return; - } - - cleanup = dispose; - }) - .catch((error) => { - console.error("Failed to subscribe to unread channel updates", error); - }); - - return () => { - isDisposed = true; - if (cleanup) { - void cleanup(); - } - }; - }, [liveChannelIds]); + useLiveChannelUpdates(channels, activeChannelId, options); const unreadChannelIds = React.useMemo( () => diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 4f711e5ae..66bdf1d74 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -8,6 +8,7 @@ import { import type { PresenceStatus, RelayEvent } from "@/shared/api/types"; import { CHANNEL_EVENT_KINDS, + HOME_MENTION_EVENT_KINDS, KIND_STREAM_MESSAGE, KIND_TYPING_INDICATOR, } from "@/shared/constants/kinds"; @@ -174,6 +175,17 @@ export class RelayClient { return this.subscribe(this.buildGlobalStreamFilter(50), onEvent); } + async subscribeToChannelMentionEvents( + channelId: string, + pubkey: string, + onEvent: (event: RelayEvent) => void, + ) { + return this.subscribe( + this.buildChannelMentionFilter(channelId, pubkey, 50), + onEvent, + ); + } + async preconnect() { this.keepAliveRequested = true; await this.ensureConnected(); @@ -275,6 +287,20 @@ export class RelayClient { }; } + private buildChannelMentionFilter( + channelId: string, + pubkey: string, + limit: number, + ): RelaySubscriptionFilter { + return { + kinds: [...HOME_MENTION_EVENT_KINDS], + "#h": [channelId], + "#p": [pubkey], + limit, + since: Math.floor(Date.now() / 1_000), + }; + } + private async subscribe( filter: RelaySubscriptionFilter, onEvent: (event: RelayEvent) => void, diff --git a/desktop/src/shared/api/relayClientShared.ts b/desktop/src/shared/api/relayClientShared.ts index ac5a7f326..010bbc3cb 100644 --- a/desktop/src/shared/api/relayClientShared.ts +++ b/desktop/src/shared/api/relayClientShared.ts @@ -2,11 +2,10 @@ import type { RelayEvent } from "@/shared/api/types"; export type RelaySubscriptionFilter = { kinds: number[]; - "#h"?: string[]; limit: number; since?: number; until?: number; -}; +} & Partial>; type HistorySubscription = { mode: "history"; diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index b13dd7e73..8deaf245b 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -9,6 +9,14 @@ export const KIND_FORUM_POST = 45001; export const KIND_FORUM_COMMENT = 45003; export const KIND_TYPING_INDICATOR = 20002; +// Keep this in sync with the Home-feed mention query in sprout-db. +export const HOME_MENTION_EVENT_KINDS = [ + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, + KIND_FORUM_POST, + KIND_FORUM_COMMENT, +] as const; + export const CHANNEL_EVENT_KINDS = [ KIND_DELETION, // 5 — NIP-09 event deletions KIND_REACTION, // 7 — NIP-25 reactions diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index f63b1db9a..d73b11f2f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1267,6 +1267,58 @@ function getThreadReferenceFromTags(tags: string[][]) { }; } +function appendMentionTags( + tags: string[][], + mentionPubkeys: string[] | undefined, + selfPubkey: string, +) { + const selfLower = selfPubkey.toLowerCase(); + const seen = new Set([selfLower]); + for (const pk of mentionPubkeys ?? []) { + const lower = pk.toLowerCase(); + if (seen.has(lower)) { + continue; + } + seen.add(lower); + tags.push(["p", lower]); + } +} + +function buildTopLevelMessageTags( + channelId: string, + mentionPubkeys: string[] | undefined, + selfPubkey: string, +) { + const tags: string[][] = [["h", channelId]]; + appendMentionTags(tags, mentionPubkeys, selfPubkey); + return tags; +} + +function buildReplyMessageTags( + channelId: string, + authorPubkey: string, + parentEventId: string, + rootEventId: string, + mentionPubkeys: string[] | undefined, +) { + // Preserve the reply tag ordering that the desktop message hooks already + // expect locally: author p, h, mention ps, then thread e-tags. + const tags: string[][] = [ + ["p", authorPubkey], + ["h", channelId], + ]; + appendMentionTags(tags, mentionPubkeys, authorPubkey); + + if (parentEventId === rootEventId) { + tags.push(["e", rootEventId, "", "reply"]); + return tags; + } + + tags.push(["e", rootEventId, "", "root"]); + tags.push(["e", parentEventId, "", "reply"]); + return tags; +} + function getMockMessageStore(channelId: string): RelayEvent[] { const existing = mockMessages.get(channelId); if (existing) { @@ -2934,16 +2986,27 @@ async function handleSendChannelMessage( channelId: string; content: string; parentEventId?: string | null; + kind?: number | null; mentionPubkeys?: string[]; }, config: E2eConfig | undefined, ): Promise { + const kind = args.kind ?? 9; const identity = getIdentity(config); if (!identity) { const createdAt = Math.floor(Date.now() / 1000); + const mockPubkey = getMockMemberPubkey(config); if (!args.parentEventId) { - const event = createMockEvent(9, args.content, [["h", args.channelId]]); + const event = createMockEvent( + kind, + args.content, + buildTopLevelMessageTags( + args.channelId, + args.mentionPubkeys, + mockPubkey, + ), + ); recordMockMessage(args.channelId, event); emitMockLiveEvent(args.channelId, event); @@ -2990,35 +3053,16 @@ async function handleSendChannelMessage( const event: RelayEvent = { id: crypto.randomUUID().replace(/-/g, ""), - pubkey: getMockMemberPubkey(config), + pubkey: mockPubkey, created_at: createdAt, - kind: 9, - tags: (() => { - const authorPubkey = getMockMemberPubkey(config); - // Match production tag ordering: author p, h, mention ps, then e-tags. - const tags: string[][] = [ - ["p", authorPubkey], - ["h", args.channelId], - ]; - // Best-effort client-side normalization (relay is authoritative). - const selfLower = authorPubkey.toLowerCase(); - const seen = new Set([selfLower]); - for (const pk of args.mentionPubkeys ?? []) { - const lower = pk.toLowerCase(); - if (!seen.has(lower)) { - seen.add(lower); - tags.push(["p", lower]); - } - } - // Thread e-tags come after mention p-tags. - if (rootEventId === args.parentEventId) { - tags.push(["e", rootEventId, "", "reply"]); - } else { - tags.push(["e", rootEventId, "", "root"]); - tags.push(["e", args.parentEventId, "", "reply"]); - } - return tags; - })(), + kind, + tags: buildReplyMessageTags( + args.channelId, + mockPubkey, + args.parentEventId, + rootEventId, + args.mentionPubkeys, + ), content: args.content.trim(), sig: "mocksig".repeat(20).slice(0, 128), }; @@ -3036,32 +3080,22 @@ async function handleSendChannelMessage( } const relayIdentity = getRelayIdentity(config); - const tags: string[][] = [ - ["p", relayIdentity.pubkey], - ["h", args.channelId], - ]; - - // Add mention p-tags (deduplicated, excluding self). - const selfLower = relayIdentity.pubkey.toLowerCase(); - const seen = new Set([selfLower]); - for (const pk of args.mentionPubkeys ?? []) { - const lower = pk.toLowerCase(); - if (!seen.has(lower)) { - seen.add(lower); - tags.push(["p", lower]); - } - } - - // Add thread e-tags if replying. - if (args.parentEventId) { - // Simplified: treat parent as both root and reply for direct replies. - // The relay's NIP-10 resolver handles ancestry validation. - tags.push(["e", args.parentEventId, "", "root"]); - tags.push(["e", args.parentEventId, "", "reply"]); - } + const tags = args.parentEventId + ? buildReplyMessageTags( + args.channelId, + relayIdentity.pubkey, + args.parentEventId, + args.parentEventId, + args.mentionPubkeys, + ) + : buildTopLevelMessageTags( + args.channelId, + args.mentionPubkeys, + relayIdentity.pubkey, + ); const result = await submitSignedEvent(config, { - kind: 9, + kind, content: args.content.trim(), tags, }); diff --git a/desktop/tests/e2e/integration.spec.ts b/desktop/tests/e2e/integration.spec.ts index 1a8392712..c513d4923 100644 --- a/desktop/tests/e2e/integration.spec.ts +++ b/desktop/tests/e2e/integration.spec.ts @@ -1,6 +1,6 @@ import { expect, test, type Browser } from "@playwright/test"; -import { installRelayBridge } from "../helpers/bridge"; +import { installRelayBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { assertRelaySeeded } from "../helpers/seed"; async function createStream( @@ -32,6 +32,130 @@ async function closeChannelManagement(page: import("@playwright/test").Page) { await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible(); } +async function enableDesktopNotifications( + page: import("@playwright/test").Page, +) { + await page.getByTestId("open-settings").click(); + await expect(page.getByTestId("settings-view")).toBeVisible(); + await page.getByTestId("settings-nav-notifications").click(); + await expect(page.getByTestId("settings-notifications")).toBeVisible(); + await page.getByTestId("notifications-desktop-toggle").click(); + await expect(page.getByTestId("notifications-desktop-state")).toContainText( + "On", + ); + await page.getByTestId("settings-close").click(); +} + +async function sendChannelMessage( + page: import("@playwright/test").Page, + { + channelName, + content, + kind, + mentionPubkeys, + }: { + channelName: string; + content: string; + kind?: number | null; + mentionPubkeys?: string[]; + }, +) { + await page.evaluate( + async ({ + channelName: targetChannelName, + content, + kind, + mentionPubkeys, + }) => { + const tauriWindow = window as Window & { + __TAURI_INTERNALS__?: { + invoke: ( + command: string, + payload?: Record, + ) => Promise; + }; + }; + + 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, + parentEventId: null, + mediaTags: null, + mentionPubkeys: mentionPubkeys ?? null, + kind: kind ?? null, + }); + }, + { channelName, content, kind, mentionPubkeys }, + ); +} + +async function joinChannel( + page: import("@playwright/test").Page, + channelName: string, +) { + await page.evaluate(async (targetChannelName) => { + const tauriWindow = window as Window & { + __TAURI_INTERNALS__?: { + invoke: ( + command: string, + payload?: Record, + ) => Promise; + }; + }; + + 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("join_channel", { + channelId: channel.id, + }); + }, channelName); +} + +async function getLoggedNotifications(page: import("@playwright/test").Page) { + return page.evaluate(() => { + const win = window as Window & { + __SPROUT_E2E_NOTIFICATIONS__?: Array<{ + body: string | null; + title: string; + }>; + }; + + return win.__SPROUT_E2E_NOTIFICATIONS__ ?? []; + }); +} + +async function getLoggedNotificationCount( + page: import("@playwright/test").Page, +) { + return (await getLoggedNotifications(page)).length; +} + test.beforeAll(async () => { await assertRelaySeeded(); }); @@ -125,6 +249,134 @@ test("message delivery across users", async ({ } }); +test("live mentions refetch the home feed without waiting for polling", async ({ + browser, +}: { + browser: Browser; +}) => { + const stamp = Date.now(); + const targetContext = await browser.newContext(); + const senderContext = await browser.newContext(); + const targetPage = await targetContext.newPage(); + const senderPage = await senderContext.newPage(); + + try { + await installRelayBridge(targetPage, "tyler"); + await installRelayBridge(senderPage, "alice"); + + await targetPage.goto("/"); + await senderPage.goto("/"); + await enableDesktopNotifications(targetPage); + + await targetPage.getByTestId("channel-general").click(); + await expect(targetPage.getByTestId("chat-title")).toHaveText("general"); + + const message = `Heads up @tyler live mention ${stamp}`; + await sendChannelMessage(senderPage, { + channelName: "general", + content: message, + mentionPubkeys: [TEST_IDENTITIES.tyler.pubkey], + }); + + await expect(targetPage.getByTestId("message-timeline")).toContainText( + message, + ); + await expect(targetPage.getByTestId("sidebar-home-count")).toHaveText("1", { + timeout: 5_000, + }); + + await expect + .poll(() => getLoggedNotificationCount(targetPage), { timeout: 5_000 }) + .toBe(1); + + const notifications = await getLoggedNotifications(targetPage); + + expect(notifications).toEqual([ + { + body: message, + title: "@Mention in #general", + }, + ]); + + await targetPage.getByRole("button", { name: "Home" }).click(); + await expect(targetPage.getByTestId("chat-title")).toHaveText("Home"); + await expect(targetPage.getByTestId("sidebar-home-count")).toHaveCount(0); + await expect + .poll(() => getLoggedNotificationCount(targetPage), { timeout: 3_000 }) + .toBe(1); + } finally { + await targetContext.close(); + await senderContext.close(); + } +}); + +test("live forum mentions refetch the home feed without waiting for polling", async ({ + browser, +}: { + browser: Browser; +}) => { + const stamp = Date.now(); + const targetContext = await browser.newContext(); + const senderContext = await browser.newContext(); + const targetPage = await targetContext.newPage(); + const senderPage = await senderContext.newPage(); + + try { + await installRelayBridge(targetPage, "tyler"); + await installRelayBridge(senderPage, "alice"); + + await targetPage.goto("/"); + await senderPage.goto("/"); + await enableDesktopNotifications(targetPage); + + await targetPage.getByTestId("channel-general").click(); + await expect(targetPage.getByTestId("chat-title")).toHaveText("general"); + await joinChannel(senderPage, "watercooler"); + + const message = `Forum ping @tyler ${stamp}`; + await sendChannelMessage(senderPage, { + channelName: "watercooler", + content: message, + kind: 45001, + mentionPubkeys: [TEST_IDENTITIES.tyler.pubkey], + }); + + await expect(targetPage.getByTestId("sidebar-home-count")).toHaveText("1", { + timeout: 5_000, + }); + + await expect + .poll(() => getLoggedNotificationCount(targetPage), { timeout: 5_000 }) + .toBe(1); + + const notifications = await getLoggedNotifications(targetPage); + + expect(notifications).toEqual([ + { + body: message, + title: "@Mention in #watercooler", + }, + ]); + + await targetPage.getByRole("button", { name: "Home" }).click(); + await expect(targetPage.getByTestId("chat-title")).toHaveText("Home"); + await expect( + targetPage.getByRole("heading", { name: "Mentions" }), + ).toBeVisible(); + const mentionsSection = targetPage.locator("section").filter({ + has: targetPage.getByRole("heading", { name: "Mentions" }), + }); + await expect(mentionsSection).toContainText(message); + await expect(targetPage.getByTestId("sidebar-home-count")).toHaveCount(0); + await expect + .poll(() => getLoggedNotificationCount(targetPage), { timeout: 3_000 }) + .toBe(1); + } finally { + await targetContext.close(); + await senderContext.close(); + } +}); + test("DM channel appears in sidebar", async ({ page }) => { await installRelayBridge(page, "tyler"); await page.goto("/");