2026-09-19 21:50:06 +03:00
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
|
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
|
|
|
import { useTranslation } from 'react-i18next';
|
2026-09-20 01:32:11 +03:00
|
|
|
import { NavLink, useNavigate } from 'react-router';
|
2026-09-19 21:50:06 +03:00
|
|
|
|
2026-09-20 01:32:11 +03:00
|
|
|
import { createChannel, fetchChannels, guildChannelsQueryKey } from '@/api/guilds';
|
2026-09-20 00:15:18 +03:00
|
|
|
import type { Channel } from '@/api/types';
|
|
|
|
|
import { ChannelBadge } from '@/components/chat/ChannelBadge';
|
|
|
|
|
import { useChannelUnread } from '@/components/chat/useChannelUnread';
|
2026-09-19 21:50:06 +03:00
|
|
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
|
|
|
|
import { Field, SelectField } from '@/components/ui/Field';
|
|
|
|
|
import { Modal } from '@/components/ui/Modal';
|
|
|
|
|
import { Button } from '@/components/ui/primitives';
|
2026-09-19 23:33:34 +03:00
|
|
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
2026-09-19 21:53:35 +03:00
|
|
|
import { groupChannels } from '@/lib/channels';
|
2026-09-20 11:24:09 +03:00
|
|
|
import { canAdministerGuild, canManageGuild } from '@/lib/identity';
|
2026-09-19 21:50:06 +03:00
|
|
|
import { useCurrentUser } from '@/lib/hooks';
|
|
|
|
|
import { useSessionStore } from '@/stores/session';
|
2026-09-20 01:32:11 +03:00
|
|
|
import { useUiStore } from '@/stores/ui';
|
2026-09-19 21:50:06 +03:00
|
|
|
|
|
|
|
|
interface ChannelSidebarProps {
|
|
|
|
|
guildId: string | null;
|
|
|
|
|
channelId: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 00:15:18 +03:00
|
|
|
/**
|
|
|
|
|
* Ссылка на комнату с бейджем непрочитанного. Отдельный компонент нужен,
|
|
|
|
|
* чтобы подписка на стор сообщений жила на каждую комнату, а не на весь
|
|
|
|
|
* сайдбар (иначе любое новое сообщение перерисовывало бы список целиком).
|
|
|
|
|
*/
|
|
|
|
|
function ChannelLink({
|
|
|
|
|
guildId,
|
|
|
|
|
channel,
|
|
|
|
|
selected,
|
|
|
|
|
}: {
|
|
|
|
|
guildId: string | null;
|
|
|
|
|
channel: Channel;
|
|
|
|
|
selected: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const { unread, mentions } = useChannelUnread(channel.id);
|
|
|
|
|
const highlighted = !selected && (unread > 0 || mentions > 0);
|
|
|
|
|
// aria-label ссылки перекрывает её содержимое, поэтому счётчики озвучиваем здесь.
|
|
|
|
|
const label =
|
|
|
|
|
mentions > 0
|
|
|
|
|
? t('channels.selectMentions', { name: channel.name, count: mentions })
|
|
|
|
|
: unread > 0
|
|
|
|
|
? t('channels.selectUnread', { name: channel.name, count: unread })
|
|
|
|
|
: t('channels.select', { name: channel.name });
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<NavLink
|
|
|
|
|
to={`/app/${guildId ?? ''}/${channel.id}`}
|
|
|
|
|
aria-current={selected ? 'page' : undefined}
|
|
|
|
|
aria-label={label}
|
|
|
|
|
title={channel.name}
|
|
|
|
|
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 aria-hidden="true">{channel.type === 'voice' ? '🔊' : '#'}</span>
|
|
|
|
|
<span className="truncate">{channel.name}</span>
|
|
|
|
|
<ChannelBadge channelId={channel.id} selected={selected} />
|
|
|
|
|
</NavLink>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 21:50:06 +03:00
|
|
|
/** Сайдбар комнат выбранного сервера: категории, текстовые и голосовые комнаты. */
|
|
|
|
|
export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
const currentUser = useCurrentUser();
|
|
|
|
|
|
|
|
|
|
const guild = useSessionStore(
|
|
|
|
|
useCallback((state) => state.guilds.find((item) => item.id === guildId) ?? null, [guildId]),
|
|
|
|
|
);
|
|
|
|
|
const setGuildChannels = useSessionStore((state) => state.setGuildChannels);
|
|
|
|
|
const upsertChannel = useSessionStore((state) => state.upsertChannel);
|
2026-09-20 01:32:11 +03:00
|
|
|
const openSettings = useUiStore((state) => state.openSettings);
|
2026-09-19 21:50:06 +03:00
|
|
|
|
|
|
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
|
|
|
const [channelName, setChannelName] = useState('');
|
|
|
|
|
const [channelType, setChannelType] = useState<'text' | 'voice'>('text');
|
|
|
|
|
const [parentId, setParentId] = useState('');
|
|
|
|
|
|
2026-09-19 21:53:35 +03:00
|
|
|
const channels = useMemo(() => guild?.channels ?? [], [guild]);
|
2026-09-19 21:50:06 +03:00
|
|
|
const needsChannels = guildId !== null && guild !== null && channels.length === 0;
|
|
|
|
|
|
|
|
|
|
const remoteChannels = useQuery({
|
|
|
|
|
queryKey: guildChannelsQueryKey(guildId ?? ''),
|
|
|
|
|
queryFn: ({ signal }) => fetchChannels(guildId ?? '', signal),
|
|
|
|
|
enabled: needsChannels,
|
|
|
|
|
retry: 0,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (guildId === null || remoteChannels.data === undefined) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setGuildChannels(guildId, remoteChannels.data);
|
|
|
|
|
}, [guildId, remoteChannels.data, setGuildChannels]);
|
|
|
|
|
|
|
|
|
|
const create = useMutation({
|
|
|
|
|
mutationFn: () =>
|
|
|
|
|
createChannel(guildId ?? '', {
|
|
|
|
|
name: channelName.trim(),
|
|
|
|
|
type: channelType,
|
|
|
|
|
...(parentId === '' ? {} : { parent_id: parentId }),
|
|
|
|
|
}),
|
|
|
|
|
onSuccess: (channel) => {
|
|
|
|
|
if (guildId !== null) {
|
|
|
|
|
upsertChannel(guildId, channel);
|
|
|
|
|
}
|
|
|
|
|
setCreateOpen(false);
|
|
|
|
|
setChannelName('');
|
|
|
|
|
void queryClient.invalidateQueries({ queryKey: guildChannelsQueryKey(guildId ?? '') });
|
|
|
|
|
void navigate(`/app/${guildId ?? ''}/${channel.id}`);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const groups = useMemo(() => groupChannels(channels), [channels]);
|
2026-09-19 21:53:35 +03:00
|
|
|
const categories = useMemo(
|
|
|
|
|
() => channels.filter((channel) => channel.type === 'category'),
|
|
|
|
|
[channels],
|
|
|
|
|
);
|
2026-09-19 21:50:06 +03:00
|
|
|
const canManage =
|
2026-09-20 11:24:09 +03:00
|
|
|
guild !== null &&
|
|
|
|
|
canManageGuild(guild.my_permissions ?? [], guild.owner_id, currentUser.data?.id);
|
2026-09-19 23:33:34 +03:00
|
|
|
// Шестерёнка настроек сервера — только владельцу и правам управления.
|
|
|
|
|
const canManageSettings =
|
|
|
|
|
guild !== null &&
|
2026-09-20 11:24:09 +03:00
|
|
|
canAdministerGuild(
|
|
|
|
|
guild.my_permissions ?? [],
|
|
|
|
|
guild.owner_id,
|
|
|
|
|
currentUser.data?.id,
|
|
|
|
|
currentUser.data?.is_instance_admin === true,
|
|
|
|
|
);
|
2026-09-19 23:33:34 +03:00
|
|
|
// Каналы ещё едут по REST — показываем скелетон вместо «пусто».
|
|
|
|
|
const channelsLoading = needsChannels && remoteChannels.isPending;
|
2026-09-19 21:50:06 +03:00
|
|
|
|
|
|
|
|
if (guildId === null) {
|
2026-09-20 11:24:09 +03:00
|
|
|
return <aside className="hidden min-h-0 flex-1 bg-surface-1 p-3 md:block" />;
|
2026-09-19 21:50:06 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-09-20 11:24:09 +03:00
|
|
|
<aside aria-label={t('channels.title')} className="flex min-h-0 flex-1 flex-col bg-surface-1">
|
2026-09-19 21:50:06 +03:00
|
|
|
<header className="flex items-center justify-between gap-2 border-b border-border/40 px-3 py-3">
|
|
|
|
|
<h1 className="truncate text-sm font-semibold" title={guild?.name}>
|
|
|
|
|
{guild?.name ?? t('channels.notInGuild.title')}
|
|
|
|
|
</h1>
|
2026-09-19 23:33:34 +03:00
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
{canManageSettings ? (
|
2026-09-20 01:32:11 +03:00
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-09-19 23:33:34 +03:00
|
|
|
aria-label={t('channels.settings')}
|
|
|
|
|
title={t('channels.settings')}
|
2026-09-20 11:24:09 +03:00
|
|
|
className="rounded-[var(--radius-sm)] px-1 text-base leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
|
|
|
|
|
onClick={() => openSettings({ section: 'server', guildId })}
|
2026-09-19 23:33:34 +03:00
|
|
|
>
|
|
|
|
|
⚙
|
2026-09-20 01:32:11 +03:00
|
|
|
</button>
|
2026-09-19 23:33:34 +03:00
|
|
|
) : null}
|
|
|
|
|
{canManage ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
aria-label={t('channels.add')}
|
|
|
|
|
title={t('channels.add')}
|
|
|
|
|
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
|
|
|
|
|
onClick={() => setCreateOpen(true)}
|
|
|
|
|
>
|
|
|
|
|
+
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
2026-09-19 21:50:06 +03:00
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
|
|
|
|
|
{guild === null ? (
|
|
|
|
|
<p className="px-2 text-sm text-fg-muted">{t('channels.notInGuild.body')}</p>
|
2026-09-19 23:33:34 +03:00
|
|
|
) : channelsLoading ? (
|
|
|
|
|
<SkeletonLines className="px-2" rows={5} />
|
2026-09-19 21:50:06 +03:00
|
|
|
) : groups.length === 0 ? (
|
|
|
|
|
<p className="px-2 text-sm text-fg-muted">{t('channels.empty')}</p>
|
|
|
|
|
) : (
|
|
|
|
|
<nav aria-label={t('channels.title')}>
|
|
|
|
|
{groups.map((group) => (
|
|
|
|
|
<section key={group.category?.id ?? 'uncategorized'} className="mb-3">
|
|
|
|
|
{group.category === null ? null : (
|
|
|
|
|
<h2 className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
|
|
|
|
|
{group.category.name}
|
|
|
|
|
</h2>
|
|
|
|
|
)}
|
2026-09-19 23:33:34 +03:00
|
|
|
<ul className="gl-stagger flex flex-col gap-0.5">
|
2026-09-19 21:50:06 +03:00
|
|
|
{group.channels.map((channel) => (
|
|
|
|
|
<li key={channel.id}>
|
2026-09-20 00:15:18 +03:00
|
|
|
<ChannelLink
|
|
|
|
|
guildId={guildId}
|
|
|
|
|
channel={channel}
|
|
|
|
|
selected={channel.id === channelId}
|
|
|
|
|
/>
|
2026-09-19 21:50:06 +03:00
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</section>
|
|
|
|
|
))}
|
|
|
|
|
</nav>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{remoteChannels.isError ? (
|
|
|
|
|
<ErrorNotice
|
|
|
|
|
className="mt-2"
|
|
|
|
|
error={remoteChannels.error}
|
|
|
|
|
onRetry={() => void remoteChannels.refetch()}
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Modal
|
|
|
|
|
open={createOpen}
|
|
|
|
|
title={t('channels.create.title')}
|
|
|
|
|
onClose={() => setCreateOpen(false)}
|
|
|
|
|
footer={
|
|
|
|
|
<>
|
|
|
|
|
<Button variant="ghost" onClick={() => setCreateOpen(false)}>
|
|
|
|
|
{t('common.cancel')}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
onClick={() => create.mutate()}
|
|
|
|
|
disabled={create.isPending || channelName.trim().length === 0}
|
|
|
|
|
>
|
|
|
|
|
{t(create.isPending ? 'common.creating' : 'channels.create.submit')}
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<form
|
|
|
|
|
className="flex flex-col gap-3"
|
|
|
|
|
onSubmit={(event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
if (channelName.trim() !== '') {
|
|
|
|
|
create.mutate();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Field
|
|
|
|
|
label={t('channels.create.name')}
|
|
|
|
|
placeholder={t('channels.create.namePlaceholder')}
|
|
|
|
|
value={channelName}
|
|
|
|
|
onChange={(event) => setChannelName(event.target.value)}
|
|
|
|
|
maxLength={64}
|
|
|
|
|
/>
|
|
|
|
|
<SelectField
|
|
|
|
|
label={t('channels.create.type')}
|
|
|
|
|
value={channelType}
|
|
|
|
|
onChange={(value) => setChannelType(value === 'voice' ? 'voice' : 'text')}
|
|
|
|
|
options={[
|
|
|
|
|
{ value: 'text', label: t('channels.create.typeText') },
|
|
|
|
|
{ value: 'voice', label: t('channels.create.typeVoice') },
|
|
|
|
|
]}
|
|
|
|
|
/>
|
|
|
|
|
{categories.length === 0 ? null : (
|
|
|
|
|
<SelectField
|
|
|
|
|
label={t('channels.create.parent')}
|
|
|
|
|
value={parentId}
|
|
|
|
|
onChange={setParentId}
|
|
|
|
|
options={[
|
|
|
|
|
{ value: '', label: t('channels.create.noParent') },
|
|
|
|
|
...categories.map((category) => ({
|
|
|
|
|
value: category.id,
|
|
|
|
|
label: category.name,
|
|
|
|
|
})),
|
|
|
|
|
]}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{create.isError ? <ErrorNotice error={create.error} /> : null}
|
|
|
|
|
</form>
|
|
|
|
|
</Modal>
|
|
|
|
|
</aside>
|
|
|
|
|
);
|
|
|
|
|
}
|