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 {
|
if err != nil {
|
||||||
return nil, err
|
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 секунд на комнату и пользователя,
|
// Антиспам-лимит: 5 сообщений за 5 секунд на комнату и пользователя,
|
||||||
// администратор инстанса лимит обходит (AGENT.md 8.6, 7.19).
|
// администратор инстанса лимит обходит (AGENT.md 8.6, 7.19).
|
||||||
if !user.IsInstanceAdmin {
|
if !user.IsInstanceAdmin {
|
||||||
|
|||||||
@@ -168,6 +168,12 @@ func (s *Server) registerSocialRoutes(api huma.API) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
reverse, err := s.store.GetRelationship(ctx, target.ID, user.ID)
|
||||||
switch {
|
switch {
|
||||||
@@ -252,6 +258,65 @@ func (s *Server) registerSocialRoutes(api huma.API) {
|
|||||||
return newOKOutput(), nil
|
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{
|
huma.Register(api, huma.Operation{
|
||||||
OperationID: "openDirectChannel",
|
OperationID: "openDirectChannel",
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
@@ -280,6 +345,12 @@ func (s *Server) registerSocialRoutes(api huma.API) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, humaError(err)
|
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)
|
channel, err := s.store.FindDMChannel(ctx, user.ID, recipientID)
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
channel, err = s.store.CreateDMChannel(ctx, user.ID, recipientID)
|
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())
|
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}` — удалить из друзей, отклонить или
|
* `DELETE /users/@me/relationships/{id}` — удалить из друзей, отклонить или
|
||||||
* отменить заявку. Отдельной ручки блокировки на сервере нет: блокировка —
|
* отменить заявку. Отдельной ручки блокировки на сервере нет: блокировка —
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useRef, useState } from 'react';
|
|||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
||||||
import { SystemBadges } from '@/components/profile/SystemBadges';
|
import { SystemBadges } from '@/components/profile/SystemBadges';
|
||||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
@@ -33,6 +33,11 @@ export function FriendRow({ friend }: { friend: RelationshipUser }) {
|
|||||||
const nameRef = useRef<HTMLButtonElement | null>(null);
|
const nameRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const menuRef = useRef<HTMLButtonElement | null>(null);
|
const menuRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
const block = useMutation({
|
||||||
|
mutationFn: () => blockUser(friend.user_id),
|
||||||
|
onSuccess: () => reload(),
|
||||||
|
});
|
||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: () => removeRelationship(friend.user_id),
|
mutationFn: () => removeRelationship(friend.user_id),
|
||||||
onSuccess: () => reload(),
|
onSuccess: () => reload(),
|
||||||
@@ -180,11 +185,12 @@ export function FriendRow({ friend }: { friend: RelationshipUser }) {
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
danger
|
danger
|
||||||
icon="⛔"
|
icon="⛔"
|
||||||
disabled={remove.isPending}
|
data-testid={`friend-block-${friend.user_id}`}
|
||||||
|
disabled={block.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
// Отдельной ручки блокировки нет: блокировка — тот же DELETE.
|
// Блокировка запрещает личные сообщения и снимает дружбу (AGENT.md 7.2).
|
||||||
remove.mutate();
|
block.mutate();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('friends.list.block')}
|
{t('friends.list.block')}
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ import { useEffect, useId, useState } from 'react';
|
|||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { FriendRow } from '@/components/friends/FriendRow';
|
||||||
|
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
||||||
import { InviteJoin } from '@/components/friends/InviteJoin';
|
import { InviteJoin } from '@/components/friends/InviteJoin';
|
||||||
import { UserSearch } from '@/components/friends/UserSearch';
|
import { UserSearch } from '@/components/friends/UserSearch';
|
||||||
import { Avatar } from '@/components/ui/Avatar';
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
@@ -29,6 +35,7 @@ export function FriendsView() {
|
|||||||
const friends = useFriendsStore((state) => state.friends);
|
const friends = useFriendsStore((state) => state.friends);
|
||||||
const incoming = useFriendsStore((state) => state.incoming);
|
const incoming = useFriendsStore((state) => state.incoming);
|
||||||
const outgoing = useFriendsStore((state) => state.outgoing);
|
const outgoing = useFriendsStore((state) => state.outgoing);
|
||||||
|
const blocked = useFriendsStore((state) => state.blocked);
|
||||||
const status = useFriendsStore((state) => state.status);
|
const status = useFriendsStore((state) => state.status);
|
||||||
const error = useFriendsStore((state) => state.error);
|
const error = useFriendsStore((state) => state.error);
|
||||||
const load = useFriendsStore((state) => state.load);
|
const load = useFriendsStore((state) => state.load);
|
||||||
@@ -44,6 +51,12 @@ export function FriendsView() {
|
|||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
// Снятие блокировки: список перечитывается, человек снова может писать.
|
||||||
|
const unblock = useMutation({
|
||||||
|
mutationFn: (userId: string) => unblockUser(userId),
|
||||||
|
onSuccess: () => reload(),
|
||||||
|
});
|
||||||
|
|
||||||
const respond = useMutation({
|
const respond = useMutation({
|
||||||
mutationFn: ({ kind, userId }: { kind: 'accept' | 'decline' | 'cancel'; userId: string }) =>
|
mutationFn: ({ kind, userId }: { kind: 'accept' | 'decline' | 'cancel'; userId: string }) =>
|
||||||
kind === 'accept' ? acceptFriendRequest(userId) : removeRelationship(userId),
|
kind === 'accept' ? acceptFriendRequest(userId) : removeRelationship(userId),
|
||||||
@@ -222,7 +235,49 @@ export function FriendsView() {
|
|||||||
</section>
|
</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}
|
{respond.isError ? <ErrorNotice className="mt-3" error={respond.error} /> : null}
|
||||||
|
{unblock.isError ? <ErrorNotice className="mt-3" error={unblock.error} /> : null}
|
||||||
|
|
||||||
<InviteJoin className="mt-8" />
|
<InviteJoin className="mt-8" />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -418,6 +418,11 @@
|
|||||||
"title": "Conversation unavailable",
|
"title": "Conversation unavailable",
|
||||||
"body": "This direct conversation was not found: it may be deleted or closed to you."
|
"body": "This direct conversation was not found: it may be deleted or closed to you."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"blocked": {
|
||||||
|
"section": "Blocked ({{count}})",
|
||||||
|
"unblock": "Unblock",
|
||||||
|
"alreadyBlocked": "Blocked"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"invites": {
|
"invites": {
|
||||||
|
|||||||
@@ -418,6 +418,11 @@
|
|||||||
"title": "Беседа недоступна",
|
"title": "Беседа недоступна",
|
||||||
"body": "Личная беседа не найдена: возможно, она удалена или доступ к ней закрыт."
|
"body": "Личная беседа не найдена: возможно, она удалена или доступ к ней закрыт."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"blocked": {
|
||||||
|
"section": "Заблокированные ({{count}})",
|
||||||
|
"unblock": "Разблокировать",
|
||||||
|
"alreadyBlocked": "Заблокирован"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"invites": {
|
"invites": {
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
kickGuildMember,
|
kickGuildMember,
|
||||||
timeoutGuildMember,
|
timeoutGuildMember,
|
||||||
} from '@/api/moderation';
|
} from '@/api/moderation';
|
||||||
|
import { blockUser } from '@/api/friends';
|
||||||
import { Menu, MenuGroupLabel, MenuItem, MenuSeparator } from '@/components/ui/Menu';
|
import { Menu, MenuGroupLabel, MenuItem, MenuSeparator } from '@/components/ui/Menu';
|
||||||
|
import { useFriendsStore } from '@/stores/friends';
|
||||||
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||||
|
|
||||||
/** Пресеты тайм-аута в минутах: 0 — снять ограничение. */
|
/** Пресеты тайм-аута в минутах: 0 — снять ограничение. */
|
||||||
@@ -64,6 +66,17 @@ export function MemberModerationMenu({
|
|||||||
close();
|
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({
|
const kick = useMutation({
|
||||||
mutationFn: () => kickGuildMember(guildId, userId),
|
mutationFn: () => kickGuildMember(guildId, userId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -162,6 +175,16 @@ export function MemberModerationMenu({
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
) : null}
|
) : 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 : (
|
{error === null || error === undefined ? null : (
|
||||||
<p className="px-2 py-1 text-xs text-danger">{describeError(error)}</p>
|
<p className="px-2 py-1 text-xs text-danger">{describeError(error)}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
messagesRoutes,
|
messagesRoutes,
|
||||||
recordedRequests,
|
recordedRequests,
|
||||||
renderApp,
|
renderApp,
|
||||||
|
waitForPage,
|
||||||
type FetchRoute,
|
type FetchRoute,
|
||||||
} from './helpers';
|
} 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