Files
glchat/web/src/components/friends/FriendsView.tsx
T
grendervill ed98c78c9e feat(social): блокировка пользователей
- PUT/DELETE /users/@me/blocks/{id}: блокировка снимает дружбу и заявки в обе
  стороны, разблокировка возвращает возможность писать (AGENT.md 7.2)
- блокировка запрещает личные сообщения (ошибка dm.blocked), открытие беседы и
  заявки в друзья с любой стороны
- клиент: раздел «Заблокированные» в друзьях со снятием блокировки, действие
  «Заблокировать» в меню строки друга и в меню модерации участника
- тесты: Go (блокировка, заявки, беседа, сообщение) и Vitest (список, снятие,
  блокировка из меню)
2026-09-21 19:21:00 +03:00

286 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useId, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
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';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { Button } from '@/components/ui/primitives';
import { useFriendsStore } from '@/stores/friends';
import { useSessionStore } from '@/stores/session';
import { useUiStore } from '@/stores/ui';
/** Имя для заявки: список связей не хранит отдельного «ника». */
function displayName(user: RelationshipUser): string {
return user.display_name === '' ? user.username : user.display_name;
}
/**
* Раздел «Друзья»: заявки, список друзей, поиск по логину и присоединение
* по приглашению. Личные беседы открываются в области контента (см.
* `DirectMessageView`), а их список живёт в сайдбаре раздела.
*/
export function FriendsView() {
const { t } = useTranslation();
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);
const reload = useFriendsStore((state) => state.reload);
const dmChannels = useSessionStore((state) => state.dmChannels);
const listOpen = useUiStore((state) => state.friendsListOpen);
const toggleFriendsList = useUiStore((state) => state.toggleFriendsList);
const listId = useId();
const [searchOpen, setSearchOpen] = useState(false);
useEffect(() => {
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),
onSuccess: () => reload(),
});
// Пусто — только когда нет ни друзей, ни бесед, ни заявок: иначе заявку
// было бы не увидеть.
const isEmpty =
friends.length === 0 &&
dmChannels.length === 0 &&
incoming.length === 0 &&
outgoing.length === 0;
return (
<section id="app-content" className="absolute inset-0 overflow-y-auto p-4">
<header className="flex items-center gap-2">
<h2 className="text-sm font-semibold">{t('friends.title')}</h2>
<Button
variant="ghost"
className="ml-auto"
aria-expanded={searchOpen}
onClick={() => setSearchOpen((value) => !value)}
>
{t('friends.search.open')}
</Button>
</header>
<div className="mt-3">
{searchOpen ? <UserSearch onClose={() => setSearchOpen(false)} /> : null}
</div>
{status === 'error' ? (
<ErrorNotice
error={error}
onRetry={() => {
void reload();
}}
/>
) : null}
{status === 'loading' && friends.length === 0 ? <SkeletonLines rows={3} /> : null}
{incoming.length === 0 ? null : (
<section aria-label={t('friends.requests.incoming')} className="mt-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-fg-muted">
{t('friends.requests.incoming')}
</h3>
<ul className="gl-stagger mt-1 flex flex-col gap-1" data-testid="friends-incoming">
{incoming.map((user) => (
<li
key={user.user_id}
className="flex items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 hover:bg-surface-2"
>
<Avatar
name={displayName(user)}
seed={user.user_id}
fileId={user.avatar_file_id}
size="md"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{displayName(user)}</span>
<span className="block truncate text-xs text-fg-muted">@{user.username}</span>
</span>
<span className="flex shrink-0 items-center gap-1">
<Button
disabled={respond.isPending}
onClick={() => {
respond.mutate({ kind: 'accept', userId: user.user_id });
}}
>
{t('friends.requests.accept')}
</Button>
<Button
variant="ghost"
disabled={respond.isPending}
onClick={() => {
respond.mutate({ kind: 'decline', userId: user.user_id });
}}
>
{t('friends.requests.decline')}
</Button>
</span>
</li>
))}
</ul>
</section>
)}
{outgoing.length === 0 ? null : (
<section aria-label={t('friends.requests.outgoing')} className="mt-4">
<h3 className="text-xs font-semibold uppercase tracking-wide text-fg-muted">
{t('friends.requests.outgoing')}
</h3>
<ul className="gl-stagger mt-1 flex flex-col gap-1" data-testid="friends-outgoing">
{outgoing.map((user) => (
<li
key={user.user_id}
className="flex items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 hover:bg-surface-2"
>
<Avatar
name={displayName(user)}
seed={user.user_id}
fileId={user.avatar_file_id}
size="md"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{displayName(user)}</span>
<span className="block truncate text-xs text-fg-muted">@{user.username}</span>
</span>
<Button
variant="ghost"
disabled={respond.isPending}
onClick={() => {
respond.mutate({ kind: 'cancel', userId: user.user_id });
}}
>
{t('friends.requests.cancel')}
</Button>
</li>
))}
</ul>
</section>
)}
{isEmpty ? (
<div
className="flex flex-col items-center justify-center gap-3 px-4 py-12 text-center"
data-testid="friends-empty"
>
<h3 className="text-lg font-semibold">{t('friends.empty.title')}</h3>
<p className="max-w-md text-sm text-fg-muted">{t('friends.empty.body')}</p>
<Button onClick={() => setSearchOpen(true)}>{t('friends.empty.search')}</Button>
</div>
) : (
<section aria-label={t('friends.list.label')} className="mt-4">
{/*
Список свёрнут по умолчанию: раскрывается снизу вверх, состояние
переживает перезагрузку страницы (zustand + localStorage).
*/}
<button
type="button"
aria-expanded={listOpen}
aria-controls={listId}
data-testid="friends-list-toggle"
disabled={friends.length === 0}
className="flex items-center gap-1 rounded-[var(--radius-sm)] px-2 py-1 text-xs font-semibold uppercase tracking-wide text-fg-muted transition-colors duration-[var(--duration-fast)] hover:text-fg disabled:cursor-default disabled:hover:text-fg-muted"
onClick={toggleFriendsList}
>
<span aria-hidden="true" className={listOpen ? 'gl-caret-open' : 'gl-caret'}>
▾
</span>
{t('friends.list.toggle', { count: friends.length })}
</button>
<div
id={listId}
className="gl-reveal-up"
data-state={listOpen ? 'open' : 'closed'}
data-testid="friends-list-region"
aria-hidden={listOpen ? undefined : true}
>
{!listOpen ? null : friends.length === 0 ? (
<p className="px-2 py-1 text-sm text-fg-muted">{t('friends.friendsEmpty')}</p>
) : (
<ul
className="gl-stagger mt-1 flex flex-col gap-0.5"
data-testid="friends-list"
aria-label={t('friends.list.label')}
>
{friends.map((friend) => (
<FriendRow key={friend.user_id} friend={friend} />
))}
</ul>
)}
</div>
</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>
);
}