Files
glchat/web/src/pages/settings/MemberModerationMenu.tsx
T
grendervill ed98c78c9e feat(social): блокировка пользователей
- PUT/DELETE /users/@me/blocks/{id}: блокировка снимает дружбу и заявки в обе
  стороны, разблокировка возвращает возможность писать (AGENT.md 7.2)
- блокировка запрещает личные сообщения (ошибка dm.blocked), открытие беседы и
  заявки в друзья с любой стороны
- клиент: раздел «Заблокированные» в друзьях со снятием блокировки, действие
  «Заблокировать» в меню строки друга и в меню модерации участника
- тесты: Go (блокировка, заявки, беседа, сообщение) и Vitest (список, снятие,
  блокировка из меню)
2026-09-21 19:21:00 +03:00

195 lines
6.2 KiB
TypeScript

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { guildMembersQueryKey } from '@/api/guilds';
import {
banGuildMember,
guildBansQueryKey,
kickGuildMember,
timeoutGuildMember,
} from '@/api/moderation';
import { blockUser } from '@/api/friends';
import { Menu, MenuGroupLabel, MenuItem, MenuSeparator } from '@/components/ui/Menu';
import { useFriendsStore } from '@/stores/friends';
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
/** Пресеты тайм-аута в минутах: 0 — снять ограничение. */
const TIMEOUT_CHOICES = [1, 10, 60, 1440, 0] as const;
interface MemberModerationMenuProps {
guildId: string;
userId: string;
userName: string;
canKick: boolean;
canBan: boolean;
canTimeout: boolean;
}
/**
* Меню модерации участника в списке сервера (AGENT.md 7.17): тайм-аут,
* исключение и бан с причиной. Пункты скрыты без соответствующих прав, а
* сервер всё равно проверяет их повторно.
*/
export function MemberModerationMenu({
guildId,
userId,
userName,
canKick,
canBan,
canTimeout,
}: MemberModerationMenuProps) {
const { t } = useTranslation();
const describeError = useApiErrorMessage();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [reason, setReason] = useState('');
const anchorRef = useRef<HTMLButtonElement | null>(null);
const close = useCallback(() => setOpen(false), []);
const refresh = (): void => {
void queryClient.invalidateQueries({ queryKey: guildMembersQueryKey(guildId) });
void queryClient.invalidateQueries({ queryKey: guildBansQueryKey(guildId) });
};
const timeout = useMutation({
mutationFn: (minutes: number) =>
timeoutGuildMember(
guildId,
userId,
minutes === 0
? '2000-01-01T00:00:00Z'
: new Date(Date.now() + minutes * 60_000).toISOString(),
),
onSuccess: () => {
refresh();
close();
},
});
const reloadRelationships = useFriendsStore((state) => state.reload);
const blockedUsers = useFriendsStore((state) => state.blocked);
const blocked = blockedUsers.some((entry) => entry.user_id === userId);
const block = useMutation({
mutationFn: () => blockUser(userId),
onSuccess: () => {
void reloadRelationships();
},
});
const kick = useMutation({
mutationFn: () => kickGuildMember(guildId, userId),
onSuccess: () => {
refresh();
close();
},
});
const ban = useMutation({
mutationFn: () => banGuildMember(guildId, userId, reason.trim()),
onSuccess: () => {
refresh();
close();
},
});
if (!canKick && !canBan && !canTimeout) {
return null;
}
const error = timeout.error ?? kick.error ?? ban.error;
const pending = timeout.isPending || kick.isPending || ban.isPending;
return (
<>
<button
ref={anchorRef}
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-label={t('settings.servers.members.moderate', { name: userName })}
title={t('settings.servers.members.moderate', { name: userName })}
data-testid={`member-moderate-${userId}`}
onClick={() => setOpen((value) => !value)}
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
>
<span aria-hidden="true">⋯</span>
</button>
<Menu
open={open}
onClose={close}
anchorRef={anchorRef}
label={t('settings.servers.members.moderate', { name: userName })}
placement="bottom"
>
{canTimeout ? (
<>
<MenuGroupLabel>{t('settings.servers.members.timeout')}</MenuGroupLabel>
{TIMEOUT_CHOICES.map((minutes) => (
<MenuItem
key={minutes}
disabled={pending}
data-testid={`member-timeout-${minutes}`}
onClick={() => timeout.mutate(minutes)}
>
{minutes === 0
? t('settings.servers.members.timeoutClear')
: t('settings.servers.bans.timeoutMinutes', { count: minutes })}
</MenuItem>
))}
<MenuSeparator />
</>
) : null}
{canBan ? (
<>
<MenuGroupLabel>{t('settings.servers.members.banReason')}</MenuGroupLabel>
<label className="flex flex-col gap-1 px-2 pb-1">
<input
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-2 py-1 text-sm text-fg"
value={reason}
onChange={(event) => setReason(event.target.value)}
maxLength={400}
placeholder={t('settings.servers.members.banReasonPlaceholder')}
data-testid={`member-ban-reason-${userId}`}
/>
</label>
<MenuItem
danger
disabled={pending}
data-testid={`member-ban-${userId}`}
onClick={() => ban.mutate()}
>
{t('settings.servers.members.ban')}
</MenuItem>
</>
) : null}
{canKick ? (
<MenuItem
danger
disabled={pending}
data-testid={`member-kick-${userId}`}
onClick={() => kick.mutate()}
>
{t('settings.servers.members.kick')}
</MenuItem>
) : null}
{/* Личная блокировка не зависит от прав на сервере (AGENT.md 7.2). */}
<MenuItem
danger
disabled={pending || blocked}
data-testid={`member-block-${userId}`}
onClick={() => block.mutate()}
>
{t(blocked ? 'friends.blocked.alreadyBlocked' : 'friends.list.block')}
</MenuItem>
{error === null || error === undefined ? null : (
<p className="px-2 py-1 text-xs text-danger">{describeError(error)}</p>
)}
</Menu>
</>
);
}