mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Add desktop badge and notification ground work (#257)
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
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 = {
|
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],
|
||||||
|
|||||||
@@ -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 ({
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user