Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/app/components/DeviceVerification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
requestClose={handleCancel}
dismissOnClickOutside={false}
escapeDeactivates={false}
deactivateCloses={false}
>
<Dialog variant="Surface">
<Header style={DialogHeaderStyles} variant="Surface" size="500">
Expand Down
22 changes: 21 additions & 1 deletion src/app/components/ReceiveSelfDeviceVerification.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ vi.mock('$hooks/useMatrixClient', () => ({
}));

vi.mock('$components/modal-overlay/ModalOverlay', () => ({
ModalOverlay: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
ModalOverlay: ({
children,
deactivateCloses,
}: {
children: React.ReactNode;
deactivateCloses?: boolean;
}) => <div data-deactivate-closes={String(deactivateCloses)}>{children}</div>,
}));

const pendingRequest = {
Expand Down Expand Up @@ -51,6 +57,20 @@ describe('ReceiveSelfDeviceVerification', () => {
await waitFor(() => expect(screen.getByText('Device Verification')).toBeInTheDocument());
});

it('does not treat unmounting as the user cancelling', async () => {
const cancel = vi.fn<() => Promise<void>>(async () => undefined);
getVerificationRequestsToDeviceInProgress.mockReturnValue([{ ...pendingRequest, cancel }]);

const { unmount } = renderReceiver();
await waitFor(() => expect(screen.getByText('Device Verification')).toBeInTheDocument());
expect(
screen.getByText('Device Verification').closest('[data-deactivate-closes]')
).toHaveAttribute('data-deactivate-closes', 'false');

unmount();
expect(cancel).not.toHaveBeenCalled();
});

it('ignores a request this device started', async () => {
getVerificationRequestsToDeviceInProgress.mockReturnValue([
{ ...pendingRequest, initiatedByMe: true },
Expand Down
10 changes: 7 additions & 3 deletions src/app/components/modal-overlay/ModalOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ type ModalOverlayProps = {
background?: string;
/** Set false for full-bleed viewers that inset their own controls. */
respectSafeArea?: boolean;
/** Set false where unmounting must not count as the user dismissing the overlay. */
deactivateCloses?: boolean;
children: ReactNode;
};

Expand All @@ -52,8 +54,10 @@ export function ModalOverlay({
escapeDeactivates = stopPropagation,
background,
respectSafeArea = true,
deactivateCloses = true,
children,
}: ModalOverlayProps) {
const onDeactivate = deactivateCloses ? requestClose : undefined;
// Null outside a provider, where desktop is the safe assumption.
const isMobile = useScreenSizeOptionally() === ScreenSize.Mobile;
const ownedModalRef = useRef<HTMLDivElement | null>(null);
Expand All @@ -73,7 +77,7 @@ export function ModalOverlay({
initialFocus: false,
fallbackFocus: () => contentRef?.current ?? document.body,
escapeDeactivates,
onDeactivate: requestClose,
onDeactivate,
}}
>
<div
Expand Down Expand Up @@ -102,7 +106,7 @@ export function ModalOverlay({
const focusTrapOptions: FocusTrapOptions = {
initialFocus: false,
fallbackFocus: () => document.body,
onDeactivate: requestClose,
onDeactivate,
clickOutsideDeactivates: dismissOnClickOutside,
escapeDeactivates,
};
Expand All @@ -128,7 +132,7 @@ export function ModalOverlay({
fallbackFocus: () =>
(size ? ownedModalRef.current : contentRef?.current) ?? document.body,
clickOutsideDeactivates: dismissOnClickOutside,
onDeactivate: requestClose,
onDeactivate,
escapeDeactivates,
}}
>
Expand Down
16 changes: 16 additions & 0 deletions src/app/crypto/engineCrypto/EngineCrypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@

const entries = Object.entries(value as Record<string, unknown>)
.filter(([, item]) => item !== undefined)
.sort(([a], [b]) => (a < b ? -1 : 1));

Check warning on line 136 in src/app/crypto/engineCrypto/EngineCrypto.ts

View workflow job for this annotation

GitHub Actions / Lint

unicorn(no-array-sort)

src/app/crypto/engineCrypto/EngineCrypto.ts:136:6: Use `Array#toSorted()` instead of `Array#sort()`.
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}`;
};

Expand Down Expand Up @@ -2005,14 +2005,30 @@
return request;
}

async #cancelStaleRequests(userId: string): Promise<void> {
const stale = [...this.#verificationRequests.values()].filter(
(request) => request.otherUserId === userId && request.pending
);
for (const request of stale) {
traceVerification('Cancelling a stale verification request', {
flowId: request.transactionId ?? null,
});
// eslint-disable-next-line no-await-in-loop
await request.cancel().catch(() => undefined);
if (request.transactionId) this.#verificationRequests.delete(request.transactionId);
}
}

async requestOwnUserVerification(): Promise<VerificationRequest> {
await this.#cancelStaleRequests(this.#identity.userId);
return this.#startVerification('userIdentity.requestVerification', {
userId: this.#identity.userId,
methods: SUPPORTED_VERIFICATION_METHOD_CODES,
});
}

async requestDeviceVerification(userId: string, deviceId: string): Promise<VerificationRequest> {
await this.#cancelStaleRequests(userId);
await this.#sendTracked(await this.#call('queryKeysForUsers', { users: [userId] }));
await this.#flushOutgoingRequests();
return this.#startVerification('device.requestVerification', {
Expand Down
35 changes: 35 additions & 0 deletions src/app/crypto/engineCrypto/incomingVerificationRequest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,41 @@ describe('sending a verification request', () => {
});
});

describe('stale verification requests', () => {
beforeEach(() => {
mockInvoke.mockReset();
vi.mocked(traceVerification).mockClear();
});

it('cancels an existing pending flow before starting a new one', async () => {
const { mx } = clientSpy();
const invoked: string[] = [];
mockInvoke.mockImplementation(async (_identity, method) => {
invoked.push(method as string);
if (method === 'receiveSyncChanges') {
return [{ type: 3, rawEvent: JSON.stringify(REQUEST_EVENT) }];
}
if (method === 'getVerificationRequest') return requestState;
if (method === 'queryKeysForUsers') {
return { id: 'q1', type: 1, className: 'KeysQueryRequest', body: '{}' };
}
if (method === 'device.requestVerification') {
return { request: { ...requestState, flowId: '$new' }, outgoingRequest: null };
}
return [];
});

const crypto = new EngineCrypto(mx, { userId: '@me:e.org', deviceId: 'D' });
await crypto.preprocessToDeviceMessages([REQUEST_EVENT as never]);
await crypto.requestDeviceVerification('@me:e.org', 'OTHER');

expect(invoked).toContain('verificationRequest.cancel');
expect(invoked.indexOf('verificationRequest.cancel')).toBeLessThan(
invoked.indexOf('device.requestVerification')
);
});
});

describe('pending verification request sweep', () => {
beforeEach(() => mockInvoke.mockReset());

Expand Down
26 changes: 16 additions & 10 deletions src/app/hooks/useNetworkRecovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { onlineManager } from '@tanstack/react-query';
import { TauriEvent, listen } from '@tauri-apps/api/event';
import { isTauri } from '@tauri-apps/api/core';
import type { MatrixClient } from '$types/matrix-sdk';
import { SyncState } from '$types/matrix-sdk';
import type { NudgeReason } from '$client/reconnect';
import { abortClassicSyncPoll, nudgeReconnect } from '$client/reconnect';
import { useSyncState } from './useSyncState';
Expand All @@ -19,6 +20,7 @@ const WEDGED_NUDGE_ATTEMPTS = 3;

export const useNetworkRecovery = (mx: MatrixClient | undefined): void => {
const lastSyncAtRef = useRef(Date.now());
const syncStateRef = useRef<SyncState | null>(null);
const verifyTimerRef = useRef<number | undefined>(undefined);
const deadNudgesRef = useRef(0);

Expand All @@ -39,11 +41,15 @@ export const useNetworkRecovery = (mx: MatrixClient | undefined): void => {

useSyncState(
mx,
useCallback(() => {
lastSyncAtRef.current = Date.now();
deadNudgesRef.current = 0;
cancelVerify();
}, [cancelVerify])
useCallback(
(current) => {
syncStateRef.current = current;
lastSyncAtRef.current = Date.now();
deadNudgesRef.current = 0;
cancelVerify();
},
[cancelVerify]
)
);

// Foreground nudges (resume / visible-stale / online) get one sync verification:
Expand All @@ -70,11 +76,11 @@ export const useNetworkRecovery = (mx: MatrixClient | undefined): void => {

const onOnline = () => nudgeForeground('online');
const onVisible = () => {
if (
document.visibilityState === 'visible' &&
Date.now() - lastSyncAtRef.current >= VISIBLE_STALE_MS
) {
nudgeForeground('visible');
if (document.visibilityState !== 'visible') return;
const degraded =
syncStateRef.current === SyncState.Error || syncStateRef.current === SyncState.Reconnecting;
if (degraded || Date.now() - lastSyncAtRef.current >= VISIBLE_STALE_MS) {
nudgeForeground('visible', degraded ? { force: true } : undefined);
}
};

Expand Down
20 changes: 20 additions & 0 deletions src/app/pages/client/SyncStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,26 @@ describe('useStickyDisconnected (hysteresis)', () => {
expect(result.current).toBeNull();
});

it('waits longer before the banner when the app has just been resumed', () => {
const { result, rerender } = renderHook(({ state }) => useStickyDisconnected(state), {
initialProps: { state: SyncState.Syncing as SyncState | null },
});
act(() => {
document.dispatchEvent(new Event('visibilitychange'));
});

rerender({ state: SyncState.Error });
act(() => {
vi.advanceTimersByTime(3000);
});
expect(result.current).toBeNull();

act(() => {
vi.advanceTimersByTime(6000);
});
expect(result.current).toBe(SyncState.Error);
});

it('shows banner after degraded for >2s', () => {
const { result, rerender } = renderHook(({ state }) => useStickyDisconnected(state), {
initialProps: { state: SyncState.Syncing as SyncState | null },
Expand Down
33 changes: 31 additions & 2 deletions src/app/pages/client/SyncStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,16 @@ import { type TitlebarStatusView, titlebarStatusAtom } from '$state/titlebarStat
import { SyncConnectionStatusBanner } from '$components/SyncConnectionStatus';
import { useDesktopSetting } from '$state/hooks/desktopSettings';
import { hasCustomDesktopTitlebar } from '$utils/tauriTitlebar';
import { createDebugLogger } from '$utils/debugLogger';

const syncLog = createDebugLogger('sync-status');

const DISCONNECTED_SHOW_DELAY_MS = 2000;
const DISCONNECTED_HIDE_DELAY_MS = 3000;
// Coming back from the background kills the poll; the SDK reconnects on its own well
// inside this window, so waiting spares a banner for something already being fixed.
const RESUME_SHOW_DELAY_MS = 8000;
const RESUME_WINDOW_MS = 10000;

type StateData = {
current: SyncState | null;
Expand All @@ -37,8 +44,17 @@ export const useStickyDisconnected = (syncCurrent: SyncState | null): SyncState
const degraded =
syncCurrent === SyncState.Reconnecting || syncCurrent === SyncState.Error ? syncCurrent : null;
const showStartedAtRef = useRef<number | null>(null);
const becameVisibleAtRef = useRef(0);
const [stickyDisconnected, setStickyDisconnected] = useState<SyncState | null>(null);

useEffect(() => {
const onVisibility = () => {
if (document.visibilityState === 'visible') becameVisibleAtRef.current = Date.now();
};
document.addEventListener('visibilitychange', onVisibility);
return () => document.removeEventListener('visibilitychange', onVisibility);
}, []);

useEffect(() => {
if (degraded) {
if (stickyDisconnected) {
Expand All @@ -49,7 +65,9 @@ export const useStickyDisconnected = (syncCurrent: SyncState | null): SyncState

const startedAt = showStartedAtRef.current ?? Date.now();
showStartedAtRef.current = startedAt;
const remaining = Math.max(0, DISCONNECTED_SHOW_DELAY_MS - (Date.now() - startedAt));
const justResumed = startedAt - becameVisibleAtRef.current < RESUME_WINDOW_MS;
const showDelay = justResumed ? RESUME_SHOW_DELAY_MS : DISCONNECTED_SHOW_DELAY_MS;
const remaining = Math.max(0, showDelay - (Date.now() - startedAt));
const id = setTimeout(() => {
showStartedAtRef.current = null;
setStickyDisconnected(degraded);
Expand Down Expand Up @@ -83,7 +101,7 @@ export function SyncStatus({ mx }: SyncStatusProps) {

useSyncState(
mx,
useCallback((current, previous) => {
useCallback((current, previous, data) => {
const showConnecting = shouldShowConnecting(hasConnectedRef.current, current, previous);
if (current === SyncState.Syncing) hasConnectedRef.current = true;

Expand All @@ -99,6 +117,17 @@ export function SyncStatus({ mx }: SyncStatusProps) {
});

if (current === SyncState.Reconnecting || current === SyncState.Error) {
const error = data?.error as
| { name?: string; message?: string; errcode?: string; httpStatus?: number }
| undefined;
syncLog.warn('network', 'Sync degraded', {
state: current,
previous: previous ?? 'none',
name: error?.name ?? 'none',
message: error?.message ?? 'none',
errcode: error?.errcode ?? 'none',
httpStatus: error?.httpStatus ?? -1,
});
Sentry.addBreadcrumb({
category: 'sync',
message: `Sync state changed to ${current}`,
Expand Down
Loading