// 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 } 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 { useIsOneOnOne, useRoom } from '../../hooks/useRoom'; import { useUserPresence } from '../../hooks/useUserPresence'; import { useStateEvent } from '../../hooks/useStateEvent'; import { IgnoredUserAlert, UserActionsMenu } from './UserChips'; import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { UserBanAlert, UserInviteAlert, UserKickAlert } from './UserModeration'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { useMembership } from '../../hooks/useMembership'; import { Membership, StateEvent } from '../../../types/matrix/room'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare'; 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; // Desktop side-pane only — forwarded straight to `UserHero` so the // hero avatar can render in its inline-expanded mode while the // rest of the card stays in flow. Mobile leaves this `undefined` // and keeps using the full-rail avatar swap inside // `RoomViewProfilePanel`. avatarExpanded?: boolean; }; export function UserRoomProfile({ userId, onAvatarClick, avatarExpanded }: UserRoomProfileProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const navigate = useNavigate(); const closeUserRoomProfile = useCloseUserRoomProfile(); const ignoredUsers = useIgnoredUsers(); const ignored = ignoredUsers.includes(userId); const room = useRoom(); const powerLevels = usePowerLevels(room); const creators = useRoomCreators(room); 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); const canKickUser = permissions.action('kick', myUserId) && hasMorePower(myUserId, userId); const canBanUser = permissions.action('ban', myUserId) && hasMorePower(myUserId, userId); const canUnban = permissions.action('ban', myUserId); const canInvite = permissions.action('invite', myUserId); const member = room.getMember(userId); const membership = useMembership(room, userId); const displayName = getMemberDisplayName(room, userId); const avatarMxc = getMemberAvatarMxc(room, userId); // The collapsed HeroAvatar renders at 96px CSS (user-profile/styles.css.ts). // A `crop` thumbnail can't fix retina sharpness here: Synapse only // pre-generates 32px and 96px CROP thumbnails by default, so any `crop` // request > 96 just returns the 96px one — upscaled and pixelated on a 2–3× // display. `scale` thumbnails go up to 320/640/800, so we request a 320 scale: // sharp on retina yet ~10× smaller than the full-resolution original (which // the SDK returns when no dimensions are passed, and which made the hero // visibly progressive-load on open). `UserAvatar` forces `object-fit: cover`, // so a non-square scale thumbnail still fills the circle. The full-res // original is reserved for the expanded tile + tap-to-zoom fullview below. const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication, 320, 320, 'scale')) ?? undefined; // Full-resolution original for the desktop side-pane expanded (~340px) tile — // a 96px thumbnail would upscale and pixelate when blown up. Only fetched // when the avatar is expanded (UserHero swaps to this src then). const avatarUrlExpanded = (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 params: DirectCreateSearchParams = { userId }; navigate(withSearchParam(getDirectCreatePath(), params)); }; return ( {/* 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 && (
)}
{userId !== myUserId && } {userId !== myUserId && }
{ignored && } {member && membership === Membership.Ban && ( )} {member && membership === Membership.Leave && member.events.member && member.events.member.getSender() !== userId && ( )} {member && membership === Membership.Invite && ( )}
); }