194 lines
7.0 KiB
TypeScript
194 lines
7.0 KiB
TypeScript
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
|
|
import { useTranslation } from 'react-i18next';
|
||
|
|
import { Outlet, useLocation, useNavigate } from 'react-router';
|
||
|
|
|
||
|
|
import { createGuild, fetchGuild, guildQueryKey } from '@/api/guilds';
|
||
|
|
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
|
||
|
|
import { instanceQueryKey } from '@/api/instance';
|
||
|
|
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 { useInstance } from '@/lib/hooks';
|
||
|
|
import { useSessionStore } from '@/stores/session';
|
||
|
|
import { ChannelSidebar } from '@/pages/app/ChannelSidebar';
|
||
|
|
import { GuildRail } from '@/pages/app/GuildRail';
|
||
|
|
import { UserPanel } from '@/pages/app/UserPanel';
|
||
|
|
import { errorCode } from '@/lib/format';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Оболочка приложения: рейка серверов, сайдбар комнат, контент и панель
|
||
|
|
* пользователя. Данные серверов приходят из снапшота шлюза, а если шлюз ещё
|
||
|
|
* не подключился — подстраховываемся REST-запросом `GET /users/@me/guilds`.
|
||
|
|
*/
|
||
|
|
export default function AppLayout() {
|
||
|
|
const { t } = useTranslation();
|
||
|
|
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 selectChannel = useSessionStore((state) => state.selectChannel);
|
||
|
|
const upsertGuild = useSessionStore((state) => state.upsertGuild);
|
||
|
|
|
||
|
|
const [createOpen, setCreateOpen] = useState(false);
|
||
|
|
const [guildName, setGuildName] = useState('');
|
||
|
|
|
||
|
|
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]);
|
||
|
|
|
||
|
|
// URL — источник правды для выбранного сервера и комнаты.
|
||
|
|
useEffect(() => {
|
||
|
|
if (guildId === selectedGuildId) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
selectChannel(guildId, channelId);
|
||
|
|
}, [guildId, channelId, selectedGuildId, selectChannel]);
|
||
|
|
|
||
|
|
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 = guildId !== null && guilds.some((guild) => guild.id === guildId);
|
||
|
|
const fallbackGuild = useQuery({
|
||
|
|
queryKey: guildQueryKey(guildId ?? ''),
|
||
|
|
queryFn: ({ signal }) => fetchGuild(guildId ?? '', signal),
|
||
|
|
enabled: guildId !== null && !guildInSnapshot,
|
||
|
|
retry: 0,
|
||
|
|
});
|
||
|
|
const hydrated = useRef<string | null>(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 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">
|
||
|
|
<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>
|
||
|
|
<div className="flex min-h-0 flex-1">
|
||
|
|
<GuildRail
|
||
|
|
guilds={guildList}
|
||
|
|
selectedGuildId={guildId}
|
||
|
|
canCreate={canCreate}
|
||
|
|
onCreate={() => setCreateOpen(true)}
|
||
|
|
/>
|
||
|
|
<ChannelSidebar guildId={guildId} channelId={channelId} />
|
||
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
||
|
|
<div className="min-h-0 flex-1 overflow-auto">
|
||
|
|
<Outlet />
|
||
|
|
</div>
|
||
|
|
<UserPanel />
|
||
|
|
</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 && errorCode(create.error) === null ? (
|
||
|
|
<p className="text-xs text-warning">{t('errors.auth.guild_limit_reached')}</p>
|
||
|
|
) : null}
|
||
|
|
{create.isError ? <ErrorNotice error={create.error} /> : null}
|
||
|
|
</form>
|
||
|
|
</Modal>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|