Files
glchat/web/src/pages/settings/ChannelSettingsPage.tsx
T
grendervill 2e038a1d3f feat(webhooks): вебхуки комнат — бэкенд, клиент и настройки комнаты
- миграция 00012: таблица webhooks, снимок имени и аватара в messages (AGENT.md 7.11)
- API: список/создание/правка/удаление и пересоздание токена (MANAGE_WEBHOOKS,
  step-up при создании, аудит), загрузка аватара отдельной multipart-ручкой
- исполнение POST /webhooks/{id}/{token} без сессии: content, username,
  avatar_url, файлы создателя вебхука, лимит 30/мин на вебхук
- клиент: пункт настроек «Комната» со списком вебхуков, копированием ссылки,
  пересозданием токена и удалением
- сообщения вебхуков: имя, аватар и значок в ленте вместо «неизвестного автора»
- fix: неполный ответ REST больше не затирает данные READY-снапшота
2026-09-21 00:26:50 +03:00

164 lines
5.7 KiB
TypeScript

import { useEffect, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fetchChannels, guildChannelsQueryKey } from '@/api/guilds';
import type { Channel } from '@/api/types';
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { SelectField } from '@/components/ui/Field';
import { Card } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { canManageWebhooks } from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { ChannelWebhooksSection } from '@/pages/settings/ChannelWebhooksSection';
import { GuildPicker } from '@/pages/settings/GuildPicker';
/**
* Пункт «Комната» окна настроек: управление одной комнатой — пока это вебхуки
* (AGENT.md 7.11). Сервер выбирается тем же списком, что и в пункте «Сервер»,
* комната — выпадающим списком текстовых комнат.
*/
export function ChannelSettingsPage() {
const sessionGuilds = useSessionStore((state) => state.guilds);
const upsertGuild = useSessionStore((state) => state.upsertGuild);
const selectSettingsGuild = useSessionStore((state) => state.selectSettingsGuild);
const settingsGuildId = useSessionStore((state) => state.settingsGuildId);
// Подстраховка снапшота: без неё список серверов пуст до подключения шлюза.
const myGuilds = useQuery({
queryKey: myGuildsQueryKey,
queryFn: ({ signal }) => fetchMyGuilds(signal),
retry: 0,
enabled: sessionGuilds.length === 0,
});
useEffect(() => {
if (sessionGuilds.length > 0 || myGuilds.data === undefined) {
return;
}
for (const guild of myGuilds.data) {
upsertGuild(guild);
}
}, [sessionGuilds.length, myGuilds.data, upsertGuild]);
const guilds = useMemo(
() => (sessionGuilds.length > 0 ? sessionGuilds : (myGuilds.data ?? [])),
[sessionGuilds, myGuilds.data],
);
return (
<div className="flex flex-col gap-5">
<GuildPicker
guilds={guilds}
isPending={myGuilds.isPending && guilds.length === 0}
onSelect={selectSettingsGuild}
/>
<ChannelSettingsBody guildId={settingsGuildId} />
</div>
);
}
/** Тело пункта: выбор комнаты и её секции. */
function ChannelSettingsBody({ guildId }: { guildId: string | null }) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const settingsChannelId = useSessionStore((state) => state.settingsChannelId);
const selectSettingsChannel = useSessionStore((state) => state.selectSettingsChannel);
const guild = useSessionStore((state) =>
guildId === null ? null : (state.guilds.find((item) => item.id === guildId) ?? null),
);
// Комнаты сервера: в снапшоте они могут быть пустыми (сервер только открыли),
// поэтому тянем их по REST.
const channels = useQuery({
queryKey: guildChannelsQueryKey(guildId ?? ''),
queryFn: ({ signal }) => fetchChannels(guildId ?? '', signal),
enabled: guildId !== null,
retry: 0,
});
const textChannels = useMemo(
() => (channels.data ?? []).filter((channel: Channel) => channel.type === 'text'),
[channels.data],
);
// Первую текстовую комнату выбираем сами: список только что загрузился.
useEffect(() => {
if (settingsChannelId !== null || textChannels.length === 0) {
return;
}
const first = textChannels[0];
if (first !== undefined) {
selectSettingsChannel(first.id);
}
}, [settingsChannelId, textChannels, selectSettingsChannel]);
if (guildId === null || guild === null) {
return (
<Card>
<p className="text-sm text-fg-muted">{t('settings.channel.noGuild')}</p>
</Card>
);
}
const isInstanceAdmin = currentUser.data?.is_instance_admin === true;
const canManage = canManageWebhooks(
guild.my_permissions ?? [],
guild.owner_id,
currentUser.data?.id,
isInstanceAdmin,
);
const active = textChannels.find((channel) => channel.id === settingsChannelId) ?? null;
return (
<>
{channels.isPending ? (
<Card>
<SkeletonLines rows={3} />
</Card>
) : null}
{channels.isError ? (
<Card>
<ErrorNotice error={channels.error} onRetry={() => void channels.refetch()} />
</Card>
) : null}
{channels.isSuccess && textChannels.length === 0 ? (
<Card>
<p className="text-sm text-fg-muted">{t('settings.channel.noChannels')}</p>
</Card>
) : null}
{textChannels.length === 0 ? null : (
<Card>
<SelectField
label={t('settings.channel.pick')}
value={active?.id ?? ''}
onChange={(value) => selectSettingsChannel(value)}
options={textChannels.map((channel) => ({
value: channel.id,
label: `#${channel.name}`,
}))}
/>
</Card>
)}
{active === null ? null : (
<>
<ChannelWebhooksSection
key={`${active.id}-webhooks`}
channelId={active.id}
canManage={canManage}
/>
{canManage ? null : (
<Card>
<p className="text-sm text-fg-muted">{t('settings.channel.noPermission')}</p>
</Card>
)}
</>
)}
</>
);
}