Skip to content
This repository was archived by the owner on Mar 9, 2026. It is now read-only.

Commit db5eaa1

Browse files
author
Evie Gauthier
committed
Merge branch 'fix/notification-delivery-bugs' (PR 7w1#252)
2 parents efddd9f + 379dc15 commit db5eaa1

4 files changed

Lines changed: 82 additions & 72 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
sable: patch
3+
---
4+
5+
fix: notification delivery bugs
6+
7+
- **Push notifications always silent**`resolveSilent` in the service worker now always returns `false`, leaving sound/vibration decisions entirely to the OS and Sygnal push gateway. The in-app sound setting no longer affects push sound.
8+
- **In-app banner showing "sent an encrypted message"** — Events reaching the banner are already decrypted by the SDK. `isEncryptedRoom: false` is now passed so the actual message body is always shown when message content preview is enabled.
9+
- **Desktop OS notifications not firing when page is hidden** — The OS notification block now runs before the `visibilityState !== 'visible'` guard, which only gates the in-app banner and audio. Notifications now fire even when the browser window is minimised.
10+
- **iOS lock screen media player after notification sound**`mediaSession.playbackState` is cleared after a short delay following `play()`, dismissing the lock screen widget. If in-app media has since registered its own metadata, the session is left untouched.
11+
- **In-app banner not appearing on desktop** — The banner was gated behind a `mobileOrTablet()` check; it now fires on all platforms when In-App Notifications is enabled.

src/app/pages/client/ClientNonUIFeatures.tsx

Lines changed: 64 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,19 @@ import { mobileOrTablet } from '$utils/user-agent';
4040
import { getInboxInvitesPath } from '../pathUtils';
4141
import { BackgroundNotifications } from './BackgroundNotifications';
4242

43+
function clearMediaSessionQuickly(): void {
44+
if (!('mediaSession' in navigator)) return;
45+
// iOS registers the lock screen media player as a side-effect of
46+
// HTMLAudioElement.play(). We delay slightly so iOS has finished updating
47+
// the media session before we clear it — clearing too early is a no-op.
48+
// We only clear if no real in-app media (video/audio in a room) has since
49+
// registered meaningful metadata; if it has, leave it alone.
50+
setTimeout(() => {
51+
if (navigator.mediaSession.metadata !== null) return;
52+
navigator.mediaSession.playbackState = 'none';
53+
}, 500);
54+
}
55+
4356
function SystemEmojiFeature() {
4457
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
4558

@@ -157,6 +170,7 @@ function InviteNotifications() {
157170
const playSound = useCallback(() => {
158171
const audioElement = audioRef.current;
159172
audioElement?.play();
173+
clearMediaSessionQuickly();
160174
}, []);
161175

162176
useEffect(() => {
@@ -229,6 +243,7 @@ function MessageNotifications() {
229243
const playSound = useCallback(() => {
230244
const audioElement = audioRef.current;
231245
audioElement?.play();
246+
clearMediaSessionQuickly();
232247
}, []);
233248

234249
useEffect(() => {
@@ -291,21 +306,52 @@ function MessageNotifications() {
291306
// If neither a loud nor a highlight rule matches, and it's not a DM, nothing to show.
292307
if (!isHighlightByRule && !loudByRule && !isDM) return;
293308

294-
// Page hidden: SW (push) handles the OS notification. Nothing to do in-app.
295-
if (document.visibilityState !== 'visible') return;
296-
297309
// Record as notified to prevent duplicate banners (e.g. re-emitted decrypted events).
298310
notifiedEventsRef.current.add(eventId);
299311
if (notifiedEventsRef.current.size > 200) {
300312
const first = notifiedEventsRef.current.values().next().value;
301313
if (first) notifiedEventsRef.current.delete(first);
302314
}
303315

304-
// Page is visible — show the themed in-app notification banner for any
305-
// highlighted message (mention / keyword) or loud push rule.
306-
// okay fast patch because that showNotifications setting atom is not getting set correctly or something
307-
if (mobileOrTablet() && showNotifications && (isHighlightByRule || loudByRule || isDM)) {
316+
// On desktop: fire an OS notification so the user is alerted even when the
317+
// browser window is minimised or the tab is not active.
318+
if (!mobileOrTablet() && showSystemNotifications && notificationPermission('granted')) {
308319
const isEncryptedRoom = !!getStateEvent(room, StateEvent.RoomEncryption);
320+
const avatarMxc =
321+
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
322+
const osPayload = buildRoomMessageNotification({
323+
roomName: room.name ?? 'Unknown',
324+
roomAvatar: avatarMxc
325+
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
326+
: undefined,
327+
username:
328+
getMemberDisplayName(room, sender, nicknamesRef.current) ??
329+
getMxIdLocalPart(sender) ??
330+
sender,
331+
previewText: resolveNotificationPreviewText({
332+
content: mEvent.getContent(),
333+
eventType: mEvent.getType(),
334+
isEncryptedRoom,
335+
showMessageContent,
336+
showEncryptedMessageContent,
337+
}),
338+
silent: !notificationSound || !loudByRule,
339+
eventId,
340+
});
341+
const noti = new window.Notification(osPayload.title, osPayload.options);
342+
const { roomId } = room;
343+
noti.onclick = () => {
344+
window.focus();
345+
setPending({ roomId, eventId, targetSessionId: mx.getUserId() ?? undefined });
346+
noti.close();
347+
};
348+
}
349+
350+
// Everything below requires the page to be visible (in-app UI + audio).
351+
if (document.visibilityState !== 'visible') return;
352+
353+
// Page is visible — show the themed in-app notification banner.
354+
if (showNotifications && (isHighlightByRule || loudByRule || isDM)) {
309355
const avatarMxc =
310356
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
311357
const roomAvatar = avatarMxc
@@ -316,21 +362,22 @@ function MessageNotifications() {
316362
getMxIdLocalPart(sender) ??
317363
sender;
318364
const content = mEvent.getContent();
365+
// Events reaching here are already decrypted (m.room.encrypted is skipped
366+
// above). Pass isEncryptedRoom:false so the preview always shows the actual
367+
// message body when showMessageContent is enabled.
319368
const previewText = resolveNotificationPreviewText({
320369
content: mEvent.getContent(),
321370
eventType: mEvent.getType(),
322-
isEncryptedRoom,
371+
isEncryptedRoom: false,
323372
showMessageContent,
324373
showEncryptedMessageContent,
325374
});
326375

327376
// Build a rich ReactNode body using the same HTML parser as the room
328-
// timeline — this gives full mxc image transforms, mention pills,
329-
// linkify, spoilers, code blocks, etc.
377+
// timeline — mxc images, mention pills, linkify, spoilers, code blocks.
330378
let bodyNode: ReactNode;
331379
if (
332380
showMessageContent &&
333-
(!isEncryptedRoom || showEncryptedMessageContent) &&
334381
content.format === 'org.matrix.custom.html' &&
335382
content.formatted_body
336383
) {
@@ -347,7 +394,7 @@ function MessageNotifications() {
347394
roomAvatar,
348395
username: resolvedSenderName,
349396
previewText,
350-
silent: !notificationSound,
397+
silent: !notificationSound || !loudByRule,
351398
eventId,
352399
});
353400
const { roomId } = room;
@@ -371,45 +418,8 @@ function MessageNotifications() {
371418
});
372419
}
373420

374-
// On desktop: also fire an OS notification so the user is alerted even
375-
// if the browser window is minimised (respects System Notifications toggle).
376-
if (!mobileOrTablet() && showSystemNotifications && notificationPermission('granted')) {
377-
const isEncryptedRoom = !!getStateEvent(room, StateEvent.RoomEncryption);
378-
const avatarMxc =
379-
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
380-
const osPayload = buildRoomMessageNotification({
381-
roomName: room.name ?? 'Unknown',
382-
roomAvatar: avatarMxc
383-
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
384-
: undefined,
385-
username:
386-
getMemberDisplayName(room, sender, nicknamesRef.current) ??
387-
getMxIdLocalPart(sender) ??
388-
sender,
389-
previewText: resolveNotificationPreviewText({
390-
content: mEvent.getContent(),
391-
eventType: mEvent.getType(),
392-
isEncryptedRoom,
393-
showMessageContent,
394-
showEncryptedMessageContent,
395-
}),
396-
// Play sound only if the push rule requests it and the user has sounds enabled.
397-
silent: !notificationSound || !loudByRule,
398-
eventId,
399-
});
400-
const noti = new window.Notification(osPayload.title, osPayload.options);
401-
const { roomId } = room;
402-
noti.onclick = () => {
403-
window.focus();
404-
setPending({ roomId, eventId, targetSessionId: mx.getUserId() ?? undefined });
405-
noti.close();
406-
};
407-
}
408-
409421
// In-app audio: play whenever notification sounds are enabled.
410-
// Not gated on loudByRule — that only controls the OS-level silent flag.
411-
// Audio API requires a visible, focused document; skip when hidden.
412-
if (document.visibilityState === 'visible' && notificationSound) {
422+
if (notificationSound) {
413423
playSound();
414424
}
415425
};
@@ -497,8 +507,6 @@ export function HandleNotificationClick() {
497507
}
498508

499509
function SyncNotificationSettingsWithServiceWorker() {
500-
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
501-
const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications');
502510
const [showMessageContent] = useSetting(settingsAtom, 'showMessageContentInNotifications');
503511
const [showEncryptedMessageContent] = useSetting(
504512
settingsAtom,
@@ -524,9 +532,11 @@ function SyncNotificationSettingsWithServiceWorker() {
524532

525533
useEffect(() => {
526534
if (!('serviceWorker' in navigator)) return;
535+
// notificationSoundEnabled is intentionally excluded: push notification sound
536+
// is governed by the push rule's tweakSound alone (OS/Sygnal handles it).
537+
// The in-app sound setting only controls the in-page <audio> playback above.
527538
const payload = {
528539
type: 'setNotificationSettings' as const,
529-
notificationSoundEnabled: notificationSound,
530540
showMessageContent,
531541
showEncryptedMessageContent,
532542
clearNotificationsOnRead,
@@ -536,13 +546,7 @@ function SyncNotificationSettingsWithServiceWorker() {
536546
navigator.serviceWorker.ready.then((registration) => {
537547
registration.active?.postMessage(payload);
538548
});
539-
}, [
540-
notificationSound,
541-
usePushNotifications,
542-
showMessageContent,
543-
showEncryptedMessageContent,
544-
clearNotificationsOnRead,
545-
]);
549+
}, [showMessageContent, showEncryptedMessageContent, clearNotificationsOnRead]);
546550

547551
return null;
548552
}

src/sw.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ let showMessageContent = false;
1616
let showEncryptedMessageContent = false;
1717
let clearNotificationsOnRead = false;
1818
const { handlePushNotificationPushData } = createPushNotifications(self, () => ({
19-
notificationSoundEnabled,
2019
showMessageContent,
2120
showEncryptedMessageContent,
2221
}));

src/sw/pushNotification.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
} from '../app/utils/notificationStyle';
88

99
type NotificationSettings = {
10-
notificationSoundEnabled: boolean;
1110
showMessageContent: boolean;
1211
showEncryptedMessageContent: boolean;
1312
};
@@ -16,13 +15,10 @@ export const createPushNotifications = (
1615
self: ServiceWorkerGlobalScope,
1716
getNotificationSettings: () => NotificationSettings
1817
) => {
19-
const resolveSilent = (silent: unknown, tweakSound?: unknown): boolean => {
20-
if (typeof silent === 'boolean') return silent;
21-
// If the push rule doesn't request a sound tweak, the notification should be silent
22-
// (no sound), regardless of the user's global sound preference.
23-
if (!tweakSound) return true;
24-
return !getNotificationSettings().notificationSoundEnabled;
25-
};
18+
// Push notification sound is always controlled by the OS/device settings.
19+
// We never explicitly silence push notifications — the user's device notification
20+
// preferences (volume, Do Not Disturb, per-app settings) handle that instead.
21+
const resolveSilent = (): boolean => false;
2622

2723
const showNotificationWithData = async (
2824
title: string,
@@ -73,7 +69,7 @@ export const createPushNotifications = (
7369
showMessageContent: getNotificationSettings().showMessageContent,
7470
showEncryptedMessageContent: getNotificationSettings().showEncryptedMessageContent,
7571
}),
76-
silent: resolveSilent(pushData?.silent, pushData?.tweaks?.sound),
72+
silent: resolveSilent(),
7773
eventId: pushData?.event_id,
7874
recipientId: typeof pushData?.user_id === 'string' ? pushData.user_id : undefined,
7975
data,
@@ -108,7 +104,7 @@ export const createPushNotifications = (
108104
showMessageContent: getNotificationSettings().showMessageContent,
109105
showEncryptedMessageContent: getNotificationSettings().showEncryptedMessageContent,
110106
}),
111-
silent: resolveSilent(pushData?.silent, pushData?.tweaks?.sound),
107+
silent: resolveSilent(),
112108
eventId: pushData?.event_id,
113109
recipientId: typeof pushData?.user_id === 'string' ? pushData.user_id : undefined,
114110
data,
@@ -141,7 +137,7 @@ export const createPushNotifications = (
141137
...pushData.data,
142138
};
143139

144-
await showNotificationWithData('New Invitation', body, data, resolveSilent(pushData?.silent));
140+
await showNotificationWithData('New Invitation', body, data, resolveSilent());
145141
};
146142

147143
const handlePushNotificationPushData = async (pushData: any) => {

0 commit comments

Comments
 (0)