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 { fetchGuild, guildQueryKey } from '@/api/guilds'; import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users'; import { GatewayBridge } from '@/components/GatewayBridge'; import { VoiceSpeakingNotice } from '@/components/voice/VoiceSpeakingNotice'; 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 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 openGuildCreate = useUiStore((state) => state.openGuildCreate); const segments = location.pathname.split('/').filter((segment) => segment !== ''); 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 && channelId === selectedChannelId) { return; } selectChannel(guildId, channelId); }, [guildId, channelId, selectedGuildId, selectedChannelId, selectChannel]); // `/app/empty` — служебный маршрут, а не идентификатор сервера. const realGuildId = guildId === 'empty' ? null : guildId; // Сервер появился (создали свой или вступили по приглашению) — открываем его. // В разделе друзей ничего не переключаем: там свой маршрут. useEffect(() => { if (isFriends || realGuildId !== null || guilds.length === 0) { return; } const first = guilds[0]; if (first === undefined) { return; } const channel = useSessionStore.getState().firstChannelId(first.id); void navigate(channel === null ? `/app/${first.id}` : `/app/${first.id}/${channel}`, { replace: true, }); }, [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, queryFn: ({ signal }) => fetchMyGuilds(signal), retry: 0, }); // Список из снапшота шлюза; если он пуст — берём REST-ответ. useEffect(() => { if (guilds.length > 0 || myGuilds.data === undefined) { return; } for (const guild of myGuilds.data) { upsertGuild(guild); } }, [guilds.length, myGuilds.data, upsertGuild]); const guildInSnapshot = realGuildId !== null && guilds.some((guild) => guild.id === realGuildId); const fallbackGuild = useQuery({ queryKey: guildQueryKey(realGuildId ?? ''), queryFn: ({ signal }) => fetchGuild(realGuildId ?? '', signal), enabled: realGuildId !== null && !guildInSnapshot, retry: 0, }); const hydrated = useRef(null); // Сервер есть в URL, но отсутствует в снапшоте (после RESUME или вступления // по приглашению). useEffect(() => { const guild = fallbackGuild.data; if (guild === undefined || hydrated.current === guild.id) { return; } hydrated.current = guild.id; upsertGuild({ id: guild.id, name: guild.name, owner_id: guild.owner_id, is_main: guild.is_main, member_count: guild.member_count, my_role_ids: guild.my_role_ids, my_permissions: guild.my_permissions, ...(guild.icon_file_id === undefined ? {} : { icon_file_id: guild.icon_file_id }), }); }, [fallbackGuild.data, upsertGuild]); const guildList = useMemo( () => guilds.map((guild) => ({ id: guild.id, name: guild.name })), [guilds], ); const canCreate = instance.data?.allow_guild_creation !== false; return (
{t('app.skipToContent')}
{/* Левая колонка: сайдбар раздела и панель пользователя под ним. Ширину задаёт колонка, поэтому сайдбары её больше не дублируют. */}
{isFriends ? ( ) : ( )}
{/* Ключ по пути: смена комнаты/сервера появляется с анимацией. `relative` нужен чату: его область позиционируется absolute inset-0 и скроллится изнутри, а не всей страницей. */}
{/* Подсказка про выход с сервера — внизу области контента. */} {realGuildId === null ? null : (

{t('channels.leaveHint')}

)}
{/* Подсказка «вы говорите с выключенным микрофоном» — поверх любого экрана. */}
); }