From 99be186fa18c89774053593c2a61c63b4fe9e514 Mon Sep 17 00:00:00 2001 From: Hampus Date: Wed, 14 Oct 2026 18:39:09 +0200 Subject: [PATCH] fix(guild): import all template channel types, fetch on desktop (#3473) --- .../services/data/GuildOperationsService.ts | 64 +++-- .../services/data/TemplateChannelImport.ts | 118 ++++++++ .../guild/tests/GuildTemplateImport.test.ts | 252 +++++++++++++++++- fluxer_api/src/api/openapi/openapi.json | 148 ++++++++-- .../add_guild_modal/TemplateImportForm.tsx | 177 +++++++++--- .../modals/add_guild_modal/shared.ts | 19 +- .../src/features/i18n/locales/ar/messages.po | 25 ++ .../locales/auto-i18n-reviewed-unchanged.json | 5 + .../src/features/i18n/locales/bg/messages.po | 25 ++ .../src/features/i18n/locales/cs/messages.po | 25 ++ .../src/features/i18n/locales/da/messages.po | 25 ++ .../src/features/i18n/locales/de/messages.po | 25 ++ .../src/features/i18n/locales/el/messages.po | 25 ++ .../features/i18n/locales/en-GB/messages.po | 25 ++ .../features/i18n/locales/en-US/messages.po | 25 ++ .../features/i18n/locales/es-419/messages.po | 25 ++ .../features/i18n/locales/es-ES/messages.po | 25 ++ .../src/features/i18n/locales/fi/messages.po | 25 ++ .../src/features/i18n/locales/fr/messages.po | 25 ++ .../src/features/i18n/locales/he/messages.po | 25 ++ .../src/features/i18n/locales/hi/messages.po | 25 ++ .../src/features/i18n/locales/hr/messages.po | 25 ++ .../src/features/i18n/locales/hu/messages.po | 25 ++ .../src/features/i18n/locales/id/messages.po | 25 ++ .../src/features/i18n/locales/it/messages.po | 25 ++ .../src/features/i18n/locales/ja/messages.po | 25 ++ .../src/features/i18n/locales/ko/messages.po | 25 ++ .../src/features/i18n/locales/lt/messages.po | 25 ++ .../src/features/i18n/locales/nl/messages.po | 25 ++ .../src/features/i18n/locales/no/messages.po | 25 ++ .../src/features/i18n/locales/pl/messages.po | 25 ++ .../features/i18n/locales/pt-BR/messages.po | 25 ++ .../src/features/i18n/locales/ro/messages.po | 25 ++ .../src/features/i18n/locales/ru/messages.po | 25 ++ .../features/i18n/locales/sv-SE/messages.po | 25 ++ .../src/features/i18n/locales/th/messages.po | 25 ++ .../src/features/i18n/locales/tr/messages.po | 25 ++ .../src/features/i18n/locales/uk/messages.po | 25 ++ .../src/features/i18n/locales/vi/messages.po | 25 ++ .../features/i18n/locales/zh-CN/messages.po | 25 ++ .../features/i18n/locales/zh-TW/messages.po | 25 ++ fluxer_app/src/types/electron.d.ts | 2 + fluxer_desktop/src/common/Types.ts | 2 + fluxer_desktop/src/main/IpcHandlers.ts | 6 + .../main/OtherPlatformTemplateIpc.test.mjs | 206 ++++++++++++++ .../src/main/OtherPlatformTemplateIpc.ts | 145 ++++++++++ .../preload/OtherPlatformTemplatePreload.ts | 29 ++ fluxer_desktop/src/preload/index.ts | 2 + .../src/OtherPlatformTemplateContract.ts | 38 +++ .../src/domains/guild/GuildTemplateSchemas.ts | 56 +++- 50 files changed, 2008 insertions(+), 111 deletions(-) create mode 100644 fluxer_api/src/api/guild/services/data/TemplateChannelImport.ts create mode 100644 fluxer_desktop/src/main/OtherPlatformTemplateIpc.test.mjs create mode 100644 fluxer_desktop/src/main/OtherPlatformTemplateIpc.ts create mode 100644 fluxer_desktop/src/preload/OtherPlatformTemplatePreload.ts create mode 100644 packages/desktop_ipc/src/OtherPlatformTemplateContract.ts diff --git a/fluxer_api/src/api/guild/services/data/GuildOperationsService.ts b/fluxer_api/src/api/guild/services/data/GuildOperationsService.ts index d7ee5ee34..8cf10a8c9 100644 --- a/fluxer_api/src/api/guild/services/data/GuildOperationsService.ts +++ b/fluxer_api/src/api/guild/services/data/GuildOperationsService.ts @@ -18,6 +18,7 @@ import { import type {ChannelService} from '@app/api/channel/services/ChannelService'; import {getContentMessage} from '@app/api/content_i18n/ContentI18n'; import {BatchBuilder} from '@app/api/database/CassandraQueryExecution'; +import {Db, type DbOp} from '@app/api/database/CassandraTypes'; import type {PermissionOverwrite} from '@app/api/database/types/ChannelTypes'; import type {GuildRow} from '@app/api/database/types/GuildTypes'; import {isTainted} from '@app/api/experiment/ChannelThreadsGate'; @@ -25,6 +26,11 @@ import {mapGuildToGuildResponse, mapGuildToPartialResponse} from '@app/api/guild import type {GuildDiscoveryRepository} from '@app/api/guild/repositories/GuildDiscoveryRepository'; import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository'; import type {GuildDataHelpers} from '@app/api/guild/services/data/GuildDataHelpers'; +import { + buildTemplateThreadParentConfig, + clipTemplateTopic, + mapOtherPlatformTemplateChannelType, +} from '@app/api/guild/services/data/TemplateChannelImport'; import {memberEndsInvitePause} from '@app/api/guild/services/GuildInvitePauseApplier'; import {emitGuildCreated, emitGuildFeaturesChanged} from '@app/api/infrastructure/activity/GuildEvents'; import {contentModerationService} from '@app/api/infrastructure/ContentModerationService'; @@ -43,7 +49,16 @@ import {getGuildSearchService} from '@app/api/SearchFactory'; import type {GuildDiscoveryContext} from '@app/api/search/guild/GuildSearchSerializer'; import {deleteChannelMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup'; import {deleteThreadSearchDocuments} from '@app/api/search/thread/ThreadSearchService'; -import {Channels, ChannelsByGuild, GuildMembers, GuildMembersByUserId, GuildRoles, Guilds} from '@app/api/Tables'; +import { + Channels, + ChannelsByGuild, + GuildMembers, + GuildMembersByUserId, + GuildRoles, + Guilds, + ThreadOnlyChannelsByGuild, + ThreadParentConfig as ThreadParentConfigTable, +} from '@app/api/Tables'; import type {UserRepository} from '@app/api/user/repositories/UserRepository'; import {mapUserSettingsToResponse} from '@app/api/user/UserMappers'; import {addGuildToUncategorizedFolder, removeGuildFromUserFolders} from '@app/api/user/utils/GuildFolderUtils'; @@ -71,6 +86,7 @@ import { VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT, VOICE_CHANNEL_USER_LIMIT_MAX, } from '@fluxer/constants/src/LimitConstants'; +import {THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; import { DEFAULT_THREAD_PERMISSIONS, THREAD_AWARE_ALL_PERMISSIONS, @@ -190,7 +206,6 @@ const THREAD_PURGE_CONCURRENCY = 8; const GUILD_DELETION_MEMBER_CONCURRENCY = 25; const GUILD_DELETION_PENDING_FEATURES = [GuildFeatures.UNAVAILABLE_FOR_EVERYONE, GuildFeatures.UNAVAILABLE_HIDDEN]; const TEMPLATE_AFK_TIMEOUT_MAX_SECONDS = 3600; -const THE_OTHER_PLATFORM_GUILD_STAGE_VOICE_CHANNEL_TYPE = 13; function isSystemChannelType(type: number): boolean { return type === ChannelTypes.GUILD_TEXT || type === ChannelTypes.GUILD_ANNOUNCEMENT; @@ -1024,7 +1039,7 @@ export class GuildOperationsService { throw new GuildTemplateInvalidError(); } seenChannelKeys.add(channelKey); - const fluxerType = this.mapOtherPlatformTemplateChannelTypeToFluxer(channel.type); + const fluxerType = mapOtherPlatformTemplateChannelType(channel.type); if (fluxerType == null) { skippedOtherPlatformChannelTypes.add(channel.type); continue; @@ -1102,7 +1117,9 @@ export class GuildOperationsService { const parentKey = channel.parent_id != null ? this.getTemplateEntityKey(channel.parent_id) : null; const parentType = parentKey != null ? channelTypeMap.get(parentKey) : null; const parentId = - parentKey != null && parentType === ChannelTypes.GUILD_CATEGORY ? (channelIdMap.get(parentKey) ?? null) : null; + fluxerType !== ChannelTypes.GUILD_CATEGORY && parentKey != null && parentType === ChannelTypes.GUILD_CATEGORY + ? (channelIdMap.get(parentKey) ?? null) + : null; const isVoice = fluxerType === ChannelTypes.GUILD_VOICE; let permissionOverwrites: Map | null = null; if (channel.permission_overwrites && channel.permission_overwrites.length > 0) { @@ -1132,7 +1149,7 @@ export class GuildOperationsService { guild_id: guildId, type: fluxerType, name: channel.name, - topic: channel.topic ?? null, + topic: clipTemplateTopic(channel.topic, fluxerType), icon_hash: null, url: null, parent_id: parentId, @@ -1160,11 +1177,25 @@ export class GuildOperationsService { }), ); batch.addPrepared( - ChannelsByGuild.upsertAll({ - guild_id: guildId, - channel_id: channelId, - }), + THREAD_ONLY_CHANNEL_TYPES.has(fluxerType) + ? ThreadOnlyChannelsByGuild.upsertAll({guild_id: guildId, channel_id: channelId}) + : ChannelsByGuild.upsertAll({guild_id: guildId, channel_id: channelId}), ); + const parentConfig = await buildTemplateThreadParentConfig(channel, fluxerType, () => + this.snowflakeService.generate(), + ); + if (parentConfig) { + const ops: Record> = {}; + for (const [key, value] of Object.entries(parentConfig)) { + if (value !== undefined) ops[key] = Db.set(value); + } + batch.addPrepared( + ThreadParentConfigTable.patchByPk( + {guild_id: guildId, channel_id: channelId}, + ops as Parameters[1], + ), + ); + } } let systemChannelId: ChannelID | null = null; if (template.system_channel_id != null) { @@ -1241,21 +1272,6 @@ export class GuildOperationsService { return roles.find((role) => this.getTemplateEntityKey(role.id) === '0') ?? null; } - private mapOtherPlatformTemplateChannelTypeToFluxer(channelType: number): number | null { - if ( - channelType === ChannelTypes.GUILD_TEXT || - channelType === ChannelTypes.GUILD_ANNOUNCEMENT || - channelType === ChannelTypes.GUILD_VOICE || - channelType === ChannelTypes.GUILD_CATEGORY - ) { - return channelType; - } - if (channelType === THE_OTHER_PLATFORM_GUILD_STAGE_VOICE_CHANNEL_TYPE) { - return ChannelTypes.GUILD_VOICE; - } - return null; - } - private sanitiseTemplateGuildSettings(template?: TemplateSerializedGuild): TemplateGuildSettings { return { verificationLevel: this.clampTemplateSetting( diff --git a/fluxer_api/src/api/guild/services/data/TemplateChannelImport.ts b/fluxer_api/src/api/guild/services/data/TemplateChannelImport.ts new file mode 100644 index 000000000..660146387 --- /dev/null +++ b/fluxer_api/src/api/guild/services/data/TemplateChannelImport.ts @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ThreadParentConfigPatch} from '@app/api/channel/repositories/ThreadRepository'; +import type {ForumTagUdt} from '@app/api/database/types/ThreadTypes'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {CHANNEL_TOPIC_MAX_LENGTH} from '@fluxer/constants/src/LimitConstants'; +import { + ChannelFlags, + FORUM_TAG_NAME_MAX_LENGTH, + FORUM_TOPIC_MAX_LENGTH, + ForumLayoutTypes, + ForumSortOrderTypes, + ForumTagSettings, + MAX_FORUM_TAGS_PER_CHANNEL, + settableChannelFlags, + THREAD_AUTO_ARCHIVE_DURATIONS, + THREAD_ONLY_CHANNEL_TYPES, + THREAD_PARENT_CHANNEL_TYPES, + THREAD_RATE_LIMIT_PER_USER_MAX, +} from '@fluxer/constants/src/ThreadConstants'; +import type {TemplateChannel} from '@fluxer/schema/src/domains/guild/GuildTemplateSchemas'; +import {isValidSingleUnicodeEmoji} from '@fluxer/schema/src/primitives/EmojiValidators'; + +const THE_OTHER_PLATFORM_GUILD_STAGE_VOICE_CHANNEL_TYPE = 13; + +const IMPORTED_CHANNEL_TYPES = new Set([ + ChannelTypes.GUILD_TEXT, + ChannelTypes.GUILD_VOICE, + ChannelTypes.GUILD_CATEGORY, + ChannelTypes.GUILD_ANNOUNCEMENT, + ChannelTypes.GUILD_FORUM, + ChannelTypes.GUILD_MEDIA, +]); + +const FORUM_SORT_ORDERS = new Set(Object.values(ForumSortOrderTypes)); +const FORUM_LAYOUTS = new Set(Object.values(ForumLayoutTypes)); +const FORUM_TAG_SETTINGS = new Set(Object.values(ForumTagSettings)); +const AUTO_ARCHIVE_DURATIONS = new Set(THREAD_AUTO_ARCHIVE_DURATIONS); + +export function mapOtherPlatformTemplateChannelType(channelType: number): number | null { + if (IMPORTED_CHANNEL_TYPES.has(channelType)) return channelType; + if (channelType === THE_OTHER_PLATFORM_GUILD_STAGE_VOICE_CHANNEL_TYPE) return ChannelTypes.GUILD_VOICE; + return null; +} + +function clipText(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + const last = value.charCodeAt(maxLength - 1); + const end = last >= 0xd800 && last <= 0xdbff ? maxLength - 1 : maxLength; + return value.slice(0, end); +} + +export function clipTemplateTopic(topic: string | null | undefined, channelType: number): string | null { + if (!topic) return null; + const maxLength = THREAD_ONLY_CHANNEL_TYPES.has(channelType) ? FORUM_TOPIC_MAX_LENGTH : CHANNEL_TOPIC_MAX_LENGTH; + return clipText(topic, maxLength); +} + +function pickUnicodeEmoji(emojiName: string | null | undefined): string | null { + return emojiName != null && isValidSingleUnicodeEmoji(emojiName) ? emojiName : null; +} + +async function buildTemplateTags( + tags: NonNullable, + generateId: () => Promise, +): Promise> { + const names = new Set(); + const result: Array = []; + for (const tag of tags) { + if (result.length >= MAX_FORUM_TAGS_PER_CHANNEL) break; + const name = clipText(tag.name.trim(), FORUM_TAG_NAME_MAX_LENGTH); + if (name.length === 0 || names.has(name)) continue; + names.add(name); + result.push({ + id: await generateId(), + name, + moderated: tag.moderated === true, + emoji_id: null, + emoji_name: pickUnicodeEmoji(tag.emoji_name), + }); + } + return result; +} + +export async function buildTemplateThreadParentConfig( + channel: TemplateChannel, + channelType: number, + generateId: () => Promise, +): Promise { + if (!THREAD_PARENT_CHANNEL_TYPES.has(channelType)) return null; + const patch: ThreadParentConfigPatch = {}; + const archiveDuration = channel.default_auto_archive_duration; + if (archiveDuration != null && AUTO_ARCHIVE_DURATIONS.has(archiveDuration)) { + patch.default_auto_archive_duration = archiveDuration; + } + const threadSlowmode = channel.default_thread_rate_limit_per_user; + if (threadSlowmode != null && threadSlowmode > 0) { + patch.default_thread_rate_limit_per_user = Math.min(threadSlowmode, THREAD_RATE_LIMIT_PER_USER_MAX); + } + if (THREAD_ONLY_CHANNEL_TYPES.has(channelType)) { + const tags = await buildTemplateTags(channel.available_tags ?? [], generateId); + if (tags.length > 0) patch.available_tags = tags; + const reactionEmoji = pickUnicodeEmoji(channel.default_reaction_emoji?.emoji_name); + if (reactionEmoji) patch.default_reaction_emoji_name = reactionEmoji; + const sortOrder = channel.default_sort_order; + if (sortOrder != null && FORUM_SORT_ORDERS.has(sortOrder)) patch.default_sort_order = sortOrder; + const layout = channel.default_forum_layout; + if (channelType === ChannelTypes.GUILD_FORUM && layout != null && FORUM_LAYOUTS.has(layout)) { + patch.default_forum_layout = layout; + } + const tagSetting = channel.default_tag_setting; + if (tagSetting != null && FORUM_TAG_SETTINGS.has(tagSetting)) patch.default_tag_setting = tagSetting; + let flags = (channel.flags ?? 0) & settableChannelFlags(channelType); + if (!tags.some((tag) => !tag.moderated)) flags &= ~ChannelFlags.REQUIRE_TAG; + if (flags !== 0) patch.flags = flags; + } + return Object.keys(patch).length > 0 ? patch : null; +} diff --git a/fluxer_api/src/api/guild/tests/GuildTemplateImport.test.ts b/fluxer_api/src/api/guild/tests/GuildTemplateImport.test.ts index 9e63719d6..673e3404a 100644 --- a/fluxer_api/src/api/guild/tests/GuildTemplateImport.test.ts +++ b/fluxer_api/src/api/guild/tests/GuildTemplateImport.test.ts @@ -1,12 +1,17 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils'; +import {createGuildID} from '@app/api/BrandedTypes'; +import {fetchMany} from '@app/api/database/CassandraQueryExecution'; import {getGuildChannels, getRoles} from '@app/api/guild/tests/GuildTestUtils'; +import {ChannelsByGuild, ThreadOnlyChannelsByGuild} from '@app/api/Tables'; import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness'; import {createBuilder} from '@app/api/test/TestRequestBuilder'; import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; import {SystemChannelFlags} from '@fluxer/constants/src/GuildConstants'; import {CHANNEL_TOPIC_MAX_LENGTH, VOICE_CHANNEL_USER_LIMIT_MAX} from '@fluxer/constants/src/LimitConstants'; +import {ChannelFlags, ForumLayoutTypes, ForumSortOrderTypes} from '@fluxer/constants/src/ThreadConstants'; +import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas'; import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest'; @@ -110,7 +115,7 @@ describe('Guild Template Import', () => { expect(guild.system_channel_flags).toBe(SystemChannelFlags.SUPPRESS_JOIN_NOTIFICATIONS); expect(guild.afk_timeout).toBe(60); }); - test('maps supported competitor-only channel types to Fluxer equivalents and ignores unsupported ones', async () => { + test('imports announcement, stage, forum and media channels and ignores unsupported types', async () => { const account = await createTestAccount(harness); const guild = await createBuilder(harness, account.token) .post('/guilds') @@ -155,16 +160,31 @@ describe('Guild Template Import', () => { }, { id: '4104', - type: 16, + type: ChannelTypes.GUILD_MEDIA, name: 'media-feed', parent_id: '4101', position: 3, }, + { + id: '4105', + type: ChannelTypes.GUILD_FORUM, + name: 'help', + parent_id: '4101', + position: 4, + rate_limit_per_user: 120, + }, + { + id: '4106', + type: 14, + name: 'directory', + parent_id: '4101', + position: 5, + }, ], }, }) .execute(); - const channels = await getGuildChannels(harness, account.token, guild.id); + const channels = await listThreadAwareChannels(account.token, guild.id); const categoryChannel = channels.find((channel) => channel.name === 'Main'); const announcementsChannel = channels.find((channel) => channel.name === 'announcements'); const stageChannel = channels.find((channel) => channel.name === 'town-hall'); @@ -176,7 +196,14 @@ describe('Guild Template Import', () => { expect(stageChannel).toBeDefined(); expect(stageChannel?.type).toBe(ChannelTypes.GUILD_VOICE); expect(stageChannel?.parent_id).toBe(categoryChannel?.id ?? null); - expect(mediaChannel).toBeUndefined(); + const forumChannel = channels.find((channel) => channel.name === 'help'); + expect(mediaChannel?.type).toBe(ChannelTypes.GUILD_MEDIA); + expect(mediaChannel?.parent_id).toBe(categoryChannel?.id ?? null); + expect(mediaChannel?.position).toBe(3); + expect(forumChannel?.type).toBe(ChannelTypes.GUILD_FORUM); + expect(forumChannel?.parent_id).toBe(categoryChannel?.id ?? null); + expect(forumChannel?.rate_limit_per_user).toBe(120); + expect(channels.find((channel) => channel.name === 'directory')).toBeUndefined(); expect(guild.system_channel_id).toBe(announcementsChannel?.id ?? null); }); test('accepts template roles and channels with empty names', async () => { @@ -273,7 +300,7 @@ describe('Guild Template Import', () => { expect(channels.find((channel) => channel.name === 'town-hall')?.user_limit).toBe(VOICE_CHANNEL_USER_LIMIT_MAX); expect(channels.find((channel) => channel.name === 'lounge')?.voice_connection_limit).toBe(100); }); - test('accepts forum-length topics and shortens them to the channel topic limit', async () => { + test('accepts forum-length topics and shortens them only for non-forum channels', async () => { const account = await createTestAccount(harness); const longTopic = `${'a'.repeat(CHANNEL_TOPIC_MAX_LENGTH - 1)}\u{1F600}${'b'.repeat(300)}`; const guild = await createBuilder(harness, account.token) @@ -288,13 +315,224 @@ describe('Guild Template Import', () => { }), }) .execute(); - const channels = await getGuildChannels(harness, account.token, guild.id); + const channels = await listThreadAwareChannels(account.token, guild.id); const general = channels.find((channel) => channel.name === 'general'); expect(general?.topic).toBe('a'.repeat(CHANNEL_TOPIC_MAX_LENGTH - 1)); - expect(channels.find((channel) => channel.name === 'projects')).toBeUndefined(); + expect(channels.find((channel) => channel.name === 'projects')?.topic).toBe('c'.repeat(1356)); }); + test('imports forum settings and drops values that cannot carry over', async () => { + const account = await createTestAccount(harness); + const guild = await createBuilder(harness, account.token) + .post('/guilds') + .body({ + name: 'Forum Settings Guild', + template: buildMinimalTemplate({ + channels: [ + {id: 6001, type: ChannelTypes.GUILD_TEXT, name: 'general', position: 0}, + { + id: 6002, + type: ChannelTypes.GUILD_FORUM, + name: 'support', + position: 1, + topic: 'Be kind', + available_tags: [ + {id: 111, name: 'bug', moderated: false, emoji_id: null, emoji_name: '\u{1F41B}'}, + {id: 112, name: 'custom', moderated: false, emoji_id: '123456789', emoji_name: null}, + {id: 113, name: 'bug', moderated: true, emoji_id: null, emoji_name: null}, + {id: 114, name: 'staff', moderated: true, emoji_id: null, emoji_name: 'not-an-emoji'}, + ], + default_reaction_emoji: {emoji_id: '987654321', emoji_name: null}, + default_sort_order: ForumSortOrderTypes.CREATION_TIME, + default_forum_layout: ForumLayoutTypes.GRID, + default_tag_setting: 'match_all', + default_thread_rate_limit_per_user: 60, + default_auto_archive_duration: 1440, + flags: ChannelFlags.REQUIRE_TAG | ChannelFlags.HIDE_MEDIA_DOWNLOAD_OPTIONS, + }, + { + id: 6003, + type: ChannelTypes.GUILD_MEDIA, + name: 'gallery', + position: 2, + available_tags: Array.from({length: 25}, (_, index) => ({name: `tag-${index}`})), + default_reaction_emoji: {emoji_id: null, emoji_name: '\u{1F525}'}, + default_forum_layout: ForumLayoutTypes.LIST, + default_sort_order: 7, + flags: ChannelFlags.HIDE_MEDIA_DOWNLOAD_OPTIONS, + }, + { + id: 6004, + type: ChannelTypes.GUILD_FORUM, + name: 'announcements-forum', + position: 3, + available_tags: [{name: 'staff', moderated: true}], + flags: ChannelFlags.REQUIRE_TAG, + }, + ], + }), + }) + .execute(); + const channels = await listThreadAwareChannels(account.token, guild.id); + const forum = await getThreadAwareChannel(account.token, findChannelId(channels, 'support')); + expect(forum.type).toBe(ChannelTypes.GUILD_FORUM); + expect(forum.topic).toBe('Be kind'); + expect( + forum.available_tags?.map(({name, moderated, emoji_id, emoji_name}) => ({name, moderated, emoji_id, emoji_name})), + ).toEqual([ + {name: 'bug', moderated: false, emoji_id: null, emoji_name: '\u{1F41B}'}, + {name: 'custom', moderated: false, emoji_id: null, emoji_name: null}, + {name: 'staff', moderated: true, emoji_id: null, emoji_name: null}, + ]); + expect(new Set(forum.available_tags?.map((tag) => tag.id)).size).toBe(3); + expect(forum.available_tags?.some((tag) => ['111', '112', '113', '114'].includes(tag.id))).toBe(false); + expect(forum.default_reaction_emoji).toBeNull(); + expect(forum.default_sort_order).toBe(ForumSortOrderTypes.CREATION_TIME); + expect(forum.default_forum_layout).toBe(ForumLayoutTypes.GRID); + expect(forum.default_tag_setting).toBe('match_all'); + expect(forum.default_thread_rate_limit_per_user).toBe(60); + expect(forum.default_auto_archive_duration).toBe(1440); + expect(forum.flags).toBe(ChannelFlags.REQUIRE_TAG); + const media = await getThreadAwareChannel(account.token, findChannelId(channels, 'gallery')); + expect(media.type).toBe(ChannelTypes.GUILD_MEDIA); + expect(media.available_tags).toHaveLength(20); + expect(media.default_reaction_emoji).toEqual({emoji_id: null, emoji_name: '\u{1F525}'}); + expect(media.default_forum_layout).toBeUndefined(); + expect(media.default_sort_order).toBeNull(); + expect(media.flags).toBe(ChannelFlags.HIDE_MEDIA_DOWNLOAD_OPTIONS); + const moderatedOnly = await getThreadAwareChannel(account.token, findChannelId(channels, 'announcements-forum')); + expect(moderatedOnly.flags).toBe(0); + }); + test('indexes imported forum and media channels as thread-only channels', async () => { + const account = await createTestAccount(harness); + const guild = await createBuilder(harness, account.token) + .post('/guilds') + .body({ + name: 'Index Guild', + template: buildMinimalTemplate({ + channels: [ + {id: 6001, type: ChannelTypes.GUILD_TEXT, name: 'general', position: 0}, + {id: 6002, type: ChannelTypes.GUILD_FORUM, name: 'forum', position: 1}, + {id: 6003, type: ChannelTypes.GUILD_MEDIA, name: 'media', position: 2}, + ], + }), + }) + .execute(); + const channels = await listThreadAwareChannels(account.token, guild.id); + const threadOnlyIds = [findChannelId(channels, 'forum'), findChannelId(channels, 'media')]; + const guildId = createGuildID(BigInt(guild.id)); + const threadOnlyRows = await fetchMany<{channel_id: bigint}>( + ThreadOnlyChannelsByGuild.selectCql({where: ThreadOnlyChannelsByGuild.where.eq('guild_id')}), + {guild_id: guildId}, + ); + const regularRows = await fetchMany<{channel_id: bigint}>( + ChannelsByGuild.selectCql({where: ChannelsByGuild.where.eq('guild_id')}), + {guild_id: guildId}, + ); + expect(threadOnlyRows.map((row) => row.channel_id.toString()).sort()).toEqual([...threadOnlyIds].sort()); + expect(regularRows.some((row) => threadOnlyIds.includes(row.channel_id.toString()))).toBe(false); + expect(regularRows.some((row) => row.channel_id.toString() === findChannelId(channels, 'general'))).toBe(true); + }); + test('accepts channels shaped like a full template export', async () => { + const account = await createTestAccount(harness); + const exported = { + bitrate: 64000, + user_limit: 0, + nsfw: false, + rate_limit_per_user: 0, + topic: null, + default_auto_archive_duration: null, + permission_overwrites: [{id: 0, type: 0, allow: '0', deny: Permissions.SEND_MESSAGES.toString()}], + available_tags: [], + template: '', + default_reaction_emoji: null, + default_thread_rate_limit_per_user: 0, + default_sort_order: null, + default_forum_layout: 0, + default_tag_setting: null, + icon_emoji: null, + theme_color: null, + }; + const guild = await createBuilder(harness, account.token) + .post('/guilds') + .body({ + name: 'Exported Guild', + template: { + ...buildMinimalTemplate({ + channels: [ + {...exported, id: 1, type: ChannelTypes.GUILD_CATEGORY, name: 'Info', position: 0, parent_id: null}, + {...exported, id: 6001, type: ChannelTypes.GUILD_TEXT, name: 'rules', position: 0, parent_id: 1}, + {...exported, id: 3, type: ChannelTypes.GUILD_CATEGORY, name: 'Nested', position: 1, parent_id: 1}, + { + ...exported, + id: 4, + type: ChannelTypes.GUILD_FORUM, + name: 'ideas', + position: 1, + parent_id: 1, + nsfw: null, + }, + ], + }), + afk_channel_id: null, + preferred_locale: 'en-US', + region: 'us-west', + }, + }) + .execute(); + const channels = await listThreadAwareChannels(account.token, guild.id); + const category = channels.find((channel) => channel.name === 'Info'); + const rules = channels.find((channel) => channel.name === 'rules'); + expect(rules?.parent_id).toBe(category?.id ?? null); + expect(rules?.permission_overwrites?.find((overwrite) => overwrite.id === guild.id)?.deny).toBe( + Permissions.SEND_MESSAGES.toString(), + ); + expect(channels.find((channel) => channel.name === 'Nested')?.parent_id ?? null).toBeNull(); + expect(channels.find((channel) => channel.name === 'ideas')?.parent_id).toBe(category?.id ?? null); + expect(guild.system_channel_id).toBe(rules?.id ?? null); + }); + test('hides imported forum channels from viewers outside the threads experiment', async () => { + const account = await createTestAccount(harness); + const guild = await createBuilder(harness, account.token) + .post('/guilds') + .body({ + name: 'Hidden Forum Guild', + template: buildMinimalTemplate({ + channels: [ + {id: 6001, type: ChannelTypes.GUILD_TEXT, name: 'general', position: 0}, + {id: 6002, type: ChannelTypes.GUILD_FORUM, name: 'forum', position: 1}, + ], + }), + }) + .execute(); + const legacy = await getGuildChannels(harness, account.token, guild.id); + expect(legacy.some((channel) => channel.name === 'forum')).toBe(false); + expect(legacy.some((channel) => channel.name === 'general')).toBe(true); + }); + + function listThreadAwareChannels(token: string, guildId: string): Promise> { + return createBuilder>(harness, token) + .get(`/guilds/${guildId}/channels`) + .header(THREAD_FEATURES_HEADER, THREAD_FEATURES_CAPABLE) + .execute(); + } + + function getThreadAwareChannel(token: string, channelId: string): Promise { + return createBuilder(harness, token) + .get(`/channels/${channelId}`) + .header(THREAD_FEATURES_HEADER, THREAD_FEATURES_CAPABLE) + .execute(); + } }); +const THREAD_FEATURES_HEADER = 'X-Fluxer-Features'; +const THREAD_FEATURES_CAPABLE = 'channel_threads'; + +function findChannelId(channels: Array, name: string): string { + const channel = channels.find((candidate) => candidate.name === name); + if (!channel) throw new Error(`Missing channel ${name}`); + return channel.id; +} + const DEFAULT_EVERYONE_PERMISSIONS = Permissions.VIEW_CHANNEL.toString(); function buildMinimalTemplate(overrides: {channels?: Array; roles?: Array}) { diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index 848db787d..eb0f4d500 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -36430,13 +36430,16 @@ "anyOf": [{"type": "integer", "minimum": 0, "maximum": 9007199254740991}, {"type": "string"}], "description": "The template-local channel ID" }, - "type": {"type": "number", "description": "The channel type (0 = text, 2 = voice, 4 = category)"}, + "type": { + "type": "number", + "description": "The channel type (0 = text, 2 = voice, 4 = category, 5 = announcement, 13 = stage, 15 = forum, 16 = media)" + }, "name": { "description": "The name of the channel", "anyOf": [{"type": "string", "maxLength": 100}, {"type": "null"}] }, "topic": { - "description": "The channel topic, shortened to the Fluxer channel topic limit", + "description": "The channel topic or forum guidelines, shortened to the limit for the channel type", "anyOf": [{"type": "string", "maxLength": 4096}, {"type": "null"}] }, "position": {"description": "The position of the channel", "$ref": "#/components/schemas/Int32Type"}, @@ -36462,38 +36465,129 @@ "description": "The per-user voice connection limit for voice channels", "anyOf": [{"type": "integer", "minimum": 1, "maximum": 100}, {"type": "null"}] }, - "nsfw": {"description": "Whether the channel is NSFW", "type": "boolean"}, + "nsfw": {"description": "Whether the channel is NSFW", "type": ["boolean", "null"]}, "rate_limit_per_user": { "description": "Slowmode rate limit in seconds", - "type": "integer", - "minimum": 0, - "maximum": 21600 + "anyOf": [{"type": "integer", "minimum": 0, "maximum": 21600}, {"type": "null"}] }, "permission_overwrites": { "description": "Permission overwrites for this channel", - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "anyOf": [{"type": "integer", "minimum": 0, "maximum": 9007199254740991}, {"type": "string"}], - "description": "The ID of the role or user for this overwrite" - }, - "type": { - "description": "The type of overwrite (0/role = role, 1/member = member)", - "type": ["number", "string"] - }, - "allow": { - "anyOf": [{"type": "string"}, {"type": "integer", "minimum": 0, "maximum": 9007199254740991}], - "description": "The allowed permissions bitfield as a string" - }, - "deny": { - "anyOf": [{"type": "string"}, {"type": "integer", "minimum": 0, "maximum": 9007199254740991}], - "description": "The denied permissions bitfield as a string" + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "anyOf": [{"type": "integer", "minimum": 0, "maximum": 9007199254740991}, {"type": "string"}], + "description": "The ID of the role or user for this overwrite" + }, + "type": { + "description": "The type of overwrite (0/role = role, 1/member = member)", + "type": ["number", "string"] + }, + "allow": { + "anyOf": [{"type": "string"}, {"type": "integer", "minimum": 0, "maximum": 9007199254740991}], + "description": "The allowed permissions bitfield as a string" + }, + "deny": { + "anyOf": [{"type": "string"}, {"type": "integer", "minimum": 0, "maximum": 9007199254740991}], + "description": "The denied permissions bitfield as a string" + } + }, + "required": ["id", "type", "allow", "deny"] } }, - "required": ["id", "type", "allow", "deny"] - } + {"type": "null"} + ] + }, + "available_tags": { + "description": "Tags for forum and media channels", + "anyOf": [ + { + "maxItems": 100, + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string", "maxLength": 100, "description": "The name of the tag"}, + "moderated": { + "description": "Whether only moderators can apply the tag", + "type": ["boolean", "null"] + }, + "emoji_id": { + "description": "A custom emoji reference, ignored on import", + "anyOf": [ + { + "anyOf": [ + {"type": "string", "maxLength": 64}, + {"type": "integer", "minimum": 0, "maximum": 9007199254740991} + ] + }, + {"type": "null"} + ] + }, + "emoji_name": { + "description": "The unicode emoji of the tag", + "anyOf": [{"type": "string", "maxLength": 64}, {"type": "null"}] + } + }, + "required": ["name"] + } + }, + {"type": "null"} + ] + }, + "default_reaction_emoji": { + "description": "Default reaction for forum and media posts", + "anyOf": [ + { + "type": "object", + "properties": { + "emoji_id": { + "description": "A custom emoji reference, ignored on import", + "anyOf": [ + { + "anyOf": [ + {"type": "string", "maxLength": 64}, + {"type": "integer", "minimum": 0, "maximum": 9007199254740991} + ] + }, + {"type": "null"} + ] + }, + "emoji_name": { + "description": "The unicode emoji", + "anyOf": [{"type": "string", "maxLength": 64}, {"type": "null"}] + } + } + }, + {"type": "null"} + ] + }, + "default_sort_order": { + "description": "Default post sort order for forum and media channels", + "anyOf": [{"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991}, {"type": "null"}] + }, + "default_forum_layout": { + "description": "Default layout for forum channels", + "anyOf": [{"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991}, {"type": "null"}] + }, + "default_tag_setting": { + "description": "How posts are filtered by several tags", + "anyOf": [{"type": "string", "maxLength": 32}, {"type": "null"}] + }, + "default_thread_rate_limit_per_user": { + "description": "Slowmode in seconds copied onto new threads", + "anyOf": [{"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991}, {"type": "null"}] + }, + "default_auto_archive_duration": { + "description": "Default auto archive duration in minutes for new threads", + "anyOf": [{"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991}, {"type": "null"}] + }, + "flags": { + "description": "Channel flags", + "anyOf": [{"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991}, {"type": "null"}] } }, "required": ["id", "type", "position"] diff --git a/fluxer_app/src/features/guild/components/modals/add_guild_modal/TemplateImportForm.tsx b/fluxer_app/src/features/guild/components/modals/add_guild_modal/TemplateImportForm.tsx index 0efb4437d..584928823 100644 --- a/fluxer_app/src/features/guild/components/modals/add_guild_modal/TemplateImportForm.tsx +++ b/fluxer_app/src/features/guild/components/modals/add_guild_modal/TemplateImportForm.tsx @@ -27,26 +27,32 @@ import { type TemplateCreateFormInputs, type TemplateImportFormInputs, type TemplateJsonFormInputs, + THE_OTHER_PLATFORM_TEMPLATE_NOT_FOUND_DESCRIPTOR, + THE_OTHER_PLATFORM_TEMPLATE_RATE_LIMITED_DESCRIPTOR, + THE_OTHER_PLATFORM_TEMPLATE_UNREADABLE_DESCRIPTOR, THIS_DOESN_T_LOOK_LIKE_A_VALID_TEMPLATE_DESCRIPTOR, } from '@app/features/guild/components/modals/add_guild_modal/shared'; import {CREATE_COMMUNITY_DESCRIPTOR, NEXT_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import {openExternalUrlWithWarning} from '@app/features/messaging/utils/ExternalLinkUtils'; import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands'; +import {Logger} from '@app/features/platform/utils/AppLogger'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import {Button} from '@app/features/ui/button/Button'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {Form} from '@app/features/ui/components/form/Form'; import {Input, Textarea} from '@app/features/ui/components/form/FormInput'; import {SteppedCarousel} from '@app/features/ui/stepped_carousel/SteppedCarousel'; +import {getElectronAPI} from '@app/features/ui/utils/NativeUtils'; import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; import {THE_OTHER_PLATFORM} from '@fluxer/constants/src/ExternalPlatformConstants'; +import {OtherPlatformTemplateFetchFailure} from '@fluxer/desktop_ipc/src/OtherPlatformTemplateContract'; import type {TemplateSerializedGuild} from '@fluxer/schema/src/domains/guild/GuildTemplateSchemas'; import {msg} from '@lingui/core/macro'; import {Trans, useLingui} from '@lingui/react/macro'; import {ArrowSquareOutIcon} from '@phosphor-icons/react'; import {observer} from 'mobx-react-lite'; import type React from 'react'; -import {useCallback, useContext, useEffect, useId, useMemo, useState} from 'react'; +import {useCallback, useContext, useEffect, useId, useMemo, useRef, useState} from 'react'; import {useForm} from 'react-hook-form'; import * as v from 'valibot'; @@ -71,7 +77,7 @@ const OverwriteType = v.pipe( return value; }), ); -const TemplateChannelSchema = v.object({ +const TemplateChannelSchema = v.looseObject({ id: TemplateEntityId, type: v.number(), name: v.nullish(v.string(), ''), @@ -81,9 +87,9 @@ const TemplateChannelSchema = v.object({ bitrate: v.nullish(v.number()), user_limit: v.nullish(v.number()), voice_connection_limit: v.nullish(v.number()), - nsfw: v.optional(v.boolean()), - rate_limit_per_user: v.optional(v.number()), - permission_overwrites: v.optional( + nsfw: v.nullish(v.boolean()), + rate_limit_per_user: v.nullish(v.number()), + permission_overwrites: v.nullish( v.array(v.object({id: TemplateEntityId, type: OverwriteType, allow: PermissionBitfield, deny: PermissionBitfield})), ), }); @@ -116,6 +122,9 @@ const GuildTemplateResponseSchema = v.object({ }); type TemplateImportStep = 'url' | 'json' | 'create'; +type TemplateResponseOutcome = 'applied' | 'invalid'; + +const logger = new Logger('TemplateImportForm'); const TEMPLATE_IMPORT_STEP_ORDER: ReadonlyArray = ['url', 'json', 'create']; const TEMPLATE_STATS_DESCRIPTOR = msg({ @@ -124,6 +133,18 @@ const TEMPLATE_STATS_DESCRIPTOR = msg({ comment: 'Template preview summary listing how many text channels, voice channels, categories, and roles will be imported.', }); +const TEMPLATE_STATS_WITH_FORUMS_DESCRIPTOR = msg({ + message: + '{textChannelCount, plural, one {# text channel} other {# text channels}}, {forumChannelCount, plural, one {# forum channel} other {# forum channels}}, {voiceChannelCount, plural, one {# voice channel} other {# voice channels}}, {categoryCount, plural, one {# category} other {# categories}}, {roleCount, plural, one {# role} other {# roles}}', + comment: + 'Template preview summary listing how many text channels, forum channels, voice channels, categories, and roles will be imported. Used when the template has at least one forum or media channel.', +}); +const COULDN_T_LOAD_THE_TEMPLATE_AUTOMATICALLY_DESCRIPTOR = msg({ + message: + "Couldn't load the template automatically. Open the link below in your browser, then copy the entire JSON response and paste it into the box.", + comment: + 'Instructions in the desktop app import-template flow shown when looking up the template directly failed, so the user has to fetch the JSON in a browser and paste it instead. "JSON" must not be translated.', +}); const IMAGE_COULDN_T_BE_USED_DESCRIPTOR = msg({ message: "Image couldn't be used", comment: 'Error modal title shown when a template-import community icon upload cannot be accepted or processed.', @@ -139,6 +160,9 @@ export const TemplateImportForm = observer(() => { const [apiUrl, setApiUrl] = useState(null); const [templateData, setTemplateData] = useState(null); const [templateName, setTemplateName] = useState(null); + const [loadedDirectly, setLoadedDirectly] = useState(false); + const [directLoadFailed, setDirectLoadFailed] = useState(false); + const lookupInFlightRef = useRef(false); const urlForm = useForm({defaultValues: {url: ''}}); const jsonForm = useForm({defaultValues: {json: ''}}); const createForm = useForm({defaultValues: {name: ''}}); @@ -146,23 +170,86 @@ export const TemplateImportForm = observer(() => { const urlFormId = useId(); const jsonFormId = useId(); const createFormId = useId(); + const applyTemplateResponse = useCallback( + (json: unknown): TemplateResponseOutcome => { + const parsed = v.safeParse(GuildTemplateResponseSchema, json); + if (!parsed.success) return 'invalid'; + setTemplateData(parsed.output.serialized_source_guild); + setTemplateName(parsed.output.name); + createForm.setValue('name', parsed.output.serialized_source_guild.name); + return 'applied'; + }, + [createForm], + ); + const fallBackToPaste = useCallback((code: string, directLoadAttempted: boolean) => { + setApiUrl(`https://discord.com/api/guilds/templates/${code}`); + setLoadedDirectly(false); + setDirectLoadFailed(directLoadAttempted); + setStep('json'); + }, []); const handleResolveUrl = useCallback( - (data: TemplateImportFormInputs) => { + async (data: TemplateImportFormInputs) => { const code = parseTemplateCode(data.url); + const invalidCodeMessage = i18n._(PLEASE_ENTER_A_VALID_THE_OTHER_PLATFORM_TEMPLATE_URL_OR_DESCRIPTOR, { + theOtherPlatform: THE_OTHER_PLATFORM, + }); if (!code) { - urlForm.setError('url', { - message: i18n._(PLEASE_ENTER_A_VALID_THE_OTHER_PLATFORM_TEMPLATE_URL_OR_DESCRIPTOR, { - theOtherPlatform: THE_OTHER_PLATFORM, - }), - }); + urlForm.setError('url', {message: invalidCodeMessage}); return; } - setApiUrl(`https://discord.com/api/guilds/templates/${code}`); - setStep('json'); + const templateApi = getElectronAPI()?.otherPlatformTemplate; + if (typeof templateApi?.fetch !== 'function') { + fallBackToPaste(code, false); + return; + } + if (lookupInFlightRef.current) return; + lookupInFlightRef.current = true; + let result: Awaited>; + try { + result = await templateApi.fetch(code); + } catch (error) { + logger.warn('Direct template lookup failed, falling back to paste', error); + fallBackToPaste(code, true); + return; + } finally { + lookupInFlightRef.current = false; + } + if (result.ok) { + if (applyTemplateResponse(result.template) === 'invalid') { + urlForm.setError('url', { + message: i18n._(THE_OTHER_PLATFORM_TEMPLATE_UNREADABLE_DESCRIPTOR, {theOtherPlatform: THE_OTHER_PLATFORM}), + }); + return; + } + setLoadedDirectly(true); + setStep('create'); + return; + } + switch (result.failure) { + case OtherPlatformTemplateFetchFailure.INVALID_CODE: + urlForm.setError('url', {message: invalidCodeMessage}); + return; + case OtherPlatformTemplateFetchFailure.NOT_FOUND: + urlForm.setError('url', { + message: i18n._(THE_OTHER_PLATFORM_TEMPLATE_NOT_FOUND_DESCRIPTOR, {theOtherPlatform: THE_OTHER_PLATFORM}), + }); + return; + case OtherPlatformTemplateFetchFailure.RATE_LIMITED: + urlForm.setError('url', { + message: i18n._(THE_OTHER_PLATFORM_TEMPLATE_RATE_LIMITED_DESCRIPTOR, { + theOtherPlatform: THE_OTHER_PLATFORM, + }), + }); + return; + case OtherPlatformTemplateFetchFailure.NETWORK: + case OtherPlatformTemplateFetchFailure.INVALID_RESPONSE: + fallBackToPaste(code, true); + return; + } }, - [urlForm, i18n], + [urlForm, i18n, applyTemplateResponse, fallBackToPaste], ); - const {handleSubmit: handleUrlSubmit} = useFormSubmit({ + const {handleSubmit: handleUrlSubmit, isSubmitting: isResolvingUrl} = useFormSubmit({ form: urlForm, onSubmit: handleResolveUrl, defaultErrorField: 'url', @@ -176,17 +263,13 @@ export const TemplateImportForm = observer(() => { jsonForm.setError('json', {message: i18n._(INVALID_JSON_PLEASE_PASTE_THE_FULL_RESPONSE_FROM_DESCRIPTOR)}); return; } - const parsed = v.safeParse(GuildTemplateResponseSchema, json); - if (!parsed.success) { + if (applyTemplateResponse(json) === 'invalid') { jsonForm.setError('json', {message: i18n._(THIS_DOESN_T_LOOK_LIKE_A_VALID_TEMPLATE_DESCRIPTOR)}); return; } - setTemplateData(parsed.output.serialized_source_guild); - setTemplateName(parsed.output.name); - createForm.setValue('name', parsed.output.serialized_source_guild.name); setStep('create'); }, - [jsonForm, createForm, i18n], + [jsonForm, i18n, applyTemplateResponse], ); const {handleSubmit: handleJsonSubmit} = useFormSubmit({ form: jsonForm, @@ -231,13 +314,15 @@ export const TemplateImportForm = observer(() => { const channelTypes = templateData.channels .map((channel) => mapTemplateChannelTypeToFluxer(channel.type)) .filter((channelType): channelType is number => channelType !== null); - const textChannels = channelTypes.filter( - (channelType) => channelType === ChannelTypes.GUILD_TEXT || channelType === ChannelTypes.GUILD_ANNOUNCEMENT, - ).length; - const voiceChannels = channelTypes.filter((channelType) => channelType === ChannelTypes.GUILD_VOICE).length; - const categories = channelTypes.filter((channelType) => channelType === ChannelTypes.GUILD_CATEGORY).length; - const roles = templateData.roles.filter((role) => !isTemplateEveryoneRole(role)).length; - return {textChannels, voiceChannels, categories, roles}; + const countOf = (...types: ReadonlyArray) => + channelTypes.filter((channelType) => types.includes(channelType)).length; + return { + textChannels: countOf(ChannelTypes.GUILD_TEXT, ChannelTypes.GUILD_ANNOUNCEMENT), + forumChannels: countOf(ChannelTypes.GUILD_FORUM, ChannelTypes.GUILD_MEDIA), + voiceChannels: countOf(ChannelTypes.GUILD_VOICE), + categories: countOf(ChannelTypes.GUILD_CATEGORY), + roles: templateData.roles.filter((role) => !isTemplateEveryoneRole(role)).length, + }; }, [templateData]); const handleOpenApiUrl = useCallback(() => { if (apiUrl) { @@ -250,7 +335,7 @@ export const TemplateImportForm = observer(() => { modalFooterContext?.setFooterContent( <>