fix(files): аватары и баннеры доступны авторизованным пользователям
Аватар нельзя было показать в списке друзей: файл без комнаты был доступен только загрузившему. Добавлено назначение файла (`files.purpose`: attachment | avatar | banner, миграция 00006): аватары и баннеры отдаются любому авторизованному пользователю, вложения по-прежнему проверяются по правам комнаты, а непривязанные загрузки остаются приватными.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user