486 lines
16 KiB
TypeScript
486 lines
16 KiB
TypeScript
// 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 { getMxIdLocalPart, 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 (nick + share menu) ───────────────────────────────────────
|
||
//
|
||
// The value is the LOCALPART only — the server half lives inside the
|
||
// full mxid that «Copy user ID» puts on the clipboard. Showing
|
||
// `nick:server` here duplicated the server for every same-homeserver
|
||
// user and read as protocol noise (user request, redesign item 14).
|
||
// The trailing ⋯ menu carries exactly two actions: copy the full
|
||
// `@nick:server` and copy the matrix.to link.
|
||
|
||
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 = getMxIdLocalPart(userId) ?? userId;
|
||
|
||
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>
|
||
);
|
||
}
|