feat: channel categories with drag-and-drop reordering

Add channel categories (named groups) with full CRUD, collapsible headers
with unread indicators, and native HTML5 drag-and-drop for reordering
channels within/between categories and reordering categories themselves.

- Schema: channel_categories table, category_id column on channels
- Server: category CRUD endpoints, batch channel-layout reorder endpoint
- WebSocket: categories in ready payload, category_created/updated/deleted
  and channel_layout_updated events with per-user VIEW_CHANNEL filtering
- Frontend: dynamic category-based sidebar layout replacing hardcoded
  Text/Voice sections, collapse state persisted to localStorage,
  category selector in CreateChannel modal, federation-aware store
This commit is contained in:
Jannis Braun
2026-03-12 01:19:06 +01:00
parent b6d44f1568
commit c51d6b1a0e
11 changed files with 1045 additions and 121 deletions
+21
View File
@@ -11,6 +11,7 @@ import type {
SpaceWithChannelsAndMembers,
MemberWithUser,
Channel,
ChannelCategory,
DmChannel,
ServerEvent,
SpaceFolder,
@@ -694,6 +695,24 @@ function buildReadyPayload(userId: string): {
arr.push(ch);
}
// Batch: all categories for all spaces (1 query instead of N)
const allCategories = batchInArray(
spaceIds,
ids => db.select().from(schema.channelCategories).where(inArray(schema.channelCategories.spaceId, ids)).all(),
);
const categoriesBySpace = new Map<string, ChannelCategory[]>();
for (const cat of allCategories) {
let arr = categoriesBySpace.get(cat.spaceId);
if (!arr) { arr = []; categoriesBySpace.set(cat.spaceId, arr); }
arr.push({
id: cat.id,
spaceId: cat.spaceId,
name: cat.name,
position: cat.position ?? 0,
createdAt: cat.createdAt,
});
}
// Batch: last message ID per channel (1 query instead of N×C)
const allChannelIds = allChannels.map(ch => ch.id);
const lastMsgMap = new Map<string, string>();
@@ -782,6 +801,7 @@ function buildReadyPayload(userId: string): {
type: ch.type as Channel['type'],
topic: ch.topic,
position: ch.position ?? 0,
categoryId: ch.categoryId ?? null,
createdAt: ch.createdAt,
lastMessageId: lastMsgMap.get(ch.id) ?? null,
myPermissions: permissionsToString(chPerms),
@@ -801,6 +821,7 @@ function buildReadyPayload(userId: string): {
description: spaceRow.description ?? null,
createdAt: spaceRow.createdAt,
channels: visibleChannels,
categories: categoriesBySpace.get(spaceRow.id) ?? [],
members,
roles: roles.map(r => ({
id: r.id,