forked from github/cinny
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:
@@ -1,5 +1,6 @@
|
||||
import React, { ReactNode, useCallback, useRef } from 'react';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { config } from 'folds';
|
||||
import {
|
||||
CallEmbedContextProvider,
|
||||
CallEmbedRefContextProvider,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
203
src/app/features/call/CallControls.tsx
Normal file
203
src/app/features/call/CallControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -18,3 +18,7 @@ export const ControlDivider = style({
|
||||
export const CallMemberCard = style({
|
||||
padding: config.space.S300,
|
||||
});
|
||||
|
||||
export const CallControlContainer = style({
|
||||
padding: config.space.S400,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -14,6 +14,7 @@ import { callEmbedAtom } from '../state/callEmbed';
|
||||
import { useResizeObserver } from './useResizeObserver';
|
||||
import { CallControlState } from '../plugins/call/CallControlState';
|
||||
import { useCallMembersChange, useCallSession } from './useCall';
|
||||
import { CallPreferences } from '../state/callPreferences';
|
||||
|
||||
const CallEmbedContext = createContext<CallEmbed | undefined>(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);
|
||||
},
|
||||
|
||||
@@ -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 <CallStatus callEmbed={callEmbed} />;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user