Add desktop badge and notification ground work (#257)

This commit is contained in:
Wes
2026-04-07 10:26:13 -07:00
committed by GitHub
parent 853ebe5bfd
commit 35588a0594
11 changed files with 736 additions and 63 deletions

View File

@@ -6,8 +6,11 @@
"permissions": [ "permissions": [
"core:default", "core:default",
"core:webview:allow-set-webview-zoom", "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-start-dragging",
"core:window:allow-toggle-maximize", "core:window:allow-toggle-maximize",
"core:window:allow-unminimize",
"core:window:allow-show", "core:window:allow-show",
"core:window:allow-close", "core:window:allow-close",
"notification:default", "notification:default",

View File

@@ -17,6 +17,12 @@ import {
import { useUnreadChannels } from "@/features/channels/useUnreadChannels"; import { useUnreadChannels } from "@/features/channels/useUnreadChannels";
import { HomeScreen } from "@/features/home/ui/HomeScreen"; import { HomeScreen } from "@/features/home/ui/HomeScreen";
import { useHomeFeedNotifications } from "@/features/notifications/hooks"; import { useHomeFeedNotifications } from "@/features/notifications/hooks";
import {
listenForDesktopNotificationActions,
revealDesktopAppWindow,
setDesktopAppBadgeCount,
type DesktopNotificationTarget,
} from "@/features/notifications/lib/desktop";
import { usePresenceSession } from "@/features/presence/hooks"; import { usePresenceSession } from "@/features/presence/hooks";
import { useProfileQuery } from "@/features/profile/hooks"; import { useProfileQuery } from "@/features/profile/hooks";
import type { SettingsSection } from "@/features/settings/ui/SettingsPanels"; import type { SettingsSection } from "@/features/settings/ui/SettingsPanels";
@@ -50,6 +56,23 @@ const WorkflowsScreen = React.lazy(async () => {
return { default: module.WorkflowsScreen }; 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() { export function AppShell() {
useWebviewZoomShortcuts(); useWebviewZoomShortcuts();
@@ -234,7 +257,7 @@ export function AppShell() {
); );
}, []); }, []);
const handleOpenSearchResult = React.useCallback( const openEventAnchor = React.useCallback(
(hit: SearchHit) => { (hit: SearchHit) => {
setSearchAnchor(hit); setSearchAnchor(hit);
setSearchAnchorChannelId(hit.channelId); setSearchAnchorChannelId(hit.channelId);
@@ -272,6 +295,38 @@ export function AppShell() {
[handleOpenChannel], [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(() => { React.useEffect(() => {
let isCancelled = false; 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(() => { React.useLayoutEffect(() => {
if (settingsOpen) { if (settingsOpen) {
return; return;

View File

@@ -185,6 +185,24 @@ export function ChannelScreen({
timelineMessages.find((message) => message.id === editTargetId) ?? null, timelineMessages.find((message) => message.id === editTargetId) ?? null,
[editTargetId, timelineMessages], [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 { const {
handleCancelEdit, handleCancelEdit,
@@ -377,6 +395,10 @@ export function ChannelScreen({
<ForumView <ForumView
channel={activeChannel} channel={activeChannel}
currentPubkey={currentPubkey} currentPubkey={currentPubkey}
onTargetReached={onTargetReached}
targetEvent={forumTargetEvent}
targetEventId={forumTargetEventId}
targetEventKind={forumTargetEventKind}
/> />
</React.Suspense> </React.Suspense>
) : ( ) : (

View File

@@ -44,8 +44,10 @@ type ForumThreadPanelProps = {
onReply: (content: string, mentionPubkeys: string[]) => void; onReply: (content: string, mentionPubkeys: string[]) => void;
onDeletePost?: (eventId: string) => void; onDeletePost?: (eventId: string) => void;
onDeleteReply?: (eventId: string) => void; onDeleteReply?: (eventId: string) => void;
onTargetReached?: (eventId: string) => void;
canDeletePost?: boolean; canDeletePost?: boolean;
isDeletingPost?: boolean; isDeletingPost?: boolean;
targetEventId?: string | null;
}; };
function canDeleteReply( function canDeleteReply(
@@ -119,7 +121,7 @@ function ReplyRow({
const replyMentionNames = resolveMentionNames(reply.tags, profiles); const replyMentionNames = resolveMentionNames(reply.tags, profiles);
return ( return (
<div className="group px-4 py-3"> <div className="group px-4 py-3" data-forum-event-id={reply.eventId}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ProfileAvatar <ProfileAvatar
avatarUrl={replyAvatarUrl} avatarUrl={replyAvatarUrl}
@@ -187,8 +189,10 @@ export function ForumThreadPanel({
onReply, onReply,
onDeletePost, onDeletePost,
onDeleteReply, onDeleteReply,
onTargetReached,
canDeletePost, canDeletePost,
isDeletingPost, isDeletingPost,
targetEventId,
}: ForumThreadPanelProps) { }: ForumThreadPanelProps) {
const scrollRef = React.useRef<HTMLDivElement>(null); const scrollRef = React.useRef<HTMLDivElement>(null);
const [isDeletePostOpen, setIsDeletePostOpen] = React.useState(false); const [isDeletePostOpen, setIsDeletePostOpen] = React.useState(false);
@@ -198,6 +202,23 @@ export function ForumThreadPanel({
[channels], [channels],
); );
React.useEffect(() => {
if (!thread || !targetEventId) {
return;
}
const targetElement =
scrollRef.current?.querySelector<HTMLElement>(
`[data-forum-event-id="${targetEventId}"]`,
) ?? null;
if (!targetElement) {
return;
}
targetElement.scrollIntoView({ block: "center" });
onTargetReached?.(targetEventId);
}, [onTargetReached, targetEventId, thread]);
if (isLoading || !thread) { if (isLoading || !thread) {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
@@ -253,6 +274,7 @@ export function ForumThreadPanel({
"group border-b border-border/60 p-4", "group border-b border-border/60 p-4",
isDeletingPost && "pointer-events-none opacity-50", isDeletingPost && "pointer-events-none opacity-50",
)} )}
data-forum-event-id={post.eventId}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ProfileAvatar <ProfileAvatar

View File

@@ -1,9 +1,11 @@
import { MessageSquareText } from "lucide-react"; import { MessageSquareText } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { getThreadReference } from "@/features/messages/lib/threading";
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks"; import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity"; import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
import type { Channel } from "@/shared/api/types"; import type { Channel, RelayEvent } from "@/shared/api/types";
import { KIND_FORUM_COMMENT, KIND_FORUM_POST } from "@/shared/constants/kinds";
import { Skeleton } from "@/shared/ui/skeleton"; import { Skeleton } from "@/shared/ui/skeleton";
import { import {
@@ -21,6 +23,10 @@ import { ForumThreadPanel } from "./ForumThreadPanel";
type ForumViewProps = { type ForumViewProps = {
channel: Channel; channel: Channel;
currentPubkey?: string; currentPubkey?: string;
onTargetReached?: (messageId: string) => void;
targetEvent: RelayEvent | null;
targetEventId: string | null;
targetEventKind: number | null;
}; };
function canDelete(postPubkey: string, currentPubkey?: string): boolean { function canDelete(postPubkey: string, currentPubkey?: string): boolean {
@@ -30,7 +36,14 @@ function canDelete(postPubkey: string, currentPubkey?: string): boolean {
return postPubkey.toLowerCase() === currentPubkey.toLowerCase(); 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<string | null>( const [expandedPostId, setExpandedPostId] = React.useState<string | null>(
null, null,
); );
@@ -84,6 +97,22 @@ export function ForumView({ channel, currentPubkey }: ForumViewProps) {
), ),
[profileQuery.data, profilesQuery.data?.profiles], [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 // Reset expanded post when channel changes
const previousChannelIdRef = React.useRef(channel.id); const previousChannelIdRef = React.useRef(channel.id);
@@ -93,6 +122,14 @@ export function ForumView({ channel, currentPubkey }: ForumViewProps) {
setIsComposerOpen(false); setIsComposerOpen(false);
} }
React.useEffect(() => {
if (!targetThreadId || expandedPostId === targetThreadId) {
return;
}
setExpandedPostId(targetThreadId);
}, [expandedPostId, targetThreadId]);
if (expandedPostId) { if (expandedPostId) {
const threadPost = threadQuery.data?.post; const threadPost = threadQuery.data?.post;
const canDeleteExpandedPost = threadPost const canDeleteExpandedPost = threadPost
@@ -124,7 +161,9 @@ export function ForumView({ channel, currentPubkey }: ForumViewProps) {
mentionPubkeys, mentionPubkeys,
}); });
}} }}
onTargetReached={onTargetReached}
profiles={profiles} profiles={profiles}
targetEventId={targetEventId}
thread={threadQuery.data} thread={threadQuery.data}
/> />
); );

View File

@@ -2,6 +2,12 @@ import * as React from "react";
import { useHomeFeedQuery } from "@/features/home/hooks"; import { useHomeFeedQuery } from "@/features/home/hooks";
import type { FeedItem, HomeFeedResponse } from "@/shared/api/types"; import type { FeedItem, HomeFeedResponse } from "@/shared/api/types";
import {
collectHomeAlertItems,
eligibleFeedNotificationItems,
notificationBody,
notificationTitle,
} from "./lib/feed";
import { import {
getDesktopNotificationPermissionState, getDesktopNotificationPermissionState,
requestDesktopNotificationAccess, requestDesktopNotificationAccess,
@@ -13,7 +19,6 @@ export type { DesktopNotificationPermissionState } from "./lib/desktop";
const NOTIFICATION_SETTINGS_STORAGE_KEY = "sprout-notification-settings.v1"; const NOTIFICATION_SETTINGS_STORAGE_KEY = "sprout-notification-settings.v1";
const HOME_FEED_SEEN_STORAGE_KEY = "sprout-home-feed-seen.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; const HOME_FEED_SEEN_MAX_ITEMS = 500;
export type NotificationSettings = { export type NotificationSettings = {
@@ -155,58 +160,6 @@ function mergeSeenFeedIds(current: string[], nextIds: readonly string[]) {
: values.slice(values.length - HOME_FEED_SEEN_MAX_ITEMS); : 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<NotificationSettings, "mentions" | "needsAction">,
) {
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) { export function useNotificationSettings(pubkey?: string) {
const normalizedPubkey = pubkey?.trim().toLowerCase() ?? ""; const normalizedPubkey = pubkey?.trim().toLowerCase() ?? "";
const [settings, setSettings] = React.useState<NotificationSettings>(() => const [settings, setSettings] = React.useState<NotificationSettings>(() =>
@@ -345,6 +298,15 @@ export function useFeedDesktopNotifications(
async (item: FeedItem) => { async (item: FeedItem) => {
await sendDesktopNotification({ await sendDesktopNotification({
body: notificationBody(item), 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), title: notificationTitle(item),
}); });
}, },

View File

@@ -1,23 +1,109 @@
import { isTauri } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { import {
isPermissionGranted, isPermissionGranted,
onAction,
requestPermission, requestPermission,
sendNotification,
} from "@tauri-apps/plugin-notification"; } from "@tauri-apps/plugin-notification";
export type DesktopNotificationPermissionState = export type DesktopNotificationPermissionState =
| NotificationPermission | NotificationPermission
| "unsupported"; | "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 = { type DesktopNotificationPayload = {
body?: string; body?: string;
target?: DesktopNotificationTarget;
title: string; title: string;
}; };
const DESKTOP_NOTIFICATION_ACTION_EVENT = "sprout:desktop-notification-action";
type DesktopNotificationOptions = NotificationOptions & {
extra?: Record<string, unknown>;
};
type TestWindow = Window & {
__SPROUT_E2E_APP_BADGE_COUNT__?: number;
};
function hasNotificationApi() { function hasNotificationApi() {
return typeof window !== "undefined" && "Notification" in window; return typeof window !== "undefined" && "Notification" in window;
} }
function notificationExtra(
target: DesktopNotificationTarget | undefined,
): Record<string, unknown> | 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<DesktopNotificationTarget>;
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<DesktopNotificationTarget>(
DESKTOP_NOTIFICATION_ACTION_EVENT,
{
detail: target,
},
),
);
}
export async function getDesktopNotificationPermissionState(): Promise<DesktopNotificationPermissionState> { export async function getDesktopNotificationPermissionState(): Promise<DesktopNotificationPermissionState> {
if (!hasNotificationApi()) { if (!hasNotificationApi()) {
return "unsupported"; return "unsupported";
@@ -46,6 +132,85 @@ export async function requestDesktopNotificationAccess(): Promise<DesktopNotific
return requestPermission(); return requestPermission();
} }
export async function listenForDesktopNotificationActions(
onTarget: (target: DesktopNotificationTarget) => void,
): Promise<() => void> {
if (typeof window === "undefined") {
return () => {};
}
function handleNotificationAction(event: Event) {
const customEvent = event as CustomEvent<DesktopNotificationTarget>;
onTarget(customEvent.detail);
}
window.addEventListener(
DESKTOP_NOTIFICATION_ACTION_EVENT,
handleNotificationAction,
);
let pluginListener: { unregister: () => Promise<void> } | 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<void> {
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<void> {
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( export async function sendDesktopNotification(
payload: DesktopNotificationPayload, payload: DesktopNotificationPayload,
): Promise<boolean> { ): Promise<boolean> {
@@ -53,9 +218,18 @@ export async function sendDesktopNotification(
return false; return false;
} }
sendNotification({ const notification = new window.Notification(payload.title, {
body: payload.body, 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; return true;
} }

View File

@@ -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);
}

View File

@@ -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 = { type RawSearchHit = {
event_id: string; event_id: string;
content: string; content: string;
@@ -1470,7 +1516,35 @@ function getMockMessageStore(channelId: string): RelayEvent[] {
sig: "mocksig".repeat(20).slice(0, 128), 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); mockMessages.set(channelId, seeded);
return seeded; return seeded;
@@ -1536,6 +1610,115 @@ function emitMockTypingIndicator(channelId: string, pubkey: string) {
return event; 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<RawForumPostsResponse> {
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<RawForumThreadResponse> {
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( function createMockEvent(
kind: number, kind: number,
content: string, content: string,
@@ -3734,6 +3917,14 @@ export function maybeInstallE2eTauriMocks() {
payload as Parameters<typeof handleSearchMessages>[0], payload as Parameters<typeof handleSearchMessages>[0],
activeConfig, activeConfig,
); );
case "get_forum_posts":
return handleGetForumPosts(
payload as Parameters<typeof handleGetForumPosts>[0],
);
case "get_forum_thread":
return handleGetForumThread(
payload as Parameters<typeof handleGetForumThread>[0],
);
case "send_channel_message": case "send_channel_message":
return handleSendChannelMessage( return handleSendChannelMessage(
payload as Parameters<typeof handleSendChannelMessage>[0], payload as Parameters<typeof handleSendChannelMessage>[0],

View File

@@ -69,8 +69,19 @@ test("updates presence from the profile menu", async ({ page }) => {
test("notification settings drive the Home badge and desktop alerts", async ({ test("notification settings drive the Home badge and desktop alerts", async ({
page, 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 page.goto("/");
await expect(page.getByTestId("sidebar-home-count")).toHaveCount(0); await expect(page.getByTestId("sidebar-home-count")).toHaveCount(0);
await expect.poll(getAppBadgeCount).toBe(0);
await openSettings(page, "notifications"); await openSettings(page, "notifications");
await expect(page.getByTestId("settings-notifications")).toBeVisible(); 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(page.getByTestId("sidebar-home-count")).toHaveText("1");
await expect.poll(getAppBadgeCount).toBe(1);
await expect await expect
.poll(() => .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 openSettings(page, "notifications");
await page.getByTestId("notifications-home-badge-toggle").click(); await page.getByTestId("notifications-home-badge-toggle").click();
await page.getByTestId("settings-close").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(page.getByTestId("sidebar-home-count")).toHaveCount(0);
await expect.poll(getAppBadgeCount).toBe(0);
await openSettings(page, "notifications"); await openSettings(page, "notifications");
await page.getByTestId("notifications-home-badge-toggle").click(); await page.getByTestId("notifications-home-badge-toggle").click();
await page.getByTestId("settings-close").click(); await page.getByTestId("settings-close").click();
await expect(page.getByTestId("sidebar-home-count")).toHaveText("1"); await expect(page.getByTestId("sidebar-home-count")).toHaveText("1");
await expect.poll(getAppBadgeCount).toBe(1);
await page.getByRole("button", { name: "Home" }).click(); await page.getByRole("button", { name: "Home" }).click();
await expect(page.getByTestId("chat-title")).toHaveText("Home"); await expect(page.getByTestId("chat-title")).toHaveText("Home");
await expect(page.getByTestId("sidebar-home-count")).toHaveCount(0); 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 ({ test("opens settings with the keyboard shortcut and updates theme", async ({

View File

@@ -70,6 +70,7 @@ export async function installBridge(page: Page, options: BridgeOptions) {
body: string | null; body: string | null;
title: string; title: string;
}> = []; }> = [];
const notificationInstances: MockNotification[] = [];
class MockNotification extends EventTarget { class MockNotification extends EventTarget {
static permission: NotificationPermission = "granted"; static permission: NotificationPermission = "granted";
@@ -79,12 +80,14 @@ export async function installBridge(page: Page, options: BridgeOptions) {
} }
body: string | null; body: string | null;
onclick: ((event: Event) => void) | null = null;
title: string; title: string;
constructor(title: string, options?: NotificationOptions) { constructor(title: string, options?: NotificationOptions) {
super(); super();
this.title = title; this.title = title;
this.body = options?.body ?? null; this.body = options?.body ?? null;
notificationInstances.push(this);
notificationLog.push({ notificationLog.push({
body: this.body, body: this.body,
title: this.title, title: this.title,
@@ -102,6 +105,8 @@ export async function installBridge(page: Page, options: BridgeOptions) {
const testWindow = window as Window & { const testWindow = window as Window & {
__SPROUT_E2E__?: Record<string, unknown>; __SPROUT_E2E__?: Record<string, unknown>;
__SPROUT_E2E_APP_BADGE_COUNT__?: number;
__SPROUT_E2E_CLICK_NOTIFICATION__?: (index: number) => boolean;
__SPROUT_E2E_NOTIFICATIONS__?: Array<{ __SPROUT_E2E_NOTIFICATIONS__?: Array<{
body: string | null; body: string | null;
title: string; title: string;
@@ -117,6 +122,18 @@ export async function installBridge(page: Page, options: BridgeOptions) {
relayHttpUrl: relayHttpUrl ?? currentConfig.relayHttpUrl, relayHttpUrl: relayHttpUrl ?? currentConfig.relayHttpUrl,
relayWsUrl: relayWsUrl ?? currentConfig.relayWsUrl, 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; testWindow.__SPROUT_E2E_NOTIFICATIONS__ = notificationLog;
}, },
{ {