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>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { createGuild } from '@/api/guilds';
import { instanceQueryKey } from '@/api/instance';
import { myGuildsQueryKey } from '@/api/users';
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 { useUiStore } from '@/stores/ui';
/**
* Единственное модальное окно создания сервера (`POST /guilds {name}`).
* Открывается из рейки и с пустого экрана «У вас пока нет серверов»,
* поэтому смонтировано в `AppLayout`, а состояние — в `ui`-сторе.
*/
export function CreateGuildModal() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const instance = useInstance();
const open = useUiStore((state) => state.guildCreateOpen);
const close = useUiStore((state) => state.closeGuildCreate);
const guilds = useSessionStore((state) => state.guilds);
const upsertGuild = useSessionStore((state) => state.upsertGuild);
const [name, setName] = useState('');
const create = useMutation({
mutationFn: (guildName: string) => createGuild(guildName),
onSuccess: (guild) => {
close();
setName('');
upsertGuild(guild);
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
void navigate(`/app/${guild.id}`);
},
});
const limitReached =
instance.data !== undefined && guilds.length >= instance.data.max_guilds_per_user;
return (
<Modal
open={open}
title={t('guilds.create.title')}
onClose={close}
footer={
<>
<Button variant="ghost" onClick={close}>
{t('common.cancel')}
</Button>
<Button
onClick={() => create.mutate(name.trim())}
disabled={create.isPending || name.trim().length === 0}
>
{t(create.isPending ? 'common.creating' : 'guilds.create.submit')}
</Button>
</>
}
>
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
if (name.trim() !== '') {
create.mutate(name.trim());
}
}}
>
<Field
label={t('guilds.create.name')}
placeholder={t('guilds.create.namePlaceholder')}
value={name}
onChange={(event) => setName(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>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { useParams } from 'react-router';
import { DirectMessageView } from '@/components/friends/DirectMessageView';
import { FriendsView } from '@/components/friends/FriendsView';
/**
* Маршрут раздела «Друзья»: `/app/friends` — список друзей и заявок,
* `/app/friends/:channelId` — открытая личная беседа.
*/
export default function FriendsPage() {
const { channelId } = useParams<{ channelId?: string }>();
if (channelId === undefined) {
return <FriendsView />;
}
return <DirectMessageView channelId={channelId} />;
}
+202
View File
@@ -0,0 +1,202 @@
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink } from 'react-router';
import type { Channel, UserStatus } from '@/api/types';
import { useDmUnread } from '@/components/friends/useDmUnread';
import { useOpenDirectChannel } from '@/components/friends/useOpenDirectChannel';
import { Avatar } from '@/components/ui/Avatar';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { presenceDotClass } from '@/lib/presence';
import { useFriendsStore } from '@/stores/friends';
import { useSessionStore } from '@/stores/session';
/** Точка статуса собеседника: та же форма, что в списке друзей. */
function StatusDot({ status, testId }: { status: UserStatus; testId: string }) {
return (
<span
aria-hidden="true"
data-testid={testId}
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]}`}
/>
);
}
/**
* Ссылка на личную беседу. Отдельный компонент — чтобы подписка на
* непрочитанные жила на каждую беседу, а не на весь сайдбар.
*/
function DirectChannelLink({ channel, selected }: { channel: Channel; selected: boolean }) {
const { t } = useTranslation();
const { unread, mentions, exact } = useDmUnread(channel);
const highlighted = !selected && (unread > 0 || mentions > 0);
const name = channel.name === '' ? t('friends.dm.unknown') : channel.name;
const label =
mentions > 0
? t('channels.selectMentions', { name, count: mentions })
: unread > 0
? t('channels.selectUnread', { name, count: unread })
: t('friends.open', { name });
return (
<NavLink
to={`/app/friends/${channel.id}`}
aria-current={selected ? 'page' : undefined}
aria-label={label}
title={name}
data-testid={`dm-link-${channel.id}`}
className={`relative flex items-center gap-2 truncate rounded-[var(--radius-sm)] px-2 py-1 text-sm transition-colors duration-[var(--duration-fast)] ${
selected
? 'gl-indicator bg-surface-3 text-fg'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg'
} ${highlighted ? 'font-semibold text-fg' : ''}`}
>
<span className="relative shrink-0">
<Avatar
name={name}
seed={channel.recipient_id ?? channel.id}
fileId={channel.icon_file_id}
size="sm"
/>
<StatusDot status={channel.status ?? 'offline'} testId={`dm-status-${channel.id}`} />
</span>
<span className="truncate">{name}</span>
{selected ? null : mentions > 0 ? (
<span
data-testid={`dm-mentions-${channel.id}`}
title={t('chat.mentions', { count: mentions })}
className="ml-auto rounded-full bg-danger px-1.5 text-[10px] font-semibold leading-4 text-white"
>
{mentions}
</span>
) : unread > 0 ? (
<span
data-testid={`dm-unread-${channel.id}`}
title={t('chat.unread', { count: unread })}
className={
exact
? 'ml-auto rounded-full bg-surface-3 px-1.5 text-[10px] font-semibold leading-4 text-fg'
: 'ml-auto h-2 w-2 rounded-full bg-fg'
}
>
{exact ? unread : ''}
</span>
) : null}
</NavLink>
);
}
/** Порядок бесед: свежие переписки выше, остальные — по имени. */
function byRecent(left: Channel, right: Channel): number {
const leftAt = Date.parse(left.last_message_at ?? '');
const rightAt = Date.parse(right.last_message_at ?? '');
if (Number.isFinite(leftAt) && Number.isFinite(rightAt) && leftAt !== rightAt) {
return rightAt - leftAt;
}
return left.name.localeCompare(right.name);
}
/** Порядок друзей: сначала те, кто в сети. */
function byPresence(left: { visible_status: UserStatus }, right: { visible_status: UserStatus }) {
const rank = (status: UserStatus): number => (status === 'offline' ? 1 : 0);
return rank(left.visible_status) - rank(right.visible_status);
}
/**
* Сайдбар раздела «Друзья»: заголовок, список личных бесед и список друзей.
* Личные беседы не попадают в рейку серверов — они живут только здесь.
*/
export function FriendsSidebar({ channelId }: { channelId: string | null }) {
const { t } = useTranslation();
const dmChannels = useSessionStore((state) => state.dmChannels);
const friends = useFriendsStore((state) => state.friends);
const status = useFriendsStore((state) => state.status);
const load = useFriendsStore((state) => state.load);
const openDm = useOpenDirectChannel();
useEffect(() => {
void load();
}, [load]);
const direct = useMemo(() => [...dmChannels].sort(byRecent), [dmChannels]);
const friendsSorted = useMemo(() => [...friends].sort(byPresence), [friends]);
return (
<aside
aria-label={t('friends.sidebarLabel')}
className="flex w-60 shrink-0 flex-col border-r border-border/40 bg-surface-1"
>
<header className="flex items-center gap-2 border-b border-border/40 px-3 py-3">
<h1 className="truncate text-sm font-semibold">{t('friends.title')}</h1>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
<section aria-label={t('friends.dmSection')}>
<h2 className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
{t('friends.dmSection')}
</h2>
{direct.length === 0 ? (
<p className="px-2 text-xs text-fg-muted">{t('friends.dmEmpty')}</p>
) : (
<ul className="gl-stagger flex flex-col gap-0.5" data-testid="dm-list">
{direct.map((channel) => (
<li key={channel.id}>
<DirectChannelLink channel={channel} selected={channel.id === channelId} />
</li>
))}
</ul>
)}
</section>
<section aria-label={t('friends.friendsSection')} className="mt-4">
<h2 className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
{t('friends.friendsSection')}
</h2>
{status === 'loading' && friends.length === 0 ? (
<SkeletonLines className="px-2" rows={3} />
) : friends.length === 0 ? (
<p className="px-2 text-xs text-fg-muted">{t('friends.friendsEmpty')}</p>
) : (
<ul className="gl-stagger flex flex-col gap-0.5" data-testid="friends-sidebar-list">
{friendsSorted.map((friend) => {
const name = friend.display_name === '' ? friend.username : friend.display_name;
return (
<li key={friend.user_id}>
<button
type="button"
aria-label={t('friends.open', { name })}
title={name}
onClick={() => {
openDm.mutate(friend.user_id);
}}
className="flex w-full items-center gap-2 truncate rounded-[var(--radius-sm)] px-2 py-1 text-left text-sm text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-2 hover:text-fg"
>
<span className="relative shrink-0">
<Avatar
name={name}
seed={friend.user_id}
fileId={friend.avatar_file_id}
size="sm"
/>
<StatusDot
status={friend.visible_status}
testId={`friend-sidebar-status-${friend.user_id}`}
/>
</span>
<span className="truncate">{name}</span>
</button>
</li>
);
})}
</ul>
)}
</section>
</div>
<footer className="border-t border-border/40 px-3 py-2">
<p className="text-xs text-fg-muted">{t('friends.sidebarHint')}</p>
</footer>
</aside>
);
}
+36 -1
View File
@@ -10,7 +10,10 @@ interface GuildRailProps {
onCreate: () => void;
}
/** Левая рейка: иконки серверов-инициалов и кнопка создания сервера. */
/**
* Левая рейка: первой иконкой — «Друзья», ниже — серверы и кнопка создания.
* Личные беседы в рейку не попадают: они живут внутри раздела друзей.
*/
export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: GuildRailProps) {
const { t } = useTranslation();
@@ -19,6 +22,38 @@ export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: Guil
aria-label={t('guilds.railLabel')}
className="flex w-[72px] shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-border/40 bg-surface-2/60 py-3"
>
<NavLink
to="/app/friends"
title={t('friends.rail')}
aria-label={t('friends.rail')}
data-testid="friends-rail-link"
className={({ isActive }) =>
`relative flex h-14 w-14 items-center justify-center rounded-[var(--radius-md)] border text-2xl transition-[border-radius,border-color,background-color,color,transform] duration-[var(--duration-fast)] ${
isActive
? 'gl-ring rounded-[var(--radius-lg)] border-accent bg-surface-1 text-accent'
: 'border-border bg-surface-1 text-fg-muted hover:-translate-y-0.5 hover:rounded-[var(--radius-lg)] hover:text-fg active:translate-y-0'
}`
}
>
{/* Облачко с двумя собеседниками: узнаваемая иконка личных бесед. */}
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
className="h-7 w-7"
>
<path d="M4 5.5h16v10H9l-5 4z" />
<circle cx="9.5" cy="10.5" r="1.4" fill="currentColor" stroke="none" />
<circle cx="14.5" cy="10.5" r="1.4" fill="currentColor" stroke="none" />
</svg>
</NavLink>
<div role="separator" aria-hidden="true" className="my-0.5 h-px w-8 bg-border/60" />
<ul className="gl-stagger flex flex-col items-center gap-2" data-testid="guild-rail">
{guilds.map((guild) => {
const active = guild.id === selectedGuildId;
+17 -56
View File
@@ -1,72 +1,33 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { joinGuild } from '@/api/guilds';
import { instanceQueryKey } from '@/api/instance';
import { myGuildsQueryKey } from '@/api/users';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { InviteJoin } from '@/components/friends/InviteJoin';
import { Button, Card } from '@/components/ui/primitives';
import { useInstance } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { useUiStore } from '@/stores/ui';
/**
* Экран пустого списка серверов: присоединиться к главному серверу инстанса
* (id берём из `GET /instance`) или создать свой (кнопка есть в рейке).
* Экран пустого списка серверов. Главного сервера больше нет: новый
* пользователь видит пустую рейку, может создать свой сервер или
* присоединиться по приглашению. Иконки серверов появятся в рейке сразу
* после вступления.
*/
export default function NoGuildView() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const instance = useInstance();
const guilds = useSessionStore((state) => state.guilds);
const mainGuildId = instance.data?.main_guild_id;
const alreadyMember =
mainGuildId !== undefined && guilds.some((guild) => guild.id === mainGuildId);
const join = useMutation({
mutationFn: (guildId: string) => joinGuild(guildId),
onSuccess: () => {
// Список серверов обновится снапшотом шлюза; REST — подстраховка.
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
},
});
const openGuildCreate = useUiStore((state) => state.openGuildCreate);
return (
<section id="app-content" className="flex h-full items-center justify-center p-6">
<Card className="max-w-lg text-center">
<h2 className="text-lg font-semibold">{t('guilds.empty.title')}</h2>
<p className="mt-2 text-sm text-fg-muted">{t('guilds.empty.body')}</p>
<section id="app-content" className="absolute inset-0 overflow-y-auto p-6">
<div className="flex min-h-full items-center justify-center">
<Card className="w-full max-w-lg">
<h2 className="text-lg font-semibold">{t('guilds.empty.title')}</h2>
<p className="mt-2 text-sm text-fg-muted">{t('guilds.empty.body')}</p>
{instance.isPending ? (
<p className="mt-4 text-sm text-fg-muted" role="status">
{t('common.loading')}
</p>
) : null}
{mainGuildId !== undefined && !alreadyMember ? (
<Button
className="mt-4"
disabled={join.isPending}
onClick={() => join.mutate(mainGuildId)}
>
{t(join.isPending ? 'guilds.empty.joining' : 'guilds.empty.joinMain')}
<Button className="mt-4" onClick={openGuildCreate}>
{t('guilds.add')}
</Button>
) : null}
{mainGuildId === undefined && !instance.isPending ? (
<p className="mt-4 text-sm text-fg-muted">{t('guilds.empty.noMain')}</p>
) : null}
{join.isError ? <ErrorNotice className="mt-3" error={join.error} /> : null}
{instance.isError ? (
<ErrorNotice
className="mt-3"
error={instance.error}
onRetry={() => void instance.refetch()}
/>
) : null}
</Card>
<InviteJoin className="mt-6 border-t border-border/60 pt-4" />
</Card>
</div>
</section>
);
}
+1
View File
@@ -11,6 +11,7 @@ export const OnboardingPage = lazy(() => import('@/pages/OnboardingPage'));
export const StatusPage = lazy(() => import('@/pages/StatusPage'));
export const AppLayout = lazy(() => import('@/pages/app/AppLayout'));
export const GuildView = lazy(() => import('@/pages/app/GuildView'));
export const FriendsPage = lazy(() => import('@/pages/app/FriendsPage'));
export const NoGuildView = lazy(() => import('@/pages/app/NoGuildView'));
export const SettingsLayout = lazy(() => import('@/pages/settings/SettingsLayout'));
export const AccountSettingsLayout = lazy(() => import('@/pages/settings/AccountSettingsLayout'));