feat(desktop): notification sound + sender names in titles (#501)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-05-07 17:25:29 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 7b2c615d8f
commit 5fff9239fa
12 changed files with 146 additions and 12 deletions
Binary file not shown.
+1
View File
@@ -38,6 +38,7 @@ const overrides = new Map([
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
["src/features/channels/ui/ChannelPane.tsx", 520], // composer/timeline/sidebar orchestration + anchored agent activity footers
["src/features/channels/ui/ChannelScreen.tsx", 550], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification
["src/features/notifications/hooks.ts", 535], // notification settings + feed notification lifecycle + profile batch resolution + truncated-pubkey guard + badge state
["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates
["src/features/messages/ui/MessageComposer.tsx", 700], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape)
["src/features/settings/ui/SettingsView.tsx", 600],
+7 -1
View File
@@ -27,6 +27,7 @@ import {
setDesktopAppBadgeCount,
type DesktopNotificationTarget,
} from "@/features/notifications/lib/desktop";
import { playNotificationSound } from "@/features/notifications/lib/sound";
import { PreventSleepProvider } from "@/features/agents/usePreventSleep";
import {
usePresenceSession,
@@ -187,7 +188,7 @@ export function AppShell() {
: "New message";
void sendDesktopNotification({
title: "Direct message",
title: channelName,
body,
target: {
channelId: channel.id,
@@ -198,6 +199,10 @@ export function AppShell() {
kind: event.kind,
pubkey: event.pubkey,
},
}).then((didSend) => {
if (didSend && notificationSettings.settings.soundEnabled) {
playNotificationSound();
}
});
},
);
@@ -668,6 +673,7 @@ export function AppShell() {
onSetNeedsActionNotificationsEnabled={
notificationSettings.setNeedsActionEnabled
}
onSetSoundEnabled={notificationSettings.setSoundEnabled}
section={settingsSection}
/>
</React.Suspense>
+70 -5
View File
@@ -1,6 +1,12 @@
import * as React from "react";
import { useHomeFeedQuery } from "@/features/home/hooks";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import {
resolveUserLabel,
truncatePubkey,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import type { FeedItem, HomeFeedResponse } from "@/shared/api/types";
import {
collectHomeAlertItems,
@@ -14,6 +20,7 @@ import {
sendDesktopNotification,
type DesktopNotificationPermissionState,
} from "./lib/desktop";
import { playNotificationSound } from "./lib/sound";
export type { DesktopNotificationPermissionState } from "./lib/desktop";
@@ -26,6 +33,7 @@ export type NotificationSettings = {
homeBadgeEnabled: boolean;
mentions: boolean;
needsAction: boolean;
soundEnabled: boolean;
};
const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
@@ -33,6 +41,7 @@ const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
homeBadgeEnabled: true,
mentions: true,
needsAction: true,
soundEnabled: true,
};
function notificationSettingsStorageKey(pubkey: string) {
@@ -66,6 +75,10 @@ function sanitizeNotificationSettings(value: unknown): NotificationSettings {
typeof candidate.needsAction === "boolean"
? candidate.needsAction
: DEFAULT_NOTIFICATION_SETTINGS.needsAction,
soundEnabled:
typeof candidate.soundEnabled === "boolean"
? candidate.soundEnabled
: DEFAULT_NOTIFICATION_SETTINGS.soundEnabled,
};
}
@@ -267,6 +280,13 @@ export function useNotificationSettings(pubkey?: string) {
}));
}, []);
const setSoundEnabled = React.useCallback((enabled: boolean) => {
setSettings((current) => ({
...current,
soundEnabled: enabled,
}));
}, []);
return {
errorMessage,
isUpdatingDesktopEnabled,
@@ -275,6 +295,7 @@ export function useNotificationSettings(pubkey?: string) {
setHomeBadgeEnabled,
setMentionsEnabled,
setNeedsActionEnabled,
setSoundEnabled,
settings,
};
}
@@ -283,6 +304,7 @@ export function useFeedDesktopNotifications(
feed: HomeFeedResponse | undefined,
pubkey: string | undefined,
settings: NotificationSettings,
profiles?: UserProfileLookup,
) {
const normalizedPubkey = pubkey?.trim().toLowerCase() ?? "";
const seenItemIdsRef = React.useRef<Set<string>>(
@@ -294,8 +316,8 @@ export function useFeedDesktopNotifications(
}, [normalizedPubkey]);
const deliverFeedNotification = React.useEffectEvent(
async (item: FeedItem) => {
await sendDesktopNotification({
async (item: FeedItem, senderName?: string) => {
const didSend = await sendDesktopNotification({
body: notificationBody(item),
target: {
channelId: item.channelId,
@@ -306,8 +328,12 @@ export function useFeedDesktopNotifications(
kind: item.kind,
pubkey: item.pubkey,
},
title: notificationTitle(item),
title: notificationTitle(item, senderName),
});
if (didSend && settings.soundEnabled) {
playNotificationSound();
}
},
);
@@ -316,6 +342,14 @@ export function useFeedDesktopNotifications(
return;
}
// Wait for sender profiles to load so notification titles include names.
// The first-load seed below marks all current items as seen, so we must
// defer it until profiles are available — otherwise items get marked seen
// before we can dispatch notifications with sender names.
if (profiles === undefined) {
return;
}
const currentFeedItems = collectHomeAlertItems(feed);
// Guard: empty seen set + populated feed means first load or cleared
@@ -353,11 +387,24 @@ export function useFeedDesktopNotifications(
writeStoredSeenFeedIds(normalizedPubkey, [...nextSeenItemIds]);
for (const item of newItems) {
void deliverFeedNotification(item);
const resolvedLabel = profiles
? resolveUserLabel({
pubkey: item.pubkey,
profiles,
preferResolvedSelfLabel: true,
})
: undefined;
// Only use real display names, not truncated pubkey fallbacks.
const senderName =
resolvedLabel && resolvedLabel !== truncatePubkey(item.pubkey)
? resolvedLabel
: undefined;
void deliverFeedNotification(item, senderName);
}
}, [
feed,
normalizedPubkey,
profiles,
settings.desktopEnabled,
settings.mentions,
settings.needsAction,
@@ -369,8 +416,9 @@ export function useHomeFeedNotificationState(
pubkey: string | undefined,
settings: NotificationSettings,
isHomeActive: boolean,
profiles?: UserProfileLookup,
) {
useFeedDesktopNotifications(feed, pubkey, settings);
useFeedDesktopNotifications(feed, pubkey, settings, profiles);
const normalizedPubkey = pubkey?.trim().toLowerCase() ?? "";
const [seenFeedIds, setSeenFeedIds] = React.useState<string[]>(() =>
readStoredSeenFeedIds(normalizedPubkey),
@@ -451,11 +499,28 @@ export function useHomeFeedNotifications(
};
}, []);
const feedItems = React.useMemo(
() =>
homeFeedQuery.data
? [
...homeFeedQuery.data.feed.mentions,
...homeFeedQuery.data.feed.needsAction,
]
: [],
[homeFeedQuery.data],
);
const feedProfilesQuery = useUsersBatchQuery(
feedItems.map((item) => item.pubkey),
{ enabled: feedItems.length > 0 },
);
const homeBadgeCount = useHomeFeedNotificationState(
homeFeedQuery.data,
pubkey,
notificationSettings.settings,
isHomeActive,
feedProfilesQuery.data?.profiles,
);
return {
@@ -220,6 +220,7 @@ export async function sendDesktopNotification(
const notification = new window.Notification(payload.title, {
body: payload.body,
silent: true,
extra: notificationExtra(payload.target),
} as DesktopNotificationOptions);
+11 -5
View File
@@ -2,24 +2,30 @@ import type { FeedItem, HomeFeedResponse } from "@/shared/api/types";
const FEED_NOTIFICATION_BODY_MAX_LENGTH = 140;
export function notificationTitle(item: FeedItem) {
export function notificationTitle(item: FeedItem, senderName?: string) {
const channelLabel = item.channelName.trim()
? ` in #${item.channelName.trim()}`
: "";
if (item.channelType === "dm") {
return "Direct message";
return senderName || "Direct message";
}
if (item.category === "mention") {
return `@Mention${channelLabel}`;
return senderName
? `${senderName} mentioned you${channelLabel}`
: `@Mention${channelLabel}`;
}
if (item.kind === 46010) {
return `Approval Requested${channelLabel}`;
return senderName
? `${senderName} requested approval${channelLabel}`
: `Approval Requested${channelLabel}`;
}
return `Needs Action${channelLabel}`;
return senderName
? `${senderName}${channelLabel}`
: `Needs Action${channelLabel}`;
}
export function notificationBody(item: FeedItem) {
@@ -0,0 +1,20 @@
let cachedAudio: HTMLAudioElement | null = null;
function getNotificationAudio(): HTMLAudioElement {
if (!cachedAudio) {
cachedAudio = new Audio("/sounds/desktop-notification.mp3");
}
return cachedAudio;
}
export function playNotificationSound(): void {
try {
const audio = getNotificationAudio();
audio.currentTime = 0;
audio.play().catch(() => {
// Best-effort — user may not have interacted with the page yet.
});
} catch {
// Best-effort only.
}
}
@@ -13,6 +13,7 @@ export function NotificationSettingsCard({
onSetHomeBadgeEnabled,
onSetMentionNotificationsEnabled,
onSetNeedsActionNotificationsEnabled,
onSetSoundEnabled,
}: {
isUpdatingDesktopNotifications: boolean;
notificationErrorMessage: string | null;
@@ -22,6 +23,7 @@ export function NotificationSettingsCard({
onSetHomeBadgeEnabled: (enabled: boolean) => void;
onSetMentionNotificationsEnabled: (enabled: boolean) => void;
onSetNeedsActionNotificationsEnabled: (enabled: boolean) => void;
onSetSoundEnabled: (enabled: boolean) => void;
}) {
const permissionBlocked =
notificationPermission === "denied" ||
@@ -75,6 +77,32 @@ export function NotificationSettingsCard({
/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<label
className="text-sm font-medium"
htmlFor="notification-sound-switch"
>
Notification sound
</label>
<p className="text-sm text-muted-foreground">
Play a sound when a desktop notification fires.
</p>
</div>
<Switch
checked={
notificationSettings.desktopEnabled &&
notificationSettings.soundEnabled
}
data-testid="notifications-sound-toggle"
disabled={!notificationSettings.desktopEnabled}
id="notification-sound-switch"
onCheckedChange={(checked) => {
onSetSoundEnabled(checked);
}}
/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<label className="text-sm font-medium" htmlFor="home-badge-switch">
@@ -61,6 +61,7 @@ export type SettingsPanelProps = {
onSetHomeBadgeEnabled: (enabled: boolean) => void;
onSetMentionNotificationsEnabled: (enabled: boolean) => void;
onSetNeedsActionNotificationsEnabled: (enabled: boolean) => void;
onSetSoundEnabled: (enabled: boolean) => void;
};
export const settingsSections: SettingsSectionDescriptor[] = [
@@ -254,6 +255,7 @@ export function renderSettingsSection(
onSetNeedsActionNotificationsEnabled={
props.onSetNeedsActionNotificationsEnabled
}
onSetSoundEnabled={props.onSetSoundEnabled}
/>
);
case "agents":
@@ -16,6 +16,7 @@ type SettingsScreenProps = {
onSetHomeBadgeEnabled: (enabled: boolean) => void;
onSetMentionNotificationsEnabled: (enabled: boolean) => void;
onSetNeedsActionNotificationsEnabled: (enabled: boolean) => void;
onSetSoundEnabled: (enabled: boolean) => void;
section: SettingsSection;
};
@@ -32,6 +33,7 @@ export function SettingsScreen({
onSetHomeBadgeEnabled,
onSetMentionNotificationsEnabled,
onSetNeedsActionNotificationsEnabled,
onSetSoundEnabled,
section,
}: SettingsScreenProps) {
return (
@@ -50,6 +52,7 @@ export function SettingsScreen({
onSetNeedsActionNotificationsEnabled={
onSetNeedsActionNotificationsEnabled
}
onSetSoundEnabled={onSetSoundEnabled}
section={section}
/>
);
@@ -75,6 +75,7 @@ export function SettingsView({
onSetHomeBadgeEnabled,
onSetMentionNotificationsEnabled,
onSetNeedsActionNotificationsEnabled,
onSetSoundEnabled,
section,
}: SettingsViewProps) {
const myMembershipQuery = useMyRelayMembershipQuery();
@@ -213,6 +214,7 @@ export function SettingsView({
onSetHomeBadgeEnabled,
onSetMentionNotificationsEnabled,
onSetNeedsActionNotificationsEnabled,
onSetSoundEnabled,
})}
</div>
</section>
+1 -1
View File
@@ -164,7 +164,7 @@ test("notification settings drive the Home badge and desktop alerts", async ({
expect(notifications).toEqual([
{
body: "Please review the rollout checklist.",
title: "@Mention in #engineering",
title: "bob mentioned you in #engineering",
},
]);