Skip to content
This repository was archived by the owner on Mar 10, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/fix-notification-delivery-bugs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
sable: patch
---

fix: notification delivery bugs

- **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.
- **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.
- **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.
- **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.
- **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.
12 changes: 8 additions & 4 deletions src/app/hooks/useNotificationJumper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { useMatrixClient } from './useMatrixClient';
import { getCanonicalAliasOrRoomId } from '../utils/matrix';
import { getDirectRoomPath, getHomeRoomPath, getSpaceRoomPath } from '../pages/pathUtils';
import { getOrphanParents, guessPerfectParent } from '../utils/room';
import { roomToParentsAtom } from '../state/room/roomToParents';
import { createLogger } from '../utils/debug';

Expand Down Expand Up @@ -62,12 +63,15 @@
// If the room lives inside a space, route through the space path so
// SpaceRouteRoomProvider can resolve it — HomeRouteRoomProvider only
// knows orphan rooms and would show JoinBeforeNavigate otherwise.
const parents = roomToParents.get(pending.roomId);
const firstParentId = parents && [...parents][0];
if (firstParentId) {
// Use getOrphanParents + guessPerfectParent (same as useRoomNavigate) so
// we always navigate to a root-level space, not a subspace — subspace
// paths are not recognised by the router and land on JoinBeforeNavigate.
const orphanParents = getOrphanParents(roomToParents, pending.roomId);
if (orphanParents.length > 0) {
const parentSpace = guessPerfectParent(mx, pending.roomId, orphanParents) ?? orphanParents[0];

Check failure on line 71 in src/app/hooks/useNotificationJumper.ts

View workflow job for this annotation

GitHub Actions / Lint

Insert `⏎···········`
navigate(
getSpaceRoomPath(
getCanonicalAliasOrRoomId(mx, firstParentId),
getCanonicalAliasOrRoomId(mx, parentSpace),
roomIdOrAlias,
pending.eventId
)
Expand Down
147 changes: 87 additions & 60 deletions src/app/pages/client/ClientNonUIFeatures.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,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');

Expand Down Expand Up @@ -158,6 +171,7 @@ function InviteNotifications() {
const playSound = useCallback(() => {
const audioElement = audioRef.current;
audioElement?.play();
clearMediaSessionQuickly();
}, []);

useEffect(() => {
Expand Down Expand Up @@ -230,6 +244,7 @@ function MessageNotifications() {
const playSound = useCallback(() => {
const audioElement = audioRef.current;
audioElement?.play();
clearMediaSessionQuickly();
}, []);

useEffect(() => {
Expand Down Expand Up @@ -292,8 +307,13 @@ 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;
// With sliding sync we only load m.room.member/$ME in required_state, so
// PushProcessor cannot evaluate the room_member_count == 2 condition on
// .m.rule.room_one_to_one. That rule therefore fails to match, and DM
// messages fall through to .m.rule.message which carries no sound tweak —
// leaving loudByRule=false. Treat known DMs as inherently loud so that
// the OS notification and badge are consistent with the DM context.
const isLoud = loudByRule || isDM;

// Record as notified to prevent duplicate banners (e.g. re-emitted decrypted events).
notifiedEventsRef.current.add(eventId);
Expand All @@ -302,11 +322,60 @@ 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)) {
const isEncryptedRoom = !!getStateEvent(room, StateEvent.RoomEncryption);
// On desktop: fire an OS notification when the window is not focused so
// the user is alerted while the browser is minimised or the tab is in the
// background. When the window IS focused the in-app banner (below) is the
// appropriate alert — we skip the OS notification to avoid a duplicate.
// The whole block is wrapped in try/catch: window.Notification() can throw
// in sandboxed environments, browsers with DnD active, or Electron — and
// an uncaught exception here would abort the handler before setInAppBanner
// is reached, causing in-app notifications to silently vanish too.
if (
!mobileOrTablet() &&
!document.hasFocus() &&
showSystemNotifications &&
notificationPermission('granted')
) {
try {
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 || !isLoud,
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();
};
} catch {
// window.Notification unavailable or blocked (sandboxed context, DnD, etc.)
}
}

// 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
Expand All @@ -317,21 +386,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
) {
Expand All @@ -348,7 +418,7 @@ function MessageNotifications() {
roomAvatar,
username: resolvedSenderName,
previewText,
silent: !notificationSound,
silent: !notificationSound || !isLoud,
eventId,
});
const { roomId } = room;
Expand All @@ -372,45 +442,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();
}
};
Expand Down Expand Up @@ -498,8 +531,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,
Expand All @@ -525,9 +556,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 <audio> playback above.
const payload = {
type: 'setNotificationSettings' as const,
notificationSoundEnabled: notificationSound,
showMessageContent,
showEncryptedMessageContent,
clearNotificationsOnRead,
Expand All @@ -537,13 +570,7 @@ function SyncNotificationSettingsWithServiceWorker() {
navigator.serviceWorker.ready.then((registration) => {
registration.active?.postMessage(payload);
});
}, [
notificationSound,
usePushNotifications,
showMessageContent,
showEncryptedMessageContent,
clearNotificationsOnRead,
]);
}, [showMessageContent, showEncryptedMessageContent, clearNotificationsOnRead]);

return null;
}
Expand Down
1 change: 0 additions & 1 deletion src/sw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
let showEncryptedMessageContent = false;
let clearNotificationsOnRead = false;
const { handlePushNotificationPushData } = createPushNotifications(self, () => ({
notificationSoundEnabled,
showMessageContent,
showEncryptedMessageContent,
}));
Expand Down Expand Up @@ -297,20 +296,20 @@
// because iOS Safari PWA often returns empty or stale results from matchAll().
const hasVisibleClient =
appIsVisible || clients.some((client) => client.visibilityState === 'visible');
console.debug(

Check warning on line 299 in src/sw.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
'[SW push] appIsVisible:',
appIsVisible,
'| clients:',
clients.map((c) => ({ url: c.url, visibility: c.visibilityState }))
);
console.debug('[SW push] hasVisibleClient:', hasVisibleClient);

Check warning on line 305 in src/sw.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
if (hasVisibleClient) {
console.debug('[SW push] suppressing OS notification — app is visible');

Check warning on line 307 in src/sw.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
return;
}

const pushData = event.data.json();
console.debug('[SW push] raw payload:', JSON.stringify(pushData, null, 2));

Check warning on line 312 in src/sw.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement

try {
if (typeof pushData?.unread === 'number') {
Expand Down Expand Up @@ -350,8 +349,8 @@
const pushEventId: string | undefined = data?.event_id ?? undefined;
const isInvite = data?.content?.membership === 'invite';

console.debug('[SW notificationclick] notification data:', JSON.stringify(data, null, 2));

Check warning on line 352 in src/sw.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
console.debug('[SW notificationclick] resolved fields:', {

Check warning on line 353 in src/sw.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
pushUserId,
pushRoomId,
pushEventId,
Expand Down Expand Up @@ -383,7 +382,7 @@
targetUrl = new URL('inbox/notifications/', scope).href;
}

console.debug('[SW notificationclick] targetUrl:', targetUrl);

Check warning on line 385 in src/sw.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement

event.waitUntil(
(async () => {
Expand Down
18 changes: 7 additions & 11 deletions src/sw/pushNotification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
} from '../app/utils/notificationStyle';

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

const showNotificationWithData = async (
title: string,
Expand Down Expand Up @@ -73,7 +69,7 @@ export const createPushNotifications = (
showMessageContent: getNotificationSettings().showMessageContent,
showEncryptedMessageContent: getNotificationSettings().showEncryptedMessageContent,
}),
silent: resolveSilent(pushData?.silent, pushData?.tweaks?.sound),
silent: resolveSilent(),
eventId: pushData?.event_id,
recipientId: typeof pushData?.user_id === 'string' ? pushData.user_id : undefined,
data,
Expand Down Expand Up @@ -108,7 +104,7 @@ export const createPushNotifications = (
showMessageContent: getNotificationSettings().showMessageContent,
showEncryptedMessageContent: getNotificationSettings().showEncryptedMessageContent,
}),
silent: resolveSilent(pushData?.silent, pushData?.tweaks?.sound),
silent: resolveSilent(),
eventId: pushData?.event_id,
recipientId: typeof pushData?.user_id === 'string' ? pushData.user_id : undefined,
data,
Expand Down Expand Up @@ -141,7 +137,7 @@ export const createPushNotifications = (
...pushData.data,
};

await showNotificationWithData('New Invitation', body, data, resolveSilent(pushData?.silent));
await showNotificationWithData('New Invitation', body, data, resolveSilent());
};

const handlePushNotificationPushData = async (pushData: any) => {
Expand Down
Loading