Files
cinny/src/app/features/settings/notifications/SystemNotification.tsx
T
Lotus CIandClaude Opus 5.5 5d15013a27
CI / Build & Quality Checks (push) Skipped
CI / Secret scan (gitleaks) (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Build & Quality Checks (pull_request) Successful in 3m18s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 24m56s
feat(settings): quick wins from the settings audit (#453)
- #456 Copy pass: sentence case for every setting title and section,
  plainer names ("Hide joins and leaves", "Link previews", "Emoji
  style: Twemoji", "Rejoin calls after a restart", "Also use the
  browser's noise suppression", "Push-to-talk key"...) and grammar fixes
  ("when messages arrive", "Play a sound when a new message arrives",
  blocked users "Enter their Matrix ID"). Labels only: no setting is
  renamed in storage.
- #455 One theme control: Theme: System / Light / Dark. System shows the
  light and dark variant pickers, Light or Dark one picker of that kind.
  Replaces the System switch plus a Theme dropdown that was greyed out
  whenever the switch was on. Uses the existing settings (no migration);
  picking a variant also remembers it for System.
- #457 Remove the "Block Messages" stub from Notifications; the GIF
  picker row only appears in builds with a GIF key.
- #458 (part 1) "Export/Import Messages Data" -> "Export/Import
  encryption keys", with a sentence on when you need it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-25 01:37:37 -04:00

405 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text, Switch, Button, Chip, Icon, Icons, color, config, Spinner } from 'folds';
import { IPusherRequest } from 'matrix-js-sdk';
import { useAtomValue, useSetAtom } from 'jotai';
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
import { NOTIFICATION_SOUND_MAP } from '../../../utils/notificationSounds';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import {
isSnoozeActive,
nextTimeAtHour,
notificationSnoozeUntilAtom,
SNOOZE_INDEFINITE,
} from '../../../state/notificationSnooze';
import { formatFriendlyDateTime } from '../../../utils/datetimeInput';
import { getNotificationState, usePermissionState } from '../../../hooks/usePermission';
import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
function EmailNotification() {
const mx = useMatrixClient();
const [result, refreshResult] = useEmailNotifications();
const [setState, setEnable] = useAsyncCallback(
useCallback(
async (email: string, enable: boolean) => {
if (enable) {
await mx.setPusher({
kind: 'email',
app_id: 'm.email',
pushkey: email,
app_display_name: 'Email Notifications',
device_display_name: email,
lang: 'en',
data: {
brand: 'Lotus Chat',
},
append: true,
});
return;
}
await mx.setPusher({
pushkey: email,
app_id: 'm.email',
kind: null,
} as unknown as IPusherRequest);
},
[mx],
),
);
const handleChange = (value: boolean) => {
if (result && result.email) {
setEnable(result.email, value).then(() => {
refreshResult();
});
}
};
return (
<SettingTile
title="Email notifications"
description={
<>
{result && !result.email && (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
Your account does not have any email attached.
</Text>
)}
{result && result.email && <>Send notification to your email. {`("${result.email}")`}</>}
{result === null && (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
Unexpected Error!
</Text>
)}
{result === undefined && 'Send notification to your email.'}
</>
}
after={
<>
{setState.status !== AsyncStatus.Loading &&
typeof result === 'object' &&
result?.email && <Switch value={result.enabled} onChange={handleChange} />}
{(setState.status === AsyncStatus.Loading || result === undefined) && (
<Spinner variant="Secondary" />
)}
</>
}
/>
);
}
const SOUND_OPTIONS: { label: string; value: 'notification' | 'invite' | 'call' | 'none' }[] = [
{ label: 'Default', value: 'notification' },
{ label: 'Ping', value: 'invite' },
{ label: 'Call', value: 'call' },
{ label: 'None', value: 'none' },
];
const INVITE_SOUND_OPTIONS: {
label: string;
value: 'notification' | 'invite' | 'call' | 'none';
}[] = [
{ label: 'Default', value: 'invite' },
{ label: 'Ping', value: 'notification' },
{ label: 'Call', value: 'call' },
{ label: 'None', value: 'none' },
];
function playPreview(soundId: string) {
const src = NOTIFICATION_SOUND_MAP[soundId];
if (!src) return;
new Audio(src).play().catch(() => undefined);
}
const selectStyle: React.CSSProperties = {
background: color.SurfaceVariant.Container,
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
color: color.SurfaceVariant.OnContainer,
colorScheme: 'dark',
fontSize: '0.82rem',
padding: `${config.space.S100} ${config.space.S200}`,
cursor: 'pointer',
outline: 'none',
};
const SNOOZE_PRESETS: Array<{ label: string; resolve: (now: number) => number }> = [
{ label: '30 minutes', resolve: (now) => now + 30 * 60_000 },
{ label: '1 hour', resolve: (now) => now + 60 * 60_000 },
{ label: '4 hours', resolve: (now) => now + 4 * 60 * 60_000 },
{ label: 'Until 8 AM', resolve: (now) => nextTimeAtHour(8, now) },
{ label: 'Until I resume', resolve: () => SNOOZE_INDEFINITE },
];
// Cross-platform "pause notifications" — sets a snooze instant that the
// notification gate (ClientNonUIFeatures) reads to suppress alerts + sounds.
function PauseNotifications() {
const { prefs } = useTimestampFormatter();
const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
const setSnoozeUntil = useSetAtom(notificationSnoozeUntilAtom);
// While paused, tick so the status flips to "on" the moment the snooze lapses.
const [, setTick] = useState(0);
const active = isSnoozeActive(snoozeUntil);
useEffect(() => {
if (!active) return undefined;
const id = setInterval(() => setTick((n) => n + 1), 30_000);
return () => clearInterval(id);
}, [active]);
const status = !active
? 'Notifications are on.'
: snoozeUntil >= SNOOZE_INDEFINITE
? 'Paused until you resume.'
: `Paused until ${formatFriendlyDateTime(snoozeUntil, prefs)}.`;
return (
<SettingTile
title="Pause notifications"
description={<span style={active ? { color: color.Warning.Main } : undefined}>{status}</span>}
after={
active ? (
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={() => setSnoozeUntil(0)}
before={<Icon size="100" src={Icons.BellRing} />}
>
<Text size="B300">Resume</Text>
</Button>
) : undefined
}
>
<Box gap="200" wrap="Wrap" style={{ marginTop: config.space.S200 }}>
{SNOOZE_PRESETS.map((preset) => (
<Chip
key={preset.label}
variant="SurfaceVariant"
radii="Pill"
onClick={() => setSnoozeUntil(preset.resolve(Date.now()))}
>
<Text size="B300">{preset.label}</Text>
</Chip>
))}
</Box>
</SettingTile>
);
}
export function SystemNotification() {
const notifPermission = usePermissionState('notifications', getNotificationState());
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
const [isNotificationSounds, setIsNotificationSounds] = useSetting(
settingsAtom,
'isNotificationSounds',
);
const [messageSoundId, setMessageSoundId] = useSetting(settingsAtom, 'messageSoundId');
const [inviteSoundId, setInviteSoundId] = useSetting(settingsAtom, 'inviteSoundId');
const [quietHoursEnabled, setQuietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
const [quietHoursStart, setQuietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
const [quietHoursEnd, setQuietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
const requestNotificationPermission = () => {
window.Notification.requestPermission();
};
return (
<Box direction="Column" gap="100">
<Text size="L400">System</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Desktop notifications"
description={
notifPermission === 'denied' ? (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
{'Notification' in window
? 'Notification permission is blocked. Please allow notification permission from browser address bar.'
: 'Notifications are not supported by the system.'}
</Text>
) : (
<span>Show desktop notifications when messages arrive.</span>
)
}
after={
notifPermission === 'prompt' ? (
<Button size="300" radii="300" onClick={requestNotificationPermission}>
<Text size="B300">Enable</Text>
</Button>
) : (
<Switch
disabled={notifPermission !== 'granted'}
value={showNotifications}
onChange={setShowNotifications}
/>
)
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<PauseNotifications />
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Notification sound"
description="Play a sound when a new message arrives."
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
/>
{isNotificationSounds && (
<Box direction="Column" gap="300" style={{ paddingTop: config.space.S100 }}>
<Box alignItems="Center" gap="200">
<Text size="T300" style={{ flexGrow: 1, color: color.SurfaceVariant.OnContainer }}>
Message Sound
</Text>
<select
value={messageSoundId}
onChange={(e) =>
setMessageSoundId(e.target.value as 'notification' | 'invite' | 'call' | 'none')
}
aria-label="Message notification sound"
style={selectStyle}
>
{SOUND_OPTIONS.map((opt) => (
<option
key={opt.value}
value={opt.value}
style={{
background: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
}}
>
{opt.label}
</option>
))}
</select>
{messageSoundId !== 'none' && (
<Button
size="300"
radii="300"
variant="Secondary"
onClick={() => playPreview(messageSoundId)}
aria-label="Preview message sound"
>
<Text size="B300">▶</Text>
</Button>
)}
</Box>
<Box alignItems="Center" gap="200">
<Text size="T300" style={{ flexGrow: 1, color: color.SurfaceVariant.OnContainer }}>
Invite Sound
</Text>
<select
value={inviteSoundId}
onChange={(e) =>
setInviteSoundId(e.target.value as 'notification' | 'invite' | 'call' | 'none')
}
aria-label="Invite notification sound"
style={selectStyle}
>
{INVITE_SOUND_OPTIONS.map((opt) => (
<option
key={opt.value}
value={opt.value}
style={{
background: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
}}
>
{opt.label}
</option>
))}
</select>
{inviteSoundId !== 'none' && (
<Button
size="300"
radii="300"
variant="Secondary"
onClick={() => playPreview(inviteSoundId)}
aria-label="Preview invite sound"
>
<Text size="B300">▶</Text>
</Button>
)}
</Box>
</Box>
)}
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Quiet hours"
description="Notifications are silenced between these times. Handles overnight (e.g. 11:00 PM – 8:00 AM)."
after={<Switch value={quietHoursEnabled} onChange={setQuietHoursEnabled} />}
/>
{quietHoursEnabled && (
<Box direction="Column" gap="300" style={{ paddingTop: config.space.S100 }}>
<Box alignItems="Center" gap="200">
<Text size="T300" style={{ flexGrow: 1, color: color.SurfaceVariant.OnContainer }}>
Start
</Text>
<input
type="time"
value={quietHoursStart}
onChange={(e) => setQuietHoursStart(e.target.value)}
aria-label="Quiet hours start time"
style={selectStyle}
/>
</Box>
<Box alignItems="Center" gap="200">
<Text size="T300" style={{ flexGrow: 1, color: color.SurfaceVariant.OnContainer }}>
End
</Text>
<input
type="time"
value={quietHoursEnd}
onChange={(e) => setQuietHoursEnd(e.target.value)}
aria-label="Quiet hours end time"
style={selectStyle}
/>
</Box>
{(!quietHoursStart || !quietHoursEnd) && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Set both a start and end time — quiet hours stay inactive until both are filled in.
</Text>
)}
</Box>
)}
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<EmailNotification />
</SequenceCard>
</Box>
);
}