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
@@ -0,0 +1,147 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { Message } from '@/api/messages';
import { ChatView } from '@/components/chat/ChatView';
import { PinsPanel } from '@/components/chat/PinsPanel';
import { SearchPanel } from '@/components/chat/SearchPanel';
import { useAuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { Avatar } from '@/components/ui/Avatar';
import { Card } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { presenceDotClass } from '@/lib/presence';
import { useMessagesStore } from '@/stores/messages';
import { useSessionStore } from '@/stores/session';
/**
* Личная беседа в области контента: шапка с собеседником и его статусом,
* лента, поиск и закреплённые сообщения — тем же `ChatView`, что и в комнатах
* сервера (права участника беседы даёт сервер, отдельных прав сервера нет).
*/
export function DirectMessageView({ channelId }: { channelId: string }) {
const { t } = useTranslation();
const channel = useSessionStore(
useCallback(
(state) => state.dmChannels.find((item) => item.id === channelId) ?? null,
[channelId],
),
);
const reloadDmChannels = useSessionStore((state) => state.reloadDmChannels);
// Прямая ссылка на беседу: список приходит снапшотом, но после перезагрузки
// страницы его может ещё не быть — подстраховываемся REST-запросом.
useEffect(() => {
if (channel === null) {
void reloadDmChannels();
}
}, [channel, reloadDmChannels]);
const [searchOpen, setSearchOpen] = useState(false);
const [pinsOpen, setPinsOpen] = useState(false);
const [jump, setJump] = useState<{ id: string; token: number } | null>(null);
const messages = useMessagesStore((state) => state.channels[channelId]?.messages);
const authorIds = useMemo(
() =>
(messages ?? [])
.map((message: Message) => message.author_id)
.filter((id): id is string => id !== undefined),
[messages],
);
const authors = useAuthorDirectory(null, authorIds);
const onJumpHandled = useCallback(() => setJump(null), []);
const goToMessage = useCallback((id: string) => {
setJump({ id, token: Date.now() });
}, []);
if (channel === null) {
return (
<section id="app-content" className="absolute inset-0 flex items-center justify-center p-6">
<Card className="max-w-md text-center" data-testid="dm-missing">
<h2 className="text-lg font-semibold">{t('friends.dm.notFound.title')}</h2>
<p className="mt-2 text-sm text-fg-muted">{t('friends.dm.notFound.body')}</p>
<div className="mt-4">
<SkeletonLines rows={2} />
</div>
</Card>
</section>
);
}
const name = channel.name === '' ? t('friends.dm.unknown') : channel.name;
const status = channel.status ?? 'offline';
return (
<section id="app-content" className="absolute inset-0 flex flex-col">
<header className="flex items-center gap-2 border-b border-border/40 px-4 py-2">
<span className="relative shrink-0">
<Avatar
name={name}
seed={channel.recipient_id ?? channel.id}
fileId={channel.icon_file_id}
size="sm"
/>
<span
aria-hidden="true"
data-testid="dm-status"
data-status={status}
className={`absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-surface-1 ${presenceDotClass[status]}`}
/>
</span>
<h2 className="truncate text-sm font-semibold" title={name}>
{name}
</h2>
<span className="sr-only">{t(`user.status.${status}`)}</span>
<div className="ml-auto flex items-center gap-1">
<button
type="button"
aria-label={t('chat.search.open')}
title={t('chat.search.open')}
aria-expanded={searchOpen}
className="rounded-[var(--radius-sm)] px-1.5 text-base leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
onClick={() => setSearchOpen((open) => !open)}
>
🔍
</button>
<button
type="button"
aria-label={t('chat.pins.open')}
title={t('chat.pins.open')}
className="rounded-[var(--radius-sm)] px-1.5 text-base leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
onClick={() => setPinsOpen(true)}
>
📌
</button>
</div>
</header>
<SearchPanel
channelId={channel.id}
open={searchOpen}
onClose={() => setSearchOpen(false)}
authors={authors}
onJump={goToMessage}
/>
<ChatView
key={channel.id}
channel={channel}
guildId={null}
jumpTo={jump}
onJumpHandled={onJumpHandled}
/>
<PinsPanel
channelId={channel.id}
channelName={name}
open={pinsOpen}
onClose={() => setPinsOpen(false)}
authors={authors}
canManageMessages={false}
onJump={(id) => {
setPinsOpen(false);
goToMessage(id);
}}
/>
</section>
);
}
+178
View File
@@ -0,0 +1,178 @@
import { useCallback, useRef, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { removeRelationship, type RelationshipUser } from '@/api/friends';
import { Avatar } from '@/components/ui/Avatar';
import { Menu, MenuItem, MenuSeparator } from '@/components/ui/Menu';
import { Popover } from '@/components/ui/Popover';
import { Button } from '@/components/ui/primitives';
import { DEFAULT_TIMEZONE } from '@/lib/timezones';
import { formatLastSeen, presenceDotClass } from '@/lib/presence';
import { useCurrentUser } from '@/lib/hooks';
import { useFriendsStore } from '@/stores/friends';
import { useOpenDirectChannel } from '@/components/friends/useOpenDirectChannel';
/**
* Строка списка друзей: аватар с точкой статуса, имя (с подсказкой о
* последнем входе) и действия — «Написать» и меню «…».
*
* Подсказка появляется по наведению и по фокусу: имя — кнопка, поэтому
* доступна с клавиатуры, а клик по ней открывает беседу.
*/
export function FriendRow({ friend }: { friend: RelationshipUser }) {
const { t, i18n } = useTranslation();
const currentUser = useCurrentUser();
const reload = useFriendsStore((state) => state.reload);
const openDm = useOpenDirectChannel();
const [hintOpen, setHintOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const nameRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLButtonElement | null>(null);
const remove = useMutation({
mutationFn: () => removeRelationship(friend.user_id),
onSuccess: () => reload(),
});
const displayName = friend.display_name === '' ? friend.username : friend.display_name;
const status = friend.visible_status;
const statusLabel = t(`user.status.${status}`);
// Время последнего входа — в часовом поясе пользователя из настроек.
const time = formatLastSeen(
friend.last_seen_at,
currentUser.data?.timezone ?? DEFAULT_TIMEZONE,
i18n.language,
);
const hint =
status !== 'offline'
? t('friends.lastSeen.online')
: time === null
? t('friends.lastSeen.unknown')
: t('friends.lastSeen.at', { time });
const closeHint = useCallback(() => setHintOpen(false), []);
return (
<li
className="relative flex items-center gap-2 rounded-[var(--radius-md)] px-2 py-1.5 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
data-testid={`friend-row-${friend.user_id}`}
>
<button
ref={nameRef}
type="button"
aria-label={t('friends.open', { name: displayName })}
title={t('friends.open', { name: displayName })}
onClick={() => {
openDm.mutate(friend.user_id);
}}
onMouseEnter={() => setHintOpen(true)}
onMouseLeave={closeHint}
onFocus={() => setHintOpen(true)}
onBlur={closeHint}
className="flex min-w-0 flex-1 items-center gap-3 rounded-[var(--radius-sm)] text-left"
data-testid={`friend-name-${friend.user_id}`}
>
<span className="relative shrink-0">
<Avatar
name={displayName}
seed={friend.user_id}
fileId={friend.avatar_file_id}
size="md"
/>
<span
aria-hidden="true"
data-testid={`friend-status-${friend.user_id}`}
data-status={status}
title={statusLabel}
className={`absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-surface-1 ${presenceDotClass[status]}`}
/>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{displayName}</span>
<span className="block truncate text-xs text-fg-muted">@{friend.username}</span>
</span>
</button>
<span className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
disabled={openDm.isPending}
onClick={() => {
openDm.mutate(friend.user_id);
}}
>
{t('friends.list.message')}
</Button>
<button
ref={menuRef}
type="button"
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-label={t('friends.list.more', { name: displayName })}
title={t('friends.list.more', { name: displayName })}
className="rounded-[var(--radius-sm)] px-1.5 py-1 text-sm leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
onClick={() => setMenuOpen((value) => !value)}
>
<span aria-hidden="true">…</span>
</button>
</span>
<Popover
open={hintOpen}
label={t('friends.lastSeen.label')}
anchorRef={nameRef}
placement="bottom"
onClose={closeHint}
className="w-52"
>
<p className="px-1 py-0.5 text-xs text-fg">{hint}</p>
</Popover>
<Menu
open={menuOpen}
label={t('friends.list.menu', { name: displayName })}
anchorRef={menuRef}
placement="bottom"
align="right"
onClose={() => setMenuOpen(false)}
>
<MenuItem
icon="💬"
onClick={() => {
setMenuOpen(false);
openDm.mutate(friend.user_id);
}}
>
{t('friends.list.message')}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
icon="✖"
disabled={remove.isPending}
onClick={() => {
setMenuOpen(false);
remove.mutate();
}}
>
{t('friends.list.remove')}
</MenuItem>
<MenuItem
danger
icon="⛔"
disabled={remove.isPending}
onClick={() => {
setMenuOpen(false);
// Отдельной ручки блокировки нет: блокировка — тот же DELETE.
remove.mutate();
}}
>
{t('friends.list.block')}
</MenuItem>
</Menu>
</li>
);
}
+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>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { acceptInvite, fetchInvite, inviteQueryKey, parseInviteCode } from '@/api/invites';
import { instanceQueryKey } from '@/api/instance';
import { myGuildsQueryKey } from '@/api/users';
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 { useApiErrorMessage } from '@/lib/useApiErrorMessage';
/** Пауза перед предпросмотром: код или ссылку обычно вставляют целиком. */
export const INVITE_DEBOUNCE_MS = 400;
interface InviteJoinProps {
className?: string;
}
/**
* Присоединение по приглашению: поле для кода или ссылки, предпросмотр
* сервера и кнопка «Присоединиться».
*
* Действует и в разделе друзей, и на пустом экране серверов: новый
* пользователь попадает на сервер только по приглашению.
*/
export function InviteJoin({ className = '' }: InviteJoinProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const describeError = useApiErrorMessage();
const [value, setValue] = useState('');
const [debounced, setDebounced] = useState('');
useEffect(() => {
const timer = window.setTimeout(() => setDebounced(value), INVITE_DEBOUNCE_MS);
return () => {
window.clearTimeout(timer);
};
}, [value]);
const code = parseInviteCode(debounced);
const invalid = debounced.trim() !== '' && code === null;
const preview = useQuery({
queryKey: inviteQueryKey(code ?? ''),
queryFn: ({ signal }) => fetchInvite(code ?? '', signal),
enabled: code !== null,
retry: 0,
});
const join = useMutation({
mutationFn: (target: string) => acceptInvite(target),
onSuccess: (guildId) => {
// Список серверов придёт снапшотом шлюза; REST — подстраховка.
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
// AppLayout подхватит сервер по URL и откроет его первый канал.
void navigate(`/app/${guildId}`);
},
});
return (
<section aria-label={t('invites.title')} className={className} data-testid="invite-join">
<h3 className="text-sm font-semibold">{t('invites.title')}</h3>
<p className="mt-1 text-xs text-fg-muted">{t('invites.hint')}</p>
<form
className="mt-2 flex items-end gap-2"
onSubmit={(event) => {
event.preventDefault();
if (code !== null) {
join.mutate(code);
}
}}
>
<div className="flex-1">
<Field
label={t('invites.label')}
placeholder={t('invites.placeholder')}
value={value}
maxLength={256}
onChange={(event) => {
setValue(event.target.value);
join.reset();
}}
error={invalid ? t('invites.invalid') : undefined}
/>
</div>
<Button type="submit" className="mt-6" disabled={code === null || join.isPending}>
{t(join.isPending ? 'invites.joining' : 'invites.join')}
</Button>
</form>
{code !== null && preview.isFetching ? <SkeletonLines className="mt-3" rows={2} /> : null}
{code !== null && preview.isError ? (
<p role="alert" className="mt-3 text-sm text-danger">
{describeError(preview.error)}
</p>
) : null}
{preview.data === undefined ? null : (
<div
className="mt-3 flex items-center gap-3 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-3 py-2"
data-testid="invite-preview"
>
<Avatar
name={preview.data.guild.name}
seed={preview.data.guild.id}
fileId={preview.data.guild.icon_file_id}
size="md"
shape="square"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{preview.data.guild.name}</span>
<span className="block text-xs text-fg-muted">
{t('invites.members', { count: preview.data.guild.member_count })}
</span>
</span>
{preview.data.guild.is_member ? (
<span className="text-xs text-fg-muted">{t('invites.alreadyMember')}</span>
) : null}
</div>
)}
{join.isError ? <ErrorNotice className="mt-3" error={join.error} /> : null}
</section>
);
}
+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>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { useCallback } from 'react';
import type { Channel } from '@/api/types';
import { unreadCount, useMessagesStore, type ChannelMessagesState } from '@/stores/messages';
/**
* Непрочитанные личной беседы.
*
* Для беседы с загруженной историей счётчик считается как у комнат
* (`unreadCount`). Пока история не загружена, сравниваем `last_message_id`
* канала с отметкой чтения из READY/`READ_STATE_UPDATE`: точного числа в этом
* случае нет, поэтому `exact = false` — в сайдбаре рисуем точку, а не цифру.
*/
export function useDmUnread(channel: Channel): {
unread: number;
mentions: number;
exact: boolean;
} {
const record = useMessagesStore(
useCallback(
(state: { channels: Record<string, ChannelMessagesState> }) => state.channels[channel.id],
[channel.id],
),
);
const mentions = record?.mentionCount ?? 0;
if (record !== undefined && record.messages.length > 0) {
return { unread: unreadCount(record), mentions, exact: true };
}
const known = record?.readStateKnown === true;
const marker = record?.lastReadMessageId ?? null;
const hasNewer = channel.last_message_id !== undefined && channel.last_message_id !== marker;
return { unread: known && hasNewer ? 1 : 0, mentions, exact: false };
}
@@ -0,0 +1,29 @@
import { useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router';
import { openDirectChannel } from '@/api/friends';
import { useSessionStore } from '@/stores/session';
/**
* Открывает личную беседу с пользователем: `POST /users/@me/channels`
* возвращает уже существующий канал или создаёт новый, после чего беседа
* кладётся в снапшот сессии и открывается в области контента.
*/
export function useOpenDirectChannel() {
const navigate = useNavigate();
const upsertDmChannel = useSessionStore((state) => state.upsertDmChannel);
return useMutation({
mutationFn: async (recipientId: string) => {
// Беседа уже открыта — не дёргаем сервер лишний раз.
const existing = useSessionStore
.getState()
.dmChannels.find((channel) => channel.recipient_id === recipientId);
return existing ?? (await openDirectChannel(recipientId));
},
onSuccess: (channel) => {
upsertDmChannel(channel);
void navigate(`/app/friends/${channel.id}`);
},
});
}