Files
cinny/src/app/components/user-profile/UserRoomProfile.tsx
T
jared 877e1eaca7 feat: extended profile fields, push rule editor, server ACL editor
P2-8: Pronouns (m.pronouns) and Timezone (m.tz) fields in Settings →
Account → Profile; saved via MSC4133 PUT /profile/{userId}/{field};
useExtendedProfile hook fetches both in parallel; UserHero displays
pronouns below display name and timezone string below username

P2-11: Full push rule editor in Settings → Notifications below keyword
rules; covers override/room/sender/underride rule kinds; enable/disable
toggle per rule, human-readable labels for built-in rules, delete button
for custom rules, add-rule form for room and sender rules

P2-12: Server ACL viewer/editor in room settings (Server ACL tab);
reads m.room.server_acl state event; allow/deny server lists with
wildcard validation; allow IP literals toggle; power-level gated
(edit requires sufficient PL, otherwise read-only view)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 23:13:33 -04:00

338 lines
12 KiB
TypeScript

import { Box, Button, config, Icon, IconButton, Icons, Spinner, Text, toRem } from 'folds';
import React, { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { VerificationRequest } from 'matrix-js-sdk/lib/crypto-api';
import { AsyncState, AsyncStatus, useAsync } from '../../hooks/useAsyncCallback';
import { VerificationStatus } from '../../hooks/useDeviceVerificationStatus';
import { DeviceVerificationStatus } from '../DeviceVerificationStatus';
import { DeviceVerification } from '../DeviceVerification';
import { useOtherUserDevices, UserDevice } from '../../hooks/useOtherUserDevices';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { UserHero, UserHeroName } from './UserHero';
import { getMxIdServer, 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 { useRoom } from '../../hooks/useRoom';
import { useUserPresence } from '../../hooks/useUserPresence';
import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips';
import { useCrossSigningActive } from '../../hooks/useCrossSigning';
import { MemberVerificationBadge } from '../MemberVerificationBadge';
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { PowerChip } from './PowerChip';
import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './UserModeration';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { useMembership } from '../../hooks/useMembership';
import { Membership } from '../../../types/matrix/room';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
import { CreatorChip } from './CreatorChip';
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
import { DirectCreateSearchParams } from '../../pages/paths';
import { useExtendedProfile } from '../../hooks/useExtendedProfile';
type VerifyDeviceButtonProps = {
userId: string;
deviceId: string;
};
function VerifyDeviceButton({ userId, deviceId }: VerifyDeviceButtonProps) {
const mx = useMatrixClient();
const [requestState, setRequestState] = useState<AsyncState<VerificationRequest, Error>>({
status: AsyncStatus.Idle,
});
const requestVerification = useAsync<VerificationRequest, Error, []>(
useCallback(() => {
const crypto = mx.getCrypto();
if (!crypto) return Promise.reject(new Error('Crypto unavailable'));
return crypto.requestDeviceVerification(userId, deviceId);
}, [mx, userId, deviceId]),
setRequestState,
);
const handleExit = useCallback(() => setRequestState({ status: AsyncStatus.Idle }), []);
const requesting = requestState.status === AsyncStatus.Loading;
return (
<>
<Button
size="300"
variant="Warning"
fill="Soft"
radii="300"
onClick={requestVerification}
before={requesting && <Spinner size="100" variant="Warning" />}
disabled={requesting}
>
<Text size="B300">Verify</Text>
</Button>
{requestState.status === AsyncStatus.Success && (
<DeviceVerification request={requestState.data} onExit={handleExit} />
)}
</>
);
}
type UserDeviceRowProps = {
userId: string;
device: UserDevice;
};
function UserDeviceRow({ userId, device }: UserDeviceRowProps) {
const mx = useMatrixClient();
const crypto = mx.getCrypto() ?? undefined;
return (
<DeviceVerificationStatus crypto={crypto} userId={userId} deviceId={device.deviceId}>
{(status) => {
const color =
status === VerificationStatus.Verified
? 'var(--tc-positive-normal, #5effc4)'
: status === VerificationStatus.Unverified
? 'var(--tc-warning-normal, #ffcc55)'
: 'var(--tc-surface-low-contrast)';
return (
<Box alignItems="Center" gap="200">
<Icon size="100" src={Icons.ShieldUser} style={{ color, flexShrink: 0 }} />
<Box grow="Yes" direction="Column" style={{ minWidth: 0 }}>
<Text size="T300" truncate>
{device.displayName ?? device.deviceId}
</Text>
{device.displayName && (
<Text
size="T200"
truncate
style={{ color: 'var(--tc-surface-low-contrast)', fontFamily: 'monospace' }}
>
{device.deviceId}
</Text>
)}
</Box>
{status === VerificationStatus.Unverified && (
<VerifyDeviceButton userId={userId} deviceId={device.deviceId} />
)}
</Box>
);
}}
</DeviceVerificationStatus>
);
}
type UserDeviceSessionsProps = {
userId: string;
};
function UserDeviceSessions({ userId }: UserDeviceSessionsProps) {
const devicesState = useOtherUserDevices(userId);
const [expanded, setExpanded] = useState(false);
if (devicesState.status === 'loading') {
return (
<Box alignItems="Center" gap="200" style={{ padding: config.space.S200, opacity: 0.6 }}>
<Spinner size="100" variant="Secondary" />
<Text size="T200">Loading sessions</Text>
</Box>
);
}
if (devicesState.status === 'error') {
return (
<Box
className={ContainerColor({ variant: 'Critical' })}
alignItems="Center"
gap="200"
style={{ padding: config.space.S200, borderRadius: config.radii.R300 }}
>
<Icon size="100" src={Icons.Warning} />
<Text size="T200">Could not load sessions.</Text>
</Box>
);
}
const devices = devicesState.devices;
if (devices.length === 0) return null;
return (
<Box
direction="Column"
gap="100"
style={{
background: 'var(--bg-surface-variant)',
borderRadius: config.radii.R300,
padding: config.space.S300,
}}
>
<Box alignItems="Center" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Icon size="100" src={Icons.Monitor} style={{ flexShrink: 0 }} />
<Text size="T300">
<b>Sessions</b>
</Text>
<Text size="T200" style={{ color: 'var(--tc-surface-low-contrast)' }}>
{devices.length}
</Text>
</Box>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
aria-label={expanded ? 'Collapse sessions' : 'Expand sessions'}
onClick={() => setExpanded((v) => !v)}
>
<Icon
size="100"
src={expanded ? Icons.ChevronTop : Icons.ChevronBottom}
style={{ flexShrink: 0 }}
/>
</IconButton>
</Box>
{expanded && (
<Box direction="Column" gap="200" style={{ paddingTop: config.space.S100 }}>
{devices.map((device) => (
<Box
key={device.deviceId}
style={{
paddingTop: config.space.S100,
paddingBottom: config.space.S100,
borderTop: `${toRem(1)} solid var(--border-surface-variant)`,
}}
>
<UserDeviceRow userId={userId} device={device} />
</Box>
))}
</Box>
)}
</Box>
);
}
type UserRoomProfileProps = {
userId: string;
};
export function UserRoomProfile({ userId }: UserRoomProfileProps) {
const mx = useMatrixClient();
const crossSigningActive = useCrossSigningActive();
const useAuthentication = useMediaAuthentication();
const navigate = useNavigate();
const closeUserRoomProfile = useCloseUserRoomProfile();
const ignoredUsers = useIgnoredUsers();
const ignored = ignoredUsers.includes(userId);
const room = useRoom();
const showEncryption = crossSigningActive;
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const { hasMorePower } = useMemberPowerCompare(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 server = getMxIdServer(userId);
const displayName = getMemberDisplayName(room, userId);
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
const presence = useUserPresence(userId);
const extProfile = useExtendedProfile(userId);
const handleMessage = () => {
closeUserRoomProfile();
const directSearchParam: DirectCreateSearchParams = {
userId,
};
navigate(withSearchParam(getDirectCreatePath(), directSearchParam));
};
return (
<Box direction="Column">
<UserHero
userId={userId}
avatarUrl={avatarUrl}
presence={presence && presence.lastActiveTs !== 0 ? presence : undefined}
/>
<Box direction="Column" gap="500" style={{ padding: config.space.S400 }}>
<Box direction="Column" gap="400">
<Box gap="400" alignItems="Center">
<UserHeroName
displayName={displayName}
userId={userId}
status={presence?.status}
pronouns={extProfile.pronouns}
timezone={extProfile.timezone}
/>
{showEncryption && <MemberVerificationBadge userId={userId} />}
{userId !== myUserId && (
<Box shrink="No">
<Button
size="300"
variant="Primary"
fill="Solid"
radii="300"
before={<Icon size="50" src={Icons.Message} filled />}
onClick={handleMessage}
>
<Text size="B300">Message</Text>
</Button>
</Box>
)}
</Box>
<Box alignItems="Center" gap="200" wrap="Wrap">
{server && <ServerChip server={server} />}
<ShareChip userId={userId} />
{creator ? <CreatorChip /> : <PowerChip userId={userId} />}
{userId !== myUserId && <MutualRoomsChip userId={userId} />}
{userId !== myUserId && <OptionsChip userId={userId} />}
</Box>
</Box>
{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()}
/>
)}
<UserModeration
userId={userId}
canInvite={canInvite && membership === Membership.Leave}
canKick={canKickUser && membership === Membership.Join}
canBan={canBanUser && membership !== Membership.Ban}
/>
{showEncryption && userId !== myUserId && <UserDeviceSessions userId={userId} />}
</Box>
</Box>
);
}