feat(profile): mobile top-horseshoe rail and desktop right pane host a Dawn-style user card with hero, info rows, and floating 3-dot actions menu

This commit is contained in:
v.lagerev 2026-05-08 19:04:12 +03:00
parent 7054ca2981
commit 9e42508902
21 changed files with 2111 additions and 816 deletions

View file

@ -407,6 +407,8 @@
"open_call_room": "Open call room"
},
"Room": {
"drag_to_close": "Drag up to close",
"collapse_avatar": "Collapse avatar",
"new_messages": "New Messages",
"jump_to_unread": "Jump to Unread",
"mark_as_read": "Mark as Read",
@ -911,5 +913,40 @@
},
"unknown_title": "Robot not found",
"unknown_description": "This robot is not in the Vojo catalog."
},
"User": {
"message": "Message",
"call": "Call",
"more": "More",
"block": "Block User",
"unblock": "Unblock User",
"blocked_title": "Blocked User",
"blocked_description": "You do not receive any messages or invites from this user.",
"profile_title": "Profile",
"presence_online": "Online",
"presence_unavailable": "Idle",
"presence_offline": "Offline",
"last_seen_just_now": "Last seen just now",
"last_seen_minutes_one": "Last seen {{count}} minute ago",
"last_seen_minutes_other": "Last seen {{count}} minutes ago",
"last_seen_hours_one": "Last seen {{count}} hour ago",
"last_seen_hours_other": "Last seen {{count}} hours ago",
"last_seen_yesterday": "Last seen yesterday at {{time}}",
"last_seen_date": "Last seen {{date}}",
"row_id": "id",
"row_server": "server",
"row_role": "role",
"row_mutual": "shared",
"row_mutual_loading": "Loading…",
"row_mutual_spaces": "Spaces",
"row_mutual_rooms": "Rooms",
"row_mutual_dms": "Direct messages",
"row_mutual_count_one": "{{count}} chat",
"row_mutual_count_other": "{{count}} chats",
"copy_user_id": "Copy user ID",
"copy_user_link": "Copy user link",
"copy_server": "Copy server",
"explore_community": "Explore community",
"open_in_browser": "Open in browser"
}
}

View file

@ -409,6 +409,8 @@
"open_call_room": "Открыть чат звонка"
},
"Room": {
"drag_to_close": "Потянуть вверх чтобы закрыть",
"collapse_avatar": "Свернуть аватар",
"new_messages": "Новые сообщения",
"jump_to_unread": "К непрочитанным",
"mark_as_read": "Отметить прочитанным",
@ -915,5 +917,43 @@
},
"unknown_title": "Робот не найден",
"unknown_description": "Этого робота нет в каталоге Vojo."
},
"User": {
"message": "Написать",
"call": "Позвонить",
"more": "Ещё",
"block": "Заблокировать",
"unblock": "Разблокировать",
"blocked_title": "Заблокирован",
"blocked_description": "Сообщения и приглашения от этого пользователя не приходят.",
"profile_title": "Профиль",
"presence_online": "В сети",
"presence_unavailable": "Не активен",
"presence_offline": "Не в сети",
"last_seen_just_now": "Был в сети только что",
"last_seen_minutes_one": "Был в сети {{count}} минуту назад",
"last_seen_minutes_few": "Был в сети {{count}} минуты назад",
"last_seen_minutes_many": "Был в сети {{count}} минут назад",
"last_seen_hours_one": "Был в сети {{count}} час назад",
"last_seen_hours_few": "Был в сети {{count}} часа назад",
"last_seen_hours_many": "Был в сети {{count}} часов назад",
"last_seen_yesterday": "Был в сети вчера в {{time}}",
"last_seen_date": "Был в сети {{date}}",
"row_id": "id",
"row_server": "сервер",
"row_role": "роль",
"row_mutual": "общие",
"row_mutual_loading": "Загрузка…",
"row_mutual_spaces": "Пространства",
"row_mutual_rooms": "Чаты",
"row_mutual_dms": "Личные",
"row_mutual_count_one": "{{count}} чат",
"row_mutual_count_few": "{{count}} чата",
"row_mutual_count_many": "{{count}} чатов",
"copy_user_id": "Скопировать ID",
"copy_user_link": "Скопировать ссылку",
"copy_server": "Скопировать сервер",
"explore_community": "Открыть сервер",
"open_in_browser": "Открыть в браузере"
}
}

View file

@ -1,55 +0,0 @@
import React from 'react';
import { Menu, PopOut, toRem } from 'folds';
import FocusTrap from 'focus-trap-react';
import { useCloseUserRoomProfile, useUserRoomProfileState } from '../state/hooks/userRoomProfile';
import { UserRoomProfile } from './user-profile';
import { UserRoomProfileState } from '../state/userRoomProfile';
import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
import { stopPropagation } from '../utils/keyboard';
import { SpaceProvider } from '../hooks/useSpace';
import { RoomProvider } from '../hooks/useRoom';
function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) {
const { roomId, spaceId, userId, cords, position } = state;
const allJoinedRooms = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allJoinedRooms);
const room = getRoom(roomId);
const space = spaceId ? getRoom(spaceId) : undefined;
const close = useCloseUserRoomProfile();
if (!room) return null;
return (
<PopOut
anchor={cords}
position={position ?? 'Top'}
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: close,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ width: toRem(340) }}>
<SpaceProvider value={space ?? null}>
<RoomProvider value={room}>
<UserRoomProfile userId={userId} />
</RoomProvider>
</SpaceProvider>
</Menu>
</FocusTrap>
}
/>
);
}
export function UserRoomProfileRenderer() {
const state = useUserRoomProfileState();
if (!state) return null;
return <UserRoomProfileContextMenu state={state} />;
}

View file

