From e11f9bc52ebfc30fa2e54d1f0f6c0b7a367c1ba5 Mon Sep 17 00:00:00 2001 From: Hampus Kraft Date: Fri, 20 Feb 2026 22:36:48 +0000 Subject: [PATCH] fix: channel ordering --- .../src/actions/GuildActionCreators.tsx | 1 + .../utils/ChannelMoveOperation.test.tsx | 180 ++++++++++++++++++ .../controllers/GuildChannelController.tsx | 4 + .../guild/services/GuildChannelService.tsx | 1 + .../api/src/guild/services/GuildService.tsx | 1 + .../channel/ChannelOperationsService.tsx | 52 +++-- .../tests/GuildChannelPositions.test.tsx | 160 ++++++++++++++++ .../api/src/guild/tests/GuildTestUtils.tsx | 8 +- .../domains/channel/ChannelRequestSchemas.tsx | 3 + .../domains/channel/GuildChannelOrdering.tsx | 47 ++++- 10 files changed, 438 insertions(+), 19 deletions(-) create mode 100644 fluxer_app/src/components/layout/utils/ChannelMoveOperation.test.tsx diff --git a/fluxer_app/src/actions/GuildActionCreators.tsx b/fluxer_app/src/actions/GuildActionCreators.tsx index e9311f6d..5833c6a7 100644 --- a/fluxer_app/src/actions/GuildActionCreators.tsx +++ b/fluxer_app/src/actions/GuildActionCreators.tsx @@ -123,6 +123,7 @@ export async function moveChannel(guildId: string, operation: ChannelMoveOperati { id: operation.channelId, parent_id: operation.newParentId, + preceding_sibling_id: operation.precedingSiblingId, lock_permissions: false, position: operation.position, }, diff --git a/fluxer_app/src/components/layout/utils/ChannelMoveOperation.test.tsx b/fluxer_app/src/components/layout/utils/ChannelMoveOperation.test.tsx new file mode 100644 index 00000000..520c1350 --- /dev/null +++ b/fluxer_app/src/components/layout/utils/ChannelMoveOperation.test.tsx @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2026 Fluxer Contributors + * + * This file is part of Fluxer. + * + * Fluxer is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Fluxer is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Fluxer. If not, see . + */ + +import {DND_TYPES, type DragItem, type DropResult} from '@app/components/layout/types/DndTypes'; +import {createChannelMoveOperation} from '@app/components/layout/utils/ChannelMoveOperation'; +import {ChannelRecord} from '@app/records/ChannelRecord'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {describe, expect, it} from 'vitest'; + +const TEST_GUILD_ID = 'guild'; + +function createChannel(params: {id: string; type: number; position: number; parentId?: string | null}): ChannelRecord { + return new ChannelRecord({ + id: params.id, + guild_id: TEST_GUILD_ID, + name: params.id, + type: params.type, + position: params.position, + parent_id: params.parentId ?? null, + }); +} + +function createDragItem(channel: ChannelRecord): DragItem { + return { + type: DND_TYPES.CHANNEL, + id: channel.id, + channelType: channel.type, + parentId: channel.parentId, + guildId: TEST_GUILD_ID, + }; +} + +describe('ChannelMoveOperation', () => { + it('moves a text channel into a voice-only category at the top', () => { + const textCategory = createChannel({id: 'text-category', type: ChannelTypes.GUILD_CATEGORY, position: 0}); + const voiceCategory = createChannel({id: 'voice-category', type: ChannelTypes.GUILD_CATEGORY, position: 1}); + const generalText = createChannel({ + id: 'general', + type: ChannelTypes.GUILD_TEXT, + position: 2, + parentId: textCategory.id, + }); + const generalVoice = createChannel({ + id: 'General', + type: ChannelTypes.GUILD_VOICE, + position: 3, + parentId: voiceCategory.id, + }); + + const operation = createChannelMoveOperation({ + channels: [textCategory, voiceCategory, generalText, generalVoice], + dragItem: createDragItem(generalText), + dropResult: { + targetId: voiceCategory.id, + position: 'inside', + targetParentId: voiceCategory.id, + }, + }); + + expect(operation).toEqual({ + channelId: generalText.id, + newParentId: voiceCategory.id, + precedingSiblingId: null, + position: 0, + }); + }); + + it('keeps text channels above voice channels even when dropped after a voice channel', () => { + const voiceCategory = createChannel({id: 'voice-category', type: ChannelTypes.GUILD_CATEGORY, position: 0}); + const generalText = createChannel({id: 'general', type: ChannelTypes.GUILD_TEXT, position: 1}); + const generalVoice = createChannel({ + id: 'General', + type: ChannelTypes.GUILD_VOICE, + position: 2, + parentId: voiceCategory.id, + }); + + const dropResult: DropResult = { + targetId: generalVoice.id, + position: 'after', + targetParentId: voiceCategory.id, + }; + const operation = createChannelMoveOperation({ + channels: [voiceCategory, generalText, generalVoice], + dragItem: createDragItem(generalText), + dropResult, + }); + + expect(operation).toEqual({ + channelId: generalText.id, + newParentId: voiceCategory.id, + precedingSiblingId: null, + position: 0, + }); + }); + + it('keeps moved voice channels below all text siblings', () => { + const textCategory = createChannel({id: 'text-category', type: ChannelTypes.GUILD_CATEGORY, position: 0}); + const voiceCategory = createChannel({id: 'voice-category', type: ChannelTypes.GUILD_CATEGORY, position: 1}); + const textOne = createChannel({ + id: 'text-one', + type: ChannelTypes.GUILD_TEXT, + position: 2, + parentId: textCategory.id, + }); + const textTwo = createChannel({ + id: 'text-two', + type: ChannelTypes.GUILD_TEXT, + position: 3, + parentId: textCategory.id, + }); + const lounge = createChannel({ + id: 'lounge', + type: ChannelTypes.GUILD_VOICE, + position: 4, + parentId: voiceCategory.id, + }); + + const operation = createChannelMoveOperation({ + channels: [textCategory, voiceCategory, textOne, textTwo, lounge], + dragItem: createDragItem(lounge), + dropResult: { + targetId: textOne.id, + position: 'before', + targetParentId: textCategory.id, + }, + }); + + expect(operation).toEqual({ + channelId: lounge.id, + newParentId: textCategory.id, + precedingSiblingId: textTwo.id, + position: 2, + }); + }); + + it('returns null when dropping to the same effective placement', () => { + const category = createChannel({id: 'category', type: ChannelTypes.GUILD_CATEGORY, position: 0}); + const textOne = createChannel({ + id: 'text-one', + type: ChannelTypes.GUILD_TEXT, + position: 1, + parentId: category.id, + }); + const textTwo = createChannel({ + id: 'text-two', + type: ChannelTypes.GUILD_TEXT, + position: 2, + parentId: category.id, + }); + + const operation = createChannelMoveOperation({ + channels: [category, textOne, textTwo], + dragItem: createDragItem(textOne), + dropResult: { + targetId: textTwo.id, + position: 'before', + targetParentId: category.id, + }, + }); + + expect(operation).toBeNull(); + }); +}); diff --git a/packages/api/src/guild/controllers/GuildChannelController.tsx b/packages/api/src/guild/controllers/GuildChannelController.tsx index d8e60adb..435aac05 100644 --- a/packages/api/src/guild/controllers/GuildChannelController.tsx +++ b/packages/api/src/guild/controllers/GuildChannelController.tsx @@ -114,6 +114,10 @@ export function GuildChannelController(app: HonoApp) { channelId: createChannelID(item.id), position: item.position, parentId: item.parent_id == null ? item.parent_id : createChannelID(item.parent_id), + precedingSiblingId: + item.preceding_sibling_id == null + ? item.preceding_sibling_id + : createChannelID(item.preceding_sibling_id), lockPermissions: item.lock_permissions ?? false, })), requestCache, diff --git a/packages/api/src/guild/services/GuildChannelService.tsx b/packages/api/src/guild/services/GuildChannelService.tsx index 23dc5604..de8f9ee4 100644 --- a/packages/api/src/guild/services/GuildChannelService.tsx +++ b/packages/api/src/guild/services/GuildChannelService.tsx @@ -112,6 +112,7 @@ export class GuildChannelService { channelId: ChannelID; position?: number; parentId: ChannelID | null | undefined; + precedingSiblingId: ChannelID | null | undefined; lockPermissions: boolean; }>; requestCache: RequestCache; diff --git a/packages/api/src/guild/services/GuildService.tsx b/packages/api/src/guild/services/GuildService.tsx index d5070037..3323c24c 100644 --- a/packages/api/src/guild/services/GuildService.tsx +++ b/packages/api/src/guild/services/GuildService.tsx @@ -803,6 +803,7 @@ export class GuildService { channelId: ChannelID; position?: number; parentId: ChannelID | null | undefined; + precedingSiblingId: ChannelID | null | undefined; lockPermissions: boolean; }>; requestCache: RequestCache; diff --git a/packages/api/src/guild/services/channel/ChannelOperationsService.tsx b/packages/api/src/guild/services/channel/ChannelOperationsService.tsx index ad00e3b9..6dc3f535 100644 --- a/packages/api/src/guild/services/channel/ChannelOperationsService.tsx +++ b/packages/api/src/guild/services/channel/ChannelOperationsService.tsx @@ -401,6 +401,7 @@ export class ChannelOperationsService { channelId: ChannelID; position?: number; parentId: ChannelID | null | undefined; + precedingSiblingId: ChannelID | null | undefined; lockPermissions: boolean; }>; requestCache: RequestCache; @@ -424,6 +425,11 @@ export class ChannelOperationsService { if (update.parentId && !channelMap.has(update.parentId)) { throw InputValidationError.fromCode('parent_id', ValidationErrorCodes.INVALID_PARENT_CHANNEL); } + if (update.precedingSiblingId && !channelMap.has(update.precedingSiblingId)) { + throw InputValidationError.fromCode('preceding_sibling_id', ValidationErrorCodes.INVALID_CHANNEL_ID, { + channelId: update.precedingSiblingId.toString(), + }); + } } const viewable = new Set(await this.gatewayService.getViewableChannels({guildId, userId})); @@ -435,6 +441,9 @@ export class ChannelOperationsService { if (update.parentId && !viewable.has(update.parentId)) { throw new MissingPermissionsError(); } + if (update.precedingSiblingId && !viewable.has(update.precedingSiblingId)) { + throw new MissingPermissionsError(); + } } for (const update of updates) { @@ -453,7 +462,13 @@ export class ChannelOperationsService { private async applySinglePositionUpdate(params: { guildId: GuildID; userId: UserID; - update: {channelId: ChannelID; position?: number; parentId: ChannelID | null | undefined; lockPermissions: boolean}; + update: { + channelId: ChannelID; + position?: number; + parentId: ChannelID | null | undefined; + precedingSiblingId: ChannelID | null | undefined; + lockPermissions: boolean; + }; requestCache: RequestCache; }): Promise { const {guildId, update, requestCache} = params; @@ -478,26 +493,29 @@ export class ChannelOperationsService { throw InputValidationError.fromCode('parent_id', ValidationErrorCodes.CATEGORIES_CANNOT_HAVE_PARENTS); } - const orderedChannels = sortChannelsForOrdering(allChannels); - const siblings = orderedChannels.filter((ch) => (ch.parentId ?? null) === desiredParent); - const blockIds = computeChannelMoveBlockIds({channels: orderedChannels, targetId: target.id}); - const siblingsWithoutBlock = siblings.filter((ch) => !blockIds.has(ch.id)); + let precedingSibling = update.precedingSiblingId ?? null; + if (update.precedingSiblingId === undefined) { + const orderedChannels = sortChannelsForOrdering(allChannels); + const siblings = orderedChannels.filter((ch) => (ch.parentId ?? null) === desiredParent); + const blockIds = computeChannelMoveBlockIds({channels: orderedChannels, targetId: target.id}); + const siblingsWithoutBlock = siblings.filter((ch) => !blockIds.has(ch.id)); - let insertIndex = siblingsWithoutBlock.length; - if (update.position !== undefined) { - const adjustedPosition = Math.max(update.position, 0); - insertIndex = Math.min(adjustedPosition, siblingsWithoutBlock.length); - } else { - const isVoice = target.type === ChannelTypes.GUILD_VOICE; - if (isVoice) { - insertIndex = siblingsWithoutBlock.length; + let insertIndex = siblingsWithoutBlock.length; + if (update.position !== undefined) { + const adjustedPosition = Math.max(update.position, 0); + insertIndex = Math.min(adjustedPosition, siblingsWithoutBlock.length); } else { - const firstVoice = siblingsWithoutBlock.findIndex((ch) => ch.type === ChannelTypes.GUILD_VOICE); - insertIndex = firstVoice === -1 ? siblingsWithoutBlock.length : firstVoice; + const isVoice = target.type === ChannelTypes.GUILD_VOICE; + if (isVoice) { + insertIndex = siblingsWithoutBlock.length; + } else { + const firstVoice = siblingsWithoutBlock.findIndex((ch) => ch.type === ChannelTypes.GUILD_VOICE); + insertIndex = firstVoice === -1 ? siblingsWithoutBlock.length : firstVoice; + } } - } - const precedingSibling = insertIndex === 0 ? null : siblingsWithoutBlock[insertIndex - 1].id; + precedingSibling = insertIndex === 0 ? null : siblingsWithoutBlock[insertIndex - 1].id; + } await this.executeChannelReorder({ guildId, diff --git a/packages/api/src/guild/tests/GuildChannelPositions.test.tsx b/packages/api/src/guild/tests/GuildChannelPositions.test.tsx index 9c98d2b7..8da16058 100644 --- a/packages/api/src/guild/tests/GuildChannelPositions.test.tsx +++ b/packages/api/src/guild/tests/GuildChannelPositions.test.tsx @@ -33,6 +33,7 @@ import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/Ap import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants'; import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder'; import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; +import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {afterEach, beforeEach, describe, expect, test} from 'vitest'; describe('Guild Channel Positions', () => { @@ -257,4 +258,163 @@ describe('Guild Channel Positions', () => { .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); expect(destinationSiblings.map((channel) => channel.id)).toEqual([textOne.id, textTwo.id, voiceChannel.id]); }); + + test('should move default general text under the default voice category', async () => { + const account = await createTestAccount(harness); + const guild = await createGuild(harness, account.token, 'Test Guild'); + const channels = await getGuildChannels(harness, account.token, guild.id); + + const general = channels.find((channel) => channel.type === ChannelTypes.GUILD_TEXT && channel.name === 'general'); + const voiceCategory = channels.find( + (channel) => channel.type === ChannelTypes.GUILD_CATEGORY && channel.name === 'Voice Channels', + ); + const generalVoice = channels.find( + (channel) => channel.type === ChannelTypes.GUILD_VOICE && channel.name === 'General', + ); + + expect(general).toBeDefined(); + expect(voiceCategory).toBeDefined(); + expect(generalVoice).toBeDefined(); + + await updateChannelPositions(harness, account.token, guild.id, [ + { + id: general!.id, + parent_id: voiceCategory!.id, + position: 0, + }, + ]); + + const updatedChannels = await getGuildChannels(harness, account.token, guild.id); + const voiceCategorySiblings = updatedChannels + .filter((channel) => channel.parent_id === voiceCategory!.id) + .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); + expect(voiceCategorySiblings.map((channel) => channel.id)).toEqual([general!.id, generalVoice!.id]); + }); + + test('should prioritise preceding_sibling_id over position when both are provided', async () => { + const account = await createTestAccount(harness); + const guild = await createGuild(harness, account.token, 'Test Guild'); + + const category = await createChannel(harness, account.token, guild.id, 'Category', ChannelTypes.GUILD_CATEGORY); + const textOne = await createChannel(harness, account.token, guild.id, 'one', ChannelTypes.GUILD_TEXT); + const textTwo = await createChannel(harness, account.token, guild.id, 'two', ChannelTypes.GUILD_TEXT); + const textThree = await createChannel(harness, account.token, guild.id, 'three', ChannelTypes.GUILD_TEXT); + + await updateChannelPositions(harness, account.token, guild.id, [ + {id: textOne.id, parent_id: category.id}, + {id: textTwo.id, parent_id: category.id}, + {id: textThree.id, parent_id: category.id}, + ]); + + await updateChannelPositions(harness, account.token, guild.id, [ + { + id: textThree.id, + parent_id: category.id, + position: 0, + preceding_sibling_id: textOne.id, + }, + ]); + + const channels = await getGuildChannels(harness, account.token, guild.id); + const siblings = channels + .filter((channel) => channel.parent_id === category.id) + .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); + expect(siblings.map((channel) => channel.id)).toEqual([textOne.id, textThree.id, textTwo.id]); + }); + + test('should reject preceding sibling from a different parent', async () => { + const account = await createTestAccount(harness); + const guild = await createGuild(harness, account.token, 'Test Guild'); + + const categoryOne = await createChannel(harness, account.token, guild.id, 'Cat 1', ChannelTypes.GUILD_CATEGORY); + const categoryTwo = await createChannel(harness, account.token, guild.id, 'Cat 2', ChannelTypes.GUILD_CATEGORY); + const textOne = await createChannel(harness, account.token, guild.id, 'one', ChannelTypes.GUILD_TEXT); + const textTwo = await createChannel(harness, account.token, guild.id, 'two', ChannelTypes.GUILD_TEXT); + + await updateChannelPositions(harness, account.token, guild.id, [ + {id: textOne.id, parent_id: categoryOne.id}, + {id: textTwo.id, parent_id: categoryTwo.id}, + ]); + + const response = await createBuilder<{ + code: string; + errors: Array<{path: string; code: string; message: string}>; + }>(harness, account.token) + .patch(`/guilds/${guild.id}/channels`) + .body([ + { + id: textOne.id, + parent_id: categoryOne.id, + preceding_sibling_id: textTwo.id, + }, + ]) + .expect(HTTP_STATUS.BAD_REQUEST) + .execute(); + + expect(response.code).toBe('INVALID_FORM_BODY'); + expect(response.errors[0]?.path).toBe('preceding_sibling_id'); + expect(response.errors[0]?.code).toBe(ValidationErrorCodes.PRECEDING_CHANNEL_MUST_SHARE_PARENT); + }); + + test('should reject positioning a category relative to its own child', async () => { + const account = await createTestAccount(harness); + const guild = await createGuild(harness, account.token, 'Test Guild'); + + const category = await createChannel(harness, account.token, guild.id, 'Cat', ChannelTypes.GUILD_CATEGORY); + const child = await createChannel(harness, account.token, guild.id, 'child', ChannelTypes.GUILD_TEXT); + + await updateChannelPositions(harness, account.token, guild.id, [{id: child.id, parent_id: category.id}]); + + const response = await createBuilder<{ + code: string; + errors: Array<{path: string; code: string; message: string}>; + }>(harness, account.token) + .patch(`/guilds/${guild.id}/channels`) + .body([ + { + id: category.id, + parent_id: null, + preceding_sibling_id: child.id, + }, + ]) + .expect(HTTP_STATUS.BAD_REQUEST) + .execute(); + + expect(response.code).toBe('INVALID_FORM_BODY'); + expect(response.errors[0]?.path).toBe('preceding_sibling_id'); + expect(response.errors[0]?.code).toBe(ValidationErrorCodes.CANNOT_POSITION_CHANNEL_RELATIVE_TO_ITSELF); + }); + + test('should reject text channels being positioned below voice channels via preceding_sibling_id', async () => { + const account = await createTestAccount(harness); + const guild = await createGuild(harness, account.token, 'Test Guild'); + + const category = await createChannel(harness, account.token, guild.id, 'Mixed', ChannelTypes.GUILD_CATEGORY); + const text = await createChannel(harness, account.token, guild.id, 'text', ChannelTypes.GUILD_TEXT); + const voice = await createChannel(harness, account.token, guild.id, 'voice', ChannelTypes.GUILD_VOICE); + + await updateChannelPositions(harness, account.token, guild.id, [ + {id: text.id, parent_id: category.id}, + {id: voice.id, parent_id: category.id}, + ]); + + const response = await createBuilder<{ + code: string; + errors: Array<{path: string; code: string; message: string}>; + }>(harness, account.token) + .patch(`/guilds/${guild.id}/channels`) + .body([ + { + id: text.id, + parent_id: category.id, + preceding_sibling_id: voice.id, + }, + ]) + .expect(HTTP_STATUS.BAD_REQUEST) + .execute(); + + expect(response.code).toBe('INVALID_FORM_BODY'); + expect(response.errors[0]?.path).toBe('preceding_sibling_id'); + expect(response.errors[0]?.code).toBe(ValidationErrorCodes.VOICE_CHANNELS_CANNOT_BE_ABOVE_TEXT_CHANNELS); + }); }); diff --git a/packages/api/src/guild/tests/GuildTestUtils.tsx b/packages/api/src/guild/tests/GuildTestUtils.tsx index 43ea363e..f9d2208d 100644 --- a/packages/api/src/guild/tests/GuildTestUtils.tsx +++ b/packages/api/src/guild/tests/GuildTestUtils.tsx @@ -97,7 +97,13 @@ export async function updateChannelPositions( harness: ApiTestHarness, token: string, guildId: string, - positions: Array<{id: string; position?: number; lock_permissions?: boolean | null; parent_id?: string | null}>, + positions: Array<{ + id: string; + position?: number; + lock_permissions?: boolean | null; + parent_id?: string | null; + preceding_sibling_id?: string | null; + }>, ): Promise { await createBuilder(harness, token).patch(`/guilds/${guildId}/channels`).body(positions).expect(204).execute(); } diff --git a/packages/schema/src/domains/channel/ChannelRequestSchemas.tsx b/packages/schema/src/domains/channel/ChannelRequestSchemas.tsx index 3b704770..93c7002d 100644 --- a/packages/schema/src/domains/channel/ChannelRequestSchemas.tsx +++ b/packages/schema/src/domains/channel/ChannelRequestSchemas.tsx @@ -234,6 +234,9 @@ export const ChannelPositionUpdateRequest = z.array( id: SnowflakeType.describe('The ID of the channel to reposition'), position: z.number().int().nonnegative().optional().describe('New position for the channel'), parent_id: SnowflakeType.nullish().describe('New parent category ID'), + preceding_sibling_id: SnowflakeType.nullish().describe( + 'ID of the sibling channel that should directly precede this channel after reordering', + ), lock_permissions: z.boolean().optional().describe('Whether to sync permissions with the new parent'), }), ); diff --git a/packages/schema/src/domains/channel/GuildChannelOrdering.tsx b/packages/schema/src/domains/channel/GuildChannelOrdering.tsx index da31c1bd..22041dff 100644 --- a/packages/schema/src/domains/channel/GuildChannelOrdering.tsx +++ b/packages/schema/src/domains/channel/GuildChannelOrdering.tsx @@ -67,7 +67,52 @@ export function compareChannelOrdering( export function sortChannelsForOrdering>( channels: ReadonlyArray, ): Array { - return [...channels].sort(compareChannelOrdering); + const channelById = new Map(channels.map((channel) => [channel.id, channel])); + const childrenByParent = new Map>(); + const rootChannels: Array = []; + + for (const channel of channels) { + const parentId = channel.parentId ?? null; + if (parentId === null || !channelById.has(parentId)) { + rootChannels.push(channel); + continue; + } + + const existingChildren = childrenByParent.get(parentId); + if (existingChildren) { + existingChildren.push(channel); + } else { + childrenByParent.set(parentId, [channel]); + } + } + + const orderedChannels: Array = []; + const seen = new Set(); + + const sortedRoots = [...rootChannels].sort(compareChannelOrdering); + for (const root of sortedRoots) { + orderedChannels.push(root); + seen.add(root.id); + + if (root.type !== ChannelTypes.GUILD_CATEGORY) { + continue; + } + + const children = childrenByParent.get(root.id); + if (!children) { + continue; + } + + for (const child of [...children].sort(compareChannelOrdering)) { + orderedChannels.push(child); + seen.add(child.id); + } + } + + const remaining = channels.filter((channel) => !seen.has(channel.id)).sort(compareChannelOrdering); + orderedChannels.push(...remaining); + + return orderedChannels; } export function computeChannelMoveBlockIds>({