877e1eaca7
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>
338 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|