fix(desktop): restore notification sounds (#2582)

Restore desktop notification sounds by default, add a persisted Play sound setting, and group notification permission and testing in a dedicated Notifications section.
This commit is contained in:
Glen Stampoultzis
2026-08-08 22:28:37 +02:00
committed by GitHub
parent b19db9537c
commit b9b3a24d58
24 changed files with 451 additions and 123 deletions
@@ -0,0 +1,150 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import { RotateCw } from "lucide-react-native";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { DesktopPermissionRow } from "@/desktop/components/desktop-permission-row";
import { useDesktopPermissions } from "@/desktop/permissions/use-desktop-permissions";
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings";
const ThemedRotateCw = withUnistyles(RotateCw, (theme) => ({
size: theme.iconSize.md,
color: theme.colors.foregroundMuted,
}));
export function DesktopNotificationsSection() {
const { t } = useTranslation();
const { settings, isSaving, updateSettings } = useDesktopSettings();
const {
isDesktopApp,
snapshot,
isRefreshing,
requestingPermission,
testNotificationState,
refreshPermissions,
requestPermission,
sendTestNotification,
} = useDesktopPermissions();
const handleRefreshPress = useCallback(() => {
void refreshPermissions();
}, [refreshPermissions]);
const handleRequestNotifications = useCallback(() => {
void requestPermission("notifications");
}, [requestPermission]);
const handlePlaySoundChange = useCallback(
(playSound: boolean) => {
void updateSettings({ notifications: { playSound } }).catch(() => {
// useDesktopSettings owns the user-visible IPC error.
});
},
[updateSettings],
);
const handleSendTestNotification = useCallback(() => {
void sendTestNotification();
}, [sendTestNotification]);
const isPermissionBusy = isRefreshing || requestingPermission !== null;
const isSendingTestNotification = testNotificationState.status === "sending";
const refreshIcon = useMemo(() => <ThemedRotateCw />, []);
const refreshButton = useMemo(
() => (
<Button
variant="ghost"
size="sm"
leftIcon={refreshIcon}
onPress={handleRefreshPress}
disabled={isPermissionBusy}
accessibilityLabel={t("settings.notifications.refreshAccessibility")}
>
{isRefreshing ? t("settings.permissions.refreshing") : t("settings.permissions.refresh")}
</Button>
),
[handleRefreshPress, isPermissionBusy, isRefreshing, refreshIcon, t],
);
const permissionLabels = useMemo(
() => ({
granted: t("settings.permissions.actions.granted"),
request: t("settings.permissions.actions.request"),
requesting: t("settings.permissions.actions.requesting"),
}),
[t],
);
if (!isDesktopApp) {
return null;
}
const notificationsGranted = snapshot?.notifications.state === "granted";
return (
<SettingsSection title={t("settings.notifications.title")} trailing={refreshButton}>
<View style={settingsStyles.card}>
<DesktopPermissionRow
title={t("settings.notifications.permission")}
status={snapshot?.notifications ?? null}
isRequesting={requestingPermission === "notifications"}
onRequest={handleRequestNotifications}
labels={permissionLabels}
/>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.notifications.playSound")}</Text>
<Text style={settingsStyles.rowHint}>{t("settings.notifications.playSoundHint")}</Text>
</View>
<Switch
value={settings.notifications.playSound}
onValueChange={handlePlaySoundChange}
disabled={isSaving}
accessibilityLabel={t("settings.notifications.playSound")}
testID="desktop-notifications-play-sound-switch"
/>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.notifications.test")}</Text>
<Text style={settingsStyles.rowHint}>
{notificationsGranted
? t("settings.notifications.testHint")
: t("settings.notifications.permissionRequired")}
</Text>
</View>
<Button
variant="outline"
size="sm"
onPress={handleSendTestNotification}
disabled={!notificationsGranted || isPermissionBusy || isSendingTestNotification}
>
{isSendingTestNotification
? t("settings.notifications.sending")
: t("settings.notifications.send")}
</Button>
</View>
</View>
{testNotificationState.status === "success" ? (
<Alert
variant="success"
title={t("settings.notifications.sentTitle")}
description={t("settings.notifications.sentDescription")}
testID="desktop-notifications-test-success"
/>
) : null}
{testNotificationState.status === "error" ? (
<Alert
variant="error"
title={t("settings.notifications.sendFailedTitle")}
description={testNotificationState.message}
testID="desktop-notifications-test-error"
/>
) : null}
</SettingsSection>
);
}
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Check } from "lucide-react-native";
import { Button } from "@/components/ui/button";
import { settingsStyles } from "@/styles/settings";
@@ -14,16 +14,16 @@ export interface DesktopPermissionRowProps {
granted: string;
request: string;
requesting: string;
busyExtraAction: (label: string) => string;
};
showBorder?: boolean;
onRequest: () => void;
extraActionLabel?: string;
isExtraActionBusy?: boolean;
isExtraActionDisabled?: boolean;
onExtraAction?: () => void;
}
const ThemedCheck = withUnistyles(Check, (theme) => ({
size: theme.iconSize.sm,
color: theme.colors.foregroundMuted,
}));
export function DesktopPermissionRow({
title,
status,
@@ -31,12 +31,7 @@ export function DesktopPermissionRow({
labels,
showBorder,
onRequest,
extraActionLabel,
isExtraActionBusy = false,
isExtraActionDisabled = false,
onExtraAction,
}: DesktopPermissionRowProps) {
const { theme } = useUnistyles();
const state = status?.state ?? "unknown";
const isGranted = state === "granted";
const shouldShowDetail =
@@ -58,21 +53,9 @@ export function DesktopPermissionRow({
</View>
<View style={styles.permissionRowActions}>
{isGranted ? (
<View style={styles.permissionGrantedActions}>
<View style={styles.permissionStatusPill}>
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.permissionStatusText}>{labels.granted}</Text>
</View>
{extraActionLabel && onExtraAction ? (
<Button
variant="outline"
size="sm"
onPress={onExtraAction}
disabled={isExtraActionDisabled || isExtraActionBusy}
>
{isExtraActionBusy ? labels.busyExtraAction(extraActionLabel) : extraActionLabel}
</Button>
) : null}
<View style={styles.permissionStatusPill}>
<ThemedCheck />
<Text style={styles.permissionStatusText}>{labels.granted}</Text>
</View>
) : (
<Button variant="outline" size="sm" onPress={onRequest} disabled={isRequesting}>
@@ -92,11 +75,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "flex-end",
gap: theme.spacing[1],
},
permissionGrantedActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
permissionStatusPill: {
flexDirection: "row",
alignItems: "center",
@@ -1,7 +1,7 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { View } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import { RotateCw } from "lucide-react-native";
import { Button } from "@/components/ui/button";
import { DesktopPermissionRow } from "@/desktop/components/desktop-permission-row";
@@ -9,49 +9,33 @@ import { useDesktopPermissions } from "@/desktop/permissions/use-desktop-permiss
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section";
const ThemedRotateCw = withUnistyles(RotateCw, (theme) => ({
size: theme.iconSize.md,
color: theme.colors.foregroundMuted,
}));
export function DesktopPermissionsSection() {
const { t } = useTranslation();
const { theme } = useUnistyles();
const {
isDesktopApp,
snapshot,
isRefreshing,
requestingPermission,
isSendingTestNotification,
testNotificationError,
refreshPermissions,
requestPermission,
sendTestNotification,
} = useDesktopPermissions();
const errorTextStyle = useMemo(
() => [styles.errorText, { color: theme.colors.destructive }],
[theme.colors.destructive],
);
const handleRefreshPress = useCallback(() => {
void refreshPermissions();
}, [refreshPermissions]);
const handleRequestNotifications = useCallback(() => {
void requestPermission("notifications");
}, [requestPermission]);
const handleRequestMicrophone = useCallback(() => {
void requestPermission("microphone");
}, [requestPermission]);
const handleSendTestNotification = useCallback(() => {
void sendTestNotification();
}, [sendTestNotification]);
const isBusy = isRefreshing || requestingPermission !== null;
const notificationsGranted = snapshot?.notifications.state === "granted";
const refreshIcon = useMemo(
() => <RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />,
[theme.iconSize.md, theme.colors.foregroundMuted],
);
const refreshIcon = useMemo(() => <ThemedRotateCw />, []);
const refreshButton = useMemo(
() => (
@@ -74,7 +58,6 @@ export function DesktopPermissionsSection() {
granted: t("settings.permissions.actions.granted"),
request: t("settings.permissions.actions.request"),
requesting: t("settings.permissions.actions.requesting"),
busyExtraAction: (label: string) => t("settings.permissions.actions.busySuffix", { label }),
}),
[t],
);
@@ -86,21 +69,8 @@ export function DesktopPermissionsSection() {
return (
<SettingsSection title={t("settings.permissions.title")} trailing={refreshButton}>
<View style={settingsStyles.card}>
<DesktopPermissionRow
title={t("settings.permissions.notifications")}
status={snapshot?.notifications ?? null}
isRequesting={requestingPermission === "notifications"}
onRequest={handleRequestNotifications}
labels={permissionLabels}
extraActionLabel={t("settings.permissions.test")}
isExtraActionBusy={isSendingTestNotification}
isExtraActionDisabled={!notificationsGranted || isBusy}
onExtraAction={handleSendTestNotification}
/>
{testNotificationError ? <Text style={errorTextStyle}>{testNotificationError}</Text> : null}
<DesktopPermissionRow
title={t("settings.permissions.microphone")}
showBorder
status={snapshot?.microphone ?? null}
isRequesting={requestingPermission === "microphone"}
onRequest={handleRequestMicrophone}
@@ -110,11 +80,3 @@ export function DesktopPermissionsSection() {
</SettingsSection>
);
}
const styles = StyleSheet.create((theme) => ({
errorText: {
fontSize: theme.fontSize.xs,
paddingHorizontal: theme.spacing[4],
paddingBottom: theme.spacing[2],
},
}));
@@ -14,13 +14,18 @@ export interface UseDesktopPermissionsReturn {
snapshot: DesktopPermissionSnapshot | null;
isRefreshing: boolean;
requestingPermission: DesktopPermissionKind | null;
isSendingTestNotification: boolean;
testNotificationError: string | null;
testNotificationState: TestNotificationState;
refreshPermissions: () => Promise<void>;
requestPermission: (kind: DesktopPermissionKind) => Promise<void>;
sendTestNotification: () => Promise<void>;
}
export type TestNotificationState =
| { status: "idle" }
| { status: "sending" }
| { status: "success" }
| { status: "error"; message: string };
export function useDesktopPermissions(): UseDesktopPermissionsReturn {
const { t } = useTranslation();
const isDesktopApp = shouldShowDesktopPermissionSection();
@@ -30,7 +35,9 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
const [requestingPermission, setRequestingPermission] = useState<DesktopPermissionKind | null>(
null,
);
const [isSendingTestNotification, setIsSendingTestNotification] = useState(false);
const [testNotificationState, setTestNotificationState] = useState<TestNotificationState>({
status: "idle",
});
useEffect(() => {
return () => {
@@ -111,28 +118,34 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
[isDesktopApp, refreshPermissions, t],
);
const [testNotificationError, setTestNotificationError] = useState<string | null>(null);
const sendTestNotification = useCallback(async () => {
if (!isDesktopApp) {
return;
}
setIsSendingTestNotification(true);
setTestNotificationError(null);
setTestNotificationState({ status: "sending" });
try {
const sent = await sendOsNotification({
title: t("desktop.permissions.testNotification.title"),
body: t("desktop.permissions.testNotification.body"),
});
if (!sent) {
setTestNotificationError(t("desktop.permissions.testNotification.notDelivered"));
if (!isMountedRef.current) {
return;
}
setTestNotificationState(
sent
? { status: "success" }
: {
status: "error",
message: t("desktop.permissions.testNotification.notDelivered"),
},
);
} catch {
setTestNotificationError(t("desktop.permissions.testNotification.failed"));
} finally {
if (isMountedRef.current) {
setIsSendingTestNotification(false);
setTestNotificationState({
status: "error",
message: t("desktop.permissions.testNotification.failed"),
});
}
}
}, [isDesktopApp, t]);
@@ -150,8 +163,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
snapshot,
isRefreshing,
requestingPermission,
isSendingTestNotification,
testNotificationError,
testNotificationState,
refreshPermissions,
requestPermission,
sendTestNotification,
@@ -13,6 +13,9 @@ const DESKTOP_SETTINGS_QUERY_KEY = ["desktop-settings"] as const;
export interface DesktopSettings {
releaseChannel: ReleaseChannel;
notifications: {
playSound: boolean;
};
daemon: {
manageBuiltInDaemon: boolean;
keepRunningAfterQuit: boolean;
@@ -21,11 +24,15 @@ export interface DesktopSettings {
export interface DesktopSettingsPatch {
releaseChannel?: ReleaseChannel;
notifications?: Partial<DesktopSettings["notifications"]>;
daemon?: Partial<DesktopSettings["daemon"]>;
}
export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
releaseChannel: "stable",
notifications: {
playSound: true,
},
daemon: {
manageBuiltInDaemon: true,
keepRunningAfterQuit: false,
@@ -144,10 +151,17 @@ export async function migrateLegacyDesktopSettings(input: {
function parseDesktopSettings(raw: unknown): DesktopSettings {
const record = isRecord(raw) ? raw : {};
const notifications = isRecord(record.notifications) ? record.notifications : {};
const daemon = isRecord(record.daemon) ? record.daemon : {};
return {
releaseChannel: record.releaseChannel === "beta" ? "beta" : "stable",
notifications: {
playSound:
typeof notifications.playSound === "boolean"
? notifications.playSound
: DEFAULT_DESKTOP_SETTINGS.notifications.playSound,
},
daemon: {
manageBuiltInDaemon:
typeof daemon.manageBuiltInDaemon === "boolean"
@@ -167,6 +181,10 @@ function mergeDesktopSettings(
): DesktopSettings {
return {
releaseChannel: updates.releaseChannel ?? current.releaseChannel,
notifications: {
...current.notifications,
...updates.notifications,
},
daemon: {
...current.daemon,
...updates.daemon,
@@ -177,6 +195,7 @@ function mergeDesktopSettings(
function normalizePatch(updates: DesktopSettingsPatch): Record<string, unknown> {
return {
...(updates.releaseChannel ? { releaseChannel: updates.releaseChannel } : {}),
...(updates.notifications ? { notifications: updates.notifications } : {}),
...(updates.daemon ? { daemon: updates.daemon } : {}),
};
}