diff --git a/public/locales/en.json b/public/locales/en.json index 3388f249..6ce2dd45 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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", diff --git a/public/locales/ru.json b/public/locales/ru.json index 56f6a7e3..d584bf4a 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -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": "Новые сообщения", diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index 9d1502fd..1851cb1e 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -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; + onToggle: () => void | Promise; 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 ( - {enabled ? t('Call.mic_off') : t('Call.mic_on')} + {enabled ? labelOn : labelOff} } > {(anchorRef) => ( onToggle()} - outlined disabled={disabled} + aria-label={enabled ? labelOn : labelOff} > - + )} ); } -type SoundButtonProps = { - enabled: boolean; - onToggle: () => void; - disabled?: boolean; -}; -function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) { - const { t } = useTranslation(); - return ( - - {enabled ? t('Call.sound_off') : t('Call.sound_on')} - - } - > - {(anchorRef) => ( - onToggle()} - outlined - disabled={disabled} - > - - - )} - - ); -} - -type VideoButtonProps = { - enabled: boolean; - onToggle: () => Promise; - disabled?: boolean; -}; -function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) { - const { t } = useTranslation(); - return ( - - {enabled ? t('Call.camera_off') : t('Call.camera_on')} - - } - > - {(anchorRef) => ( - onToggle()} - outlined - disabled={disabled} - > - - - )} - - ); -} - -type ScreenShareButtonProps = { - enabled: boolean; - onToggle: () => void; - disabled?: boolean; -}; -function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonProps) { - const { t } = useTranslation(); - return ( - - {enabled ? t('Call.screenshare_off') : t('Call.screenshare_on')} - - } - > - {(anchorRef) => ( - - - - )} - - ); -} - -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 ( - - - callEmbed.control.toggleMicrophone()} + + callEmbed.control.toggleMicrophone()} + disabled={!callJoined} + iconOn={CallMicIcon} + iconOff={CallMicMuteIcon} + labelOn={t('Call.mic_off')} + labelOff={t('Call.mic_on')} + /> + callEmbed.control.toggleSound()} + disabled={!callJoined} + iconOn={CallHeadphoneIcon} + iconOff={CallHeadphoneMuteIcon} + labelOn={t('Call.sound_off')} + labelOff={t('Call.sound_on')} + /> + {showVideo && ( + callEmbed.control.toggleVideo()} disabled={!callJoined} + iconOn={CallVideoIcon} + iconOff={CallVideoMuteIcon} + labelOn={t('Call.camera_off')} + labelOff={t('Call.camera_on')} + variantOn="Success" + variantOff="Surface" /> - callEmbed.control.toggleSound()} + )} + {showScreenshare && ( + 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 && } - callEmbed.control.toggleVideo()} - disabled={!callJoined} - /> - {!compact && ( - callEmbed.control.toggleScreenshare()} - disabled={!callJoined} - /> - )} - - )} - - - - ) : ( - - ) + )} + + {t('Call.end_call')} + } - disabled={exiting} - outlined - onClick={handleHangup} > - {!compact && ( - - {t('Call.end_call')} - + {(anchorRef) => ( + + {exiting ? ( + + ) : ( + + )} + )} - + ); } diff --git a/src/app/features/call-status/CallRoomName.tsx b/src/app/features/call-status/CallRoomName.tsx deleted file mode 100644 index 39f0e914..00000000 --- a/src/app/features/call-status/CallRoomName.tsx +++ /dev/null @@ -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 ( - - } - onClick={() => navigateRoom(room.roomId)} - > - - {name} - {!dm && perfectOrphanParent && ( - - {' •'} {getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent} - - )} - - - ); -} diff --git a/src/app/features/call-status/CallStatus.tsx b/src/app/features/call-status/CallStatus.tsx index 1d30d1b4..51d9fe52 100644 --- a/src/app/features/call-status/CallStatus.tsx +++ b/src/app/features/call-status/CallStatus.tsx @@ -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 ( - - {memberVisible ? ( - - - - ) : ( - - )} - - {!compact && ( - <> - - {speakers.size > 0 && ( - <> - - - - - )} - + navigateRoom(room.roomId)} + aria-label={t('Call.open_call_room')} + > + + {isOneOnOne ? ( + } + /> + ) : ( + } + /> )} + + + + {displayName} + + + + + {subText} + + - {memberVisible && ( - - - - )} - {memberVisible && !compact && } - - {compact && ( - - - - )} + diff --git a/src/app/features/call-status/IncomingCallStrip.tsx b/src/app/features/call-status/IncomingCallStrip.tsx index 1ab8aecd..d78957e9 100644 --- a/src/app/features/call-status/IncomingCallStrip.tsx +++ b/src/app/features/call-status/IncomingCallStrip.tsx @@ -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 ( - - - } - /> - - - - {displayName} - - - {t('Call.incoming')} - - + + } + /> + + + + {displayName} + + + {t('Call.incoming')} + - + - + )} @@ -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')} > - + )} diff --git a/src/app/features/call-status/LiveChip.tsx b/src/app/features/call-status/LiveChip.tsx deleted file mode 100644 index a5d00a55..00000000 --- a/src/app/features/call-status/LiveChip.tsx +++ /dev/null @@ -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(); - - const handleOpenMenu: MouseEventHandler = (evt) => { - setCords(evt.currentTarget.getBoundingClientRect()); - }; - - return ( - setCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - - - - - {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 ( - - openUserProfile( - room.roomId, - undefined, - userId, - getMouseEventCords(evt.nativeEvent), - 'Right' - ) - } - before={ - - } - /> - - } - > - - {name} - - - ); - })} - - - - - - } - > - } - after={} - radii="Pill" - onClick={handleOpenMenu} - > - - {count} Live - - - - ); -} diff --git a/src/app/features/call-status/MemberGlance.tsx b/src/app/features/call-status/MemberGlance.tsx deleted file mode 100644 index 2e65069a..00000000 --- a/src/app/features/call-status/MemberGlance.tsx +++ /dev/null @@ -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; - 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 ( - - {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 ( - - openUserProfile( - room.roomId, - undefined, - userId, - getMouseEventCords(evt.nativeEvent), - 'Top' - ) - } - > - } - /> - - ); - })} - {remainingCount > 0 && ( - - +{remainingCount} - - )} - - ); -} diff --git a/src/app/features/call-status/MemberSpeaking.tsx b/src/app/features/call-status/MemberSpeaking.tsx deleted file mode 100644 index 27e272f2..00000000 --- a/src/app/features/call-status/MemberSpeaking.tsx +++ /dev/null @@ -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; -}; -export function MemberSpeaking({ room, speakers }: MemberSpeakingProps) { - const speakingNames = Array.from(speakers).map( - (userId) => getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId - ); - return ( - - - - {speakingNames.length === 1 && ( - <> - {speakingNames[0]} - - {' is speaking...'} - - - )} - {speakingNames.length === 2 && ( - <> - {speakingNames[0]} - - {' and '} - - {speakingNames[1]} - - {' are speaking...'} - - - )} - {speakingNames.length === 3 && ( - <> - {speakingNames[0]} - - {', '} - - {speakingNames[1]} - - {' and '} - - {speakingNames[2]} - - {' are speaking...'} - - - )} - {speakingNames.length > 3 && ( - <> - {speakingNames[0]} - - {', '} - - {speakingNames[1]} - - {', '} - - {speakingNames[2]} - - {' and '} - - {speakingNames.length - 3} others - - {' are speaking...'} - - - )} - - - ); -} diff --git a/src/app/features/call-status/callIcons.tsx b/src/app/features/call-status/callIcons.tsx new file mode 100644 index 00000000..3b097005 --- /dev/null +++ b/src/app/features/call-status/callIcons.tsx @@ -0,0 +1,134 @@ +// These exports are folds `IconSrc` callbacks — `(filled?: boolean) => +// JSX.Element` — that the folds `` component invokes as `src(filled)` +// to populate the inner SVG content. They are NOT React components (they +// are never mounted via JSX as ``), 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 `` (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 = () => ( + <> + + + + +); + +export const CallMicMuteIcon: IconSrc = () => ( + <> + + + + + +); + +export const CallHeadphoneIcon: IconSrc = () => ( + <> + + + + +); + +export const CallHeadphoneMuteIcon: IconSrc = () => ( + <> + + + + + +); + +export const CallVideoIcon: IconSrc = () => ( + <> + + + +); + +export const CallVideoMuteIcon: IconSrc = () => ( + <> + + + + +); + +export const CallScreenShareIcon: IconSrc = () => ( + <> + + + + + +); + +// 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 = () => ( + <> + + + + + + +); + +// Classic «pick up» handset, used on the IncomingCallStrip Answer +// button. Kept upright (no rotation) so it reads as «receive call». +export const CallPhoneIcon: IconSrc = () => ( + +); + +// 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 = () => ( + + + +); diff --git a/src/app/features/call-status/components.tsx b/src/app/features/call-status/components.tsx deleted file mode 100644 index cd0c0daf..00000000 --- a/src/app/features/call-status/components.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import { Line } from 'folds'; -import * as css from './styles.css'; - -export function StatusDivider() { - return ( - - ); -} diff --git a/src/app/features/call-status/styles.css.ts b/src/app/features/call-status/styles.css.ts index dd32bc3d..8adc5a8f 100644 --- a/src/app/features/call-status/styles.css.ts +++ b/src/app/features/call-status/styles.css.ts @@ -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 `
+
+ {children} +
+
+ + + {ringing &&
} +
+
+ ); +} diff --git a/src/app/pages/IncomingCallStripRenderer.tsx b/src/app/pages/IncomingCallStripRenderer.tsx index accbbdbb..96ec7690 100644 --- a/src/app/pages/IncomingCallStripRenderer.tsx +++ b/src/app/pages/IncomingCallStripRenderer.tsx @@ -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() { - {hasIncoming && ( - - {entries.map((call) => { - const room = mx.getRoom(call.roomId); - if (!room) return null; - return ( - - ); - })} - - )} + {hasIncoming && + entries.map((call) => { + const room = mx.getRoom(call.roomId); + if (!room) return null; + return ( + + ); + })} ); } diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 0893ed86..9ea496b3 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -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) - - - - } - > - - - - + + + + + } + > + + + diff --git a/src/app/state/incomingCalls.ts b/src/app/state/incomingCalls.ts index 453a499c..e7169313 100644 --- a/src/app/state/incomingCalls.ts +++ b/src/app/state/incomingCalls.ts @@ -15,6 +15,11 @@ export type IncomingCallAction = const baseIncomingCallsAtom = atom>(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, [IncomingCallAction], void>( (get) => get(baseIncomingCallsAtom), (get, set, action) => { diff --git a/src/index.css b/src/index.css index e0c9e146..f220b67f 100644 --- a/src/index.css +++ b/src/index.css @@ -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: ''; + initial-value: 0deg; + inherits: false; +} + +@keyframes vojo-orbit-sweep { + to { + --vojo-orbit-angle: 360deg; + } +}