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:
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user