diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 85fbde981c..4e3d55d987 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -6,8 +6,11 @@ "permissions": [ "core:default", "core:webview:allow-set-webview-zoom", + "core:window:allow-set-badge-count", + "core:window:allow-set-focus", "core:window:allow-start-dragging", "core:window:allow-toggle-maximize", + "core:window:allow-unminimize", "core:window:allow-show", "core:window:allow-close", "notification:default", diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index deb391b6c7..f6401af23c 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -17,6 +17,12 @@ import { import { useUnreadChannels } from "@/features/channels/useUnreadChannels"; import { HomeScreen } from "@/features/home/ui/HomeScreen"; import { useHomeFeedNotifications } from "@/features/notifications/hooks"; +import { + listenForDesktopNotificationActions, + revealDesktopAppWindow, + setDesktopAppBadgeCount, + type DesktopNotificationTarget, +} from "@/features/notifications/lib/desktop"; import { usePresenceSession } from "@/features/presence/hooks"; import { useProfileQuery } from "@/features/profile/hooks"; import type { SettingsSection } from "@/features/settings/ui/SettingsPanels"; @@ -50,6 +56,23 @@ const WorkflowsScreen = React.lazy(async () => { return { default: module.WorkflowsScreen }; }); +function toSearchAnchor(target: DesktopNotificationTarget): SearchHit | null { + if (!target.eventId) { + return null; + } + + return { + eventId: target.eventId, + content: target.content ?? "", + kind: target.kind ?? 9, + pubkey: target.pubkey ?? "", + channelId: target.channelId, + channelName: target.channelName ?? null, + createdAt: target.createdAt ?? Math.floor(Date.now() / 1_000), + score: 0, + }; +} + export function AppShell() { useWebviewZoomShortcuts(); @@ -234,7 +257,7 @@ export function AppShell() { ); }, []); - const handleOpenSearchResult = React.useCallback( + const openEventAnchor = React.useCallback( (hit: SearchHit) => { setSearchAnchor(hit); setSearchAnchorChannelId(hit.channelId); @@ -272,6 +295,38 @@ export function AppShell() { [handleOpenChannel], ); + const handleOpenSearchResult = React.useCallback( + (hit: SearchHit) => { + openEventAnchor(hit); + }, + [openEventAnchor], + ); + + const handleDesktopNotificationAction = React.useEffectEvent( + async (target: DesktopNotificationTarget) => { + await revealDesktopAppWindow(); + + if (!target.channelId) { + setSearchAnchor(null); + setSearchAnchorChannelId(null); + setSearchAnchorEvent(null); + selectView("home"); + return; + } + + const anchor = toSearchAnchor(target); + if (!anchor) { + setSearchAnchor(null); + setSearchAnchorChannelId(null); + setSearchAnchorEvent(null); + await handleOpenChannel(target.channelId); + return; + } + + openEventAnchor(anchor); + }, + ); + React.useEffect(() => { let isCancelled = false; @@ -286,6 +341,35 @@ export function AppShell() { }; }, []); + React.useEffect(() => { + void setDesktopAppBadgeCount(homeBadgeCount); + }, [homeBadgeCount]); + + React.useEffect(() => { + let isCancelled = false; + let cleanup = () => {}; + + void listenForDesktopNotificationActions((target) => { + if (isCancelled) { + return; + } + + void handleDesktopNotificationAction(target); + }).then((dispose) => { + if (isCancelled) { + dispose(); + return; + } + + cleanup = dispose; + }); + + return () => { + isCancelled = true; + cleanup(); + }; + }, []); + React.useLayoutEffect(() => { if (settingsOpen) { return; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 678822c6df..de7c3c9547 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -185,6 +185,24 @@ export function ChannelScreen({ timelineMessages.find((message) => message.id === editTargetId) ?? null, [editTargetId, timelineMessages], ); + const forumTargetEventId = + activeChannel && + activeChannel.channelType === "forum" && + searchAnchorChannelId === activeChannel.id + ? (searchAnchor?.eventId ?? null) + : null; + const forumTargetEventKind = + activeChannel && + activeChannel.channelType === "forum" && + searchAnchorChannelId === activeChannel.id + ? (searchAnchor?.kind ?? null) + : null; + const forumTargetEvent = + activeChannel && + activeChannel.channelType === "forum" && + searchAnchorChannelId === activeChannel.id + ? searchAnchorEvent + : null; const { handleCancelEdit, @@ -377,6 +395,10 @@ export function ChannelScreen({ ) : ( diff --git a/desktop/src/features/forum/ui/ForumThreadPanel.tsx b/desktop/src/features/forum/ui/ForumThreadPanel.tsx index 0d30eee6f3..6052eb5a70 100644 --- a/desktop/src/features/forum/ui/ForumThreadPanel.tsx +++ b/desktop/src/features/forum/ui/ForumThreadPanel.tsx @@ -44,8 +44,10 @@ type ForumThreadPanelProps = { onReply: (content: string, mentionPubkeys: string[]) => void; onDeletePost?: (eventId: string) => void; onDeleteReply?: (eventId: string) => void; + onTargetReached?: (eventId: string) => void; canDeletePost?: boolean; isDeletingPost?: boolean; + targetEventId?: string | null; }; function canDeleteReply( @@ -119,7 +121,7 @@ function ReplyRow({ const replyMentionNames = resolveMentionNames(reply.tags, profiles); return ( -
+
(null); const [isDeletePostOpen, setIsDeletePostOpen] = React.useState(false); @@ -198,6 +202,23 @@ export function ForumThreadPanel({ [channels], ); + React.useEffect(() => { + if (!thread || !targetEventId) { + return; + } + + const targetElement = + scrollRef.current?.querySelector( + `[data-forum-event-id="${targetEventId}"]`, + ) ?? null; + if (!targetElement) { + return; + } + + targetElement.scrollIntoView({ block: "center" }); + onTargetReached?.(targetEventId); + }, [onTargetReached, targetEventId, thread]); + if (isLoading || !thread) { return (
@@ -253,6 +274,7 @@ export function ForumThreadPanel({ "group border-b border-border/60 p-4", isDeletingPost && "pointer-events-none opacity-50", )} + data-forum-event-id={post.eventId} >
void; + targetEvent: RelayEvent | null; + targetEventId: string | null; + targetEventKind: number | null; }; function canDelete(postPubkey: string, currentPubkey?: string): boolean { @@ -30,7 +36,14 @@ function canDelete(postPubkey: string, currentPubkey?: string): boolean { return postPubkey.toLowerCase() === currentPubkey.toLowerCase(); } -export function ForumView({ channel, currentPubkey }: ForumViewProps) { +export function ForumView({ + channel, + currentPubkey, + onTargetReached, + targetEvent, + targetEventId, + targetEventKind, +}: ForumViewProps) { const [expandedPostId, setExpandedPostId] = React.useState( null, ); @@ -84,6 +97,22 @@ export function ForumView({ channel, currentPubkey }: ForumViewProps) { ), [profileQuery.data, profilesQuery.data?.profiles], ); + const targetThreadId = React.useMemo(() => { + if (!targetEventId) { + return null; + } + + if (targetEventKind === KIND_FORUM_POST) { + return targetEventId; + } + + if (!targetEvent || targetEvent.kind !== KIND_FORUM_COMMENT) { + return null; + } + + const thread = getThreadReference(targetEvent.tags); + return thread.rootId ?? thread.parentId ?? null; + }, [targetEvent, targetEventId, targetEventKind]); // Reset expanded post when channel changes const previousChannelIdRef = React.useRef(channel.id); @@ -93,6 +122,14 @@ export function ForumView({ channel, currentPubkey }: ForumViewProps) { setIsComposerOpen(false); } + React.useEffect(() => { + if (!targetThreadId || expandedPostId === targetThreadId) { + return; + } + + setExpandedPostId(targetThreadId); + }, [expandedPostId, targetThreadId]); + if (expandedPostId) { const threadPost = threadQuery.data?.post; const canDeleteExpandedPost = threadPost @@ -124,7 +161,9 @@ export function ForumView({ channel, currentPubkey }: ForumViewProps) { mentionPubkeys, }); }} + onTargetReached={onTargetReached} profiles={profiles} + targetEventId={targetEventId} thread={threadQuery.data} /> ); diff --git a/desktop/src/features/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts index 9f481df097..3f008cf383 100644 --- a/desktop/src/features/notifications/hooks.ts +++ b/desktop/src/features/notifications/hooks.ts @@ -2,6 +2,12 @@ import * as React from "react"; import { useHomeFeedQuery } from "@/features/home/hooks"; import type { FeedItem, HomeFeedResponse } from "@/shared/api/types"; +import { + collectHomeAlertItems, + eligibleFeedNotificationItems, + notificationBody, + notificationTitle, +} from "./lib/feed"; import { getDesktopNotificationPermissionState, requestDesktopNotificationAccess, @@ -13,7 +19,6 @@ export type { DesktopNotificationPermissionState } from "./lib/desktop"; const NOTIFICATION_SETTINGS_STORAGE_KEY = "sprout-notification-settings.v1"; const HOME_FEED_SEEN_STORAGE_KEY = "sprout-home-feed-seen.v1"; -const FEED_NOTIFICATION_BODY_MAX_LENGTH = 140; const HOME_FEED_SEEN_MAX_ITEMS = 500; export type NotificationSettings = { @@ -155,58 +160,6 @@ function mergeSeenFeedIds(current: string[], nextIds: readonly string[]) { : values.slice(values.length - HOME_FEED_SEEN_MAX_ITEMS); } -function notificationTitle(item: FeedItem) { - const channelLabel = item.channelName.trim() - ? ` in #${item.channelName.trim()}` - : ""; - - if (item.category === "mention") { - return `@Mention${channelLabel}`; - } - - if (item.kind === 46010) { - return `Approval Requested${channelLabel}`; - } - - return `Needs Action${channelLabel}`; -} - -function notificationBody(item: FeedItem) { - const content = item.content.trim(); - const fallback = - item.kind === 46010 - ? "A workflow is waiting for your approval." - : "Something in Sprout needs your attention."; - const body = content.length > 0 ? content : fallback; - - if (body.length <= FEED_NOTIFICATION_BODY_MAX_LENGTH) { - return body; - } - - return `${body.slice(0, FEED_NOTIFICATION_BODY_MAX_LENGTH - 3).trimEnd()}...`; -} - -function collectHomeAlertItems(feed: HomeFeedResponse) { - return [...feed.feed.mentions, ...feed.feed.needsAction]; -} - -function eligibleFeedNotificationItems( - feed: HomeFeedResponse, - options: Pick, -) { - const items: FeedItem[] = []; - - if (options.mentions) { - items.push(...feed.feed.mentions); - } - - if (options.needsAction) { - items.push(...feed.feed.needsAction); - } - - return items.sort((left, right) => left.createdAt - right.createdAt); -} - export function useNotificationSettings(pubkey?: string) { const normalizedPubkey = pubkey?.trim().toLowerCase() ?? ""; const [settings, setSettings] = React.useState(() => @@ -345,6 +298,15 @@ export function useFeedDesktopNotifications( async (item: FeedItem) => { await sendDesktopNotification({ body: notificationBody(item), + target: { + channelId: item.channelId, + channelName: item.channelName, + content: item.content, + createdAt: item.createdAt, + eventId: item.id, + kind: item.kind, + pubkey: item.pubkey, + }, title: notificationTitle(item), }); }, diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index 1dab39c177..f9486949b7 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -1,23 +1,109 @@ import { isTauri } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; import { isPermissionGranted, + onAction, requestPermission, - sendNotification, } from "@tauri-apps/plugin-notification"; export type DesktopNotificationPermissionState = | NotificationPermission | "unsupported"; +export type DesktopNotificationTarget = { + channelId: string | null; + channelName?: string | null; + content?: string; + createdAt?: number | null; + eventId: string | null; + kind: number | null; + pubkey?: string; +}; + type DesktopNotificationPayload = { body?: string; + target?: DesktopNotificationTarget; title: string; }; +const DESKTOP_NOTIFICATION_ACTION_EVENT = "sprout:desktop-notification-action"; + +type DesktopNotificationOptions = NotificationOptions & { + extra?: Record; +}; + +type TestWindow = Window & { + __SPROUT_E2E_APP_BADGE_COUNT__?: number; +}; + function hasNotificationApi() { return typeof window !== "undefined" && "Notification" in window; } +function notificationExtra( + target: DesktopNotificationTarget | undefined, +): Record | undefined { + if (!target) { + return undefined; + } + + return { + sproutNotificationTarget: target, + }; +} + +function parseNotificationTarget( + value: unknown, +): DesktopNotificationTarget | null { + if (!value || typeof value !== "object") { + return null; + } + + const candidate = value as Partial; + const channelId = + typeof candidate.channelId === "string" ? candidate.channelId : null; + const channelName = + typeof candidate.channelName === "string" ? candidate.channelName : null; + const content = + typeof candidate.content === "string" ? candidate.content : undefined; + const createdAt = + typeof candidate.createdAt === "number" ? candidate.createdAt : null; + const eventId = + typeof candidate.eventId === "string" ? candidate.eventId : null; + const kind = typeof candidate.kind === "number" ? candidate.kind : null; + const pubkey = + typeof candidate.pubkey === "string" ? candidate.pubkey : undefined; + + if (!channelId && !eventId) { + return null; + } + + return { + channelId, + channelName, + content, + createdAt, + eventId, + kind, + pubkey, + }; +} + +function dispatchDesktopNotificationTarget(target: DesktopNotificationTarget) { + if (typeof window === "undefined") { + return; + } + + window.dispatchEvent( + new CustomEvent( + DESKTOP_NOTIFICATION_ACTION_EVENT, + { + detail: target, + }, + ), + ); +} + export async function getDesktopNotificationPermissionState(): Promise { if (!hasNotificationApi()) { return "unsupported"; @@ -46,6 +132,85 @@ export async function requestDesktopNotificationAccess(): Promise void, +): Promise<() => void> { + if (typeof window === "undefined") { + return () => {}; + } + + function handleNotificationAction(event: Event) { + const customEvent = event as CustomEvent; + onTarget(customEvent.detail); + } + + window.addEventListener( + DESKTOP_NOTIFICATION_ACTION_EVENT, + handleNotificationAction, + ); + + let pluginListener: { unregister: () => Promise } | null = null; + + if (isTauri()) { + try { + pluginListener = await onAction((notification) => { + const target = parseNotificationTarget( + notification.extra?.sproutNotificationTarget, + ); + if (!target) { + return; + } + + dispatchDesktopNotificationTarget(target); + }); + } catch { + pluginListener = null; + } + } + + return () => { + window.removeEventListener( + DESKTOP_NOTIFICATION_ACTION_EVENT, + handleNotificationAction, + ); + void pluginListener?.unregister(); + }; +} + +export async function setDesktopAppBadgeCount(count: number): Promise { + if (typeof window !== "undefined") { + (window as TestWindow).__SPROUT_E2E_APP_BADGE_COUNT__ = count; + } + + if (!isTauri()) { + return; + } + + try { + await getCurrentWindow().setBadgeCount(count > 0 ? count : undefined); + } catch { + // Ignore unsupported platforms and best-effort badge sync failures. + } +} + +export async function revealDesktopAppWindow(): Promise { + if (!isTauri()) { + if (typeof window !== "undefined") { + window.focus(); + } + return; + } + + try { + const currentWindow = getCurrentWindow(); + await currentWindow.unminimize(); + await currentWindow.show(); + await currentWindow.setFocus(); + } catch { + // Best effort only. + } +} + export async function sendDesktopNotification( payload: DesktopNotificationPayload, ): Promise { @@ -53,9 +218,18 @@ export async function sendDesktopNotification( return false; } - sendNotification({ + const notification = new window.Notification(payload.title, { body: payload.body, - title: payload.title, - }); + extra: notificationExtra(payload.target), + } as DesktopNotificationOptions); + + const target = payload.target; + if (!isTauri() && target) { + notification.onclick = () => { + dispatchDesktopNotificationTarget(target); + notification.close(); + }; + } + return true; } diff --git a/desktop/src/features/notifications/lib/feed.ts b/desktop/src/features/notifications/lib/feed.ts new file mode 100644 index 0000000000..6572de63ef --- /dev/null +++ b/desktop/src/features/notifications/lib/feed.ts @@ -0,0 +1,55 @@ +import type { FeedItem, HomeFeedResponse } from "@/shared/api/types"; + +const FEED_NOTIFICATION_BODY_MAX_LENGTH = 140; + +export function notificationTitle(item: FeedItem) { + const channelLabel = item.channelName.trim() + ? ` in #${item.channelName.trim()}` + : ""; + + if (item.category === "mention") { + return `@Mention${channelLabel}`; + } + + if (item.kind === 46010) { + return `Approval Requested${channelLabel}`; + } + + return `Needs Action${channelLabel}`; +} + +export function notificationBody(item: FeedItem) { + const content = item.content.trim(); + const fallback = + item.kind === 46010 + ? "A workflow is waiting for your approval." + : "Something in Sprout needs your attention."; + const body = content.length > 0 ? content : fallback; + + if (body.length <= FEED_NOTIFICATION_BODY_MAX_LENGTH) { + return body; + } + + return `${body.slice(0, FEED_NOTIFICATION_BODY_MAX_LENGTH - 3).trimEnd()}...`; +} + +export function collectHomeAlertItems(feed: HomeFeedResponse) { + return [...feed.feed.mentions, ...feed.feed.needsAction]; +} + +export function eligibleFeedNotificationItems( + feed: HomeFeedResponse, + options: { mentions: boolean; needsAction: boolean }, +) { + const items: FeedItem[] = []; + + if (options.mentions) { + items.push(...feed.feed.mentions); + } + + if (options.needsAction) { + items.push(...feed.feed.needsAction); + } + + return items.sort((left, right) => left.createdAt - right.createdAt); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 7c4eaaa8f0..7e989c48c3 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -161,6 +161,52 @@ type RawHomeFeedResponse = { }; }; +type RawThreadSummary = { + reply_count: number; + descendant_count: number; + last_reply_at: number | null; + participants: string[]; +}; + +type RawForumPost = { + event_id: string; + pubkey: string; + content: string; + kind: number; + created_at: number; + channel_id: string; + tags: string[][]; + thread_summary: RawThreadSummary | null; + reactions: unknown; +}; + +type RawForumPostsResponse = { + messages: RawForumPost[]; + next_cursor: number | null; +}; + +type RawForumReply = { + event_id: string; + pubkey: string; + content: string; + kind: number; + created_at: number; + channel_id: string; + tags: string[][]; + parent_event_id: string | null; + root_event_id: string | null; + depth: number; + broadcast: boolean; + reactions: unknown; +}; + +type RawForumThreadResponse = { + root: RawForumPost; + replies: RawForumReply[]; + total_replies: number; + next_cursor: string | null; +}; + type RawSearchHit = { event_id: string; content: string; @@ -1470,7 +1516,35 @@ function getMockMessageStore(channelId: string): RelayEvent[] { sig: "mocksig".repeat(20).slice(0, 128), }, ] - : []; + : channelId === "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11" + ? [ + { + id: "mock-forum-release-thread", + pubkey: + "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f", + created_at: Math.floor(Date.now() / 1000) - 90 * 60, + kind: 45001, + tags: [["h", channelId]], + content: "Release checklist: async feedback thread.", + sig: "mocksig".repeat(20).slice(0, 128), + }, + { + id: "mock-forum-release-reply", + pubkey: ALICE_PUBKEY, + created_at: Math.floor(Date.now() / 1000) - 80 * 60, + kind: 45003, + tags: buildReplyMessageTags( + channelId, + ALICE_PUBKEY, + "mock-forum-release-thread", + "mock-forum-release-thread", + undefined, + ), + content: "Looks good to me. We should ship it.", + sig: "mocksig".repeat(20).slice(0, 128), + }, + ] + : []; mockMessages.set(channelId, seeded); return seeded; @@ -1536,6 +1610,115 @@ function emitMockTypingIndicator(channelId: string, pubkey: string) { return event; } +function toRawForumPost( + event: RelayEvent, + channelId: string, + threadSummary: RawThreadSummary | null, +): RawForumPost { + return { + event_id: event.id, + pubkey: event.pubkey, + content: event.content, + kind: event.kind, + created_at: event.created_at, + channel_id: channelId, + tags: event.tags, + thread_summary: threadSummary, + reactions: null, + }; +} + +function toRawForumReply(event: RelayEvent, channelId: string): RawForumReply { + const thread = getThreadReferenceFromTags(event.tags); + + return { + event_id: event.id, + pubkey: event.pubkey, + content: event.content, + kind: event.kind, + created_at: event.created_at, + channel_id: channelId, + tags: event.tags, + parent_event_id: thread.parentEventId, + root_event_id: thread.rootEventId, + depth: + thread.rootEventId && thread.parentEventId !== thread.rootEventId ? 2 : 1, + broadcast: false, + reactions: null, + }; +} + +async function handleGetForumPosts(args: { + channelId: string; + limit?: number | null; + before?: number | null; +}): Promise { + const events = getMockMessageStore(args.channelId); + const posts = events + .filter((event) => event.kind === 45001) + .filter((event) => (args.before ? event.created_at < args.before : true)) + .sort((left, right) => right.created_at - left.created_at) + .slice(0, args.limit ?? 50) + .map((event) => { + const replies = events.filter((candidate) => { + if (candidate.kind !== 45003) { + return false; + } + + const thread = getThreadReferenceFromTags(candidate.tags); + return (thread.rootEventId ?? thread.parentEventId) === event.id; + }); + + return toRawForumPost(event, args.channelId, { + reply_count: replies.length, + descendant_count: replies.length, + last_reply_at: + replies.length > 0 ? replies[replies.length - 1].created_at : null, + participants: [...new Set(replies.map((reply) => reply.pubkey))], + }); + }); + + return { + messages: posts, + next_cursor: null, + }; +} + +async function handleGetForumThread(args: { + channelId: string; + eventId: string; +}): Promise { + const events = getMockMessageStore(args.channelId); + const root = events.find( + (event) => event.id === args.eventId && event.kind === 45001, + ); + if (!root) { + throw new Error(`Mock forum thread not found: ${args.eventId}`); + } + + const replies = events + .filter((event) => event.kind === 45003) + .filter((event) => { + const thread = getThreadReferenceFromTags(event.tags); + return (thread.rootEventId ?? thread.parentEventId) === root.id; + }) + .sort((left, right) => left.created_at - right.created_at) + .map((event) => toRawForumReply(event, args.channelId)); + + return { + root: toRawForumPost(root, args.channelId, { + reply_count: replies.length, + descendant_count: replies.length, + last_reply_at: + replies.length > 0 ? replies[replies.length - 1].created_at : null, + participants: [...new Set(replies.map((reply) => reply.pubkey))], + }), + replies, + total_replies: replies.length, + next_cursor: null, + }; +} + function createMockEvent( kind: number, content: string, @@ -3734,6 +3917,14 @@ export function maybeInstallE2eTauriMocks() { payload as Parameters[0], activeConfig, ); + case "get_forum_posts": + return handleGetForumPosts( + payload as Parameters[0], + ); + case "get_forum_thread": + return handleGetForumThread( + payload as Parameters[0], + ); case "send_channel_message": return handleSendChannelMessage( payload as Parameters[0], diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 49cd85518c..8904f0b112 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -69,8 +69,19 @@ test("updates presence from the profile menu", async ({ page }) => { test("notification settings drive the Home badge and desktop alerts", async ({ page, }) => { + async function getAppBadgeCount() { + return page.evaluate(() => { + const win = window as Window & { + __SPROUT_E2E_APP_BADGE_COUNT__?: number; + }; + + return win.__SPROUT_E2E_APP_BADGE_COUNT__ ?? 0; + }); + } + await page.goto("/"); await expect(page.getByTestId("sidebar-home-count")).toHaveCount(0); + await expect.poll(getAppBadgeCount).toBe(0); await openSettings(page, "notifications"); await expect(page.getByTestId("settings-notifications")).toBeVisible(); @@ -122,6 +133,7 @@ test("notification settings drive the Home badge and desktop alerts", async ({ }); await expect(page.getByTestId("sidebar-home-count")).toHaveText("1"); + await expect.poll(getAppBadgeCount).toBe(1); await expect .poll(() => @@ -156,20 +168,112 @@ test("notification settings drive the Home badge and desktop alerts", async ({ }, ]); + const clickedNotification = await page.evaluate(() => { + const win = window as Window & { + __SPROUT_E2E_CLICK_NOTIFICATION__?: (index: number) => boolean; + }; + + return win.__SPROUT_E2E_CLICK_NOTIFICATION__?.(0) ?? false; + }); + expect(clickedNotification).toBe(true); + + await expect(page.getByTestId("chat-title")).toHaveText("engineering"); + await expect(page.getByTestId("message-timeline")).toContainText( + "Please review the rollout checklist.", + ); + await openSettings(page, "notifications"); await page.getByTestId("notifications-home-badge-toggle").click(); await page.getByTestId("settings-close").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect(page.getByTestId("chat-title")).toHaveText("engineering"); await expect(page.getByTestId("sidebar-home-count")).toHaveCount(0); + await expect.poll(getAppBadgeCount).toBe(0); await openSettings(page, "notifications"); await page.getByTestId("notifications-home-badge-toggle").click(); await page.getByTestId("settings-close").click(); await expect(page.getByTestId("sidebar-home-count")).toHaveText("1"); + await expect.poll(getAppBadgeCount).toBe(1); await page.getByRole("button", { name: "Home" }).click(); await expect(page.getByTestId("chat-title")).toHaveText("Home"); await expect(page.getByTestId("sidebar-home-count")).toHaveCount(0); + await expect.poll(getAppBadgeCount).toBe(0); +}); + +test("desktop notification clicks open the matching forum thread", async ({ + page, +}) => { + await page.goto("/"); + + await openSettings(page, "notifications"); + await page.getByTestId("notifications-desktop-toggle").click(); + await expect(page.getByTestId("notifications-desktop-state")).toContainText( + "On", + ); + await page.getByTestId("settings-close").click(); + await expect(page.getByTestId("chat-title")).toHaveText("Home"); + + await page.evaluate(() => { + const win = window as Window & { + __SPROUT_E2E_PUSH_MOCK_FEED_ITEM__?: (item: { + category: "mention" | "needs_action" | "activity" | "agent_activity"; + channel_id: string | null; + channel_name: string; + content: string; + created_at: number; + id: string; + kind: number; + pubkey: string; + tags: string[][]; + }) => unknown; + }; + + win.__SPROUT_E2E_PUSH_MOCK_FEED_ITEM__?.({ + category: "mention", + channel_id: "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11", + channel_name: "watercooler", + content: "Release checklist: async feedback thread.", + created_at: Math.floor(Date.now() / 1000) + 5, + id: "mock-forum-release-thread", + kind: 45001, + pubkey: + "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f", + tags: [["h", "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11"]], + }); + }); + + await expect + .poll(() => + page.evaluate(() => { + const win = window as Window & { + __SPROUT_E2E_NOTIFICATIONS__?: Array<{ + body: string | null; + title: string; + }>; + }; + + return win.__SPROUT_E2E_NOTIFICATIONS__?.length ?? 0; + }), + ) + .toBe(1); + + const clickedNotification = await page.evaluate(() => { + const win = window as Window & { + __SPROUT_E2E_CLICK_NOTIFICATION__?: (index: number) => boolean; + }; + + return win.__SPROUT_E2E_CLICK_NOTIFICATION__?.(0) ?? false; + }); + expect(clickedNotification).toBe(true); + + await expect(page.getByTestId("chat-title")).toHaveText("watercooler"); + await expect( + page.getByRole("button", { name: "Back to posts" }), + ).toBeVisible(); + await expect( + page.getByText("Release checklist: async feedback thread."), + ).toBeVisible(); }); test("opens settings with the keyboard shortcut and updates theme", async ({ diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 14ed5f2f39..b6e27e7fb2 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -70,6 +70,7 @@ export async function installBridge(page: Page, options: BridgeOptions) { body: string | null; title: string; }> = []; + const notificationInstances: MockNotification[] = []; class MockNotification extends EventTarget { static permission: NotificationPermission = "granted"; @@ -79,12 +80,14 @@ export async function installBridge(page: Page, options: BridgeOptions) { } body: string | null; + onclick: ((event: Event) => void) | null = null; title: string; constructor(title: string, options?: NotificationOptions) { super(); this.title = title; this.body = options?.body ?? null; + notificationInstances.push(this); notificationLog.push({ body: this.body, title: this.title, @@ -102,6 +105,8 @@ export async function installBridge(page: Page, options: BridgeOptions) { const testWindow = window as Window & { __SPROUT_E2E__?: Record; + __SPROUT_E2E_APP_BADGE_COUNT__?: number; + __SPROUT_E2E_CLICK_NOTIFICATION__?: (index: number) => boolean; __SPROUT_E2E_NOTIFICATIONS__?: Array<{ body: string | null; title: string; @@ -117,6 +122,18 @@ export async function installBridge(page: Page, options: BridgeOptions) { relayHttpUrl: relayHttpUrl ?? currentConfig.relayHttpUrl, relayWsUrl: relayWsUrl ?? currentConfig.relayWsUrl, }; + testWindow.__SPROUT_E2E_APP_BADGE_COUNT__ = 0; + testWindow.__SPROUT_E2E_CLICK_NOTIFICATION__ = (index: number) => { + const notification = notificationInstances[index]; + if (!notification) { + return false; + } + + const event = new Event("click"); + notification.dispatchEvent(event); + notification.onclick?.(event); + return true; + }; testWindow.__SPROUT_E2E_NOTIFICATIONS__ = notificationLog; }, {