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
- #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
405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|