diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 1dc9ff2dc..fb85767c6 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -473,8 +473,16 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) const sender = event.getSender(); const content = event.getContent(); + // Trust the caller's sender_ts only when it's within 20s of the server's + // timestamp in EITHER direction. A fast caller clock made a fresh invite + // look expired-in-the-future; a SLOW one left sender_ts in the past so + // `Date.now() >= senderTs + lifetime` hid/dismissed the ring for a + // genuinely fresh invite (COR-3). Fall back to the server ts on large skew. const senderTs = - content.sender_ts - event.getTs() > 20000 ? event.getTs() : content.sender_ts; + typeof content.sender_ts === 'number' && + Math.abs(content.sender_ts - event.getTs()) <= 20000 + ? content.sender_ts + : event.getTs(); const lifetime = Math.min(content.lifetime, 120000); const notificationType = content.notification_type; const relation = diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index d4bdd30f8..d36deeccd 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -159,6 +159,7 @@ export class CallControl extends EventEmitter implements CallControlState { desired.sound, this.screenshare, this.spotlight, + this.screenshareAudioMuted, ); await this.applyState(); // P6-2: CallEmbed calls forceState() only from onCallJoined(), so this is diff --git a/src/app/state/upload.ts b/src/app/state/upload.ts index b1311376e..8ecee4cbf 100644 --- a/src/app/state/upload.ts +++ b/src/app/state/upload.ts @@ -1,7 +1,7 @@ import { atom, useAtom } from 'jotai'; import { atomFamily } from 'jotai/utils'; import { MatrixClient, UploadResponse, UploadProgress, MatrixError } from 'matrix-js-sdk'; -import { useCallback } from 'react'; +import { useCallback, useRef } from 'react'; import { useThrottle } from '../hooks/useThrottle'; import { uploadContent, TUploadContent } from '../utils/matrix'; @@ -110,19 +110,25 @@ export const useBindUploadAtom = ( { immediate: true, wait: 200 }, ); - const startUpload = useCallback( - () => - uploadContent(mx, file, { - hideFilename, - onPromise: (promise: Promise) => setUpload({ promise }), - onProgress: handleProgress, - onSuccess: (mxc) => setUpload({ mxc }), - onError: (error) => setUpload({ error }), - }), - [mx, file, hideFilename, setUpload, handleProgress], - ); + const abortRef = useRef(undefined); + + const startUpload = useCallback(() => { + const controller = new AbortController(); + abortRef.current = controller; + return uploadContent(mx, file, { + hideFilename, + signal: controller.signal, + onPromise: (promise: Promise) => setUpload({ promise }), + onProgress: handleProgress, + onSuccess: (mxc) => setUpload({ mxc }), + onError: (error) => setUpload({ error }), + }); + }, [mx, file, hideFilename, setUpload, handleProgress]); const cancelUpload = useCallback(async () => { + // Abort the retry loop first (covers a cancel during the back-off sleep, when + // there is no in-flight request), then abort any in-flight upload request. + abortRef.current?.abort(); if (upload.status === UploadStatus.Loading) { await mx.cancelUpload(upload.promise); } diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index c346ce538..ce1497309 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -144,6 +144,9 @@ export type ContentUploadOptions = { onProgress?: (progress: UploadProgress) => void; onSuccess: (mxc: string) => void; onError: (error: MatrixError) => void; + // Aborts the retry loop, including while it's sleeping between attempts (a + // plain mx.cancelUpload only aborts an in-flight request, not the back-off). + signal?: AbortSignal; }; // Build a MatrixError defensively from an unexpected upload response. @@ -192,16 +195,38 @@ export const uploadContent = async ( file: TUploadContent, options: ContentUploadOptions, ) => { - const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options; + const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError, signal } = + options; + // Resolves after `ms`, or early if `signal` aborts (so a cancel during the + // back-off is honored immediately instead of waiting out the delay). const sleepForMs = (ms: number) => - new Promise((resolve) => { - setTimeout(resolve, ms); + new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + const onAbort = () => { + clearTimeout(timeout); + resolve(); + }; + const timeout = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal?.addEventListener('abort', onAbort, { once: true }); }); + const abortError = () => + matrixErrorFromUnknown(new DOMException('Upload cancelled', 'AbortError')); + let lastError: MatrixError | undefined; for (let retryCount = 0; retryCount <= UPLOAD_MAX_RETRY_COUNT; retryCount += 1) { + if (signal?.aborted) { + onError(abortError()); + return; + } const uploadPromise = mx.uploadContent(file, { name, type: fileType, @@ -236,6 +261,11 @@ export const uploadContent = async ( Math.min(1000 * 2 ** retryCount, 30_000); // eslint-disable-next-line no-await-in-loop await sleepForMs(waitMS); + // Cancelled during the back-off — stop instead of resurrecting the upload. + if (signal?.aborted) { + onError(abortError()); + return; + } } }