diff --git a/.github/workflows/docker-pr.yml b/.github/workflows/docker-pr.yml index 19237f95a..b4025085c 100644 --- a/.github/workflows/docker-pr.yml +++ b/.github/workflows/docker-pr.yml @@ -22,11 +22,11 @@ jobs: uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Login to Docker Hub #Do not update this action from a outside PR if: github.event.pull_request.head.repo.fork == false - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} @@ -34,7 +34,7 @@ jobs: - name: Login to the Github Container registry #Do not update this action from a outside PR if: github.event.pull_request.head.repo.fork == false - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -43,14 +43,14 @@ jobs: - name: Extract metadata (tags, labels) for Docker, GHCR id: meta - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: images: | ajbura/cinny ghcr.io/${{ github.repository }} - name: Build Docker image (no push) - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . platforms: linux/amd64 diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index 3c150e505..a07bfc74a 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -70,27 +70,27 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Login to Docker Hub #Do not update this action from a outside PR - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login to the Github Container registry #Do not update this action from a outside PR - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker, GHCR id: meta - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: images: | ${{ secrets.DOCKER_USERNAME }}/cinny ghcr.io/${{ github.repository }} - name: Build and push Docker image - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/package-lock.json b/package-lock.json index 970749d6a..2ebd619d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lotus-chat", - "version": "4.12.2-lotus", + "version": "4.12.3-lotus", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lotus-chat", - "version": "4.12.2-lotus", + "version": "4.12.3-lotus", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { @@ -79,7 +79,7 @@ "ua-parser-js": "2.0.10" }, "devDependencies": { - "@element-hq/element-call-embedded": "0.19.4", + "@element-hq/element-call-embedded": "0.20.1", "@rollup/plugin-inject": "5.0.5", "@rollup/plugin-wasm": "6.2.2", "@sentry/vite-plugin": "5.3.0", @@ -1792,9 +1792,9 @@ } }, "node_modules/@element-hq/element-call-embedded": { - "version": "0.19.4", - "resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.19.4.tgz", - "integrity": "sha512-crawgHughTv6yYoCqgq7cKLxUDtYU/Xr7KgSFCT0NM++QHoYsM5WGmIU/yY2Q0QYPuzmHXIEK95IZmJaQ1jIJA==", + "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/@emnapi/core": { diff --git a/package.json b/package.json index c28d25b5e..d55d9387f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lotus-chat", - "version": "4.12.2-lotus", + "version": "4.12.3-lotus", "description": "Lotus Chat — Matrix client for Lotus Guild", "main": "index.js", "type": "module", @@ -103,7 +103,7 @@ "ua-parser-js": "2.0.10" }, "devDependencies": { - "@element-hq/element-call-embedded": "0.19.4", + "@element-hq/element-call-embedded": "0.20.1", "@rollup/plugin-inject": "5.0.5", "@rollup/plugin-wasm": "6.2.2", "@sentry/vite-plugin": "5.3.0", diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index c40ee12ff..d504d9c3f 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -192,11 +192,11 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr /> - + {roomName} - + {info.intent === 'video' ? 'Incoming Video Call' : 'Incoming Voice Call'} @@ -283,6 +283,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/components/page/style.css.ts b/src/app/components/page/style.css.ts index e03829bd5..d8ed843d3 100644 --- a/src/app/components/page/style.css.ts +++ b/src/app/components/page/style.css.ts @@ -117,6 +117,7 @@ export const PageHeroSection = style([ }, ]); + export const PageContentCenter = style([ DefaultReset, { diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index b8d037542..40b96bf06 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} aria-label={enabled ? 'Turn off microphone' : 'Turn on microphone'} aria-pressed={!enabled} > @@ -85,6 +88,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} aria-label={enabled ? 'Stop Video' : 'Start Video'} 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]), ); @@ -181,7 +194,7 @@ export function CallControl({ callEmbed.control.toggleMicrophone()} + onToggle={handleMicrophoneToggle} disabled={!callJoined} /> {!compact && } - callEmbed.control.toggleVideo()} - disabled={!callJoined} - /> + {!compact && ( callEmbed.control.toggleMicrophone(), + [callEmbed], + ); + const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]); + const pttActiveRef = useRef(false); useEffect(() => { @@ -368,10 +374,7 @@ export function CallControls({ callEmbed }: CallControlsProps) { > - callEmbed.control.toggleMicrophone()} - /> + callEmbed.control.toggleSound()} /> {!compact && } - callEmbed.control.toggleVideo()} /> + diff --git a/src/app/features/call/Controls.tsx b/src/app/features/call/Controls.tsx index 3bbfd21f5..0a7d3815e 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} aria-label={enabled ? 'Turn Off Microphone' : 'Turn On Microphone'} outlined + disabled={loading} > @@ -82,10 +87,13 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) { type VideoButtonProps = { enabled: boolean; - onToggle: () => void; + onToggle: () => Promise; disabled?: boolean; }; export 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} aria-label={ disabled ? 'Camera disabled in settings' : enabled ? 'Stop camera' : 'Start camera' } diff --git a/src/app/features/call/PrescreenControls.tsx b/src/app/features/call/PrescreenControls.tsx index cdc35e5eb..f624ec6af 100644 --- a/src/app/features/call/PrescreenControls.tsx +++ b/src/app/features/call/PrescreenControls.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Box, Button, Icon, Icons, Spinner, Text } from 'folds'; import { SequenceCard } from '../../components/sequence-card'; import * as css from './styles.css'; @@ -54,6 +54,9 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) { useCallPreferences(); const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin'); + const handleMicrophoneToggle = useCallback(async () => toggleMicrophone(), [toggleMicrophone]); + const handleVideoToggle = useCallback(async () => toggleVideo(), [toggleVideo]); + return ( - + - + diff --git a/src/app/features/lobby/LobbyHero.css.tsx b/src/app/features/lobby/LobbyHero.css.tsx index ad7e9382b..3349213a2 100644 --- a/src/app/features/lobby/LobbyHero.css.tsx +++ b/src/app/features/lobby/LobbyHero.css.tsx @@ -6,6 +6,7 @@ export const LobbyHeroTopic = style({ WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden', + wordBreak: 'break-word', ':hover': { cursor: 'pointer', diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index 9ce7bfac4..b87a620fa 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -14,10 +14,14 @@ export class CallControl extends EventEmitter implements CallControlState { private iframe: HTMLIFrameElement; + private bodyMutationObserver: MutationObserver; + private controlMutationObserver: MutationObserver; private _pipMode = false; + private mediaStatePromiseResolver: undefined | (() => void); + private get document(): Document | undefined { return this.iframe.contentDocument ?? this.iframe.contentWindow?.document; } @@ -30,17 +34,29 @@ export class CallControl extends EventEmitter implements CallControlState { return screenshareBtn ?? undefined; } + private get leaveButton(): Element | undefined { + const leaveBtn = this.document?.querySelector('[data-testid="incall_leave"]'); + + return leaveBtn ?? undefined; + } + private get settingsButton(): HTMLElement | undefined { - // EC 0.19.3: settings button has data-testid="settings-bottom-center" - return ( - (this.document?.querySelector('[data-testid="settings-bottom-center"]') as HTMLElement) ?? - undefined - ); + // EC 0.20.1: settings button moved to bottom-left; fall back to bottom-center. + 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 { - // EC 0.19.3: reactions/raise-hand button has a CSS module class containing "raiseHand" - return (this.document?.querySelector('[class*="raiseHand"]') as HTMLElement) ?? undefined; + // EC 0.20.1: reactions/raise-hand button sits just before the leave button. + const reactionsButton = this.leaveButton?.previousElementSibling as HTMLElement | null; + + return reactionsButton ?? undefined; } private get spotlightButton(): HTMLInputElement | undefined { @@ -66,6 +82,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)); } @@ -118,6 +135,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; @@ -141,8 +182,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 { @@ -186,9 +233,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; @@ -321,6 +373,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 9088e52c2..36167671e 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -214,6 +214,10 @@ export class CallEmbed { const controlState = initialControlState ?? new CallControlState(true, false, true); this.control = new CallControl(controlState, call, iframe); this.initialState = controlState; + this.control.startObserving(); + iframe.onload = () => { + this.control.startObserving(); + }; let initialMediaEvent = true; this.disposables.push(