// 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 (
{label} {value} {trailing && {trailing}}
); } // ── 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(); const [copied, setCopied] = useTimeoutToggle(); const open: MouseEventHandler = (evt) => { setCords(evt.currentTarget.getBoundingClientRect()); }; const close = () => setCords(undefined); const handle = getMxIdLocalPart(userId) ?? userId; return ( isKeyHotkey('arrowdown', evt), isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), }} >
{ copyToClipboard(userId); setCopied(); close(); }} > {t('User.copy_user_id')} { copyToClipboard(getMatrixToUser(userId)); setCopied(); close(); }} > {t('User.copy_user_link')}
} > } />
); } // ── server ─────────────────────────────────────────────────────── export function ServerRow({ userId }: { userId: string }) { const { t } = useTranslation(); const mx = useMatrixClient(); const navigate = useNavigate(); const closeProfile = useCloseUserRoomProfile(); const [cords, setCords] = useState(); const open: MouseEventHandler = (evt) => { setCords(evt.currentTarget.getBoundingClientRect()); }; const close = () => setCords(undefined); const server = getMxIdServer(userId); const myServer = getMxIdServer(mx.getSafeUserId()); if (!server) return null; return ( isKeyHotkey('arrowdown', evt), isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), }} >
{ copyToClipboard(server); close(); }} > {t('User.copy_server')} { navigate(getExploreServerPath(server)); closeProfile(); }} > {t('User.explore_community')} { openExternalUrl(`https://${server}`); close(); }} > {t('User.open_in_browser')}
} > } />
); } // ── 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 ? ( {roleLabel} ) : ( {roleLabel} ); return ; } // ── 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(); const open: MouseEventHandler = (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 ( { if (room.isSpaceRoom()) navigateSpace(roomId); else navigateRoom(roomId); closeProfile(); }} before={ {dm || room.isSpaceRoom() ? ( ( {nameInitials(room.name)} )} /> ) : ( )} } > {room.name} ); }; const value = loading ? ( {t('User.row_mutual_loading')} ) : ( t('User.row_mutual_count', { count: total }) ); return ( isKeyHotkey('arrowdown', evt), isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt), }} > {mutual.spaces.length > 0 && ( {t('User.row_mutual_spaces')} {mutual.spaces.map(renderItem)} )} {mutual.rooms.length > 0 && ( {t('User.row_mutual_rooms')} {mutual.rooms.map(renderItem)} )} {mutual.directs.length > 0 && ( {t('User.row_mutual_dms')} {mutual.directs.map(renderItem)} )} ) : null } > } /> ); }