Skip to content
Merged
4 changes: 2 additions & 2 deletions src/app/components/image-viewer/ImageViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -978,7 +980,7 @@ async function handleMinimalPushPayload(
}
}

const hasInMemoryPreview = Boolean(previewText);
const hasInMemoryPreview = Boolean(previewText) && !inMemoryStillEncrypted;
if (!previewText) {
previewText = isEncryptedRoom ? 'Encrypted message' : 'New message';
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
12 changes: 7 additions & 5 deletions src/app/features/settings/notifications/UnifiedPushTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export async function loadUnifiedPushDistributorState(): Promise<UnifiedPushDist
return { distributors, selectedDistributor: savedDistributor };
}

if (distributors.length === 1) {
if (!savedDistributor && distributors.length === 1) {
const [onlyDistributor] = distributors;
if (onlyDistributor) {
await saveUnifiedPushDistributor(onlyDistributor);
Expand All @@ -165,11 +165,13 @@ export async function ensureUnifiedPushDistributorSelection(
distributors: string[],
selectedDistributor: string
): Promise<string> {
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);
Expand Down
32 changes: 19 additions & 13 deletions src/app/pages/client/BackgroundNotifications.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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, {
Expand All @@ -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);
}
}
}
Expand Down Expand Up @@ -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);
});
}
};
Expand Down
44 changes: 41 additions & 3 deletions src/client/slidingSync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
});

Expand All @@ -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();
Expand Down
34 changes: 30 additions & 4 deletions src/client/slidingSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -716,6 +718,10 @@ export class SlidingSyncManager {

private pushDrainPollsLeft = 0;

private pushDrainSawEvents = false;

private pushDrainTimer: ReturnType<typeof setTimeout> | undefined;

private readonly resumeWaiters = new Set<() => void>();

private readonly transportStateListeners = new Set<() => void>();
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
Loading