From bc6caddcc8bfe7948c93db1f0662295976891c97 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:04:48 +1100 Subject: [PATCH] Add own control buttons for element-call (#2744) * add mutation observer hok * add hook to read speaking member by observing iframe content * display speaking member name in call status bar and improve layout * fix shrining * add joined call control bar * remove chat toggle from room header * change member speaking icon to mic * fix joined call control appear in other * show spinner on end call button * hide call statusbar for mobile view when room is selected * make call statusbar more mobile friendly * fix call status bar item align --- src/app/components/CallEmbedProvider.tsx | 1 + src/app/features/call-status/CallControl.tsx | 54 +++-- src/app/features/call-status/CallStatus.tsx | 30 +-- src/app/features/call-status/LiveChip.tsx | 2 +- .../features/call-status/MemberSpeaking.tsx | 2 +- src/app/features/call/CallControls.tsx | 203 ++++++++++++++++++ src/app/features/call/CallView.tsx | 95 ++++---- src/app/features/call/Controls.tsx | 32 +++ src/app/features/call/PrescreenControls.tsx | 3 +- src/app/features/call/styles.css.ts | 4 + src/app/features/room-nav/RoomNavItem.tsx | 3 +- src/app/features/room/RoomViewHeader.tsx | 22 -- src/app/hooks/useCallEmbed.ts | 9 +- src/app/pages/CallStatusRenderer.tsx | 7 + src/app/plugins/call/CallControl.ts | 124 ++++++++++- src/app/plugins/call/CallControlState.ts | 14 +- src/app/plugins/call/CallEmbed.ts | 16 ++ 17 files changed, 521 insertions(+), 100 deletions(-) create mode 100644 src/app/features/call/CallControls.tsx 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, + }} + > + + + + + {spotlight ? 'Grid View' : 'Spotlight View'} + + + + + Reactions + + + + + Settings + + + + + + } + > + + + + + + + + + + + + ); +} 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) {