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
This commit is contained in:
Ajay Bura
2026-03-09 14:04:48 +11:00
committed by GitHub
parent 55e8306576
commit bc6caddcc8
17 changed files with 521 additions and 100 deletions

View File

@@ -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 (
<TooltipProvider
position="Top"
@@ -123,7 +126,7 @@ function ScreenShareButton() {
fill="Soft"
radii="300"
size="300"
onClick={() => setEnabled(!enabled)}
onClick={onToggle}
outlined
>
<Icon size="100" src={Icons.ScreenShare} filled={enabled} />
@@ -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 (
<Box shrink="No" alignItems="Center" gap="300">
@@ -144,21 +153,36 @@ export function CallControl({ callEmbed }: { callEmbed: CallEmbed }) {
onToggle={() => callEmbed.control.toggleMicrophone()}
/>
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
{!compact && <StatusDivider />}
<VideoButton enabled={video} onToggle={() => callEmbed.control.toggleVideo()} />
{false && <ScreenShareButton />}
{!compact && (
<ScreenShareButton
enabled={screenshare}
onToggle={() => callEmbed.control.toggleScreenshare()}
/>
)}
</Box>
<StatusDivider />
<Chip
variant="Critical"
radii="300"
radii="Pill"
fill="Soft"
before={<Icon size="50" src={Icons.PhoneDown} filled />}
before={
exiting ? (
<Spinner variant="Critical" fill="Soft" size="50" />
) : (
<Icon size="50" src={Icons.PhoneDown} filled />
)
}
disabled={exiting}
outlined
onClick={() => callEmbed.hangup()}
onClick={hangup}
>
<Text as="span" size="L400">
End
</Text>
{!compact && (
<Text as="span" size="L400">
End
</Text>
)}
</Chip>
</Box>
);

View File

@@ -47,18 +47,17 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
) : (
<Spinner variant="Secondary" size="200" />
)}
<Box
grow="Yes"
alignItems="Center"
gap="Inherit"
justifyContent={compact ? 'Center' : undefined}
>
<CallRoomName room={room} />
{speakers.size > 0 && !compact && (
<Box grow="Yes" alignItems="Center" gap="Inherit">
{!compact && (
<>
<StatusDivider />
<span data-spacing-node />
<MemberSpeaking room={room} speakers={speakers} />
<CallRoomName room={room} />
{speakers.size > 0 && (
<>
<StatusDivider />
<span data-spacing-node />
<MemberSpeaking room={room} speakers={speakers} />
</>
)}
</>
)}
</Box>
@@ -69,8 +68,13 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
)}
</Box>
{memberVisible && !compact && <StatusDivider />}
<Box shrink="No" alignItems="Center" justifyContent="Center" gap="Inherit">
<CallControl callEmbed={callEmbed} />
<Box shrink="No" alignItems="Center" gap="Inherit">
{compact && (
<Box grow="Yes">
<CallRoomName room={room} />
</Box>
)}
<CallControl compact={compact} callEmbed={callEmbed} />
</Box>
</Box>
);

View File

