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;
},
{