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:
parent
7054ca2981
commit
9e42508902
21 changed files with 2111 additions and 816 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Открыть в браузере"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
480
src/app/components/user-profile/UserInfoRows.tsx
Normal file
480
src/app/components/user-profile/UserInfoRows.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
155
src/app/features/room/RoomViewProfilePanel.css.ts
Normal file
155
src/app/features/room/RoomViewProfilePanel.css.ts
Normal 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',
|
||||
});
|
||||
410
src/app/features/room/RoomViewProfilePanel.tsx
Normal file
410
src/app/features/room/RoomViewProfilePanel.tsx
Normal 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>;
|
||||
}
|
||||
70
src/app/features/room/RoomViewProfileSidePanel.css.ts
Normal file
70
src/app/features/room/RoomViewProfileSidePanel.css.ts
Normal 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',
|
||||
});
|
||||
135
src/app/features/room/RoomViewProfileSidePanel.tsx
Normal file
135
src/app/features/room/RoomViewProfileSidePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/app/hooks/useDmCallVisible.ts
Normal file
41
src/app/hooks/useDmCallVisible.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
50
src/app/pages/HorseshoeContainer.tsx
Normal file
50
src/app/pages/HorseshoeContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue