feat(calls): split-horseshoe call surface with redesigned ring/active pill, orbit border, custom outline icons, tap-to-room

This commit is contained in:
heaven 2026-05-08 01:30:36 +03:00
parent d58e69d49f
commit f4292611cf
20 changed files with 711 additions and 713 deletions

View file

@ -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",

View file

@ -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": "Новые сообщения",

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View 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>
);

View file

@ -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} />
);
}

View file

@ -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}`,
});

View file

@ -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;
};

View file

@ -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} />;
}

View 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',
},
},
});

View 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>
);
}

View file

@ -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}
/>
);
})}
</>
);
}

View file

@ -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 />

View file

@ -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) => {

View file

@ -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;
}
}