mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add desktop notifications and improve DM labeling (#107)
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+10
@@ -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
|
||||
|
||||
Generated
+69
-1
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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("/");
|
||||
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user