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

215 lines
7.2 KiB
TypeScript

// Action surfaces rendered alongside the user-profile card after
// the Dawn redesign:
//
// - `UserActionsMenu` — single 3-dot pill anchored to the
// top-right of the card. Hosts every imperative action we have
// (start DM in groups, block / unblock, kick / ban / invite for
// mods). Replaces the old bottom actions row, which on mobile
// overflowed past the rail height — the user couldn't reach
// Block without discovering a hidden scroll.
// - `IgnoredUserAlert` — banner shown when the viewer has the
// profile target on their ignore list. Pure information, no
// action of its own.
//
// The legacy chip-strip components (Server / Share / MutualRooms)
// were lifted into `UserInfoRows.tsx` as Fleet-style attribute rows
// and are intentionally not re-exported here.
import React, { MouseEventHandler, useCallback, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey';
import {
Box,
Icon,
IconButton,
Icons,
Line,
Menu,
MenuItem,
PopOut,
RectCords,
Spinner,
Text,
config,
toRem,
} from 'folds';
import { useTranslation } from 'react-i18next';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { CutoutCard } from '../cutout-card';
import { SettingTile } from '../setting-tile';
import { UserModeration } from './UserModeration';
export function IgnoredUserAlert() {
const { t } = useTranslation();
return (
<CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
<SettingTile>
<Box direction="Column" gap="200">
<Box gap="200" justifyContent="SpaceBetween">
<Text size="L400">{t('User.blocked_title')}</Text>
</Box>
<Box direction="Column">
<Text size="T200">{t('User.blocked_description')}</Text>
</Box>
</Box>
</SettingTile>
</CutoutCard>
);
}
type UserActionsMenuProps = {
userId: string;
// Whether to surface «Написать» (start a private DM) — true only
// in group rooms when the target isn't the viewer themselves; in
// 1:1 the user is already in the conversation, so the action is a
// no-op.
showStartDm?: boolean;
onStartDm?: () => void;
// Room-moderation gates. Each is independently true/false; if all
// three are false the moderation block is suppressed entirely so
// the menu doesn't carry a dangling separator.
canKick?: boolean;
canBan?: boolean;
canInvite?: boolean;
};
// 3-dot pill containing every imperative action for the profile
// target. Anchored top-right of the card by the parent layout.
//
// Block / Unblock lives here too — earlier I tried promoting it to
// a standalone chip in a bottom actions row, but on mobile the rail
// is ~42vh and that row scrolled below the visible rail edge, so
// users couldn't reach it. Putting Block back into the menu keeps
// the action accessible at the same vertical position regardless of
// content length.
export function UserActionsMenu({
userId,
showStartDm,
onStartDm,
canKick,
canBan,
canInvite,
}: UserActionsMenuProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const [cords, setCords] = useState<RectCords>();
const open: MouseEventHandler<HTMLButtonElement> = (evt) => {
// Anchor the popout to the trigger's TOP edge with zero height
// — `position="Bottom"` then drops the menu starting from that
// edge, so it visually covers the 3-dot button instead of
// floating below it. Looks tighter on mobile where space is
// tight; the trigger naturally re-emerges when the menu closes.
const rect = evt.currentTarget.getBoundingClientRect();
setCords({ x: rect.x, y: rect.y, width: rect.width, height: 0 });
};
const close = () => setCords(undefined);
const ignoredUsers = useIgnoredUsers();
const ignored = ignoredUsers.includes(userId);
const [ignoreState, toggleIgnore] = useAsyncCallback(
useCallback(async () => {
const next = ignoredUsers.filter((u) => u !== userId);
if (!ignored) next.push(userId);
await mx.setIgnoredUsers(next);
}, [mx, ignoredUsers, userId, ignored])
);
const ignoring = ignoreState.status === AsyncStatus.Loading;
const moderationVisible = !!(canKick || canBan || canInvite);
return (
<PopOut
anchor={cords}
position="Bottom"
align="End"
// No offset: combined with the `height: 0` anchor above, this
// makes the popout's top edge land on the trigger's top edge
// — i.e., the menu opens *over* the 3-dots, not below.
offset={0}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: close,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
isKeyForward: (evt: KeyboardEvent) => isKeyHotkey('arrowdown', evt),
isKeyBackward: (evt: KeyboardEvent) => isKeyHotkey('arrowup', evt),
}}
>
<Menu style={{ minWidth: toRem(220) }}>
{showStartDm && onStartDm && (
<div style={{ padding: config.space.S100 }}>
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="300"
onClick={() => {
onStartDm();
close();
}}
before={<Icon size="50" src={Icons.Message} filled />}
>
<Text size="B300">{t('User.message')}</Text>
</MenuItem>
</div>
)}
{showStartDm && onStartDm && <Line size="300" />}
<div style={{ padding: config.space.S100 }}>
<MenuItem
variant="Critical"
fill="None"
size="300"
radii="300"
onClick={() => {
toggleIgnore();
close();
}}
before={
ignoring ? (
<Spinner variant="Critical" size="50" />
) : (
<Icon size="50" src={Icons.Prohibited} />
)
}
disabled={ignoring}
>
<Text size="B300">{ignored ? t('User.unblock') : t('User.block')}</Text>
</MenuItem>
</div>
{moderationVisible && (
<>
<Line size="300" />
<div style={{ padding: config.space.S200 }}>
<UserModeration
userId={userId}
canKick={!!canKick}
canBan={!!canBan}
canInvite={!!canInvite}
/>
</div>
</>
)}
</Menu>
</FocusTrap>
}
>
<IconButton
size="300"
radii="Pill"
fill="None"
onClick={open}
aria-pressed={!!cords}
aria-label={t('User.more')}
>
<Icon size="200" src={Icons.VerticalDots} />
</IconButton>
</PopOut>
);
}