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-снапшота
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user