d02ef7527a
Аватар нельзя было показать в списке друзей: файл без комнаты был доступен только загрузившему. Добавлено назначение файла (`files.purpose`: attachment | avatar | banner, миграция 00006): аватары и баннеры отдаются любому авторизованному пользователю, вложения по-прежнему проверяются по правам комнаты, а непривязанные загрузки остаются приватными.
187 lines
6.8 KiB
TypeScript
187 lines
6.8 KiB
TypeScript
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>
|
|
);
|
|
}
|