From ccf17ea4072db75751e3f59404d5511579e824db Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 31 Aug 2026 22:43:26 +0200 Subject: [PATCH 1/8] fix(mobile): handle immersive-mode rejections in the image viewer --- src/app/components/image-viewer/ImageViewer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 889c352a6..fd119e135 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -91,9 +91,9 @@ export const ImageViewer = as<'div', ImageViewerProps>( useEffect(() => { if (!isMobile || !isAndroidTauri()) return undefined; - void setImmersiveMode({ enabled: true }); + setImmersiveMode({ enabled: true }).catch(() => {}); return () => { - void setImmersiveMode({ enabled: false }); + setImmersiveMode({ enabled: false }).catch(() => {}); }; }, [isMobile]); From 10737b1034a7dfda78f41c779ea9f1f3af5f86c9 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 31 Aug 2026 22:58:59 +0200 Subject: [PATCH 2/8] fix(sync): keep draining to-device until the push payload actually arrives --- src/client/slidingSync.test.ts | 9 ++++++++- src/client/slidingSync.ts | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index 78dce3efb..74ff1210f 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -1815,7 +1815,7 @@ describe('SlidingSyncManager pause/resume', () => { await expect(manager.waitForResume()).resolves.toBeUndefined(); }); - it('requestPushDrain() flags a drain that clears once to-device comes back empty', () => { + it('keeps draining when the first poll lands before the to-device message arrives', () => { const manager = makeManager(makeMockMx()); manager.attach(); manager.pause(); @@ -1824,7 +1824,14 @@ describe('SlidingSyncManager pause/resume', () => { expect(manager.isDrainingPush()).toBe(true); fireLifecycle(SlidingSyncState.Complete, { extensions: { to_device: { events: [] } } }); + expect(manager.isDrainingPush()).toBe(true); + + fireLifecycle(SlidingSyncState.Complete, { + extensions: { to_device: { events: [{ type: 'm.room.key' }] } }, + }); + expect(manager.isDrainingPush()).toBe(true); + fireLifecycle(SlidingSyncState.Complete, { extensions: { to_device: { events: [] } } }); expect(manager.isDrainingPush()).toBe(false); }); diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 0eb928225..ee5f34f09 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -716,6 +716,8 @@ export class SlidingSyncManager { private pushDrainPollsLeft = 0; + private pushDrainSawEvents = false; + private readonly resumeWaiters = new Set<() => void>(); private readonly transportStateListeners = new Set<() => void>(); @@ -1046,6 +1048,7 @@ export class SlidingSyncManager { public requestPushDrain(): void { if (this.disposed || this.pushDrainPollsLeft === MAX_PUSH_DRAIN_POLLS) return; this.pushDrainPollsLeft = MAX_PUSH_DRAIN_POLLS; + this.pushDrainSawEvents = false; debugLog.info('sync', 'Sliding sync asked to drain to-device after a push'); this.notifyTransportState(); } @@ -1053,10 +1056,13 @@ export class SlidingSyncManager { private settlePushDrain(resp: MSC3575SlidingSyncResponse): void { if (this.pushDrainPollsLeft === 0) return; const toDevice = resp.extensions?.to_device as { events?: unknown[] } | undefined; - const drained = (toDevice?.events?.length ?? 0) === 0; + const received = (toDevice?.events?.length ?? 0) > 0; + if (received) this.pushDrainSawEvents = true; this.pushDrainPollsLeft -= 1; + const drained = this.pushDrainSawEvents && !received; if (!drained && this.pushDrainPollsLeft > 0) return; this.pushDrainPollsLeft = 0; + this.pushDrainSawEvents = false; this.notifyTransportState(); } @@ -1123,6 +1129,7 @@ export class SlidingSyncManager { this.disposed = true; this.paused = false; this.pushDrainPollsLeft = 0; + this.pushDrainSawEvents = false; this.transportStateListeners.clear(); this.releaseResumeWaiters(); globalThis.clearTimeout(this.pollWatchdogTimer); From 7caa72be4fc0ecc29a7cd58be729b07022b4ee40 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 31 Aug 2026 23:00:20 +0200 Subject: [PATCH 3/8] fix(notifications): stop replacing a chosen UnifiedPush distributor on restart --- .../notifications/UnifiedPushTransport.test.ts | 6 +++--- .../settings/notifications/UnifiedPushTransport.ts | 10 ++++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/app/features/settings/notifications/UnifiedPushTransport.test.ts b/src/app/features/settings/notifications/UnifiedPushTransport.test.ts index ef513a962..a9abac66a 100644 --- a/src/app/features/settings/notifications/UnifiedPushTransport.test.ts +++ b/src/app/features/settings/notifications/UnifiedPushTransport.test.ts @@ -212,15 +212,15 @@ describe('UnifiedPush distributor state helpers', () => { expect(unifiedPushApi.setDistributor).toHaveBeenCalledOnce(); }); - it('replaces a stale selected distributor with the first available one', async () => { + it('never replaces an explicitly chosen distributor that the scan did not list', async () => { unifiedPushApi.setDistributor.mockResolvedValue(undefined); await expect( ensureUnifiedPushDistributorSelection( ['org.unifiedpush.distributor.ntfy', 'org.unifiedpush.distributor.nextpush'], 'org.unifiedpush.distributor.removed' ) - ).resolves.toBe('org.unifiedpush.distributor.ntfy'); - expect(unifiedPushApi.setDistributor).toHaveBeenCalledWith('org.unifiedpush.distributor.ntfy'); + ).resolves.toBe(''); + expect(unifiedPushApi.setDistributor).not.toHaveBeenCalled(); }); it('persists a selected distributor through the transport helper', async () => { diff --git a/src/app/features/settings/notifications/UnifiedPushTransport.ts b/src/app/features/settings/notifications/UnifiedPushTransport.ts index 37278d8a5..119cdde86 100644 --- a/src/app/features/settings/notifications/UnifiedPushTransport.ts +++ b/src/app/features/settings/notifications/UnifiedPushTransport.ts @@ -165,11 +165,13 @@ export async function ensureUnifiedPushDistributorSelection( distributors: string[], selectedDistributor: string ): Promise { - const distributor = - selectedDistributor && distributors.includes(selectedDistributor) - ? selectedDistributor - : distributors[0]; + if (selectedDistributor) { + if (!distributors.includes(selectedDistributor)) return ''; + await saveUnifiedPushDistributor(selectedDistributor); + return selectedDistributor; + } + const distributor = distributors[0]; if (!distributor) return ''; await saveUnifiedPushDistributor(distributor); From 3a096d4478ed6c48313a384e605e6789394fcb7a Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 31 Aug 2026 23:08:21 +0200 Subject: [PATCH 4/8] fix(notifications): keep a saved distributor the scan did not list --- .../notifications/UnifiedPushTransport.test.ts | 15 +++++++++------ .../notifications/UnifiedPushTransport.ts | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/app/features/settings/notifications/UnifiedPushTransport.test.ts b/src/app/features/settings/notifications/UnifiedPushTransport.test.ts index a9abac66a..906bcec76 100644 --- a/src/app/features/settings/notifications/UnifiedPushTransport.test.ts +++ b/src/app/features/settings/notifications/UnifiedPushTransport.test.ts @@ -190,15 +190,18 @@ describe('UnifiedPush distributor state helpers', () => { expect(unifiedPushApi.setDistributor).toHaveBeenCalledOnce(); }); - it('drops a stale saved distributor that is no longer installed', async () => { - localStorage.setItem('unifiedpush_distributor', 'org.unifiedpush.distributor.removed'); - unifiedPushApi.listDistributors.mockResolvedValue(['org.unifiedpush.distributor.ntfy']); + it('keeps a saved distributor the scan did not list instead of adopting the only other one', async () => { + localStorage.setItem('unifiedpush_distributor', 'org.unifiedpush.distributor.ntfy'); + unifiedPushApi.listDistributors.mockResolvedValue(['moe.sable.client']); await expect(loadUnifiedPushDistributorState()).resolves.toEqual({ - distributors: ['org.unifiedpush.distributor.ntfy'], - selectedDistributor: 'org.unifiedpush.distributor.ntfy', + distributors: ['moe.sable.client'], + selectedDistributor: '', }); - expect(unifiedPushApi.setDistributor).toHaveBeenCalledWith('org.unifiedpush.distributor.ntfy'); + expect(unifiedPushApi.setDistributor).not.toHaveBeenCalled(); + expect(localStorage.getItem('unifiedpush_distributor')).toBe( + 'org.unifiedpush.distributor.ntfy' + ); }); it('ensures a distributor selection by auto-saving the first available distributor', async () => { diff --git a/src/app/features/settings/notifications/UnifiedPushTransport.ts b/src/app/features/settings/notifications/UnifiedPushTransport.ts index 119cdde86..7f6ef5af4 100644 --- a/src/app/features/settings/notifications/UnifiedPushTransport.ts +++ b/src/app/features/settings/notifications/UnifiedPushTransport.ts @@ -150,7 +150,7 @@ export async function loadUnifiedPushDistributorState(): Promise Date: Mon, 31 Aug 2026 23:10:17 +0200 Subject: [PATCH 5/8] fix(sync): bound the push drain by unproductive polls and a wall-clock deadline --- src/client/slidingSync.test.ts | 35 ++++++++++++++++++++++++++++++++-- src/client/slidingSync.ts | 33 +++++++++++++++++++++++++------- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index 74ff1210f..75a1c9dce 100644 --- a/src/client/slidingSync.test.ts +++ b/src/client/slidingSync.test.ts @@ -1848,21 +1848,52 @@ describe('SlidingSyncManager pause/resume', () => { expect(manager.isDrainingPush()).toBe(false); }); - it('gives up on a never-draining queue once the poll budget runs out', () => { + it('gives up once the budget of empty polls runs out', () => { const MAX_PUSH_DRAIN_POLLS = 5; const manager = makeManager(makeMockMx()); manager.attach(); manager.requestPushDrain(); - const withKeys = { extensions: { to_device: { events: [{ type: 'm.room.key' }] } } }; + const empty = { extensions: { to_device: { events: [] } } }; for (let i = 0; i < MAX_PUSH_DRAIN_POLLS; i += 1) { expect(manager.isDrainingPush()).toBe(true); + fireLifecycle(SlidingSyncState.Complete, empty); + } + + expect(manager.isDrainingPush()).toBe(false); + }); + + it('does not spend the poll budget while to-device events keep arriving', () => { + const manager = makeManager(makeMockMx()); + manager.attach(); + manager.requestPushDrain(); + + const withKeys = { extensions: { to_device: { events: [{ type: 'm.room.key' }] } } }; + for (let i = 0; i < 20; i += 1) { fireLifecycle(SlidingSyncState.Complete, withKeys); + expect(manager.isDrainingPush()).toBe(true); } + fireLifecycle(SlidingSyncState.Complete, { extensions: { to_device: { events: [] } } }); expect(manager.isDrainingPush()).toBe(false); }); + it('stops draining when no poll ever completes', () => { + vi.useFakeTimers(); + try { + const manager = makeManager(makeMockMx()); + manager.attach(); + manager.requestPushDrain(); + expect(manager.isDrainingPush()).toBe(true); + + vi.advanceTimersByTime(120_000); + + expect(manager.isDrainingPush()).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + it('does not park the transport when a drain settles', () => { const manager = makeManager(makeMockMx()); manager.attach(); diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index ee5f34f09..c1923ff83 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -53,6 +53,8 @@ const POLL_DEADLINE_MARGIN_MS = 20_000; // The SDK's to_device extension takes 100 events per response, so a backlog needs several. const MAX_PUSH_DRAIN_POLLS = 5; +const PUSH_DRAIN_TIMEOUT_MS = 120_000; + const ACTIVE_ROOM_SUBSCRIPTION_KEY = 'active_room'; const CALL_ROOM_SUBSCRIPTION_KEY = 'call_room'; const SIDEBAR_ROOM_SUBSCRIPTION_KEY = 'sidebar_room'; @@ -718,6 +720,8 @@ export class SlidingSyncManager { private pushDrainSawEvents = false; + private pushDrainTimer: ReturnType | undefined; + private readonly resumeWaiters = new Set<() => void>(); private readonly transportStateListeners = new Set<() => void>(); @@ -1049,23 +1053,34 @@ export class SlidingSyncManager { if (this.disposed || this.pushDrainPollsLeft === MAX_PUSH_DRAIN_POLLS) return; this.pushDrainPollsLeft = MAX_PUSH_DRAIN_POLLS; this.pushDrainSawEvents = false; + if (this.pushDrainTimer !== undefined) clearTimeout(this.pushDrainTimer); + this.pushDrainTimer = setTimeout(() => this.endPushDrain(), PUSH_DRAIN_TIMEOUT_MS); debugLog.info('sync', 'Sliding sync asked to drain to-device after a push'); this.notifyTransportState(); } - private settlePushDrain(resp: MSC3575SlidingSyncResponse): void { + private endPushDrain(): void { + if (this.pushDrainTimer !== undefined) { + clearTimeout(this.pushDrainTimer); + this.pushDrainTimer = undefined; + } if (this.pushDrainPollsLeft === 0) return; - const toDevice = resp.extensions?.to_device as { events?: unknown[] } | undefined; - const received = (toDevice?.events?.length ?? 0) > 0; - if (received) this.pushDrainSawEvents = true; - this.pushDrainPollsLeft -= 1; - const drained = this.pushDrainSawEvents && !received; - if (!drained && this.pushDrainPollsLeft > 0) return; this.pushDrainPollsLeft = 0; this.pushDrainSawEvents = false; this.notifyTransportState(); } + private settlePushDrain(resp: MSC3575SlidingSyncResponse): void { + if (this.pushDrainPollsLeft === 0) return; + const toDevice = resp.extensions?.to_device as { events?: unknown[] } | undefined; + if ((toDevice?.events?.length ?? 0) > 0) { + this.pushDrainSawEvents = true; + return; + } + this.pushDrainPollsLeft -= 1; + if (this.pushDrainSawEvents || this.pushDrainPollsLeft === 0) this.endPushDrain(); + } + public isPaused(): boolean { return this.paused; } @@ -1130,6 +1145,10 @@ export class SlidingSyncManager { this.paused = false; this.pushDrainPollsLeft = 0; this.pushDrainSawEvents = false; + if (this.pushDrainTimer !== undefined) { + clearTimeout(this.pushDrainTimer); + this.pushDrainTimer = undefined; + } this.transportStateListeners.clear(); this.releaseResumeWaiters(); globalThis.clearTimeout(this.pollWatchdogTimer); From 5319aafba07b82036b828661b262ae761a13c8f6 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 31 Aug 2026 23:11:30 +0200 Subject: [PATCH 6/8] fix(notifications): retry the preview when the timeline copy is still encrypted --- .../settings/notifications/UnifiedPushNotifications.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/features/settings/notifications/UnifiedPushNotifications.ts b/src/app/features/settings/notifications/UnifiedPushNotifications.ts index d22572eaf..bb34711aa 100644 --- a/src/app/features/settings/notifications/UnifiedPushNotifications.ts +++ b/src/app/features/settings/notifications/UnifiedPushNotifications.ts @@ -957,10 +957,12 @@ async function handleMinimalPushPayload( let senderName: string | undefined; let senderId: string | undefined; let previewText: string | undefined; + let inMemoryStillEncrypted = false; if (room && eventId) { const timeline = room.getLiveTimeline().getEvents(); const mEvent = timeline.find((e) => e.getId() === eventId); if (mEvent) { + inMemoryStillEncrypted = !holdsPlaintext(mEvent); const sender = mEvent.getSender(); if (sender) { const member = room.getMember(sender); @@ -978,7 +980,7 @@ async function handleMinimalPushPayload( } } - const hasInMemoryPreview = Boolean(previewText); + const hasInMemoryPreview = Boolean(previewText) && !inMemoryStillEncrypted; if (!previewText) { previewText = isEncryptedRoom ? 'Encrypted message' : 'New message'; } From 3552bca6839e3a4edf33ff6caf32a777a96db692 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 31 Aug 2026 23:15:19 +0200 Subject: [PATCH 7/8] fix(notifications): avoid the never-settling service worker wait in background clients --- .../pages/client/BackgroundNotifications.tsx | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index 57dc4c241..7d37ccbc9 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -43,7 +43,7 @@ import { import * as Sentry from '@sentry/react'; import { startClient, stopClient } from '$client/initMatrix'; import { createSessionTokenRefresher } from '$client/oidcTokenRefresher'; -import { isDesktopTauri } from '$utils/platform'; +import { hasServiceWorker, isDesktopTauri } from '$utils/platform'; import { isMobileOrTablet } from '$utils/platform'; const log = createLogger('BackgroundNotifications'); @@ -241,7 +241,7 @@ export function BackgroundNotifications() { // by the SW notificationclick event. This routes through HandleNotificationClick // (postMessage path) which does the account switch + deep link reliably on all // platforms including iOS where window.Notification onclick is not fired. - if ('serviceWorker' in navigator) { + if (hasServiceWorker()) { try { const reg = await navigator.serviceWorker.ready; await reg.showNotification(opts.title, { @@ -257,18 +257,22 @@ export function BackgroundNotifications() { } } if ('Notification' in window && window.Notification.permission === 'granted') { - const noti = new window.Notification(opts.title, { - icon: opts.icon, - badge: opts.badge, - body: opts.body, - silent: opts.silent ?? false, - data: opts.data, - }); - if (opts.onClick) { - noti.addEventListener('click', () => { - opts.onClick?.(); - noti.close(); + try { + const noti = new window.Notification(opts.title, { + icon: opts.icon, + badge: opts.badge, + body: opts.body, + silent: opts.silent ?? false, + data: opts.data, }); + if (opts.onClick) { + noti.addEventListener('click', () => { + opts.onClick?.(); + noti.close(); + }); + } + } catch (err) { + debugLog.error('notification', 'Failed to show a background OS notification', err); } } } @@ -577,6 +581,8 @@ export function BackgroundNotifications() { silent: notificationPayload.options.silent ?? undefined, data: notificationPayload.options.data, onClick: notifOnClick, + }).catch((err: unknown) => { + debugLog.error('notification', 'Failed to send a background OS notification', err); }); } }; From a0d363d47b0c44476cc5a1163aeb9c8d4c69c40c Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 31 Aug 2026 23:17:29 +0200 Subject: [PATCH 8/8] fix(notifications): do not fall back to native when the chosen distributor is missing --- .../features/settings/notifications/SystemNotification.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index 537322892..54301c166 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -641,6 +641,12 @@ function BackgroundPushNotificationSetting() { const distributor = await ensureConfiguredUnifiedPushDistributor(); if (!distributor) { + const chosen = selectedDistributor || pushTransportOverride.unifiedPushDistributor; + if (chosen) { + throw new Error( + 'The selected UnifiedPush distributor is unavailable. Open it once, or choose another distributor.' + ); + } return nativeFallback('UnifiedPush is not configured.'); }