Files
cinny/src/app/features/settings/notifications/SystemNotification.tsx
T
jared bb8f9032ee
CI / Build & Quality Checks (push) Successful in 10m27s
feat: P2-3 sort rooms, P2-5 quiet hours, P2-2 custom notification sounds
P2-3 — Sort Non-Space Rooms:
- homeRoomSort: 'recent' | 'alpha' | 'unread' setting (default 'recent')
- factoryRoomIdByUnread comparator: unread rooms first, tie-break by count
- Sort icon button in Rooms NavCategoryHeader opens PopOut menu with
  three options (Recent Activity / A→Z / Unread First), checkmark on active
- Collapsed state still filters to unread-only regardless of sort choice

P2-5 — Notification Quiet Hours:
- quietHoursEnabled / quietHoursStart / quietHoursEnd added to settings
  (defaults: false, '23:00', '08:00')
- isInQuietHours() helper handles both normal and overnight spans;
  start===end treated as zero-length window (disabled) to avoid silent no-op
- Both InviteNotifications and MessageNotifications gate notify() and
  playSound() behind the quiet-hours check
- Settings → Notifications: new Quiet Hours card with Switch + two
  <input type="time"> fields (only shown when enabled)

P2-2 — Custom Notification Sounds:
- messageSoundId / inviteSoundId settings: 'notification'|'invite'|'call'|'none'
- notificationSounds.ts: shared NOTIFICATION_SOUND_MAP (removes duplication
  between ClientNonUIFeatures and SystemNotification — code review fix)
- Audio source updated reactively via useEffect when sound ID changes
- Settings → Notifications: Message Sound + Invite Sound selects expand
  when the master sound toggle is on; each has a ▶ preview button
- playPreview() catches audio.play() rejections (code review fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 19:41:02 -04:00

318 lines
11 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 } from 'react';
import { Box, Text, Switch, Button, color, config, Spinner } from 'folds';
import { IPusherRequest } from 'matrix-js-sdk';
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 { 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 Notification"
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',
};
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 message 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"
>
<SettingTile
title="Notification Sound"
description="Play sound when new message arrive."
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>
</Box>
)}
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<EmailNotification />
</SequenceCard>
</Box>
);
}