From d87d720c7a24ccc90d03863f9e5028e4e32fcfa5 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 8 Mar 2026 12:28:15 -0400 Subject: [PATCH 1/9] fix: notification delivery bugs (push sound, OS notifications, in-app audio, media session) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveSilent = () => false — OS/Sygnal handles push sound entirely; the in-app sound setting no longer affects push notification sound - remove notificationSoundEnabled from SW postMessage payload - move OS notification block before visibilityState guard so desktop notifications fire even when the browser window is minimised - pass isEncryptedRoom:false for in-app banner preview — events are already decrypted by the SDK so the actual message body is always shown - call clearMediaSessionQuickly() after play() to dismiss the iOS lock screen media player; only clears playbackState if no real media metadata is set --- src/app/pages/client/ClientNonUIFeatures.tsx | 124 ++++++++++--------- src/sw.ts | 1 - src/sw/pushNotification.ts | 18 ++- 3 files changed, 71 insertions(+), 72 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 56c333065..f2bb4d533 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -40,6 +40,19 @@ import { mobileOrTablet } from '$utils/user-agent'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; +function clearMediaSessionQuickly(): void { + if (!('mediaSession' in navigator)) return; + // iOS registers the lock screen media player as a side-effect of + // HTMLAudioElement.play(). We delay slightly so iOS has finished updating + // the media session before we clear it — clearing too early is a no-op. + // We only clear if no real in-app media (video/audio in a room) has since + // registered meaningful metadata; if it has, leave it alone. + setTimeout(() => { + if (navigator.mediaSession.metadata !== null) return; + navigator.mediaSession.playbackState = 'none'; + }, 500); +} + function SystemEmojiFeature() { const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); @@ -157,6 +170,7 @@ function InviteNotifications() { const playSound = useCallback(() => { const audioElement = audioRef.current; audioElement?.play(); + clearMediaSessionQuickly(); }, []); useEffect(() => { @@ -229,6 +243,7 @@ function MessageNotifications() { const playSound = useCallback(() => { const audioElement = audioRef.current; audioElement?.play(); + clearMediaSessionQuickly(); }, []); useEffect(() => { @@ -291,9 +306,6 @@ function MessageNotifications() { // If neither a loud nor a highlight rule matches, and it's not a DM, nothing to show. if (!isHighlightByRule && !loudByRule && !isDM) return; - // Page hidden: SW (push) handles the OS notification. Nothing to do in-app. - if (document.visibilityState !== 'visible') return; - // Record as notified to prevent duplicate banners (e.g. re-emitted decrypted events). notifiedEventsRef.current.add(eventId); if (notifiedEventsRef.current.size > 200) { @@ -301,11 +313,45 @@ function MessageNotifications() { if (first) notifiedEventsRef.current.delete(first); } - // Page is visible — show the themed in-app notification banner for any - // highlighted message (mention / keyword) or loud push rule. - // okay fast patch because that showNotifications setting atom is not getting set correctly or something - if (mobileOrTablet() && showNotifications && (isHighlightByRule || loudByRule || isDM)) { + // On desktop: fire an OS notification so the user is alerted even when the + // browser window is minimised or the tab is not active. + if (!mobileOrTablet() && showSystemNotifications && notificationPermission('granted')) { const isEncryptedRoom = !!getStateEvent(room, StateEvent.RoomEncryption); + const avatarMxc = + room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); + const osPayload = buildRoomMessageNotification({ + roomName: room.name ?? 'Unknown', + roomAvatar: avatarMxc + ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) + : undefined, + username: + getMemberDisplayName(room, sender, nicknamesRef.current) ?? + getMxIdLocalPart(sender) ?? + sender, + previewText: resolveNotificationPreviewText({ + content: mEvent.getContent(), + eventType: mEvent.getType(), + isEncryptedRoom, + showMessageContent, + showEncryptedMessageContent, + }), + silent: !notificationSound || !loudByRule, + eventId, + }); + const noti = new window.Notification(osPayload.title, osPayload.options); + const { roomId } = room; + noti.onclick = () => { + window.focus(); + setPending({ roomId, eventId, targetSessionId: mx.getUserId() ?? undefined }); + noti.close(); + }; + } + + // Everything below requires the page to be visible (in-app UI + audio). + if (document.visibilityState !== 'visible') return; + + // Page is visible — show the themed in-app notification banner. + if (showNotifications && (isHighlightByRule || loudByRule || isDM)) { const avatarMxc = room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); const roomAvatar = avatarMxc @@ -316,21 +362,22 @@ function MessageNotifications() { getMxIdLocalPart(sender) ?? sender; const content = mEvent.getContent(); + // Events reaching here are already decrypted (m.room.encrypted is skipped + // above). Pass isEncryptedRoom:false so the preview always shows the actual + // message body when showMessageContent is enabled. const previewText = resolveNotificationPreviewText({ content: mEvent.getContent(), eventType: mEvent.getType(), - isEncryptedRoom, + isEncryptedRoom: false, showMessageContent, showEncryptedMessageContent, }); // Build a rich ReactNode body using the same HTML parser as the room - // timeline — this gives full mxc image transforms, mention pills, - // linkify, spoilers, code blocks, etc. + // timeline — mxc images, mention pills, linkify, spoilers, code blocks. let bodyNode: ReactNode; if ( showMessageContent && - (!isEncryptedRoom || showEncryptedMessageContent) && content.format === 'org.matrix.custom.html' && content.formatted_body ) { @@ -347,7 +394,7 @@ function MessageNotifications() { roomAvatar, username: resolvedSenderName, previewText, - silent: !notificationSound, + silent: !notificationSound || !loudByRule, eventId, }); const { roomId } = room; @@ -371,45 +418,8 @@ function MessageNotifications() { }); } - // On desktop: also fire an OS notification so the user is alerted even - // if the browser window is minimised (respects System Notifications toggle). - if (!mobileOrTablet() && showSystemNotifications && notificationPermission('granted')) { - const isEncryptedRoom = !!getStateEvent(room, StateEvent.RoomEncryption); - const avatarMxc = - room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); - const osPayload = buildRoomMessageNotification({ - roomName: room.name ?? 'Unknown', - roomAvatar: avatarMxc - ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) - : undefined, - username: - getMemberDisplayName(room, sender, nicknamesRef.current) ?? - getMxIdLocalPart(sender) ?? - sender, - previewText: resolveNotificationPreviewText({ - content: mEvent.getContent(), - eventType: mEvent.getType(), - isEncryptedRoom, - showMessageContent, - showEncryptedMessageContent, - }), - // Play sound only if the push rule requests it and the user has sounds enabled. - silent: !notificationSound || !loudByRule, - eventId, - }); - const noti = new window.Notification(osPayload.title, osPayload.options); - const { roomId } = room; - noti.onclick = () => { - window.focus(); - setPending({ roomId, eventId, targetSessionId: mx.getUserId() ?? undefined }); - noti.close(); - }; - } - // In-app audio: play whenever notification sounds are enabled. - // Not gated on loudByRule — that only controls the OS-level silent flag. - // Audio API requires a visible, focused document; skip when hidden. - if (document.visibilityState === 'visible' && notificationSound) { + if (notificationSound) { playSound(); } }; @@ -497,8 +507,6 @@ export function HandleNotificationClick() { } function SyncNotificationSettingsWithServiceWorker() { - const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); - const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); const [showMessageContent] = useSetting(settingsAtom, 'showMessageContentInNotifications'); const [showEncryptedMessageContent] = useSetting( settingsAtom, @@ -524,9 +532,11 @@ function SyncNotificationSettingsWithServiceWorker() { useEffect(() => { if (!('serviceWorker' in navigator)) return; + // notificationSoundEnabled is intentionally excluded: push notification sound + // is governed by the push rule's tweakSound alone (OS/Sygnal handles it). + // The in-app sound setting only controls the in-page