From dbd964d40c73b504853b14652434969344ae2062 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 12 Mar 2026 03:38:22 +0100 Subject: [PATCH] feat: space sidebar drag-and-drop reordering with folder system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add user_space_layout table and PUT /api/users/@me/space-layout endpoint for persisting per-user sidebar ordering. Spaces can be freely reordered via drag-and-drop, folders created by dragging one space onto another, and folders auto-dissolve when they have fewer than 2 members. Includes folder context menu (rename, color, ungroup), collapsed folder mini-grid icons, multi-tab sync via WebSocket, and localStorage collapse state. Removes the rigid native/federated split — federated spaces now intermix freely while keeping their globe badge. --- CLAUDE.md | 14 +- packages/server/src/db/migrate.ts | 18 + packages/server/src/db/schema.ts | 7 + packages/server/src/routes/explore.ts | 15 + packages/server/src/routes/spaces.ts | 10 +- packages/server/src/routes/users.ts | 162 +++- packages/server/src/ws/handler.ts | 10 +- packages/shared/src/types.ts | 9 +- packages/web/src/api/client.ts | 11 + .../src/components/layout/SpaceSidebar.tsx | 803 ++++++++++++++++-- packages/web/src/hooks/useWebSocket.ts | 10 +- packages/web/src/stores/spaceStore.ts | 30 +- 12 files changed, 1006 insertions(+), 93 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7e033c12..d9f42781 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -473,9 +473,17 @@ CREATE TABLE space_folders ( CREATE TABLE space_folder_members ( folder_id TEXT NOT NULL REFERENCES space_folders(id) ON DELETE CASCADE, space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + position INTEGER DEFAULT 0, -- ordering within folder PRIMARY KEY (folder_id, space_id) ); +-- User Space Layout (per-user sidebar ordering) +CREATE TABLE user_space_layout ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + layout TEXT NOT NULL DEFAULT '[]', -- JSON array of {t:'s',id} | {t:'f',id} items + updated_at INTEGER NOT NULL +); + -- Instance Settings (singleton row, id=1) CREATE TABLE instance_settings ( id INTEGER PRIMARY KEY DEFAULT 1, @@ -544,6 +552,7 @@ POST /api/users/@me/verify-password (auth) { password } → POST /api/users/@me/change-password (auth) { currentPassword?, newPassword } → { token } DELETE /api/users/@me (auth) { password, username } → { success } GET /api/users/:id/mutuals (auth) ?homeUserId= → { mutualFriends[], mutualSpaces[] } +PUT /api/users/@me/space-layout (auth) { items, folders } → { items, folders } # Spaces POST /api/spaces (auth) { name, icon?, banner?, avatarColor?, visibility?, description? } → { space } @@ -693,7 +702,7 @@ All WebSocket messages are JSON over `/ws`. Client authenticates by sending `{ t ### Server → Client ``` -{ type: 'ready', user, spaces, dmChannels, folders, voiceStates, readStates, activeCalls } +{ type: 'ready', user, spaces, dmChannels, folders, spaceLayout, voiceStates, readStates, activeCalls } { type: 'pong' } # Server Messages @@ -751,6 +760,9 @@ All WebSocket messages are JSON over `/ws`. Client authenticates by sending `{ t { type: 'join_request_received', request } { type: 'join_request_accepted', request, space? } { type: 'join_request_declined', request } + +# Space Layout +{ type: 'space_layout_updated', layout: SpaceLayoutItem[], folders: SpaceFolder[] } ``` ## PERMISSION SYSTEM diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 7cc7cf73..3d1aeb02 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -233,6 +233,24 @@ export function runMigrations(db: Database.Database): void { // ─── Convert video channels to voice (video type removed) ───────────────── migrateVideoChannels(db); + // ─── Ensure user_space_layout table exists ──────────────────────────────── + db.exec(` + CREATE TABLE IF NOT EXISTS user_space_layout ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + layout TEXT NOT NULL DEFAULT '[]', + updated_at INTEGER NOT NULL + ); + `); + + // ─── Add position column to space_folder_members ────────────────────────── + { + const sfmColumns = db.pragma('table_info(space_folder_members)') as { name: string }[]; + if (!sfmColumns.some(c => c.name === 'position')) { + db.exec('ALTER TABLE space_folder_members ADD COLUMN position INTEGER DEFAULT 0'); + console.log('Migrating: Added position column to space_folder_members'); + } + } + console.log('Migrations complete.'); } diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 47a9e7c6..7d25cf67 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -192,10 +192,17 @@ export const spaceFolders = sqliteTable('space_folders', { export const spaceFolderMembers = sqliteTable('space_folder_members', { folderId: text('folder_id').notNull().references(() => spaceFolders.id, { onDelete: 'cascade' }), spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }), + position: integer('position').default(0), }, (table) => ({ pk: primaryKey({ columns: [table.folderId, table.spaceId] }), })); +export const userSpaceLayout = sqliteTable('user_space_layout', { + userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }), + layout: text('layout').notNull().default('[]'), + updatedAt: integer('updated_at').notNull(), +}); + export const instanceSettings = sqliteTable('instance_settings', { id: integer('id').primaryKey().default(1), instanceName: text('instance_name').default('Backspace'), diff --git a/packages/server/src/routes/explore.ts b/packages/server/src/routes/explore.ts index 8dfebc96..a031b123 100644 --- a/packages/server/src/routes/explore.ts +++ b/packages/server/src/routes/explore.ts @@ -68,6 +68,19 @@ function buildFullSpace(spaceId: string, forUserId: string): SpaceWithChannelsAn .where(eq(schema.channels.spaceId, spaceId)) .all(); + const categories = db.select() + .from(schema.channelCategories) + .where(eq(schema.channelCategories.spaceId, spaceId)) + .orderBy(schema.channelCategories.position) + .all() + .map(c => ({ + id: c.id, + spaceId: c.spaceId, + name: c.name, + position: c.position ?? 0, + createdAt: c.createdAt, + })); + const roles = db.select() .from(schema.roles) .where(eq(schema.roles.spaceId, spaceId)) @@ -135,6 +148,7 @@ function buildFullSpace(spaceId: string, forUserId: string): SpaceWithChannelsAn type: ch.type as Channel['type'], topic: ch.topic, position: ch.position ?? 0, + categoryId: ch.categoryId ?? null, createdAt: ch.createdAt, myPermissions: permissionsToString(chPerms), }); @@ -153,6 +167,7 @@ function buildFullSpace(spaceId: string, forUserId: string): SpaceWithChannelsAn description: space.description ?? null, createdAt: space.createdAt, channels: visibleChannels, + categories, members, roles: roles.map(r => ({ id: r.id, diff --git a/packages/server/src/routes/spaces.ts b/packages/server/src/routes/spaces.ts index 405dfe82..fa3d4d33 100644 --- a/packages/server/src/routes/spaces.ts +++ b/packages/server/src/routes/spaces.ts @@ -1089,15 +1089,15 @@ export async function spaceRoutes(app: FastifyInstance): Promise { const userIds = [...new Set(banRows.map(b => b.userId))]; const bannedByIds = [...new Set(banRows.map(b => b.bannedBy))]; - const allUserIds = [...new Set([...userIds, ...bannedByIds])]; - const users = db.select().from(schema.users) - .where(inArray(schema.users.id, allUserIds)) - .all(); + const allUserIds = [...new Set([...userIds, ...bannedByIds].filter((id): id is string => id !== null))]; + const users = allUserIds.length > 0 + ? db.select().from(schema.users).where(inArray(schema.users.id, allUserIds)).all() + : []; const userMap = new Map(users.map(u => [u.id, u])); const bans = banRows.map(b => { const user = userMap.get(b.userId); - const moderator = userMap.get(b.bannedBy); + const moderator = b.bannedBy ? userMap.get(b.bannedBy) : undefined; return { spaceId: b.spaceId, userId: b.userId, diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index 743b11d3..c55c6806 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -4,10 +4,11 @@ import crypto from 'crypto'; import { getDb, schema } from '../db/index.js'; import { authenticate, verifyPassword, hashPassword, signJwt } from '../utils/auth.js'; import { connectionManager } from '../ws/handler.js'; -import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance } from '@backspace/shared'; +import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance, SpaceLayoutItem, SpaceFolder } from '@backspace/shared'; import { AVATAR_COLORS } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; import { deleteUploadFile, deleteAttachmentFiles } from '../utils/fileCleanup.js'; +import { generateSnowflake } from '../utils/snowflake.js'; export async function userRoutes(app: FastifyInstance): Promise { app.get('/api/users/@me', { preHandler: authenticate }, async (request, reply) => { @@ -442,6 +443,165 @@ export async function userRoutes(app: FastifyInstance): Promise { return reply.code(200).send(sanitized); }); + // PUT /api/users/@me/space-layout — save sidebar layout (reorder, folders) + app.put<{ Body: { items: SpaceLayoutItem[]; folders: Record } }>( + '/api/users/@me/space-layout', { preHandler: authenticate }, async (request, reply) => { + const { items, folders } = request.body; + const userId = request.userId; + + if (!Array.isArray(items)) { + return reply.code(400).send({ error: 'items must be an array', statusCode: 400 }); + } + if (!folders || typeof folders !== 'object') { + return reply.code(400).send({ error: 'folders must be an object', statusCode: 400 }); + } + + // Validate items + for (const item of items) { + if (!item || (item.t !== 's' && item.t !== 'f') || typeof item.id !== 'string') { + return reply.code(400).send({ error: 'Each item must have t ("s" or "f") and id string', statusCode: 400 }); + } + } + + // Validate folders + for (const [key, folder] of Object.entries(folders)) { + if (!Array.isArray(folder.spaceIds)) { + return reply.code(400).send({ error: `Folder "${key}" must have spaceIds array`, statusCode: 400 }); + } + } + + const db = getDb(); + + // Map new:* folder keys to server-generated IDs + const newIdMap = new Map(); + for (const key of Object.keys(folders)) { + if (key.startsWith('new:')) { + newIdMap.set(key, generateSnowflake()); + } + } + + db.transaction((tx) => { + // Get existing folder IDs for this user + const existingFolders = tx.select({ id: schema.spaceFolders.id }) + .from(schema.spaceFolders) + .where(eq(schema.spaceFolders.userId, userId)) + .all(); + const existingFolderIds = new Set(existingFolders.map(f => f.id)); + + // Determine which folders to keep (ones in the request, with resolved IDs) + const keepFolderIds = new Set(); + for (const [key, folder] of Object.entries(folders)) { + const resolvedId = newIdMap.get(key) ?? key; + keepFolderIds.add(resolvedId); + + if (key.startsWith('new:')) { + // Create new folder + tx.insert(schema.spaceFolders).values({ + id: resolvedId, + userId, + name: folder.name, + color: folder.color, + position: 0, + createdAt: Date.now(), + }).run(); + } else if (existingFolderIds.has(key)) { + // Update existing folder + tx.update(schema.spaceFolders) + .set({ name: folder.name, color: folder.color }) + .where(and(eq(schema.spaceFolders.id, key), eq(schema.spaceFolders.userId, userId))) + .run(); + } + + // Clear and re-insert folder members with position + tx.delete(schema.spaceFolderMembers) + .where(eq(schema.spaceFolderMembers.folderId, resolvedId)) + .run(); + + for (let i = 0; i < folder.spaceIds.length; i++) { + const spaceId = folder.spaceIds[i]; + if (!spaceId) continue; + tx.insert(schema.spaceFolderMembers).values({ + folderId: resolvedId, + spaceId, + position: i, + }).run(); + } + } + + // Delete folders that are no longer in the request + for (const existingId of existingFolderIds) { + if (!keepFolderIds.has(existingId)) { + tx.delete(schema.spaceFolderMembers) + .where(eq(schema.spaceFolderMembers.folderId, existingId)) + .run(); + tx.delete(schema.spaceFolders) + .where(and(eq(schema.spaceFolders.id, existingId), eq(schema.spaceFolders.userId, userId))) + .run(); + } + } + + // Replace new:* keys in items array with server-generated IDs + const finalItems: SpaceLayoutItem[] = items.map(item => { + if (item.t === 'f' && newIdMap.has(item.id)) { + return { t: 'f' as const, id: newIdMap.get(item.id)! }; + } + return item; + }); + + // Upsert user_space_layout + const existing = tx.select().from(schema.userSpaceLayout) + .where(eq(schema.userSpaceLayout.userId, userId)).get(); + if (existing) { + tx.update(schema.userSpaceLayout) + .set({ layout: JSON.stringify(finalItems), updatedAt: Date.now() }) + .where(eq(schema.userSpaceLayout.userId, userId)) + .run(); + } else { + tx.insert(schema.userSpaceLayout).values({ + userId, + layout: JSON.stringify(finalItems), + updatedAt: Date.now(), + }).run(); + } + }); + + // Build response: fetch final state + const finalLayout = db.select().from(schema.userSpaceLayout) + .where(eq(schema.userSpaceLayout.userId, userId)).get(); + const finalItems: SpaceLayoutItem[] = finalLayout ? JSON.parse(finalLayout.layout) : []; + + const finalFolderRows = db.select().from(schema.spaceFolders) + .where(eq(schema.spaceFolders.userId, userId)) + .orderBy(schema.spaceFolders.position) + .all(); + + const responseFolders: SpaceFolder[] = []; + for (const folder of finalFolderRows) { + const memberRows = db.select() + .from(schema.spaceFolderMembers) + .where(eq(schema.spaceFolderMembers.folderId, folder.id)) + .orderBy(schema.spaceFolderMembers.position) + .all(); + responseFolders.push({ + id: folder.id, + userId: folder.userId, + name: folder.name, + color: folder.color, + position: folder.position ?? 0, + spaceIds: memberRows.map(m => m.spaceId), + }); + } + + // Broadcast to user's other connections (multi-tab sync) + connectionManager.sendToUser(userId, { + type: 'space_layout_updated', + layout: finalItems, + folders: responseFolders, + }); + + return reply.code(200).send({ items: finalItems, folders: responseFolders }); + }); + app.get<{ Params: { id: string } }>('/api/users/:id', { preHandler: authenticate }, async (request, reply) => { const { id } = request.params; const db = getDb(); diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index e84ddd16..86652d86 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -15,6 +15,7 @@ import type { DmChannel, ServerEvent, SpaceFolder, + SpaceLayoutItem, ReadState, ActiveCallInfo, } from '@backspace/shared'; @@ -652,6 +653,7 @@ function buildReadyPayload(userId: string): { spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders: SpaceFolder[]; + spaceLayout: SpaceLayoutItem[] | null; voiceStates: Record; voiceUserStates: Record; spaceVoiceStates: Record; @@ -926,6 +928,7 @@ function buildReadyPayload(userId: string): { const folderSpaceIds = db.select() .from(schema.spaceFolderMembers) .where(eq(schema.spaceFolderMembers.folderId, folder.id)) + .orderBy(schema.spaceFolderMembers.position) .all() .map(m => m.spaceId); @@ -939,6 +942,11 @@ function buildReadyPayload(userId: string): { }); } + // Get user space layout + const layoutRow = db.select().from(schema.userSpaceLayout) + .where(eq(schema.userSpaceLayout.userId, userId)).get(); + const spaceLayout: SpaceLayoutItem[] | null = layoutRow ? JSON.parse(layoutRow.layout) : null; + // Build voice states — tell the client who is currently in voice channels // across all their spaces const voiceStates: Record = {}; @@ -1029,7 +1037,7 @@ function buildReadyPayload(userId: string): { lastReadMessageId: rs.lastReadMessageId, })); - return { user, spaces, dmChannels, folders, voiceStates, voiceUserStates, spaceVoiceStates, readStates, activeCalls }; + return { user, spaces, dmChannels, folders, spaceLayout, voiceStates, voiceUserStates, spaceVoiceStates, readStates, activeCalls }; } export async function registerWebSocket(app: FastifyInstance): Promise { diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 661b38ec..54df6c6d 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -122,6 +122,12 @@ export interface SpaceFolder { spaceIds: string[]; } +// ─── Space Layout Types ──────────────────────────────────────────────────── + +export type SpaceLayoutItem = + | { t: 's'; id: string } + | { t: 'f'; id: string }; + // ─── Channel Types ────────────────────────────────────────────────────────── export type ChannelType = 'text' | 'voice'; @@ -265,7 +271,7 @@ export type ClientEvent = // Server → Client Events export type ServerEvent = - | { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; voiceStates?: Record; voiceUserStates?: Record; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record } + | { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; voiceStates?: Record; voiceUserStates?: Record; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record } | { type: 'message_created'; message: MessageWithUser } | { type: 'message_updated'; message: MessageWithUser } | { type: 'message_deleted'; messageId: string; channelId: string } @@ -311,6 +317,7 @@ export type ServerEvent = | { type: 'category_updated'; category: ChannelCategory; spaceId: string } | { type: 'category_deleted'; categoryId: string; spaceId: string } | { type: 'channel_layout_updated'; spaceId: string; channels: Channel[]; categories: ChannelCategory[] } + | { type: 'space_layout_updated'; layout: SpaceLayoutItem[]; folders: SpaceFolder[] } | { type: 'pong' } | { type: 'error'; message: string }; diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 4df5845d..63981fa9 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -36,6 +36,8 @@ import type { ExploreSpace, JoinRequest, Role, + SpaceLayoutItem, + SpaceFolder, } from '@backspace/shared'; export class RateLimitError extends Error { @@ -64,6 +66,10 @@ export class BackspaceApiClient { getMutuals: (id: string, homeUserId?: string) => Promise<{ mutualFriends: User[]; mutualSpaces: { id: string; name: string; icon: string | null; avatarColor: string | null }[] }>; }; + readonly spaceLayout: { + update: (data: { items: SpaceLayoutItem[]; folders: Record }) => Promise<{ items: SpaceLayoutItem[]; folders: SpaceFolder[] }>; + }; + readonly spaces: { list: () => Promise; get: (id: string) => Promise; @@ -270,6 +276,11 @@ export class BackspaceApiClient { }, }; + this.spaceLayout = { + update: (data) => + request<{ items: SpaceLayoutItem[]; folders: SpaceFolder[] }>('PUT', '/users/@me/space-layout', data), + }; + this.spaces = { list: () => request('GET', '/spaces'), get: (id: string) => request('GET', `/spaces/${id}`), diff --git a/packages/web/src/components/layout/SpaceSidebar.tsx b/packages/web/src/components/layout/SpaceSidebar.tsx index b6a40d19..31a0ccee 100644 --- a/packages/web/src/components/layout/SpaceSidebar.tsx +++ b/packages/web/src/components/layout/SpaceSidebar.tsx @@ -2,15 +2,53 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react' import ReactDOM from 'react-dom'; import { useNavigate, useLocation } from 'react-router-dom'; import { useSpaceStore, getMyUserIdForOrigin } from '../../stores/spaceStore'; +import type { TaggedSpace } from '../../stores/spaceStore'; import { useChatStore } from '../../stores/chatStore'; import { useUIStore } from '../../stores/uiStore'; import { useInstanceStore } from '../../stores/instanceStore'; import { useAuthStore } from '../../stores/authStore'; import { Tooltip } from '../ui/Tooltip'; import { ConfirmDialog } from '../ui/ConfirmDialog'; +import type { SpaceLayoutItem, SpaceFolder } from '@backspace/shared'; import { getSpaceGradient, HOME_GRADIENT } from '../../utils/gradients'; +// ─── Resolved layout types ───────────────────────────────────────────────── + +type ResolvedItem = + | { type: 'space'; space: TaggedSpace } + | { type: 'folder'; folder: SpaceFolder; spaces: TaggedSpace[]; collapsed: boolean }; + +// ─── localStorage keys ──────────────────────────────────────────────────── + +const COLLAPSED_KEY = 'backspace:collapsed-space-folders'; + +function getCollapsedFolders(): Set { + try { + const raw = localStorage.getItem(COLLAPSED_KEY); + if (raw) return new Set(JSON.parse(raw)); + } catch { /* ignore */ } + return new Set(); +} + +function setCollapsedFolders(ids: Set): void { + localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...ids])); +} + +// ─── Folder color presets ───────────────────────────────────────────────── + +const FOLDER_COLORS = [ + { name: 'mint', value: '#86efac' }, + { name: 'peach', value: '#fbbf93' }, + { name: 'lavender', value: '#c4b5fd' }, + { name: 'sky', value: '#7dd3fc' }, + { name: 'amber', value: '#fcd34d' }, + { name: 'rose', value: '#fda4af' }, + { name: 'coral', value: '#fb7185' }, +]; + +// ─── SidebarItem ───────────────────────────────────────────────────────── + interface SidebarItemProps { id: string; name: string; @@ -26,9 +64,16 @@ interface SidebarItemProps { federationBadge?: boolean; federationDisconnected?: boolean; tooltipText?: string; + draggable?: boolean; + onDragStart?: (e: React.DragEvent) => void; + onDragOver?: (e: React.DragEvent) => void; + onDragEnd?: () => void; + onDrop?: (e: React.DragEvent) => void; + isDragging?: boolean; + dropIndicator?: 'before' | 'after' | 'merge' | null; } -function SidebarItem({ id, name, icon, avatarColor, active, onClick, onContextMenu, type = 'space', actionType, hasUnread, dimmed, federationBadge, federationDisconnected, tooltipText }: SidebarItemProps) { +function SidebarItem({ id, name, icon, avatarColor, active, onClick, onContextMenu, type = 'space', actionType, hasUnread, dimmed, federationBadge, federationDisconnected, tooltipText, draggable, onDragStart, onDragOver, onDragEnd, onDrop, isDragging, dropIndicator }: SidebarItemProps) { const [isHovered, setIsHovered] = useState(false); const firstLetter = name.charAt(0).toUpperCase(); @@ -106,7 +151,7 @@ function SidebarItem({ id, name, icon, avatarColor, active, onClick, onContextMe ); const innerContent = ( -
+
{buttonContent} {federationBadge && (
@@ -124,11 +169,24 @@ function SidebarItem({ id, name, icon, avatarColor, active, onClick, onContextMe return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} onContextMenu={onContextMenu} + draggable={draggable} + onDragStart={onDragStart} + onDragOver={onDragOver} + onDragEnd={onDragEnd} + onDrop={onDrop} > + {/* Drop indicator lines */} + {dropIndicator === 'before' && ( +
+ )} + {dropIndicator === 'after' && ( +
+ )} + {/* Pill Indicator */} {(type === 'space' || type === 'dm') && (
@@ -149,23 +207,142 @@ function SidebarItem({ id, name, icon, avatarColor, active, onClick, onContextMe ); } -function InstanceDivider({ label, disconnected }: { label: string; disconnected: boolean }) { +// ─── Mini space icon for collapsed folder ────────────────────────────────── + +function MiniSpaceIcon({ space }: { space: TaggedSpace }) { + const icon = space.icon; + if (icon) { + return ( + + ); + } + const grad = getSpaceGradient(space.id, space.name, space.avatarColor); return ( - -
-
-
- - - -
-
- +
+ {space.name.charAt(0).toUpperCase()} +
); } +// ─── Collapsed folder icon (2×2 grid) ───────────────────────────────────── + +function CollapsedFolderIcon({ spaces, color }: { spaces: TaggedSpace[]; color: string | null }) { + const display = spaces.slice(0, 4); + const remaining = spaces.length - 4; + return ( +
+ {color &&
} +
+ {display.map((s) => ( +
+ +
+ ))} + {/* Fill empty cells */} + {display.length < 4 && Array.from({ length: 4 - display.length }).map((_, i) => ( +
+ ))} +
+ {remaining > 0 && ( +
+ +{remaining} +
+ )} +
+ ); +} + +// ─── Folder context menu ────────────────────────────────────────────────── + +function FolderContextMenu({ folder, x, y, onClose, onRename, onColorChange, onUngroup }: { + folder: SpaceFolder; + x: number; + y: number; + onClose: () => void; + onRename: () => void; + onColorChange: (color: string | null) => void; + onUngroup: () => void; +}) { + const menuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose(); + }; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [onClose]); + + const menuWidth = 200; + const menuHeight = 140; + const clampedX = Math.min(x, window.innerWidth - menuWidth - 8); + const clampedY = Math.min(y, window.innerHeight - menuHeight - 8); + + return ReactDOM.createPortal( +
+ +
+

Folder Color

+
+
+
+
+ +
, + document.body, + ); +} + +// ─── SpaceContextMenu ───────────────────────────────────────────────────── + function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: number; y: number; onClose: () => void }) { const menuRef = useRef(null); const space = useSpaceStore((s) => s.spaces.find(sp => sp.id === spaceId)); @@ -293,6 +470,8 @@ function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: numb ); } +// ─── TransferOwnershipModal ─────────────────────────────────────────────── + function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string; onClose: () => void }) { const modalRef = useRef(null); const space = useSpaceStore((s) => s.spaces.find(sp => sp.id === spaceId)); @@ -459,12 +638,17 @@ function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string; onClose ); } +// ─── SpaceSidebar (main component) ──────────────────────────────────────── + export function SpaceSidebar() { const spaces = useSpaceStore((s) => s.spaces); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace); const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap); const dmChannels = useSpaceStore((s) => s.dmChannels); + const folders = useSpaceStore((s) => s.folders); + const spaceLayout = useSpaceStore((s) => s.spaceLayout); + const updateSpaceLayout = useSpaceStore((s) => s.updateSpaceLayout); const showDms = useUIStore((s) => s.showDms); const setShowDms = useUIStore((s) => s.setShowDms); const openModal = useUIStore((s) => s.openModal); @@ -476,8 +660,31 @@ export function SpaceSidebar() { const navigate = useNavigate(); const location = useLocation(); - // Single context menu state + // Collapsed folders state + const [collapsedFolderIds, setCollapsedFolderIdsState] = useState(getCollapsedFolders); + const setCollapsedFolderIds = useCallback((ids: Set) => { + setCollapsedFolderIdsState(ids); + setCollapsedFolders(ids); + }, []); + + const toggleFolderCollapse = useCallback((folderId: string) => { + setCollapsedFolderIdsState(prev => { + const next = new Set(prev); + if (next.has(folderId)) next.delete(folderId); + else next.add(folderId); + setCollapsedFolders(next); + return next; + }); + }, []); + + // Context menus const [contextMenu, setContextMenu] = useState<{ spaceId: string; x: number; y: number } | null>(null); + const [folderContextMenu, setFolderContextMenu] = useState<{ folder: SpaceFolder; x: number; y: number } | null>(null); + const [renamingFolderId, setRenamingFolderId] = useState(null); + + // DnD state + const [dragState, setDragState] = useState<{ dragId: string; dragType: 'space' | 'folder'; sourceFolderId?: string } | null>(null); + const [dropIndicator, setDropIndicator] = useState<{ targetId: string; position: 'before' | 'after' | 'merge' } | null>(null); const handleSpaceContextMenu = useCallback((spaceId: string, e: React.MouseEvent) => { e.preventDefault(); @@ -486,20 +693,6 @@ export function SpaceSidebar() { const closeContextMenu = useCallback(() => setContextMenu(null), []); - // Group spaces by origin - const groupedSpaces = useMemo(() => { - const home = spaces.filter(s => !(s as any)._instanceOrigin); - const remoteMap = new Map(); - for (const s of spaces) { - const origin = (s as any)._instanceOrigin; - if (!origin) continue; - const list = remoteMap.get(origin) || []; - list.push(s); - remoteMap.set(origin, list); - } - return { home, remoteGroups: Array.from(remoteMap.entries()) }; - }, [spaces]); - // Set of disconnected origins const disconnectedOrigins = useMemo(() => { const set = new Set(); @@ -511,6 +704,64 @@ export function SpaceSidebar() { return set; }, [instances]); + // Build space lookup map + const spaceMap = useMemo(() => { + const map = new Map(); + for (const s of spaces) map.set(s.id, s); + return map; + }, [spaces]); + + // Build folder lookup map + const folderMap = useMemo(() => { + const map = new Map(); + for (const f of folders) map.set(f.id, f); + return map; + }, [folders]); + + // Reconciled layout: merge spaceLayout with actual spaces and folders + const resolvedLayout = useMemo((): ResolvedItem[] => { + const memberSpaceIds = new Set(spaces.map(s => s.id)); + const result: ResolvedItem[] = []; + const accountedSpaceIds = new Set(); + + if (spaceLayout && spaceLayout.length > 0) { + for (const item of spaceLayout) { + if (item.t === 's') { + const space = spaceMap.get(item.id); + if (space) { + result.push({ type: 'space', space }); + accountedSpaceIds.add(item.id); + } + } else if (item.t === 'f') { + const folder = folderMap.get(item.id); + if (folder) { + const folderSpaces = folder.spaceIds + .map(sid => spaceMap.get(sid)) + .filter((s): s is TaggedSpace => !!s); + if (folderSpaces.length > 0) { + result.push({ + type: 'folder', + folder, + spaces: folderSpaces, + collapsed: collapsedFolderIds.has(folder.id), + }); + for (const s of folderSpaces) accountedSpaceIds.add(s.id); + } + } + } + } + } + + // Append any spaces not in the layout (newly joined, etc.) + for (const space of spaces) { + if (!accountedSpaceIds.has(space.id)) { + result.push({ type: 'space', space }); + } + } + + return result; + }, [spaceLayout, spaces, spaceMap, folderMap, collapsedFolderIds]); + // Compute which spaces have unread channels const unreadSpaceIds = useMemo(() => { const ids = new Set(); @@ -529,9 +780,9 @@ export function SpaceSidebar() { return false; }, [unreadChannels, dmChannels]); - const handleSpaceClick = (spaceId: string) => { - const space = spaces.find(s => s.id === spaceId); - const origin = (space as any)?._instanceOrigin; + const handleSpaceClick = useCallback((spaceId: string) => { + const space = spaceMap.get(spaceId); + const origin = space?._instanceOrigin; if (origin && disconnectedOrigins.has(origin)) { const inst = instances.find(i => i.origin === origin); addToast(`Reconnecting to ${inst?.label || 'remote instance'}...`, 'warning', 4000); @@ -540,7 +791,7 @@ export function SpaceSidebar() { setCurrentSpace(spaceId); setShowDms(false); navigate(`/channels/${spaceId}`); - }; + }, [spaceMap, disconnectedOrigins, instances, addToast, setCurrentSpace, setShowDms, navigate]); const handleDmClick = () => { setShowDms(true); @@ -555,6 +806,258 @@ export function SpaceSidebar() { navigate('/explore'); }; + // ─── DnD handlers ────────────────────────────────────────────────────── + + const handleDragStart = useCallback((e: React.DragEvent, id: string, type: 'space' | 'folder', sourceFolderId?: string) => { + e.dataTransfer.setData('text/plain', id); + e.dataTransfer.effectAllowed = 'move'; + setDragState({ dragId: id, dragType: type, sourceFolderId }); + }, []); + + const handleDragOver = useCallback((e: React.DragEvent, targetId: string, targetType: 'space' | 'folder') => { + if (!dragState) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + + const rect = e.currentTarget.getBoundingClientRect(); + const relY = e.clientY - rect.top; + const height = rect.height; + + let position: 'before' | 'after' | 'merge'; + if (targetType === 'folder' || dragState.dragType === 'space') { + // Space items: top 25% = before, middle 50% = merge, bottom 25% = after + if (relY < height * 0.25) { + position = 'before'; + } else if (relY > height * 0.75) { + position = 'after'; + } else { + // Merge zone: only if dragging a space onto another space or folder + if (dragState.dragType === 'space' && dragState.dragId !== targetId) { + position = 'merge'; + } else { + position = relY < height * 0.5 ? 'before' : 'after'; + } + } + } else { + // Folder dragging: only before/after, no merge + position = relY < height * 0.5 ? 'before' : 'after'; + } + + setDropIndicator({ targetId, position }); + }, [dragState]); + + const handleDragEnd = useCallback(() => { + setDragState(null); + setDropIndicator(null); + }, []); + + // Build layout items and folder payload from resolvedLayout for persistence + const buildLayoutPayload = useCallback((resolved: ResolvedItem[]) => { + const items: SpaceLayoutItem[] = []; + const folderPayload: Record = {}; + + for (const item of resolved) { + if (item.type === 'space') { + items.push({ t: 's', id: item.space.id }); + } else { + items.push({ t: 'f', id: item.folder.id }); + folderPayload[item.folder.id] = { + name: item.folder.name, + color: item.folder.color, + spaceIds: item.spaces.map(s => s.id), + }; + } + } + + return { items, folderPayload }; + }, []); + + const persistLayout = useCallback((resolved: ResolvedItem[]) => { + const { items, folderPayload } = buildLayoutPayload(resolved); + updateSpaceLayout(items, folderPayload); + }, [buildLayoutPayload, updateSpaceLayout]); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + if (!dragState || !dropIndicator) { + handleDragEnd(); + return; + } + + const { dragId, dragType, sourceFolderId } = dragState; + const { targetId, position } = dropIndicator; + + // Don't drop on self + if (dragId === targetId && position !== 'merge') { + handleDragEnd(); + return; + } + + // Work with a mutable copy of the resolved layout + let newLayout = resolvedLayout.map(item => { + if (item.type === 'folder') { + return { ...item, spaces: [...item.spaces], folder: { ...item.folder } }; + } + return { ...item }; + }) as ResolvedItem[]; + + if (dragType === 'space') { + const dragSpace = spaceMap.get(dragId); + if (!dragSpace) { handleDragEnd(); return; } + + // Remove from source + if (sourceFolderId) { + // Remove from folder + const folderItem = newLayout.find(i => i.type === 'folder' && i.folder.id === sourceFolderId) as (ResolvedItem & { type: 'folder' }) | undefined; + if (folderItem) { + folderItem.spaces = folderItem.spaces.filter(s => s.id !== dragId); + folderItem.folder = { ...folderItem.folder, spaceIds: folderItem.spaces.map(s => s.id) }; + } + } else { + // Remove standalone + newLayout = newLayout.filter(item => !(item.type === 'space' && item.space.id === dragId)); + } + + if (position === 'merge') { + // Find the target + const targetIdx = newLayout.findIndex(item => + (item.type === 'space' && item.space.id === targetId) || + (item.type === 'folder' && item.folder.id === targetId) + ); + if (targetIdx === -1) { handleDragEnd(); return; } + + const targetItem = newLayout[targetIdx]; + if (!targetItem) { handleDragEnd(); return; } + + if (targetItem.type === 'space') { + // Create new folder with both spaces + const tempId = `new:${Date.now()}`; + const newFolder: ResolvedItem = { + type: 'folder', + folder: { + id: tempId, + userId: '', + name: null, + color: null, + position: 0, + spaceIds: [targetItem.space.id, dragSpace.id], + }, + spaces: [targetItem.space, dragSpace], + collapsed: false, + }; + newLayout[targetIdx] = newFolder; + } else if (targetItem.type === 'folder') { + // Add to existing folder + targetItem.spaces.push(dragSpace); + targetItem.folder = { ...targetItem.folder, spaceIds: targetItem.spaces.map(s => s.id) }; + } + } else { + // Reorder: insert before or after target + const targetIdx = newLayout.findIndex(item => + (item.type === 'space' && item.space.id === targetId) || + (item.type === 'folder' && item.folder.id === targetId) + ); + if (targetIdx === -1) { handleDragEnd(); return; } + + const insertIdx = position === 'before' ? targetIdx : targetIdx + 1; + const newItem: ResolvedItem = { type: 'space', space: dragSpace }; + newLayout.splice(insertIdx, 0, newItem); + } + + // Dissolve folders with < 2 members + newLayout = newLayout.flatMap(item => { + if (item.type === 'folder' && item.spaces.length < 2) { + if (item.spaces.length === 1 && item.spaces[0]) { + return [{ type: 'space' as const, space: item.spaces[0] }]; + } + return []; // 0 members, remove entirely + } + return [item]; + }); + + } else if (dragType === 'folder') { + // Remove the folder from its current position + const dragIdx = newLayout.findIndex(i => i.type === 'folder' && i.folder.id === dragId); + if (dragIdx === -1) { handleDragEnd(); return; } + const dragItem = newLayout.splice(dragIdx, 1)[0]; + if (!dragItem) { handleDragEnd(); return; } + + // Insert at target position + const targetIdx = newLayout.findIndex(item => + (item.type === 'space' && item.space.id === targetId) || + (item.type === 'folder' && item.folder.id === targetId) + ); + if (targetIdx === -1) { + newLayout.push(dragItem); + } else { + const insertIdx = position === 'before' ? targetIdx : targetIdx + 1; + newLayout.splice(insertIdx, 0, dragItem); + } + } + + persistLayout(newLayout); + handleDragEnd(); + }, [dragState, dropIndicator, resolvedLayout, spaceMap, handleDragEnd, persistLayout]); + + // ─── Folder actions ────────────────────────────────────────────────── + + const handleFolderRename = useCallback((folderId: string, name: string) => { + const newLayout = resolvedLayout.map(item => { + if (item.type === 'folder' && item.folder.id === folderId) { + return { ...item, folder: { ...item.folder, name: name.trim() || null } }; + } + return item; + }); + persistLayout(newLayout); + setRenamingFolderId(null); + }, [resolvedLayout, persistLayout]); + + const handleFolderColorChange = useCallback((folderId: string, color: string | null) => { + const newLayout = resolvedLayout.map(item => { + if (item.type === 'folder' && item.folder.id === folderId) { + return { ...item, folder: { ...item.folder, color } }; + } + return item; + }); + persistLayout(newLayout); + }, [resolvedLayout, persistLayout]); + + const handleUngroup = useCallback((folderId: string) => { + const newLayout = resolvedLayout.flatMap(item => { + if (item.type === 'folder' && item.folder.id === folderId) { + return item.spaces.map(s => ({ type: 'space' as const, space: s })); + } + return [item]; + }); + persistLayout(newLayout); + }, [resolvedLayout, persistLayout]); + + // ─── Helpers for rendering ────────────────────────────────────────── + + const getFederationInfo = useCallback((space: TaggedSpace) => { + const origin = space._instanceOrigin; + const isFederated = !!origin; + const isDimmed = isFederated && disconnectedOrigins.has(origin); + let tooltipText = space.name; + if (isFederated) { + try { + const hostLabel = new URL(origin).host; + tooltipText = `${space.name} \u00b7 ${hostLabel}`; + } catch { /* ignore */ } + } + return { isFederated, isDimmed, tooltipText }; + }, [disconnectedOrigins]); + + // Check if a folder has any unread spaces + const folderHasUnread = useCallback((folderSpaces: TaggedSpace[]) => { + return folderSpaces.some(s => unreadSpaceIds.has(s.id)); + }, [unreadSpaceIds]); + + // Check if folder has any active space + const folderHasActive = useCallback((folderSpaces: TaggedSpace[]) => { + return currentSpaceId ? folderSpaces.some(s => s.id === currentSpaceId) : false; + }, [currentSpaceId]); + return ( ); } diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 25d4f50c..0c873303 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -107,7 +107,7 @@ function handleEvent(origin: string, event: ServerEvent): void { } } - populateFromReady(origin, event.spaces, event.folders, event.dmChannels); + populateFromReady(origin, event.spaces, event.folders, event.dmChannels, event.spaceLayout); // Cache authoritative identity for this origin (federation-safe) if (!isHome) { @@ -697,6 +697,14 @@ function handleEvent(origin: string, event: ServerEvent): void { break; } + case 'space_layout_updated': { + if (!isHome) break; + const { setSpaceLayout } = useSpaceStore.getState(); + setSpaceLayout(event.layout); + useSpaceStore.setState({ folders: event.folders }); + break; + } + // ─── Join request events (home-only) ──────────────────────────────── case 'join_request_received': { diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index 4898e8d1..59362c1c 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import type { Space, Channel, ChannelCategory, MemberWithUser, SpaceWithChannelsAndMembers, Role, SpaceFolder, DmChannel, User, UpdateSpaceRequest, CreateSpaceRequest } from '@backspace/shared'; +import type { Space, Channel, ChannelCategory, MemberWithUser, SpaceWithChannelsAndMembers, Role, SpaceFolder, SpaceLayoutItem, DmChannel, User, UpdateSpaceRequest, CreateSpaceRequest } from '@backspace/shared'; import { api, BackspaceApiClient } from '../api/client'; import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls'; import { isSelf } from '../utils/identity'; @@ -30,6 +30,7 @@ interface SpaceState { members: MemberWithUser[]; roles: Role[]; folders: SpaceFolder[]; + spaceLayout: SpaceLayoutItem[] | null; dmChannels: DmChannel[]; channelToSpaceMap: Map; channelLastMessageIds: Map; @@ -72,7 +73,9 @@ interface SpaceState { updateUserEverywhere: (user: User) => void; addMember: (member: MemberWithUser) => void; removeMember: (userId: string) => void; - populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[]) => void; + setSpaceLayout: (layout: SpaceLayoutItem[] | null) => void; + updateSpaceLayout: (items: SpaceLayoutItem[], folders: Record) => Promise; + populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[], spaceLayout?: SpaceLayoutItem[] | null) => void; addSpaceFromReady: (origin: string, space: SpaceWithChannelsAndMembers) => void; removeInstanceSpaces: (origin: string) => void; transferOwnership: (spaceId: string, newOwnerId: string) => Promise; @@ -87,6 +90,7 @@ export const useSpaceStore = create((set, get) => ({ members: [], roles: [], folders: [], + spaceLayout: null, dmChannels: [], channelToSpaceMap: new Map(), channelLastMessageIds: new Map(), @@ -415,7 +419,22 @@ export const useSpaceStore = create((set, get) => ({ })); }, - populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[]) => { + setSpaceLayout: (layout) => set({ spaceLayout: layout }), + + updateSpaceLayout: async (items, folders) => { + // Optimistic: apply the layout immediately + set({ spaceLayout: items }); + + try { + const result = await api.spaceLayout.update({ items, folders }); + // Server may have resolved new:* IDs + set({ spaceLayout: result.items, folders: result.folders }); + } catch (err) { + console.error('Failed to save space layout:', err); + } + }, + + populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[], spaceLayout?: SpaceLayoutItem[] | null) => { const isHome = !origin; // Tag all incoming servers with their instance origin @@ -533,9 +552,12 @@ export const useSpaceStore = create((set, get) => ({ categoryOriginMap, }; - // Only set folders from home origin + // Only set folders and layout from home origin if (isHome) { update.folders = folders || []; + if (spaceLayout !== undefined) { + update.spaceLayout = spaceLayout ?? null; + } } set(update as any);