diff --git a/package-lock.json b/package-lock.json index 1ccaa03f9..85be67fb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "jotai": "2.6.0", "linkify-react": "4.3.2", "linkifyjs": "4.3.2", - "matrix-js-sdk": "41.5.0", + "matrix-js-sdk": "41.7.0", "matrix-widget-api": "1.16.1", "millify": "6.1.0", "pdfjs-dist": "4.2.67", @@ -66,7 +66,7 @@ "ua-parser-js": "1.0.35" }, "devDependencies": { - "@element-hq/element-call-embedded": "0.19.1", + "@element-hq/element-call-embedded": "0.20.1", "@esbuild-plugins/node-globals-polyfill": "0.2.3", "@rollup/plugin-inject": "5.0.3", "@rollup/plugin-wasm": "6.1.1", @@ -1774,9 +1774,9 @@ } }, "node_modules/@element-hq/element-call-embedded": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.19.1.tgz", - "integrity": "sha512-RDZY3P3LTx10ACaGhzkwh2+boNB3x54zHF/7v/cCyoQlAVfEYMhgMEb4CRTwJFwwYFe1r++6Higa0A0G5XxZ8Q==", + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.20.1.tgz", + "integrity": "sha512-ODg2r7UmR8UjRpapLKbn6v1PS8fu/r58zdbvXMYaAlUEAC2f6L/9Moc9S4noG1+ARgWxY+m2vLmNDK9G9uFZYQ==", "dev": true }, "node_modules/@emotion/hash": { @@ -2386,9 +2386,9 @@ } }, "node_modules/@matrix-org/matrix-sdk-crypto-wasm": { - "version": "18.3.0", - "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.0.tgz", - "integrity": "sha512-9a4feyt8QLysARu7PHKaRWT+wcCd+IYH074LXp9QK5WqfN4zUXueRhiSSMNT18Bm+8q3sBR/4zxDxOSDR0M8Kg==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.1.tgz", + "integrity": "sha512-VRjWhE1UgHnPpJ3b9B5+8z71ZC/HICFngPPFIN6ktzmUBKI5RusPujzbAQUoB3CgZ0yU58L99AfSQS4YTztSWw==", "license": "Apache-2.0", "engines": { "node": ">= 18" @@ -6216,12 +6216,16 @@ "optional": true }, "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/conventional-commit-types": { @@ -10106,16 +10110,16 @@ "license": "Apache-2.0" }, "node_modules/matrix-js-sdk": { - "version": "41.5.0", - "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.5.0.tgz", - "integrity": "sha512-CK3h+qQJ4wkVEUgEWc5MdLjccXyiFqncCC53P+auqOhnX2U6tAFsRfnbML1QQiKIsFMzqTrAnF/4a5LUUOIeXg==", + "version": "41.7.0", + "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.7.0.tgz", + "integrity": "sha512-MP0xNv/VVRbshq00TE6EVo77IIXsQk0KjiVtgKV0t9j/V77a6Klt00QrrO0XykkTUsNC0+mQeBMxnx75rZO86Q==", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.12.5", - "@matrix-org/matrix-sdk-crypto-wasm": "^18.2.0", + "@matrix-org/matrix-sdk-crypto-wasm": "^18.3.1", "another-json": "^0.2.0", "bs58": "^6.0.0", - "content-type": "^1.0.4", + "content-type": "^2.0.0", "jwt-decode": "^4.0.0", "loglevel": "^1.9.2", "matrix-events-sdk": "0.0.1", diff --git a/package.json b/package.json index 4c306e31a..f763c3eb5 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "jotai": "2.6.0", "linkify-react": "4.3.2", "linkifyjs": "4.3.2", - "matrix-js-sdk": "41.5.0", + "matrix-js-sdk": "41.7.0", "matrix-widget-api": "1.16.1", "millify": "6.1.0", "pdfjs-dist": "4.2.67", @@ -90,7 +90,7 @@ "ua-parser-js": "1.0.35" }, "devDependencies": { - "@element-hq/element-call-embedded": "0.19.1", + "@element-hq/element-call-embedded": "0.20.1", "@esbuild-plugins/node-globals-polyfill": "0.2.3", "@rollup/plugin-inject": "5.0.3", "@rollup/plugin-wasm": "6.1.1", diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 94784e11f..a6f6dec54 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -148,11 +148,13 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr /> - + {roomName} - Incoming Call + + Incoming Call + {!livekitSupported && ( @@ -237,6 +239,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) // only process rtc notification reference events. // we do not want to wait to decrypt all events. if (event.getRelation()?.rel_type !== RelationType.Reference) return; + if (room?.isCallRoom()) return; if (event.isEncrypted()) { if (!event.isBeingDecrypted()) { diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index 6416fda52..1808be929 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -12,6 +12,9 @@ type MicrophoneButtonProps = { disabled?: boolean; }; function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) { + const [micState, toggleMic] = useAsyncCallback(onToggle); + const loading = micState.status === AsyncStatus.Loading; + return ( onToggle()} + onClick={toggleMic} outlined - disabled={disabled} + disabled={disabled || loading} > @@ -82,6 +85,9 @@ type VideoButtonProps = { disabled?: boolean; }; function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) { + const [videoState, toggleVideo] = useAsyncCallback(onToggle); + const loading = videoState.status === AsyncStatus.Loading; + return ( onToggle()} + onClick={toggleVideo} outlined - disabled={disabled} + disabled={disabled || loading} > callEmbed.control.toggleMicrophone(), [callEmbed]); + const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]); + const [hangupState, hangup] = useAsyncCallback( useCallback(() => callEmbed.hangup(), [callEmbed]) ); @@ -177,7 +186,7 @@ export function CallControl({ callEmbed.control.toggleMicrophone()} + onToggle={handleMicrophoneToggle} disabled={!callJoined} /> } callEmbed.control.toggleVideo()} + onToggle={handleVideoToggle} disabled={!callJoined} /> {!compact && ( diff --git a/src/app/features/call/CallControls.tsx b/src/app/features/call/CallControls.tsx index 72edc57f3..469b47a8e 100644 --- a/src/app/features/call/CallControls.tsx +++ b/src/app/features/call/CallControls.tsx @@ -71,6 +71,9 @@ export function CallControls({ callEmbed }: CallControlsProps) { setCords(undefined); }; + const handleMicrophoneToggle = useCallback(() => callEmbed.control.toggleMicrophone(), [callEmbed]); + const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]); + const [hangupState, hangup] = useAsyncCallback( useCallback(() => callEmbed.hangup(), [callEmbed]) ); @@ -96,13 +99,13 @@ export function CallControls({ callEmbed }: CallControlsProps) { callEmbed.control.toggleMicrophone()} + onToggle={handleMicrophoneToggle} /> callEmbed.control.toggleSound()} /> {!compact && } - callEmbed.control.toggleVideo()} /> + callEmbed.control.toggleScreenshare()} diff --git a/src/app/features/call/Controls.tsx b/src/app/features/call/Controls.tsx index 143a80226..6aa94ff50 100644 --- a/src/app/features/call/Controls.tsx +++ b/src/app/features/call/Controls.tsx @@ -3,6 +3,7 @@ import { Icon, IconButton, Icons, Line, Text, Tooltip, TooltipProvider } from 'f import { useAtom } from 'jotai'; import * as css from './styles.css'; import { callChatAtom } from '../../state/callEmbed'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; export function ControlDivider() { return ( @@ -12,9 +13,12 @@ export function ControlDivider() { type MicrophoneButtonProps = { enabled: boolean; - onToggle: () => void; + onToggle: () => Promise; }; export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) { + const [micState, toggleMic] = useAsyncCallback(onToggle); + const loading = micState.status === AsyncStatus.Loading; + return ( onToggle()} + onClick={toggleMic} outlined + disabled={loading} > @@ -80,9 +85,12 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) { type VideoButtonProps = { enabled: boolean; - onToggle: () => void; + onToggle: () => Promise; }; export function VideoButton({ enabled, onToggle }: VideoButtonProps) { + const [videoState, toggleVideo] = useAsyncCallback(onToggle); + const loading = videoState.status === AsyncStatus.Loading; + return ( onToggle()} + onClick={toggleVideo} outlined + disabled={loading} > toggleMicrophone(), [toggleMicrophone]); + const handleVideoToggle = useCallback(async () => toggleVideo(), [toggleVideo]); + return ( - + - + diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index f4162d73b..62f6966a2 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -14,8 +14,12 @@ export class CallControl extends EventEmitter implements CallControlState { private iframe: HTMLIFrameElement; + private bodyMutationObserver: MutationObserver; + private controlMutationObserver: MutationObserver; + private mediaStatePromiseResolver: undefined | (() => void); + private get document(): Document | undefined { return this.iframe.contentDocument ?? this.iframe.contentWindow?.document; } @@ -28,16 +32,25 @@ export class CallControl extends EventEmitter implements CallControlState { return screenshareBtn ?? undefined; } - private get settingsButton(): HTMLElement | undefined { + private get leaveButton(): Element | undefined { const leaveBtn = this.document?.querySelector('[data-testid="incall_leave"]'); - const settingsButton = leaveBtn?.previousElementSibling as HTMLElement | null; + return leaveBtn ?? undefined; + } - return settingsButton ?? undefined; + private get settingsButton(): HTMLElement | undefined { + const settingsButtonLeft = this.document?.querySelector( + '[data-testid="settings-bottom-left"]' + ) as HTMLButtonElement | undefined; + const settingsButtonCenter = this.document?.querySelector( + '[data-testid="settings-bottom-center"]' + ) as HTMLButtonElement | undefined; + + return settingsButtonLeft ?? settingsButtonCenter ?? undefined; } private get reactionsButton(): HTMLElement | undefined { - const reactionsButton = this.settingsButton?.previousElementSibling as HTMLElement | null; + const reactionsButton = this.leaveButton?.previousElementSibling as HTMLElement | null; return reactionsButton ?? undefined; } @@ -65,6 +78,7 @@ export class CallControl extends EventEmitter implements CallControlState { this.call = call; this.iframe = iframe; + this.bodyMutationObserver = new MutationObserver(this.onBodyMutation.bind(this)); this.controlMutationObserver = new MutationObserver(this.onControlMutation.bind(this)); } @@ -102,6 +116,30 @@ export class CallControl extends EventEmitter implements CallControlState { } public startObserving() { + if (!this.document) return; + + this.bodyMutationObserver.observe(this.document.body, { + childList: true, + subtree: false, // only direct children of body + }); + this.onBodyMutation(); + } + + private onBodyMutation() { + if (!this.document) return; + + this.document.body.style.setProperty('background', 'none', 'important'); + + const controls = this.leaveButton?.parentElement?.parentElement; + if (controls) { + controls.style.setProperty('position', 'absolute'); + controls.style.setProperty('visibility', 'hidden'); + } + + this.observeControls(); + } + + private observeControls() { this.controlMutationObserver.disconnect(); const screenshareBtn = this.screenshareButton; @@ -125,8 +163,14 @@ export class CallControl extends EventEmitter implements CallControlState { this.setSound(this.sound); } - private setMediaState(state: ElementMediaStatePayload) { - return this.call.transport.send(ElementWidgetActions.DeviceMute, state); + private async setMediaState(state: ElementMediaStatePayload) { + const data = await this.call.transport.send(ElementWidgetActions.DeviceMute, state); + return new Promise(resolve => { + if (this.mediaStatePromiseResolver) { + this.mediaStatePromiseResolver(); + } + this.mediaStatePromiseResolver = () => resolve(data); + }); } private setSound(sound: boolean): void { @@ -157,9 +201,14 @@ export class CallControl extends EventEmitter implements CallControlState { if (this.microphone && !this.sound) { this.toggleSound(); } + + if (this.mediaStatePromiseResolver) { + this.mediaStatePromiseResolver(); + this.mediaStatePromiseResolver = undefined; + } } - public onControlMutation() { + private onControlMutation() { const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary'; const spotlight: boolean = this.spotlightButton?.checked ?? false; @@ -230,6 +279,7 @@ export class CallControl extends EventEmitter implements CallControlState { } public dispose() { + this.bodyMutationObserver.disconnect(); this.controlMutationObserver.disconnect(); } diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index f79b64b18..8d03bc8a8 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -172,6 +172,10 @@ export class CallEmbed { const controlState = initialControlState ?? new CallControlState(true, false, true); this.control = new CallControl(controlState, call, iframe); + this.control.startObserving(); + iframe.onload = () => { + this.control.startObserving(); + }; let initialMediaEvent = true; this.disposables.push( @@ -272,21 +276,6 @@ export class CallEmbed { private onCallJoined(): void { this.joined = true; - this.applyStyles(); - this.control.startObserving(); - } - - private applyStyles(): void { - const doc = this.document; - if (!doc) return; - - doc.body.style.setProperty('background', 'none', 'important'); - const controls = doc.body.querySelector('[data-testid="incall_leave"]')?.parentElement - ?.parentElement; - if (controls) { - controls.style.setProperty('position', 'absolute'); - controls.style.setProperty('visibility', 'hidden'); - } } private onEvent(ev: MatrixEvent): void {