feat: P2-3 sort rooms, P2-5 quiet hours, P2-2 custom notification sounds
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:
2026-06-03 19:41:02 -04:00
parent 8e4cbc47c4
commit bb8f9032ee
5 changed files with 416 additions and 31 deletions
+94 -23
View File
@@ -9,6 +9,7 @@ import LogoHighlightSVG from '../../../../public/res/lotus-highlight.png';
import NotificationSound from '../../../../public/sound/notification.ogg';
import InviteSound from '../../../../public/sound/invite.ogg';
import { notificationPermission, setFavicon } from '../../utils/dom';
import { NOTIFICATION_SOUND_MAP } from '../../utils/notificationSounds';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { allInvitesAtom } from '../../state/room-list/inviteList';
@@ -28,6 +29,18 @@ import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { usePresenceUpdater } from '../../hooks/usePresenceUpdater';
function isInQuietHours(start: string, end: string): boolean {
const now = new Date();
const [sh, sm] = start.split(':').map(Number);
const [eh, em] = end.split(':').map(Number);
const cur = now.getHours() * 60 + now.getMinutes();
const s = sh! * 60 + (sm ?? 0);
const e = eh! * 60 + (em ?? 0);
// start===end means zero-length window → treat as disabled (no quiet hours)
if (s === e) return false;
return s < e ? cur >= s && cur < e : cur >= s || cur < e;
}
function SystemEmojiFeature() {
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
@@ -90,6 +103,23 @@ function InviteNotifications() {
const navigate = useNavigate();
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
const [inviteSoundId] = useSetting(settingsAtom, 'inviteSoundId');
const soundSrc =
inviteSoundId !== 'none' ? (NOTIFICATION_SOUND_MAP[inviteSoundId] ?? InviteSound) : null;
useEffect(() => {
const el = audioRef.current;
if (!el) return;
const source = el.querySelector('source');
if (source && soundSrc) {
source.src = soundSrc;
el.load();
}
}, [soundSrc]);
const notify = useCallback(
(count: number) => {
@@ -115,19 +145,34 @@ function InviteNotifications() {
useEffect(() => {
if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') {
if (showNotifications && notificationPermission('granted')) {
notify(invites.length - perviousInviteLen);
}
const quietActive = quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd);
if (!quietActive) {
if (showNotifications && notificationPermission('granted')) {
notify(invites.length - perviousInviteLen);
}
if (notificationSound) {
playSound();
if (notificationSound && inviteSoundId !== 'none') {
playSound();
}
}
}
}, [mx, invites, perviousInviteLen, showNotifications, notificationSound, notify, playSound]);
}, [
mx,
invites,
perviousInviteLen,
showNotifications,
notificationSound,
notify,
playSound,
quietHoursEnabled,
quietHoursStart,
quietHoursEnd,
inviteSoundId,
]);
return (
<audio ref={audioRef} style={{ display: 'none' }}>
<source src={InviteSound} type="audio/ogg" />
<source src={soundSrc ?? InviteSound} type="audio/ogg" />
</audio>
);
}
@@ -145,6 +190,25 @@ function MessageNotifications() {
const useAuthentication = useMediaAuthentication();
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled');
const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart');
const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd');
const [messageSoundId] = useSetting(settingsAtom, 'messageSoundId');
const soundSrc =
messageSoundId !== 'none'
? (NOTIFICATION_SOUND_MAP[messageSoundId] ?? NotificationSound)
: null;
useEffect(() => {
const el = audioRef.current;
if (!el) return;
const source = el.querySelector('source');
if (source && soundSrc) {
source.src = soundSrc;
el.load();
}
}, [soundSrc]);
const navigate = useNavigate();
const notificationSelected = useInboxNotificationsSelected();
@@ -221,22 +285,25 @@ function MessageNotifications() {
return;
}
if (showNotifications && notificationPermission('granted')) {
const avatarMxc =
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
notify({
roomName: room.name ?? 'Unknown',
roomAvatar: avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined,
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
roomId: room.roomId,
eventId,
});
}
const quietActive = quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd);
if (!quietActive) {
if (showNotifications && notificationPermission('granted')) {
const avatarMxc =
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
notify({
roomName: room.name ?? 'Unknown',
roomAvatar: avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined,
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
roomId: room.roomId,
eventId,
});
}
if (notificationSound) {
playSound();
if (notificationSound && messageSoundId !== 'none') {
playSound();
}
}
};
mx.on(RoomEvent.Timeline, handleTimelineEvent);
@@ -252,11 +319,15 @@ function MessageNotifications() {
notify,
selectedRoomId,
useAuthentication,
quietHoursEnabled,
quietHoursStart,
quietHoursEnd,
messageSoundId,
]);
return (
<audio ref={audioRef} style={{ display: 'none' }}>
<source src={NotificationSound} type="audio/ogg" />
<source src={soundSrc ?? NotificationSound} type="audio/ogg" />
</audio>
);
}
+135 -7
View File
@@ -27,6 +27,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { useAtom, useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
import FocusTrap from 'focus-trap-react';
import { Unread } from '../../../../types/matrix/room';
import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort';
import {
NavButton,
@@ -209,6 +210,17 @@ function HomeEmpty() {
);
}
const factoryRoomIdByUnread =
(roomToUnread: Map<string, Unread>) =>
(aId: string, bId: string): number => {
const aUnread = roomToUnread.get(aId);
const bUnread = roomToUnread.get(bId);
const aHas = (aUnread?.total ?? 0) > 0;
const bHas = (bUnread?.total ?? 0) > 0;
if (aHas !== bHas) return aHas ? -1 : 1;
return (bUnread?.total ?? 0) - (aUnread?.total ?? 0);
};
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
export function Home() {
@@ -245,6 +257,9 @@ export function Home() {
const searchSelected = useHomeSearchSelected();
const noRoomToDisplay = rooms.length === 0;
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
const [homeRoomSort, setHomeRoomSort] = useSetting(settingsAtom, 'homeRoomSort');
const roomToUnread = useAtomValue(roomToUnreadAtom);
const [sortMenuAnchor, setSortMenuAnchor] = useState<RectCords>();
const { favoriteRooms, otherRooms } = useMemo(() => {
const favs: string[] = [];
@@ -271,16 +286,31 @@ export function Home() {
);
const sortedRooms = useMemo(() => {
const items = Array.from(otherRooms).sort(
closedCategories.has(DEFAULT_CATEGORY_ID)
? factoryRoomIdByActivity(mx)
: factoryRoomIdByAtoZ(mx),
);
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
const isClosed = closedCategories.has(DEFAULT_CATEGORY_ID);
let comparator: (a: string, b: string) => number;
if (isClosed) {
comparator = factoryRoomIdByActivity(mx);
} else if (homeRoomSort === 'alpha') {
comparator = factoryRoomIdByAtoZ(mx);
} else if (homeRoomSort === 'unread') {
comparator = factoryRoomIdByUnread(roomToUnread);
} else {
comparator = factoryRoomIdByActivity(mx);
}
const items = Array.from(otherRooms).sort(comparator);
if (isClosed) {
return items.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId);
}
return items;
}, [mx, otherRooms, closedCategories, roomsWithUnreadSet, selectedRoomId]);
}, [
mx,
otherRooms,
closedCategories,
roomsWithUnreadSet,
selectedRoomId,
homeRoomSort,
roomToUnread,
]);
const filteredRooms = useMemo(() => {
if (!filterQuery.trim()) return sortedRooms;
@@ -469,6 +499,104 @@ export function Home() {
>
Rooms
</RoomNavCategoryButton>
{!closedCategories.has(DEFAULT_CATEGORY_ID) && (
<>
<IconButton
aria-expanded={!!sortMenuAnchor}
aria-haspopup="menu"
variant="Background"
fill="None"
size="300"
radii="300"
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
const cords = evt.currentTarget.getBoundingClientRect();
setSortMenuAnchor((current) => (current ? undefined : cords));
}}
aria-label="Sort rooms"
>
<Icon src={Icons.Sort} size="100" />
</IconButton>
<PopOut
anchor={sortMenuAnchor}
position="Bottom"
align="End"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setSortMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ maxWidth: toRem(180), width: '100vw' }}>
<Box
direction="Column"
gap="100"
style={{ padding: config.space.S100 }}
>
<MenuItem
onClick={() => {
setHomeRoomSort('recent');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'recent' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Recent Activity
</Text>
</MenuItem>
<MenuItem
onClick={() => {
setHomeRoomSort('alpha');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'alpha' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
A Z
</Text>
</MenuItem>
<MenuItem
onClick={() => {
setHomeRoomSort('unread');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'unread' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Unread First
</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
/>
</>
)}
</NavCategoryHeader>
<div
style={{