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
+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>