feat: P2-3 sort rooms, P2-5 quiet hours, P2-2 custom notification sounds
CI / Build & Quality Checks (push) Successful in 10m27s
CI / Build & Quality Checks (push) Successful in 10m27s
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>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { Box, Text, Switch, Button, color, Spinner } from 'folds';
|
||||
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';
|
||||
@@ -84,6 +85,41 @@ function EmailNotification() {
|
||||
);
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -91,6 +127,11 @@ export function SystemNotification() {
|
||||
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();
|
||||
@@ -144,6 +185,124 @@ export function SystemNotification() {
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user