2026-09-20 01:39:04 +03:00
|
|
|
import { useEffect, useMemo, useRef } from 'react';
|
|
|
|
|
import { useQuery } from '@tanstack/react-query';
|
2026-09-19 21:50:06 +03:00
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
|
import { Outlet, useLocation, useNavigate } from 'react-router';
|
|
|
|
|
|
2026-09-20 01:39:04 +03:00
|
|
|
import { fetchGuild, guildQueryKey } from '@/api/guilds';
|
2026-09-19 21:50:06 +03:00
|
|
|
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
|
2026-09-19 22:03:46 +03:00
|
|
|
import { GatewayBridge } from '@/components/GatewayBridge';
|
|
|
|
|
import { useCurrentUser, useInstance } from '@/lib/hooks';
|
2026-09-19 21:50:06 +03:00
|
|
|
import { useSessionStore } from '@/stores/session';
|
2026-09-20 01:39:04 +03:00
|
|
|
import { useUiStore } from '@/stores/ui';
|
2026-09-19 21:50:06 +03:00
|
|
|
import { ChannelSidebar } from '@/pages/app/ChannelSidebar';
|
2026-09-20 01:39:04 +03:00
|
|
|
import { CreateGuildModal } from '@/pages/app/CreateGuildModal';
|
|
|
|
|
import { FriendsSidebar } from '@/pages/app/FriendsSidebar';
|
2026-09-19 21:50:06 +03:00
|
|
|
import { GuildRail } from '@/pages/app/GuildRail';
|
|
|
|
|
import { UserPanel } from '@/pages/app/UserPanel';
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-20 01:39:04 +03:00
|
|
|
* Оболочка приложения: рейка серверов, сайдбар комнат (или друзей), контент и
|
|
|
|
|
* панель пользователя. Данные серверов приходят из снапшота шлюза (соединение
|
2026-09-19 22:03:46 +03:00
|
|
|
* поднимает `GatewayBridge`), а пока шлюз не подключился — подстраховываемся
|
|
|
|
|
* REST-запросом `GET /users/@me/guilds`.
|
2026-09-20 01:39:04 +03:00
|
|
|
*
|
|
|
|
|
* Раздел «Друзья» — такой же раздел первого уровня, как сервер: он занимает
|
|
|
|
|
* `/app/friends` и `/app/friends/:channelId`, но не является сервером.
|
2026-09-19 21:50:06 +03:00
|
|
|
*/
|
|
|
|
|
export default function AppLayout() {
|
|
|
|
|
const { t } = useTranslation();
|
2026-09-19 22:03:46 +03:00
|
|
|
const currentUser = useCurrentUser();
|
2026-09-19 21:50:06 +03:00
|
|
|
const location = useLocation();
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
const instance = useInstance();
|
|
|
|
|
|
|
|
|
|
const guilds = useSessionStore((state) => state.guilds);
|
|
|
|
|
const selectedGuildId = useSessionStore((state) => state.selectedGuildId);
|
2026-09-20 01:39:04 +03:00
|
|
|
const selectedChannelId = useSessionStore((state) => state.selectedChannelId);
|
2026-09-19 21:50:06 +03:00
|
|
|
const selectChannel = useSessionStore((state) => state.selectChannel);
|
|
|
|
|
const upsertGuild = useSessionStore((state) => state.upsertGuild);
|
2026-09-20 01:39:04 +03:00
|
|
|
const openGuildCreate = useUiStore((state) => state.openGuildCreate);
|
2026-09-19 21:50:06 +03:00
|
|
|
|
|
|
|
|
const segments = location.pathname.split('/').filter((segment) => segment !== '');
|
2026-09-20 01:39:04 +03:00
|
|
|
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;
|
2026-09-19 21:50:06 +03:00
|
|
|
|
|
|
|
|
// URL — источник правды для выбранного сервера и комнаты.
|
|
|
|
|
useEffect(() => {
|
2026-09-20 01:39:04 +03:00
|
|
|
if (guildId === selectedGuildId && channelId === selectedChannelId) {
|
2026-09-19 21:50:06 +03:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
selectChannel(guildId, channelId);
|
2026-09-20 01:39:04 +03:00
|
|
|
}, [guildId, channelId, selectedGuildId, selectedChannelId, selectChannel]);
|
2026-09-19 21:50:06 +03:00
|
|
|
|
2026-09-19 21:53:35 +03:00
|
|
|
// `/app/empty` — служебный маршрут, а не идентификатор сервера.
|
|
|
|
|
const realGuildId = guildId === 'empty' ? null : guildId;
|
|
|
|
|
|
2026-09-20 01:39:04 +03:00
|
|
|
// Сервер появился (создали свой или вступили по приглашению) — открываем его.
|
|
|
|
|
// В разделе друзей ничего не переключаем: там свой маршрут.
|
2026-09-19 22:03:46 +03:00
|
|
|
useEffect(() => {
|
2026-09-20 01:39:04 +03:00
|
|
|
if (isFriends || realGuildId !== null || guilds.length === 0) {
|
2026-09-19 22:03:46 +03:00
|
|
|
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,
|
|
|
|
|
});
|
2026-09-20 01:39:04 +03:00
|
|
|
}, [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]);
|
2026-09-19 22:03:46 +03:00
|
|
|
|
2026-09-19 21:50:06 +03:00
|
|
|
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]);
|
|
|
|
|
|
2026-09-19 21:53:35 +03:00
|
|
|
const guildInSnapshot = realGuildId !== null && guilds.some((guild) => guild.id === realGuildId);
|
2026-09-19 21:50:06 +03:00
|
|
|
const fallbackGuild = useQuery({
|
2026-09-19 21:53:35 +03:00
|
|
|
queryKey: guildQueryKey(realGuildId ?? ''),
|
|
|
|
|
queryFn: ({ signal }) => fetchGuild(realGuildId ?? '', signal),
|
|
|
|
|
enabled: realGuildId !== null && !guildInSnapshot,
|
2026-09-19 21:50:06 +03:00
|
|
|
retry: 0,
|
|
|
|
|
});
|
|
|
|
|
const hydrated = useRef<string | null>(null);
|
|
|
|
|
|
2026-09-20 01:39:04 +03:00
|
|
|
// Сервер есть в URL, но отсутствует в снапшоте (после RESUME или вступления
|
|
|
|
|
// по приглашению).
|
2026-09-19 21:50:06 +03:00
|
|
|
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 (
|
|
|
|
|
<div className="flex h-full min-h-full flex-col">
|
|
|
|
|
<a
|
|
|
|
|
href="#app-content"
|
|
|
|
|
className="sr-only focus:not-sr-only focus:absolute focus:left-2 focus:top-2 focus:z-50 focus:rounded-[var(--radius-sm)] focus:bg-surface-2 focus:px-3 focus:py-1"
|
|
|
|
|
>
|
|
|
|
|
{t('app.skipToContent')}
|
|
|
|
|
</a>
|
2026-09-19 22:03:46 +03:00
|
|
|
<GatewayBridge userId={currentUser.data?.id ?? null} />
|
2026-09-19 21:50:06 +03:00
|
|
|
<div className="flex min-h-0 flex-1">
|
|
|
|
|
<GuildRail
|
|
|
|
|
guilds={guildList}
|
|
|
|
|
selectedGuildId={guildId}
|
|
|
|
|
canCreate={canCreate}
|
2026-09-20 01:39:04 +03:00
|
|
|
onCreate={openGuildCreate}
|
2026-09-19 21:50:06 +03:00
|
|
|
/>
|
2026-09-20 11:24:09 +03:00
|
|
|
{/*
|
|
|
|
|
Левая колонка: сайдбар раздела и панель пользователя под ним.
|
|
|
|
|
Ширину задаёт колонка, поэтому сайдбары её больше не дублируют.
|
|
|
|
|
*/}
|
|
|
|
|
<div
|
|
|
|
|
data-testid="app-sidebar-column"
|
|
|
|
|
className="flex w-60 shrink-0 flex-col border-r border-border/40 bg-surface-1"
|
|
|
|
|
>
|
|
|
|
|
{isFriends ? (
|
|
|
|
|
<FriendsSidebar channelId={dmChannelId} />
|
|
|
|
|
) : (
|
|
|
|
|
<ChannelSidebar guildId={guildId} channelId={channelId} />
|
|
|
|
|
)}
|
|
|
|
|
<UserPanel />
|
|
|
|
|
</div>
|
2026-09-19 21:50:06 +03:00
|
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
2026-09-20 00:15:18 +03:00
|
|
|
{/*
|
|
|
|
|
Ключ по пути: смена комнаты/сервера появляется с анимацией.
|
|
|
|
|
`relative` нужен чату: его область позиционируется absolute inset-0
|
|
|
|
|
и скроллится изнутри, а не всей страницей.
|
|
|
|
|
*/}
|
|
|
|
|
<div key={location.pathname} className="gl-page relative min-h-0 flex-1 overflow-auto">
|
2026-09-19 21:50:06 +03:00
|
|
|
<Outlet />
|
2026-09-20 11:24:09 +03:00
|
|
|
{/* Подсказка про выход с сервера — внизу области контента. */}
|
|
|
|
|
{realGuildId === null ? null : (
|
|
|
|
|
<p
|
|
|
|
|
data-testid="guild-leave-hint"
|
|
|
|
|
className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-surface-1 via-surface-1/80 to-transparent px-4 py-1.5 text-center text-xs text-fg-muted"
|
|
|
|
|
>
|
|
|
|
|
{t('channels.leaveHint')}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
2026-09-19 21:50:06 +03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-09-20 01:39:04 +03:00
|
|
|
<CreateGuildModal />
|
2026-09-19 21:50:06 +03:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|