feat(social): блокировка пользователей

- PUT/DELETE /users/@me/blocks/{id}: блокировка снимает дружбу и заявки в обе
  стороны, разблокировка возвращает возможность писать (AGENT.md 7.2)
- блокировка запрещает личные сообщения (ошибка dm.blocked), открытие беседы и
  заявки в друзья с любой стороны
- клиент: раздел «Заблокированные» в друзьях со снятием блокировки, действие
  «Заблокировать» в меню строки друга и в меню модерации участника
- тесты: Go (блокировка, заявки, беседа, сообщение) и Vitest (список, снятие,
  блокировка из меню)
This commit is contained in:
2026-09-21 19:21:00 +03:00
parent afcdf9c1c4
commit ed98c78c9e
11 changed files with 461 additions and 5 deletions
+14
View File
@@ -188,6 +188,20 @@ export async function acceptFriendRequest(userId: string): Promise<void> {
});
}
/** `PUT /users/@me/blocks/{id}` — заблокировать (AGENT.md 7.2). */
export async function blockUser(userId: string): Promise<void> {
await request<{ ok: true }>(`/users/@me/blocks/${encodeURIComponent(userId)}`, {
method: 'PUT',
});
}
/** `DELETE /users/@me/blocks/{id}` — снять блокировку. */
export async function unblockUser(userId: string): Promise<void> {
await request<{ ok: true }>(`/users/@me/blocks/${encodeURIComponent(userId)}`, {
method: 'DELETE',
});
}
/**
* `DELETE /users/@me/relationships/{id}` — удалить из друзей, отклонить или
* отменить заявку. Отдельной ручки блокировки на сервере нет: блокировка —
+10 -4
View File
@@ -2,7 +2,7 @@ import { useCallback, useRef, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { removeRelationship, type RelationshipUser } from '@/api/friends';
import { blockUser, removeRelationship, type RelationshipUser } from '@/api/friends';
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
import { SystemBadges } from '@/components/profile/SystemBadges';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
@@ -33,6 +33,11 @@ export function FriendRow({ friend }: { friend: RelationshipUser }) {
const nameRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLButtonElement | null>(null);
const block = useMutation({
mutationFn: () => blockUser(friend.user_id),
onSuccess: () => reload(),
});
const remove = useMutation({
mutationFn: () => removeRelationship(friend.user_id),
onSuccess: () => reload(),
@@ -180,11 +185,12 @@ export function FriendRow({ friend }: { friend: RelationshipUser }) {
<MenuItem
danger
icon="⛔"
disabled={remove.isPending}
data-testid={`friend-block-${friend.user_id}`}
disabled={block.isPending}
onClick={() => {
setMenuOpen(false);
// Отдельной ручки блокировки нет: блокировка — тот же DELETE.
remove.mutate();
// Блокировка запрещает личные сообщения и снимает дружбу (AGENT.md 7.2).
block.mutate();
}}
>
{t('friends.list.block')}
+56 -1
View File
@@ -2,8 +2,14 @@ import { useEffect, useId, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { acceptFriendRequest, removeRelationship, type RelationshipUser } from '@/api/friends';
import {
acceptFriendRequest,
removeRelationship,
unblockUser,
type RelationshipUser,
} from '@/api/friends';
import { FriendRow } from '@/components/friends/FriendRow';
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
import { InviteJoin } from '@/components/friends/InviteJoin';
import { UserSearch } from '@/components/friends/UserSearch';
import { Avatar } from '@/components/ui/Avatar';
@@ -29,6 +35,7 @@ export function FriendsView() {
const friends = useFriendsStore((state) => state.friends);
const incoming = useFriendsStore((state) => state.incoming);
const outgoing = useFriendsStore((state) => state.outgoing);
const blocked = useFriendsStore((state) => state.blocked);
const status = useFriendsStore((state) => state.status);
const error = useFriendsStore((state) => state.error);
const load = useFriendsStore((state) => state.load);
@@ -44,6 +51,12 @@ export function FriendsView() {
void load();
}, [load]);
// Снятие блокировки: список перечитывается, человек снова может писать.
const unblock = useMutation({
mutationFn: (userId: string) => unblockUser(userId),
onSuccess: () => reload(),
});
const respond = useMutation({
mutationFn: ({ kind, userId }: { kind: 'accept' | 'decline' | 'cancel'; userId: string }) =>
kind === 'accept' ? acceptFriendRequest(userId) : removeRelationship(userId),
@@ -222,7 +235,49 @@ export function FriendsView() {
</section>
)}
{blocked.length === 0 ? null : (
<section aria-label={t('friends.blocked.section')} className="mt-4">
<h3 className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
{t('friends.blocked.section', { count: blocked.length })}
</h3>
<ul className="gl-stagger mt-1 flex flex-col gap-0.5" data-testid="friends-blocked">
{blocked.map((user) => (
<li
key={user.user_id}
data-testid={`blocked-row-${user.user_id}`}
className="flex items-center gap-3 rounded-[var(--radius-sm)] px-2 py-1.5 hover:bg-surface-2"
>
<FramedAvatar
name={displayName(user)}
seed={user.user_id}
fileId={user.avatar_file_id}
size="md"
cosmetics={user.cosmetics}
/>
<span className="min-w-0 flex-1">
<Nickname
name={displayName(user)}
cosmetics={user.cosmetics}
className="text-sm font-medium"
/>
<span className="block truncate text-xs text-fg-muted">@{user.username}</span>
</span>
<Button
variant="ghost"
data-testid={`blocked-unblock-${user.user_id}`}
disabled={unblock.isPending}
onClick={() => unblock.mutate(user.user_id)}
>
{t('friends.blocked.unblock')}
</Button>
</li>
))}
</ul>
</section>
)}
{respond.isError ? <ErrorNotice className="mt-3" error={respond.error} /> : null}
{unblock.isError ? <ErrorNotice className="mt-3" error={unblock.error} /> : null}
<InviteJoin className="mt-8" />
</section>
+5
View File
@@ -418,6 +418,11 @@
"title": "Conversation unavailable",
"body": "This direct conversation was not found: it may be deleted or closed to you."
}
},
"blocked": {
"section": "Blocked ({{count}})",
"unblock": "Unblock",
"alreadyBlocked": "Blocked"
}
},
"invites": {
+5
View File
@@ -418,6 +418,11 @@
"title": "Беседа недоступна",
"body": "Личная беседа не найдена: возможно, она удалена или доступ к ней закрыт."
}
},
"blocked": {
"section": "Заблокированные ({{count}})",
"unblock": "Разблокировать",
"alreadyBlocked": "Заблокирован"
}
},
"invites": {
@@ -9,7 +9,9 @@ import {
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 — снять ограничение. */
@@ -64,6 +66,17 @@ export function MemberModerationMenu({
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: () => {
@@ -162,6 +175,16 @@ export function MemberModerationMenu({
</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>
)}
+61
View File
@@ -15,6 +15,7 @@ import {
messagesRoutes,
recordedRequests,
renderApp,
waitForPage,
type FetchRoute,
} from './helpers';
@@ -891,3 +892,63 @@ describe('события шлюза о друзьях', () => {
});
});
});
describe('блокировка пользователей', () => {
it('показывает заблокированных и снимает блокировку', async () => {
const blocked = relationship({
user_id: 'user-7',
username: 'spammer',
display_name: 'Spammer',
relationship: 'blocked',
});
const fetchMock = installFetch(
baseRoutes({ friends: [], incoming: [], outgoing: [], blocked: [blocked] }, [
{
match: '/api/v1/users/@me/blocks/user-7',
method: 'DELETE',
response: () => json({ ok: true }),
},
]),
);
renderApp('/app/friends');
await waitForPage('Друзья');
const section = await screen.findByTestId('friends-blocked');
expect(within(section).getByText('Spammer')).toBeVisible();
await userEvent.click(screen.getByTestId('blocked-unblock-user-7'));
await waitFor(() => {
expect(
recordedRequests(fetchMock).some(
(request) => request.method === 'DELETE' && request.url.includes('/blocks/user-7'),
),
).toBe(true);
});
});
it('блокирует друга из меню строки', async () => {
const fetchMock = installFetch(
baseRoutes({ friends: [bob], incoming: [], outgoing: [], blocked: [] }, [
{
match: '/api/v1/users/@me/blocks/user-2',
method: 'PUT',
response: () => json({ ok: true }),
},
]),
);
renderApp('/app/friends');
await waitForPage('Друзья');
await userEvent.click(await screen.findByTestId('friends-list-toggle'));
await userEvent.click(await screen.findByLabelText('Действия: Боб'));
await userEvent.click(await screen.findByTestId('friend-block-user-2'));
await waitFor(() => {
expect(
recordedRequests(fetchMock).some(
(request) => request.method === 'PUT' && request.url.includes('/blocks/user-2'),
),
).toBe(true);
});
});
});