204 lines
9.3 KiB
TypeScript
204 lines
9.3 KiB
TypeScript
// 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 (
|
||
<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>
|
||
);
|
||
}
|