Merge upstream v4.12.3 (Element Call 0.20.1) into lotus
CI / Build & Quality Checks (push) Successful in 10m42s
CI / Trigger Desktop Build (push) Successful in 10s

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 04:11:41 -04:00
13 changed files with 136 additions and 53 deletions
+5 -5
View File
@@ -22,11 +22,11 @@ jobs:
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx - 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 - name: Login to Docker Hub #Do not update this action from a outside PR
if: github.event.pull_request.head.repo.fork == false 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: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} 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 - 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 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: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -43,14 +43,14 @@ jobs:
- name: Extract metadata (tags, labels) for Docker, GHCR - name: Extract metadata (tags, labels) for Docker, GHCR
id: meta id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with: with:
images: | images: |
ajbura/cinny ajbura/cinny
ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}
- name: Build Docker image (no push) - 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: with:
context: . context: .
platforms: linux/amd64 platforms: linux/amd64
+5 -5
View File
@@ -70,27 +70,27 @@ jobs:
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx - 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 - 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: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to the Github Container registry #Do not update this action from a outside PR - 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: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker, GHCR - name: Extract metadata (tags, labels) for Docker, GHCR
id: meta id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with: with:
images: | images: |
${{ secrets.DOCKER_USERNAME }}/cinny ${{ secrets.DOCKER_USERNAME }}/cinny
ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}
- name: Build and push Docker image - 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: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
+6 -6
View File
@@ -1,12 +1,12 @@
{ {
"name": "lotus-chat", "name": "lotus-chat",
"version": "4.12.2-lotus", "version": "4.12.3-lotus",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lotus-chat", "name": "lotus-chat",
"version": "4.12.2-lotus", "version": "4.12.3-lotus",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
@@ -79,7 +79,7 @@
"ua-parser-js": "2.0.10" "ua-parser-js": "2.0.10"
}, },
"devDependencies": { "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-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2", "@rollup/plugin-wasm": "6.2.2",
"@sentry/vite-plugin": "5.3.0", "@sentry/vite-plugin": "5.3.0",
@@ -1792,9 +1792,9 @@
} }
}, },
"node_modules/@element-hq/element-call-embedded": { "node_modules/@element-hq/element-call-embedded": {
"version": "0.19.4", "version": "0.20.1",
"resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.19.4.tgz", "resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.20.1.tgz",
"integrity": "sha512-crawgHughTv6yYoCqgq7cKLxUDtYU/Xr7KgSFCT0NM++QHoYsM5WGmIU/yY2Q0QYPuzmHXIEK95IZmJaQ1jIJA==", "integrity": "sha512-ODg2r7UmR8UjRpapLKbn6v1PS8fu/r58zdbvXMYaAlUEAC2f6L/9Moc9S4noG1+ARgWxY+m2vLmNDK9G9uFZYQ==",
"dev": true "dev": true
}, },
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "lotus-chat", "name": "lotus-chat",
"version": "4.12.2-lotus", "version": "4.12.3-lotus",
"description": "Lotus Chat — Matrix client for Lotus Guild", "description": "Lotus Chat — Matrix client for Lotus Guild",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@@ -103,7 +103,7 @@
"ua-parser-js": "2.0.10" "ua-parser-js": "2.0.10"
}, },
"devDependencies": { "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-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2", "@rollup/plugin-wasm": "6.2.2",
"@sentry/vite-plugin": "5.3.0", "@sentry/vite-plugin": "5.3.0",
+3 -2
View File
@@ -192,11 +192,11 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
/> />
</Avatar> </Avatar>
</Box> </Box>
<Box grow="Yes" direction="Column" gap="100"> <Box grow="Yes" direction="Column" gap="100" alignItems="Center">
<Text size="H3" align="Center" truncate> <Text size="H3" align="Center" truncate>
{roomName} {roomName}
</Text> </Text>
<Text size="T300"> <Text size="T300" align="Center">
{info.intent === 'video' ? 'Incoming Video Call' : 'Incoming Voice Call'} {info.intent === 'video' ? 'Incoming Video Call' : 'Incoming Voice Call'}
</Text> </Text>
</Box> </Box>
@@ -283,6 +283,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
// only process rtc notification reference events. // only process rtc notification reference events.
// we do not want to wait to decrypt all events. // we do not want to wait to decrypt all events.
if (event.getRelation()?.rel_type !== RelationType.Reference) return; if (event.getRelation()?.rel_type !== RelationType.Reference) return;
if (room?.isCallRoom()) return;
if (event.isEncrypted()) { if (event.isEncrypted()) {
if (!event.isBeingDecrypted()) { if (!event.isBeingDecrypted()) {
+1
View File
@@ -117,6 +117,7 @@ export const PageHeroSection = style([
}, },
]); ]);
export const PageContentCenter = style([ export const PageContentCenter = style([
DefaultReset, DefaultReset,
{ {
+19 -10
View File
@@ -12,6 +12,9 @@ type MicrophoneButtonProps = {
disabled?: boolean; disabled?: boolean;
}; };
function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) { function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) {
const [micState, toggleMic] = useAsyncCallback(onToggle);
const loading = micState.status === AsyncStatus.Loading;
return ( return (
<TooltipProvider <TooltipProvider
position="Top" position="Top"
@@ -28,9 +31,9 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps
fill="Soft" fill="Soft"
radii="300" radii="300"
size="300" size="300"
onClick={() => onToggle()} onClick={toggleMic}
outlined outlined
disabled={disabled} disabled={disabled || loading}
aria-label={enabled ? 'Turn off microphone' : 'Turn on microphone'} aria-label={enabled ? 'Turn off microphone' : 'Turn on microphone'}
aria-pressed={!enabled} aria-pressed={!enabled}
> >
@@ -85,6 +88,9 @@ type VideoButtonProps = {
disabled?: boolean; disabled?: boolean;
}; };
function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) { function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
const [videoState, toggleVideo] = useAsyncCallback(onToggle);
const loading = videoState.status === AsyncStatus.Loading;
return ( return (
<TooltipProvider <TooltipProvider
position="Top" position="Top"
@@ -101,10 +107,10 @@ function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
fill="Soft" fill="Soft"
radii="300" radii="300"
size="300" size="300"
onClick={() => onToggle()} onClick={toggleVideo}
aria-label={enabled ? 'Stop Video' : 'Start Video'} aria-label={enabled ? 'Stop Video' : 'Start Video'}
outlined outlined
disabled={disabled} disabled={disabled || loading}
> >
<Icon <Icon
size="100" size="100"
@@ -162,6 +168,13 @@ export function CallControl({
}) { }) {
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control); const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
const setCallEmbed = useSetAtom(callEmbedAtom); const setCallEmbed = useSetAtom(callEmbedAtom);
const handleMicrophoneToggle = useCallback(
() => callEmbed.control.toggleMicrophone(),
[callEmbed],
);
const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]);
const [hangupState, hangup] = useAsyncCallback( const [hangupState, hangup] = useAsyncCallback(
useCallback(() => callEmbed.hangup(), [callEmbed]), useCallback(() => callEmbed.hangup(), [callEmbed]),
); );
@@ -181,7 +194,7 @@ export function CallControl({
<Box alignItems="Inherit" gap="200"> <Box alignItems="Inherit" gap="200">
<MicrophoneButton <MicrophoneButton
enabled={microphone} enabled={microphone}
onToggle={() => callEmbed.control.toggleMicrophone()} onToggle={handleMicrophoneToggle}
disabled={!callJoined} disabled={!callJoined}
/> />
<SoundButton <SoundButton
@@ -190,11 +203,7 @@ export function CallControl({
disabled={!callJoined} disabled={!callJoined}
/> />
{!compact && <StatusDivider />} {!compact && <StatusDivider />}
<VideoButton <VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
enabled={video}
onToggle={() => callEmbed.control.toggleVideo()}
disabled={!callJoined}
/>
{!compact && ( {!compact && (
<ScreenShareButton <ScreenShareButton
enabled={screenshare} enabled={screenshare}
+8 -5
View File
@@ -129,6 +129,12 @@ export function CallControls({ callEmbed }: CallControlsProps) {
setCords(undefined); setCords(undefined);
}; };
const handleMicrophoneToggle = useCallback(
() => callEmbed.control.toggleMicrophone(),
[callEmbed],
);
const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]);
const pttActiveRef = useRef(false); const pttActiveRef = useRef(false);
useEffect(() => { useEffect(() => {
@@ -368,10 +374,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
> >
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}> <Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200"> <Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<MicrophoneButton <MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} />
enabled={microphone}
onToggle={() => callEmbed.control.toggleMicrophone()}
/>
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} /> <SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
<ScreenshareAudioButton <ScreenshareAudioButton
muted={screenshareAudioMuted} muted={screenshareAudioMuted}
@@ -380,7 +383,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
</Box> </Box>
{!compact && <ControlDivider />} {!compact && <ControlDivider />}
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200"> <Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<VideoButton enabled={video} onToggle={() => callEmbed.control.toggleVideo()} /> <VideoButton enabled={video} onToggle={handleVideoToggle} />
<ScreenShareButton <ScreenShareButton
enabled={screenshare} enabled={screenshare}
onToggle={() => onToggle={() =>
+13 -5
View File
@@ -3,6 +3,7 @@ import { Icon, IconButton, Icons, Line, Text, Tooltip, TooltipProvider } from 'f
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import * as css from './styles.css'; import * as css from './styles.css';
import { callChatAtom } from '../../state/callEmbed'; import { callChatAtom } from '../../state/callEmbed';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
export function ControlDivider() { export function ControlDivider() {
return ( return (
@@ -12,9 +13,12 @@ export function ControlDivider() {
type MicrophoneButtonProps = { type MicrophoneButtonProps = {
enabled: boolean; enabled: boolean;
onToggle: () => void; onToggle: () => Promise<unknown>;
}; };
export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) { export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
const [micState, toggleMic] = useAsyncCallback(onToggle);
const loading = micState.status === AsyncStatus.Loading;
return ( return (
<TooltipProvider <TooltipProvider
position="Top" position="Top"
@@ -32,9 +36,10 @@ export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
fill="Soft" fill="Soft"
radii="400" radii="400"
size="400" size="400"
onClick={() => onToggle()} onClick={toggleMic}
aria-label={enabled ? 'Turn Off Microphone' : 'Turn On Microphone'} aria-label={enabled ? 'Turn Off Microphone' : 'Turn On Microphone'}
outlined outlined
disabled={loading}
> >
<Icon size="400" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} /> <Icon size="400" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
</IconButton> </IconButton>
@@ -82,10 +87,13 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
type VideoButtonProps = { type VideoButtonProps = {
enabled: boolean; enabled: boolean;
onToggle: () => void; onToggle: () => Promise<unknown>;
disabled?: boolean; disabled?: boolean;
}; };
export function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) { export function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
const [videoState, toggleVideo] = useAsyncCallback(onToggle);
const loading = videoState.status === AsyncStatus.Loading;
return ( return (
<TooltipProvider <TooltipProvider
position="Top" position="Top"
@@ -105,9 +113,9 @@ export function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
fill="Soft" fill="Soft"
radii="400" radii="400"
size="400" size="400"
onClick={() => onToggle()} onClick={toggleVideo}
outlined outlined
disabled={disabled} disabled={disabled || loading}
aria-label={ aria-label={
disabled ? 'Camera disabled in settings' : enabled ? 'Stop camera' : 'Start camera' disabled ? 'Camera disabled in settings' : enabled ? 'Stop camera' : 'Start camera'
} }
+6 -3
View File
@@ -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 { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
import { SequenceCard } from '../../components/sequence-card'; import { SequenceCard } from '../../components/sequence-card';
import * as css from './styles.css'; import * as css from './styles.css';
@@ -54,6 +54,9 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
useCallPreferences(); useCallPreferences();
const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin'); const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
const handleMicrophoneToggle = useCallback(async () => toggleMicrophone(), [toggleMicrophone]);
const handleVideoToggle = useCallback(async () => toggleVideo(), [toggleVideo]);
return ( return (
<SequenceCard <SequenceCard
className={css.ControlCard} className={css.ControlCard}
@@ -65,12 +68,12 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
wrap="Wrap" wrap="Wrap"
> >
<Box shrink="No" alignItems="Inherit" justifyContent="SpaceBetween" gap="200"> <Box shrink="No" alignItems="Inherit" justifyContent="SpaceBetween" gap="200">
<MicrophoneButton enabled={microphone} onToggle={toggleMicrophone} /> <MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} />
<SoundButton enabled={sound} onToggle={toggleSound} /> <SoundButton enabled={sound} onToggle={toggleSound} />
</Box> </Box>
<ControlDivider /> <ControlDivider />
<Box shrink="No" alignItems="Inherit" justifyContent="SpaceBetween" gap="200"> <Box shrink="No" alignItems="Inherit" justifyContent="SpaceBetween" gap="200">
<VideoButton enabled={video} onToggle={toggleVideo} disabled={!cameraOnJoin} /> <VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!cameraOnJoin} />
<ChatButton /> <ChatButton />
</Box> </Box>
<Box grow="Yes" direction="Column" gap="200"> <Box grow="Yes" direction="Column" gap="200">
+1
View File
@@ -6,6 +6,7 @@ export const LobbyHeroTopic = style({
WebkitLineClamp: 3, WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical', WebkitBoxOrient: 'vertical',
overflow: 'hidden', overflow: 'hidden',
wordBreak: 'break-word',
':hover': { ':hover': {
cursor: 'pointer', cursor: 'pointer',
+63 -10
View File
@@ -14,10 +14,14 @@ export class CallControl extends EventEmitter implements CallControlState {
private iframe: HTMLIFrameElement; private iframe: HTMLIFrameElement;
private bodyMutationObserver: MutationObserver;
private controlMutationObserver: MutationObserver; private controlMutationObserver: MutationObserver;
private _pipMode = false; private _pipMode = false;
private mediaStatePromiseResolver: undefined | (() => void);
private get document(): Document | undefined { private get document(): Document | undefined {
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document; return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
} }
@@ -30,17 +34,29 @@ export class CallControl extends EventEmitter implements CallControlState {
return screenshareBtn ?? undefined; 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 { private get settingsButton(): HTMLElement | undefined {
// EC 0.19.3: settings button has data-testid="settings-bottom-center" // EC 0.20.1: settings button moved to bottom-left; fall back to bottom-center.
return ( const settingsButtonLeft = this.document?.querySelector(
(this.document?.querySelector('[data-testid="settings-bottom-center"]') as HTMLElement) ?? '[data-testid="settings-bottom-left"]',
undefined ) 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 { private get reactionsButton(): HTMLElement | undefined {
// EC 0.19.3: reactions/raise-hand button has a CSS module class containing "raiseHand" // EC 0.20.1: reactions/raise-hand button sits just before the leave button.
return (this.document?.querySelector('[class*="raiseHand"]') as HTMLElement) ?? undefined; const reactionsButton = this.leaveButton?.previousElementSibling as HTMLElement | null;
return reactionsButton ?? undefined;
} }
private get spotlightButton(): HTMLInputElement | undefined { private get spotlightButton(): HTMLInputElement | undefined {
@@ -66,6 +82,7 @@ export class CallControl extends EventEmitter implements CallControlState {
this.call = call; this.call = call;
this.iframe = iframe; this.iframe = iframe;
this.bodyMutationObserver = new MutationObserver(this.onBodyMutation.bind(this));
this.controlMutationObserver = new MutationObserver(this.onControlMutation.bind(this)); this.controlMutationObserver = new MutationObserver(this.onControlMutation.bind(this));
} }
@@ -118,6 +135,30 @@ export class CallControl extends EventEmitter implements CallControlState {
} }
public startObserving() { 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(); this.controlMutationObserver.disconnect();
const screenshareBtn = this.screenshareButton; const screenshareBtn = this.screenshareButton;
@@ -141,8 +182,14 @@ export class CallControl extends EventEmitter implements CallControlState {
this.setSound(this.sound); this.setSound(this.sound);
} }
private setMediaState(state: ElementMediaStatePayload) { private async setMediaState(state: ElementMediaStatePayload) {
return this.call.transport.send(ElementWidgetActions.DeviceMute, state); const data = await this.call.transport.send(ElementWidgetActions.DeviceMute, state);
return new Promise<typeof data>((resolve) => {
if (this.mediaStatePromiseResolver) {
this.mediaStatePromiseResolver();
}
this.mediaStatePromiseResolver = () => resolve(data);
});
} }
private setSound(sound: boolean): void { private setSound(sound: boolean): void {
@@ -186,9 +233,14 @@ export class CallControl extends EventEmitter implements CallControlState {
if (this.microphone && !this.sound) { if (this.microphone && !this.sound) {
this.toggleSound(); this.toggleSound();
} }
if (this.mediaStatePromiseResolver) {
this.mediaStatePromiseResolver();
this.mediaStatePromiseResolver = undefined;
}
} }
public onControlMutation() { private onControlMutation() {
const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary'; const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary';
const spotlight: boolean = this.spotlightButton?.checked ?? false; const spotlight: boolean = this.spotlightButton?.checked ?? false;
@@ -321,6 +373,7 @@ export class CallControl extends EventEmitter implements CallControlState {
} }
public dispose() { public dispose() {
this.bodyMutationObserver.disconnect();
this.controlMutationObserver.disconnect(); this.controlMutationObserver.disconnect();
} }
+4
View File
@@ -214,6 +214,10 @@ export class CallEmbed {
const controlState = initialControlState ?? new CallControlState(true, false, true); const controlState = initialControlState ?? new CallControlState(true, false, true);
this.control = new CallControl(controlState, call, iframe); this.control = new CallControl(controlState, call, iframe);
this.initialState = controlState; this.initialState = controlState;
this.control.startObserving();
iframe.onload = () => {
this.control.startObserving();
};
let initialMediaEvent = true; let initialMediaEvent = true;
this.disposables.push( this.disposables.push(