fix(files): аватары и баннеры доступны авторизованным пользователям

Аватар нельзя было показать в списке друзей: файл без комнаты был доступен
только загрузившему. Добавлено назначение файла (`files.purpose`: attachment |
avatar | banner, миграция 00006): аватары и баннеры отдаются любому
авторизованному пользователю, вложения по-прежнему проверяются по правам
комнаты, а непривязанные загрузки остаются приватными.
This commit is contained in:
2026-09-20 01:39:04 +03:00
parent 20eed227ca
commit d02ef7527a
35 changed files with 2330 additions and 191 deletions
+203
View File
@@ -0,0 +1,203 @@
import { useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { acceptFriendRequest, removeRelationship, type RelationshipUser } from '@/api/friends';
import { FriendRow } from '@/components/friends/FriendRow';
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';
/** Имя для заявки: список связей не хранит отдельного «ника». */
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 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 [searchOpen, setSearchOpen] = useState(false);
useEffect(() => {
void load();
}, [load]);
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.empty.search')}
</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">
<h3 className="text-xs font-semibold uppercase tracking-wide text-fg-muted">
{t('friends.friendsSection')}
</h3>
{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>
)}
</section>
)}
{respond.isError ? <ErrorNotice className="mt-3" error={respond.error} /> : null}
<InviteJoin className="mt-8" />
</section>
);
}