// 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),
}}
>
}
>
}
/>
);
}
// ── 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),
}}
>
}
>
}
/>
);
}
// ── 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 (
);
};
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),
}}
>
) : null
}
>
}
/>
);
}