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 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 { IPusherRequest } from 'matrix-js-sdk';
|
||||||
|
import { NOTIFICATION_SOUND_MAP } from '../../../utils/notificationSounds';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
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() {
|
export function SystemNotification() {
|
||||||
const notifPermission = usePermissionState('notifications', getNotificationState());
|
const notifPermission = usePermissionState('notifications', getNotificationState());
|
||||||
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
|
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||||
@@ -91,6 +127,11 @@ export function SystemNotification() {
|
|||||||
settingsAtom,
|
settingsAtom,
|
||||||
'isNotificationSounds',
|
'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 = () => {
|
const requestNotificationPermission = () => {
|
||||||
window.Notification.requestPermission();
|
window.Notification.requestPermission();
|
||||||
@@ -144,6 +185,124 @@ export function SystemNotification() {
|
|||||||
description="Play sound when new message arrive."
|
description="Play sound when new message arrive."
|
||||||
after={<Switch value={isNotificationSounds} onChange={setIsNotificationSounds} />}
|
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>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={SequenceCardStyle}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import LogoHighlightSVG from '../../../../public/res/lotus-highlight.png';
|
|||||||
import NotificationSound from '../../../../public/sound/notification.ogg';
|
import NotificationSound from '../../../../public/sound/notification.ogg';
|
||||||
import InviteSound from '../../../../public/sound/invite.ogg';
|
import InviteSound from '../../../../public/sound/invite.ogg';
|
||||||
import { notificationPermission, setFavicon } from '../../utils/dom';
|
import { notificationPermission, setFavicon } from '../../utils/dom';
|
||||||
|
import { NOTIFICATION_SOUND_MAP } from '../../utils/notificationSounds';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { allInvitesAtom } from '../../state/room-list/inviteList';
|
import { allInvitesAtom } from '../../state/room-list/inviteList';
|
||||||
@@ -28,6 +29,18 @@ import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
|||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { usePresenceUpdater } from '../../hooks/usePresenceUpdater';
|
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() {
|
function SystemEmojiFeature() {
|
||||||
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
||||||
|
|
||||||
@@ -90,6 +103,23 @@ function InviteNotifications() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||||
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
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(
|
const notify = useCallback(
|
||||||
(count: number) => {
|
(count: number) => {
|
||||||
@@ -115,19 +145,34 @@ function InviteNotifications() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') {
|
if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') {
|
||||||
if (showNotifications && notificationPermission('granted')) {
|
const quietActive = quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd);
|
||||||
notify(invites.length - perviousInviteLen);
|
if (!quietActive) {
|
||||||
}
|
if (showNotifications && notificationPermission('granted')) {
|
||||||
|
notify(invites.length - perviousInviteLen);
|
||||||
|
}
|
||||||
|
|
||||||
if (notificationSound) {
|
if (notificationSound && inviteSoundId !== 'none') {
|
||||||
playSound();
|
playSound();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [mx, invites, perviousInviteLen, showNotifications, notificationSound, notify, playSound]);
|
}, [
|
||||||
|
mx,
|
||||||
|
invites,
|
||||||
|
perviousInviteLen,
|
||||||
|
showNotifications,
|
||||||
|
notificationSound,
|
||||||
|
notify,
|
||||||
|
playSound,
|
||||||
|
quietHoursEnabled,
|
||||||
|
quietHoursStart,
|
||||||
|
quietHoursEnd,
|
||||||
|
inviteSoundId,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<audio ref={audioRef} style={{ display: 'none' }}>
|
<audio ref={audioRef} style={{ display: 'none' }}>
|
||||||
<source src={InviteSound} type="audio/ogg" />
|
<source src={soundSrc ?? InviteSound} type="audio/ogg" />
|
||||||
</audio>
|
</audio>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -145,6 +190,25 @@ function MessageNotifications() {
|
|||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||||
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
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 navigate = useNavigate();
|
||||||
const notificationSelected = useInboxNotificationsSelected();
|
const notificationSelected = useInboxNotificationsSelected();
|
||||||
@@ -221,22 +285,25 @@ function MessageNotifications() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showNotifications && notificationPermission('granted')) {
|
const quietActive = quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd);
|
||||||
const avatarMxc =
|
if (!quietActive) {
|
||||||
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
|
if (showNotifications && notificationPermission('granted')) {
|
||||||
notify({
|
const avatarMxc =
|
||||||
roomName: room.name ?? 'Unknown',
|
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
|
||||||
roomAvatar: avatarMxc
|
notify({
|
||||||
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
|
roomName: room.name ?? 'Unknown',
|
||||||
: undefined,
|
roomAvatar: avatarMxc
|
||||||
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
|
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
|
||||||
roomId: room.roomId,
|
: undefined,
|
||||||
eventId,
|
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
|
||||||
});
|
roomId: room.roomId,
|
||||||
}
|
eventId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (notificationSound) {
|
if (notificationSound && messageSoundId !== 'none') {
|
||||||
playSound();
|
playSound();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||||
@@ -252,11 +319,15 @@ function MessageNotifications() {
|
|||||||
notify,
|
notify,
|
||||||
selectedRoomId,
|
selectedRoomId,
|
||||||
useAuthentication,
|
useAuthentication,
|
||||||
|
quietHoursEnabled,
|
||||||
|
quietHoursStart,
|
||||||
|
quietHoursEnd,
|
||||||
|
messageSoundId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<audio ref={audioRef} style={{ display: 'none' }}>
|
<audio ref={audioRef} style={{ display: 'none' }}>
|
||||||
<source src={NotificationSound} type="audio/ogg" />
|
<source src={soundSrc ?? NotificationSound} type="audio/ogg" />
|
||||||
</audio>
|
</audio>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
|||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { selectAtom } from 'jotai/utils';
|
import { selectAtom } from 'jotai/utils';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { Unread } from '../../../../types/matrix/room';
|
||||||
import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort';
|
import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort';
|
||||||
import {
|
import {
|
||||||
NavButton,
|
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 DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
|
||||||
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
|
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
|
||||||
export function Home() {
|
export function Home() {
|
||||||
@@ -245,6 +257,9 @@ export function Home() {
|
|||||||
const searchSelected = useHomeSearchSelected();
|
const searchSelected = useHomeSearchSelected();
|
||||||
const noRoomToDisplay = rooms.length === 0;
|
const noRoomToDisplay = rooms.length === 0;
|
||||||
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
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 { favoriteRooms, otherRooms } = useMemo(() => {
|
||||||
const favs: string[] = [];
|
const favs: string[] = [];
|
||||||
@@ -271,16 +286,31 @@ export function Home() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const sortedRooms = useMemo(() => {
|
const sortedRooms = useMemo(() => {
|
||||||
const items = Array.from(otherRooms).sort(
|
const isClosed = closedCategories.has(DEFAULT_CATEGORY_ID);
|
||||||
closedCategories.has(DEFAULT_CATEGORY_ID)
|
let comparator: (a: string, b: string) => number;
|
||||||
? factoryRoomIdByActivity(mx)
|
if (isClosed) {
|
||||||
: factoryRoomIdByAtoZ(mx),
|
comparator = factoryRoomIdByActivity(mx);
|
||||||
);
|
} else if (homeRoomSort === 'alpha') {
|
||||||
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
|
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.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId);
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}, [mx, otherRooms, closedCategories, roomsWithUnreadSet, selectedRoomId]);
|
}, [
|
||||||
|
mx,
|
||||||
|
otherRooms,
|
||||||
|
closedCategories,
|
||||||
|
roomsWithUnreadSet,
|
||||||
|
selectedRoomId,
|
||||||
|
homeRoomSort,
|
||||||
|
roomToUnread,
|
||||||
|
]);
|
||||||
|
|
||||||
const filteredRooms = useMemo(() => {
|
const filteredRooms = useMemo(() => {
|
||||||
if (!filterQuery.trim()) return sortedRooms;
|
if (!filterQuery.trim()) return sortedRooms;
|
||||||
@@ -469,6 +499,104 @@ export function Home() {
|
|||||||
>
|
>
|
||||||
Rooms
|
Rooms
|
||||||
</RoomNavCategoryButton>
|
</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>
|
</NavCategoryHeader>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ export interface Settings {
|
|||||||
|
|
||||||
showNotifications: boolean;
|
showNotifications: boolean;
|
||||||
isNotificationSounds: boolean;
|
isNotificationSounds: boolean;
|
||||||
|
messageSoundId: 'notification' | 'invite' | 'call' | 'none';
|
||||||
|
inviteSoundId: 'notification' | 'invite' | 'call' | 'none';
|
||||||
|
|
||||||
|
quietHoursEnabled: boolean;
|
||||||
|
quietHoursStart: string; // "HH:MM" 24h
|
||||||
|
quietHoursEnd: string; // "HH:MM" 24h
|
||||||
|
|
||||||
|
homeRoomSort: 'recent' | 'alpha' | 'unread';
|
||||||
|
|
||||||
hour24Clock: boolean;
|
hour24Clock: boolean;
|
||||||
dateFormatString: string;
|
dateFormatString: string;
|
||||||
@@ -114,6 +122,14 @@ const defaultSettings: Settings = {
|
|||||||
|
|
||||||
showNotifications: true,
|
showNotifications: true,
|
||||||
isNotificationSounds: true,
|
isNotificationSounds: true,
|
||||||
|
messageSoundId: 'notification',
|
||||||
|
inviteSoundId: 'invite',
|
||||||
|
|
||||||
|
quietHoursEnabled: false,
|
||||||
|
quietHoursStart: '23:00',
|
||||||
|
quietHoursEnd: '08:00',
|
||||||
|
|
||||||
|
homeRoomSort: 'recent',
|
||||||
|
|
||||||
hour24Clock: false,
|
hour24Clock: false,
|
||||||
dateFormatString: 'D MMM YYYY',
|
dateFormatString: 'D MMM YYYY',
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import NotificationSound from '../../../public/sound/notification.ogg';
|
||||||
|
import InviteSound from '../../../public/sound/invite.ogg';
|
||||||
|
import CallSound from '../../../public/sound/call.ogg';
|
||||||
|
|
||||||
|
export type SoundId = 'notification' | 'invite' | 'call' | 'none';
|
||||||
|
|
||||||
|
export const NOTIFICATION_SOUND_MAP: Record<string, string> = {
|
||||||
|
notification: NotificationSound,
|
||||||
|
invite: InviteSound,
|
||||||
|
call: CallSound,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user