mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Add desktop badge and notification ground work (#257)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
<ForumView
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
onTargetReached={onTargetReached}
|
||||
targetEvent={forumTargetEvent}
|
||||
targetEventId={forumTargetEventId}
|
||||
targetEventKind={forumTargetEventKind}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
|
||||
@@ -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 (
|
||||
<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">
|
||||
<ProfileAvatar
|
||||
avatarUrl={replyAvatarUrl}
|
||||
@@ -187,8 +189,10 @@ export function ForumThreadPanel({
|
||||
onReply,
|
||||
onDeletePost,
|
||||
onDeleteReply,
|
||||
onTargetReached,
|
||||
canDeletePost,
|
||||
isDeletingPost,
|
||||
targetEventId,
|
||||
}: ForumThreadPanelProps) {
|
||||
const scrollRef = React.useRef<HTMLDivElement>(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<HTMLElement>(
|
||||
`[data-forum-event-id="${targetEventId}"]`,
|
||||
) ?? null;
|
||||
if (!targetElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetElement.scrollIntoView({ block: "center" });
|
||||
onTargetReached?.(targetEventId);
|
||||
}, [onTargetReached, targetEventId, thread]);
|
||||
|
||||
if (isLoading || !thread) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -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}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProfileAvatar
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { MessageSquareText } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { getThreadReference } from "@/features/messages/lib/threading";
|
||||
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
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 {
|
||||
@@ -21,6 +23,10 @@ import { ForumThreadPanel } from "./ForumThreadPanel";
|
||||
type ForumViewProps = {
|
||||
channel: Channel;
|
||||
currentPubkey?: string;
|
||||
onTargetReached?: (messageId: string) => 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<string | null>(
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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<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) {
|
||||
const normalizedPubkey = pubkey?.trim().toLowerCase() ?? "";
|
||||
const [settings, setSettings] = React.useState<NotificationSettings>(() =>
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
type TestWindow = Window & {
|
||||
__SPROUT_E2E_APP_BADGE_COUNT__?: number;
|
||||
};
|
||||
|
||||
function hasNotificationApi() {
|
||||
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> {
|
||||
if (!hasNotificationApi()) {
|
||||
return "unsupported";
|
||||
@@ -46,6 +132,85 @@ export async function requestDesktopNotificationAccess(): Promise<DesktopNotific
|
||||
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(
|
||||
payload: DesktopNotificationPayload,
|
||||
): Promise<boolean> {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
55
desktop/src/features/notifications/lib/feed.ts
Normal file
55
desktop/src/features/notifications/lib/feed.ts
Normal 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);
|
||||
}
|
||||
@@ -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<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(
|
||||
kind: number,
|
||||
content: string,
|
||||
@@ -3734,6 +3917,14 @@ export function maybeInstallE2eTauriMocks() {
|
||||
payload as Parameters<typeof handleSearchMessages>[0],
|
||||
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":
|
||||
return handleSendChannelMessage(
|
||||
payload as Parameters<typeof handleSendChannelMessage>[0],
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
__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;
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user