vojo/src/app/components/user-profile/UserRoomProfile.tsx

204 lines
9.3 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.

// 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 23×
// 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 (
<Box direction="Column" gap="500" className={css.CardRoot}>
{/* 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 && (
<div className={css.CardActionsAnchor}>
<UserActionsMenu
userId={userId}
showStartDm={showStartDmChip}
onStartDm={handleMessage}
canKick={canKickUser && membership === Membership.Join}
canBan={canBanUser && membership !== Membership.Ban}
canInvite={canInvite && membership === Membership.Leave}
/>
</div>
)}
<UserHero
userId={userId}
displayName={displayName}
avatarUrl={avatarUrl}
avatarUrlExpanded={avatarUrlExpanded}
presence={presence}
encrypted={encrypted}
onAvatarClick={onAvatarClick}
avatarExpanded={avatarExpanded}
/>
<div className={css.InfoSection}>
<IdRow userId={userId} />
<ServerRow userId={userId} />
{userId !== myUserId && <RoleRow roleLabel={roleLabel} emphasis={creator} />}
{userId !== myUserId && <MutualRoomsRow userId={userId} />}
</div>
{ignored && <IgnoredUserAlert />}
{member && membership === Membership.Ban && (
<UserBanAlert
userId={userId}
reason={member.events.member?.getContent().reason}
canUnban={canUnban}
bannedBy={member.events.member?.getSender()}
ts={member.events.member?.getTs()}
/>
)}
{member &&
membership === Membership.Leave &&
member.events.member &&
member.events.member.getSender() !== userId && (
<UserKickAlert
reason={member.events.member?.getContent().reason}
kickedBy={member.events.member?.getSender()}
ts={member.events.member?.getTs()}
/>
)}
{member && membership === Membership.Invite && (
<UserInviteAlert
userId={userId}
reason={member.events.member?.getContent().reason}
canKick={canKickUser}
invitedBy={member.events.member?.getSender()}
ts={member.events.member?.getTs()}
/>
)}
</Box>
);
}