diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index 020cf9239..4f957808c 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -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={} /> + {isNotificationSounds && ( + + + + Message Sound + + + {messageSoundId !== 'none' && ( + + )} + + + + Invite Sound + + + {inviteSoundId !== 'none' && ( + + )} + + + )} + + + } + /> + {quietHoursEnabled && ( + + + + Start + + setQuietHoursStart(e.target.value)} + aria-label="Quiet hours start time" + style={selectStyle} + /> + + + + End + + setQuietHoursEnd(e.target.value)} + aria-label="Quiet hours end time" + style={selectStyle} + /> + + + )} = 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 ( ); } @@ -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 ( ); } diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index 989234898..6e9c01a06 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -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) => + (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(); 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 + {!closedCategories.has(DEFAULT_CATEGORY_ID) && ( + <> + ) => { + const cords = evt.currentTarget.getBoundingClientRect(); + setSortMenuAnchor((current) => (current ? undefined : cords)); + }} + aria-label="Sort rooms" + > + + + setSortMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + { + setHomeRoomSort('recent'); + setSortMenuAnchor(undefined); + }} + size="300" + after={ + homeRoomSort === 'recent' ? ( + + ) : undefined + } + radii="300" + > + + Recent Activity + + + { + setHomeRoomSort('alpha'); + setSortMenuAnchor(undefined); + }} + size="300" + after={ + homeRoomSort === 'alpha' ? ( + + ) : undefined + } + radii="300" + > + + A → Z + + + { + setHomeRoomSort('unread'); + setSortMenuAnchor(undefined); + }} + size="300" + after={ + homeRoomSort === 'unread' ? ( + + ) : undefined + } + radii="300" + > + + Unread First + + + + + + } + /> + + )}
= { + notification: NotificationSound, + invite: InviteSound, + call: CallSound, +};