@ -1,439 +1,58 @@
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
import { useAtomValue } from 'jotai';
import { useNavigate } from 'react-router-dom';
// Action surfaces rendered alongside the user-profile card after
// the Dawn redesign:
//
// - `UserActionsMenu` — single 3-dot pill anchored to the
// top-right of the card. Hosts every imperative action we have
// (start DM in groups, block / unblock, kick / ban / invite for
// mods). Replaces the old bottom actions row, which on mobile
// overflowed past the rail height — the user couldn't reach
// Block without discovering a hidden scroll.
// - `IgnoredUserAlert` — banner shown when the viewer has the
// profile target on their ignore list. Pure information, no
// action of its own.
//
// The legacy chip-strip components (Server / Share / MutualRooms)
// were lifted into `UserInfoRows.tsx` as Fleet-style attribute rows
// and are intentionally not re-exported here.
import React, { MouseEventHandler, useCallback, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey';
import { Room } from 'matrix-js-sdk';
import {
PopOut,
Box,
Icon,
IconButton,
Icons,
Line,
Menu,
MenuItem,
config,
Text,
Line,
Chip,
Icon,
Icons,
PopOut,
RectCords,
Spinner,
Text,
config,
toRem,
Box,
Scroll,
Avatar,
} from 'folds';
import { useTranslation } from 'react-i18next';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMxIdServer } from '../../utils/matrix';
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { stopPropagation } from '../../utils/keyboard';
import { copyToClipboard } from '../../utils/dom';
import { getExploreServerPath } from '../../pages/pathUtils';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { factoryRoomIdByAtoZ } from '../../utils/sort';
import { useMutualRooms, useMutualRoomsSupport } from '../../hooks/useMutualRooms';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { mDirectAtom } from '../../state/mDirectList';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { openExternalUrl } from '../../utils/capacitor';
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
import { RoomAvatar, RoomIcon } from '../room-avatar';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
import { nameInitials } from '../../utils/common';
import { getMatrixToUser } from '../../plugins/matrix-to';
import { useTimeoutToggle } from '../../hooks/useTimeoutToggle';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { CutoutCard } from '../cutout-card';
import { SettingTile } from '../setting-tile';
export function ServerChip({ server }: { server: string }) {
const mx = useMatrixClient();
const myServer = getMxIdServer(mx.getSafeUserId());
const navigate = useNavigate();
const closeProfile = useCloseUserRoomProfile();
const [copied, setCopied] = useTimeoutToggle();
const [cords, setCords] = useState<RectCords>();
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const close = () => setCords(undefined);
return (
<PopOut
anchor={cords}
position="Bottom"
align="Start"
offset={4}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: close,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
}}
>
<Menu>
<div style={{ padding: config.space.S100 }}>
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="300"
onClick={() => {
copyToClipboard(server);
setCopied();
close();
}}
>
<Text size="B300">Copy Server</Text>
</MenuItem>
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="300"
onClick={() => {
navigate(getExploreServerPath(server));
closeProfile();
}}
>
<Text size="B300">Explore Community</Text>
</MenuItem>
</div>
<Line size="300" />
<div style={{ padding: config.space.S100 }}>
<MenuItem
variant={myServer === server ? 'Surface' : 'Critical'}
fill="None"
size="300"
radii="300"
onClick={() => {
openExternalUrl(`https://${server}`);
close();
}}
>
<Text size="B300">Open in Browser</Text>
</MenuItem>
</div>
</Menu>
</FocusTrap>
}
>
<Chip
variant={myServer === server ? 'SurfaceVariant' : 'Warning'}
radii="Pill"
before={
cords ? (
<Icon size="50" src={Icons.ChevronBottom} />
) : (
<Icon size="50" src={copied ? Icons.Check : Icons.Server} />
)
}
onClick={open}
aria-pressed={!!cords}
>
<Text size="B300" truncate>
{server}
</Text>
</Chip>
</PopOut>
);
}
export function ShareChip({ userId }: { userId: string }) {
const [cords, setCords] = useState<RectCords>();
const [copied, setCopied] = useTimeoutToggle();
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const close = () => setCords(undefined);
return (
<PopOut
anchor={cords}
position="Bottom"
align="Start"
offset={4}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: close,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
}}
>
<Menu>
<div style={{ padding: config.space.S100 }}>
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="300"
onClick={() => {
copyToClipboard(userId);
setCopied();
close();
}}
>
<Text size="B300">Copy User ID</Text>
</MenuItem>
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="300"
onClick={() => {
copyToClipboard(getMatrixToUser(userId));
setCopied();
close();
}}
>
<Text size="B300">Copy User Link</Text>
</MenuItem>
</div>
</Menu>
</FocusTrap>
}
>
<Chip
variant={copied ? 'Success' : 'SurfaceVariant'}
radii="Pill"
before={
cords ? (
<Icon size="50" src={Icons.ChevronBottom} />
) : (
<Icon size="50" src={copied ? Icons.Check : Icons.Link} />
)
}
onClick={open}
aria-pressed={!!cords}
>
<Text size="B300" truncate>
Share
</Text>
</Chip>
</PopOut>
);
}
type MutualRoomsData = {
rooms: Room[];
spaces: Room[];
directs: Room[];
};
export function MutualRoomsChip({ userId }: { userId: string }) {
const mx = useMatrixClient();
const mutualRoomSupported = useMutualRoomsSupport();
const mutualRoomsState = useMutualRooms(userId);
const { navigateRoom, navigateSpace } = useRoomNavigate();
const closeUserRoomProfile = useCloseUserRoomProfile();
// Read m.direct directly here, NOT useDirectRooms — after P3c the latter
// returns ALL non-space rooms (universal Direct list), which would collapse
// the «mutual DMs» vs «mutual rooms» split in this profile sheet.
const mDirects = useAtomValue(mDirectAtom);
const useAuthentication = useMediaAuthentication();
const allJoinedRooms = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allJoinedRooms);
const [cords, setCords] = useState<RectCords>();
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const close = () => setCords(undefined);
const mutual: MutualRoomsData = useMemo(() => {
const data: MutualRoomsData = {
rooms: [],
spaces: [],
directs: [],
};
if (mutualRoomsState.status === AsyncStatus.Success) {
const mutualRooms = mutualRoomsState.data
.sort(factoryRoomIdByAtoZ(mx))
.map(getRoom)
.filter((room) => !!room);
mutualRooms.forEach((room) => {
if (room.isSpaceRoom()) {
data.spaces.push(room);
return;
}
if (mDirects.has(room.roomId)) {
data.directs.push(room);
return;
}
data.rooms.push(room);
});
}
return data;
}, [mutualRoomsState, getRoom, mDirects, mx]);
if (
userId === mx.getSafeUserId() ||
!mutualRoomSupported ||
mutualRoomsState.status === AsyncStatus.Error
) {
return null;
}
const renderItem = (room: Room) => {
const { roomId } = room;
const dm = mDirects.has(roomId);
return (
<MenuItem
key={roomId}
variant="Surface"
fill="None"
size="300"
radii="300"
style={{ paddingLeft: config.space.S100 }}
onClick={() => {
if (room.isSpaceRoom()) {
navigateSpace(roomId);
} else {
navigateRoom(roomId);
}
closeUserRoomProfile();
}}
before={
<Avatar size="200" radii={dm ? '400' : '300'}>
{dm || room.isSpaceRoom() ? (
<RoomAvatar
roomId={room.roomId}
src={
dm
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
) : (
<RoomIcon size="100" joinRule={room.getJoinRule()} roomType={room.getType()} />
)}
</Avatar>
}
>
<Text size="B300" truncate>
{room.name}
</Text>
</MenuItem>
);
};
return (
<PopOut
anchor={cords}
position="Bottom"
align="Start"
offset={4}
content={
mutualRoomsState.status === AsyncStatus.Success ? (
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: close,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
}}
>
<Menu
style={{
display: 'flex',
maxWidth: toRem(200),
maxHeight: '80vh',
}}
>
<Box grow="Yes">
<Scroll size="300" hideTrack>
<Box
direction="Column"
gap="400"
style={{ padding: config.space.S200, paddingRight: 0 }}
>
{mutual.spaces.length > 0 && (
<Box direction="Column" gap="100">
<Text style={{ paddingLeft: config.space.S100 }} size="L400">
Spaces
</Text>
{mutual.spaces.map(renderItem)}
</Box>
)}
{mutual.rooms.length > 0 && (
<Box direction="Column" gap="100">
<Text style={{ paddingLeft: config.space.S100 }} size="L400">
Rooms
</Text>
{mutual.rooms.map(renderItem)}
</Box>
)}
{mutual.directs.length > 0 && (
<Box direction="Column" gap="100">
<Text style={{ paddingLeft: config.space.S100 }} size="L400">
Direct Messages
</Text>
{mutual.directs.map(renderItem)}
</Box>
)}
</Box>
</Scroll>
</Box>
</Menu>
</FocusTrap>
) : null
}
>
<Chip
variant="SurfaceVariant"
radii="Pill"
before={mutualRoomsState.status === AsyncStatus.Loading && <Spinner size="50" />}
disabled={
mutualRoomsState.status !== AsyncStatus.Success || mutualRoomsState.data.length === 0
}
onClick={open}
aria-pressed={!!cords}
>
<Text size="B300">
{mutualRoomsState.status === AsyncStatus.Success &&
`${mutualRoomsState.data.length} Mutual Rooms`}
{mutualRoomsState.status === AsyncStatus.Loading && 'Mutual Rooms'}
</Text>
</Chip>
</PopOut>
);
}
import { UserModeration } from './UserModeration';
export function IgnoredUserAlert() {
const { t } = useTranslation();
return (
<CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
<SettingTile>
<Box direction="Column" gap="200">
<Box gap="200" justifyContent="SpaceBetween">
<Text size="L400">Blocked User</Text>
<Text size="L400">{t('User.blocked_title')}</Text>
</Box>
<Box direction="Column">
<Text size="T200">You do not receive any messages or invites from this user.</Text>
<Text size="T200">{t('User.blocked_description')}</Text>
</Box>
</Box>
</SettingTile>
@ -441,14 +60,52 @@ export function IgnoredUserAlert() {
);
}
export function OptionsChip({ userId }: { userId: string }) {
type UserActionsMenuProps = {
userId: string;
// Whether to surface «Написать» (start a private DM) — true only
// in group rooms when the target isn't the viewer themselves; in
// 1:1 the user is already in the conversation, so the action is a
// no-op.
showStartDm?: boolean;
onStartDm?: () => void;
// Room-moderation gates. Each is independently true/false; if all
// three are false the moderation block is suppressed entirely so
// the menu doesn't carry a dangling separator.
canKick?: boolean;
canBan?: boolean;
canInvite?: boolean;
};
// 3-dot pill containing every imperative action for the profile
// target. Anchored top-right of the card by the parent layout.
//
// Block / Unblock lives here too — earlier I tried promoting it to
// a standalone chip in a bottom actions row, but on mobile the rail
// is ~42vh and that row scrolled below the visible rail edge, so
// users couldn't reach it. Putting Block back into the menu keeps
// the action accessible at the same vertical position regardless of
// content length.
export function UserActionsMenu({
userId,
showStartDm,
onStartDm,
canKick,
canBan,
canInvite,
}: UserActionsMenuProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const [cords, setCords] = useState<RectCords>();
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
// Anchor the popout to the trigger's TOP edge with zero height
// — `position="Bottom"` then drops the menu starting from that
// edge, so it visually covers the 3-dot button instead of
// floating below it. Looks tighter on mobile where space is
// tight; the trigger naturally re-emerges when the menu closes.
const rect = evt.currentTarget.getBoundingClientRect();
setCords({ x: rect.x, y: rect.y, width: rect.width, height: 0 });
};
const close = () => setCords(undefined);
const ignoredUsers = useIgnoredUsers();
@ -456,19 +113,24 @@ export function OptionsChip({ userId }: { userId: string }) {
const [ignoreState, toggleIgnore] = useAsyncCallback(
useCallback(async () => {
const users = ignoredUsers.filter((u) => u !== userId);
if (!ignored) users.push(userId);
await mx.setIgnoredUsers(users);
const next = ignoredUsers.filter((u) => u !== userId);
if (!ignored) next.push(userId);
await mx.setIgnoredUsers(next);
}, [mx, ignoredUsers, userId, ignored])
);
const ignoring = ignoreState.status === AsyncStatus.Loading;
const moderationVisible = !!(canKick || canBan || canInvite);
return (
<PopOut
anchor={cords}
position="Bottom"
align="Start"
offset={4}
align="End"
// No offset: combined with the `height: 0` anchor above, this
// makes the popout's top edge land on the trigger's top edge
// — i.e., the menu opens *over* the 3-dots, not below.
offset={0}
content={
<FocusTrap
focusTrapOptions={{
@ -480,7 +142,25 @@ export function OptionsChip({ userId }: { userId: string }) {
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
}}
>
<Menu>
<Menu style={{ minWidth: toRem(220) }}>
{showStartDm && onStartDm && (
<div style={{ padding: config.space.S100 }}>
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="300"
onClick={() => {
onStartDm();
close();
}}
before={<Icon size="50" src={Icons.Message} filled />}
>
<Text size="B300">{t('User.message')}</Text>
</MenuItem>
</div>
)}
{showStartDm && onStartDm && <Line size="300" />}
<div style={{ padding: config.space.S100 }}>
<MenuItem
variant="Critical"
@ -500,20 +180,36 @@ export function OptionsChip({ userId }: { userId: string }) {
}
disabled={ignoring}
>
<Text size="B300">{ignored ? 'Unblock User' : 'Block User'}</Text>
<Text size="B300">{ignored ? t('User.unblock') : t('User.block')}</Text>
</MenuItem>
</div>
{moderationVisible && (
<>
<Line size="300" />
<div style={{ padding: config.space.S200 }}>
<UserModeration
userId={userId}
canKick={!!canKick}
canBan={!!canBan}
canInvite={!!canInvite}
/>
</div>
</>
)}
</Menu>
</FocusTrap>
}
>
<Chip variant="SurfaceVariant" radii="Pill" onClick={open} aria-pressed={!!cords}>
{ignoring ? (
<Spinner variant="Secondary" size="50" />
) : (
<Icon size="50" src={Icons.HorizontalDots} />
)}
</Chip>
<IconButton
size="300"
radii="Pill"
variant="SurfaceVariant"
onClick={open}
aria-pressed={!!cords}
aria-label={t('User.more')}
>
<Icon size="100" src={Icons.VerticalDots} />
</IconButton>
</PopOut>
);
}

View file

@ -1,120 +1,203 @@
import React, { useState } from 'react';
import {
Avatar,
Box,
Icon,
Icons,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
} from 'folds';
// «Dawn-evolved» hero used at the top of the user-profile card.
// Visual language is borrowed from the design bundle in
// `docs/design/new-direct-messages-design`: centred large gradient
// avatar with an inset green online dot, display name in display
// weight, monospaced handle in muted, presence row with coloured
// status dot + last-seen line. The optional e2ee chip mirrors the
// chat header's lock+«e2ee» token so the surfaces feel like one
// system rather than two design dialects.
import React from 'react';
import { Box, Icon, Icons, Text } from 'folds';
import classNames from 'classnames';
import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next';
import * as css from './styles.css';
import { UserAvatar } from '../user-avatar';
import colorMXID from '../../../util/colorMXID';
import { getMxIdLocalPart } from '../../utils/matrix';
import { BreakWord, LineClamp3 } from '../../styles/Text.css';
import { UserPresence } from '../../hooks/useUserPresence';
import { AvatarPresence, PresenceBadge } from '../presence';
import { ImageViewer } from '../image-viewer';
import { stopPropagation } from '../../utils/keyboard';
import { Presence, UserPresence } from '../../hooks/useUserPresence';
import {
timeDayMonYear,
timeHourMinute,
today as isToday,
yesterday as isYesterday,
} from '../../utils/time';
import colorMXID from '../../../util/colorMXID';
// Shade a `#rrggbb` hex by clamping each channel by `amt`. Borrowed
// from the design bundle's `shared.jsx::shade` helper. We use it to
// build the avatar's 135° gradient bottom-stop without needing the
// CSS `color-mix()` function (which isn't widely enough supported
// on old Android WebView builds — see fallbackBg comment below).
const shadeHex = (hex: string, amt: number): string => {
const cleaned = hex.replace('#', '');
if (cleaned.length !== 6) return hex;
const r = parseInt(cleaned.slice(0, 2), 16);
const g = parseInt(cleaned.slice(2, 4), 16);
const b = parseInt(cleaned.slice(4, 6), 16);
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return hex;
const clamp = (v: number) => Math.max(0, Math.min(255, v + amt));
const toByte = (v: number) => clamp(v).toString(16).padStart(2, '0');
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
};
// Bucketed last-seen formatter. Each bucket has its own i18n key so
// translators can phrase «just now», «N minutes ago», «yesterday at
// 14:32» and an absolute date independently — i18next's plural
// suffixes (`_one` / `_few` / `_many` / `_other`) handle Russian
// number agreement automatically. We deliberately do not use
// `Intl.RelativeTimeFormat` here because it follows the *browser*
// locale, not the i18next-selected language; mixing those gives us
// English «5 minutes ago» under a Russian UI.
const formatLastSeen = (
ts: number,
t: ReturnType<typeof useTranslation>['t']
): string => {
const now = Date.now();
const diffMs = Math.max(0, now - ts);
const mins = Math.floor(diffMs / 60_000);
if (mins < 1) return t('User.last_seen_just_now');
if (mins < 60) return t('User.last_seen_minutes', { count: mins });
const hours = Math.floor(diffMs / 3_600_000);
if (hours < 24 && isToday(ts)) {
return t('User.last_seen_hours', { count: hours });
}
if (isYesterday(ts)) {
return t('User.last_seen_yesterday', { time: timeHourMinute(ts) });
}
return t('User.last_seen_date', { date: timeDayMonYear(ts) });
};
type UserHeroProps = {
userId: string;
displayName?: string;
avatarUrl?: string;
presence?: UserPresence;
encrypted?: boolean;
onAvatarClick?: () => void;
};
export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
const [viewAvatar, setViewAvatar] = useState<string>();
return (
<Box direction="Column" className={css.UserHero}>
<div
className={css.UserHeroCoverContainer}
style={{
backgroundColor: colorMXID(userId),
filter: avatarUrl ? undefined : 'brightness(50%)',
}}
>
{avatarUrl && (
<img className={css.UserHeroCover} src={avatarUrl} alt={userId} draggable="false" />
)}
</div>
<div className={css.UserHeroAvatarContainer}>
<AvatarPresence
className={css.UserAvatarContainer}
badge={
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
}
>
<Avatar
as={avatarUrl ? 'button' : 'div'}
onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined}
className={css.UserHeroAvatar}
size="500"
>
<UserAvatar
className={css.UserHeroAvatarImg}
userId={userId}
src={avatarUrl}
alt={userId}
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
/>
</Avatar>
</AvatarPresence>
{viewAvatar && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setViewAvatar(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="500" onContextMenu={(evt: any) => evt.stopPropagation()}>
<ImageViewer
src={viewAvatar}
alt={userId}
requestClose={() => setViewAvatar(undefined)}
/>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
</div>
</Box>
);
}
type UserHeroNameProps = {
displayName?: string;
userId: string;
};
export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
export function UserHero({
userId,
displayName,
avatarUrl,
presence,
encrypted,
onAvatarClick,
}: UserHeroProps) {
const { t } = useTranslation();
const username = getMxIdLocalPart(userId);
const online = presence?.presence === Presence.Online;
// Offline / idle states get the formatted last-seen line (with the
// grey dot prefix as a quiet «last activity» cue). Online state is
// rendered separately as a coloured «онлайн» tag matching the chat
// header — no dot prefix because the avatar already carries the
// green online indicator, and a duplicated dot reads as visual noise.
let presenceLabel: string | undefined;
if (!online) {
if (presence?.lastActiveTs && presence.lastActiveTs > 0) {
// Both Unavailable and Offline get last-seen detail when we
// have a real timestamp to anchor it. The SDK sometimes
// reports `lastActiveTs === 0` for users we've never seen —
// those still fall through to the plain «Idle / Offline»
// labels so we don't claim «last seen 1970».
presenceLabel = formatLastSeen(presence.lastActiveTs, t);
} else if (presence?.presence === Presence.Unavailable) {
presenceLabel = t('User.presence_unavailable');
} else if (presence) {
presenceLabel = t('User.presence_offline');
}
}
const initial = (username?.[0] ?? userId.replace(/^@/, '')[0] ?? '?').toUpperCase();
// The Dawn mockups use a 135° gradient on the avatar fallback —
// base colour to a slightly darker shade of itself. We compute
// the darker stop in JS rather than via CSS `color-mix(in srgb,
// …)` because the latter only ships in Chrome 111+ / Safari 16.2+
// / Firefox 113+, and Capacitor's bundled WebView on stale
// Android devices can be older than that — a missing `color-mix`
// would parse the gradient as `none` and the fallback would
// render flat black.
const fallbackColor = colorMXID(userId);
const fallbackBg = `linear-gradient(135deg, ${fallbackColor}, ${shadeHex(fallbackColor, -22)})`;
const avatarNode = (
<span className={css.HeroAvatar} aria-hidden={onAvatarClick ? undefined : true}>
<UserAvatar
userId={userId}
src={avatarUrl}
alt={displayName ?? userId}
renderFallback={() => (
<span className={css.HeroAvatarFallback} style={{ backgroundImage: fallbackBg }}>
{initial}
</span>
)}
/>
{online && <span className={css.HeroOnlineDot} aria-hidden />}
</span>
);
return (
<Box grow="Yes" direction="Column" gap="0">
<Box alignItems="Baseline" gap="200" wrap="Wrap">
<Box direction="Column" alignItems="Center" gap="200" className={css.HeroRoot}>
{onAvatarClick ? (
<button
type="button"
onClick={onAvatarClick}
className={css.HeroAvatarButton}
aria-label={t('Room.expand_avatar', { defaultValue: 'Open avatar' })}
>
{avatarNode}
</button>
) : (
avatarNode
)}
<Box direction="Column" alignItems="Center" gap="100" className={css.HeroIdentity}>
<Text
size="H4"
size="H3"
align="Center"
className={classNames(BreakWord, LineClamp3)}
title={displayName ?? username}
title={displayName ?? username ?? userId}
>
{displayName ?? username ?? userId}
</Text>
</Box>
<Box alignItems="Center" gap="100" wrap="Wrap">
<Text size="T200" className={classNames(BreakWord, LineClamp3)} title={username}>
@{username}
</Text>
</Box>
{(online || presenceLabel || encrypted) && (
<Box alignItems="Center" gap="200" className={css.HeroPresence}>
{online ? (
<Text as="span" size="T200" className={css.HeroOnlineTag}>
{t('Room.status_online')}
</Text>
) : (
presenceLabel && (
<>
<span className={css.PresenceDot} aria-hidden />
<Text as="span" size="T200" priority="300">
{presenceLabel}
</Text>
</>
)
)}
{encrypted && (
<>
{(online || presenceLabel) && (
<span className={css.HeroBullet} aria-hidden>
·
</span>
)}
<span className={css.HeroE2ee}>
<Icon size="50" src={Icons.Lock} filled />
<Text as="span" size="T200">
{t('Room.encrypted_short')}
</Text>
</span>
</>
)}
</Box>
)}
</Box>
);
}

View file

@ -0,0 +1,480 @@
// Fleet-style info section for the user-profile card.
//
// Each row is a single line: monospace label on the left in a fixed
// gutter, the actual value in the middle (truncates), and an optional
// trailing affordance (copy / open / chevron-menu) on the right. The
// visual is borrowed from the Dawn design bundle's IDE-attribute
// rows — the goal is to fill the card with structured information
// rather than a strip of horizontally-scattered chips.
//
// Each interactive row owns its own popout menu (re-using the
// menu content patterns the legacy `UserChips` exposed). The hosts
// (`UserRoomProfile`) just stitch them together; they don't know
// about menu state.
import React, { MouseEventHandler, useMemo, useState } from 'react';
import {
Avatar,
Box,
Icon,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Scroll,
Spinner,
Text,
config,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey';
import { Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useMutualRooms, useMutualRoomsSupport } from '../../hooks/useMutualRooms';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useTimeoutToggle } from '../../hooks/useTimeoutToggle';
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
import { mDirectAtom } from '../../state/mDirectList';
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { copyToClipboard } from '../../utils/dom';
import { stopPropagation } from '../../utils/keyboard';
import { factoryRoomIdByAtoZ } from '../../utils/sort';
import { openExternalUrl } from '../../utils/capacitor';
import { getMxIdServer } from '../../utils/matrix';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
import { nameInitials } from '../../utils/common';
import { getExploreServerPath } from '../../pages/pathUtils';
import { getMatrixToUser } from '../../plugins/matrix-to';
import { AsyncStatus } from '../../hooks/useAsyncCallback';
import { RoomAvatar, RoomIcon } from '../room-avatar';
import * as css from './styles.css';
// ── Generic row primitive ────────────────────────────────────────
type InfoRowProps = {
label: string;
value: React.ReactNode;
// Right-side affordance. Either an inline button (e.g. a copy
// glyph) or a popout-menu trigger pulled from one of the row
// variants below.
trailing?: React.ReactNode;
// Tooltip for the value (typically the full untruncated text).
title?: string;
};
export function InfoRow({ label, value, trailing, title }: InfoRowProps) {
return (
<div className={css.InfoRow}>
<span className={css.InfoRowLabel}>{label}</span>
<span className={css.InfoRowValue} title={title}>
{value}
</span>
{trailing && <span className={css.InfoRowTrailing}>{trailing}</span>}
</div>
);
}
// ── id (handle + share menu) ─────────────────────────────────────
export function IdRow({ userId }: { userId: string }) {
const { t } = useTranslation();
const [cords, setCords] = useState<RectCords>();
const [copied, setCopied] = useTimeoutToggle();
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const close = () => setCords(undefined);
const handle = userId.replace(/^@/, '');
return (
<PopOut
anchor={cords}
position="Bottom"
align="End"
offset={4}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: close,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
}}
>
<Menu>
<div style={{ padding: config.space.S100 }}>
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="300"
onClick={() => {
copyToClipboard(userId);
setCopied();
close();
}}
>
<Text size="B300">{t('User.copy_user_id')}</Text>
</MenuItem>
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="300"
onClick={() => {
copyToClipboard(getMatrixToUser(userId));
setCopied();
close();
}}
>
<Text size="B300">{t('User.copy_user_link')}</Text>
</MenuItem>
</div>
</Menu>
</FocusTrap>
}
>
<InfoRow
label={t('User.row_id')}
value={handle}
title={handle}
trailing={
<button
type="button"
className={css.InfoRowAction}
onClick={open}
aria-pressed={!!cords}
aria-label={t('User.copy_user_id')}
>
<Icon size="50" src={copied ? Icons.Check : Icons.HorizontalDots} />
</button>
}
/>
</PopOut>
);
}
// ── server ───────────────────────────────────────────────────────
export function ServerRow({ userId }: { userId: string }) {
const { t } = useTranslation();
const mx = useMatrixClient();
const navigate = useNavigate();
const closeProfile = useCloseUserRoomProfile();
const [cords, setCords] = useState<RectCords>();
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const close = () => setCords(undefined);
const server = getMxIdServer(userId);
const myServer = getMxIdServer(mx.getSafeUserId());
if (!server) return null;
return (
<PopOut
anchor={cords}
position="Bottom"
align="End"
offset={4}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: close,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
}}
>
<Menu>
<div style={{ padding: config.space.S100 }}>
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="300"
onClick={() => {
copyToClipboard(server);
close();
}}
>
<Text size="B300">{t('User.copy_server')}</Text>
</MenuItem>
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="300"
onClick={() => {
navigate(getExploreServerPath(server));
closeProfile();
}}
>
<Text size="B300">{t('User.explore_community')}</Text>
</MenuItem>
<MenuItem
variant={myServer === server ? 'Surface' : 'Critical'}
fill="None"
size="300"
radii="300"
onClick={() => {
openExternalUrl(`https://${server}`);
close();
}}
>
<Text size="B300">{t('User.open_in_browser')}</Text>
</MenuItem>
</div>
</Menu>
</FocusTrap>
}
>
<InfoRow
label={t('User.row_server')}
value={server}
title={server}
trailing={
<button
type="button"
className={css.InfoRowAction}
onClick={open}
aria-pressed={!!cords}
aria-label={t('User.open_in_browser')}
>
<Icon size="50" src={Icons.External} />
</button>
}
/>
</PopOut>
);
}
// ── role (info-only) ─────────────────────────────────────────────
type RoleRowProps = {
roleLabel: string;
// Render the role in an accent style — used for creators since
// they sit above the regular power-level ladder.
emphasis?: boolean;
};
// Role row — just the localised role label («Admin», «Moderator»,
// «Member», или кастомный tag.name в комнатах со своей PL-таблицей).
// We intentionally don't surface the raw power-level number: «pl N»
// is Matrix protocol jargon that doesn't help end users, and
// duplicates what the role label already conveys for the standard
// 0 / 50 / 100 ladder.
export function RoleRow({ roleLabel, emphasis }: RoleRowProps) {
const { t } = useTranslation();
const value = emphasis ? (
<span className={css.InfoRowAccent}>
<Icon size="50" src={Icons.Star} filled />
<span>{roleLabel}</span>
</span>
) : (
<span>{roleLabel}</span>
);
return <InfoRow label={t('User.row_role')} value={value} />;
}
// ── mutual rooms ─────────────────────────────────────────────────
type MutualRoomsData = {
rooms: Room[];
spaces: Room[];
directs: Room[];
};
export function MutualRoomsRow({ userId }: { userId: string }) {
const { t } = useTranslation();
const mx = useMatrixClient();
const supported = useMutualRoomsSupport();
const state = useMutualRooms(userId);
const { navigateRoom, navigateSpace } = useRoomNavigate();
const closeProfile = useCloseUserRoomProfile();
// Read m.direct directly here, NOT useDirectRooms — the latter
// returns universal-Direct after P3c, which would collapse the
// mutual-DMs vs mutual-rooms split that this menu surfaces.
const mDirects = useAtomValue(mDirectAtom);
const useAuthentication = useMediaAuthentication();
const allJoined = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allJoined);
const [cords, setCords] = useState<RectCords>();
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const close = () => setCords(undefined);
const mutual: MutualRoomsData = useMemo(() => {
const data: MutualRoomsData = { rooms: [], spaces: [], directs: [] };
if (state.status === AsyncStatus.Success) {
// Copy the array before sorting — `Array.prototype.sort`
// mutates in place, and `state.data` is the same reference
// returned by `useMutualRooms` to every caller. Sorting the
// backing store would silently reorder it for any other
// consumer of the same response.
[...state.data]
.sort(factoryRoomIdByAtoZ(mx))
.map(getRoom)
.filter((r): r is Room => !!r)
.forEach((room) => {
if (room.isSpaceRoom()) data.spaces.push(room);
else if (mDirects.has(room.roomId)) data.directs.push(room);
else data.rooms.push(room);
});
}
return data;
}, [state, getRoom, mDirects, mx]);
if (!supported || state.status === AsyncStatus.Error) return null;
const total = state.status === AsyncStatus.Success ? state.data.length : 0;
const loading = state.status === AsyncStatus.Loading;
const renderItem = (room: Room) => {
const { roomId } = room;
const dm = mDirects.has(roomId);
return (
<MenuItem
key={roomId}
variant="Surface"
fill="None"
size="300"
radii="300"
style={{ paddingLeft: config.space.S100 }}
onClick={() => {
if (room.isSpaceRoom()) navigateSpace(roomId);
else navigateRoom(roomId);
closeProfile();
}}
before={
<Avatar size="200" radii={dm ? '400' : '300'}>
{dm || room.isSpaceRoom() ? (
<RoomAvatar
roomId={room.roomId}
src={
dm
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: getRoomAvatarUrl(mx, room, 96, useAuthentication)
}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
) : (
<RoomIcon size="100" joinRule={room.getJoinRule()} roomType={room.getType()} />
)}
</Avatar>
}
>
<Text size="B300" truncate>
{room.name}
</Text>
</MenuItem>
);
};
const value = loading ? (
<span className={css.InfoRowMixed}>
<Spinner size="50" />
<span>{t('User.row_mutual_loading')}</span>
</span>
) : (
t('User.row_mutual_count', { count: total })
);
return (
<PopOut
anchor={cords}
position="Bottom"
align="End"
offset={4}
content={
state.status === AsyncStatus.Success ? (
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: close,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
}}
>
<Menu style={{ display: 'flex', maxWidth: toRem(220), maxHeight: '80vh' }}>
<Box grow="Yes">
<Scroll size="300" hideTrack>
<Box
direction="Column"
gap="400"
style={{ padding: config.space.S200, paddingRight: 0 }}
>
{mutual.spaces.length > 0 && (
<Box direction="Column" gap="100">
<Text style={{ paddingLeft: config.space.S100 }} size="L400">
{t('User.row_mutual_spaces')}
</Text>
{mutual.spaces.map(renderItem)}
</Box>
)}
{mutual.rooms.length > 0 && (
<Box direction="Column" gap="100">
<Text style={{ paddingLeft: config.space.S100 }} size="L400">
{t('User.row_mutual_rooms')}
</Text>
{mutual.rooms.map(renderItem)}
</Box>
)}
{mutual.directs.length > 0 && (
<Box direction="Column" gap="100">
<Text style={{ paddingLeft: config.space.S100 }} size="L400">
{t('User.row_mutual_dms')}
</Text>
{mutual.directs.map(renderItem)}
</Box>
)}
</Box>
</Scroll>
</Box>
</Menu>
</FocusTrap>
) : null
}
>
<InfoRow
label={t('User.row_mutual')}
value={value}
trailing={
<button
type="button"
className={css.InfoRowAction}
onClick={open}
aria-pressed={!!cords}
disabled={loading || total === 0}
aria-label={t('User.row_mutual')}
>
<Icon size="50" src={Icons.ChevronBottom} />
</button>
}
/>
</PopOut>
);
}

