feat(social): блокировка пользователей
- PUT/DELETE /users/@me/blocks/{id}: блокировка снимает дружбу и заявки в обе
стороны, разблокировка возвращает возможность писать (AGENT.md 7.2)
- блокировка запрещает личные сообщения (ошибка dm.blocked), открытие беседы и
заявки в друзья с любой стороны
- клиент: раздел «Заблокированные» в друзьях со снятием блокировки, действие
«Заблокировать» в меню строки друга и в меню модерации участника
- тесты: Go (блокировка, заявки, беседа, сообщение) и Vitest (список, снятие,
блокировка из меню)
This commit is contained in:
@@ -107,6 +107,16 @@ func (s *Server) registerMessageRoutes(api huma.API) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Личная беседа: блокировка запрещает переписку в обе стороны (AGENT.md 7.2).
|
||||
if channel.GuildID == nil {
|
||||
blocked, err := s.store.DMBlocked(ctx, channelID, user.ID)
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
if blocked {
|
||||
return nil, humaErrorStatus(http.StatusForbidden, "dm.blocked", "personal messages are blocked")
|
||||
}
|
||||
}
|
||||
// Антиспам-лимит: 5 сообщений за 5 секунд на комнату и пользователя,
|
||||
// администратор инстанса лимит обходит (AGENT.md 8.6, 7.19).
|
||||
if !user.IsInstanceAdmin {
|
||||
|
||||
@@ -168,6 +168,12 @@ func (s *Server) registerSocialRoutes(api huma.API) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Блокировка сильнее заявки: переписка и дружба с ней невозможны.
|
||||
if blocked, err := s.store.IsBlocked(ctx, user.ID, target.ID); err != nil {
|
||||
return nil, humaError(err)
|
||||
} else if blocked {
|
||||
return nil, humaErrorStatus(http.StatusForbidden, "relationship.blocked", "user is blocked")
|
||||
}
|
||||
// Если встречная заявка уже есть — сразу становимся друзьями.
|
||||
reverse, err := s.store.GetRelationship(ctx, target.ID, user.ID)
|
||||
switch {
|
||||
@@ -252,6 +258,65 @@ func (s *Server) registerSocialRoutes(api huma.API) {
|
||||
return newOKOutput(), nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "blockUser",
|
||||
Method: http.MethodPut,
|
||||
Path: "/users/@me/blocks/{user_id}",
|
||||
Summary: "Заблокировать пользователя",
|
||||
Tags: []string{"Friends"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *struct {
|
||||
UserID string `path:"user_id"`
|
||||
},
|
||||
) (*okOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetID, err := parseID("user_id", input.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if targetID == user.ID {
|
||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "you cannot block yourself")
|
||||
}
|
||||
if _, err := s.store.GetUser(ctx, targetID); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
// Блокировка снимает дружбу и заявки в обе стороны (AGENT.md 7.2).
|
||||
if err := s.store.BlockUser(ctx, user.ID, targetID); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
s.dispatchRelationshipUpdate(user.ID, targetID)
|
||||
return newOKOutput(), nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "unblockUser",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/users/@me/blocks/{user_id}",
|
||||
Summary: "Снять блокировку",
|
||||
Tags: []string{"Friends"},
|
||||
Security: security,
|
||||
}, func(ctx context.Context, input *struct {
|
||||
UserID string `path:"user_id"`
|
||||
},
|
||||
) (*okOutput, error) {
|
||||
user, _, err := requireUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetID, err := parseID("user_id", input.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.UnblockUser(ctx, user.ID, targetID); err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
s.dispatchRelationshipUpdate(user.ID, targetID)
|
||||
return newOKOutput(), nil
|
||||
})
|
||||
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "openDirectChannel",
|
||||
Method: http.MethodPost,
|
||||
@@ -280,6 +345,12 @@ func (s *Server) registerSocialRoutes(api huma.API) {
|
||||
if err != nil {
|
||||
return nil, humaError(err)
|
||||
}
|
||||
// С заблокированным беседа не открывается (AGENT.md 7.2).
|
||||
if blocked, err := s.store.IsBlocked(ctx, user.ID, recipientID); err != nil {
|
||||
return nil, humaError(err)
|
||||
} else if blocked {
|
||||
return nil, humaErrorStatus(http.StatusForbidden, "dm.blocked", "personal messages are blocked")
|
||||
}
|
||||
channel, err := s.store.FindDMChannel(ctx, user.ID, recipientID)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
channel, err = s.store.CreateDMChannel(ctx, user.ID, recipientID)
|
||||
|
||||
@@ -521,3 +521,84 @@ func TestMembersCarrySystemBadges(t *testing.T) {
|
||||
t.Fatalf("участник %s не найден: %s", ownerID, list.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockUserStopsDMsAndRequests(t *testing.T) {
|
||||
srv, owner, member, _, memberID := cosmeticsFixture(t)
|
||||
me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", owner)
|
||||
ownerID := decodeResponse[struct {
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"user"`
|
||||
}](t, me).User.ID
|
||||
|
||||
// Заводим дружбу, затем блокируем: связь снимается, остаётся блокировка.
|
||||
request := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships",
|
||||
`{"user_id":"`+memberID+`"}`, owner)
|
||||
if request.Code != http.StatusOK {
|
||||
t.Fatalf("заявка = %d (%s)", request.Code, request.Body.String())
|
||||
}
|
||||
if accept := doJSON(t, srv, http.MethodPost,
|
||||
"/api/v1/users/@me/relationships/"+ownerID+"/accept", "", member); accept.Code != http.StatusOK {
|
||||
t.Fatalf("принятие = %d", accept.Code)
|
||||
}
|
||||
|
||||
blocked := doJSON(t, srv, http.MethodPut, "/api/v1/users/@me/blocks/"+memberID, "", owner)
|
||||
if blocked.Code != http.StatusOK {
|
||||
t.Fatalf("блокировка = %d (%s)", blocked.Code, blocked.Body.String())
|
||||
}
|
||||
relations := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", owner)
|
||||
list := decodeResponse[struct {
|
||||
Friends []struct {
|
||||
UserID string `json:"user_id"`
|
||||
} `json:"friends"`
|
||||
Blocked []struct {
|
||||
UserID string `json:"user_id"`
|
||||
} `json:"blocked"`
|
||||
}](t, relations)
|
||||
if len(list.Friends) != 0 || len(list.Blocked) != 1 || list.Blocked[0].UserID != memberID {
|
||||
t.Fatalf("после блокировки: друзей %d, заблокированных %d", len(list.Friends), len(list.Blocked))
|
||||
}
|
||||
|
||||
// Заявка в друзья от заблокированного отклоняется.
|
||||
again := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships",
|
||||
`{"user_id":"`+ownerID+`"}`, member)
|
||||
if again.Code != http.StatusForbidden {
|
||||
t.Fatalf("заявка от заблокированного = %d, ожидался 403 (%s)", again.Code, again.Body.String())
|
||||
}
|
||||
|
||||
// Личная беседа не открывается и сообщения в неё не уходят.
|
||||
open := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels",
|
||||
`{"recipient_id":"`+memberID+`"}`, owner)
|
||||
if open.Code != http.StatusForbidden {
|
||||
t.Fatalf("беседа с заблокированным = %d, ожидался 403 (%s)", open.Code, open.Body.String())
|
||||
}
|
||||
|
||||
// Снятие блокировки возвращает возможность писать.
|
||||
if unblock := doJSON(t, srv, http.MethodDelete, "/api/v1/users/@me/blocks/"+memberID, "", owner); unblock.Code != http.StatusOK {
|
||||
t.Fatalf("снятие блокировки = %d", unblock.Code)
|
||||
}
|
||||
reopened := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels",
|
||||
`{"recipient_id":"`+memberID+`"}`, owner)
|
||||
if reopened.Code != http.StatusOK {
|
||||
t.Fatalf("беседа после снятия блокировки = %d (%s)", reopened.Code, reopened.Body.String())
|
||||
}
|
||||
channelID := decodeResponse[struct {
|
||||
Channel struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"channel"`
|
||||
}](t, reopened).Channel.ID
|
||||
|
||||
// Блокируем снова уже при существующей беседе: сообщение не отправляется.
|
||||
if rec := doJSON(t, srv, http.MethodPut, "/api/v1/users/@me/blocks/"+memberID, "", owner); rec.Code != http.StatusOK {
|
||||
t.Fatalf("повторная блокировка = %d", rec.Code)
|
||||
}
|
||||
post := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages",
|
||||
`{"content":"привет"}`, owner)
|
||||
if post.Code != http.StatusForbidden {
|
||||
t.Fatalf("сообщение заблокированному = %d, ожидался 403 (%s)", post.Code, post.Body.String())
|
||||
}
|
||||
if code := errorCodeOf(t, post); code != "dm.blocked" {
|
||||
t.Fatalf("код ошибки = %q", code)
|
||||
}
|
||||
_ = ownerID
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Блокировка пользователя (AGENT.md 7.2, 8.2): запрещает личные сообщения и
|
||||
// заявки в друзья. Хранится той же таблицей связей, что друзья: тип `blocked`
|
||||
// сильнее остальных, поэтому при блокировке встречные связи снимаются.
|
||||
|
||||
// BlockUser блокирует пользователя: дружба и заявки в обе стороны снимаются,
|
||||
// у блокирующего остаётся связь `blocked`.
|
||||
func (s *Store) BlockUser(ctx context.Context, userID, targetID uint64) error {
|
||||
if userID == targetID {
|
||||
return ErrConflict
|
||||
}
|
||||
tx, err := s.writer.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// Снимаем все встречные связи: и дружбу, и заявки.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM relationships WHERE (user_id = ? AND target_id = ?) OR (user_id = ? AND target_id = ?)`,
|
||||
int64(userID), int64(targetID), int64(targetID), int64(userID)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO relationships (user_id, target_id, type, created_at, updated_at)
|
||||
VALUES (?, ?, 'blocked', ?, ?)`,
|
||||
int64(userID), int64(targetID), s.Now(), s.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// UnblockUser снимает блокировку; дружбу после неё нужно заводить заново.
|
||||
func (s *Store) UnblockUser(ctx context.Context, userID, targetID uint64) error {
|
||||
relation, err := s.GetRelationship(ctx, userID, targetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if relation.Type != RelationshipBlocked {
|
||||
return ErrNotFound
|
||||
}
|
||||
return s.RemoveRelationship(ctx, userID, targetID)
|
||||
}
|
||||
|
||||
// IsBlocked сообщает, блокирует ли пользователь другого (в любую сторону).
|
||||
func (s *Store) IsBlocked(ctx context.Context, userID, targetID uint64) (bool, error) {
|
||||
var count int
|
||||
err := s.reader.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM relationships
|
||||
WHERE type = 'blocked'
|
||||
AND ((user_id = ? AND target_id = ?) OR (user_id = ? AND target_id = ?))`,
|
||||
int64(userID), int64(targetID), int64(targetID), int64(userID)).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// ListBlocked возвращает заблокированных пользователей (для списка в клиенте).
|
||||
func (s *Store) ListBlocked(ctx context.Context, userID uint64) ([]RelationshipProfile, error) {
|
||||
return s.ListRelationships(ctx, userID, RelationshipBlocked)
|
||||
}
|
||||
|
||||
// BlockedIDs возвращает множество пользователей, которые заблокированы
|
||||
// пользователем: нужно для фильтрации поиска и списков.
|
||||
func (s *Store) BlockedIDs(ctx context.Context, userID uint64) (map[uint64]bool, error) {
|
||||
rows, err := s.reader.QueryContext(ctx,
|
||||
`SELECT target_id FROM relationships WHERE user_id = ? AND type = 'blocked'`, int64(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
ids := map[uint64]bool{}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids[id] = true
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// DMBlocked проверяет, есть ли среди участников личной беседы тот, кто
|
||||
// блокирует пользователя или кого блокирует он сам: переписка запрещена
|
||||
// (AGENT.md 7.2).
|
||||
func (s *Store) DMBlocked(ctx context.Context, channelID, userID uint64) (bool, error) {
|
||||
rows, err := s.reader.QueryContext(ctx,
|
||||
`SELECT user_id FROM dm_participants WHERE channel_id = ?`, int64(channelID))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
participants := make([]uint64, 0, 2)
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return false, err
|
||||
}
|
||||
participants = append(participants, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, participant := range participants {
|
||||
if participant == userID {
|
||||
continue
|
||||
}
|
||||
blocked, err := s.IsBlocked(ctx, userID, participant)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if blocked {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -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}` — удалить из друзей, отклонить или
|
||||
* отменить заявку. Отдельной ручки блокировки на сервере нет: блокировка —
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user