feat(social): блокировка пользователей
- PUT/DELETE /users/@me/blocks/{id}: блокировка снимает дружбу и заявки в обе
стороны, разблокировка возвращает возможность писать (AGENT.md 7.2)
- блокировка запрещает личные сообщения (ошибка dm.blocked), открытие беседы и
заявки в друзья с любой стороны
- клиент: раздел «Заблокированные» в друзьях со снятием блокировки, действие
«Заблокировать» в меню строки друга и в меню модерации участника
- тесты: Go (блокировка, заявки, беседа, сообщение) и Vitest (список, снятие,
блокировка из меню)
This commit is contained in:
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user