cinny/src/app/components/user-profile/PowerChip.tsx
Ajay Bura 4d1ae4eafd
Some checks failed
Deploy to Netlify (dev) / Deploy to Netlify (push) Has been cancelled
Redesign user profile view (#2396)
* WIP - new profile view

* render common rooms in user profile

* add presence component

* WIP - room user profile

* temp hide profile button

* show mutual rooms in spaces, rooms and direct messages categories

* add message button

* add option to change user powers in profile

* improve ban info and option to unban

* add share user button in user profile

* add option to block user in user profile

* improve blocked user alert body

* add moderation tool in user profile

* open profile view on left side in member drawer

* open new user profile in all places
2025-08-09 22:16:10 +10:00

344 lines
11 KiB
TypeScript

import {
Box,
Button,
Chip,
config,
Dialog,
Header,
Icon,
IconButton,
Icons,
Line,
Menu,
MenuItem,
Overlay,
OverlayBackdrop,
OverlayCenter,
PopOut,
RectCords,
Spinner,
Text,
toRem,
} from 'folds';
import React, { MouseEventHandler, useCallback, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { PowerColorBadge, PowerIcon } from '../power';
import { usePowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
import { getPowers, getTagIconSrc, usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { stopPropagation } from '../../utils/keyboard';
import { StateEvent } from '../../../types/matrix/room';
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
import { RoomSettingsPage } from '../../state/roomSettings';
import { useRoom } from '../../hooks/useRoom';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { CutoutCard } from '../cutout-card';
import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
import { SpaceSettingsPage } from '../../state/spaceSettings';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { BreakWord } from '../../styles/Text.css';
type SelfDemoteAlertProps = {
power: number;
onCancel: () => void;
onChange: (power: number) => void;
};
function SelfDemoteAlert({ power, onCancel, onChange }: SelfDemoteAlertProps) {
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onCancel,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Header
style={{ padding: `0 ${config.space.S200} 0 ${config.space.S400}` }}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Self Demotion</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400, paddingTop: 0 }} direction="Column" gap="500">
<Box direction="Column" gap="200">
<Text priority="400">
You are about to demote yourself! You will not be able to regain this power
yourself. Are you sure?
</Text>
</Box>
<Box direction="Column" gap="200">
<Button type="submit" variant="Warning" onClick={() => onChange(power)}>
<Text size="B400">Demote</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
type SharedPowerAlertProps = {
power: number;
onCancel: () => void;
onChange: (power: number) => void;
};
function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps) {
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onCancel,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Header
style={{ padding: `0 ${config.space.S200} 0 ${config.space.S400}` }}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Shared Power</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400, paddingTop: 0 }} direction="Column" gap="500">
<Box direction="Column" gap="200">
<Text priority="400">
You are promoting the user to have the same power as yourself! You will not be
able to change their power afterward. Are you sure?
</Text>
</Box>
<Box direction="Column" gap="200">
<Button type="submit" variant="Warning" onClick={() => onChange(power)}>
<Text size="B400">Promote</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
export function PowerChip({ userId }: { userId: string }) {
const mx = useMatrixClient();
const room = useRoom();
const space = useSpaceOptionally();
const useAuthentication = useMediaAuthentication();
const openRoomSettings = useOpenRoomSettings();
const openSpaceSettings = useOpenSpaceSettings();
const powerLevels = usePowerLevels(room);
const { getPowerLevel, canSendStateEvent } = usePowerLevelsAPI(powerLevels);
const [powerLevelTags, getPowerLevelTag] = usePowerLevelTags(room, powerLevels);
const myPower = getPowerLevel(mx.getSafeUserId());
const userPower = getPowerLevel(userId);
const canChangePowers =
canSendStateEvent(StateEvent.RoomPowerLevels, myPower) &&
(mx.getSafeUserId() === userId ? true : myPower > userPower);
const tag = getPowerLevelTag(userPower);
const tagIconSrc = tag.icon && getTagIconSrc(mx, useAuthentication, tag.icon);
const [cords, setCords] = useState<RectCords>();
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect());
};
const close = () => setCords(undefined);
const [powerState, changePower] = useAsyncCallback<undefined, Error, [number]>(
useCallback(
async (power: number) => {
await mx.setPowerLevel(room.roomId, userId, power);
},
[mx, userId, room]
)
);
const changing = powerState.status === AsyncStatus.Loading;
const error = powerState.status === AsyncStatus.Error;
const [selfDemote, setSelfDemote] = useState<number>();
const [sharedPower, setSharedPower] = useState<number>();
const handlePowerSelect = (power: number): void => {
close();
if (!canChangePowers) return;
if (power === userPower) return;
if (userId === mx.getSafeUserId()) {
setSelfDemote(power);
return;
}
if (power === myPower) {
setSharedPower(power);
return;
}
changePower(power);
};
const handleSelfDemote = (power: number) => {
setSelfDemote(undefined);
changePower(power);
};
const handleSharedPower = (power: number) => {
setSharedPower(undefined);
changePower(power);
};
return (
<>
<PopOut
anchor={cords}
position="Bottom"
align="Start"
offset={4}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: close,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
}}
>
<Menu>
<Box
direction="Column"
gap="100"
style={{ padding: config.space.S100, maxWidth: toRem(200) }}
>
{error && (
<CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
<Text size="L400">Error: {powerState.error.name}</Text>
<Text className={BreakWord} size="T200">
{powerState.error.message}
</Text>
</CutoutCard>
)}
{getPowers(powerLevelTags).map((power) => {
const powerTag = powerLevelTags[power];
const powerTagIconSrc =
powerTag.icon && getTagIconSrc(mx, useAuthentication, powerTag.icon);
const canAssignPower = power <= myPower;
return (
<MenuItem
key={power}
variant={userPower === power ? 'Primary' : 'Surface'}
fill="None"
size="300"
radii="300"
aria-disabled={changing || !canChangePowers || !canAssignPower}
aria-pressed={userPower === power}
before={<PowerColorBadge color={powerTag.color} />}
after={
powerTagIconSrc ? (
<PowerIcon size="50" iconSrc={powerTagIconSrc} />
) : undefined
}
onClick={
canChangePowers && canAssignPower
? () => handlePowerSelect(power)
: undefined
}
>
<Text size="B300">{powerTag.name}</Text>
</MenuItem>
);
})}
</Box>
<Line size="300" />
<div style={{ padding: config.space.S100 }}>
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="300"
onClick={() => {
if (room.isSpaceRoom()) {
openSpaceSettings(
room.roomId,
space?.roomId,
SpaceSettingsPage.PermissionsPage
);
} else {
openRoomSettings(
room.roomId,
space?.roomId,
RoomSettingsPage.PermissionsPage
);
}
close();
}}
>
<Text size="B300">Manage Powers</Text>
</MenuItem>
</div>
</Menu>
</FocusTrap>
}
>
<Chip
variant={error ? 'Critical' : 'SurfaceVariant'}
radii="Pill"
before={
cords ? (
<Icon size="50" src={Icons.ChevronBottom} />
) : (
<>
{!changing && <PowerColorBadge color={tag.color} />}
{changing && <Spinner size="50" variant="Secondary" fill="Soft" />}
</>
)
}
after={tagIconSrc ? <PowerIcon size="50" iconSrc={tagIconSrc} /> : undefined}
onClick={open}
aria-pressed={!!cords}
>
<Text size="B300" truncate>
{tag.name}
</Text>
</Chip>
</PopOut>
{typeof selfDemote === 'number' ? (
<SelfDemoteAlert
power={selfDemote}
onCancel={() => setSelfDemote(undefined)}
onChange={handleSelfDemote}
/>
) : null}
{typeof sharedPower === 'number' ? (
<SharedPowerAlert
power={sharedPower}
onCancel={() => setSharedPower(undefined)}
onChange={handleSharedPower}
/>
) : null}
</>
);
}