2e038a1d3f
- миграция 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-снапшота
164 lines
5.7 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|