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