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
+186
View File
@@ -0,0 +1,186 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
acceptFriendRequest,
searchUsers,
sendFriendRequest,
userSearchQueryKey,
type RelationshipUser,
} from '@/api/friends';
import { Avatar } from '@/components/ui/Avatar';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Button } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { useOpenDirectChannel } from '@/components/friends/useOpenDirectChannel';
import { useFriendsStore } from '@/stores/friends';
/** Пауза перед запросом: пользователь обычно печатает логин целиком. */
export const SEARCH_DEBOUNCE_MS = 400;
/** Сервер требует минимум два символа (`q` в `GET /users/search`). */
export const SEARCH_MIN_LENGTH = 2;
interface UserSearchProps {
onClose: () => void;
}
/**
* Поиск пользователей по логину с debounce и заявкой в друзья.
*
* Состояние связи берём из стора друзей, а не из ответа поиска: после
* «Добавить в друзья» кнопка сразу становится «Заявка отправлена».
* Компонент монтируется только при открытом поиске — состояние не залипает.
*/
export function UserSearch({ onClose }: UserSearchProps) {
const { t } = useTranslation();
const [value, setValue] = useState('');
const [query, setQuery] = useState('');
const friends = useFriendsStore((state) => state.friends);
const outgoing = useFriendsStore((state) => state.outgoing);
const reload = useFriendsStore((state) => state.reload);
const openDm = useOpenDirectChannel();
useEffect(() => {
const trimmed = value.trim();
const timer = window.setTimeout(() => setQuery(trimmed), SEARCH_DEBOUNCE_MS);
return () => {
window.clearTimeout(timer);
};
}, [value]);
const search = useQuery({
queryKey: userSearchQueryKey(query),
queryFn: ({ signal }) => searchUsers(query, signal),
enabled: query.length >= SEARCH_MIN_LENGTH,
retry: 0,
});
const add = useMutation({
mutationFn: (user: RelationshipUser) => sendFriendRequest({ username: user.username }),
onSuccess: () => reload(),
});
const accept = useMutation({
mutationFn: (userId: string) => acceptFriendRequest(userId),
onSuccess: () => reload(),
});
const results = search.data ?? [];
const tooShort = value.trim().length > 0 && value.trim().length < SEARCH_MIN_LENGTH;
return (
<section
aria-label={t('friends.search.title')}
className="gl-popover-down mb-3 rounded-[var(--radius-lg)] border border-border/60 bg-surface-1 p-3"
data-testid="friends-search"
>
<div className="flex items-start gap-2">
<div className="flex-1">
<Field
label={t('friends.search.label')}
placeholder={t('friends.search.placeholder')}
value={value}
maxLength={32}
autoFocus
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
onClose();
}
}}
hint={tooShort ? t('friends.search.hint') : undefined}
/>
</div>
<Button variant="ghost" className="mt-6" onClick={onClose}>
{t('friends.search.close')}
</Button>
</div>
{search.isFetching ? <SkeletonLines className="mt-3" rows={2} /> : null}
{search.isError ? (
<ErrorNotice
className="mt-3"
error={search.error}
onRetry={() => {
void search.refetch();
}}
/>
) : null}
{!search.isFetching && query.length >= SEARCH_MIN_LENGTH && results.length === 0 ? (
<p className="mt-3 text-sm text-fg-muted">{t('friends.search.empty')}</p>
) : null}
<ul className="gl-stagger mt-2 flex flex-col gap-1">
{results.map((user) => {
const alreadyFriend = friends.some((item) => item.user_id === user.user_id);
const requested =
!alreadyFriend && outgoing.some((item) => item.user_id === user.user_id);
const incoming = !alreadyFriend && user.relationship === 'incoming';
return (
<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={user.display_name}
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">{user.display_name}</span>
<span className="block truncate text-xs text-fg-muted">@{user.username}</span>
</span>
<span className="flex shrink-0 items-center gap-1">
{alreadyFriend ? (
<span className="px-2 text-xs text-fg-muted">{t('friends.search.friend')}</span>
) : requested ? (
<span className="px-2 text-xs text-fg-muted">
{t('friends.search.requested')}
</span>
) : incoming ? (
<Button
disabled={accept.isPending}
onClick={() => {
accept.mutate(user.user_id);
}}
>
{t('friends.requests.accept')}
</Button>
) : (
<Button
variant="ghost"
disabled={add.isPending}
onClick={() => {
add.reset();
add.mutate(user);
}}
>
{t(add.isPending ? 'friends.search.adding' : 'friends.search.add')}
</Button>
)}
<Button
disabled={openDm.isPending}
onClick={() => {
openDm.mutate(user.user_id);
}}
>
{t('friends.search.message')}
</Button>
</span>
</li>
);
})}
</ul>
{add.isError ? <ErrorNotice className="mt-3" error={add.error} /> : null}
{accept.isError ? <ErrorNotice className="mt-3" error={accept.error} /> : null}
{openDm.isError ? <ErrorNotice className="mt-3" error={openDm.error} /> : null}
</section>
);
}