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
+49 -90
View File
@@ -1,62 +1,65 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Outlet, useLocation, useNavigate } from 'react-router';
import { createGuild, fetchGuild, guildQueryKey } from '@/api/guilds';
import { fetchGuild, guildQueryKey } from '@/api/guilds';
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
import { instanceQueryKey } from '@/api/instance';
import { GatewayBridge } from '@/components/GatewayBridge';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/primitives';
import { useCurrentUser, useInstance } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { useUiStore } from '@/stores/ui';
import { ChannelSidebar } from '@/pages/app/ChannelSidebar';
import { CreateGuildModal } from '@/pages/app/CreateGuildModal';
import { FriendsSidebar } from '@/pages/app/FriendsSidebar';
import { GuildRail } from '@/pages/app/GuildRail';
import { UserPanel } from '@/pages/app/UserPanel';
/**
* Оболочка приложения: рейка серверов, сайдбар комнат, контент и панель
* пользователя. Данные серверов приходят из снапшота шлюза (соединение
* Оболочка приложения: рейка серверов, сайдбар комнат (или друзей), контент и
* панель пользователя. Данные серверов приходят из снапшота шлюза (соединение
* поднимает `GatewayBridge`), а пока шлюз не подключился — подстраховываемся
* REST-запросом `GET /users/@me/guilds`.
*
* Раздел «Друзья» — такой же раздел первого уровня, как сервер: он занимает
* `/app/friends` и `/app/friends/:channelId`, но не является сервером.
*/
export default function AppLayout() {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const location = useLocation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const instance = useInstance();
const guilds = useSessionStore((state) => state.guilds);
const selectedGuildId = useSessionStore((state) => state.selectedGuildId);
const selectedChannelId = useSessionStore((state) => state.selectedChannelId);
const selectChannel = useSessionStore((state) => state.selectChannel);
const upsertGuild = useSessionStore((state) => state.upsertGuild);
const [createOpen, setCreateOpen] = useState(false);
const [guildName, setGuildName] = useState('');
const openGuildCreate = useUiStore((state) => state.openGuildCreate);
const segments = location.pathname.split('/').filter((segment) => segment !== '');
const guildId = segments[1] === undefined ? null : decodeURIComponent(segments[1]);
const channelId = segments[2] === undefined ? null : decodeURIComponent(segments[2]);
const isFriends = segments[1] === 'friends';
const guildId = isFriends || segments[1] === undefined ? null : decodeURIComponent(segments[1]);
const channelId = isFriends || segments[2] === undefined ? null : decodeURIComponent(segments[2]);
const dmChannelId =
isFriends && segments[2] !== undefined ? decodeURIComponent(segments[2]) : null;
// URL — источник правды для выбранного сервера и комнаты.
useEffect(() => {
if (guildId === selectedGuildId) {
if (guildId === selectedGuildId && channelId === selectedChannelId) {
return;
}
selectChannel(guildId, channelId);
}, [guildId, channelId, selectedGuildId, selectChannel]);
}, [guildId, channelId, selectedGuildId, selectedChannelId, selectChannel]);
// `/app/empty` — служебный маршрут, а не идентификатор сервера.
const realGuildId = guildId === 'empty' ? null : guildId;
// Сервер появился (вступили в главный или создали свой) — сразу открываем его.
// Сервер появился (создали свой или вступили по приглашению) — открываем его.
// В разделе друзей ничего не переключаем: там свой маршрут.
useEffect(() => {
if (realGuildId !== null || guilds.length === 0) {
if (isFriends || realGuildId !== null || guilds.length === 0) {
return;
}
const first = guilds[0];
@@ -67,7 +70,23 @@ export default function AppLayout() {
void navigate(channel === null ? `/app/${first.id}` : `/app/${first.id}/${channel}`, {
replace: true,
});
}, [realGuildId, guilds, navigate]);
}, [isFriends, realGuildId, guilds, navigate]);
// Сервер выбран, а комната ещё нет (например, сразу после вступления по
// приглашению): открываем его общий канал, как только список загрузился.
const selectedGuild = useMemo(
() => (realGuildId === null ? null : (guilds.find((g) => g.id === realGuildId) ?? null)),
[realGuildId, guilds],
);
useEffect(() => {
if (realGuildId === null || channelId !== null || selectedGuild === null) {
return;
}
const channel = useSessionStore.getState().firstChannelId(realGuildId);
if (channel !== null && channel !== channelId) {
void navigate(`/app/${realGuildId}/${channel}`, { replace: true });
}
}, [realGuildId, channelId, selectedGuild, navigate]);
const myGuilds = useQuery({
queryKey: myGuildsQueryKey,
@@ -94,7 +113,8 @@ export default function AppLayout() {
});
const hydrated = useRef<string | null>(null);
// Сервер есть в URL, но отсутствует в снапшоте (например, после RESUME).
// Сервер есть в URL, но отсутствует в снапшоте (после RESUME или вступления
// по приглашению).
useEffect(() => {
const guild = fallbackGuild.data;
if (guild === undefined || hydrated.current === guild.id) {
@@ -113,30 +133,12 @@ export default function AppLayout() {
});
}, [fallbackGuild.data, upsertGuild]);
const create = useMutation({
mutationFn: (name: string) => createGuild(name),
onSuccess: (guild) => {
setCreateOpen(false);
setGuildName('');
upsertGuild(guild);
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
void navigate(`/app/${guild.id}`);
},
});
const closeCreate = useCallback(() => {
setCreateOpen(false);
}, []);
const guildList = useMemo(
() => guilds.map((guild) => ({ id: guild.id, name: guild.name })),
[guilds],
);
const canCreate = instance.data?.allow_guild_creation !== false;
const limitReached =
instance.data !== undefined && guilds.length >= instance.data.max_guilds_per_user;
return (
<div className="flex h-full min-h-full flex-col">
@@ -152,9 +154,13 @@ export default function AppLayout() {
guilds={guildList}
selectedGuildId={guildId}
canCreate={canCreate}
onCreate={() => setCreateOpen(true)}
onCreate={openGuildCreate}
/>
<ChannelSidebar guildId={guildId} channelId={channelId} />
{isFriends ? (
<FriendsSidebar channelId={dmChannelId} />
) : (
<ChannelSidebar guildId={guildId} channelId={channelId} />
)}
<div className="flex min-w-0 flex-1 flex-col">
{/*
Ключ по пути: смена комнаты/сервера появляется с анимацией.
@@ -168,54 +174,7 @@ export default function AppLayout() {
</div>
</div>
<Modal
open={createOpen}
title={t('guilds.create.title')}
onClose={closeCreate}
footer={
<>
<Button variant="ghost" onClick={closeCreate}>
{t('common.cancel')}
</Button>
<Button
onClick={() => create.mutate(guildName.trim())}
disabled={create.isPending || guildName.trim().length === 0}
>
{t(create.isPending ? 'common.creating' : 'guilds.create.submit')}
</Button>
</>
}
>
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
if (guildName.trim() !== '') {
create.mutate(guildName.trim());
}
}}
>
<Field
label={t('guilds.create.name')}
placeholder={t('guilds.create.namePlaceholder')}
value={guildName}
onChange={(event) => setGuildName(event.target.value)}
maxLength={64}
hint={t('guilds.create.hint')}
/>
{instance.data === undefined ? null : (
<p className="text-xs text-fg-muted">
{t('guilds.create.limit', { count: instance.data.max_guilds_per_user })}
</p>
)}
{limitReached && !create.isError ? (
<p className="text-xs text-warning" role="status">
{t('guilds.create.limitReached')}
</p>
) : null}
{create.isError ? <ErrorNotice error={create.error} /> : null}
</form>
</Modal>
<CreateGuildModal />
</div>
);
}