feat(calls): split-horseshoe call surface with redesigned ring/active pill, orbit border, custom outline icons, tap-to-room
This commit is contained in:
parent
c74029c38c
commit
7054ca2981
20 changed files with 711 additions and 713 deletions
|
|
@ -400,7 +400,11 @@
|
|||
"screenshare_on": "Start Screenshare",
|
||||
"chat_close": "Close Chat",
|
||||
"chat_open": "Open Chat",
|
||||
"end_call": "End"
|
||||
"end_call": "End",
|
||||
"in_call": "In call",
|
||||
"in_call_count": "{{count}} in call",
|
||||
"connecting": "Connecting…",
|
||||
"open_call_room": "Open call room"
|
||||
},
|
||||
"Room": {
|
||||
"new_messages": "New Messages",
|
||||
|
|
|
|||
|
|
@ -402,7 +402,11 @@
|
|||
"screenshare_on": "Начать показ экрана",
|
||||
"chat_close": "Закрыть чат",
|
||||
"chat_open": "Открыть чат",
|
||||
"end_call": "Завершить"
|
||||
"end_call": "Завершить",
|
||||
"in_call": "В звонке",
|
||||
"in_call_count": "{{count}} в звонке",
|
||||
"connecting": "Соединение…",
|
||||
"open_call_room": "Открыть чат звонка"
|
||||
},
|
||||
"Room": {
|
||||
"new_messages": "Новые сообщения",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,21 @@
|
|||
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
|
||||
import { Box, Icon, IconButton, IconSrc, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useSetAtom, useStore } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StatusDivider } from './components';
|
||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { callEmbedAtom } from '../../state/callEmbed';
|
||||
import {
|
||||
CallHeadphoneIcon,
|
||||
CallHeadphoneMuteIcon,
|
||||
CallMicIcon,
|
||||
CallMicMuteIcon,
|
||||
CallPhoneDownIcon,
|
||||
CallScreenShareIcon,
|
||||
CallScreenShareMuteIcon,
|
||||
CallVideoIcon,
|
||||
CallVideoMuteIcon,
|
||||
} from './callIcons';
|
||||
|
||||
// Fail-open window for widget hangup ack. If the widget is healthy, its
|
||||
// im.vector.hangup / io.element.close action lands within a second and
|
||||
|
|
@ -15,159 +25,73 @@ import { callEmbedAtom } from '../../state/callEmbed';
|
|||
// here to match the mobile-ish feel of our DM flow and PEER_LEAVE_GRACE_MS).
|
||||
const HANGUP_TIMEOUT_MS = 8000;
|
||||
|
||||
type MicrophoneButtonProps = {
|
||||
// All controls share the same shape so the active-call pill reads as a
|
||||
// single visual family with the IncomingCallStrip Decline/Answer buttons:
|
||||
// pill-radius, soft fill, size 500 (~52-56px). Variant changes with state
|
||||
// (Surface = on, Warning = muted/off, Critical = hangup, Success =
|
||||
// camera/screenshare on).
|
||||
const BUTTON_SIZE = '500' as const;
|
||||
const BUTTON_RADII = 'Pill' as const;
|
||||
const BUTTON_FILL = 'Soft' as const;
|
||||
const ICON_SIZE = '300' as const;
|
||||
|
||||
type ToggleButtonProps = {
|
||||
enabled: boolean;
|
||||
onToggle: () => Promise<unknown>;
|
||||
onToggle: () => void | Promise<unknown>;
|
||||
disabled?: boolean;
|
||||
iconOn: IconSrc;
|
||||
iconOff: IconSrc;
|
||||
labelOn: string;
|
||||
labelOff: string;
|
||||
variantOn?: 'Surface' | 'Success';
|
||||
variantOff?: 'Warning' | 'Surface';
|
||||
};
|
||||
function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
function ToggleButton({
|
||||
enabled,
|
||||
onToggle,
|
||||
disabled,
|
||||
iconOn,
|
||||
iconOff,
|
||||
labelOn,
|
||||
labelOff,
|
||||
variantOn = 'Surface',
|
||||
variantOff = 'Warning',
|
||||
}: ToggleButtonProps) {
|
||||
return (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text size="T200">{enabled ? t('Call.mic_off') : t('Call.mic_on')}</Text>
|
||||
<Text size="T200">{enabled ? labelOn : labelOff}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
ref={anchorRef}
|
||||
variant={enabled ? 'Surface' : 'Warning'}
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
size="300"
|
||||
variant={enabled ? variantOn : variantOff}
|
||||
fill={BUTTON_FILL}
|
||||
radii={BUTTON_RADII}
|
||||
size={BUTTON_SIZE}
|
||||
onClick={() => onToggle()}
|
||||
outlined
|
||||
disabled={disabled}
|
||||
aria-label={enabled ? labelOn : labelOff}
|
||||
>
|
||||
<Icon size="100" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
|
||||
<Icon size={ICON_SIZE} src={enabled ? iconOn : iconOff} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type SoundButtonProps = {
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text size="T200">{enabled ? t('Call.sound_off') : t('Call.sound_on')}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
ref={anchorRef}
|
||||
variant={enabled ? 'Surface' : 'Warning'}
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
size="300"
|
||||
onClick={() => onToggle()}
|
||||
outlined
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon
|
||||
size="100"
|
||||
src={enabled ? Icons.Headphone : Icons.HeadphoneMute}
|
||||
filled={!enabled}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type VideoButtonProps = {
|
||||
enabled: boolean;
|
||||
onToggle: () => Promise<unknown>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text size="T200">{enabled ? t('Call.camera_off') : t('Call.camera_on')}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
ref={anchorRef}
|
||||
variant={enabled ? 'Success' : 'Surface'}
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
size="300"
|
||||
onClick={() => onToggle()}
|
||||
outlined
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon
|
||||
size="100"
|
||||
src={enabled ? Icons.VideoCamera : Icons.VideoCameraMute}
|
||||
filled={enabled}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type ScreenShareButtonProps = {
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text size="T200">{enabled ? t('Call.screenshare_off') : t('Call.screenshare_on')}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
ref={anchorRef}
|
||||
variant={enabled ? 'Success' : 'Surface'}
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
size="300"
|
||||
onClick={onToggle}
|
||||
outlined
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon size="100" src={Icons.ScreenShare} filled={enabled} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallControl({
|
||||
callEmbed,
|
||||
compact,
|
||||
callJoined,
|
||||
}: {
|
||||
type CallControlProps = {
|
||||
callEmbed: CallEmbed;
|
||||
compact: boolean;
|
||||
callJoined: boolean;
|
||||
}) {
|
||||
};
|
||||
|
||||
export function CallControl({ callEmbed, compact, callJoined }: CallControlProps) {
|
||||
const { t } = useTranslation();
|
||||
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
|
|
@ -207,59 +131,87 @@ export function CallControl({
|
|||
hangup();
|
||||
};
|
||||
|
||||
// Visibility mirrors the legacy strip: Mic + Sound always render,
|
||||
// Video joins when the call isn't voice-only (still shown on mobile —
|
||||
// users want to be able to flip the camera on a phone), Screenshare
|
||||
// is desktop-only because mobile WebRTC capture is meaningfully
|
||||
// different and Element Call gates it natively.
|
||||
const showVideo = !callEmbed.voiceOnly;
|
||||
const showScreenshare = !compact && !callEmbed.voiceOnly;
|
||||
|
||||
return (
|
||||
<Box shrink="No" alignItems="Center" gap="300">
|
||||
<Box alignItems="Inherit" gap="200">
|
||||
<MicrophoneButton
|
||||
enabled={microphone}
|
||||
onToggle={() => callEmbed.control.toggleMicrophone()}
|
||||
<Box shrink="No" alignItems="Center" gap="200">
|
||||
<ToggleButton
|
||||
enabled={microphone}
|
||||
onToggle={() => callEmbed.control.toggleMicrophone()}
|
||||
disabled={!callJoined}
|
||||
iconOn={CallMicIcon}
|
||||
iconOff={CallMicMuteIcon}
|
||||
labelOn={t('Call.mic_off')}
|
||||
labelOff={t('Call.mic_on')}
|
||||
/>
|
||||
<ToggleButton
|
||||
enabled={sound}
|
||||
onToggle={() => callEmbed.control.toggleSound()}
|
||||
disabled={!callJoined}
|
||||
iconOn={CallHeadphoneIcon}
|
||||
iconOff={CallHeadphoneMuteIcon}
|
||||
labelOn={t('Call.sound_off')}
|
||||
labelOff={t('Call.sound_on')}
|
||||
/>
|
||||
{showVideo && (
|
||||
<ToggleButton
|
||||
enabled={video}
|
||||
onToggle={() => callEmbed.control.toggleVideo()}
|
||||
disabled={!callJoined}
|
||||
iconOn={CallVideoIcon}
|
||||
iconOff={CallVideoMuteIcon}
|
||||
labelOn={t('Call.camera_off')}
|
||||
labelOff={t('Call.camera_on')}
|
||||
variantOn="Success"
|
||||
variantOff="Surface"
|
||||
/>
|
||||
<SoundButton
|
||||
enabled={sound}
|
||||
onToggle={() => callEmbed.control.toggleSound()}
|
||||
)}
|
||||
{showScreenshare && (
|
||||
<ToggleButton
|
||||
enabled={screenshare}
|
||||
onToggle={() => callEmbed.control.toggleScreenshare()}
|
||||
disabled={!callJoined}
|
||||
iconOn={CallScreenShareIcon}
|
||||
iconOff={CallScreenShareMuteIcon}
|
||||
labelOn={t('Call.screenshare_off')}
|
||||
labelOff={t('Call.screenshare_on')}
|
||||
variantOn="Success"
|
||||
variantOff="Surface"
|
||||
/>
|
||||
{!callEmbed.voiceOnly && (
|
||||
<>
|
||||
{!compact && <StatusDivider />}
|
||||
<VideoButton
|
||||
enabled={video}
|
||||
onToggle={() => callEmbed.control.toggleVideo()}
|
||||
disabled={!callJoined}
|
||||
/>
|
||||
{!compact && (
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() => callEmbed.control.toggleScreenshare()}
|
||||
disabled={!callJoined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<StatusDivider />
|
||||
<Chip
|
||||
variant="Critical"
|
||||
radii="Pill"
|
||||
fill="Soft"
|
||||
before={
|
||||
exiting ? (
|
||||
<Spinner variant="Critical" fill="Soft" size="50" />
|
||||
) : (
|
||||
<Icon size="50" src={Icons.PhoneDown} filled />
|
||||
)
|
||||
)}
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text size="T200">{t('Call.end_call')}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
disabled={exiting}
|
||||
outlined
|
||||
onClick={handleHangup}
|
||||
>
|
||||
{!compact && (
|
||||
<Text as="span" size="L400">
|
||||
{t('Call.end_call')}
|
||||
</Text>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
ref={anchorRef}
|
||||
variant="Critical"
|
||||
fill={BUTTON_FILL}
|
||||
radii={BUTTON_RADII}
|
||||
size={BUTTON_SIZE}
|
||||
onClick={handleHangup}
|
||||
disabled={exiting}
|
||||
aria-label={t('Call.end_call')}
|
||||
>
|
||||
{exiting ? (
|
||||
<Spinner variant="Critical" fill="Soft" size="200" />
|
||||
) : (
|
||||
<Icon size={ICON_SIZE} src={CallPhoneDownIcon} />
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
</Chip>
|
||||
</TooltipProvider>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { Chip, Text } from 'folds';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useRoomName } from '../../hooks/useRoomMeta';
|
||||
import { RoomIcon } from '../../components/room-avatar';
|
||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||
import { getAllParents, guessPerfectParent } from '../../utils/room';
|
||||
import { useOrphanSpaces } from '../../state/hooks/roomList';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
|
||||
type CallRoomNameProps = {
|
||||
room: Room;
|
||||
};
|
||||
export function CallRoomName({ room }: CallRoomNameProps) {
|
||||
const mx = useMatrixClient();
|
||||
const name = useRoomName(room);
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const dm = mDirects.has(room.roomId);
|
||||
|
||||
const allRoomsSet = useAllJoinedRoomsSet();
|
||||
const getRoom = useGetRoom(allRoomsSet);
|
||||
|
||||
const allParents = getAllParents(roomToParents, room.roomId);
|
||||
const orphanParents = allParents && orphanSpaces.filter((o) => allParents.has(o));
|
||||
const perfectOrphanParent = orphanParents && guessPerfectParent(mx, room.roomId, orphanParents);
|
||||
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
return (
|
||||
<Chip
|
||||
variant="Background"
|
||||
radii="Pill"
|
||||
before={
|
||||
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} filled />
|
||||
}
|
||||
onClick={() => navigateRoom(room.roomId)}
|
||||
>
|
||||
<Text size="L400" truncate>
|
||||
{name}
|
||||
{!dm && perfectOrphanParent && (
|
||||
<Text as="span" size="T200" priority="300">
|
||||
{' •'} <b>{getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent}</b>
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,79 +1,138 @@
|
|||
import React from 'react';
|
||||
import { Box, Spinner } from 'folds';
|
||||
import { Avatar, Box, Icon, Icons, Text } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import { LiveChip } from './LiveChip';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as css from './styles.css';
|
||||
import { CallRoomName } from './CallRoomName';
|
||||
import { CallControl } from './CallControl';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
||||
import { MemberGlance } from './MemberGlance';
|
||||
import { StatusDivider } from './components';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { CallEmbed } from '../../plugins/call/CallEmbed';
|
||||
import { useCallJoined } from '../../hooks/useCallEmbed';
|
||||
import { useCallSpeakers } from '../../hooks/useCallSpeakers';
|
||||
import { MemberSpeaking } from './MemberSpeaking';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useRoomAvatar, useRoomName } from '../../hooks/useRoomMeta';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { RoomAvatar } from '../../components/room-avatar';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import {
|
||||
getMxIdLocalPart,
|
||||
guessDmRoomUserId,
|
||||
mxcUrlToHttp,
|
||||
} from '../../utils/matrix';
|
||||
|
||||
type CallStatusProps = {
|
||||
callEmbed: CallEmbed;
|
||||
};
|
||||
export function CallStatus({ callEmbed }: CallStatusProps) {
|
||||
const { room } = callEmbed;
|
||||
|
||||
export function CallStatus({ callEmbed }: CallStatusProps) {
|
||||
const { t } = useTranslation();
|
||||
const { room } = callEmbed;
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const callJoined = useCallJoined(callEmbed);
|
||||
const callSession = useCallSession(room);
|
||||
const callMembers = useCallMembers(room, callSession);
|
||||
const screenSize = useScreenSize();
|
||||
const callJoined = useCallJoined(callEmbed);
|
||||
const speakers = useCallSpeakers(callEmbed);
|
||||
|
||||
const screenSize = useScreenSizeContext();
|
||||
const compact = screenSize === ScreenSize.Mobile;
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
const memberVisible = callJoined && callMembers.length > 0;
|
||||
// Member-count gate mirrors the rest of the post-P3c app: 1:1 chrome
|
||||
// surfaces the peer (their avatar + display name); group chrome
|
||||
// surfaces the room itself. Snapshot — call duration is short relative
|
||||
// to membership churn, and `useCallMembers` already reactively covers
|
||||
// the «who's actually live» count below, so peer chrome at most lags
|
||||
// one render behind a third joiner before re-painting.
|
||||
const isOneOnOne = room.getInvitedAndJoinedMemberCount() === 2;
|
||||
|
||||
const myUserId = mx.getSafeUserId();
|
||||
const peerCandidate = isOneOnOne ? guessDmRoomUserId(room, myUserId) : undefined;
|
||||
const peerUserId = peerCandidate && peerCandidate !== myUserId ? peerCandidate : undefined;
|
||||
const peerName = peerUserId
|
||||
? getMemberDisplayName(room, peerUserId) ?? getMxIdLocalPart(peerUserId) ?? peerUserId
|
||||
: undefined;
|
||||
|
||||
const roomName = useRoomName(room);
|
||||
const avatarMxc = useRoomAvatar(room, isOneOnOne);
|
||||
const avatarUrl = avatarMxc
|
||||
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||
: undefined;
|
||||
|
||||
// Defensive fallback chain — empty roomName + unresolved peer would
|
||||
// otherwise collapse the H4 to "" and leave the pill anonymous.
|
||||
const displayName =
|
||||
(isOneOnOne && peerName) ||
|
||||
roomName ||
|
||||
peerUserId ||
|
||||
room.roomId;
|
||||
|
||||
// Sub-text is the live state: «Соединение…» until the widget reports
|
||||
// joined, then either «В звонке» (1:1) or «N в звонке» (group). The
|
||||
// pulsing dot is bound to `callJoined` so the user gets instant
|
||||
// visual confirmation that LiveKit is up.
|
||||
const memberCount = callMembers.length;
|
||||
let subText: string;
|
||||
if (!callJoined) {
|
||||
subText = t('Call.connecting');
|
||||
} else if (isOneOnOne || memberCount <= 1) {
|
||||
subText = t('Call.in_call');
|
||||
} else {
|
||||
subText = t('Call.in_call_count', { count: memberCount });
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classNames(css.CallStatus, ContainerColor({ variant: 'Background' }))}
|
||||
shrink="No"
|
||||
direction="Row"
|
||||
alignItems="Center"
|
||||
gap="400"
|
||||
alignItems={compact ? undefined : 'Center'}
|
||||
direction={compact ? 'Column' : 'Row'}
|
||||
>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
{memberVisible ? (
|
||||
<Box shrink="No">
|
||||
<LiveChip count={callMembers.length} room={room} members={callMembers} />
|
||||
</Box>
|
||||
) : (
|
||||
<Spinner variant="Secondary" size="200" />
|
||||
)}
|
||||
<Box grow="Yes" alignItems="Center" gap="Inherit">
|
||||
{!compact && (
|
||||
<>
|
||||
<CallRoomName room={room} />
|
||||
{speakers.size > 0 && (
|
||||
<>
|
||||
<StatusDivider />
|
||||
<span data-spacing-node />
|
||||
<MemberSpeaking room={room} speakers={speakers} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
<Box
|
||||
as="button"
|
||||
type="button"
|
||||
grow="Yes"
|
||||
alignItems="Center"
|
||||
gap="400"
|
||||
className={css.CallIdentityButton}
|
||||
onClick={() => navigateRoom(room.roomId)}
|
||||
aria-label={t('Call.open_call_room')}
|
||||
>
|
||||
<Avatar className={css.RingAvatar}>
|
||||
{isOneOnOne ? (
|
||||
<UserAvatar
|
||||
userId={peerUserId ?? ''}
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
renderFallback={() => <Icon size="400" src={Icons.User} filled />}
|
||||
/>
|
||||
) : (
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
renderFallback={() => <Icon size="400" src={Icons.Hash} filled />}
|
||||
/>
|
||||
)}
|
||||
</Avatar>
|
||||
<Box grow="Yes" direction="Column" gap="100" alignItems="Start">
|
||||
<Text size="H4" truncate>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<span
|
||||
className={classNames(css.LiveDot, callJoined && css.LiveDotPulsing)}
|
||||
aria-hidden
|
||||
/>
|
||||
<Text size="T200" priority="300" truncate>
|
||||
{subText}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{memberVisible && (
|
||||
<Box shrink="No">
|
||||
<MemberGlance room={room} members={callMembers} speakers={speakers} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{memberVisible && !compact && <StatusDivider />}
|
||||
<Box shrink="No" alignItems="Center" gap="Inherit">
|
||||
{compact && (
|
||||
<Box grow="Yes">
|
||||
<CallRoomName room={room} />
|
||||
</Box>
|
||||
)}
|
||||
<Box shrink="No" alignItems="Center" gap="200">
|
||||
<CallControl callJoined={callJoined} compact={compact} callEmbed={callEmbed} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall';
|
|||
import { getDirectRoomPath } from '../../pages/pathUtils';
|
||||
import { IncomingCall, incomingCallsAtom } from '../../state/incomingCalls';
|
||||
import { getIncomingCallKey } from '../../utils/rtcNotification';
|
||||
import { CallPhoneDownIcon, CallPhoneIcon } from './callIcons';
|
||||
|
||||
const DECLINE_RETRY_DELAY_MS = 500;
|
||||
|
||||
|
|
@ -86,31 +87,29 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
|
|||
|
||||
return (
|
||||
<Box
|
||||
className={classNames(css.CallStatus, ContainerColor({ variant: 'Background' }))}
|
||||
className={classNames(css.RingRow, ContainerColor({ variant: 'Background' }))}
|
||||
shrink="No"
|
||||
gap="400"
|
||||
alignItems="Center"
|
||||
direction="Row"
|
||||
alignItems="Center"
|
||||
gap="500"
|
||||
>
|
||||
<Box grow="Yes" alignItems="Center" gap="300">
|
||||
<Avatar size="300">
|
||||
<UserAvatar
|
||||
userId={senderId ?? ''}
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="T300" truncate>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
{t('Call.incoming')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Avatar className={css.RingAvatar}>
|
||||
<UserAvatar
|
||||
userId={senderId ?? ''}
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
renderFallback={() => <Icon size="400" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="H4" truncate>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
{t('Call.incoming')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No" alignItems="Center" gap="200">
|
||||
<Box shrink="No" alignItems="Center" gap="300">
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
tooltip={
|
||||
|
|
@ -125,11 +124,11 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
|
|||
variant="Critical"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
size="300"
|
||||
size="500"
|
||||
onClick={handleDecline}
|
||||
aria-label={t('Call.decline')}
|
||||
>
|
||||
<Icon size="100" src={Icons.PhoneDown} filled />
|
||||
<Icon size="300" src={CallPhoneDownIcon} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
|
|
@ -147,11 +146,11 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
|
|||
variant="Success"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
size="300"
|
||||
size="500"
|
||||
onClick={handleAnswer}
|
||||
aria-label={t('Call.answer')}
|
||||
>
|
||||
<Icon size="100" src={Icons.Phone} filled />
|
||||
<Icon size="300" src={CallPhoneIcon} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
|
|
|
|||
|
|
@ -1,137 +0,0 @@
|
|||
import React, { MouseEventHandler, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Chip,
|
||||
config,
|
||||
Icon,
|
||||
Icons,
|
||||
Menu,
|
||||
MenuItem,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Scroll,
|
||||
Text,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import * as css from './styles.css';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { getMouseEventCords } from '../../utils/dom';
|
||||
|
||||
type LiveChipProps = {
|
||||
room: Room;
|
||||
members: CallMembership[];
|
||||
count: number;
|
||||
};
|
||||
export function LiveChip({ count, room, members }: LiveChipProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const openUserProfile = useOpenUserRoomProfile();
|
||||
|
||||
const [cords, setCords] = useState<RectCords>();
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
return (
|
||||
<PopOut
|
||||
anchor={cords}
|
||||
position="Top"
|
||||
align="Start"
|
||||
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
|
||||
style={{
|
||||
maxHeight: '75vh',
|
||||
maxWidth: toRem(300),
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Scroll size="0" hideTrack visibility="Hover">
|
||||
<Box direction="Column" style={{ padding: config.space.S100 }}>
|
||||
{members.map((callMember) => {
|
||||
const userId = callMember.sender;
|
||||
if (!userId) return null;
|
||||
const name =
|
||||
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||
const avatarMxc = getMemberAvatarMxc(room, userId);
|
||||
const avatarUrl = avatarMxc
|
||||
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={callMember.membershipID}
|
||||
size="400"
|
||||
variant="Surface"
|
||||
radii="300"
|
||||
style={{ paddingLeft: config.space.S200 }}
|
||||
onClick={(evt) =>
|
||||
openUserProfile(
|
||||
room.roomId,
|
||||
undefined,
|
||||
userId,
|
||||
getMouseEventCords(evt.nativeEvent),
|
||||
'Right'
|
||||
)
|
||||
}
|
||||
before={
|
||||
<Avatar size="200" radii="400">
|
||||
<UserAvatar
|
||||
userId={userId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<Text size="T300" truncate>
|
||||
{name}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
variant="Surface"
|
||||
fill="Soft"
|
||||
before={<Badge variant="Critical" fill="Solid" size="200" />}
|
||||
after={<Icon size="50" src={cords ? Icons.ChevronBottom : Icons.ChevronTop} />}
|
||||
radii="Pill"
|
||||
onClick={handleOpenMenu}
|
||||
>
|
||||
<Text className={css.LiveChipText} as="span" size="L400" truncate>
|
||||
{count} Live
|
||||
</Text>
|
||||
</Chip>
|
||||
</PopOut>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import { Box, config, Icon, Icons, Text } from 'folds';
|
||||
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
||||
import React from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { StackedAvatar } from '../../components/stacked-avatar';
|
||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { getMouseEventCords } from '../../utils/dom';
|
||||
import * as css from './styles.css';
|
||||
|
||||
type MemberGlanceProps = {
|
||||
room: Room;
|
||||
members: CallMembership[];
|
||||
speakers: Set<string>;
|
||||
max?: number;
|
||||
};
|
||||
export function MemberGlance({ room, members, speakers, max = 6 }: MemberGlanceProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const openUserProfile = useOpenUserRoomProfile();
|
||||
|
||||
const visibleMembers = members.slice(0, max);
|
||||
const remainingCount = max && members.length > max ? members.length - max : 0;
|
||||
|
||||
return (
|
||||
<Box alignItems="Center">
|
||||
{visibleMembers.map((callMember) => {
|
||||
const userId = callMember.sender;
|
||||
if (!userId) return null;
|
||||
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||
const avatarMxc = getMemberAvatarMxc(room, userId);
|
||||
const avatarUrl = avatarMxc
|
||||
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<StackedAvatar
|
||||
key={callMember.membershipID}
|
||||
className={speakers.has(callMember.sender) ? css.SpeakerAvatarOutline : undefined}
|
||||
title={name}
|
||||
as="button"
|
||||
variant="Background"
|
||||
size="200"
|
||||
radii="Pill"
|
||||
onClick={(evt) =>
|
||||
openUserProfile(
|
||||
room.roomId,
|
||||
undefined,
|
||||
userId,
|
||||
getMouseEventCords(evt.nativeEvent),
|
||||
'Top'
|
||||
)
|
||||
}
|
||||
>
|
||||
<UserAvatar
|
||||
userId={userId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</StackedAvatar>
|
||||
);
|
||||
})}
|
||||
{remainingCount > 0 && (
|
||||
<Text size="L400" style={{ paddingLeft: config.space.S100 }}>
|
||||
+{remainingCount}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import { Room } from 'matrix-js-sdk';
|
||||
import React from 'react';
|
||||
import { Box, Icon, Icons, Text } from 'folds';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
|
||||
type MemberSpeakingProps = {
|
||||
room: Room;
|
||||
speakers: Set<string>;
|
||||
};
|
||||
export function MemberSpeaking({ room, speakers }: MemberSpeakingProps) {
|
||||
const speakingNames = Array.from(speakers).map(
|
||||
(userId) => getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId
|
||||
);
|
||||
return (
|
||||
<Box alignItems="Center" gap="100">
|
||||
<Icon size="100" src={Icons.Mic} filled />
|
||||
<Text size="T200" truncate>
|
||||
{speakingNames.length === 1 && (
|
||||
<>
|
||||
<b>{speakingNames[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' is speaking...'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{speakingNames.length === 2 && (
|
||||
<>
|
||||
<b>{speakingNames[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{speakingNames[1]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' are speaking...'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{speakingNames.length === 3 && (
|
||||
<>
|
||||
<b>{speakingNames[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{speakingNames[1]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{speakingNames[2]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' are speaking...'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{speakingNames.length > 3 && (
|
||||
<>
|
||||
<b>{speakingNames[0]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{speakingNames[1]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{', '}
|
||||
</Text>
|
||||
<b>{speakingNames[2]}</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' and '}
|
||||
</Text>
|
||||
<b>{speakingNames.length - 3} others</b>
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
{' are speaking...'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
134
src/app/features/call-status/callIcons.tsx
Normal file
134
src/app/features/call-status/callIcons.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// These exports are folds `IconSrc` callbacks — `(filled?: boolean) =>
|
||||
// JSX.Element` — that the folds `<Icon>` component invokes as `src(filled)`
|
||||
// to populate the inner SVG content. They are NOT React components (they
|
||||
// are never mounted via JSX as `<CallMicIcon />`), so the
|
||||
// `react/function-component-definition` rule's "use a function
|
||||
// declaration" guidance is misapplied here.
|
||||
/* eslint-disable react/function-component-definition */
|
||||
|
||||
// Custom call-control icon set, drawn to match the design-system style
|
||||
// captured in `docs/design/new-direct-messages-design/project/shared.jsx`
|
||||
// (lines 4-19): 24×24 viewBox, stroke-only (no fill), 1.6px stroke weight
|
||||
// for handset/mic/video glyphs, round line joins/caps, currentColor — so
|
||||
// the same icons read consistently across the green/red/neutral variant
|
||||
// flips of the IncomingCallStrip / CallControl buttons.
|
||||
//
|
||||
// Folds' `Icon` component owns the outer `<svg>` (viewBox=0 0 24 24,
|
||||
// fill=none, sizing class via the `size` prop). An `IconSrc` therefore
|
||||
// returns only the inner shapes — same convention as the stock
|
||||
// `Icons.*` table (see folds/dist/index.js Icons block).
|
||||
//
|
||||
// `filled` is intentionally ignored: this is an outline icon family,
|
||||
// the muted/active distinction is carried by the diagonal slash plus
|
||||
// the button's variant color, not by an alternate filled shape.
|
||||
|
||||
import React from 'react';
|
||||
import { IconSrc } from 'folds';
|
||||
|
||||
const STROKE = {
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.6,
|
||||
strokeLinecap: 'round' as const,
|
||||
strokeLinejoin: 'round' as const,
|
||||
fill: 'none',
|
||||
};
|
||||
|
||||
// Slightly heavier than the body strokes so the «muted» indicator
|
||||
// reads as a deliberate cancellation rather than ornamentation.
|
||||
const SLASH = {
|
||||
...STROKE,
|
||||
strokeWidth: 2,
|
||||
};
|
||||
|
||||
export const CallMicIcon: IconSrc = () => (
|
||||
<>
|
||||
<rect x="9" y="3" width="6" height="12" rx="3" {...STROKE} />
|
||||
<path d="M5 11a7 7 0 0 0 14 0" {...STROKE} />
|
||||
<path d="M12 18v3" {...STROKE} />
|
||||
</>
|
||||
);
|
||||
|
||||
export const CallMicMuteIcon: IconSrc = () => (
|
||||
<>
|
||||
<rect x="9" y="3" width="6" height="12" rx="3" {...STROKE} />
|
||||
<path d="M5 11a7 7 0 0 0 14 0" {...STROKE} />
|
||||
<path d="M12 18v3" {...STROKE} />
|
||||
<path d="M3 3l18 18" {...SLASH} />
|
||||
</>
|
||||
);
|
||||
|
||||
export const CallHeadphoneIcon: IconSrc = () => (
|
||||
<>
|
||||
<path d="M4 14a8 8 0 0 1 16 0" {...STROKE} />
|
||||
<path d="M4 14v4a2 2 0 0 0 2 2h2v-7H6a2 2 0 0 0-2 2z" {...STROKE} />
|
||||
<path d="M20 14v4a2 2 0 0 1-2 2h-2v-7h2a2 2 0 0 1 2 2z" {...STROKE} />
|
||||
</>
|
||||
);
|
||||
|
||||
export const CallHeadphoneMuteIcon: IconSrc = () => (
|
||||
<>
|
||||
<path d="M4 14a8 8 0 0 1 16 0" {...STROKE} />
|
||||
<path d="M4 14v4a2 2 0 0 0 2 2h2v-7H6a2 2 0 0 0-2 2z" {...STROKE} />
|
||||
<path d="M20 14v4a2 2 0 0 1-2 2h-2v-7h2a2 2 0 0 1 2 2z" {...STROKE} />
|
||||
<path d="M3 3l18 18" {...SLASH} />
|
||||
</>
|
||||
);
|
||||
|
||||
export const CallVideoIcon: IconSrc = () => (
|
||||
<>
|
||||
<rect x="3" y="6" width="13" height="12" rx="2" {...STROKE} />
|
||||
<path d="M16 10l5-3v10l-5-3" {...STROKE} />
|
||||
</>
|
||||
);
|
||||
|
||||
export const CallVideoMuteIcon: IconSrc = () => (
|
||||
<>
|
||||
<rect x="3" y="6" width="13" height="12" rx="2" {...STROKE} />
|
||||
<path d="M16 10l5-3v10l-5-3" {...STROKE} />
|
||||
<path d="M3 3l18 18" {...SLASH} />
|
||||
</>
|
||||
);
|
||||
|
||||
export const CallScreenShareIcon: IconSrc = () => (
|
||||
<>
|
||||
<rect x="3" y="4" width="18" height="13" rx="2" {...STROKE} />
|
||||
<path d="M9 21h6M12 17v4" {...STROKE} />
|
||||
<path d="M8.5 11l3.5-3.5L15.5 11" {...STROKE} />
|
||||
<path d="M12 7.5V14" {...STROKE} />
|
||||
</>
|
||||
);
|
||||
|
||||
// Screenshare-off carries the same slash convention as the other muted
|
||||
// glyphs so the on/off state has both a shape cue and a variant cue —
|
||||
// matters for high-contrast / monochrome modes where the
|
||||
// Success-vs-Surface variant flip alone is too subtle.
|
||||
export const CallScreenShareMuteIcon: IconSrc = () => (
|
||||
<>
|
||||
<rect x="3" y="4" width="18" height="13" rx="2" {...STROKE} />
|
||||
<path d="M9 21h6M12 17v4" {...STROKE} />
|
||||
<path d="M8.5 11l3.5-3.5L15.5 11" {...STROKE} />
|
||||
<path d="M12 7.5V14" {...STROKE} />
|
||||
<path d="M3 3l18 18" {...SLASH} />
|
||||
</>
|
||||
);
|
||||
|
||||
// Classic «pick up» handset, used on the IncomingCallStrip Answer
|
||||
// button. Kept upright (no rotation) so it reads as «receive call».
|
||||
export const CallPhoneIcon: IconSrc = () => (
|
||||
<path
|
||||
d="M5 4h4l2 5-3 2a12 12 0 0 0 6 6l2-3 5 2v4a2 2 0 0 1-2 2A17 17 0 0 1 3 6a2 2 0 0 1 2-2z"
|
||||
{...STROKE}
|
||||
/>
|
||||
);
|
||||
|
||||
// Same handset rotated 135° — universal «hang up / decline» glyph
|
||||
// (handset tilted off the cradle). Reused for Decline (IncomingCallStrip)
|
||||
// and Hangup (CallControl).
|
||||
export const CallPhoneDownIcon: IconSrc = () => (
|
||||
<g transform="rotate(135 12 12)">
|
||||
<path
|
||||
d="M5 4h4l2 5-3 2a12 12 0 0 0 6 6l2-3 5 2v4a2 2 0 0 1-2 2A17 17 0 0 1 3 6a2 2 0 0 1 2-2z"
|
||||
{...STROKE}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Line } from 'folds';
|
||||
import * as css from './styles.css';
|
||||
|
||||
export function StatusDivider() {
|
||||
return (
|
||||
<Line variant="Background" size="300" direction="Vertical" className={css.ControlDivider} />
|
||||
);
|
||||
}
|
||||
|
|
@ -1,21 +1,92 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { globalStyle, keyframes, style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
export const LiveChipText = style({
|
||||
color: color.Critical.Main,
|
||||
// === Incoming-call row + active-call pill ===
|
||||
//
|
||||
// Both live inside the bottom horseshoe rail owned by
|
||||
// `CallSurfaceContainer`. The rail paints the rounded shell — the rows
|
||||
// themselves stay flat, with hairline dividers between adjacent rows so
|
||||
// stacked entries (multiple concurrent rings, or a ring above the
|
||||
// active-call pill) read as separate items inside the same card.
|
||||
|
||||
export const RingRow = style({
|
||||
padding: toRem(20),
|
||||
});
|
||||
|
||||
export const CallStatus = style([
|
||||
{
|
||||
padding: `${toRem(6)} ${config.space.S200}`,
|
||||
borderTop: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
|
||||
export const RingAvatar = style({
|
||||
width: toRem(64),
|
||||
height: toRem(64),
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
// Active-call pill. Vertical padding is intentionally smaller than
|
||||
// `RingRow` (12 vs 20) — the pill is a secondary, persistent surface
|
||||
// and shouldn't visually outweigh an active ring row stacked above it.
|
||||
// No border-top of its own; the divider is lifted to the cross-row
|
||||
// `globalStyle` blocks below so each row is responsible only for its
|
||||
// own padding.
|
||||
export const CallStatus = style({
|
||||
padding: `${toRem(12)} ${toRem(20)}`,
|
||||
});
|
||||
|
||||
// Identity tap-target inside the pill — wraps the avatar+name+sub-text
|
||||
// region into a single button that navigates back to the call room.
|
||||
// Restores the affordance the legacy `CallRoomName` chip used to carry.
|
||||
// Strips default `<button>` chrome so the visual is identical to a
|
||||
// plain Box, but keeps native focus-ring + keyboard activation.
|
||||
export const CallIdentityButton = style({
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
color: 'inherit',
|
||||
font: 'inherit',
|
||||
});
|
||||
|
||||
// Hairline dividers between adjacent rows in the rail. `globalStyle` is
|
||||
// used for cross-class adjacency because vanilla-extract's `selectors`
|
||||
// key requires `&` to be the *matched* element, which forbids selectors
|
||||
// like `${RingRow} + ${CallStatus}` from a single style block.
|
||||
//
|
||||
// Order assumption: in the rail, RingRow rows precede CallStatus (see
|
||||
// `CallSurfaceContainer.tsx`). If a future contributor reorders these
|
||||
// — or introduces a new row type — re-audit the divider rules below.
|
||||
const rowDivider = `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`;
|
||||
|
||||
globalStyle(`${RingRow} + ${RingRow}`, {
|
||||
borderTop: rowDivider,
|
||||
});
|
||||
|
||||
globalStyle(`${RingRow} + ${CallStatus}`, {
|
||||
borderTop: rowDivider,
|
||||
});
|
||||
|
||||
// Live indicator next to the active-call sub-text. Static red dot until
|
||||
// the widget reports `joined`, then a subtle pulse — same visual
|
||||
// language as the LiveKit SDK's «we're really live» state. Reduced
|
||||
// motion stops the pulse but keeps the dot visible.
|
||||
const livePulse = keyframes({
|
||||
'0%, 100%': { opacity: 1, transform: 'scale(1)' },
|
||||
'50%': { opacity: 0.55, transform: 'scale(0.85)' },
|
||||
});
|
||||
|
||||
export const LiveDot = style({
|
||||
width: toRem(8),
|
||||
height: toRem(8),
|
||||
borderRadius: '50%',
|
||||
backgroundColor: color.Critical.Main,
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const LiveDotPulsing = style({
|
||||
animation: `${livePulse} 1.6s ease-in-out infinite`,
|
||||
'@media': {
|
||||
'(prefers-reduced-motion: reduce)': {
|
||||
animation: 'none',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const ControlDivider = style({
|
||||
height: toRem(16),
|
||||
});
|
||||
|
||||
export const SpeakerAvatarOutline = style({
|
||||
boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Success.Main}`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { CallEmbed } from '../plugins/call';
|
||||
import { useMutationObserver } from './useMutationObserver';
|
||||
import { isUserId } from '../utils/matrix';
|
||||
import { useCallMembers, useCallSession } from './useCall';
|
||||
import { useCallJoined } from './useCallEmbed';
|
||||
|
||||
export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
||||
const [speakers, setSpeakers] = useState(new Set<string>());
|
||||
const callSession = useCallSession(callEmbed.room);
|
||||
const callMembers = useCallMembers(callEmbed.room, callSession);
|
||||
const joined = useCallJoined(callEmbed);
|
||||
|
||||
const videoContainers = useMemo(() => {
|
||||
if (callMembers && joined) return callEmbed.document?.querySelectorAll('[data-video-fit]');
|
||||
return undefined;
|
||||
}, [callEmbed, callMembers, joined]);
|
||||
|
||||
const mutationObserver = useMutationObserver(
|
||||
useCallback(
|
||||
(mutations) => {
|
||||
const s = new Set<string>();
|
||||
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type !== 'attributes') return;
|
||||
const el = mutation.target as HTMLElement;
|
||||
|
||||
const style = callEmbed.iframe.contentWindow?.getComputedStyle(el, '::before');
|
||||
if (!style) return;
|
||||
const tileBackgroundImage = style.getPropertyValue('background-image');
|
||||
const speaking = tileBackgroundImage !== 'none';
|
||||
if (!speaking) return;
|
||||
|
||||
const speakerId = el.querySelector('[aria-label]')?.getAttribute('aria-label');
|
||||
if (speakerId && isUserId(speakerId)) {
|
||||
s.add(speakerId);
|
||||
}
|
||||
});
|
||||
|
||||
setSpeakers(s);
|
||||
},
|
||||
[callEmbed]
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
videoContainers?.forEach((element) => {
|
||||
mutationObserver.observe(element, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style'],
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
mutationObserver.disconnect();
|
||||
};
|
||||
}, [videoContainers, mutationObserver]);
|
||||
|
||||
return speakers;
|
||||
};
|
||||
|
|
@ -4,16 +4,31 @@ import { CallStatus } from '../features/call-status';
|
|||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||
|
||||
export function CallStatusRenderer() {
|
||||
// Whether the active-call pill is *currently* drawing in the rail. The
|
||||
// CallSurfaceContainer reuses this to decide whether the horseshoe shell
|
||||
// is justified — when the pill is suppressed (mobile + on call room +
|
||||
// video) the rail collapses to zero height, so painting the gap-color +
|
||||
// rounding the appShell would leave a dangling void below the UI.
|
||||
export function useCallStatusVisible(): boolean {
|
||||
const callEmbed = useCallEmbed();
|
||||
const selectedRoom = useSelectedRoom();
|
||||
|
||||
const screenSize = useScreenSizeContext();
|
||||
|
||||
if (!callEmbed) return null;
|
||||
if (!callEmbed) return false;
|
||||
if (
|
||||
screenSize === ScreenSize.Mobile &&
|
||||
callEmbed.roomId === selectedRoom &&
|
||||
!callEmbed.voiceOnly
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (screenSize === ScreenSize.Mobile && callEmbed.roomId === selectedRoom && !callEmbed.voiceOnly)
|
||||
return null;
|
||||
export function CallStatusRenderer() {
|
||||
const visible = useCallStatusVisible();
|
||||
const callEmbed = useCallEmbed();
|
||||
|
||||
if (!visible || !callEmbed) return null;
|
||||
return <CallStatus callEmbed={callEmbed} />;
|
||||
}
|
||||
|
|
|
|||
93
src/app/pages/CallSurfaceContainer.css.ts
Normal file
93
src/app/pages/CallSurfaceContainer.css.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { toRem } from 'folds';
|
||||
|
||||
// Color of the «void» between the two horseshoes — fixed in design, not
|
||||
// theme-driven. The surface paints this only while a call surface is
|
||||
// present so the rest of the app retains its normal page background.
|
||||
const SURFACE_GAP_COLOR = '#090909';
|
||||
const HORSESHOE_RADIUS = toRem(24);
|
||||
const HORSESHOE_GAP = toRem(8);
|
||||
|
||||
// Outer flex column that hosts the whole client UI plus the bottom call
|
||||
// rail. Stays flex:1 with a height-bound (`min-height: 0`) so nested
|
||||
// scroll containers inside ClientLayout can shrink correctly.
|
||||
export const surface = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
});
|
||||
|
||||
// «Top horseshoe» — wraps the whole client UI (sidebar + outlet). The
|
||||
// rounded bottom corners + bottom margin only kick in while a call
|
||||
// surface is mounted, painting the gap-color through the resulting void.
|
||||
export const appShell = style({
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
});
|
||||
|
||||
export const surfaceCallActive = style({
|
||||
backgroundColor: SURFACE_GAP_COLOR,
|
||||
});
|
||||
|
||||
export const appShellCallActive = style({
|
||||
borderBottomLeftRadius: HORSESHOE_RADIUS,
|
||||
borderBottomRightRadius: HORSESHOE_RADIUS,
|
||||
overflow: 'hidden',
|
||||
marginBottom: HORSESHOE_GAP,
|
||||
});
|
||||
|
||||
// «Bottom horseshoe» — the call rail. Rounded *top* corners only because
|
||||
// it sits flush against the safe-area inset; the bottom is the screen
|
||||
// edge. `position: relative` carries the absolute-positioned orbit
|
||||
// border.
|
||||
export const bottomRail = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const bottomRailCallActive = style({
|
||||
position: 'relative',
|
||||
borderTopLeftRadius: HORSESHOE_RADIUS,
|
||||
borderTopRightRadius: HORSESHOE_RADIUS,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
// Orbit border — a small green segment of a conic-gradient that runs
|
||||
// around the rail's perimeter when a ring is incoming. The mask trick
|
||||
// (content-box layer XOR full-box layer) cuts the inner area, so only
|
||||
// the 2px rim shows the gradient. The rotating angle is driven by the
|
||||
// `--vojo-orbit-angle` custom property, registered via `@property` in
|
||||
// `src/index.css` (vanilla-extract has no native @property binding).
|
||||
//
|
||||
// Wide-and-short rails sweep faster across long edges and slower around
|
||||
// corners — that's a property of the conic-gradient parametrization, and
|
||||
// reads as a pleasant pulse rather than as a uniform marquee.
|
||||
export const ringOrbit = style({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: 'inherit',
|
||||
padding: toRem(2),
|
||||
pointerEvents: 'none',
|
||||
background:
|
||||
'conic-gradient(from var(--vojo-orbit-angle, 0deg), transparent 0deg 280deg, rgba(91, 227, 197, 0.15) 300deg, #5BE3C5 335deg, rgba(91, 227, 197, 0.15) 350deg, transparent 360deg)',
|
||||
filter: 'drop-shadow(0 0 6px rgba(91, 227, 197, 0.55))',
|
||||
WebkitMask:
|
||||
'linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)',
|
||||
WebkitMaskComposite: 'xor',
|
||||
mask: 'linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)',
|
||||
maskComposite: 'exclude',
|
||||
animationName: 'vojo-orbit-sweep',
|
||||
animationDuration: '1.8s',
|
||||
animationTimingFunction: 'linear',
|
||||
animationIterationCount: 'infinite',
|
||||
'@media': {
|
||||
'(prefers-reduced-motion: reduce)': {
|
||||
animation: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
59
src/app/pages/CallSurfaceContainer.tsx
Normal file
59
src/app/pages/CallSurfaceContainer.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// Bottom-horseshoe call surface.
|
||||
//
|
||||
// Wraps `ClientLayout` plus the bottom call-rail (incoming-ring strips +
|
||||
// active-call status pill) into a single flex column. While at least one
|
||||
// ring is queued in `incomingCallsAtom` *or* an active call embed is
|
||||
// mounted (`callEmbedAtom`), both halves get the «two horseshoes
|
||||
// split-screen» treatment:
|
||||
//
|
||||
// • Top half (whole client UI) — rounded bottom corners, 8px margin
|
||||
// pushing the rest of the app up.
|
||||
// • Bottom half (call rail) — rounded top corners, single horseshoe
|
||||
// containing every concurrent ring as a stacked row plus the
|
||||
// active-call pill underneath.
|
||||
//
|
||||
// The void between paints `#090909` from the `surface` div. The
|
||||
// «orbiting border» (a conic-gradient sweep around the rail's rim) only
|
||||
// renders while a ring is *incoming* — it's the alarm cue. Once the
|
||||
// user is in the call the orbit is replaced by the static rail.
|
||||
//
|
||||
// Lifting the whole UI (including the global SidebarNav) is intentional:
|
||||
// the wrapper sits *above* `ClientLayout`, so the effect is
|
||||
// route-agnostic.
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { isRingingAtom } from '../state/incomingCalls';
|
||||
import { IncomingCallStripRenderer } from './IncomingCallStripRenderer';
|
||||
import { CallStatusRenderer, useCallStatusVisible } from './CallStatusRenderer';
|
||||
import * as css from './CallSurfaceContainer.css';
|
||||
|
||||
type CallSurfaceContainerProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function CallSurfaceContainer({ children }: CallSurfaceContainerProps) {
|
||||
const ringing = useAtomValue(isRingingAtom);
|
||||
// Gate the horseshoe on whether the rail will *actually* paint pixels.
|
||||
// A naive `callEmbed !== undefined` check would also flip true while
|
||||
// the pill is suppressed (mobile + viewing the call room + video) —
|
||||
// that would leave a black 8px shelf with no visible rail underneath.
|
||||
// Reusing the same visibility predicate as the renderer keeps shell +
|
||||
// content in sync.
|
||||
const pillVisible = useCallStatusVisible();
|
||||
const callPresent = ringing || pillVisible;
|
||||
|
||||
return (
|
||||
<div className={classNames(css.surface, callPresent && css.surfaceCallActive)}>
|
||||
<div className={classNames(css.appShell, callPresent && css.appShellCallActive)}>
|
||||
{children}
|
||||
</div>
|
||||
<div className={classNames(css.bottomRail, callPresent && css.bottomRailCallActive)}>
|
||||
<IncomingCallStripRenderer />
|
||||
<CallStatusRenderer />
|
||||
{ringing && <div className={css.ringOrbit} aria-hidden />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,7 +22,6 @@
|
|||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Box } from 'folds';
|
||||
import { App } from '@capacitor/app';
|
||||
import { incomingCallsAtom } from '../state/incomingCalls';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
|
|
@ -121,21 +120,18 @@ export function IncomingCallStripRenderer() {
|
|||
<source src={RingSoundOgg} type="audio/ogg" />
|
||||
<source src={RingSoundMp3} type="audio/mpeg" />
|
||||
</audio>
|
||||
{hasIncoming && (
|
||||
<Box direction="Column" shrink="No">
|
||||
{entries.map((call) => {
|
||||
const room = mx.getRoom(call.roomId);
|
||||
if (!room) return null;
|
||||
return (
|
||||
<IncomingCallStrip
|
||||
key={`call_${call.callId}_${call.roomId}`}
|
||||
call={call}
|
||||
room={room}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
{hasIncoming &&
|
||||
entries.map((call) => {
|
||||
const room = mx.getRoom(call.roomId);
|
||||
if (!room) return null;
|
||||
return (
|
||||
<IncomingCallStrip
|
||||
key={`call_${call.callId}_${call.roomId}`}
|
||||
call={call}
|
||||
room={room}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,12 +72,11 @@ import { Create } from './client/create';
|
|||
import { CreateSpaceModalRenderer } from '../features/create-space';
|
||||
import { SearchModalRenderer } from '../features/search';
|
||||
import { getFallbackSession } from '../state/sessions';
|
||||
import { CallStatusRenderer } from './CallStatusRenderer';
|
||||
import { CallEmbedProvider } from '../components/CallEmbedProvider';
|
||||
import { useIncomingRtcNotifications } from '../hooks/useIncomingRtcNotifications';
|
||||
import { useCallerAutoHangup } from '../hooks/useCallerAutoHangup';
|
||||
import { usePendingCallActionConsumer } from '../hooks/usePendingCallActionConsumer';
|
||||
import { IncomingCallStripRenderer } from './IncomingCallStripRenderer';
|
||||
import { CallSurfaceContainer } from './CallSurfaceContainer';
|
||||
import { useAppUrlOpen } from '../hooks/useAppUrlOpen';
|
||||
|
||||
function IncomingCallsFeature() {
|
||||
|
|
@ -168,17 +167,17 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
<ClientBindAtoms>
|
||||
<ClientNonUIFeatures>
|
||||
<CallEmbedProvider>
|
||||
<ClientLayout
|
||||
nav={
|
||||
<MobileFriendlyClientNav>
|
||||
<SidebarNav />
|
||||
</MobileFriendlyClientNav>
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
</ClientLayout>
|
||||
<IncomingCallStripRenderer />
|
||||
<CallStatusRenderer />
|
||||
<CallSurfaceContainer>
|
||||
<ClientLayout
|
||||
nav={
|
||||
<MobileFriendlyClientNav>
|
||||
<SidebarNav />
|
||||
</MobileFriendlyClientNav>
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
</ClientLayout>
|
||||
</CallSurfaceContainer>
|
||||
<IncomingCallsFeature />
|
||||
</CallEmbedProvider>
|
||||
<SearchModalRenderer />
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ export type IncomingCallAction =
|
|||
|
||||
const baseIncomingCallsAtom = atom<Map<string, IncomingCall>>(new Map());
|
||||
|
||||
// Derived boolean flips only on the empty/non-empty edge — used by the
|
||||
// call-surface horseshoe so it doesn't re-render on every metadata-only
|
||||
// Map churn (e.g. notification id rewrites that don't affect ring count).
|
||||
export const isRingingAtom = atom((get) => get(baseIncomingCallsAtom).size > 0);
|
||||
|
||||
export const incomingCallsAtom = atom<Map<string, IncomingCall>, [IncomingCallAction], void>(
|
||||
(get) => get(baseIncomingCallsAtom),
|
||||
(get, set, action) => {
|
||||
|
|
|
|||
|
|
@ -132,3 +132,25 @@ textarea {
|
|||
audio:not([controls]) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* Orbit-sweep border for the incoming-call horseshoe.
|
||||
*
|
||||
* Animating a conic-gradient angle requires the property to be registered
|
||||
* via @property — otherwise CSS treats custom properties as untyped
|
||||
* strings and the animation snaps instead of interpolating. Defined here
|
||||
* (rather than in vanilla-extract) because @property has no first-class
|
||||
* binding in @vanilla-extract/css. The keyframe is paired with it so
|
||||
* both halves stay in one place.
|
||||
*/
|
||||
@property --vojo-orbit-angle {
|
||||
syntax: '<angle>';
|
||||
initial-value: 0deg;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@keyframes vojo-orbit-sweep {
|
||||
to {
|
||||
--vojo-orbit-angle: 360deg;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue