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]); 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.'); } 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'; } diff --git a/src/app/features/settings/notifications/UnifiedPushTransport.test.ts b/src/app/features/settings/notifications/UnifiedPushTransport.test.ts index ef513a962..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 () => { @@ -212,15 +215,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..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 { - 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); 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); }); } }; diff --git a/src/client/slidingSync.test.ts b/src/client/slidingSync.test.ts index 78dce3efb..75a1c9dce 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); }); @@ -1841,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 0eb928225..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'; @@ -716,6 +718,10 @@ export class SlidingSyncManager { private pushDrainPollsLeft = 0; + private pushDrainSawEvents = false; + + private pushDrainTimer: ReturnType | undefined; + private readonly resumeWaiters = new Set<() => void>(); private readonly transportStateListeners = new Set<() => void>(); @@ -1046,18 +1052,33 @@ 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; + 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 endPushDrain(): void { + if (this.pushDrainTimer !== undefined) { + clearTimeout(this.pushDrainTimer); + this.pushDrainTimer = undefined; + } + if (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; - const drained = (toDevice?.events?.length ?? 0) === 0; + if ((toDevice?.events?.length ?? 0) > 0) { + this.pushDrainSawEvents = true; + return; + } this.pushDrainPollsLeft -= 1; - if (!drained && this.pushDrainPollsLeft > 0) return; - this.pushDrainPollsLeft = 0; - this.notifyTransportState(); + if (this.pushDrainSawEvents || this.pushDrainPollsLeft === 0) this.endPushDrain(); } public isPaused(): boolean { @@ -1123,6 +1144,11 @@ export class SlidingSyncManager { this.disposed = true; 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);