2026-09-20 11:24:09 +03:00
|
|
|
|
import { useEffect, useId, useState } from 'react';
|
2026-09-20 01:39:04 +03:00
|
|
|
|
import { useMutation } from '@tanstack/react-query';
|
|
|
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
|
|
|
2026-09-21 19:21:00 +03:00
|
|
|
|
import {
|
|
|
|
|
|
acceptFriendRequest,
|
|
|
|
|
|
removeRelationship,
|
|
|
|
|
|
unblockUser,
|
|
|
|
|
|
type RelationshipUser,
|
|
|
|
|
|
} from '@/api/friends';
|
2026-09-20 01:39:04 +03:00
|
|
|
|
import { FriendRow } from '@/components/friends/FriendRow';
|
2026-09-21 19:21:00 +03:00
|
|
|
|
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
2026-09-20 01:39:04 +03:00
|
|
|
|
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';
|
2026-09-20 11:24:09 +03:00
|
|
|
|
import { useUiStore } from '@/stores/ui';
|
2026-09-20 01:39:04 +03:00
|
|
|
|
|
|
|
|
|
|
/** Имя для заявки: список связей не хранит отдельного «ника». */
|
|
|
|
|
|
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);
|
2026-09-21 19:21:00 +03:00
|
|
|
|
const blocked = useFriendsStore((state) => state.blocked);
|
2026-09-20 01:39:04 +03:00
|
|
|
|
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);
|
2026-09-20 11:24:09 +03:00
|
|
|
|
const listOpen = useUiStore((state) => state.friendsListOpen);
|
|
|
|
|
|
const toggleFriendsList = useUiStore((state) => state.toggleFriendsList);
|
|
|
|
|
|
const listId = useId();
|
2026-09-20 01:39:04 +03:00
|
|
|
|
|
|
|
|
|
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
void load();
|
|
|
|
|
|
}, [load]);
|
|
|
|
|
|
|
2026-09-21 19:21:00 +03:00
|
|
|
|
// Снятие блокировки: список перечитывается, человек снова может писать.
|
|
|
|
|
|
const unblock = useMutation({
|
|
|
|
|
|
mutationFn: (userId: string) => unblockUser(userId),
|
|
|
|
|
|
onSuccess: () => reload(),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-20 01:39:04 +03:00
|
|
|
|
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)}
|
|
|
|
|
|
>
|
2026-09-20 01:52:27 +03:00
|
|
|
|
{t('friends.search.open')}
|
2026-09-20 01:39:04 +03:00
|
|
|
|
</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">
|
2026-09-20 11:24:09 +03:00
|
|
|
|
{/*
|
|
|
|
|
|
Список свёрнут по умолчанию: раскрывается снизу вверх, состояние
|
|
|
|
|
|
переживает перезагрузку страницы (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>
|
2026-09-20 01:39:04 +03:00
|
|
|
|
</section>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-09-21 19:21:00 +03:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-09-20 01:39:04 +03:00
|
|
|
|
{respond.isError ? <ErrorNotice className="mt-3" error={respond.error} /> : null}
|
2026-09-21 19:21:00 +03:00
|
|
|
|
{unblock.isError ? <ErrorNotice className="mt-3" error={unblock.error} /> : null}
|
2026-09-20 01:39:04 +03:00
|
|
|
|
|
|
|
|
|
|
<InviteJoin className="mt-8" />
|
|
|
|
|
|
</section>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|