diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 03c1ebf4..7cc7cf73 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -111,6 +111,12 @@ export function runMigrations(db: Database.Database): void { columns: [ { name: 'avatar_color', type: 'TEXT' }, ] + }, + { + name: 'channels', + columns: [ + { name: 'category_id', type: 'TEXT' }, + ] } ]; @@ -168,6 +174,17 @@ export function runMigrations(db: Database.Database): void { ); `); + // Ensure channel_categories table exists (idempotent) + db.exec(` + CREATE TABLE IF NOT EXISTS channel_categories ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + name TEXT NOT NULL, + position INTEGER DEFAULT 0, + created_at INTEGER NOT NULL + ); + `); + // Ensure voice_restrictions table exists (idempotent) db.exec(` CREATE TABLE IF NOT EXISTS voice_restrictions ( diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 31fdcdcb..47a9e7c6 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -42,6 +42,14 @@ export const spaceMembers = sqliteTable('space_members', { pk: primaryKey({ columns: [table.spaceId, table.userId] }), })); +export const channelCategories = sqliteTable('channel_categories', { + id: text('id').primaryKey(), + spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + position: integer('position').default(0), + createdAt: integer('created_at').notNull(), +}); + export const channels = sqliteTable('channels', { id: text('id').primaryKey(), spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }), @@ -49,6 +57,7 @@ export const channels = sqliteTable('channels', { type: text('type').notNull(), topic: text('topic'), position: integer('position').default(0), + categoryId: text('category_id'), createdAt: integer('created_at').notNull(), }); diff --git a/packages/server/src/routes/channels.ts b/packages/server/src/routes/channels.ts index e9668af5..12c59082 100644 --- a/packages/server/src/routes/channels.ts +++ b/packages/server/src/routes/channels.ts @@ -11,6 +11,7 @@ import type { CreateChannelRequest, UpdateChannelRequest, Channel, + ChannelCategory, } from '@backspace/shared'; function rowToChannel(row: typeof schema.channels.$inferSelect): Channel { @@ -21,6 +22,17 @@ function rowToChannel(row: typeof schema.channels.$inferSelect): Channel { type: row.type as Channel['type'], topic: row.topic, position: row.position ?? 0, + categoryId: row.categoryId ?? null, + createdAt: row.createdAt, + }; +} + +function rowToCategory(row: typeof schema.channelCategories.$inferSelect): ChannelCategory { + return { + id: row.id, + spaceId: row.spaceId, + name: row.name, + position: row.position ?? 0, createdAt: row.createdAt, }; } @@ -96,7 +108,7 @@ export async function channelRoutes(app: FastifyInstance): Promise { preHandler: authenticate, }, async (request, reply) => { const { id } = request.params; - const { name, type, topic } = request.body; + const { name, type, topic, categoryId } = request.body; const db = getDb(); const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get(); @@ -121,6 +133,18 @@ export async function channelRoutes(app: FastifyInstance): Promise { return reply.code(400).send({ error: 'Channel type must be "text" or "voice"', statusCode: 400 }); } + // Validate categoryId if provided + let validCategoryId: string | null = null; + if (categoryId) { + const cat = db.select().from(schema.channelCategories) + .where(and(eq(schema.channelCategories.id, categoryId), eq(schema.channelCategories.spaceId, id))) + .get(); + if (!cat) { + return reply.code(400).send({ error: 'Category not found in this space', statusCode: 400 }); + } + validCategoryId = categoryId; + } + // Get max position for ordering const existingChannels = db.select() .from(schema.channels) @@ -139,6 +163,7 @@ export async function channelRoutes(app: FastifyInstance): Promise { type, topic: topic?.trim() || null, position: maxPosition + 1, + categoryId: validCategoryId, createdAt: now, }).run(); @@ -172,7 +197,7 @@ export async function channelRoutes(app: FastifyInstance): Promise { preHandler: authenticate, }, async (request, reply) => { const { id } = request.params; - const { name, topic, position } = request.body; + const { name, topic, position, categoryId } = request.body; const db = getDb(); const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get(); @@ -206,6 +231,20 @@ export async function channelRoutes(app: FastifyInstance): Promise { updates.position = position; } + if (categoryId !== undefined) { + if (categoryId === null) { + updates.categoryId = null; + } else { + const cat = db.select().from(schema.channelCategories) + .where(and(eq(schema.channelCategories.id, categoryId), eq(schema.channelCategories.spaceId, spaceId))) + .get(); + if (!cat) { + return reply.code(400).send({ error: 'Category not found in this space', statusCode: 400 }); + } + updates.categoryId = categoryId; + } + } + if (Object.keys(updates).length === 0) { return reply.code(400).send({ error: 'No fields to update', statusCode: 400 }); } @@ -394,4 +433,281 @@ export async function channelRoutes(app: FastifyInstance): Promise { return reply.code(200).send({ success: true }); }, ); + + // ─── Channel Category Endpoints ───────────────────────────────────────────── + + // POST /api/spaces/:id/categories - Create a category + app.post<{ Params: { id: string }; Body: { name: string } }>('/api/spaces/:id/categories', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const { name } = request.body; + const db = getDb(); + + const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get(); + if (!space) { + return reply.code(404).send({ error: 'Space not found', statusCode: 404 }); + } + + if (!hasPermission(request.userId, id, PermissionBits.MANAGE_CHANNELS)) { + return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 }); + } + + if (!name || typeof name !== 'string' || !name.trim()) { + return reply.code(400).send({ error: 'Category name is required', statusCode: 400 }); + } + + const trimmedName = name.trim(); + if (trimmedName.length > 100) { + return reply.code(400).send({ error: 'Category name must be 100 characters or less', statusCode: 400 }); + } + + const existing = db.select().from(schema.channelCategories) + .where(eq(schema.channelCategories.spaceId, id)) + .all(); + const maxPos = existing.reduce((max, c) => Math.max(max, c.position ?? 0), -1); + + const categoryId = generateSnowflake(); + const now = Date.now(); + + db.insert(schema.channelCategories).values({ + id: categoryId, + spaceId: id, + name: trimmedName, + position: maxPos + 1, + createdAt: now, + }).run(); + + const category = db.select().from(schema.channelCategories) + .where(eq(schema.channelCategories.id, categoryId)).get(); + if (!category) { + return reply.code(500).send({ error: 'Failed to create category', statusCode: 500 }); + } + + const categoryData = rowToCategory(category); + connectionManager.sendToSpace(id, { + type: 'category_created', + category: categoryData, + spaceId: id, + }); + + return reply.code(201).send(categoryData); + }); + + // PATCH /api/categories/:id - Update a category + app.patch<{ Params: { id: string }; Body: { name?: string; position?: number } }>('/api/categories/:id', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const { name, position } = request.body; + const db = getDb(); + + const category = db.select().from(schema.channelCategories) + .where(eq(schema.channelCategories.id, id)).get(); + if (!category) { + return reply.code(404).send({ error: 'Category not found', statusCode: 404 }); + } + + if (!hasPermission(request.userId, category.spaceId, PermissionBits.MANAGE_CHANNELS)) { + return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 }); + } + + const updates: Partial = {}; + + if (name !== undefined) { + const trimmedName = name.trim(); + if (!trimmedName || trimmedName.length > 100) { + return reply.code(400).send({ error: 'Category name must be 1-100 characters', statusCode: 400 }); + } + updates.name = trimmedName; + } + + if (position !== undefined) { + if (typeof position !== 'number' || position < 0) { + return reply.code(400).send({ error: 'Position must be a non-negative number', statusCode: 400 }); + } + updates.position = position; + } + + if (Object.keys(updates).length === 0) { + return reply.code(400).send({ error: 'No fields to update', statusCode: 400 }); + } + + db.update(schema.channelCategories).set(updates) + .where(eq(schema.channelCategories.id, id)).run(); + + const updated = db.select().from(schema.channelCategories) + .where(eq(schema.channelCategories.id, id)).get(); + if (!updated) { + return reply.code(500).send({ error: 'Failed to update category', statusCode: 500 }); + } + + const categoryData = rowToCategory(updated); + connectionManager.sendToSpace(category.spaceId, { + type: 'category_updated', + category: categoryData, + spaceId: category.spaceId, + }); + + return reply.code(200).send(categoryData); + }); + + // DELETE /api/categories/:id - Delete a category + app.delete<{ Params: { id: string } }>('/api/categories/:id', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const db = getDb(); + + const category = db.select().from(schema.channelCategories) + .where(eq(schema.channelCategories.id, id)).get(); + if (!category) { + return reply.code(404).send({ error: 'Category not found', statusCode: 404 }); + } + + if (!hasPermission(request.userId, category.spaceId, PermissionBits.MANAGE_CHANNELS)) { + return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 }); + } + + const spaceId = category.spaceId; + + db.transaction((tx) => { + // Null out categoryId on all channels in this category + tx.update(schema.channels).set({ categoryId: null }) + .where(eq(schema.channels.categoryId, id)).run(); + // Delete the category + tx.delete(schema.channelCategories) + .where(eq(schema.channelCategories.id, id)).run(); + }); + + // Broadcast category deletion + connectionManager.sendToSpace(spaceId, { + type: 'category_deleted', + categoryId: id, + spaceId, + }); + + // Also broadcast updated layout so channels reflect null categoryId + broadcastChannelLayout(spaceId); + + return reply.code(200).send({ success: true }); + }); + + // PATCH /api/spaces/:id/channel-layout - Batch reorder channels + categories + app.patch<{ + Params: { id: string }; + Body: { + channels: Array<{ id: string; position: number; categoryId: string | null }>; + categories: Array<{ id: string; position: number }>; + }; + }>('/api/spaces/:id/channel-layout', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const { channels: channelUpdates, categories: categoryUpdates } = request.body; + const db = getDb(); + + const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get(); + if (!space) { + return reply.code(404).send({ error: 'Space not found', statusCode: 404 }); + } + + if (!hasPermission(request.userId, id, PermissionBits.MANAGE_CHANNELS)) { + return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 }); + } + + if (!Array.isArray(channelUpdates) || !Array.isArray(categoryUpdates)) { + return reply.code(400).send({ error: 'channels and categories arrays are required', statusCode: 400 }); + } + + // Validate all channel IDs belong to this space + const spaceChannels = db.select().from(schema.channels) + .where(eq(schema.channels.spaceId, id)).all(); + const spaceChannelIds = new Set(spaceChannels.map(ch => ch.id)); + for (const ch of channelUpdates) { + if (!spaceChannelIds.has(ch.id)) { + return reply.code(400).send({ error: `Channel ${ch.id} does not belong to this space`, statusCode: 400 }); + } + if (typeof ch.position !== 'number' || ch.position < 0) { + return reply.code(400).send({ error: 'All positions must be non-negative numbers', statusCode: 400 }); + } + } + + // Validate all category IDs belong to this space + const spaceCategories = db.select().from(schema.channelCategories) + .where(eq(schema.channelCategories.spaceId, id)).all(); + const spaceCategoryIds = new Set(spaceCategories.map(c => c.id)); + for (const cat of categoryUpdates) { + if (!spaceCategoryIds.has(cat.id)) { + return reply.code(400).send({ error: `Category ${cat.id} does not belong to this space`, statusCode: 400 }); + } + if (typeof cat.position !== 'number' || cat.position < 0) { + return reply.code(400).send({ error: 'All positions must be non-negative numbers', statusCode: 400 }); + } + } + + // Validate category references in channels + for (const ch of channelUpdates) { + if (ch.categoryId !== null && !spaceCategoryIds.has(ch.categoryId)) { + return reply.code(400).send({ error: `Category ${ch.categoryId} does not belong to this space`, statusCode: 400 }); + } + } + + // Apply all updates in a transaction + db.transaction((tx) => { + for (const ch of channelUpdates) { + tx.update(schema.channels) + .set({ position: ch.position, categoryId: ch.categoryId }) + .where(eq(schema.channels.id, ch.id)) + .run(); + } + for (const cat of categoryUpdates) { + tx.update(schema.channelCategories) + .set({ position: cat.position }) + .where(eq(schema.channelCategories.id, cat.id)) + .run(); + } + }); + + // Broadcast the updated layout to all space members with per-user channel filtering + broadcastChannelLayout(id); + + return reply.code(200).send({ success: true }); + }); +} + +/** + * Broadcast updated channel layout to all space members. + * Each user gets only the channels they can view (VIEW_CHANNEL check). + */ +function broadcastChannelLayout(spaceId: string): void { + const db = getDb(); + const allChannels = db.select().from(schema.channels) + .where(eq(schema.channels.spaceId, spaceId)).all(); + const allCategories = db.select().from(schema.channelCategories) + .where(eq(schema.channelCategories.spaceId, spaceId)).all(); + + const categoryData = allCategories.map(rowToCategory); + + for (const [userId, spaceIds] of connectionManager.getUserSpaceEntries()) { + if (!spaceIds.has(spaceId)) continue; + + const visibleChannels: Channel[] = []; + for (const ch of allChannels) { + const perms = computePermissions(userId, spaceId, ch.id); + if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) { + visibleChannels.push({ + ...rowToChannel(ch), + myPermissions: permissionsToString(perms), + }); + } + } + + connectionManager.sendToUser(userId, { + type: 'channel_layout_updated', + spaceId, + channels: visibleChannels, + categories: categoryData, + }); + } } diff --git a/packages/server/src/routes/spaces.ts b/packages/server/src/routes/spaces.ts index 2d6ff74a..843861a9 100644 --- a/packages/server/src/routes/spaces.ts +++ b/packages/server/src/routes/spaces.ts @@ -14,6 +14,7 @@ import type { UpdateMemberRequest, Space, Channel, + ChannelCategory, MemberWithUser, SpaceWithChannelsAndMembers, Role, @@ -45,6 +46,7 @@ function rowToChannel(row: typeof schema.channels.$inferSelect): Channel { type: row.type as Channel['type'], topic: row.topic, position: row.position ?? 0, + categoryId: row.categoryId ?? null, createdAt: row.createdAt, }; } @@ -239,6 +241,19 @@ export async function spaceRoutes(app: FastifyInstance): Promise { }) .filter((m): m is MemberWithUser => m !== null); + // Fetch categories for this space + const categoryRows = db.select() + .from(schema.channelCategories) + .where(eq(schema.channelCategories.spaceId, id)) + .all(); + const categories: ChannelCategory[] = categoryRows.map(c => ({ + id: c.id, + spaceId: c.spaceId, + name: c.name, + position: c.position ?? 0, + createdAt: c.createdAt, + })); + // Compute space-level permissions for the requesting user const spacePerms = computePermissions(request.userId, id); @@ -259,6 +274,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise { const result: SpaceWithChannelsAndMembers = { ...rowToSpace(server), channels: visibleChannels, + categories, members, roles: roles.map(r => ({ id: r.id, diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 4f7ca227..e84ddd16 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -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(); + 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(); @@ -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, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index c12a2211..661b38ec 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -78,6 +78,7 @@ export interface JoinRequest { export interface SpaceWithChannelsAndMembers extends Space { channels: Channel[]; + categories: ChannelCategory[]; members: MemberWithUser[]; roles: Role[]; myPermissions?: string; // Computed per-user BigInt decimal string (space-level) @@ -125,6 +126,14 @@ export interface SpaceFolder { export type ChannelType = 'text' | 'voice'; +export interface ChannelCategory { + id: string; + spaceId: string; + name: string; + position: number; + createdAt: number; +} + export interface Channel { id: string; spaceId: string; @@ -132,6 +141,7 @@ export interface Channel { type: ChannelType; topic: string | null; position: number; + categoryId: string | null; createdAt: number; lastMessageId?: string | null; myPermissions?: string; // Computed per-user BigInt decimal string @@ -297,6 +307,10 @@ export type ServerEvent = | { type: 'voice_disconnected'; userId: string; channelId: string } | { type: 'user_updated'; user: User } | { type: 'member_banned'; spaceId: string; reason: string | null } + | { type: 'category_created'; category: ChannelCategory; spaceId: string } + | { 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: 'pong' } | { type: 'error'; message: string }; @@ -334,12 +348,14 @@ export interface CreateChannelRequest { name: string; type: ChannelType; topic?: string; + categoryId?: string; } export interface UpdateChannelRequest { name?: string; topic?: string; position?: number; + categoryId?: string | null; } export interface UpdateSpaceRequest { diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index a185906b..4df5845d 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -6,6 +6,7 @@ import type { Space, SpaceWithChannelsAndMembers, Channel, + ChannelCategory, MessageWithUser, MemberWithUser, Attachment, @@ -92,6 +93,13 @@ export class BackspaceApiClient { getOverrides: (channelId: string) => Promise<{ channelId: string; targetType: string; targetId: string; allow: string; deny: string }[]>; putOverride: (channelId: string, data: { targetType: string; targetId: string; allow: string; deny: string }) => Promise<{ success: boolean }>; deleteOverride: (channelId: string, targetType: string, targetId: string) => Promise<{ success: boolean }>; + updateLayout: (spaceId: string, data: { channels: Array<{ id: string; position: number; categoryId: string | null }>; categories: Array<{ id: string; position: number }> }) => Promise<{ success: boolean }>; + }; + + readonly categories: { + create: (spaceId: string, name: string) => Promise; + update: (id: string, data: { name?: string; position?: number }) => Promise; + delete: (id: string) => Promise<{ success: boolean }>; }; readonly messages: { @@ -313,6 +321,17 @@ export class BackspaceApiClient { request<{ success: boolean }>('PUT', `/channels/${channelId}/overrides`, data), deleteOverride: (channelId: string, targetType: string, targetId: string) => request<{ success: boolean }>('DELETE', `/channels/${channelId}/overrides/${targetType}/${targetId}`), + updateLayout: (spaceId: string, data: { channels: Array<{ id: string; position: number; categoryId: string | null }>; categories: Array<{ id: string; position: number }> }) => + request<{ success: boolean }>('PATCH', `/spaces/${spaceId}/channel-layout`, data), + }; + + this.categories = { + create: (spaceId: string, name: string) => + request('POST', `/spaces/${spaceId}/categories`, { name }), + update: (id: string, data: { name?: string; position?: number }) => + request('PATCH', `/categories/${id}`, data), + delete: (id: string) => + request<{ success: boolean }>('DELETE', `/categories/${id}`), }; this.messages = { diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index e8ad03c7..a4937d21 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; +import type { Channel } from '@backspace/shared'; import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore'; import { useChatStore } from '../../stores/chatStore'; import { useUIStore } from '../../stores/uiStore'; @@ -96,9 +97,213 @@ export function ChannelSidebar() { const channelPermissions = useSpaceStore((s) => s.channelPermissions); const canManageChannels = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_CHANNELS); const canCreateInvite = hasPermissionBit(mySpacePerms, PermissionBits.CREATE_INVITE); + const categories = useSpaceStore((s) => s.categories); - const textChannels = channels.filter(c => c.type === 'text'); - const voiceChannels = channels.filter(c => c.type === 'voice'); + // Drag state for channel/category reordering + const [channelDragState, setChannelDragState] = useState<{ dragType: 'channel' | 'category'; dragId: string } | null>(null); + const [dropIndicator, setDropIndicator] = useState<{ targetId: string; position: 'before' | 'after'; type: 'channel' | 'category' } | null>(null); + + // Collapse state — persisted in localStorage + const collapseKey = `backspace:collapsed-categories:${currentSpaceId}`; + const [collapsedCategories, setCollapsedCategories] = useState>(() => { + try { + const stored = localStorage.getItem(collapseKey); + return stored ? new Set(JSON.parse(stored)) : new Set(); + } catch { return new Set(); } + }); + const toggleCollapse = useCallback((categoryId: string) => { + setCollapsedCategories(prev => { + const next = new Set(prev); + if (next.has(categoryId)) next.delete(categoryId); + else next.add(categoryId); + try { localStorage.setItem(collapseKey, JSON.stringify([...next])); } catch {} + return next; + }); + }, [collapseKey]); + + // Group channels by category + const sortedCategories = useMemo(() => + [...categories].sort((a, b) => a.position - b.position), [categories]); + const uncategorizedChannels = useMemo(() => + channels.filter(c => !c.categoryId).sort((a, b) => a.position - b.position), [channels]); + const channelsByCategory = useMemo(() => { + const map = new Map(); + for (const ch of channels) { + if (!ch.categoryId) continue; + let arr = map.get(ch.categoryId); + if (!arr) { arr = []; map.set(ch.categoryId, arr); } + arr.push(ch); + } + for (const [key, arr] of map) { + map.set(key, arr.sort((a, b) => a.position - b.position)); + } + return map; + }, [channels]); + + // Check if a collapsed category has unread channels + const categoryHasUnread = useCallback((categoryId: string) => { + const chs = channelsByCategory.get(categoryId) ?? []; + return chs.some(ch => unreadChannels.has(ch.id)); + }, [channelsByCategory, unreadChannels]); + + // DnD handlers + const handleChannelDragStart = useCallback((e: React.DragEvent, channelId: string) => { + if (!canManageChannels) return; + e.dataTransfer.setData('application/x-channel-id', channelId); + e.dataTransfer.effectAllowed = 'move'; + setChannelDragState({ dragType: 'channel', dragId: channelId }); + }, [canManageChannels]); + + const handleCategoryDragStart = useCallback((e: React.DragEvent, categoryId: string) => { + if (!canManageChannels) return; + e.dataTransfer.setData('application/x-category-id', categoryId); + e.dataTransfer.effectAllowed = 'move'; + setChannelDragState({ dragType: 'category', dragId: categoryId }); + }, [canManageChannels]); + + const handleDragEnd = useCallback(() => { + setChannelDragState(null); + setDropIndicator(null); + }, []); + + const handleChannelDragOver = useCallback((e: React.DragEvent, targetId: string, type: 'channel' | 'category') => { + if (!channelDragState) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const rect = e.currentTarget.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + const position = e.clientY < midY ? 'before' : 'after'; + setDropIndicator({ targetId, position, type }); + }, [channelDragState]); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + if (!channelDragState || !currentSpaceId || !dropIndicator) { + setChannelDragState(null); + setDropIndicator(null); + return; + } + + const dragChannelId = e.dataTransfer.getData('application/x-channel-id'); + const dragCategoryId = e.dataTransfer.getData('application/x-category-id'); + + if (dragChannelId && dropIndicator) { + // Channel drag — compute new layout + const allChannelsCopy = channels.map(ch => ({ + id: ch.id, + position: ch.position, + categoryId: ch.categoryId, + })); + + if (dropIndicator.type === 'channel') { + // Dropping on a channel — take its categoryId and insert near it + const targetCh = channels.find(c => c.id === dropIndicator.targetId); + if (targetCh) { + const dragCh = allChannelsCopy.find(c => c.id === dragChannelId); + if (dragCh) { + dragCh.categoryId = targetCh.categoryId; + } + } + } else if (dropIndicator.type === 'category') { + // Dropping on a category header — move channel into that category + const dragCh = allChannelsCopy.find(c => c.id === dragChannelId); + if (dragCh) { + dragCh.categoryId = dropIndicator.targetId; + } + } + + // Recalculate positions: group by category and assign sequential positions + const grouped = new Map(); + for (const ch of allChannelsCopy) { + const key = ch.categoryId; + let arr = grouped.get(key); + if (!arr) { arr = []; grouped.set(key, arr); } + arr.push(ch); + } + + // Within each group, move dragged channel to correct position + for (const [, arr] of grouped) { + arr.sort((a, b) => a.position - b.position); + const dragIdx = arr.findIndex(c => c.id === dragChannelId); + if (dragIdx === -1) continue; + const dragItem = arr[dragIdx]!; + arr.splice(dragIdx, 1); + + // Find target position + if (dropIndicator.type === 'channel') { + const targetIdx = arr.findIndex(c => c.id === dropIndicator.targetId); + if (targetIdx !== -1) { + const insertIdx = dropIndicator.position === 'before' ? targetIdx : targetIdx + 1; + arr.splice(insertIdx, 0, dragItem); + } else { + arr.push(dragItem); + } + } else { + // Dropped on category header — add at start + arr.unshift(dragItem); + } + // Reassign positions + arr.forEach((ch, i) => { ch.position = i; }); + } + + const channelUpdates = allChannelsCopy.map(ch => ({ + id: ch.id, + position: ch.position, + categoryId: ch.categoryId, + })); + const categoryUpdates = sortedCategories.map(c => ({ + id: c.id, + position: c.position, + })); + + // Optimistic update + useSpaceStore.getState().setChannels( + channels.map(ch => { + const update = channelUpdates.find(u => u.id === ch.id); + if (update) return { ...ch, position: update.position, categoryId: update.categoryId }; + return ch; + }).sort((a, b) => a.position - b.position) + ); + + useSpaceStore.getState().updateChannelLayout(currentSpaceId, { channels: channelUpdates, categories: categoryUpdates }); + } else if (dragCategoryId && dropIndicator?.type === 'category') { + // Category drag — reorder categories + const catsCopy = sortedCategories.map(c => ({ id: c.id, position: c.position })); + const dragIdx = catsCopy.findIndex(c => c.id === dragCategoryId); + if (dragIdx !== -1) { + const dragItem = catsCopy[dragIdx]!; + catsCopy.splice(dragIdx, 1); + const targetIdx = catsCopy.findIndex(c => c.id === dropIndicator.targetId); + if (targetIdx !== -1) { + const insertIdx = dropIndicator.position === 'before' ? targetIdx : targetIdx + 1; + catsCopy.splice(insertIdx, 0, dragItem); + } else { + catsCopy.push(dragItem); + } + catsCopy.forEach((c, i) => { c.position = i; }); + + const channelUpdates = channels.map(ch => ({ + id: ch.id, + position: ch.position, + categoryId: ch.categoryId, + })); + + // Optimistic update + useSpaceStore.getState().setCategories( + categories.map(cat => { + const update = catsCopy.find(u => u.id === cat.id); + if (update) return { ...cat, position: update.position }; + return cat; + }).sort((a, b) => a.position - b.position) + ); + + useSpaceStore.getState().updateChannelLayout(currentSpaceId, { channels: channelUpdates, categories: catsCopy }); + } + } + + setChannelDragState(null); + setDropIndicator(null); + }, [channelDragState, dropIndicator, channels, sortedCategories, categories, currentSpaceId]); const handleChannelClick = (channelId: string) => { setCurrentChannel(channelId); @@ -362,125 +567,174 @@ export function ChannelSidebar() { )} - {/* Channels */} -
- {/* Text Channels */} -
-
-
- - - - Text Channels -
- {canManageChannels && ( - - )} -
-
- {textChannels.map((channel) => { - const isActive = currentChannelId === channel.id; - const isUnread = unreadChannels.has(channel.id) && !isActive; - return ( + {/* Channels — dynamic category layout */} +
e.preventDefault()}> + {/* Uncategorized channels */} + {uncategorizedChannels.length > 0 && ( +
+ {canManageChannels && sortedCategories.length === 0 && ( +
- ); - })} -
-
- - {/* Voice Channels */} -
-
-
- - - - Voice Channels -
- {canManageChannels && ( - +
)} -
-
- {voiceChannels.map((channel) => { - const chPerms = channelPermissions.get(channel.id); - const canConnect = hasPermissionBit(chPerms, PermissionBits.CONNECT); - return ( - + {uncategorizedChannels.map((channel) => ( + canConnect && handleVoiceJoin(channel.id)} - locked={!canConnect} - dragState={voiceDragState} - onDragStart={(userId: string) => setVoiceDragState({ userId, fromChannelId: channel.id })} - onDragEnd={() => setVoiceDragState(null)} + channel={channel} + isActive={currentChannelId === channel.id} + isUnread={unreadChannels.has(channel.id) && currentChannelId !== channel.id} + canManage={canManageChannels} + isDragging={channelDragState?.dragType === 'channel' && channelDragState.dragId === channel.id} + dropIndicator={dropIndicator?.targetId === channel.id ? dropIndicator.position : null} + onChannelClick={channel.type === 'voice' ? (() => { + const chPerms = channelPermissions.get(channel.id); + const canConnect = hasPermissionBit(chPerms, PermissionBits.CONNECT); + if (canConnect) handleVoiceJoin(channel.id); + }) : (() => handleChannelClick(channel.id))} + onSettingsClick={() => openModal('channelSettings', { channelId: channel.id })} + onDragStart={(e) => handleChannelDragStart(e, channel.id)} + onDragOver={(e) => handleChannelDragOver(e, channel.id, 'channel')} + onDragEnd={handleDragEnd} + voiceDragState={voiceDragState} + onVoiceDragStart={(userId: string) => setVoiceDragState({ userId, fromChannelId: channel.id })} + onVoiceDragEnd={() => setVoiceDragState(null)} + channelPermissions={channelPermissions} + handleVoiceJoin={handleVoiceJoin} /> - ); - })} + ))} +
-
+ )} + + {/* Categories with their channels */} + {sortedCategories.map((category) => { + const catChannels = channelsByCategory.get(category.id) ?? []; + const isCollapsed = collapsedCategories.has(category.id); + const hasUnread = isCollapsed && categoryHasUnread(category.id); + + return ( +
+ {/* Category header */} +
handleCategoryDragStart(e, category.id)} + onDragEnd={handleDragEnd} + onDragOver={(e) => handleChannelDragOver(e, category.id, 'category')} + onClick={() => toggleCollapse(category.id)} + > +
+ + + + {category.name} + {hasUnread && ( +
+ )} +
+ {canManageChannels && ( + + )} +
+ + {/* Category channels (hidden when collapsed, unless active) */} + {!isCollapsed && ( +
+ {catChannels.map((channel) => ( + { + const chPerms = channelPermissions.get(channel.id); + const canConnect = hasPermissionBit(chPerms, PermissionBits.CONNECT); + if (canConnect) handleVoiceJoin(channel.id); + }) : (() => handleChannelClick(channel.id))} + onSettingsClick={() => openModal('channelSettings', { channelId: channel.id })} + onDragStart={(e) => handleChannelDragStart(e, channel.id)} + onDragOver={(e) => handleChannelDragOver(e, channel.id, 'channel')} + onDragEnd={handleDragEnd} + voiceDragState={voiceDragState} + onVoiceDragStart={(userId: string) => setVoiceDragState({ userId, fromChannelId: channel.id })} + onVoiceDragEnd={() => setVoiceDragState(null)} + channelPermissions={channelPermissions} + handleVoiceJoin={handleVoiceJoin} + /> + ))} + {catChannels.length === 0 && ( +
No channels
+ )} +
+ )} +
+ ); + })} + + {/* "Create Channel" button if categories exist but no uncategorized channels */} + {sortedCategories.length > 0 && canManageChannels && ( +
+ +
+ )} + + {/* Create category button */} + {canManageChannels && ( +
+ +
+ )}
@@ -889,3 +1143,119 @@ function UserAreaPanel({
); } + +/* ─── Channel Item (unified text + voice) ──────────────────────────────────── */ + +function ChannelItem({ + channel, + isActive, + isUnread, + canManage, + isDragging, + dropIndicator, + onChannelClick, + onSettingsClick, + onDragStart, + onDragOver, + onDragEnd, + voiceDragState, + onVoiceDragStart, + onVoiceDragEnd, + channelPermissions, + handleVoiceJoin, +}: { + channel: Channel; + isActive: boolean; + isUnread: boolean; + canManage: boolean; + isDragging: boolean; + dropIndicator: 'before' | 'after' | null; + onChannelClick: () => void; + onSettingsClick: () => void; + onDragStart: (e: React.DragEvent) => void; + onDragOver: (e: React.DragEvent) => void; + onDragEnd: () => void; + voiceDragState: { userId: string; fromChannelId: string } | null; + onVoiceDragStart: (userId: string) => void; + onVoiceDragEnd: () => void; + channelPermissions: Map; + handleVoiceJoin: (channelId: string) => void; +}) { + if (channel.type === 'voice') { + const chPerms = channelPermissions.get(channel.id); + const canConnect = hasPermissionBit(chPerms, PermissionBits.CONNECT); + return ( +
+ {dropIndicator === 'before' &&
} + canConnect && handleVoiceJoin(channel.id)} + locked={!canConnect} + dragState={voiceDragState} + onDragStart={onVoiceDragStart} + onDragEnd={onVoiceDragEnd} + /> + {dropIndicator === 'after' &&
} +
+ ); + } + + return ( +
+ {dropIndicator === 'before' &&
} + + {dropIndicator === 'after' &&
} +
+ ); +} diff --git a/packages/web/src/components/modals/CreateChannel.tsx b/packages/web/src/components/modals/CreateChannel.tsx index efaea66c..cd08215e 100644 --- a/packages/web/src/components/modals/CreateChannel.tsx +++ b/packages/web/src/components/modals/CreateChannel.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Modal } from '../ui/Modal'; import { useUIStore } from '../../stores/uiStore'; import { useSpaceStore } from '../../stores/spaceStore'; @@ -7,15 +7,25 @@ export function CreateChannelModal() { const [name, setName] = useState(''); const [type, setType] = useState<'text' | 'voice'>('text'); const [topic, setTopic] = useState(''); + const [categoryId, setCategoryId] = useState(''); const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); const activeModal = useUIStore((s) => s.activeModal); + const modalData = useUIStore((s) => s.modalData); const closeModal = useUIStore((s) => s.closeModal); const createChannel = useSpaceStore((s) => s.createChannel); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); + const categories = useSpaceStore((s) => s.categories); const isOpen = activeModal === 'createChannel'; + // Pre-select category when opened from a category's + button + useEffect(() => { + if (isOpen && modalData.categoryId) { + setCategoryId(modalData.categoryId as string); + } + }, [isOpen, modalData.categoryId]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); @@ -32,11 +42,12 @@ export function CreateChannelModal() { setIsLoading(true); try { - await createChannel(currentSpaceId, name.trim(), type, topic.trim() || undefined); + await createChannel(currentSpaceId, name.trim(), type, topic.trim() || undefined, categoryId || undefined); closeModal(); setName(''); setTopic(''); setType('text'); + setCategoryId(''); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create channel'); } finally { @@ -124,6 +135,24 @@ export function CreateChannelModal() {
)} + {categories.length > 0 && ( +
+ + +
+ )} +
diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index bbfae8f9..2bce1e5c 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -651,6 +651,55 @@ function handleEvent(origin: string, event: ServerEvent): void { break; } + // ─── Category events (all origins) ──────────────────────────────────── + + case 'category_created': { + const { currentSpaceId: catSpaceId, categories: curCategories, setCategories: setCats, categoryOriginMap: catOriginMap } = useSpaceStore.getState(); + catOriginMap.set(event.category.id, origin); + if (event.spaceId === catSpaceId) { + if (!curCategories.some(c => c.id === event.category.id)) { + setCats([...curCategories, event.category].sort((a, b) => a.position - b.position)); + } + } + break; + } + + case 'category_updated': { + const { currentSpaceId: catSpaceId2, categories: curCategories2, setCategories: setCats2 } = useSpaceStore.getState(); + if (event.spaceId === catSpaceId2) { + setCats2(curCategories2.map(c => c.id === event.category.id ? event.category : c).sort((a, b) => a.position - b.position)); + } + break; + } + + case 'category_deleted': { + const { currentSpaceId: catSpaceId3, categories: curCategories3, setCategories: setCats3, channels: curChsForCat, setChannels: setChsForCat, categoryOriginMap: catOriginMap3 } = useSpaceStore.getState(); + catOriginMap3.delete(event.categoryId); + if (event.spaceId === catSpaceId3) { + setCats3(curCategories3.filter(c => c.id !== event.categoryId)); + // Null out categoryId on affected channels (server already did this, but sync local state) + setChsForCat(curChsForCat.map(ch => ch.categoryId === event.categoryId ? { ...ch, categoryId: null } : ch)); + } + break; + } + + case 'channel_layout_updated': { + const { currentSpaceId: layoutSpaceId, setChannels: setLayoutChannels, setCategories: setLayoutCategories, channelPermissions: layoutChPerms, channelToSpaceMap: layoutCtsMap, channelOriginMap: layoutCoMap } = useSpaceStore.getState(); + if (event.spaceId === layoutSpaceId) { + setLayoutChannels(event.channels.sort((a, b) => a.position - b.position)); + setLayoutCategories(event.categories.sort((a, b) => a.position - b.position)); + // Update permission maps from the new layout + for (const ch of event.channels) { + layoutCtsMap.set(ch.id, event.spaceId); + layoutCoMap.set(ch.id, origin); + if (ch.myPermissions) { + layoutChPerms.set(ch.id, ch.myPermissions); + } + } + } + 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 41be460c..b6559ef1 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, MemberWithUser, SpaceWithChannelsAndMembers, Role, SpaceFolder, DmChannel, User, UpdateSpaceRequest, CreateSpaceRequest } from '@backspace/shared'; +import type { Space, Channel, ChannelCategory, MemberWithUser, SpaceWithChannelsAndMembers, Role, SpaceFolder, DmChannel, User, UpdateSpaceRequest, CreateSpaceRequest } from '@backspace/shared'; import { api, BackspaceApiClient } from '../api/client'; import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls'; import { isSelf } from '../utils/identity'; @@ -26,6 +26,7 @@ interface SpaceState { spaces: TaggedSpace[]; currentSpaceId: string | null; channels: Channel[]; + categories: ChannelCategory[]; members: MemberWithUser[]; roles: Role[]; folders: SpaceFolder[]; @@ -35,9 +36,11 @@ interface SpaceState { spacePermissions: Map; // spaceId → myPermissions decimal string channelPermissions: Map; // channelId → myPermissions decimal string channelOriginMap: Map; // channelId → instance origin ('' = home) + categoryOriginMap: Map; // categoryId → instance origin ('' = home) setSpaces: (spaces: TaggedSpace[]) => void; setCurrentSpace: (spaceId: string | null) => void; setChannels: (channels: Channel[]) => void; + setCategories: (categories: ChannelCategory[]) => void; setMembers: (members: MemberWithUser[]) => void; setRoles: (roles: Role[]) => void; setDmChannels: (channels: DmChannel[]) => void; @@ -57,8 +60,12 @@ interface SpaceState { leaveSpace: (spaceId: string) => Promise; joinByCode: (inviteCode: string, origin?: string) => Promise; generateInvite: (spaceId: string) => Promise; - createChannel: (spaceId: string, name: string, type: 'text' | 'voice', topic?: string) => Promise; + createChannel: (spaceId: string, name: string, type: 'text' | 'voice', topic?: string, categoryId?: string) => Promise; deleteChannel: (channelId: string) => Promise; + createCategory: (spaceId: string, name: string) => Promise; + updateCategory: (categoryId: string, data: { name?: string; position?: number }) => Promise; + deleteCategory: (categoryId: string) => Promise; + updateChannelLayout: (spaceId: string, data: { channels: Array<{ id: string; position: number; categoryId: string | null }>; categories: Array<{ id: string; position: number }> }) => Promise; addSpace: (space: Space) => void; removeSpace: (spaceId: string) => void; updateMemberPresence: (userId: string, status: string) => void; @@ -76,6 +83,7 @@ export const useSpaceStore = create((set, get) => ({ spaces: [], currentSpaceId: null, channels: [], + categories: [], members: [], roles: [], folders: [], @@ -85,10 +93,12 @@ export const useSpaceStore = create((set, get) => ({ spacePermissions: new Map(), channelPermissions: new Map(), channelOriginMap: new Map(), + categoryOriginMap: new Map(), setSpaces: (spaces) => set({ spaces }), setCurrentSpace: (spaceId) => set({ currentSpaceId: spaceId }), setChannels: (channels) => set({ channels }), + setCategories: (categories) => set({ categories }), setMembers: (members) => set({ members }), setRoles: (roles) => set({ roles }), setDmChannels: (dmChannels) => set({ dmChannels }), @@ -185,6 +195,7 @@ export const useSpaceStore = create((set, get) => ({ set({ currentSpaceId: spaceId, channels: detail.channels.sort((a, b) => a.position - b.position), + categories: (detail.categories || []).sort((a, b) => a.position - b.position), members: detail.members, roles: detail.roles.sort((a, b) => b.position - a.position), spacePermissions, @@ -294,8 +305,11 @@ export const useSpaceStore = create((set, get) => ({ return result.inviteCode; }, - createChannel: async (spaceId: string, name: string, type: 'text' | 'voice', topic?: string) => { - const channel = await api.channels.create(spaceId, { name, type, topic }); + createChannel: async (spaceId: string, name: string, type: 'text' | 'voice', topic?: string, categoryId?: string) => { + const space = get().spaces.find(s => s.id === spaceId); + const origin = space?._instanceOrigin ?? ''; + const client = getApiForOrigin(origin); + const channel = await client.channels.create(spaceId, { name, type, topic, categoryId }); set((state) => { if (state.channels.some(c => c.id === channel.id)) return state; return { channels: [...state.channels, channel].sort((a, b) => a.position - b.position) }; @@ -310,6 +324,47 @@ export const useSpaceStore = create((set, get) => ({ })); }, + createCategory: async (spaceId: string, name: string) => { + const space = get().spaces.find(s => s.id === spaceId); + const origin = space?._instanceOrigin ?? ''; + const client = getApiForOrigin(origin); + const category = await client.categories.create(spaceId, name); + // Will be added via WS event, but add optimistically + set((state) => { + if (state.categories.some(c => c.id === category.id)) return state; + return { categories: [...state.categories, category].sort((a, b) => a.position - b.position) }; + }); + return category; + }, + + updateCategory: async (categoryId: string, data: { name?: string; position?: number }) => { + const cat = get().categories.find(c => c.id === categoryId); + if (!cat) return; + const space = get().spaces.find(s => s.id === cat.spaceId); + const origin = space?._instanceOrigin ?? ''; + const client = getApiForOrigin(origin); + await client.categories.update(categoryId, data); + // WS event will update the store + }, + + deleteCategory: async (categoryId: string) => { + const cat = get().categories.find(c => c.id === categoryId); + if (!cat) return; + const space = get().spaces.find(s => s.id === cat.spaceId); + const origin = space?._instanceOrigin ?? ''; + const client = getApiForOrigin(origin); + await client.categories.delete(categoryId); + // WS events will update the store + }, + + updateChannelLayout: async (spaceId: string, data: { channels: Array<{ id: string; position: number; categoryId: string | null }>; categories: Array<{ id: string; position: number }> }) => { + const space = get().spaces.find(s => s.id === spaceId); + const origin = space?._instanceOrigin ?? ''; + const client = getApiForOrigin(origin); + await client.channels.updateLayout(spaceId, data); + // WS event will broadcast the updated layout + }, + addSpace: (space: Space) => { set((state) => { if (state.spaces.find(s => s.id === space.id)) return state; @@ -386,6 +441,7 @@ export const useSpaceStore = create((set, get) => ({ const spacePermissions = new Map(get().spacePermissions); const channelPermissions = new Map(get().channelPermissions); const channelOriginMap = new Map(get().channelOriginMap); + const categoryOriginMap = new Map(get().categoryOriginMap); // If home, clear home-origin entries first to avoid stale data if (isHome) { @@ -435,6 +491,11 @@ export const useSpaceStore = create((set, get) => ({ channelPermissions.set(ch.id, ch.myPermissions); } } + if (srv.categories) { + for (const cat of srv.categories) { + categoryOriginMap.set(cat.id, origin); + } + } } // DM channels: process from any origin, normalize remote assets @@ -467,6 +528,7 @@ export const useSpaceStore = create((set, get) => ({ spacePermissions, channelPermissions, channelOriginMap, + categoryOriginMap, }; // Only set folders from home origin