View file

@ -1,32 +1,53 @@
import { Box, Button, config, Icon, Icons, Text } from 'folds';
// Dawn-evolved user profile card. Replaces the legacy chip-strip
// layout (large Message + Call buttons → horizontal chip strip)
// with a denser, IDE-style information card:
//
// • Hero — large gradient avatar, display name, monospaced
// handle, presence + last-seen, optional e2ee badge.
// • Info rows — Fleet-style attribute table: id / server / role /
// mutual chats. Each row has a trailing affordance
// (popout menu) when there's something useful to do.
// • Actions — single «Написать» chip in group rooms only
// (1:1 → user is already in the conversation),
// plus the three-dot moderation menu (`OptionsChip`).
// • Alerts — banner cards for ignored / banned / kicked /
// invited states. Same matrix-js-sdk source as
// before, just rendered after the redesigned
// surface.
import React from 'react';
import { Box } from 'folds';
import { useNavigate } from 'react-router-dom';
import { UserHero, UserHeroName } from './UserHero';
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
import { UserHero } from './UserHero';
import { IdRow, MutualRoomsRow, RoleRow, ServerRow } from './UserInfoRows';
import { mxcUrlToHttp } from '../../utils/matrix';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { usePowerLevels } from '../../hooks/usePowerLevels';
import { useRoom } from '../../hooks/useRoom';
import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
import { useUserPresence } from '../../hooks/useUserPresence';
import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips';
import { useStateEvent } from '../../hooks/useStateEvent';
import { IgnoredUserAlert, UserActionsMenu } from './UserChips';
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { PowerChip } from './PowerChip';
import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './UserModeration';
import { UserBanAlert, UserInviteAlert, UserKickAlert } from './UserModeration';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { useMembership } from '../../hooks/useMembership';
import { Membership } from '../../../types/matrix/room';
import { Membership, StateEvent } from '../../../types/matrix/room';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
import { CreatorChip } from './CreatorChip';
import { useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
import { DirectCreateSearchParams } from '../../pages/paths';
import * as css from './styles.css';
type UserRoomProfileProps = {
userId: string;
onAvatarClick?: () => void;
};
export function UserRoomProfile({ userId }: UserRoomProfileProps) {
export function UserRoomProfile({ userId, onAvatarClick }: UserRoomProfileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const navigate = useNavigate();
@ -40,6 +61,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
const permissions = useRoomPermissions(creators, powerLevels);
const { hasMorePower } = useMemberPowerCompare(creators, powerLevels);
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
const myUserId = mx.getSafeUserId();
const creator = creators.has(userId);
@ -52,91 +74,107 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
const member = room.getMember(userId);
const membership = useMembership(room, userId);
const server = getMxIdServer(userId);
const displayName = getMemberDisplayName(room, userId);
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
// Pass the raw SDK presence through. `UserHero` already guards on
// `lastActiveTs > 0` before formatting the last-seen line, so the
// «1970» misread is suppressed there. Filtering at this layer
// would also drop the *online* signal for users whose
// `lastActiveTs` happens to be 0 on a fresh sync — they'd lose
// their green dot and «онлайн» tag for no good reason.
const presence = useUserPresence(userId);
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
const encrypted = !!encryptionEvent;
const isOneOnOne = useIsOneOnOne();
const showStartDmChip = !isOneOnOne && userId !== myUserId;
// Power-tag label resolution: the room's power-level tags map a
// numeric pl to a localised role name (e.g. «admin», «moderator»,
// «member»). The hook handles the creator → «Создатель» override
// internally via `useRoomCreatorsTag`, so we just take the tag's
// `.name` and display it. The raw `pl N` number is intentionally
// not surfaced — it's Matrix protocol jargon that's redundant
// with the role label for end users.
const tag = getMemberPowerTag(userId);
const roleLabel = tag.name;
const handleMessage = () => {
closeUserRoomProfile();
const directSearchParam: DirectCreateSearchParams = {
userId,
};
navigate(withSearchParam(getDirectCreatePath(), directSearchParam));
const params: DirectCreateSearchParams = { userId };
navigate(withSearchParam(getDirectCreatePath(), params));
};
return (
<Box direction="Column">
<Box direction="Column" gap="500" className={css.CardRoot}>
{/* Top-right action menu absolute-positioned so it floats
above the hero without claiming a layout row. Hosts every
imperative action (Write / Block / Mod). On mobile the
rail is short enough that a bottom actions row scrolled
off-screen; floating it at a fixed corner of the card
fixes that. Suppressed on self-profile since none of the
items are meaningful there. */}
{userId !== myUserId && (
<div className={css.CardActionsAnchor}>
<UserActionsMenu
userId={userId}
showStartDm={showStartDmChip}
onStartDm={handleMessage}
canKick={canKickUser && membership === Membership.Join}
canBan={canBanUser && membership !== Membership.Ban}
canInvite={canInvite && membership === Membership.Leave}
/>
</div>
)}
<UserHero
userId={userId}
displayName={displayName}
avatarUrl={avatarUrl}
presence={presence && presence.lastActiveTs !== 0 ? presence : undefined}
presence={presence}
encrypted={encrypted}
onAvatarClick={onAvatarClick}
/>
<Box direction="Column" gap="500" style={{ padding: config.space.S400 }}>
<Box direction="Column" gap="400">
<Box gap="400" alignItems="Start">
<UserHeroName displayName={displayName} userId={userId} />
{userId !== myUserId && (
<Box shrink="No">
<Button
size="300"
variant="Primary"
fill="Solid"
radii="300"
before={<Icon size="50" src={Icons.Message} filled />}
onClick={handleMessage}
>
<Text size="B300">Message</Text>
</Button>
</Box>
)}
</Box>
<Box alignItems="Center" gap="200" wrap="Wrap">
{server && <ServerChip server={server} />}
<ShareChip userId={userId} />
{creator ? <CreatorChip /> : <PowerChip userId={userId} />}
{userId !== myUserId && <MutualRoomsChip userId={userId} />}
{userId !== myUserId && <OptionsChip userId={userId} />}
</Box>
</Box>
{ignored && <IgnoredUserAlert />}
{member && membership === Membership.Ban && (
<UserBanAlert
userId={userId}
reason={member.events.member?.getContent().reason}
canUnban={canUnban}
bannedBy={member.events.member?.getSender()}
ts={member.events.member?.getTs()}
/>
)}
{member &&
membership === Membership.Leave &&
member.events.member &&
member.events.member.getSender() !== userId && (
<UserKickAlert
reason={member.events.member?.getContent().reason}
kickedBy={member.events.member?.getSender()}
ts={member.events.member?.getTs()}
/>
)}
{member && membership === Membership.Invite && (
<UserInviteAlert
userId={userId}
reason={member.events.member?.getContent().reason}
canKick={canKickUser}
invitedBy={member.events.member?.getSender()}
ts={member.events.member?.getTs()}
/>
)}
<UserModeration
<div className={css.InfoSection}>
<IdRow userId={userId} />
<ServerRow userId={userId} />
{userId !== myUserId && <RoleRow roleLabel={roleLabel} emphasis={creator} />}
{userId !== myUserId && <MutualRoomsRow userId={userId} />}
</div>
{ignored && <IgnoredUserAlert />}
{member && membership === Membership.Ban && (
<UserBanAlert
userId={userId}
canInvite={canInvite && membership === Membership.Leave}
canKick={canKickUser && membership === Membership.Join}
canBan={canBanUser && membership !== Membership.Ban}
reason={member.events.member?.getContent().reason}
canUnban={canUnban}
bannedBy={member.events.member?.getSender()}
ts={member.events.member?.getTs()}
/>
</Box>
)}
{member &&
membership === Membership.Leave &&
member.events.member &&
member.events.member.getSender() !== userId && (
<UserKickAlert
reason={member.events.member?.getContent().reason}
kickedBy={member.events.member?.getSender()}
ts={member.events.member?.getTs()}
/>
)}
{member && membership === Membership.Invite && (
<UserInviteAlert
userId={userId}
reason={member.events.member?.getContent().reason}
canKick={canKickUser}
invitedBy={member.events.member?.getSender()}
ts={member.events.member?.getTs()}
/>
)}
</Box>
);
}

View file

@ -1,61 +1,228 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
export const UserHeader = style({
// ── Card root ────────────────────────────────────────────────────
//
// Establishes the positioning context for the floating top-right
// 3-dot actions trigger. The card content (hero / info / alerts)
// sits in normal flow; the trigger is absolute-positioned over it.
export const CardRoot = style({
position: 'relative',
});
// 3-dot button anchor — top-right of the card. `right: 0` lines it
// up flush with the card content's right edge (the panel padding
// is supplied by the host, so we don't add inset here).
export const CardActionsAnchor = style({
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 1,
padding: config.space.S200,
});
export const UserHero = style({
position: 'relative',
});
// ── Hero ─────────────────────────────────────────────────────────
export const UserHeroCoverContainer = style({
height: toRem(96),
overflow: 'hidden',
});
export const UserHeroCover = style({
height: '100%',
export const HeroRoot = style({
width: '100%',
objectFit: 'cover',
filter: 'blur(16px)',
transform: 'scale(2)',
paddingTop: toRem(8),
});
export const UserHeroAvatarContainer = style({
// 96px round avatar wrapper. Uses `position: relative` so the
// online-dot can sit inset at bottom-right with a ring matching the
// surrounding background. Folds' `Avatar` is overridden globally to
// be circular, so we just size the wrapper and let the inner avatar
// fill it.
export const HeroAvatar = style({
position: 'relative',
height: toRem(29),
});
export const UserAvatarContainer = style({
position: 'absolute',
left: config.space.S400,
top: 0,
transform: 'translateY(-50%)',
backgroundColor: color.Surface.Container,
// Surface plate that punches through the cover banner — sits one DOM level
// above the folds `<Avatar>`, so the global circle override on `.UserAvatar`
// (commit ead1290) does not reach it. Round it explicitly to match.
display: 'inline-flex',
width: toRem(96),
height: toRem(96),
borderRadius: '50%',
flexShrink: 0,
// Hairline ring sits on top of the gradient so we get the same
// «inset border» rhythm as the chat-list rows in the design bundle.
boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Background.Container}`,
});
export const UserHeroAvatar = style({
// boxShadow rather than outline — `outline` ignores `border-radius`, so it
// would draw a square ring around the circular avatar (the global circle
// override lives in `user-avatar/UserAvatar.css.ts` since commit ead1290).
boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Surface.Container}`,
// Linear gradient is set via inline style (so `colorMXID` can drive
// the base hue). This rule just makes the fallback span fill the
// avatar circle and centre its initial.
export const HeroAvatarFallback = style({
width: '100%',
height: '100%',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontWeight: 600,
fontSize: toRem(36),
letterSpacing: '0.5px',
textTransform: 'uppercase',
});
// Online indicator dot — green pill inset bottom-right of the
// avatar with a ring matching the parent background so it punches
// out against the avatar circle, mirroring the design bundle's
// `.online` glyph.
export const HeroOnlineDot = style({
position: 'absolute',
right: toRem(2),
bottom: toRem(2),
width: toRem(16),
height: toRem(16),
borderRadius: '50%',
backgroundColor: color.Success.Main,
boxShadow: `0 0 0 ${toRem(3)} ${color.Background.Container}`,
});
// Reset native button chrome for the tap-to-zoom avatar wrapper.
export const HeroAvatarButton = style({
display: 'inline-flex',
background: 'transparent',
border: 'none',
padding: 0,
margin: 0,
cursor: 'pointer',
});
export const HeroIdentity = style({
width: '100%',
});
export const HeroPresence = style({
width: '100%',
justifyContent: 'center',
});
// Coloured «онлайн» tag — mirrors the chat header's `OnlineTag`
// style so the two surfaces use identical typography for the same
// signal. Dropping the green dot prefix here is intentional: the
// avatar already carries the green online indicator, so a second
// dot would just be visual noise.
export const HeroOnlineTag = style({
color: color.Success.Main,
flexShrink: 0,
whiteSpace: 'nowrap',
});
export const HeroBullet = style({
color: color.Surface.ContainerLine,
});
export const HeroE2ee = style({
display: 'inline-flex',
alignItems: 'center',
gap: toRem(4),
color: color.Success.Main,
});
// Quiet «last activity» dot prefix used only on offline / idle
// presence labels. Online state has no dot here — the avatar's own
// green indicator serves that role.
export const PresenceDot = style({
width: toRem(8),
height: toRem(8),
borderRadius: '50%',
backgroundColor: color.Surface.ContainerLine,
flexShrink: 0,
});
// ── Info section / rows (Fleet-style attribute table) ────────────
export const InfoSection = style({
display: 'flex',
flexDirection: 'column',
gap: toRem(2),
paddingTop: toRem(8),
paddingBottom: toRem(8),
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
});
export const InfoRow = style({
display: 'flex',
alignItems: 'center',
gap: toRem(12),
minHeight: toRem(32),
padding: `${toRem(4)} ${toRem(2)}`,
});
// Fixed-width monospace label column. The `letter-spacing` and
// uppercase mirror Fleet's panel-attribute look.
export const InfoRowLabel = style({
flexShrink: 0,
width: toRem(72),
fontFamily: 'ui-monospace, "JetBrains Mono", monospace',
fontSize: toRem(11),
letterSpacing: '0.06em',
textTransform: 'uppercase',
color: color.Surface.OnContainer,
opacity: 0.55,
});
export const InfoRowValue = style({
flex: 1,
minWidth: 0,
fontSize: toRem(13),
color: color.Surface.OnContainer,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
});
// Right-side affordance — copy / open / chevron-menu trigger. Looks
// like a tiny ghost icon button: transparent, hover-tints in the
// «hover surface» token, never grows past the row's intrinsic
// height.
export const InfoRowTrailing = style({
flexShrink: 0,
display: 'inline-flex',
alignItems: 'center',
});
export const InfoRowAction = style({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: toRem(24),
height: toRem(24),
borderRadius: toRem(6),
background: 'transparent',
border: 'none',
color: color.Surface.OnContainer,
cursor: 'pointer',
opacity: 0.7,
selectors: {
'button&': {
cursor: 'pointer',
'&:hover': {
opacity: 1,
background: color.SurfaceVariant.Container,
},
'&:disabled': {
opacity: 0.3,
cursor: 'default',
},
'&[aria-pressed="true"]': {
opacity: 1,
background: color.SurfaceVariant.Container,
},
},
});
export const UserHeroAvatarImg = style({
selectors: {
[`button${UserHeroAvatar}:hover &`]: {
filter: 'brightness(0.5)',
},
},
// Variants used inside `InfoRowValue` — accent for creators (star
// badge), mixed (label + faint mono pl number side by side), mono
// (just the pl-N tag).
export const InfoRowAccent = style({
display: 'inline-flex',
alignItems: 'center',
gap: toRem(6),
color: color.Warning.Main,
fontWeight: 600,
});
export const InfoRowMixed = style({
display: 'inline-flex',
alignItems: 'center',
gap: toRem(8),
});

View file

@ -4,7 +4,7 @@ import { color, config, toRem } from 'folds';
// === Incoming-call row + active-call pill ===
//
// Both live inside the bottom horseshoe rail owned by
// `CallSurfaceContainer`. The rail paints the rounded shell — the rows
// `HorseshoeContainer`. 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.
@ -53,7 +53,7 @@ export const CallIdentityButton = style({
// 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
// `HorseshoeContainer.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}`;

View file

@ -18,6 +18,8 @@ import { CallView } from '../call/CallView';
import { RoomViewHeader } from './RoomViewHeader';
import { callChatAtom } from '../../state/callEmbed';
import { CallChatView } from './CallChatView';
import { RoomViewProfilePanel } from './RoomViewProfilePanel';
import { RoomViewProfileSidePanel } from './RoomViewProfileSidePanel';
type RoomProps = {
renderRoomView?: (props: { eventId?: string }) => React.ReactNode;
@ -54,25 +56,34 @@ export function Room({ renderRoomView }: RoomProps) {
);
const callView = room.isCallRoom();
const isMobile = screenSize === ScreenSize.Mobile;
return (
<PowerLevelsContextProvider value={powerLevels}>
<Box grow="Yes">
{callView && (screenSize === ScreenSize.Desktop || !chat) && (
<Box grow="Yes" direction="Column">
<RoomViewHeader callView />
<Box grow="Yes">
<CallView />
</Box>
<RoomViewProfilePanel header={<RoomViewHeader callView />}>
<Box grow="Yes">
<CallView />
</Box>
</RoomViewProfilePanel>
</Box>
)}
{!callView && (
<Box grow="Yes" direction="Column">
<RoomViewHeader />
<Box grow="Yes">{renderRoomView?.({ eventId }) ?? <RoomView eventId={eventId} />}</Box>
<RoomViewProfilePanel header={<RoomViewHeader />}>
<Box grow="Yes">{renderRoomView?.({ eventId }) ?? <RoomView eventId={eventId} />}</Box>
</RoomViewProfilePanel>
</Box>
)}
{/* Tablet / Desktop: profile renders as a third pane to the
right of the chat. Mobile uses the top horseshoe inside
`RoomViewProfilePanel`, so we don't mount the side pane
there. */}
{!isMobile && <RoomViewProfileSidePanel />}
{callView && chat && (
<>
{screenSize === ScreenSize.Desktop && (

View file

@ -38,7 +38,7 @@ import { useStateEvent } from '../../hooks/useStateEvent';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
import { useIsBridgedRoom } from '../../hooks/useIsBridgedRoom';
import { useDmCallVisible } from '../../hooks/useDmCallVisible';
import { useRoomMemberCount } from '../../hooks/useRoomMemberCount';
import { Presence, useUserPresence } from '../../hooks/useUserPresence';
import { useRoomAvatar, useRoomName } from '../../hooks/useRoomMeta';
@ -61,7 +61,6 @@ import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { settingsAtom } from '../../state/settings';
import { mDirectAtom } from '../../state/mDirectList';
import { callEmbedAtom } from '../../state/callEmbed';
import { searchModalAtom } from '../../state/searchModal';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
@ -444,22 +443,21 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
const isOneOnOne = useIsOneOnOne();
const isMobile = screenSize === ScreenSize.Mobile;
// Three-gate call surface: only strictly 1:1 + still m.direct-tagged + not
// bridged. Call lifecycle hooks (`useIncomingRtcNotifications`,
// `useCallerAutoHangup`) still gate ring delivery on `m.direct`, so the
// visibility guard mirrors them. MSC2346 `m.bridge` exclusion keeps the
// button hidden for mautrix-telegram puppet rooms even if the bridge config
// ever writes `m.direct`. See dm_1x1_redesign.md §6.8b.
//
// `useIsBridgedRoom` is reactive — a late-arriving `m.bridge` state event
// flips the gate without waiting for an unrelated rerender, which a static
// `isBridgedRoom(room)` call would miss.
const mDirects = useAtomValue(mDirectAtom);
const isBridged = useIsBridgedRoom(room);
// Call surface visibility is shared with the `Call` button inside
// `UserRoomProfile` via `useDmCallVisible` — the hook captures all
// four gates (1:1 + m.direct + !bridged + !bot-control) so the two
// call-entry surfaces can't drift apart. Header-specific extras
// here: hide in callView (we're already showing CallView). The
// hook itself documents the call-lifecycle reasoning.
const dmCallVisible = useDmCallVisible(room);
const callButtonVisible = !callView && dmCallVisible;
// `isBotControlRoom` is also passed down to `RoomMenu` to gate the
// bot-config menu item — kept here as its own derivation because
// the hook above is purpose-built for the call gate, not for
// generic «is this a bot DM?» queries.
const bots = useBotPresets();
const isBotControlRoom = isCatalogBotControlRoom(mx, room, bots);
const callButtonVisible =
!callView && isOneOnOne && mDirects.has(room.roomId) && !isBridged && !isBotControlRoom;
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
const encryptedRoom = !!encryptionEvent;

View file

@ -0,0 +1,155 @@
import { style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';
// Shared with the call-horseshoe surface so both ends of the chat
// (top profile rail, bottom call rail) read with identical geometry.
export const HORSESHOE_RADIUS_PX = 24;
export const HORSESHOE_GAP_PX = 8;
// Outer container — establishes the positioning context for the
// absolutely-positioned panel and the under-panel chat column.
// `overflow: hidden` clips the panel when it slides above the
// visible area (translateY(-railHeight)) so it disappears cleanly,
// and clips the chat's animated rounded top corners against the
// container's own edges.
export const container = style({
position: 'relative',
display: 'flex',
flex: 1,
flexDirection: 'column',
minWidth: 0,
minHeight: 0,
overflow: 'hidden',
});
// === Panel === Mobile-only top horseshoe.
//
// Full-width sheet with both bottom corners rounded, mirroring the
// bottom call rail's rounded top corners. `position: absolute` lets
// it slide in from above (translateY) without disturbing the chat
// column's flex layout below.
export const panel = style({
position: 'absolute',
top: 0,
left: 0,
zIndex: 2,
width: '100%',
// The bottom-corner radius is interpolated inline so the panel
// bottom stays flush against the chat-column top during drag (both
// square-cornered, zero gap), and only blossoms into the rounded
// horseshoe shape once the gesture commits and CSS transitions
// kick in alongside the gap.
overflow: 'hidden',
backgroundColor: color.Background.Container,
willChange: 'transform',
// Allow vertical pan inside the panel (Scroll content); the
// explicit drag-up handler will preventDefault on close gestures.
touchAction: 'pan-y',
});
export const panelInner = style({
display: 'flex',
flexDirection: 'column',
height: '100%',
});
// Functional overflow without a visible scrollbar. The card's
// content (hero + ~4 info rows + chip row) almost always fits
// inside the rail height, but moderation alerts can push it past
// — we keep the panel scrollable for that case while suppressing
// the scrollbar chrome (it's not a useful affordance on a 42vh
// rail and the user explicitly asked us to drop it).
export const panelScroll = style({
flex: 1,
minHeight: 0,
overflow: 'auto',
scrollbarWidth: 'none',
selectors: {
'&::-webkit-scrollbar': {
display: 'none',
},
},
});
// Bottom drag handle — visible cue that the panel is draggable. The
// whole panel surface is also drag-sensitive (see TSX), but the
// handle stays as an obvious affordance for users who don't try the
// surface gesture.
export const panelHandle = style({
flexShrink: 0,
height: toRem(20),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'grab',
touchAction: 'none',
selectors: {
'&:active': { cursor: 'grabbing' },
},
});
export const panelHandleBar = style({
width: toRem(36),
height: toRem(4),
borderRadius: toRem(4),
backgroundColor: color.Surface.ContainerLine,
});
// Avatar full-view mode — fills the panel with the user's avatar at
// full size when the user taps the avatar inside the open panel.
// Click-to-revert switches back to the regular profile content.
export const avatarFullView = style({
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
position: 'relative',
});
export const avatarFullImage = style({
width: '100%',
height: '100%',
objectFit: 'cover',
});
export const avatarFullFallback = style({
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: toRem(96),
fontWeight: 600,
color: color.Surface.Container,
textTransform: 'uppercase',
});
// === Under-panel chat column ===
//
// `margin-top` is interpolated inline (panel height + 8px gap) so its
// rounded top edge sits right under the panel's rounded bottom. The
// rounded corners themselves are inline-styled on this same element.
export const chatColumn = style({
display: 'flex',
flex: 1,
flexDirection: 'column',
minWidth: 0,
minHeight: 0,
willChange: 'margin-top',
});
// CSS-grid `1fr → 0fr` is animatable, unlike `height: auto`. The
// outer wrap holds the row track; the inner div is the actual
// scroll/clip surface.
export const headerWrap = style({
display: 'grid',
flexShrink: 0,
willChange: 'grid-template-rows',
});
export const headerWrapInner = style({
minHeight: 0,
overflow: 'hidden',
});

View file

@ -0,0 +1,410 @@
// Wrapper around the room's chat column that adds the user-profile
// surface on top.
//
// Mobile (≤ 750): the top «horseshoe» rail. The chat header collapses
// to height 0 while the rail is open so the visible gap between the
// rail's rounded bottom and the chat's rounded top is exactly the
// shared `HORSESHOE_GAP` (8px), matching the bottom call horseshoe.
//
// Tablet + Desktop (> 750): no top horseshoe. The wrapper is a thin
// passthrough; the profile renders as a right-side pane via
// `RoomViewProfileSidePanel`, which `Room.tsx` mounts as a sibling
// of the chat column.
import React, { ReactNode, useEffect, useRef, useState } from 'react';
import { useAtomValue } from 'jotai';
import { config } from 'folds';
import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next';
import { userRoomProfileAtom } from '../../state/userRoomProfile';
import {
useCloseUserRoomProfile,
useOpenUserRoomProfile,
} from '../../state/hooks/userRoomProfile';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { getMemberAvatarMxc } from '../../utils/room';
import { getMxIdLocalPart, guessDmRoomUserId, mxcUrlToHttp } from '../../utils/matrix';
import { UserRoomProfile } from '../../components/user-profile/UserRoomProfile';
import { stopPropagation } from '../../utils/keyboard';
import colorMXID from '../../../util/colorMXID';
import * as css from './RoomViewProfilePanel.css';
// Card height as a fraction of the viewport.
const RAIL_HEIGHT_FRACTION = 0.42;
// Past this many pixels of drag the gesture commits (open or close).
const COMMIT_THRESHOLD_PX = 80;
const ANIMATION_MS = 250;
type RoomViewProfilePanelProps = {
header: ReactNode;
children: ReactNode;
};
type DragState = {
source: 'header' | 'panel';
startY: number;
deltaY: number;
};
// Mobile-only top horseshoe. Slides down from above on drag-down on
// the chat header (1:1 DM only); slides up to close on drag-up
// anywhere on the panel. While the rail is open the chat header
// collapses to height 0 so there's no transparent ~50px void below
// the rail — the perceived gap matches the call horseshoe's 8px gap.
function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps) {
const { t } = useTranslation();
const profileState = useAtomValue(userRoomProfileAtom);
const close = useCloseUserRoomProfile();
const openProfile = useOpenUserRoomProfile();
const room = useRoom();
const space = useSpaceOptionally();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const isOneOnOne = useIsOneOnOne();
const myUserId = mx.getSafeUserId();
const peerCandidate = isOneOnOne ? guessDmRoomUserId(room, myUserId) : undefined;
const headerDragPeer =
peerCandidate && peerCandidate !== myUserId ? peerCandidate : undefined;
const headerDragEnabled = !!headerDragPeer;
const [drag, setDrag] = useState<DragState | null>(null);
const [avatarMode, setAvatarMode] = useState(false);
const headerRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
// Close profile when the room changes — atom is global state and
// would otherwise carry the previous room's userId into this room.
useEffect(() => () => close(), [room.roomId, close]);
// Reset avatar-zoom mode whenever the rendered user changes.
useEffect(() => {
setAvatarMode(false);
}, [profileState?.userId]);
const [railHeightPx, setRailHeightPx] = useState(() => {
if (typeof window === 'undefined') return 400;
return Math.round(window.innerHeight * RAIL_HEIGHT_FRACTION);
});
useEffect(() => {
const onResize = () => {
setRailHeightPx(Math.round(window.innerHeight * RAIL_HEIGHT_FRACTION));
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
const open = !!profileState;
const baseExpanded = open ? railHeightPx : 0;
const expandedPx = drag
? Math.max(0, Math.min(railHeightPx, baseExpanded + drag.deltaY))
: baseExpanded;
const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0;
const isDragging = drag !== null;
const liveUserId =
profileState?.userId ?? (drag?.source === 'header' ? headerDragPeer : undefined);
const lastUserIdRef = useRef<string | undefined>(undefined);
if (liveUserId) lastUserIdRef.current = liveUserId;
const renderUserId = liveUserId ?? lastUserIdRef.current;
const renderUserAvatarMxc = renderUserId
? getMemberAvatarMxc(room, renderUserId)
: undefined;
const renderUserAvatarUrl =
(renderUserAvatarMxc &&
mxcUrlToHttp(mx, renderUserAvatarMxc, useAuthentication, 720, 720, 'scale')) ??
undefined;
const dragRef = useRef<DragState | null>(null);
dragRef.current = drag;
const profileStateRef = useRef(profileState);
profileStateRef.current = profileState;
const openProfileRef = useRef(openProfile);
openProfileRef.current = openProfile;
const closeRef = useRef(close);
closeRef.current = close;
const roomIdRef = useRef(room.roomId);
roomIdRef.current = room.roomId;
const spaceIdRef = useRef(space?.roomId);
spaceIdRef.current = space?.roomId;
useEffect(() => {
const headerEl = headerRef.current;
const panelEl = panelRef.current;
const dragOpenCords = (): { x: number; y: number; width: number; height: number } => {
const rect = headerRef.current?.getBoundingClientRect();
if (rect) return { x: rect.left, y: rect.top, width: rect.width, height: rect.height };
return { x: 0, y: 0, width: 0, height: 0 };
};
const onHeaderTouchStart = (e: TouchEvent) => {
if (profileStateRef.current) return;
if (!headerDragEnabled) return;
const touch = e.touches[0];
setDrag({ source: 'header', startY: touch.clientY, deltaY: 0 });
};
const onPanelTouchStart = (e: TouchEvent) => {
if (!profileStateRef.current) return;
const touch = e.touches[0];
setDrag({ source: 'panel', startY: touch.clientY, deltaY: 0 });
};
const onTouchMove = (e: TouchEvent) => {
const d = dragRef.current;
if (!d) return;
const touch = e.touches[0];
const rawDelta = touch.clientY - d.startY;
let nextDelta = rawDelta;
if (d.source === 'header') {
nextDelta = Math.max(0, rawDelta);
} else {
if (rawDelta > 0) return;
nextDelta = rawDelta;
}
if (e.cancelable) e.preventDefault();
setDrag({ ...d, deltaY: nextDelta });
};
const onTouchEnd = () => {
const d = dragRef.current;
if (!d) return;
if (d.source === 'header' && d.deltaY > COMMIT_THRESHOLD_PX) {
if (headerDragPeer) {
openProfileRef.current(
roomIdRef.current,
spaceIdRef.current,
headerDragPeer,
dragOpenCords()
);
}
} else if (d.source === 'panel' && -d.deltaY > COMMIT_THRESHOLD_PX) {
closeRef.current();
}
setDrag(null);
};
if (headerEl) {
headerEl.addEventListener('touchstart', onHeaderTouchStart, { passive: true });
headerEl.addEventListener('touchmove', onTouchMove, { passive: false });
headerEl.addEventListener('touchend', onTouchEnd, { passive: true });
headerEl.addEventListener('touchcancel', onTouchEnd, { passive: true });
}
if (panelEl) {
panelEl.addEventListener('touchstart', onPanelTouchStart, { passive: true });
panelEl.addEventListener('touchmove', onTouchMove, { passive: false });
panelEl.addEventListener('touchend', onTouchEnd, { passive: true });
panelEl.addEventListener('touchcancel', onTouchEnd, { passive: true });
}
return () => {
if (headerEl) {
headerEl.removeEventListener('touchstart', onHeaderTouchStart);
headerEl.removeEventListener('touchmove', onTouchMove);
headerEl.removeEventListener('touchend', onTouchEnd);
headerEl.removeEventListener('touchcancel', onTouchEnd);
}
if (panelEl) {
panelEl.removeEventListener('touchstart', onPanelTouchStart);
panelEl.removeEventListener('touchmove', onTouchMove);
panelEl.removeEventListener('touchend', onTouchEnd);
panelEl.removeEventListener('touchcancel', onTouchEnd);
}
};
}, [headerDragEnabled, headerDragPeer]);
const panelTransition = isDragging
? 'none'
: `transform ${ANIMATION_MS}ms ease, border-bottom-left-radius ${ANIMATION_MS}ms ease, border-bottom-right-radius ${ANIMATION_MS}ms ease`;
const chatColumnTransition = isDragging
? 'none'
: `margin-top ${ANIMATION_MS}ms ease, border-top-left-radius ${ANIMATION_MS}ms ease, border-top-right-radius ${ANIMATION_MS}ms ease`;
const containerStyle: React.CSSProperties = {
backgroundColor: expandedPx > 0 ? '#090909' : undefined,
};
// The «two horseshoes» visual (8px gap + 24px bottom/top radii)
// is gated by an emerge curve that stays at 0 for most of the
// open progress and only ramps to 1 in the last 15%. Two reasons:
//
// 1) Drag-down to open — the chat header is still visible under
// the descending rail. A linear gap would show a black `#090909`
// strip between rail and header («экстра геп между старой
// маленьким хедером чата и карточкой-подковой»). With the
// curve the rail sits flush against the (collapsing) header
// until the very end, and the gap blossoms when the header
// has fully retracted.
//
// 2) Drag-up to close — touchstart begins at expandedFraction=1,
// so the gap is already 8px. As expandedFraction dips even
// slightly the curve drops sharply, so the gap closes
// smoothly with the user's gesture instead of jolting to 0
// at touchstart.
const horseshoeEmerge = Math.max(0, (expandedFraction - 0.85) / 0.15);
const chatRadius = horseshoeEmerge * css.HORSESHOE_RADIUS_PX;
const chatGap = horseshoeEmerge * css.HORSESHOE_GAP_PX;
const panelBottomRadius = chatRadius;
return (
<div className={css.container} style={containerStyle}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
// Mobile: only swipe-up dismisses the rail. Click on chat /
// scroll on chat must NOT close it AND must remain
// interactive.
clickOutsideDeactivates: false,
allowOutsideClick: () => true,
escapeDeactivates: stopPropagation,
onDeactivate: () => {
if (profileStateRef.current) close();
},
checkCanFocusTrap: () => Promise.resolve(),
}}
// Don't toggle `active` on drag — flipping it to false makes
// focus-trap-react fire `onDeactivate` synthetically, so a
// plain panel tap (deltaY=0) closes the rail. Trap stays
// active for the entire open lifetime.
active={open}
>
<div
ref={panelRef}
className={css.panel}
style={{
height: `${railHeightPx}px`,
transform: `translateY(${expandedPx - railHeightPx}px)`,
borderBottomLeftRadius: panelBottomRadius
? `${panelBottomRadius}px`
: 0,
borderBottomRightRadius: panelBottomRadius
? `${panelBottomRadius}px`
: 0,
transition: panelTransition,
visibility: expandedPx > 0 ? 'visible' : 'hidden',
}}
>
{avatarMode ? (
<button
type="button"
className={css.avatarFullView}
onClick={() => setAvatarMode(false)}
aria-label={t('Room.collapse_avatar')}
>
{renderUserAvatarUrl ? (
<img
className={css.avatarFullImage}
src={renderUserAvatarUrl}
alt={renderUserId ?? ''}
draggable={false}
/>
) : (
<div
className={css.avatarFullFallback}
style={{ backgroundColor: colorMXID(renderUserId ?? '') }}
>
{(renderUserId && getMxIdLocalPart(renderUserId)?.[0]) ?? '?'}
</div>
)}
</button>
) : (
<div className={css.panelInner}>
{/* No visible scrollbar the card content (hero +
~4 info rows + a chip row) almost always fits, and
on the rare overflow case (lots of moderation
alerts) we keep functional scrolling via the
`panelScroll` class which sets `overflow: auto` +
`scrollbar-width: none` / `::-webkit-scrollbar`
hidden. */}
<div className={css.panelScroll}>
<div style={{ padding: config.space.S400 }}>
{renderUserId && (
<UserRoomProfile
userId={renderUserId}
onAvatarClick={() => setAvatarMode(true)}
/>
)}
</div>
</div>
<div className={css.panelHandle} aria-label={t('Room.drag_to_close')}>
<div className={css.panelHandleBar} />
</div>
</div>
)}
</div>
</FocusTrap>
<div
className={css.chatColumn}
style={{
// Push the chat column down by `panel + gap` so its rounded
// top edge sits right below the panel's rounded bottom.
// The panel is `position: absolute`, so without this margin
// the chat would sit behind the panel and the top radius
// would be invisible.
marginTop:
expandedPx > 0 ? `${expandedPx + chatGap}px` : 0,
borderTopLeftRadius: chatRadius ? `${chatRadius}px` : undefined,
borderTopRightRadius: chatRadius ? `${chatRadius}px` : undefined,
// Clip the chat content to the rounded top corners. Folds
// popouts use Portals → escape this clip; the timeline
// keeps its own scroll container.
overflow: chatRadius > 0 ? 'hidden' : undefined,
transition: chatColumnTransition,
// Suppress browser pull-to-refresh / scroll-chaining so a
// header-drag-down doesn't fire pull-to-refresh briefly
// before the touchmove handler intercepts.
overscrollBehaviorY: 'contain',
}}
>
<div
ref={headerRef}
className={css.headerWrap}
style={{
// Collapse the chat header to 0 height while the rail is
// open so the only visible chrome between the rail's
// rounded bottom and the chat's rounded top is the 8px
// gap. CSS-grid's `1fr → 0fr` is animatable, unlike
// `height: auto`, and avoids needing to measure the
// header's intrinsic height in JS.
gridTemplateRows: `${1 - expandedFraction}fr`,
transition: isDragging ? 'none' : `grid-template-rows ${ANIMATION_MS}ms ease`,
// While the rail is mostly open the (collapsed) header
// shouldn't intercept drag-back-to-open gestures —
// `pan-x` lets horizontal Android-back-edge gestures
// still work.
touchAction: headerDragEnabled ? 'pan-x' : undefined,
}}
>
<div className={css.headerWrapInner}>{header}</div>
</div>
{children}
</div>
</div>
);
}
// Top-level router. On non-mobile we pass through — the side-pane
// surface is rendered by `Room.tsx` as a sibling, so the wrapper just
// hands `header` and `children` back to the parent. The mobile branch
// owns its own state machine in a sub-component so we don't run
// drag/avatar hooks on desktop renders.
export function RoomViewProfilePanel({ header, children }: RoomViewProfilePanelProps) {
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
if (!isMobile) {
return (
<>
{header}
{children}
</>
);
}
return <MobileProfileHorseshoe header={header}>{children}</MobileProfileHorseshoe>;
}

View file

@ -0,0 +1,70 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
// Right-side profile pane sized like the members drawer family —
// wide enough for the identity card + chips without dominating the
// chat. Clamp keeps it readable on narrow desktops and prevents
// runaway width on ultra-wide displays.
export const panel = style({
flexShrink: 0,
width: `clamp(${toRem(300)}, 25%, ${toRem(380)})`,
display: 'flex',
flexDirection: 'column',
backgroundColor: color.Surface.Container,
borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
minHeight: 0,
});
// Match the chat header's left gutter (RoomViewHeaderDm uses S200
// override) so the title sits at the same x-offset as the chat
// header's avatar — keeps the two rows visually balanced.
export const header = style({
paddingLeft: config.space.S300,
});
// Functional overflow without a visible scrollbar. The pane's
// content (hero + info rows + actions) almost always fits, but
// moderation alerts can push past — we keep scrolling working for
// that case while hiding the scrollbar chrome that the user asked
// us to drop.
export const scrollWrap = style({
flex: 1,
minHeight: 0,
overflow: 'auto',
scrollbarWidth: 'none',
selectors: {
'&::-webkit-scrollbar': {
display: 'none',
},
},
});
export const avatarFullView = style({
flex: 1,
minHeight: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
background: 'transparent',
border: 'none',
padding: 0,
});
export const avatarFullImage = style({
width: '100%',
height: '100%',
objectFit: 'cover',
});
export const avatarFullFallback = style({
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: toRem(96),
fontWeight: 600,
color: color.Surface.Container,
textTransform: 'uppercase',
});

View file

@ -0,0 +1,135 @@
// Desktop / tablet right-side profile pane. Renders the same
// `UserRoomProfile` content the mobile top horseshoe shows, but as a
// flex sibling next to the chat column instead of a slide-down rail.
//
// Mounted in `Room.tsx` only when the screen size is not Mobile —
// the mobile branch keeps using the top horseshoe via
// `RoomViewProfilePanel`.
import React, { useEffect, useRef, useState } from 'react';
import { useAtomValue } from 'jotai';
import { Box, Icon, IconButton, Icons, Text, config } from 'folds';
import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next';
import { userRoomProfileAtom } from '../../state/userRoomProfile';
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useRoom } from '../../hooks/useRoom';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { getMemberAvatarMxc } from '../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { UserRoomProfile } from '../../components/user-profile/UserRoomProfile';
import { stopPropagation } from '../../utils/keyboard';
import colorMXID from '../../../util/colorMXID';
import { PageHeader } from '../../components/page';
import { ContainerColor } from '../../styles/ContainerColor.css';
import * as css from './RoomViewProfileSidePanel.css';
export function RoomViewProfileSidePanel() {
const { t } = useTranslation();
const profileState = useAtomValue(userRoomProfileAtom);
const close = useCloseUserRoomProfile();
const room = useRoom();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const open = !!profileState;
const [avatarMode, setAvatarMode] = useState(false);
// Close profile when the room changes — atom is global state and
// would otherwise carry the previous room's userId into this room
// where chrome would render against the wrong member / power
// levels.
useEffect(() => () => close(), [room.roomId, close]);
// Reset avatar-zoom mode whenever the rendered user changes.
useEffect(() => {
setAvatarMode(false);
}, [profileState?.userId]);
const profileStateRef = useRef(profileState);
profileStateRef.current = profileState;
const renderUserId = profileState?.userId;
const renderUserAvatarMxc = renderUserId
? getMemberAvatarMxc(room, renderUserId)
: undefined;
const renderUserAvatarUrl =
(renderUserAvatarMxc &&
mxcUrlToHttp(mx, renderUserAvatarMxc, useAuthentication, 720, 720, 'scale')) ??
undefined;
if (!open || !renderUserId) return null;
return (
<FocusTrap
focusTrapOptions={{
initialFocus: false,
// Click outside (on the chat) closes the pane on desktop —
// matches the «third screen» mental model where the side
// pane is a temporary overlay, not pinned chrome. `Esc` also
// closes via focus-trap.
clickOutsideDeactivates: true,
allowOutsideClick: () => true,
escapeDeactivates: stopPropagation,
onDeactivate: () => {
if (profileStateRef.current) close();
},
checkCanFocusTrap: () => Promise.resolve(),
}}
active={open}
>
<div className={css.panel}>
{/* Use the same `PageHeader` (folds `Header size="600"`) the
chat header uses, so the bottom rule lines up with the
adjacent `RoomViewHeaderDm` row to the pixel. */}
<PageHeader className={`${ContainerColor({ variant: 'Surface' })} ${css.header}`}>
<Box grow="Yes" alignItems="Center">
<Text size="H4" truncate>
{t('User.profile_title')}
</Text>
</Box>
<Box shrink="No" alignItems="Center">
<IconButton fill="None" onClick={close} aria-label={t('Room.close')}>
<Icon size="400" src={Icons.Cross} />
</IconButton>
</Box>
</PageHeader>
{avatarMode ? (
<button
type="button"
className={css.avatarFullView}
onClick={() => setAvatarMode(false)}
aria-label={t('Room.collapse_avatar')}
>
{renderUserAvatarUrl ? (
<img
className={css.avatarFullImage}
src={renderUserAvatarUrl}
alt={renderUserId}
draggable={false}
/>
) : (
<div
className={css.avatarFullFallback}
style={{ backgroundColor: colorMXID(renderUserId) }}
>
{getMxIdLocalPart(renderUserId)?.[0] ?? '?'}
</div>
)}
</button>
) : (
<div className={css.scrollWrap}>
<div style={{ padding: config.space.S400 }}>
<UserRoomProfile
userId={renderUserId}
onAvatarClick={() => setAvatarMode(true)}
/>
</div>
</div>
)}
</div>
</FocusTrap>
);
}

View file

@ -0,0 +1,41 @@
// Single source of truth for «can the user start a Vojo DM voice call
// in this room?». Both `RoomViewHeaderDm`'s phone-icon button and the
// `Call` action inside `UserRoomProfile` consult this hook so the two
// surfaces can't drift apart — round-7 review caught a bug where the
// profile button skipped the bridged-room and bot-control gates and
// would have offered to start a Matrix-RTC call inside a
// mautrix-telegram puppet DM.
//
// Four-gate predicate, mirroring the call-lifecycle hooks
// (`useIncomingRtcNotifications`, `useCallerAutoHangup`) so the
// visibility guard matches the actual deliverability:
// • member-count = 2 (true 1:1 — read via `useIsOneOnOne`'s context
// so we follow the same reactive source the rest of the app uses)
// • `m.direct`-tagged — ring delivery still gates on this
// • not a bridged room — MSC2346 `m.bridge` exclusion keeps the
// surface hidden in mautrix-telegram puppet DMs (no Matrix-RTC
// equivalent)
// • not a bot-control room — bot DMs aren't human-callable
//
// `useIsOneOnOne` is route-context-bound; this hook is therefore only
// meaningful for the room currently rendered by `Room.tsx`. Callers
// outside that scope must compute their own member-count signal.
import { Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from './useMatrixClient';
import { useIsOneOnOne } from './useRoom';
import { useIsBridgedRoom } from './useIsBridgedRoom';
import { useBotPresets } from '../features/bots/catalog';
import { isCatalogBotControlRoom } from '../features/bots/room';
import { mDirectAtom } from '../state/mDirectList';
export const useDmCallVisible = (room: Room): boolean => {
const mx = useMatrixClient();
const isOneOnOne = useIsOneOnOne();
const mDirects = useAtomValue(mDirectAtom);
const isBridged = useIsBridgedRoom(room);
const bots = useBotPresets();
const isBotControlRoom = isCatalogBotControlRoom(mx, room, bots);
return isOneOnOne && mDirects.has(room.roomId) && !isBridged && !isBotControlRoom;
};

View file

@ -5,7 +5,7 @@ import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
// Whether the active-call pill is *currently* drawing in the rail. The
// CallSurfaceContainer reuses this to decide whether the horseshoe shell
// HorseshoeContainer 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.

View file

@ -1,59 +0,0 @@
// Bottom-horseshoe call surface.
//
// Wraps `ClientLayout` plus the bottom call-rail (incoming-ring strips +
// active-call status pill) into a single flex column. While at least one
// ring is queued in `incomingCallsAtom` *or* an active call embed is
// mounted (`callEmbedAtom`), both halves get the «two horseshoes
// split-screen» treatment:
//
// • Top half (whole client UI) — rounded bottom corners, 8px margin
// pushing the rest of the app up.
// • Bottom half (call rail) — rounded top corners, single horseshoe
// containing every concurrent ring as a stacked row plus the
// active-call pill underneath.
//
// The void between paints `#090909` from the `surface` div. The
// «orbiting border» (a conic-gradient sweep around the rail's rim) only
// renders while a ring is *incoming* — it's the alarm cue. Once the
// user is in the call the orbit is replaced by the static rail.
//
// Lifting the whole UI (including the global SidebarNav) is intentional:
// the wrapper sits *above* `ClientLayout`, so the effect is
// route-agnostic.
import React, { ReactNode } from 'react';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { isRingingAtom } from '../state/incomingCalls';
import { IncomingCallStripRenderer } from './IncomingCallStripRenderer';
import { CallStatusRenderer, useCallStatusVisible } from './CallStatusRenderer';
import * as css from './CallSurfaceContainer.css';
type CallSurfaceContainerProps = {
children: ReactNode;
};
export function CallSurfaceContainer({ children }: CallSurfaceContainerProps) {
const ringing = useAtomValue(isRingingAtom);
// Gate the horseshoe on whether the rail will *actually* paint pixels.
// A naive `callEmbed !== undefined` check would also flip true while
// the pill is suppressed (mobile + viewing the call room + video) —
// that would leave a black 8px shelf with no visible rail underneath.
// Reusing the same visibility predicate as the renderer keeps shell +
// content in sync.
const pillVisible = useCallStatusVisible();
const callPresent = ringing || pillVisible;
return (
<div className={classNames(css.surface, callPresent && css.surfaceCallActive)}>
<div className={classNames(css.appShell, callPresent && css.appShellCallActive)}>
{children}
</div>
<div className={classNames(css.bottomRail, callPresent && css.bottomRailCallActive)}>
<IncomingCallStripRenderer />
<CallStatusRenderer />
{ringing && <div className={css.ringOrbit} aria-hidden />}
</div>
</div>
);
}

View file

@ -1,16 +1,17 @@
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.
// Color of the «void» between the app shell and the bottom call
// rail — fixed in design, not theme-driven. Painted only when the
// call rail is mounted, so the rest of the app keeps 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.
// Outer flex column hosting app shell + bottom call rail.
// `min-height: 0` is required for nested scroll containers inside
// ClientLayout to shrink correctly.
export const surface = style({
display: 'flex',
flexDirection: 'column',
@ -19,9 +20,15 @@ export const surface = style({
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 surfaceActive = style({
backgroundColor: SURFACE_GAP_COLOR,
});
// === App shell ===
//
// Wraps the whole client UI. Gets rounded BOTTOM corners + 8px
// margin pushing the rest of the app up while a call surface is
// active.
export const appShell = style({
display: 'flex',
flex: 1,
@ -29,44 +36,37 @@ export const appShell = style({
minHeight: 0,
});
export const surfaceCallActive = style({
backgroundColor: SURFACE_GAP_COLOR,
});
export const appShellCallActive = style({
export const appShellBottomRound = 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.
// === Bottom horseshoe (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({
export const bottomRailActive = 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.
// Orbit border on the bottom rail — 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
// angle is driven by `--vojo-orbit-angle`, registered via `@property`
// in `src/index.css`.
export const ringOrbit = style({
position: 'absolute',
inset: 0,

View file

@ -0,0 +1,50 @@
// Bottom-horseshoe call surface.
//
// Wraps `ClientLayout` and the bottom call-rail (incoming-ring strips
// + active-call status pill) into a single flex column. While at least
// one ring is queued or an active call's pill is visible, the
// container paints `#090909` in the gap and pulls the app shell up
// with rounded bottom corners.
//
// The TOP horseshoe (user profile sheet) used to live here too, but
// was hoisted into `features/room/RoomViewProfilePanel.tsx` so it
// only affects the room/chat column — on desktop it no longer
// blankets the sidebar / nav rails. This wrapper now owns the call
// surface only.
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 './HorseshoeContainer.css';
type HorseshoeContainerProps = {
children: ReactNode;
};
export function HorseshoeContainer({ children }: HorseshoeContainerProps) {
const ringing = useAtomValue(isRingingAtom);
// Gate the bottom 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.surfaceActive)}>
<div className={classNames(css.appShell, callPresent && css.appShellBottomRound)}>
{children}
</div>
<div className={classNames(css.bottomRail, callPresent && css.bottomRailActive)}>
<IncomingCallStripRenderer />
<CallStatusRenderer />
{ringing && <div className={css.ringOrbit} aria-hidden />}
</div>
</div>
);
}

View file

@ -66,7 +66,6 @@ import { AutoRestoreBackupOnVerification } from '../components/BackupRestore';
import { RoomSettingsRenderer } from '../features/room-settings';
import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences';
import { SpaceSettingsRenderer } from '../features/space-settings';
import { UserRoomProfileRenderer } from '../components/UserRoomProfileRenderer';
import { CreateRoomModalRenderer } from '../features/create-room';
import { Create } from './client/create';
import { CreateSpaceModalRenderer } from '../features/create-space';
@ -76,7 +75,7 @@ import { CallEmbedProvider } from '../components/CallEmbedProvider';
import { useIncomingRtcNotifications } from '../hooks/useIncomingRtcNotifications';
import { useCallerAutoHangup } from '../hooks/useCallerAutoHangup';
import { usePendingCallActionConsumer } from '../hooks/usePendingCallActionConsumer';
import { CallSurfaceContainer } from './CallSurfaceContainer';
import { HorseshoeContainer } from './HorseshoeContainer';
import { useAppUrlOpen } from '../hooks/useAppUrlOpen';
function IncomingCallsFeature() {
@ -167,7 +166,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<ClientBindAtoms>
<ClientNonUIFeatures>
<CallEmbedProvider>
<CallSurfaceContainer>
<HorseshoeContainer>
<ClientLayout
nav={
<MobileFriendlyClientNav>
@ -177,11 +176,10 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
>
<Outlet />
</ClientLayout>
</CallSurfaceContainer>
</HorseshoeContainer>
<IncomingCallsFeature />
</CallEmbedProvider>
<SearchModalRenderer />
<UserRoomProfileRenderer />
<CreateRoomModalRenderer />
<CreateSpaceModalRenderer />
<RoomSettingsRenderer />