Add desktop notifications and improve DM labeling (#107)

This commit is contained in:
Wes
2026-03-18 13:27:15 -07:00
committed by GitHub
parent 42cf6c1dc8
commit ba1c8df5c6
20 changed files with 1426 additions and 218 deletions
+1
View File
@@ -29,6 +29,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.21",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+10
View File
@@ -35,6 +35,9 @@ importers:
'@tauri-apps/api':
specifier: ^2
version: 2.10.1
'@tauri-apps/plugin-notification':
specifier: ^2.3.3
version: 2.3.3
'@tauri-apps/plugin-opener':
specifier: ^2
version: 2.5.3
@@ -1064,6 +1067,9 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-notification@2.3.3':
resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==}
'@tauri-apps/plugin-opener@2.5.3':
resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==}
@@ -2748,6 +2754,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
'@tauri-apps/plugin-notification@2.3.3':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-opener@2.5.3':
dependencies:
'@tauri-apps/api': 2.10.1
+69 -1
View File
@@ -2244,6 +2244,18 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mac-notification-sys"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3"
dependencies = [
"cc",
"objc2",
"objc2-foundation",
"time",
]
[[package]]
name = "markup5ever"
version = "0.14.1"
@@ -2467,6 +2479,20 @@ dependencies = [
"url",
]
[[package]]
name = "notify-rust"
version = "4.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2"
dependencies = [
"futures-lite",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus",
]
[[package]]
name = "num-conv"
version = "0.2.0"
@@ -2574,6 +2600,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2969,7 +2996,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [
"base64 0.22.1",
"indexmap 2.13.0",
"quick-xml",
"quick-xml 0.38.4",
"serde",
"time",
]
@@ -3120,6 +3147,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-xml"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -4064,6 +4100,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-websocket",
"tauri-plugin-window-state",
@@ -4441,6 +4478,25 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-notification"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
dependencies = [
"log",
"notify-rust",
"rand 0.9.2",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"time",
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.3"
@@ -4598,6 +4654,18 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "tauri-winrt-notification"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
dependencies = [
"quick-xml 0.37.5",
"thiserror 2.0.18",
"windows",
"windows-version",
]
[[package]]
name = "tempfile"
version = "3.26.0"
+1
View File
@@ -41,3 +41,4 @@ sprout-core = { path = "../../crates/sprout-core" }
base64 = "0.22"
sha2 = "0.10"
chrono = { version = "0.4", features = ["serde"] }
tauri-plugin-notification = "2.3.3"
@@ -8,6 +8,7 @@
"core:window:allow-start-dragging",
"core:window:allow-show",
"core:window:allow-close",
"notification:default",
"opener:default",
"websocket:default",
"window-state:default",
+1
View File
@@ -85,6 +85,7 @@ fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let app = tauri::Builder::default()
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_opener::init())
.plugin(
tauri_plugin_window_state::Builder::default()
+47 -10
View File
@@ -14,7 +14,6 @@ import {
import { useUnreadChannels } from "@/features/channels/useUnreadChannels";
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagementSheet";
import { useHomeFeedQuery } from "@/features/home/hooks";
import { HomeView } from "@/features/home/ui/HomeView";
import {
useChannelMessagesQuery,
@@ -37,6 +36,7 @@ import {
usePresenceSession,
} from "@/features/presence/hooks";
import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
import { useHomeFeedNotifications } from "@/features/notifications/hooks";
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
import { ChannelBrowserDialog } from "@/features/channels/ui/ChannelBrowserDialog";
import { SearchDialog } from "@/features/search/ui/SearchDialog";
@@ -45,6 +45,7 @@ import {
SettingsView,
type SettingsSection,
} from "@/features/settings/ui/SettingsView";
import { resolveChannelDisplayLabel } from "@/features/sidebar/lib/channelLabels";
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
import { relayClient } from "@/shared/api/relayClient";
import { getEventById, joinChannel } from "@/shared/api/tauri";
@@ -82,7 +83,11 @@ export function AppShell() {
const identityQuery = useIdentityQuery();
const profileQuery = useProfileQuery();
const presenceSession = usePresenceSession(identityQuery.data?.pubkey);
const homeFeedQuery = useHomeFeedQuery();
const { homeBadgeCount, homeFeedQuery, notificationSettings } =
useHomeFeedNotifications(
identityQuery.data?.pubkey,
selectedView === "home",
);
const channelsQuery = useChannelsQuery();
const { refetch: refetchChannels } = channelsQuery;
const channels = channelsQuery.data ?? [];
@@ -98,14 +103,23 @@ export function AppShell() {
const createForumMutation = useCreateChannelMutation();
const activeChannel = selectedView === "channel" ? selectedChannel : null;
const activeChannelId = activeChannel?.id ?? null;
const { unreadChannelIds } = useUnreadChannels(channels, activeChannel);
const messagesQuery = useChannelMessagesQuery(activeChannel);
useChannelSubscription(activeChannel);
const latestActiveMessage =
messagesQuery.data?.[messagesQuery.data.length - 1];
const activeReadAt = latestActiveMessage
? new Date(latestActiveMessage.created_at * 1_000).toISOString()
: (activeChannel?.lastMessageAt ?? null);
const { unreadChannelIds } = useUnreadChannels(
channels,
activeChannel,
activeReadAt,
);
const activeDmParticipantPubkeys = React.useMemo(() => {
if (!activeChannel || activeChannel.channelType !== "dm") {
return [];
}
const currentPubkey = identityQuery.data?.pubkey?.toLowerCase();
return activeChannel.participantPubkeys.filter(
(pubkey) => pubkey.toLowerCase() !== currentPubkey,
);
@@ -113,16 +127,22 @@ export function AppShell() {
const activeDmPresenceQuery = usePresenceQuery(activeDmParticipantPubkeys, {
enabled: activeDmParticipantPubkeys.length > 0,
});
const activeDmProfilesQuery = useUsersBatchQuery(activeDmParticipantPubkeys, {
enabled: activeDmParticipantPubkeys.length > 0,
});
const activeDmPresenceStatus =
activeDmParticipantPubkeys.length > 0
? activeDmPresenceQuery.data?.[
activeDmParticipantPubkeys[0]?.toLowerCase()
]
: null;
const messagesQuery = useChannelMessagesQuery(activeChannel);
useChannelSubscription(activeChannel);
const activeChannelTitle = activeChannel
? resolveChannelDisplayLabel(
activeChannel,
identityQuery.data?.pubkey,
activeDmProfilesQuery.data?.profiles,
)
: "Channels";
const sendMessageMutation = useSendMessageMutation(
activeChannel,
identityQuery.data,
@@ -472,10 +492,26 @@ export function AppShell() {
<SettingsView
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
isUpdatingDesktopNotifications={
notificationSettings.isUpdatingDesktopEnabled
}
isPresenceLoading={presenceSession.isLoading}
isUpdatingPresence={presenceSession.isPending}
notificationErrorMessage={notificationSettings.errorMessage}
notificationPermission={notificationSettings.permission}
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={setSettingsSection}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
onSetHomeBadgeEnabled={notificationSettings.setHomeBadgeEnabled}
onSetMentionNotificationsEnabled={
notificationSettings.setMentionsEnabled
}
onSetNeedsActionNotificationsEnabled={
notificationSettings.setNeedsActionEnabled
}
onSetPresence={presenceSession.setStatus}
presenceError={presenceSession.error}
presenceStatus={presenceSession.currentStatus}
@@ -494,6 +530,7 @@ export function AppShell() {
: undefined
}
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
@@ -592,7 +629,7 @@ export function AppShell() {
/>
) : null
}
title={activeChannel?.name ?? "Channels"}
title={activeChannelTitle}
/>
)}
@@ -74,6 +74,7 @@ function getMessageTimestamp(event: RelayEvent) {
export function useUnreadChannels(
channels: Channel[],
activeChannel: Channel | null,
activeReadAt?: string | null,
) {
const queryClient = useQueryClient();
const [lastReadByChannel, setLastReadByChannel] =
@@ -81,6 +82,7 @@ export function useUnreadChannels(
const hasInitializedChannelsRef = React.useRef(false);
const activeChannelId = activeChannel?.id ?? null;
const activeChannelLastMessageAt = activeChannel?.lastMessageAt ?? null;
const effectiveActiveReadAt = activeReadAt ?? activeChannelLastMessageAt;
const markChannelRead = React.useCallback(
(channelId: string, readAt: string | null | undefined) => {
@@ -163,8 +165,8 @@ export function useUnreadChannels(
return;
}
markChannelRead(activeChannelId, activeChannelLastMessageAt);
}, [activeChannelId, activeChannelLastMessageAt, markChannelRead]);
markChannelRead(activeChannelId, effectiveActiveReadAt);
}, [activeChannelId, effectiveActiveReadAt, markChannelRead]);
const liveChannelIds = React.useMemo(
() =>
+483
View File
@@ -0,0 +1,483 @@
import * as React from "react";
import { useHomeFeedQuery } from "@/features/home/hooks";
import type { FeedItem, HomeFeedResponse } from "@/shared/api/types";
import {
getDesktopNotificationPermissionState,
requestDesktopNotificationAccess,
sendDesktopNotification,
type DesktopNotificationPermissionState,
} from "./lib/desktop";
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 = {
desktopEnabled: boolean;
homeBadgeEnabled: boolean;
mentions: boolean;
needsAction: boolean;
};
const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
desktopEnabled: false,
homeBadgeEnabled: true,
mentions: true,
needsAction: true,
};
function notificationSettingsStorageKey(pubkey: string) {
return `${NOTIFICATION_SETTINGS_STORAGE_KEY}:${pubkey}`;
}
function homeFeedSeenStorageKey(pubkey: string) {
return `${HOME_FEED_SEEN_STORAGE_KEY}:${pubkey}`;
}
function sanitizeNotificationSettings(value: unknown): NotificationSettings {
if (!value || typeof value !== "object") {
return DEFAULT_NOTIFICATION_SETTINGS;
}
const candidate = value as Partial<NotificationSettings>;
return {
desktopEnabled:
typeof candidate.desktopEnabled === "boolean"
? candidate.desktopEnabled
: DEFAULT_NOTIFICATION_SETTINGS.desktopEnabled,
homeBadgeEnabled:
typeof candidate.homeBadgeEnabled === "boolean"
? candidate.homeBadgeEnabled
: DEFAULT_NOTIFICATION_SETTINGS.homeBadgeEnabled,
mentions:
typeof candidate.mentions === "boolean"
? candidate.mentions
: DEFAULT_NOTIFICATION_SETTINGS.mentions,
needsAction:
typeof candidate.needsAction === "boolean"
? candidate.needsAction
: DEFAULT_NOTIFICATION_SETTINGS.needsAction,
};
}
function readStoredNotificationSettings(pubkey: string): NotificationSettings {
if (typeof window === "undefined" || pubkey.length === 0) {
return DEFAULT_NOTIFICATION_SETTINGS;
}
const rawValue = window.localStorage.getItem(
notificationSettingsStorageKey(pubkey),
);
if (!rawValue) {
return DEFAULT_NOTIFICATION_SETTINGS;
}
try {
return sanitizeNotificationSettings(JSON.parse(rawValue));
} catch {
return DEFAULT_NOTIFICATION_SETTINGS;
}
}
function writeStoredNotificationSettings(
pubkey: string,
settings: NotificationSettings,
) {
if (typeof window === "undefined" || pubkey.length === 0) {
return;
}
window.localStorage.setItem(
notificationSettingsStorageKey(pubkey),
JSON.stringify(settings),
);
}
function readStoredSeenFeedIds(pubkey: string): string[] {
if (typeof window === "undefined" || pubkey.length === 0) {
return [];
}
const rawValue = window.localStorage.getItem(homeFeedSeenStorageKey(pubkey));
if (!rawValue) {
return [];
}
try {
const parsed = JSON.parse(rawValue);
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.filter((value): value is string => typeof value === "string")
.slice(-HOME_FEED_SEEN_MAX_ITEMS);
} catch {
return [];
}
}
function writeStoredSeenFeedIds(pubkey: string, ids: string[]) {
if (typeof window === "undefined" || pubkey.length === 0) {
return;
}
window.localStorage.setItem(
homeFeedSeenStorageKey(pubkey),
JSON.stringify(ids.slice(-HOME_FEED_SEEN_MAX_ITEMS)),
);
}
function mergeSeenFeedIds(current: string[], nextIds: readonly string[]) {
const merged = new Set(current);
let didChange = false;
for (const id of nextIds) {
if (merged.has(id)) {
continue;
}
merged.add(id);
didChange = true;
}
if (!didChange) {
return current;
}
const values = [...merged];
return values.length <= HOME_FEED_SEEN_MAX_ITEMS
? values
: 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>(() =>
readStoredNotificationSettings(normalizedPubkey),
);
const [permission, setPermission] =
React.useState<DesktopNotificationPermissionState>("default");
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const [isUpdatingDesktopEnabled, setIsUpdatingDesktopEnabled] =
React.useState(false);
React.useEffect(() => {
setSettings(readStoredNotificationSettings(normalizedPubkey));
setErrorMessage(null);
}, [normalizedPubkey]);
React.useEffect(() => {
writeStoredNotificationSettings(normalizedPubkey, settings);
}, [normalizedPubkey, settings]);
const refreshPermission = React.useEffectEvent(async () => {
const nextPermission = await getDesktopNotificationPermissionState();
setPermission(nextPermission);
return nextPermission;
});
React.useEffect(() => {
void normalizedPubkey;
void refreshPermission();
}, [normalizedPubkey]);
const setDesktopEnabled = React.useCallback(async (enabled: boolean) => {
if (!enabled) {
setErrorMessage(null);
setSettings((current) => ({
...current,
desktopEnabled: false,
}));
void refreshPermission();
return true;
}
setIsUpdatingDesktopEnabled(true);
setErrorMessage(null);
try {
let nextPermission = await refreshPermission();
if (nextPermission === "default") {
nextPermission = await requestDesktopNotificationAccess();
setPermission(nextPermission);
}
if (nextPermission !== "granted") {
setSettings((current) => ({
...current,
desktopEnabled: false,
}));
setErrorMessage(
nextPermission === "denied"
? "Desktop notifications are blocked for Sprout. Enable them in system settings to turn alerts on."
: "Desktop notifications are unavailable in this environment.",
);
return false;
}
setSettings((current) => ({
...current,
desktopEnabled: true,
}));
return true;
} catch (error) {
setSettings((current) => ({
...current,
desktopEnabled: false,
}));
setErrorMessage(
error instanceof Error
? error.message
: "Failed to enable desktop notifications.",
);
return false;
} finally {
setIsUpdatingDesktopEnabled(false);
}
}, []);
const setHomeBadgeEnabled = React.useCallback((enabled: boolean) => {
setSettings((current) => ({
...current,
homeBadgeEnabled: enabled,
}));
}, []);
const setMentionsEnabled = React.useCallback((enabled: boolean) => {
setSettings((current) => ({
...current,
mentions: enabled,
}));
}, []);
const setNeedsActionEnabled = React.useCallback((enabled: boolean) => {
setSettings((current) => ({
...current,
needsAction: enabled,
}));
}, []);
return {
errorMessage,
isUpdatingDesktopEnabled,
permission,
setDesktopEnabled,
setHomeBadgeEnabled,
setMentionsEnabled,
setNeedsActionEnabled,
settings,
};
}
export function useFeedDesktopNotifications(
feed: HomeFeedResponse | undefined,
pubkey: string | undefined,
settings: NotificationSettings,
) {
const normalizedPubkey = pubkey?.trim().toLowerCase() ?? "";
const seenItemIdsRef = React.useRef<Set<string>>(new Set());
const hasInitializedFeedRef = React.useRef(false);
React.useEffect(() => {
void normalizedPubkey;
seenItemIdsRef.current = new Set();
hasInitializedFeedRef.current = false;
}, [normalizedPubkey]);
const deliverFeedNotification = React.useEffectEvent(
async (item: FeedItem) => {
await sendDesktopNotification({
body: notificationBody(item),
title: notificationTitle(item),
});
},
);
React.useEffect(() => {
if (!feed) {
return;
}
const currentFeedItems = collectHomeAlertItems(feed);
if (!hasInitializedFeedRef.current) {
hasInitializedFeedRef.current = true;
seenItemIdsRef.current = new Set(currentFeedItems.map((item) => item.id));
return;
}
const nextSeenItemIds = new Set(seenItemIdsRef.current);
const newItems = settings.desktopEnabled
? eligibleFeedNotificationItems(feed, {
mentions: settings.mentions,
needsAction: settings.needsAction,
}).filter((item) => !nextSeenItemIds.has(item.id))
: [];
for (const item of currentFeedItems) {
nextSeenItemIds.add(item.id);
}
seenItemIdsRef.current = nextSeenItemIds;
for (const item of newItems) {
void deliverFeedNotification(item);
}
}, [feed, settings.desktopEnabled, settings.mentions, settings.needsAction]);
}
export function useHomeFeedNotificationState(
feed: HomeFeedResponse | undefined,
pubkey: string | undefined,
settings: NotificationSettings,
isHomeActive: boolean,
) {
useFeedDesktopNotifications(feed, pubkey, settings);
const normalizedPubkey = pubkey?.trim().toLowerCase() ?? "";
const [seenFeedIds, setSeenFeedIds] = React.useState<string[]>(() =>
readStoredSeenFeedIds(normalizedPubkey),
);
const currentFeedIds = React.useMemo(
() =>
feed
? [...feed.feed.mentions, ...feed.feed.needsAction].map(
(item) => item.id,
)
: [],
[feed],
);
React.useEffect(() => {
setSeenFeedIds(readStoredSeenFeedIds(normalizedPubkey));
}, [normalizedPubkey]);
React.useEffect(() => {
writeStoredSeenFeedIds(normalizedPubkey, seenFeedIds);
}, [normalizedPubkey, seenFeedIds]);
const markCurrentFeedSeen = React.useEffectEvent(() => {
setSeenFeedIds((current) => mergeSeenFeedIds(current, currentFeedIds));
});
React.useEffect(() => {
if (!isHomeActive || currentFeedIds.length === 0) {
return;
}
void normalizedPubkey;
markCurrentFeedSeen();
}, [currentFeedIds, isHomeActive, normalizedPubkey]);
return React.useMemo(() => {
if (!settings.homeBadgeEnabled || isHomeActive) {
return 0;
}
if (currentFeedIds.length === 0) {
return 0;
}
const seenFeedIdSet = new Set(seenFeedIds);
return currentFeedIds.filter((id) => !seenFeedIdSet.has(id)).length;
}, [currentFeedIds, isHomeActive, seenFeedIds, settings.homeBadgeEnabled]);
}
export function useHomeFeedNotifications(
pubkey: string | undefined,
isHomeActive: boolean,
) {
const notificationSettings = useNotificationSettings(pubkey);
const homeFeedQuery = useHomeFeedQuery();
const refetchHomeFeedForE2e = React.useEffectEvent(() => {
void homeFeedQuery.refetch();
});
React.useEffect(() => {
if (typeof window === "undefined") {
return;
}
function handleMockHomeFeedUpdate() {
refetchHomeFeedForE2e();
}
window.addEventListener(
"sprout:e2e-home-feed-updated",
handleMockHomeFeedUpdate,
);
return () => {
window.removeEventListener(
"sprout:e2e-home-feed-updated",
handleMockHomeFeedUpdate,
);
};
}, []);
const homeBadgeCount = useHomeFeedNotificationState(
homeFeedQuery.data,
pubkey,
notificationSettings.settings,
isHomeActive,
);
return {
homeBadgeCount,
homeFeedQuery,
notificationSettings,
};
}
@@ -0,0 +1,61 @@
import { isTauri } from "@tauri-apps/api/core";
import {
isPermissionGranted,
requestPermission,
sendNotification,
} from "@tauri-apps/plugin-notification";
export type DesktopNotificationPermissionState =
| NotificationPermission
| "unsupported";
type DesktopNotificationPayload = {
body?: string;
title: string;
};
function hasNotificationApi() {
return typeof window !== "undefined" && "Notification" in window;
}
export async function getDesktopNotificationPermissionState(): Promise<DesktopNotificationPermissionState> {
if (!hasNotificationApi()) {
return "unsupported";
}
if (window.Notification.permission !== "default") {
return window.Notification.permission;
}
if (!isTauri()) {
return "default";
}
try {
return (await isPermissionGranted()) ? "granted" : "default";
} catch {
return "default";
}
}
export async function requestDesktopNotificationAccess(): Promise<DesktopNotificationPermissionState> {
if (!hasNotificationApi()) {
return "unsupported";
}
return requestPermission();
}
export async function sendDesktopNotification(
payload: DesktopNotificationPayload,
): Promise<boolean> {
if ((await getDesktopNotificationPermissionState()) !== "granted") {
return false;
}
sendNotification({
body: payload.body,
title: payload.title,
});
return true;
}
@@ -0,0 +1,202 @@
import {
AtSign,
BellRing,
CircleAlert,
Home as HomeIcon,
type LucideIcon,
} from "lucide-react";
import type {
DesktopNotificationPermissionState,
NotificationSettings,
} from "@/features/notifications/hooks";
import { cn } from "@/shared/lib/cn";
function notificationStatusLabel(
desktopEnabled: boolean,
permission: DesktopNotificationPermissionState,
) {
if (permission === "unsupported") {
return "Unavailable";
}
if (permission === "denied") {
return "Blocked";
}
return desktopEnabled ? "On" : "Off";
}
function notificationStatusClassName(
desktopEnabled: boolean,
permission: DesktopNotificationPermissionState,
) {
if (permission === "unsupported" || permission === "denied") {
return "border-destructive/30 bg-destructive/10 text-destructive";
}
if (desktopEnabled) {
return "border-primary/30 bg-primary/10 text-primary";
}
return "border-border/80 bg-muted text-muted-foreground";
}
function NotificationPreferenceCard({
description,
disabled = false,
enabled,
icon: Icon,
onToggle,
testId,
title,
}: {
description: string;
disabled?: boolean;
enabled: boolean;
icon: LucideIcon;
onToggle: () => void;
testId: string;
title: string;
}) {
return (
<button
aria-pressed={enabled}
className={cn(
"flex min-h-24 flex-col items-start justify-between rounded-xl border px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
enabled
? "border-primary bg-primary/10 text-foreground"
: "border-border/80 bg-background/60 text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
data-testid={testId}
disabled={disabled}
onClick={onToggle}
type="button"
>
<div className="flex items-center gap-2">
<Icon className="h-4 w-4" />
<span className="font-medium text-foreground">{title}</span>
</div>
<p className="text-sm text-muted-foreground">{description}</p>
</button>
);
}
export function NotificationSettingsCard({
isUpdatingDesktopNotifications,
notificationErrorMessage,
notificationPermission,
notificationSettings,
onSetDesktopNotificationsEnabled,
onSetHomeBadgeEnabled,
onSetMentionNotificationsEnabled,
onSetNeedsActionNotificationsEnabled,
}: {
isUpdatingDesktopNotifications: boolean;
notificationErrorMessage: string | null;
notificationPermission: DesktopNotificationPermissionState;
notificationSettings: NotificationSettings;
onSetDesktopNotificationsEnabled: (enabled: boolean) => Promise<boolean>;
onSetHomeBadgeEnabled: (enabled: boolean) => void;
onSetMentionNotificationsEnabled: (enabled: boolean) => void;
onSetNeedsActionNotificationsEnabled: (enabled: boolean) => void;
}) {
return (
<section
className="rounded-xl border border-border/80 bg-card/80 p-4 shadow-sm"
data-testid="settings-notifications"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h2 className="text-sm font-semibold tracking-tight">
Notifications
</h2>
<p className="text-sm text-muted-foreground">
Zero by default. Keep channel noise inside Home, then opt in to
desktop alerts only for the items that truly need you.
</p>
</div>
<span
className={cn(
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em]",
notificationStatusClassName(
notificationSettings.desktopEnabled,
notificationPermission,
),
)}
data-testid="notifications-desktop-state"
>
{notificationStatusLabel(
notificationSettings.desktopEnabled,
notificationPermission,
)}
</span>
</div>
<div className="mt-4 grid gap-2 md:grid-cols-2">
<NotificationPreferenceCard
description={
notificationSettings.desktopEnabled
? "Native desktop alerts are enabled for the categories you have armed below."
: "Request OS permission and surface new mentions or needs-action items outside the app."
}
disabled={isUpdatingDesktopNotifications}
enabled={notificationSettings.desktopEnabled}
icon={BellRing}
onToggle={() => {
void onSetDesktopNotificationsEnabled(
!notificationSettings.desktopEnabled,
);
}}
testId="notifications-desktop-toggle"
title={
isUpdatingDesktopNotifications ? "Requesting..." : "Desktop alerts"
}
/>
<NotificationPreferenceCard
description="Show a Home badge for mentions and needs-action items in the sidebar."
enabled={notificationSettings.homeBadgeEnabled}
icon={HomeIcon}
onToggle={() => {
onSetHomeBadgeEnabled(!notificationSettings.homeBadgeEnabled);
}}
testId="notifications-home-badge-toggle"
title="Home badge"
/>
<NotificationPreferenceCard
description="Alert when someone tags your pubkey in a channel you can access."
enabled={notificationSettings.mentions}
icon={AtSign}
onToggle={() => {
onSetMentionNotificationsEnabled(!notificationSettings.mentions);
}}
testId="notifications-mentions-toggle"
title="@Mentions"
/>
<NotificationPreferenceCard
description="Alert for reminders and workflow approvals that are waiting on you."
enabled={notificationSettings.needsAction}
icon={CircleAlert}
onToggle={() => {
onSetNeedsActionNotificationsEnabled(
!notificationSettings.needsAction,
);
}}
testId="notifications-needs-action-toggle"
title="Needs action"
/>
</div>
{notificationErrorMessage ? (
<p className="mt-4 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{notificationErrorMessage}
</p>
) : null}
<p className="mt-4 text-sm text-muted-foreground">
The Home badge is an in-app signal. Desktop alerts only fire for new
feed items after you enable them.
</p>
</section>
);
}
@@ -1,4 +1,5 @@
import {
BellRing,
CircleDot,
KeyRound,
MonitorCog,
@@ -8,6 +9,10 @@ import {
UserRound,
type LucideIcon,
} from "lucide-react";
import type {
DesktopNotificationPermissionState,
NotificationSettings,
} from "@/features/notifications/hooks";
import {
PresenceBadge,
PresenceDot,
@@ -17,11 +22,13 @@ import type { PresenceStatus } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { useTheme } from "@/shared/theme/ThemeProvider";
import { DoctorSettingsPanel } from "./DoctorSettingsPanel";
import { NotificationSettingsCard } from "./NotificationSettingsCard";
import { ProfileSettingsCard } from "./ProfileSettingsCard";
export type SettingsSection =
| "profile"
| "presence"
| "notifications"
| "appearance"
| "tokens"
| "doctor";
@@ -37,8 +44,16 @@ export type SettingsSectionDescriptor = {
export type SettingsPanelProps = {
currentPubkey?: string;
fallbackDisplayName?: string;
isUpdatingDesktopNotifications: boolean;
isPresenceLoading: boolean;
isUpdatingPresence: boolean;
notificationErrorMessage: string | null;
notificationPermission: DesktopNotificationPermissionState;
notificationSettings: NotificationSettings;
onSetDesktopNotificationsEnabled: (enabled: boolean) => Promise<boolean>;
onSetHomeBadgeEnabled: (enabled: boolean) => void;
onSetMentionNotificationsEnabled: (enabled: boolean) => void;
onSetNeedsActionNotificationsEnabled: (enabled: boolean) => void;
onSetPresence: (status: PresenceStatus) => Promise<void>;
presenceError: Error | null;
presenceStatus: PresenceStatus;
@@ -61,6 +76,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [
label: "Presence",
icon: CircleDot,
},
{
value: "notifications",
label: "Notifications",
icon: BellRing,
},
{
value: "appearance",
label: "Appearance",
@@ -276,6 +296,25 @@ export function renderSettingsSection(
presenceStatus={props.presenceStatus}
/>
);
case "notifications":
return (
<NotificationSettingsCard
isUpdatingDesktopNotifications={props.isUpdatingDesktopNotifications}
notificationErrorMessage={props.notificationErrorMessage}
notificationPermission={props.notificationPermission}
notificationSettings={props.notificationSettings}
onSetDesktopNotificationsEnabled={
props.onSetDesktopNotificationsEnabled
}
onSetHomeBadgeEnabled={props.onSetHomeBadgeEnabled}
onSetMentionNotificationsEnabled={
props.onSetMentionNotificationsEnabled
}
onSetNeedsActionNotificationsEnabled={
props.onSetNeedsActionNotificationsEnabled
}
/>
);
case "appearance":
return <ThemeSettingsCard />;
case "tokens":
@@ -76,10 +76,18 @@ function SettingsSectionButton({
export function SettingsView({
currentPubkey,
fallbackDisplayName,
isUpdatingDesktopNotifications,
isPresenceLoading,
isUpdatingPresence,
notificationErrorMessage,
notificationPermission,
notificationSettings,
onClose,
onSectionChange,
onSetDesktopNotificationsEnabled,
onSetHomeBadgeEnabled,
onSetMentionNotificationsEnabled,
onSetNeedsActionNotificationsEnabled,
onSetPresence,
presenceError,
presenceStatus,
@@ -146,8 +154,16 @@ export function SettingsView({
{renderSettingsSection(section, {
currentPubkey,
fallbackDisplayName,
isUpdatingDesktopNotifications,
isPresenceLoading,
isUpdatingPresence,
notificationErrorMessage,
notificationPermission,
notificationSettings,
onSetDesktopNotificationsEnabled,
onSetHomeBadgeEnabled,
onSetMentionNotificationsEnabled,
onSetNeedsActionNotificationsEnabled,
onSetPresence,
presenceError,
presenceStatus,
@@ -0,0 +1,49 @@
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import type { Channel } from "@/shared/api/types";
function isGenericDmChannelName(name: string) {
const normalized = name.trim().toLowerCase();
return (
normalized.length === 0 ||
normalized === "dm" ||
normalized === "direct message" ||
normalized === "direct messages"
);
}
export function resolveChannelDisplayLabel(
channel: Channel,
currentPubkey: string | undefined,
profiles: UserProfileLookup | undefined,
) {
if (channel.channelType !== "dm" || !isGenericDmChannelName(channel.name)) {
return channel.name;
}
const participants = channel.participantPubkeys.map((pubkey, index) => ({
fallbackName: channel.participants[index] ?? null,
pubkey,
}));
const otherParticipants = currentPubkey
? participants.filter(
(participant) =>
participant.pubkey.toLowerCase() !== currentPubkey.toLowerCase(),
)
: participants;
const resolvedLabels = (
otherParticipants.length > 0 ? otherParticipants : participants
).map((participant) =>
resolveUserLabel({
currentPubkey,
fallbackName: participant.fallbackName,
profiles,
pubkey: participant.pubkey,
}),
);
const uniqueLabels = [...new Set(resolvedLabels)];
return uniqueLabels.length > 0 ? uniqueLabels.join(", ") : channel.name;
}
+35 -116
View File
@@ -1,23 +1,19 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import {
Bot,
CircleDot,
Compass,
FileText,
Hash,
Home,
Plus,
Search,
} from "lucide-react";
import { Bot, Compass, Home, Plus, Search } from "lucide-react";
import * as React from "react";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
import { getPresenceLabel } from "@/features/presence/lib/presence";
import { usePresenceQuery } from "@/features/presence/hooks";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { resolveChannelDisplayLabel } from "@/features/sidebar/lib/channelLabels";
import {
ChannelMenuButton,
SidebarSection,
} from "@/features/sidebar/ui/SidebarSection";
import type { Channel, PresenceStatus, Profile } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import {
@@ -41,6 +37,7 @@ type AppSidebarProps = {
channels: Channel[];
currentPubkey?: string;
fallbackDisplayName?: string;
homeBadgeCount: number;
isLoading: boolean;
isCreatingChannel: boolean;
isCreatingForum: boolean;
@@ -66,111 +63,6 @@ type AppSidebarProps = {
onSelectSettings: () => void;
};
function SidebarChannelIcon({ channel }: { channel: Channel }) {
if (channel.channelType === "dm") {
return <CircleDot className="h-4 w-4" />;
}
if (channel.channelType === "forum") {
return <FileText className="h-4 w-4" />;
}
return <Hash className="h-4 w-4" />;
}
function ChannelMenuButton({
channel,
isActive,
hasUnread,
presenceStatus,
onSelectChannel,
}: {
channel: Channel;
isActive: boolean;
hasUnread: boolean;
presenceStatus?: PresenceStatus;
onSelectChannel: (channelId: string) => void;
}) {
return (
<SidebarMenuButton
className={cn(
!isActive &&
hasUnread &&
"font-semibold text-sidebar-foreground hover:text-sidebar-foreground",
)}
data-testid={`channel-${channel.name}`}
isActive={isActive}
onClick={() => onSelectChannel(channel.id)}
tooltip={channel.name}
type="button"
>
<SidebarChannelIcon channel={channel} />
<span className="min-w-0 flex-1 truncate">{channel.name}</span>
<div className="ml-auto flex items-center gap-2">
{presenceStatus ? (
<PresenceDot
className="h-2 w-2"
data-testid={`channel-presence-${channel.name}`}
status={presenceStatus}
/>
) : null}
{hasUnread && !isActive ? (
<span
aria-hidden="true"
className="h-2.5 w-2.5 shrink-0 rounded-full bg-primary"
data-testid={`channel-unread-${channel.name}`}
/>
) : null}
</div>
</SidebarMenuButton>
);
}
function SidebarSection({
items,
isActiveChannel,
presenceByChannelId,
selectedChannelId,
title,
testId,
unreadChannelIds,
onSelectChannel,
}: {
items: Channel[];
isActiveChannel: boolean;
presenceByChannelId?: Record<string, PresenceStatus>;
selectedChannelId: string | null;
title: string;
testId: string;
unreadChannelIds: Set<string>;
onSelectChannel: (channelId: string) => void;
}) {
if (items.length === 0) {
return null;
}
return (
<SidebarGroup>
<SidebarGroupLabel>{title}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu data-testid={testId}>
{items.map((channel) => (
<SidebarMenuItem key={channel.id}>
<ChannelMenuButton
channel={channel}
hasUnread={unreadChannelIds.has(channel.id)}
isActive={isActiveChannel && selectedChannelId === channel.id}
presenceStatus={presenceByChannelId?.[channel.id]}
onSelectChannel={onSelectChannel}
/>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
function StreamsSection({
items,
isCreateOpen,
@@ -425,6 +317,7 @@ export function AppSidebar({
channels,
currentPubkey,
fallbackDisplayName,
homeBadgeCount,
isLoading,
isCreatingChannel,
isCreatingForum,
@@ -479,6 +372,9 @@ export function AppSidebar({
const dmPresenceQuery = usePresenceQuery(dmParticipantPubkeys, {
enabled: directMessages.length > 0,
});
const dmProfilesQuery = useUsersBatchQuery(dmParticipantPubkeys, {
enabled: directMessages.length > 0,
});
const dmPresenceByChannelId = React.useMemo(
() =>
Object.fromEntries(
@@ -498,6 +394,20 @@ export function AppSidebar({
) satisfies Record<string, PresenceStatus>,
[currentPubkey, directMessages, dmPresenceQuery.data],
);
const dmChannelLabels = React.useMemo(
() =>
Object.fromEntries(
directMessages.map((channel) => [
channel.id,
resolveChannelDisplayLabel(
channel,
currentPubkey,
dmProfilesQuery.data?.profiles,
),
]),
),
[currentPubkey, directMessages, dmProfilesQuery.data],
);
const managedAgentsQuery = useManagedAgentsQuery();
const totalAgentCount = managedAgentsQuery.data?.length ?? 0;
const shouldShowAgentCount =
@@ -626,6 +536,14 @@ export function AppSidebar({
<Home className="h-4 w-4" />
<span>Home</span>
</SidebarMenuButton>
{homeBadgeCount > 0 ? (
<SidebarMenuBadge
className="right-2 rounded-full bg-primary/15 px-1.5 text-[11px] text-primary peer-data-[active=true]/menu-button:bg-sidebar-primary-foreground/20 peer-data-[active=true]/menu-button:text-sidebar-primary-foreground"
data-testid="sidebar-home-count"
>
{Math.min(homeBadgeCount, 99)}
</SidebarMenuBadge>
) : null}
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
@@ -753,6 +671,7 @@ export function AppSidebar({
<SidebarSection
isActiveChannel={selectedView === "channel"}
items={directMessages}
channelLabels={dmChannelLabels}
onSelectChannel={onSelectChannel}
presenceByChannelId={dmPresenceByChannelId}
selectedChannelId={selectedChannelId}
@@ -0,0 +1,126 @@
import { CircleDot, FileText, Hash } from "lucide-react";
import type { Channel, PresenceStatus } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/shared/ui/sidebar";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
function SidebarChannelIcon({ channel }: { channel: Channel }) {
if (channel.channelType === "dm") {
return <CircleDot className="h-4 w-4" />;
}
if (channel.channelType === "forum") {
return <FileText className="h-4 w-4" />;
}
return <Hash className="h-4 w-4" />;
}
export function ChannelMenuButton({
channel,
label,
isActive,
hasUnread,
presenceStatus,
onSelectChannel,
}: {
channel: Channel;
label?: string;
isActive: boolean;
hasUnread: boolean;
presenceStatus?: PresenceStatus;
onSelectChannel: (channelId: string) => void;
}) {
const resolvedLabel = label ?? channel.name;
return (
<SidebarMenuButton
className={cn(
!isActive &&
hasUnread &&
"font-semibold text-sidebar-foreground hover:text-sidebar-foreground",
)}
data-testid={`channel-${channel.name}`}
isActive={isActive}
onClick={() => onSelectChannel(channel.id)}
tooltip={resolvedLabel}
type="button"
>
<SidebarChannelIcon channel={channel} />
<span className="min-w-0 flex-1 truncate">{resolvedLabel}</span>
<div className="ml-auto flex items-center gap-2">
{presenceStatus ? (
<PresenceDot
className="h-2 w-2"
data-testid={`channel-presence-${channel.name}`}
status={presenceStatus}
/>
) : null}
{hasUnread && !isActive ? (
<span
aria-hidden="true"
className="h-2.5 w-2.5 shrink-0 rounded-full bg-primary"
data-testid={`channel-unread-${channel.name}`}
/>
) : null}
</div>
</SidebarMenuButton>
);
}
export function SidebarSection({
items,
channelLabels,
isActiveChannel,
presenceByChannelId,
selectedChannelId,
title,
testId,
unreadChannelIds,
onSelectChannel,
}: {
items: Channel[];
channelLabels?: Record<string, string>;
isActiveChannel: boolean;
presenceByChannelId?: Record<string, PresenceStatus>;
selectedChannelId: string | null;
title: string;
testId: string;
unreadChannelIds: Set<string>;
onSelectChannel: (channelId: string) => void;
}) {
if (items.length === 0) {
return null;
}
return (
<SidebarGroup>
<SidebarGroupLabel>{title}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu data-testid={testId}>
{items.map((channel) => (
<SidebarMenuItem key={channel.id}>
<ChannelMenuButton
channel={channel}
hasUnread={unreadChannelIds.has(channel.id)}
isActive={isActiveChannel && selectedChannelId === channel.id}
label={channelLabels?.[channel.id] ?? channel.name}
presenceStatus={presenceByChannelId?.[channel.id]}
onSelectChannel={onSelectChannel}
/>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
+100 -82
View File
@@ -304,6 +304,7 @@ declare global {
channelName: string;
pubkey?: string;
}) => RelayEvent;
__SPROUT_E2E_PUSH_MOCK_FEED_ITEM__?: (item: RawFeedItem) => RawFeedItem;
}
}
@@ -895,6 +896,12 @@ const mockPresence = new Map<string, PresenceStatus>([
[CHARLIE_PUBKEY, "online"],
[OUTSIDER_PUBKEY, "offline"],
]);
const mockFeedOverrides: RawHomeFeedResponse["feed"] = {
mentions: [],
needs_action: [],
activity: [],
agent_activity: [],
};
let installed = false;
let nextSocketId = 1;
@@ -1846,90 +1853,95 @@ async function handleGetFeed(
const includeType = (type: string) =>
wantedTypes.length === 0 || wantedTypes.includes(type);
const mentions = includeType("mentions")
? [
{
id: "mock-feed-mention",
kind: 9,
pubkey:
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
content: "Please review the release checklist.",
created_at: now - 90,
channel_id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
channel_name: "general",
tags: [
["e", "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"],
["p", DEFAULT_MOCK_IDENTITY.pubkey],
],
category: "mention" as const,
},
].slice(0, limit)
: [];
const defaultFeed: RawHomeFeedResponse["feed"] = {
mentions: [
{
id: "mock-feed-mention",
kind: 9,
pubkey:
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
content: "Please review the release checklist.",
created_at: now - 90,
channel_id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
channel_name: "general",
tags: [
["e", "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"],
["p", DEFAULT_MOCK_IDENTITY.pubkey],
],
category: "mention" as const,
},
],
needs_action: [
{
id: "mock-feed-reminder",
kind: 40007,
pubkey:
"0000000000000000000000000000000000000000000000000000000000000000",
content: "Reminder: update the launch plan before lunch.",
created_at: now - 15 * 60,
channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
channel_name: "agents",
tags: [
["e", "94a444a4-c0a3-5966-ab05-530c6ddc2301"],
["p", DEFAULT_MOCK_IDENTITY.pubkey],
],
category: "needs_action" as const,
},
],
activity: [
{
id: "mock-feed-self-activity",
kind: 9,
pubkey: DEFAULT_MOCK_IDENTITY.pubkey,
content: "I posted a note about the launch checklist.",
created_at: now - 25 * 60,
channel_id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
channel_name: "general",
tags: [["e", "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"]],
category: "activity" as const,
},
{
id: "mock-feed-activity",
kind: 9,
pubkey:
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
content: "Engineering shipped the desktop build.",
created_at: now - 42 * 60,
channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
channel_name: "engineering",
tags: [["e", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"]],
category: "activity" as const,
},
],
agent_activity: [
{
id: "mock-feed-agent",
kind: 43003,
pubkey:
"db0b028cd36f4d3e36c8300cce87252c1f7fc9495ffecc53f393fcac341ffd36",
content: "Agent progress: channel index complete.",
created_at: now - 2 * 60 * 60,
channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
channel_name: "agents",
tags: [["e", "94a444a4-c0a3-5966-ab05-530c6ddc2301"]],
category: "agent_activity" as const,
},
],
};
const needsAction = includeType("needs_action")
? [
{
id: "mock-feed-reminder",
kind: 40007,
pubkey:
"0000000000000000000000000000000000000000000000000000000000000000",
content: "Reminder: update the launch plan before lunch.",
created_at: now - 15 * 60,
channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
channel_name: "agents",
tags: [
["e", "94a444a4-c0a3-5966-ab05-530c6ddc2301"],
["p", DEFAULT_MOCK_IDENTITY.pubkey],
],
category: "needs_action" as const,
},
].slice(0, limit)
: [];
const mergeFeedCategory = (
category: keyof RawHomeFeedResponse["feed"],
): RawFeedItem[] =>
includeType(category)
? [...mockFeedOverrides[category], ...defaultFeed[category]]
.sort((left, right) => right.created_at - left.created_at)
.slice(0, limit)
: [];
const activity = includeType("activity")
? [
{
id: "mock-feed-self-activity",
kind: 9,
pubkey: DEFAULT_MOCK_IDENTITY.pubkey,
content: "I posted a note about the launch checklist.",
created_at: now - 25 * 60,
channel_id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
channel_name: "general",
tags: [["e", "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"]],
category: "activity" as const,
},
{
id: "mock-feed-activity",
kind: 9,
pubkey:
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
content: "Engineering shipped the desktop build.",
created_at: now - 42 * 60,
channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
channel_name: "engineering",
tags: [["e", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"]],
category: "activity" as const,
},
].slice(0, limit)
: [];
const agentActivity = includeType("agent_activity")
? [
{
id: "mock-feed-agent",
kind: 43003,
pubkey:
"db0b028cd36f4d3e36c8300cce87252c1f7fc9495ffecc53f393fcac341ffd36",
content: "Agent progress: channel index complete.",
created_at: now - 2 * 60 * 60,
channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
channel_name: "agents",
tags: [["e", "94a444a4-c0a3-5966-ab05-530c6ddc2301"]],
category: "agent_activity" as const,
},
].slice(0, limit)
: [];
const mentions = mergeFeedCategory("mentions");
const needsAction = mergeFeedCategory("needs_action");
const activity = mergeFeedCategory("activity");
const agentActivity = mergeFeedCategory("agent_activity");
return {
feed: {
@@ -2790,6 +2802,12 @@ export function maybeInstallE2eTauriMocks() {
return emitMockTypingIndicator(channel.id, pubkey ?? CHARLIE_PUBKEY);
};
window.__SPROUT_E2E_PUSH_MOCK_FEED_ITEM__ = (item) => {
const category = item.category === "mention" ? "mentions" : item.category;
mockFeedOverrides[category].unshift(item);
window.dispatchEvent(new CustomEvent("sprout:e2e-home-feed-updated"));
return item;
};
mockIPC(async (command, payload) => {
const activeConfig = getConfig();
const identity = getIdentity(activeConfig);
+23
View File
@@ -217,6 +217,29 @@ test("sidebar shows unread indicator for newly active channels", async ({
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
});
test("sidebar clears unread indicator after opening a DM", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("channel-unread-alice-tyler")).toHaveCount(0);
await page.evaluate((pubkey) => {
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "alice-tyler",
content: "Unread update for the DM",
pubkey,
});
}, TEST_IDENTITIES.alice.pubkey);
await expect(page.getByTestId("channel-unread-alice-tyler")).toBeVisible();
await page.getByTestId("channel-alice-tyler").click();
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
await expect(page.getByTestId("message-timeline")).toContainText(
"Unread update for the DM",
);
await expect(page.getByTestId("channel-unread-alice-tyler")).toHaveCount(0);
});
test("sidebar persists after channel switch", async ({ page }) => {
await page.goto("/");
+110
View File
@@ -88,6 +88,116 @@ test("updates presence from settings", async ({ page }) => {
);
});
test("notification settings drive the Home badge and desktop alerts", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByTestId("sidebar-home-count")).toHaveCount(0);
await page.getByTestId("open-settings").click();
await expect(page.getByTestId("settings-view")).toBeVisible();
await page.getByTestId("settings-nav-notifications").click();
await expect(page.getByTestId("settings-notifications")).toBeVisible();
await expect(page.getByTestId("notifications-desktop-state")).toContainText(
"Off",
);
await page.getByTestId("notifications-desktop-toggle").click();
await expect(page.getByTestId("notifications-desktop-state")).toContainText(
"On",
);
await page.getByTestId("settings-close").click();
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
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: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
channel_name: "engineering",
content: "Please review the rollout checklist.",
created_at: Math.floor(Date.now() / 1000) + 5,
id: `mock-feed-notification-${Date.now()}`,
kind: 9,
pubkey:
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
tags: [
["e", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"],
[
"p",
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
],
],
});
});
await expect(page.getByTestId("sidebar-home-count")).toHaveText("1");
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 notifications = await page.evaluate(() => {
const win = window as Window & {
__SPROUT_E2E_NOTIFICATIONS__?: Array<{
body: string | null;
title: string;
}>;
};
return win.__SPROUT_E2E_NOTIFICATIONS__ ?? [];
});
expect(notifications).toEqual([
{
body: "Please review the rollout checklist.",
title: "@Mention in #engineering",
},
]);
await page.getByTestId("open-settings").click();
await page.getByTestId("settings-nav-notifications").click();
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("sidebar-home-count")).toHaveCount(0);
await page.getByTestId("open-settings").click();
await page.getByTestId("settings-nav-notifications").click();
await page.getByTestId("notifications-home-badge-toggle").click();
await page.getByTestId("settings-close").click();
await expect(page.getByTestId("sidebar-home-count")).toHaveText("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);
});
test("opens settings with the keyboard shortcut and updates theme", async ({
page,
}) => {
+48 -7
View File
@@ -66,17 +66,58 @@ export async function installBridge(page: Page, options: BridgeOptions) {
await page.addInitScript(
({ identity: bridgeIdentity, mock, mode, relayHttpUrl, relayWsUrl }) => {
(
window as Window & {
__SPROUT_E2E__?: Record<string, unknown>;
const notificationLog: Array<{
body: string | null;
title: string;
}> = [];
class MockNotification extends EventTarget {
static permission: NotificationPermission = "granted";
static async requestPermission(): Promise<NotificationPermission> {
return MockNotification.permission;
}
).__SPROUT_E2E__ = {
identity: bridgeIdentity,
body: string | null;
title: string;
constructor(title: string, options?: NotificationOptions) {
super();
this.title = title;
this.body = options?.body ?? null;
notificationLog.push({
body: this.body,
title: this.title,
});
}
close() {}
}
Object.defineProperty(window, "Notification", {
configurable: true,
value: MockNotification,
writable: true,
});
const testWindow = window as Window & {
__SPROUT_E2E__?: Record<string, unknown>;
__SPROUT_E2E_NOTIFICATIONS__?: Array<{
body: string | null;
title: string;
}>;
};
const currentConfig = testWindow.__SPROUT_E2E__ ?? {};
testWindow.__SPROUT_E2E__ = {
...currentConfig,
identity: bridgeIdentity ?? currentConfig.identity,
mock,
mode,
relayHttpUrl,
relayWsUrl,
relayHttpUrl: relayHttpUrl ?? currentConfig.relayHttpUrl,
relayWsUrl: relayWsUrl ?? currentConfig.relayWsUrl,
};
testWindow.__SPROUT_E2E_NOTIFICATIONS__ = notificationLog;
},
{
identity,