2026-09-20 23:04:35 +03:00
|
|
|
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';
|
2026-09-21 19:21:00 +03:00
|
|
|
import { blockUser } from '@/api/friends';
|
2026-09-20 23:04:35 +03:00
|
|
|
import { Menu, MenuGroupLabel, MenuItem, MenuSeparator } from '@/components/ui/Menu';
|
2026-09-21 19:21:00 +03:00
|
|
|
import { useFriendsStore } from '@/stores/friends';
|
2026-09-20 23:04:35 +03:00
|
|
|
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();
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-09-21 19:21:00 +03:00
|
|
|
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();
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-20 23:04:35 +03:00
|
|
|
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}
|
|
|
|
|
|
2026-09-21 19:21:00 +03:00
|
|
|
{/* Личная блокировка не зависит от прав на сервере (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>
|
|
|
|
|
|
2026-09-20 23:04:35 +03:00
|
|
|
{error === null || error === undefined ? null : (
|
|
|
|
|
<p className="px-2 py-1 text-xs text-danger">{describeError(error)}</p>
|
|
|
|
|
)}
|
|
|
|
|
</Menu>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|