diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx
index a78c210b..b50b1f50 100644
--- a/src/app/components/CallEmbedProvider.tsx
+++ b/src/app/components/CallEmbedProvider.tsx
@@ -1,5 +1,6 @@
import React, { ReactNode, useCallback, useRef } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
+import { config } from 'folds';
import {
CallEmbedContextProvider,
CallEmbedRefContextProvider,
diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx
index 097d00c9..2f2bac7f 100644
--- a/src/app/features/call-status/CallControl.tsx
+++ b/src/app/features/call-status/CallControl.tsx
@@ -1,7 +1,8 @@
-import { Box, Chip, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider } from 'folds';
-import React, { useState } from 'react';
+import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
+import React, { useCallback } from 'react';
import { StatusDivider } from './components';
import { CallEmbed, useCallControlState } from '../../plugins/call';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
type MicrophoneButtonProps = {
enabled: boolean;
@@ -104,9 +105,11 @@ function VideoButton({ enabled, onToggle }: VideoButtonProps) {
);
}
-function ScreenShareButton() {
- const [enabled, setEnabled] = useState(false);
-
+type ScreenShareButtonProps = {
+ enabled: boolean;
+ onToggle: () => void;
+};
+function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) {
return (
setEnabled(!enabled)}
+ onClick={onToggle}
outlined
>
@@ -133,8 +136,14 @@ function ScreenShareButton() {
);
}
-export function CallControl({ callEmbed }: { callEmbed: CallEmbed }) {
- const { microphone, video, sound } = useCallControlState(callEmbed.control);
+export function CallControl({ callEmbed, compact }: { callEmbed: CallEmbed; compact: boolean }) {
+ const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
+
+ const [hangupState, hangup] = useAsyncCallback(
+ useCallback(() => callEmbed.hangup(), [callEmbed])
+ );
+ const exiting =
+ hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
return (
@@ -144,21 +153,36 @@ export function CallControl({ callEmbed }: { callEmbed: CallEmbed }) {
onToggle={() => callEmbed.control.toggleMicrophone()}
/>
callEmbed.control.toggleSound()} />
+ {!compact && }
callEmbed.control.toggleVideo()} />
- {false && }
+ {!compact && (
+ callEmbed.control.toggleScreenshare()}
+ />
+ )}
}
+ before={
+ exiting ? (
+
+ ) : (
+
+ )
+ }
+ disabled={exiting}
outlined
- onClick={() => callEmbed.hangup()}
+ onClick={hangup}
>
-
- End
-
+ {!compact && (
+
+ End
+
+ )}
);
diff --git a/src/app/features/call-status/CallStatus.tsx b/src/app/features/call-status/CallStatus.tsx
index 2b8b809f..5d2182c2 100644
--- a/src/app/features/call-status/CallStatus.tsx
+++ b/src/app/features/call-status/CallStatus.tsx
@@ -47,18 +47,17 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
) : (
)}
-
-
- {speakers.size > 0 && !compact && (
+
+ {!compact && (
<>
-
-
-
+
+ {speakers.size > 0 && (
+ <>
+
+
+
+ >
+ )}
>
)}
@@ -69,8 +68,13 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
)}
{memberVisible && !compact && }
-
-
+
+ {compact && (
+
+
+
+ )}
+
);
diff --git a/src/app/features/call-status/LiveChip.tsx b/src/app/features/call-status/LiveChip.tsx
index 34167fb6..a5d00a55 100644
--- a/src/app/features/call-status/LiveChip.tsx
+++ b/src/app/features/call-status/LiveChip.tsx
@@ -128,7 +128,7 @@ export function LiveChip({ count, room, members }: LiveChipProps) {
radii="Pill"
onClick={handleOpenMenu}
>
-
+
{count} Live
diff --git a/src/app/features/call-status/MemberSpeaking.tsx b/src/app/features/call-status/MemberSpeaking.tsx
index ddde7e6e..27e272f2 100644
--- a/src/app/features/call-status/MemberSpeaking.tsx
+++ b/src/app/features/call-status/MemberSpeaking.tsx
@@ -14,7 +14,7 @@ export function MemberSpeaking({ room, speakers }: MemberSpeakingProps) {
);
return (
-
+
{speakingNames.length === 1 && (
<>
diff --git a/src/app/features/call/CallControls.tsx b/src/app/features/call/CallControls.tsx
new file mode 100644
index 00000000..72edc57f
--- /dev/null
+++ b/src/app/features/call/CallControls.tsx
@@ -0,0 +1,203 @@
+import React, { MouseEventHandler, useCallback, useRef, useState } from 'react';
+import {
+ Box,
+ Button,
+ config,
+ Icon,
+ IconButton,
+ Icons,
+ Menu,
+ MenuItem,
+ PopOut,
+ RectCords,
+ Spinner,
+ Text,
+ toRem,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { SequenceCard } from '../../components/sequence-card';
+import * as css from './styles.css';
+import {
+ ChatButton,
+ ControlDivider,
+ MicrophoneButton,
+ ScreenShareButton,
+ SoundButton,
+ VideoButton,
+} from './Controls';
+import { CallEmbed, useCallControlState } from '../../plugins/call';
+import { useResizeObserver } from '../../hooks/useResizeObserver';
+import { stopPropagation } from '../../utils/keyboard';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+
+type CallControlsProps = {
+ callEmbed: CallEmbed;
+};
+export function CallControls({ callEmbed }: CallControlsProps) {
+ const controlRef = useRef(null);
+ const [compact, setCompact] = useState(document.body.clientWidth < 500);
+
+ useResizeObserver(
+ useCallback(() => {
+ const element = controlRef.current;
+ if (!element) return;
+ setCompact(element.clientWidth < 500);
+ }, []),
+ useCallback(() => controlRef.current, [])
+ );
+
+ const { microphone, video, sound, screenshare, spotlight } = useCallControlState(
+ callEmbed.control
+ );
+
+ const [cords, setCords] = useState();
+
+ const handleOpenMenu: MouseEventHandler = (evt) => {
+ setCords(evt.currentTarget.getBoundingClientRect());
+ };
+
+ const handleSpotlightClick = () => {
+ callEmbed.control.toggleSpotlight();
+ setCords(undefined);
+ };
+
+ const handleReactionsClick = () => {
+ callEmbed.control.toggleReactions();
+ setCords(undefined);
+ };
+
+ const handleSettingsClick = () => {
+ callEmbed.control.toggleSettings();
+ setCords(undefined);
+ };
+
+ const [hangupState, hangup] = useAsyncCallback(
+ useCallback(() => callEmbed.hangup(), [callEmbed])
+ );
+ const exiting =
+ hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
+
+ return (
+
+
+
+
+ callEmbed.control.toggleMicrophone()}
+ />
+ callEmbed.control.toggleSound()} />
+
+ {!compact && }
+
+ callEmbed.control.toggleVideo()} />
+ callEmbed.control.toggleScreenshare()}
+ />
+
+
+ {!compact && }
+
+
+
+ setCords(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+
+
+ }
+ >
+
+
+
+
+
+
+
+ ) : (
+
+ )
+ }
+ disabled={exiting}
+ >
+ End
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx
index a8923d93..0cddd2be 100644
--- a/src/app/features/call/CallView.tsx
+++ b/src/app/features/call/CallView.tsx
@@ -1,4 +1,4 @@
-import React, { useRef } from 'react';
+import React, { RefObject, useRef } from 'react';
import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds';
import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed';
import { ContainerColor } from '../../styles/ContainerColor.css';
@@ -12,6 +12,7 @@ import { StateEvent } from '../../../types/matrix/room';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css';
+import { CallControls } from './CallControls';
function JoinMessage({ hasParticipant }: { hasParticipant?: boolean }) {
if (hasParticipant) return null;
@@ -39,13 +40,10 @@ function AlreadyInCallMessage() {
);
}
-export function CallView() {
+function CallPrescreen() {
const mx = useMatrixClient();
const room = useRoom();
- const callViewRef = useRef(null);
- useCallEmbedPlacementSync(callViewRef);
-
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
@@ -57,49 +55,70 @@ export function CallView() {
const hasParticipant = callMembers.length > 0;
const callEmbed = useCallEmbed();
- const callJoined = useCallJoined(callEmbed);
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
+ return (
+
+
+
+ {hasParticipant && (
+
+
+ Participant
+
+
+
+ {callMembers.length} Live
+
+
+
+ )}
+
+
+
+ {!inOtherCall &&
+ (canJoin ? : )}
+ {inOtherCall && }
+
+
+
+
+ );
+}
+
+type CallJoinedProps = {
+ containerRef: RefObject;
+ joined: boolean;
+};
+function CallJoined({ joined, containerRef }: CallJoinedProps) {
+ const callEmbed = useCallEmbed();
+
+ return (
+
+
+ {callEmbed && joined && }
+
+ );
+}
+
+export function CallView() {
+ const room = useRoom();
+ const callContainerRef = useRef(null);
+ useCallEmbedPlacementSync(callContainerRef);
+
+ const callEmbed = useCallEmbed();
+ const callJoined = useCallJoined(callEmbed);
+
const currentJoined = callEmbed?.roomId === room.roomId && callJoined;
return (
- {!currentJoined && (
-
-
-
- {hasParticipant && (
-
-
- Participant
-
-
-
- {callMembers.length} Live
-
-
-
- )}
-
-
-
- {!inOtherCall &&
- (canJoin ? (
-
- ) : (
-
- ))}
- {inOtherCall && }
-
-
-
-
- )}
+ {!currentJoined && }
+
);
}
diff --git a/src/app/features/call/Controls.tsx b/src/app/features/call/Controls.tsx
index 39c5d87d..143a8022 100644
--- a/src/app/features/call/Controls.tsx
+++ b/src/app/features/call/Controls.tsx
@@ -114,6 +114,38 @@ export function VideoButton({ enabled, onToggle }: VideoButtonProps) {
);
}
+type ScreenShareButtonProps = {
+ enabled: boolean;
+ onToggle: () => void;
+};
+export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) {
+ return (
+
+ {enabled ? 'Stop Screenshare' : 'Start Screenshare'}
+
+ }
+ >
+ {(anchorRef) => (
+ onToggle()}
+ outlined
+ >
+
+
+ )}
+
+ );
+}
+
export function ChatButton() {
const [chat, setChat] = useAtom(callChatAtom);
diff --git a/src/app/features/call/PrescreenControls.tsx b/src/app/features/call/PrescreenControls.tsx
index 4782c9b6..1174bbf1 100644
--- a/src/app/features/call/PrescreenControls.tsx
+++ b/src/app/features/call/PrescreenControls.tsx
@@ -6,7 +6,6 @@ import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
import { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEmbed';
import { useCallPreferences } from '../../state/hooks/callPreferences';
-import { CallControlState } from '../../plugins/call/CallControlState';
type PrescreenControlsProps = {
canJoin?: boolean;
@@ -50,7 +49,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
)}
- {callView && (
-
- Chat
-
- }
- >
- {(triggerRef) => (
- setChat(!chat)}>
-
-
- )}
-
- )}
-
(undefined);
@@ -41,7 +42,7 @@ export const createCallEmbed = (
dm: boolean,
themeKind: ElementCallThemeKind,
container: HTMLElement,
- controlState?: CallControlState
+ pref?: CallPreferences
): CallEmbed => {
const rtcSession = mx.matrixRTC.getRoomSession(room);
const ongoing =
@@ -49,6 +50,8 @@ export const createCallEmbed = (
const intent = CallEmbed.getIntent(dm, ongoing);
const widget = CallEmbed.getWidget(mx, room, intent, themeKind);
+ const controlState = pref && new CallControlState(pref.microphone, pref.video, pref.sound);
+
const embed = new CallEmbed(mx, room, widget, container, controlState);
return embed;
@@ -61,12 +64,12 @@ export const useCallStart = (dm = false) => {
const callEmbedRef = useCallEmbedRef();
const startCall = useCallback(
- (room: Room, controlState?: CallControlState) => {
+ (room: Room, pref?: CallPreferences) => {
const container = callEmbedRef.current;
if (!container) {
throw new Error('Failed to start call, No embed container element found!');
}
- const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, controlState);
+ const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref);
setCallEmbed(callEmbed);
},
diff --git a/src/app/pages/CallStatusRenderer.tsx b/src/app/pages/CallStatusRenderer.tsx
index f8e38054..2836886e 100644
--- a/src/app/pages/CallStatusRenderer.tsx
+++ b/src/app/pages/CallStatusRenderer.tsx
@@ -1,11 +1,18 @@
import React from 'react';
import { useCallEmbed } from '../hooks/useCallEmbed';
import { CallStatus } from '../features/call-status';
+import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
+import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
export function CallStatusRenderer() {
const callEmbed = useCallEmbed();
+ const selectedRoom = useSelectedRoom();
+
+ const screenSize = useScreenSizeContext();
if (!callEmbed) return null;
+ if (screenSize === ScreenSize.Mobile && callEmbed.roomId === selectedRoom) return null;
+
return ;
}
diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts
index 44dcacd7..f4162d73 100644
--- a/src/app/plugins/call/CallControl.ts
+++ b/src/app/plugins/call/CallControl.ts
@@ -14,12 +14,58 @@ export class CallControl extends EventEmitter implements CallControlState {
private iframe: HTMLIFrameElement;
+ private controlMutationObserver: MutationObserver;
+
+ private get document(): Document | undefined {
+ return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
+ }
+
+ private get screenshareButton(): HTMLElement | undefined {
+ const screenshareBtn = this.document?.querySelector(
+ '[data-testid="incall_screenshare"]'
+ ) as HTMLElement | null;
+
+ return screenshareBtn ?? undefined;
+ }
+
+ private get settingsButton(): HTMLElement | undefined {
+ const leaveBtn = this.document?.querySelector('[data-testid="incall_leave"]');
+
+ const settingsButton = leaveBtn?.previousElementSibling as HTMLElement | null;
+
+ return settingsButton ?? undefined;
+ }
+
+ private get reactionsButton(): HTMLElement | undefined {
+ const reactionsButton = this.settingsButton?.previousElementSibling as HTMLElement | null;
+
+ return reactionsButton ?? undefined;
+ }
+
+ private get spotlightButton(): HTMLInputElement | undefined {
+ const spotlightButton = this.document?.querySelector(
+ 'input[value="spotlight"]'
+ ) as HTMLInputElement | null;
+
+ return spotlightButton ?? undefined;
+ }
+
+ private get gridButton(): HTMLInputElement | undefined {
+ const gridButton = this.document?.querySelector(
+ 'input[value="grid"]'
+ ) as HTMLInputElement | null;
+
+ return gridButton ?? undefined;
+ }
+
constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) {
super();
this.state = state;
this.call = call;
this.iframe = iframe;
+
+ this.controlMutationObserver = new MutationObserver(this.onControlMutation.bind(this));
}
public getState(): CallControlState {
@@ -38,6 +84,14 @@ export class CallControl extends EventEmitter implements CallControlState {
return this.state.sound;
}
+ public get screenshare(): boolean {
+ return this.state.screenshare;
+ }
+
+ public get spotlight(): boolean {
+ return this.state.spotlight;
+ }
+
public async applyState() {
await this.setMediaState({
audio_enabled: this.microphone,
@@ -47,6 +101,26 @@ export class CallControl extends EventEmitter implements CallControlState {
this.emitStateUpdate();
}
+ public startObserving() {
+ this.controlMutationObserver.disconnect();
+
+ const screenshareBtn = this.screenshareButton;
+ if (screenshareBtn) {
+ this.controlMutationObserver.observe(screenshareBtn, {
+ attributes: true,
+ attributeFilter: ['data-kind'],
+ });
+ }
+ const spotlightBtn = this.spotlightButton;
+ if (spotlightBtn) {
+ this.controlMutationObserver.observe(spotlightBtn, {
+ attributes: true,
+ });
+ }
+
+ this.onControlMutation();
+ }
+
public applySound() {
this.setSound(this.sound);
}
@@ -72,7 +146,9 @@ export class CallControl extends EventEmitter implements CallControlState {
const state = new CallControlState(
data.audio_enabled ?? this.microphone,
data.video_enabled ?? this.video,
- this.sound
+ this.sound,
+ this.screenshare,
+ this.spotlight
);
this.state = state;
@@ -83,6 +159,20 @@ export class CallControl extends EventEmitter implements CallControlState {
}
}
+ public onControlMutation() {
+ const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary';
+ const spotlight: boolean = this.spotlightButton?.checked ?? false;
+
+ this.state = new CallControlState(
+ this.microphone,
+ this.video,
+ this.sound,
+ screenshare,
+ spotlight
+ );
+ this.emitStateUpdate();
+ }
+
public toggleMicrophone() {
const payload: ElementMediaStatePayload = {
audio_enabled: !this.microphone,
@@ -104,7 +194,13 @@ export class CallControl extends EventEmitter implements CallControlState {
this.setSound(sound);
- const state = new CallControlState(this.microphone, this.video, sound);
+ const state = new CallControlState(
+ this.microphone,
+ this.video,
+ sound,
+ this.screenshare,
+ this.spotlight
+ );
this.state = state;
this.emitStateUpdate();
@@ -113,6 +209,30 @@ export class CallControl extends EventEmitter implements CallControlState {
}
}
+ public toggleScreenshare() {
+ this.screenshareButton?.click();
+ }
+
+ public toggleSpotlight() {
+ if (this.spotlight) {
+ this.gridButton?.click();
+ return;
+ }
+ this.spotlightButton?.click();
+ }
+
+ public toggleReactions() {
+ this.reactionsButton?.click();
+ }
+
+ public toggleSettings() {
+ this.settingsButton?.click();
+ }
+
+ public dispose() {
+ this.controlMutationObserver.disconnect();
+ }
+
private emitStateUpdate() {
this.emit(CallControlEvent.StateUpdate);
}
diff --git a/src/app/plugins/call/CallControlState.ts b/src/app/plugins/call/CallControlState.ts
index 42f0b196..fc7e3f22 100644
--- a/src/app/plugins/call/CallControlState.ts
+++ b/src/app/plugins/call/CallControlState.ts
@@ -5,9 +5,21 @@ export class CallControlState {
public readonly sound: boolean;
- constructor(microphone: boolean, video: boolean, sound: boolean) {
+ public readonly screenshare: boolean;
+
+ public readonly spotlight: boolean;
+
+ constructor(
+ microphone: boolean,
+ video: boolean,
+ sound: boolean,
+ screenshare = false,
+ spotlight = false
+ ) {
this.microphone = microphone;
this.video = video;
this.sound = sound;
+ this.screenshare = screenshare;
+ this.spotlight = spotlight;
}
}
diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts
index f01cd685..aeb28e36 100644
--- a/src/app/plugins/call/CallEmbed.ts
+++ b/src/app/plugins/call/CallEmbed.ts
@@ -220,6 +220,7 @@ export class CallEmbed {
});
this.call.stop();
this.container.removeChild(this.iframe);
+ this.control.dispose();
this.mx.off(ClientEvent.Event, this.onEvent.bind(this));
this.mx.off(MatrixEventEvent.Decrypted, this.onEventDecrypted.bind(this));
@@ -233,6 +234,21 @@ 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 {