@@ -128,7 +128,7 @@ export function LiveChip({ count, room, members }: LiveChipProps) {
radii="Pill"
onClick={handleOpenMenu}
>
<Text className={css.LiveChipText} as="span" size="L400">
<Text className={css.LiveChipText} as="span" size="L400" truncate>
{count} Live
</Text>
</Chip>

View File

@@ -14,7 +14,7 @@ export function MemberSpeaking({ room, speakers }: MemberSpeakingProps) {
);
return (
<Box alignItems="Center" gap="100">
<Icon size="100" src={Icons.Phone} filled />
<Icon size="100" src={Icons.Mic} filled />
<Text size="T200" truncate>
{speakingNames.length === 1 && (
<>

View File

@@ -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<HTMLDivElement>(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<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (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 (
<Box
ref={controlRef}
className={css.CallControlContainer}
justifyContent="Center"
alignItems="Center"
>
<SequenceCard
className={css.ControlCard}
variant="SurfaceVariant"
gap="400"
radii="500"
alignItems="Center"
justifyContent="SpaceBetween"
>
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<MicrophoneButton
enabled={microphone}
onToggle={() => callEmbed.control.toggleMicrophone()}
/>
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
</Box>
{!compact && <ControlDivider />}
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<VideoButton enabled={video} onToggle={() => callEmbed.control.toggleVideo()} />
<ScreenShareButton
enabled={screenshare}
onToggle={() => callEmbed.control.toggleScreenshare()}
/>
</Box>
</Box>
{!compact && <ControlDivider />}
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<ChatButton />
<PopOut
anchor={cords}
position="Top"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={handleSpotlightClick}
>
<Text size="B300" truncate>
{spotlight ? 'Grid View' : 'Spotlight View'}
</Text>
</MenuItem>
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={handleReactionsClick}
>
<Text size="B300" truncate>
Reactions
</Text>
</MenuItem>
<MenuItem
size="300"
variant="Surface"
radii="300"
onClick={handleSettingsClick}
>
<Text size="B300" truncate>
Settings
</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
>
<IconButton
variant="Surface"
fill="Soft"
radii="400"
size="400"
onClick={handleOpenMenu}
outlined
aria-pressed={!!cords}
>
<Icon size="400" src={Icons.VerticalDots} />
</IconButton>
</PopOut>
</Box>
<Box shrink="No" direction="Column">
<Button
style={{ minWidth: toRem(88) }}
variant="Critical"
fill="Solid"
onClick={hangup}
before={
exiting ? (
<Spinner variant="Critical" fill="Solid" size="200" />
) : (
<Icon src={Icons.PhoneDown} size="200" filled />
)
}
disabled={exiting}
>
<Text size="B400">End</Text>
</Button>
</Box>
</Box>
</SequenceCard>
</Box>
);
}

View File

@@ -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<HTMLDivElement>(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 (
<Scroll variant="Surface" hideTrack>
<Box className={css.CallViewContent} alignItems="Center" justifyContent="Center">
<Box style={{ maxWidth: toRem(382), width: '100%' }} direction="Column" gap="100">
{hasParticipant && (
<Header size="300">
<Box grow="Yes" alignItems="Center">
<Text size="L400">Participant</Text>
</Box>
<Badge variant="Critical" fill="Solid" size="400">
<Text as="span" size="L400" truncate>
{callMembers.length} Live
</Text>
</Badge>
</Header>
)}
<CallMemberRenderer members={callMembers} />
<PrescreenControls canJoin={canJoin} />
<Header size="300">
{!inOtherCall &&
(canJoin ? <JoinMessage hasParticipant={hasParticipant} /> : <NoPermissionMessage />)}
{inOtherCall && <AlreadyInCallMessage />}
</Header>
</Box>
</Box>
</Scroll>
);
}
type CallJoinedProps = {
containerRef: RefObject<HTMLDivElement>;
joined: boolean;
};
function CallJoined({ joined, containerRef }: CallJoinedProps) {
const callEmbed = useCallEmbed();
return (
<Box grow="Yes" direction="Column">
<Box grow="Yes" ref={containerRef} />
{callEmbed && joined && <CallControls callEmbed={callEmbed} />}
</Box>
);
}
export function CallView() {
const room = useRoom();
const callContainerRef = useRef<HTMLDivElement>(null);
useCallEmbedPlacementSync(callContainerRef);
const callEmbed = useCallEmbed();
const callJoined = useCallJoined(callEmbed);
const currentJoined = callEmbed?.roomId === room.roomId && callJoined;
return (
<Box
ref={callViewRef}
className={ContainerColor({ variant: 'Surface' })}
style={{ minWidth: toRem(280) }}
grow="Yes"
>
{!currentJoined && (
<Scroll variant="Surface" hideTrack>
<Box className={css.CallViewContent} alignItems="Center" justifyContent="Center">
<Box style={{ maxWidth: toRem(382), width: '100%' }} direction="Column" gap="100">
{hasParticipant && (
<Header size="300">
<Box grow="Yes" alignItems="Center">
<Text size="L400">Participant</Text>
</Box>
<Badge variant="Critical" fill="Solid" size="400">
<Text as="span" size="L400" truncate>
{callMembers.length} Live
</Text>
</Badge>
</Header>
)}
<CallMemberRenderer members={callMembers} />
<PrescreenControls canJoin={canJoin} />
<Header size="300">
{!inOtherCall &&
(canJoin ? (
<JoinMessage hasParticipant={hasParticipant} />
) : (
<NoPermissionMessage />
))}
{inOtherCall && <AlreadyInCallMessage />}
</Header>
</Box>
</Box>
</Scroll>
)}
{!currentJoined && <CallPrescreen />}
<CallJoined joined={currentJoined} containerRef={callContainerRef} />
</Box>
);
}

View File

@@ -114,6 +114,38 @@ export function VideoButton({ enabled, onToggle }: VideoButtonProps) {
);
}
type ScreenShareButtonProps = {
enabled: boolean;
onToggle: () => void;
};
export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) {
return (
<TooltipProvider
position="Top"
delay={500}
tooltip={
<Tooltip>
<Text size="T200">{enabled ? 'Stop Screenshare' : 'Start Screenshare'}</Text>
</Tooltip>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant={enabled ? 'Success' : 'Surface'}
fill="Soft"
radii="400"
size="400"
onClick={() => onToggle()}
outlined
>
<Icon size="400" src={Icons.ScreenShare} filled={enabled} />
</IconButton>
)}
</TooltipProvider>
);
}
export function ChatButton() {
const [chat, setChat] = useAtom(callChatAtom);

View File

@@ -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) {
<Button
variant={disabled ? 'Secondary' : 'Success'}
fill={disabled ? 'Soft' : 'Solid'}
onClick={() => startCall(room, new CallControlState(microphone, video, sound))}
onClick={() => startCall(room, { microphone, video, sound })}
disabled={disabled || joining}
before={
joining ? (

View File

@@ -18,3 +18,7 @@ export const ControlDivider = style({
export const CallMemberCard = style({
padding: config.space.S300,
});
export const CallControlContainer = style({
padding: config.space.S400,
});

View File

@@ -57,7 +57,6 @@ import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { callChatAtom } from '../../state/callEmbed';
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
import { CallControlState } from '../../plugins/call/CallControlState';
type RoomNavItemMenuProps = {
room: Room;
@@ -292,7 +291,7 @@ export function RoomNavItem({
// Start call in second click
if (selected) {
evt.preventDefault();
startCall(room, new CallControlState(callPref.microphone, callPref.video, callPref.sound));
startCall(room, callPref);
}
};

View File

@@ -24,7 +24,6 @@ import {
} from 'folds';
import { useNavigate } from 'react-router-dom';
import { Room } from 'matrix-js-sdk';
import { useAtom } from 'jotai';
import { useStateEvent } from '../../hooks/useStateEvent';
import { PageHeader } from '../../components/page';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
@@ -68,7 +67,6 @@ import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { callChatAtom } from '../../state/callEmbed';
import { RoomSettingsPage } from '../../state/roomSettings';
type RoomMenuProps = {
@@ -266,8 +264,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
const direct = useIsDirectRoom();
const [chat, setChat] = useAtom(callChatAtom);
const pinnedEvents = useRoomPinnedEvents(room);
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
const encryptedRoom = !!encryptionEvent;
@@ -476,24 +472,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
</TooltipProvider>
)}
{callView && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>Chat</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton fill="None" ref={triggerRef} onClick={() => setChat(!chat)}>
<Icon size="400" src={Icons.Message} filled={chat} />
</IconButton>
)}
</TooltipProvider>
)}
<TooltipProvider
position="Bottom"
align="End"