vojo/src/app/components/user-profile/UserInfoRows.tsx
2026-06-13 12:50:55 +03:00

486 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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>
);
}