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