diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 3a10e853f..9f150adcd 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -68,6 +68,7 @@ import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels'; import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators'; import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions'; import { useLivekitSupport } from '../hooks/useLivekitSupport'; +import { useNotificationsQuiet } from '../hooks/useNotificationsQuiet'; import { CallAvatarAnimation } from '../styles/Animations.css'; import { webRTCSupported } from '../utils/rtc'; import { zIndices } from '../styles/zIndex'; @@ -114,6 +115,10 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume'); const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId'); + // Gitea #28 — don't ring during quiet hours / DND / Focus Assist / snooze. The + // call can still be answered from this overlay; only the audible ring is + // skipped. + const quiet = useNotificationsQuiet(); const roomName = useRoomName(room); const roomAvatar = useRoomAvatar(room, dm); @@ -135,10 +140,10 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr ); useEffect(() => { - if (info.notificationType !== 'ring') return undefined; + if (info.notificationType !== 'ring' || quiet) return undefined; const stop = startRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100))); return stop; - }, [info.notificationType, ringtoneId, ringtoneVolume]); + }, [info.notificationType, ringtoneId, ringtoneVolume, quiet]); useEffect(() => { const remaining = info.senderTs + info.lifetime - Date.now(); @@ -275,6 +280,9 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume'); const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId'); + // Gitea #28 — no ping during quiet hours / DND / Focus Assist / snooze; the + // banner itself still shows so the call can be answered. + const quiet = useNotificationsQuiet(); const roomName = useRoomName(room); const roomAvatar = useRoomAvatar(room, dm); @@ -301,11 +309,11 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming // ringtone settings while the banner is showing. const pingedRef = useRef(undefined); useEffect(() => { - if (info.notificationType !== 'ring') return; + if (info.notificationType !== 'ring' || quiet) return; if (pingedRef.current === info.refEventId) return; pingedRef.current = info.refEventId; previewRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100))); - }, [info.notificationType, info.refEventId, ringtoneId, ringtoneVolume]); + }, [info.notificationType, info.refEventId, ringtoneId, ringtoneVolume, quiet]); useEffect(() => { const remaining = info.senderTs + info.lifetime - Date.now(); diff --git a/src/app/hooks/useNotificationsQuiet.test.ts b/src/app/hooks/useNotificationsQuiet.test.ts new file mode 100644 index 000000000..c8d4d1fc5 --- /dev/null +++ b/src/app/hooks/useNotificationsQuiet.test.ts @@ -0,0 +1,55 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +// `useNotificationsQuiet.ts` pulls in `state/notificationSnooze.ts`, whose +// `atomWithStorage(..., { getOnInit: true })` reads `localStorage` eagerly at +// module-import time. Plain `node:test` has no `localStorage`, so install a +// minimal stub before dynamically importing the module under test (mirrors the +// mocking approach in `state/settings.test.ts`). +(globalThis as { localStorage?: unknown }).localStorage = { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined, +}; + +const { isNotificationsQuiet } = await import('./useNotificationsQuiet'); + +const base = { + focusAssistActive: false, + manualDnd: false, + snoozeUntil: 0, + quietHoursEnabled: false, + quietHoursStart: '22:00', + quietHoursEnd: '07:00', +}; + +test('isNotificationsQuiet: quiet when none of the inputs are active', () => { + assert.equal(isNotificationsQuiet(base), false); +}); + +test('isNotificationsQuiet: active quiet-hours window suppresses (#28)', () => { + const now = new Date(2026, 0, 1, 23, 0); + assert.equal(isNotificationsQuiet({ ...base, quietHoursEnabled: true, now }), true); + // Disabled setting is not enough on its own, even inside the window. + assert.equal(isNotificationsQuiet({ ...base, quietHoursEnabled: false, now }), false); + // Outside the window, enabling it does not suppress. + const outside = new Date(2026, 0, 1, 12, 0); + assert.equal(isNotificationsQuiet({ ...base, quietHoursEnabled: true, now: outside }), false); +}); + +test('isNotificationsQuiet: presence/manual DND suppresses (#28)', () => { + assert.equal(isNotificationsQuiet({ ...base, focusAssistActive: true }), true); + assert.equal(isNotificationsQuiet({ ...base, manualDnd: true }), true); +}); + +test('isNotificationsQuiet: an active snooze suppresses (#28)', () => { + const now = Date.now(); + assert.equal( + isNotificationsQuiet({ ...base, snoozeUntil: now + 60_000, now: new Date(now) }), + true, + ); + assert.equal( + isNotificationsQuiet({ ...base, snoozeUntil: now - 60_000, now: new Date(now) }), + false, + ); +}); diff --git a/src/app/hooks/useNotificationsQuiet.ts b/src/app/hooks/useNotificationsQuiet.ts new file mode 100644 index 000000000..93b33ec08 --- /dev/null +++ b/src/app/hooks/useNotificationsQuiet.ts @@ -0,0 +1,58 @@ +import { useAtomValue } from 'jotai'; +import { focusAssistActiveAtom } from '../state/focusAssist'; +import { manualDndAtom } from '../state/manualDnd'; +import { isSnoozeActive, notificationSnoozeUntilAtom } from '../state/notificationSnooze'; +import { isWithinTimeWindow } from '../utils/timeWindow'; +import { useSetting } from '../state/hooks/settings'; +import { settingsAtom } from '../state/settings'; + +export type NotificationsQuietInputs = { + focusAssistActive: boolean; + manualDnd: boolean; + snoozeUntil: number; + quietHoursEnabled: boolean; + quietHoursStart: string; + quietHoursEnd: string; + now?: Date; +}; + +// Gitea #28 — pure "should we make noise right now?" predicate, extracted from +// the gate that `ClientNonUIFeatures` already applies to message/invite sounds +// (Focus Assist, manual tray DND, the snooze, and the quiet-hours window) so the +// incoming-call ringtone (and anything else) can honour the same rules. +export function isNotificationsQuiet({ + focusAssistActive, + manualDnd, + snoozeUntil, + quietHoursEnabled, + quietHoursStart, + quietHoursEnd, + now, +}: NotificationsQuietInputs): boolean { + return ( + focusAssistActive || + manualDnd || + isSnoozeActive(snoozeUntil, now?.getTime()) || + (quietHoursEnabled && isWithinTimeWindow(quietHoursStart, quietHoursEnd, now)) + ); +} + +// Live-atoms/settings-backed version of `isNotificationsQuiet` for use in +// components. +export function useNotificationsQuiet(): boolean { + const focusAssistActive = useAtomValue(focusAssistActiveAtom); + const manualDnd = useAtomValue(manualDndAtom); + const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom); + const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled'); + const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart'); + const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd'); + + return isNotificationsQuiet({ + focusAssistActive, + manualDnd, + snoozeUntil, + quietHoursEnabled, + quietHoursStart, + quietHoursEnd, + }); +} diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 148dc0693..151bfe4d4 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -12,10 +12,7 @@ import { Thread, ThreadEvent, } from 'matrix-js-sdk'; -import { focusAssistActiveAtom } from '../../state/focusAssist'; -import { manualDndAtom } from '../../state/manualDnd'; -import { isSnoozeActive, notificationSnoozeUntilAtom } from '../../state/notificationSnooze'; -import { isWithinTimeWindow } from '../../utils/timeWindow'; +import { useNotificationsQuiet } from '../../hooks/useNotificationsQuiet'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import NotificationSound from '../../../../public/sound/notification.ogg'; import InviteSound from '../../../../public/sound/invite.ogg'; @@ -153,12 +150,7 @@ function InviteNotifications() { const navigate = useNavigate(); const [showNotifications] = useSetting(settingsAtom, 'showNotifications'); const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); - const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled'); - const focusAssistActive = useAtomValue(focusAssistActiveAtom); - const manualDnd = useAtomValue(manualDndAtom); - const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom); - const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart'); - const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd'); + const quietActive = useNotificationsQuiet(); const [inviteSoundId] = useSetting(settingsAtom, 'inviteSoundId'); const setToast = useSetAtom(toastQueueAtom); @@ -253,11 +245,6 @@ function InviteNotifications() { knownInviteIdsRef.current = currentIds; if (newCount <= 0) return; - const quietActive = - focusAssistActive || - manualDnd || - isSnoozeActive(snoozeUntil) || - (quietHoursEnabled && isWithinTimeWindow(quietHoursStart, quietHoursEnd)); if (quietActive) return; if (showNotifications && notificationPermission('granted')) { @@ -272,12 +259,7 @@ function InviteNotifications() { notificationSound, notify, playSound, - quietHoursEnabled, - quietHoursStart, - quietHoursEnd, - focusAssistActive, - manualDnd, - snoozeUntil, + quietActive, inviteSoundId, ]); @@ -390,12 +372,7 @@ function MessageNotifications() { const useAuthentication = useMediaAuthentication(); const [showNotifications] = useSetting(settingsAtom, 'showNotifications'); const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); - const [quietHoursEnabled] = useSetting(settingsAtom, 'quietHoursEnabled'); - const focusAssistActive = useAtomValue(focusAssistActiveAtom); - const manualDnd = useAtomValue(manualDndAtom); - const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom); - const [quietHoursStart] = useSetting(settingsAtom, 'quietHoursStart'); - const [quietHoursEnd] = useSetting(settingsAtom, 'quietHoursEnd'); + const quietActive = useNotificationsQuiet(); const [messageSoundId] = useSetting(settingsAtom, 'messageSoundId'); const setToast = useSetAtom(toastQueueAtom); const mDirects = useAtomValue(mDirectAtom); @@ -543,11 +520,6 @@ function MessageNotifications() { lastNotifiedEventRef.current.set(dedupeKey, eventId); - const quietActive = - focusAssistActive || - manualDnd || - isSnoozeActive(snoozeUntil) || - (quietHoursEnabled && isWithinTimeWindow(quietHoursStart, quietHoursEnd)); if (quietActive) return; if (showNotifications && notificationPermission('granted')) { @@ -578,12 +550,7 @@ function MessageNotifications() { showNotifications, notificationSound, useAuthentication, - quietHoursEnabled, - quietHoursStart, - quietHoursEnd, - focusAssistActive, - manualDnd, - snoozeUntil, + quietActive, messageSoundId, ], );