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
d58e69d49f
commit
f4292611cf
20 changed files with 711 additions and 713 deletions
|
|
@ -400,7 +400,11 @@
|
||||||
"screenshare_on": "Start Screenshare",
|
"screenshare_on": "Start Screenshare",
|
||||||
"chat_close": "Close Chat",
|
"chat_close": "Close Chat",
|
||||||
"chat_open": "Open 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": {
|
"Room": {
|
||||||
"new_messages": "New Messages",
|
"new_messages": "New Messages",
|
||||||
|
|
|
||||||
|
|
@ -402,7 +402,11 @@
|
||||||
"screenshare_on": "Начать показ экрана",
|
"screenshare_on": "Начать показ экрана",
|
||||||
"chat_close": "Закрыть чат",
|
"chat_close": "Закрыть чат",
|
||||||
"chat_open": "Открыть чат",
|
"chat_open": "Открыть чат",
|
||||||
"end_call": "Завершить"
|
"end_call": "Завершить",
|
||||||
|
"in_call": "В звонке",
|
||||||
|
"in_call_count": "{{count}} в звонке",
|
||||||
|
"connecting": "Соединение…",
|
||||||
|
"open_call_room": "Открыть чат звонка"
|
||||||
},
|
},
|
||||||
"Room": {
|
"Room": {
|
||||||
"new_messages": "Новые сообщения",
|
"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 React, { useCallback } from 'react';
|
||||||
import { useSetAtom, useStore } from 'jotai';
|
import { useSetAtom, useStore } from 'jotai';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { StatusDivider } from './components';
|
|
||||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { callEmbedAtom } from '../../state/callEmbed';
|
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
|
// 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
|
// 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).
|
// here to match the mobile-ish feel of our DM flow and PEER_LEAVE_GRACE_MS).
|
||||||
const HANGUP_TIMEOUT_MS = 8000;
|
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;
|
enabled: boolean;
|
||||||
onToggle: () => Promise<unknown>;
|
onToggle: () => void | Promise<unknown>;
|
||||||
disabled?: boolean;
|
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 (
|
return (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Top"
|
position="Top"
|
||||||
tooltip={
|
tooltip={
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Text size="T200">{enabled ? t('Call.mic_off') : t('Call.mic_on')}</Text>
|
<Text size="T200">{enabled ? labelOn : labelOff}</Text>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(anchorRef) => (
|
{(anchorRef) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
ref={anchorRef}
|
ref={anchorRef}
|
||||||
variant={enabled ? 'Surface' : 'Warning'}
|
variant={enabled ? variantOn : variantOff}
|
||||||
fill="Soft"
|
fill={BUTTON_FILL}
|
||||||
radii="300"
|
radii={BUTTON_RADII}
|
||||||
size="300"
|
size={BUTTON_SIZE}
|
||||||
onClick={() => onToggle()}
|
onClick={() => onToggle()}
|
||||||
outlined
|
|
||||||
disabled={disabled}
|
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>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type SoundButtonProps = {
|
type CallControlProps = {
|
||||||
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,
|
|
||||||
}: {
|
|
||||||
callEmbed: CallEmbed;
|
callEmbed: CallEmbed;
|
||||||
compact: boolean;
|
compact: boolean;
|
||||||
callJoined: boolean;
|
callJoined: boolean;
|
||||||
}) {
|
};
|
||||||
|
|
||||||
|
export function CallControl({ callEmbed, compact, callJoined }: CallControlProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
|
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
|
||||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||||
|
|
@ -207,59 +131,87 @@ export function CallControl({
|
||||||
hangup();
|
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 (
|
return (
|
||||||
<Box shrink="No" alignItems="Center" gap="300">
|
<Box shrink="No" alignItems="Center" gap="200">
|
||||||
<Box alignItems="Inherit" gap="200">
|
<ToggleButton
|
||||||
<MicrophoneButton
|
enabled={microphone}
|
||||||
enabled={microphone}
|
onToggle={() => callEmbed.control.toggleMicrophone()}
|
||||||
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}
|
disabled={!callJoined}
|
||||||
|
iconOn={CallVideoIcon}
|
||||||
|
iconOff={CallVideoMuteIcon}
|
||||||
|
labelOn={t('Call.camera_off')}
|
||||||
|
labelOff={t('Call.camera_on')}
|
||||||
|
variantOn="Success"
|
||||||
|
variantOff="Surface"
|
||||||
/>
|
/>
|
||||||
<SoundButton
|
)}
|
||||||
enabled={sound}
|
{showScreenshare && (
|
||||||
onToggle={() => callEmbed.control.toggleSound()}
|
<ToggleButton
|
||||||
|
enabled={screenshare}
|
||||||
|
onToggle={() => callEmbed.control.toggleScreenshare()}
|
||||||
disabled={!callJoined}
|
disabled={!callJoined}
|
||||||
|
iconOn={CallScreenShareIcon}
|
||||||
|
iconOff={CallScreenShareMuteIcon}
|
||||||
|
labelOn={t('Call.screenshare_off')}
|
||||||
|
labelOff={t('Call.screenshare_on')}
|
||||||
|
variantOn="Success"
|
||||||
|
variantOff="Surface"
|
||||||
/>
|
/>
|
||||||
{!callEmbed.voiceOnly && (
|
)}
|
||||||
<>
|
<TooltipProvider
|
||||||
{!compact && <StatusDivider />}
|
position="Top"
|
||||||
<VideoButton
|
tooltip={
|
||||||
enabled={video}
|
<Tooltip>
|
||||||
onToggle={() => callEmbed.control.toggleVideo()}
|
<Text size="T200">{t('Call.end_call')}</Text>
|
||||||
disabled={!callJoined}
|
</Tooltip>
|
||||||
/>
|
|
||||||
{!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 />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
disabled={exiting}
|
|
||||||
outlined
|
|
||||||
onClick={handleHangup}
|
|
||||||
>
|
>
|
||||||
{!compact && (
|
{(anchorRef) => (
|
||||||
<Text as="span" size="L400">
|
<IconButton
|
||||||
{t('Call.end_call')}
|
ref={anchorRef}
|
||||||
</Text>
|
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>
|
</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 React from 'react';
|
||||||
import { Box, Spinner } from 'folds';
|
import { Avatar, Box, Icon, Icons, Text } from 'folds';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { LiveChip } from './LiveChip';
|
import { useTranslation } from 'react-i18next';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import { CallRoomName } from './CallRoomName';
|
|
||||||
import { CallControl } from './CallControl';
|
import { CallControl } from './CallControl';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||||
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { MemberGlance } from './MemberGlance';
|
|
||||||
import { StatusDivider } from './components';
|
|
||||||
import { CallEmbed } from '../../plugins/call/CallEmbed';
|
import { CallEmbed } from '../../plugins/call/CallEmbed';
|
||||||
import { useCallJoined } from '../../hooks/useCallEmbed';
|
import { useCallJoined } from '../../hooks/useCallEmbed';
|
||||||
import { useCallSpeakers } from '../../hooks/useCallSpeakers';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { MemberSpeaking } from './MemberSpeaking';
|
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 = {
|
type CallStatusProps = {
|
||||||
callEmbed: CallEmbed;
|
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 callSession = useCallSession(room);
|
||||||
const callMembers = useCallMembers(room, callSession);
|
const callMembers = useCallMembers(room, callSession);
|
||||||
const screenSize = useScreenSize();
|
const screenSize = useScreenSizeContext();
|
||||||
const callJoined = useCallJoined(callEmbed);
|
|
||||||
const speakers = useCallSpeakers(callEmbed);
|
|
||||||
|
|
||||||
const compact = screenSize === ScreenSize.Mobile;
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className={classNames(css.CallStatus, ContainerColor({ variant: 'Background' }))}
|
className={classNames(css.CallStatus, ContainerColor({ variant: 'Background' }))}
|
||||||
shrink="No"
|
shrink="No"
|
||||||
|
direction="Row"
|
||||||
|
alignItems="Center"
|
||||||
gap="400"
|
gap="400"
|
||||||
alignItems={compact ? undefined : 'Center'}
|
|
||||||
direction={compact ? 'Column' : 'Row'}
|
|
||||||
>
|
>
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
<Box
|
||||||
{memberVisible ? (
|
as="button"
|
||||||
<Box shrink="No">
|
type="button"
|
||||||
<LiveChip count={callMembers.length} room={room} members={callMembers} />
|
grow="Yes"
|
||||||
</Box>
|
alignItems="Center"
|
||||||
) : (
|
gap="400"
|
||||||
<Spinner variant="Secondary" size="200" />
|
className={css.CallIdentityButton}
|
||||||
)}
|
onClick={() => navigateRoom(room.roomId)}
|
||||||
<Box grow="Yes" alignItems="Center" gap="Inherit">
|
aria-label={t('Call.open_call_room')}
|
||||||
{!compact && (
|
>
|
||||||
<>
|
<Avatar className={css.RingAvatar}>
|
||||||
<CallRoomName room={room} />
|
{isOneOnOne ? (
|
||||||
{speakers.size > 0 && (
|
<UserAvatar
|
||||||
<>
|
userId={peerUserId ?? ''}
|
||||||
<StatusDivider />
|
src={avatarUrl}
|
||||||
<span data-spacing-node />
|
alt={displayName}
|
||||||
<MemberSpeaking room={room} speakers={speakers} />
|
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>
|
</Box>
|
||||||
{memberVisible && (
|
|
||||||
<Box shrink="No">
|
|
||||||
<MemberGlance room={room} members={callMembers} speakers={speakers} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
{memberVisible && !compact && <StatusDivider />}
|
<Box shrink="No" alignItems="Center" gap="200">
|
||||||
<Box shrink="No" alignItems="Center" gap="Inherit">
|
|
||||||
{compact && (
|
|
||||||
<Box grow="Yes">
|
|
||||||
<CallRoomName room={room} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<CallControl callJoined={callJoined} compact={compact} callEmbed={callEmbed} />
|
<CallControl callJoined={callJoined} compact={compact} callEmbed={callEmbed} />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall';
|
||||||
import { getDirectRoomPath } from '../../pages/pathUtils';
|
import { getDirectRoomPath } from '../../pages/pathUtils';
|
||||||
import { IncomingCall, incomingCallsAtom } from '../../state/incomingCalls';
|
import { IncomingCall, incomingCallsAtom } from '../../state/incomingCalls';
|
||||||
import { getIncomingCallKey } from '../../utils/rtcNotification';
|
import { getIncomingCallKey } from '../../utils/rtcNotification';
|
||||||
|
import { CallPhoneDownIcon, CallPhoneIcon } from './callIcons';
|
||||||
|
|
||||||
const DECLINE_RETRY_DELAY_MS = 500;
|
const DECLINE_RETRY_DELAY_MS = 500;
|
||||||
|
|
||||||
|
|
@ -86,31 +87,29 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className={classNames(css.CallStatus, ContainerColor({ variant: 'Background' }))}
|
className={classNames(css.RingRow, ContainerColor({ variant: 'Background' }))}
|
||||||
shrink="No"
|
shrink="No"
|
||||||
gap="400"
|
|
||||||
alignItems="Center"
|
|
||||||
direction="Row"
|
direction="Row"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="500"
|
||||||
>
|
>
|
||||||
<Box grow="Yes" alignItems="Center" gap="300">
|
<Avatar className={css.RingAvatar}>
|
||||||
<Avatar size="300">
|
<UserAvatar
|
||||||
<UserAvatar
|
userId={senderId ?? ''}
|
||||||
userId={senderId ?? ''}
|
src={avatarUrl}
|
||||||
src={avatarUrl}
|
alt={displayName}
|
||||||
alt={displayName}
|
renderFallback={() => <Icon size="400" src={Icons.User} filled />}
|
||||||
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
/>
|
||||||
/>
|
</Avatar>
|
||||||
</Avatar>
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
<Box grow="Yes" direction="Column">
|
<Text size="H4" truncate>
|
||||||
<Text size="T300" truncate>
|
{displayName}
|
||||||
{displayName}
|
</Text>
|
||||||
</Text>
|
<Text size="T200" priority="300">
|
||||||
<Text size="T200" priority="300">
|
{t('Call.incoming')}
|
||||||
{t('Call.incoming')}
|
</Text>
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" alignItems="Center" gap="200">
|
<Box shrink="No" alignItems="Center" gap="300">
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Top"
|
position="Top"
|
||||||
tooltip={
|
tooltip={
|
||||||
|
|
@ -125,11 +124,11 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
|
||||||
variant="Critical"
|
variant="Critical"
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
size="300"
|
size="500"
|
||||||
onClick={handleDecline}
|
onClick={handleDecline}
|
||||||
aria-label={t('Call.decline')}
|
aria-label={t('Call.decline')}
|
||||||
>
|
>
|
||||||
<Icon size="100" src={Icons.PhoneDown} filled />
|
<Icon size="300" src={CallPhoneDownIcon} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
@ -147,11 +146,11 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
|
||||||
variant="Success"
|
variant="Success"
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
size="300"
|
size="500"
|
||||||
onClick={handleAnswer}
|
onClick={handleAnswer}
|
||||||
aria-label={t('Call.answer')}
|
aria-label={t('Call.answer')}
|
||||||
>
|
>
|
||||||
<Icon size="100" src={Icons.Phone} filled />
|
<Icon size="300" src={CallPhoneIcon} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</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';
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
export const LiveChipText = style({
|
// === Incoming-call row + active-call pill ===
|
||||||
color: color.Critical.Main,
|
//
|
||||||
|
// 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([
|
export const RingAvatar = style({
|
||||||
{
|
width: toRem(64),
|
||||||
padding: `${toRem(6)} ${config.space.S200}`,
|
height: toRem(64),
|
||||||
borderTop: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`,
|
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 { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
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 callEmbed = useCallEmbed();
|
||||||
const selectedRoom = useSelectedRoom();
|
const selectedRoom = useSelectedRoom();
|
||||||
|
|
||||||
const screenSize = useScreenSizeContext();
|
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)
|
export function CallStatusRenderer() {
|
||||||
return null;
|
const visible = useCallStatusVisible();
|
||||||
|
const callEmbed = useCallEmbed();
|
||||||
|
|
||||||
|
if (!visible || !callEmbed) return null;
|
||||||
return <CallStatus callEmbed={callEmbed} />;
|
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 React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { Box } from 'folds';
|
|
||||||
import { App } from '@capacitor/app';
|
import { App } from '@capacitor/app';
|
||||||
import { incomingCallsAtom } from '../state/incomingCalls';
|
import { incomingCallsAtom } from '../state/incomingCalls';
|
||||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||||
|
|
@ -121,21 +120,18 @@ export function IncomingCallStripRenderer() {
|
||||||
<source src={RingSoundOgg} type="audio/ogg" />
|
<source src={RingSoundOgg} type="audio/ogg" />
|
||||||
<source src={RingSoundMp3} type="audio/mpeg" />
|
<source src={RingSoundMp3} type="audio/mpeg" />
|
||||||
</audio>
|
</audio>
|
||||||
{hasIncoming && (
|
{hasIncoming &&
|
||||||
<Box direction="Column" shrink="No">
|
entries.map((call) => {
|
||||||
{entries.map((call) => {
|
const room = mx.getRoom(call.roomId);
|
||||||
const room = mx.getRoom(call.roomId);
|
if (!room) return null;
|
||||||
if (!room) return null;
|
return (
|
||||||
return (
|
<IncomingCallStrip
|
||||||
<IncomingCallStrip
|
key={`call_${call.callId}_${call.roomId}`}
|
||||||
key={`call_${call.callId}_${call.roomId}`}
|
call={call}
|
||||||
call={call}
|
room={room}
|
||||||
room={room}
|
/>
|
||||||
/>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,12 +72,11 @@ import { Create } from './client/create';
|
||||||
import { CreateSpaceModalRenderer } from '../features/create-space';
|
import { CreateSpaceModalRenderer } from '../features/create-space';
|
||||||
import { SearchModalRenderer } from '../features/search';
|
import { SearchModalRenderer } from '../features/search';
|
||||||
import { getFallbackSession } from '../state/sessions';
|
import { getFallbackSession } from '../state/sessions';
|
||||||
import { CallStatusRenderer } from './CallStatusRenderer';
|
|
||||||
import { CallEmbedProvider } from '../components/CallEmbedProvider';
|
import { CallEmbedProvider } from '../components/CallEmbedProvider';
|
||||||
import { useIncomingRtcNotifications } from '../hooks/useIncomingRtcNotifications';
|
import { useIncomingRtcNotifications } from '../hooks/useIncomingRtcNotifications';
|
||||||
import { useCallerAutoHangup } from '../hooks/useCallerAutoHangup';
|
import { useCallerAutoHangup } from '../hooks/useCallerAutoHangup';
|
||||||
import { usePendingCallActionConsumer } from '../hooks/usePendingCallActionConsumer';
|
import { usePendingCallActionConsumer } from '../hooks/usePendingCallActionConsumer';
|
||||||
import { IncomingCallStripRenderer } from './IncomingCallStripRenderer';
|
import { CallSurfaceContainer } from './CallSurfaceContainer';
|
||||||
import { useAppUrlOpen } from '../hooks/useAppUrlOpen';
|
import { useAppUrlOpen } from '../hooks/useAppUrlOpen';
|
||||||
|
|
||||||
function IncomingCallsFeature() {
|
function IncomingCallsFeature() {
|
||||||
|
|
@ -168,17 +167,17 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||||
<ClientBindAtoms>
|
<ClientBindAtoms>
|
||||||
<ClientNonUIFeatures>
|
<ClientNonUIFeatures>
|
||||||
<CallEmbedProvider>
|
<CallEmbedProvider>
|
||||||
<ClientLayout
|
<CallSurfaceContainer>
|
||||||
nav={
|
<ClientLayout
|
||||||
<MobileFriendlyClientNav>
|
nav={
|
||||||
<SidebarNav />
|
<MobileFriendlyClientNav>
|
||||||
</MobileFriendlyClientNav>
|
<SidebarNav />
|
||||||
}
|
</MobileFriendlyClientNav>
|
||||||
>
|
}
|
||||||
<Outlet />
|
>
|
||||||
</ClientLayout>
|
<Outlet />
|
||||||
<IncomingCallStripRenderer />
|
</ClientLayout>
|
||||||
<CallStatusRenderer />
|
</CallSurfaceContainer>
|
||||||
<IncomingCallsFeature />
|
<IncomingCallsFeature />
|
||||||
</CallEmbedProvider>
|
</CallEmbedProvider>
|
||||||
<SearchModalRenderer />
|
<SearchModalRenderer />
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,11 @@ export type IncomingCallAction =
|
||||||
|
|
||||||
const baseIncomingCallsAtom = atom<Map<string, IncomingCall>>(new Map());
|
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>(
|
export const incomingCallsAtom = atom<Map<string, IncomingCall>, [IncomingCallAction], void>(
|
||||||
(get) => get(baseIncomingCallsAtom),
|
(get) => get(baseIncomingCallsAtom),
|
||||||
(get, set, action) => {
|
(get, set, action) => {
|
||||||
|
|
|
||||||
|
|
@ -132,3 +132,25 @@ textarea {
|
||||||
audio:not([controls]) {
|
audio:not([controls]) {
|
||||||
display: none !important;
|
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