refactor: rename Server → Space across entire codebase

Renames all domain terminology from "Server" to "Space" throughout the
application — database schema, API routes, shared types, stores, components,
and UI strings. Files renamed: ServerSidebar → SpaceSidebar, CreateServer →
CreateSpace, JoinServer → JoinSpace, ServerSettings → SpaceSettings,
serverStore → spaceStore, routes/servers → routes/spaces.
This commit is contained in:
Jannis Braun
2026-03-08 20:08:24 +01:00
parent d18d9c51f7
commit fc06e25731
62 changed files with 1342 additions and 1342 deletions
+53 -53
View File
@@ -12,7 +12,7 @@ Backspace has its own visual identity — it is NOT a Discord clone. The design
- **Design language:** "Aether Drift" — warm matte surfaces with subtle frosted glass accents
- **Two-material system:** Solid matte panels for content (75%), frosted glass bubbles for persistent controls (25%)
- **Color palette:** Warm dark surfaces (#13131a chat, #1a1a23 sidebars), pastel accents (mint, peach, lavender, sky, amber, rose, coral)
- **Glass elements:** Server strip (left column), voice+user bubble (bottom-left, crosses over server strip), input bubble (bottom of chat)
- **Glass elements:** Space strip (left column), voice+user bubble (bottom-left, crosses over space strip), input bubble (bottom of chat)
- **Glass material:** `backdrop-filter: blur(20px) saturate(120%)`, warm-tinted `rgba(20,20,26,0.52)`, subtle 0.07 opacity borders
- **Key principles:** Calm over flashy. Warm over cool. Quiet glass (felt, not seen). No decorative gradients. Minimal shadows.
- **Accessibility:** `prefers-reduced-transparency` media query falls back to solid surfaces
@@ -95,7 +95,7 @@ Backspace/
│ │ ├── routes/
│ │ │ ├── auth.ts
│ │ │ ├── users.ts
│ │ │ ├── servers.ts
│ │ │ ├── spaces.ts
│ │ │ ├── channels.ts
│ │ │ ├── messages.ts
│ │ │ ├── uploads.ts
@@ -128,7 +128,7 @@ Backspace/
│ │ │ └── SpeakingDetector.ts
│ │ ├── stores/
│ │ │ ├── authStore.ts
│ │ │ ├── serverStore.ts
│ │ │ ├── spaceStore.ts
│ │ │ ├── chatStore.ts
│ │ │ ├── voiceStore.ts
│ │ │ ├── socialStore.ts
@@ -147,7 +147,7 @@ Backspace/
│ │ ├── components/
│ │ │ ├── layout/
│ │ │ │ ├── AppLayout.tsx
│ │ │ │ ├── ServerSidebar.tsx
│ │ │ │ ├── SpaceSidebar.tsx
│ │ │ │ ├── ChannelSidebar.tsx
│ │ │ │ ├── MainContent.tsx
│ │ │ │ ├── RightPanel.tsx
@@ -184,12 +184,12 @@ Backspace/
│ │ │ │ ├── LoginPage.tsx
│ │ │ │ └── RegisterPage.tsx
│ │ │ ├── modals/
│ │ │ │ ├── CreateServer.tsx
│ │ │ │ ├── CreateSpace.tsx
│ │ │ │ ├── InviteModal.tsx
│ │ │ │ ├── CreateChannel.tsx
│ │ │ │ ├── JoinServer.tsx
│ │ │ │ ├── JoinSpace.tsx
│ │ │ │ ├── UserSettings.tsx
│ │ │ │ ├── ServerSettings.tsx
│ │ │ │ ├── SpaceSettings.tsx
│ │ │ │ ├── ChannelSettingsModal.tsx
│ │ │ │ ├── NewDmModal.tsx
│ │ │ │ └── AddDmMemberModal.tsx
@@ -232,8 +232,8 @@ CREATE TABLE users (
created_at INTEGER NOT NULL
);
-- Servers (Guilds)
CREATE TABLE servers (
-- Spaces (Communities)
CREATE TABLE spaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT,
@@ -242,19 +242,19 @@ CREATE TABLE servers (
created_at INTEGER NOT NULL
);
-- Server Members
CREATE TABLE server_members (
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
-- Space Members
CREATE TABLE space_members (
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
nickname TEXT,
joined_at INTEGER NOT NULL,
PRIMARY KEY (server_id, user_id)
PRIMARY KEY (space_id, user_id)
);
-- Roles
CREATE TABLE roles (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT DEFAULT '#b9bbbe',
position INTEGER DEFAULT 0,
@@ -264,16 +264,16 @@ CREATE TABLE roles (
-- Member Roles (many-to-many)
CREATE TABLE member_roles (
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (server_id, user_id, role_id)
PRIMARY KEY (space_id, user_id, role_id)
);
-- Channels
CREATE TABLE channels (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL, -- 'text' | 'voice' | 'video'
topic TEXT,
@@ -383,8 +383,8 @@ CREATE TABLE read_states (
PRIMARY KEY (user_id, channel_id)
);
-- Server Folders
CREATE TABLE server_folders (
-- Space Folders
CREATE TABLE space_folders (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT,
@@ -393,10 +393,10 @@ CREATE TABLE server_folders (
created_at INTEGER NOT NULL
);
CREATE TABLE server_folder_members (
folder_id TEXT NOT NULL REFERENCES server_folders(id) ON DELETE CASCADE,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
PRIMARY KEY (folder_id, server_id)
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,
PRIMARY KEY (folder_id, space_id)
);
-- Instance Settings (singleton row, id=1)
@@ -425,31 +425,31 @@ GET /api/users/@me (auth) →
PATCH /api/users/@me (auth) { displayName?, avatar?, customStatus?, status? } → { user }
GET /api/users/:id (auth) → { user }
# Servers
POST /api/servers (auth) { name, icon? } → { server }
GET /api/servers (auth) → { servers[] }
GET /api/servers/:id (auth) → { server, channels[], members[], roles[] }
PATCH /api/servers/:id (auth, MANAGE_SERVER) { name?, icon? } → { server }
DELETE /api/servers/:id (auth, owner) → { success }
POST /api/servers/:id/invite (auth, CREATE_INVITE) → { inviteCode }
POST /api/servers/:id/join (auth) { inviteCode } → { server }
POST /api/servers/join (auth) { inviteCode } → { server }
# Spaces
POST /api/spaces (auth) { name, icon? } → { space }
GET /api/spaces (auth) → { spaces[] }
GET /api/spaces/:id (auth) → { space, channels[], members[], roles[] }
PATCH /api/spaces/:id (auth, MANAGE_SPACE) { name?, icon? } → { space }
DELETE /api/spaces/:id (auth, owner) → { success }
POST /api/spaces/:id/invite (auth, CREATE_INVITE) → { inviteCode }
POST /api/spaces/:id/join (auth) { inviteCode } → { space }
POST /api/spaces/join (auth) { inviteCode } → { space }
# Members
GET /api/servers/:id/members (auth, member) → { members[] }
PATCH /api/servers/:id/members/:uid (auth, MANAGE_ROLES) { roles } → { member }
DELETE /api/servers/:id/members/:uid (auth, KICK_MEMBERS|self) → { success }
GET /api/spaces/:id/members (auth, member) → { members[] }
PATCH /api/spaces/:id/members/:uid (auth, MANAGE_ROLES) { roles } → { member }
DELETE /api/spaces/:id/members/:uid (auth, KICK_MEMBERS|self) → { success }
# Roles
POST /api/servers/:id/roles (auth, MANAGE_ROLES) { name, color?, permissions? } → { role }
PATCH /api/servers/:id/roles/:rid (auth, MANAGE_ROLES) { name?, color?, permissions?, position? } → { role }
DELETE /api/servers/:id/roles/:rid (auth, MANAGE_ROLES) → { success }
POST /api/servers/:id/members/:uid/roles (auth, MANAGE_ROLES) { roleId } → { success }
DELETE /api/servers/:id/members/:uid/roles/:rid (auth, MANAGE_ROLES) → { success }
POST /api/spaces/:id/roles (auth, MANAGE_ROLES) { name, color?, permissions? } → { role }
PATCH /api/spaces/:id/roles/:rid (auth, MANAGE_ROLES) { name?, color?, permissions?, position? } → { role }
DELETE /api/spaces/:id/roles/:rid (auth, MANAGE_ROLES) → { success }
POST /api/spaces/:id/members/:uid/roles (auth, MANAGE_ROLES) { roleId } → { success }
DELETE /api/spaces/:id/members/:uid/roles/:rid (auth, MANAGE_ROLES) → { success }
# Channels
GET /api/servers/:id/channels (auth, member, VIEW_CHANNEL) → { channels[] }
POST /api/servers/:id/channels (auth, MANAGE_CHANNELS) { name, type, topic? } → { channel }
GET /api/spaces/:id/channels (auth, member, VIEW_CHANNEL) → { channels[] }
POST /api/spaces/:id/channels (auth, MANAGE_CHANNELS) { name, type, topic? } → { channel }
PATCH /api/channels/:id (auth, MANAGE_CHANNELS) { name?, topic?, position? } → { channel }
DELETE /api/channels/:id (auth, MANAGE_CHANNELS) → { success }
@@ -527,7 +527,7 @@ All WebSocket messages are JSON over `/ws`. Client authenticates by sending `{ t
# Presence
{ type: 'presence_update', status: 'online' | 'idle' | 'dnd' }
# Voice (Server Channels)
# Voice (Space Channels)
{ type: 'voice_join', channelId }
{ type: 'voice_leave' }
{ type: 'voice_status', isMuted?, isDeafened?, isCameraOn?, isScreenSharing? }
@@ -541,7 +541,7 @@ All WebSocket messages are JSON over `/ws`. Client authenticates by sending `{ t
### Server → Client
```
{ type: 'ready', user, servers, dmChannels, folders, voiceStates, readStates, activeCalls }
{ type: 'ready', user, spaces, dmChannels, folders, voiceStates, readStates, activeCalls }
{ type: 'pong' }
# Server Messages
@@ -561,16 +561,16 @@ All WebSocket messages are JSON over `/ws`. Client authenticates by sending `{ t
{ type: 'dm_member_added', dmChannelId, user }
{ type: 'dm_member_removed', dmChannelId, userId }
# Channel/Server Updates
# Channel/Space Updates
{ type: 'channel_created', channel }
{ type: 'channel_updated', channel }
{ type: 'channel_deleted', channelId, serverId }
{ type: 'server_updated', server }
{ type: 'channel_deleted', channelId, spaceId }
{ type: 'space_updated', space }
{ type: 'channel_ack', channelId, messageId, userId }
# Members & Presence
{ type: 'member_joined', serverId, member: MemberWithUser }
{ type: 'member_left', serverId, userId }
{ type: 'member_joined', spaceId, member: MemberWithUser }
{ type: 'member_left', spaceId, userId }
{ type: 'presence_update', userId, status }
# Voice
@@ -599,10 +599,10 @@ Bitwise permission engine defined in `packages/shared/src/permissions.ts`. Store
| 1 | VIEW_CHANNEL | See channel in list and read messages |
| 2 | MANAGE_CHANNELS | Create, edit, delete channels |
| 3 | MANAGE_ROLES | Create, edit, delete roles |
| 4 | MANAGE_SERVER | Edit server name, icon |
| 4 | MANAGE_SPACE | Edit space name, icon |
| 5 | CREATE_INVITE | Generate invite codes |
| 6 | KICK_MEMBERS | Remove members from server |
| 7 | BAN_MEMBERS | Ban members from server |
| 6 | KICK_MEMBERS | Remove members from space |
| 7 | BAN_MEMBERS | Ban members from space |
| 10 | SEND_MESSAGES | Post messages in text channels |
| 11 | MANAGE_MESSAGES | Delete other users' messages |
| 12 | ATTACH_FILES | Upload files to messages |
@@ -672,7 +672,7 @@ pnpm dev # Starts server (:3005) + Vite (:5173) with proxy
All core features are implemented and live:
- **Auth:** Registration (first user = admin), login, JWT sessions
- **Servers:** Create, join by invite, server settings, delete
- **Spaces:** Create, join by invite, space settings, delete
- **Channels:** Text, voice, video types with position ordering
- **Messaging:** Send, edit, delete, replies, attachments, reactions, typing indicators, read states
- **Permissions:** Full RBAC with roles, per-channel overrides, computed permissions
+13 -13
View File
@@ -29,7 +29,7 @@ function createTables(db: Database.Database): void {
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS servers (
CREATE TABLE IF NOT EXISTS spaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT,
@@ -38,17 +38,17 @@ function createTables(db: Database.Database): void {
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS server_members (
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
CREATE TABLE IF NOT EXISTS space_members (
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
nickname TEXT,
joined_at INTEGER NOT NULL,
PRIMARY KEY (server_id, user_id)
PRIMARY KEY (space_id, user_id)
);
CREATE TABLE IF NOT EXISTS channels (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL,
topic TEXT,
@@ -131,7 +131,7 @@ function createTables(db: Database.Database): void {
CREATE TABLE IF NOT EXISTS roles (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT DEFAULT '#b9bbbe',
position INTEGER DEFAULT 0,
@@ -140,10 +140,10 @@ function createTables(db: Database.Database): void {
);
CREATE TABLE IF NOT EXISTS member_roles (
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (server_id, user_id, role_id)
PRIMARY KEY (space_id, user_id, role_id)
);
CREATE TABLE IF NOT EXISTS channel_overrides (
@@ -163,7 +163,7 @@ function createTables(db: Database.Database): void {
PRIMARY KEY (user_id, channel_id)
);
CREATE TABLE IF NOT EXISTS server_folders (
CREATE TABLE IF NOT EXISTS space_folders (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT,
@@ -172,10 +172,10 @@ function createTables(db: Database.Database): void {
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS server_folder_members (
folder_id TEXT NOT NULL REFERENCES server_folders(id) ON DELETE CASCADE,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
PRIMARY KEY (folder_id, server_id)
CREATE TABLE IF NOT EXISTS 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,
PRIMARY KEY (folder_id, space_id)
);
CREATE TABLE IF NOT EXISTS instance_settings (
+24 -24
View File
@@ -73,7 +73,7 @@ export function runMigrations(db: Database.Database): void {
]
},
{
name: 'servers',
name: 'spaces',
columns: [
{ name: 'visibility', type: "TEXT DEFAULT 'private'" },
{ name: 'description', type: 'TEXT' }
@@ -113,7 +113,7 @@ export function runMigrations(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS join_requests (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
message TEXT,
status TEXT NOT NULL DEFAULT 'pending',
@@ -123,7 +123,7 @@ export function runMigrations(db: Database.Database): void {
);
`);
// ─── RBAC Migration: Ensure @everyone roles exist for all servers ─────────
// ─── RBAC Migration: Ensure @everyone roles exist for all spaces ─────────
migrateEveryoneRoles(db);
// ─── Instance settings: ensure default row exists ──────────────────────────
@@ -182,58 +182,58 @@ function migrateWorkerId(db: Database.Database): void {
}
}
/** For each server, ensure an @everyone role exists with id === server.id */
/** For each space, ensure an @everyone role exists with id === space.id */
function migrateEveryoneRoles(db: Database.Database): void {
const servers = db.prepare('SELECT id FROM servers').all() as { id: string }[];
const spaces = db.prepare('SELECT id FROM spaces').all() as { id: string }[];
const now = Date.now();
const defaultPerms = permissionsToString(DEFAULT_EVERYONE_PERMISSIONS);
const adminPerms = permissionsToString(ALL_PERMISSIONS);
const insertRole = db.prepare(
'INSERT OR IGNORE INTO roles (id, server_id, name, color, position, permissions, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
'INSERT OR IGNORE INTO roles (id, space_id, name, color, position, permissions, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
);
for (const server of servers) {
// Create @everyone role if it doesn't exist (id = server.id)
insertRole.run(server.id, server.id, '@everyone', '#b9bbbe', 0, defaultPerms, now);
for (const space of spaces) {
// Create @everyone role if it doesn't exist (id = space.id)
insertRole.run(space.id, space.id, '@everyone', '#b9bbbe', 0, defaultPerms, now);
}
// Migrate existing admin members: ensure an Admin role exists and assign it
// Only run if the old `role` column still exists on server_members
const smColumns = db.pragma('table_info(server_members)') as { name: string }[];
// Only run if the old `role` column still exists on space_members
const smColumns = db.pragma('table_info(space_members)') as { name: string }[];
const hasRoleColumn = smColumns.some(c => c.name === 'role');
const adminMembers = hasRoleColumn
? db.prepare("SELECT server_id, user_id FROM server_members WHERE role = 'admin'").all() as { server_id: string; user_id: string }[]
? db.prepare("SELECT space_id, user_id FROM space_members WHERE role = 'admin'").all() as { space_id: string; user_id: string }[]
: [];
if (adminMembers.length > 0) {
// Group by server
const serverAdmins = new Map<string, string[]>();
// Group by space
const spaceAdmins = new Map<string, string[]>();
for (const row of adminMembers) {
let arr = serverAdmins.get(row.server_id);
if (!arr) { arr = []; serverAdmins.set(row.server_id, arr); }
let arr = spaceAdmins.get(row.space_id);
if (!arr) { arr = []; spaceAdmins.set(row.space_id, arr); }
arr.push(row.user_id);
}
const checkAdminRole = db.prepare(
"SELECT id FROM roles WHERE server_id = ? AND name = 'Admin' AND permissions = ?"
"SELECT id FROM roles WHERE space_id = ? AND name = 'Admin' AND permissions = ?"
);
const insertMemberRole = db.prepare(
'INSERT OR IGNORE INTO member_roles (server_id, user_id, role_id) VALUES (?, ?, ?)'
'INSERT OR IGNORE INTO member_roles (space_id, user_id, role_id) VALUES (?, ?, ?)'
);
for (const [serverId, userIds] of serverAdmins) {
// Find or create Admin role for this server
let adminRole = checkAdminRole.get(serverId, adminPerms) as { id: string } | undefined;
for (const [spaceId, userIds] of spaceAdmins) {
// Find or create Admin role for this space
let adminRole = checkAdminRole.get(spaceId, adminPerms) as { id: string } | undefined;
if (!adminRole) {
// Generate a simple unique ID for the admin role
const adminRoleId = `${serverId}-admin`;
insertRole.run(adminRoleId, serverId, 'Admin', '#e74c3c', 1, adminPerms, now);
const adminRoleId = `${spaceId}-admin`;
insertRole.run(adminRoleId, spaceId, 'Admin', '#e74c3c', 1, adminPerms, now);
adminRole = { id: adminRoleId };
}
for (const userId of userIds) {
insertMemberRole.run(serverId, userId, adminRole.id);
insertMemberRole.run(spaceId, userId, adminRole.id);
}
}
}
+14 -14
View File
@@ -15,7 +15,7 @@ export const users = sqliteTable('users', {
createdAt: integer('created_at').notNull(),
});
export const servers = sqliteTable('servers', {
export const spaces = sqliteTable('spaces', {
id: text('id').primaryKey(),
name: text('name').notNull(),
icon: text('icon'),
@@ -26,18 +26,18 @@ export const servers = sqliteTable('servers', {
createdAt: integer('created_at').notNull(),
});
export const serverMembers = sqliteTable('server_members', {
serverId: text('server_id').notNull().references(() => servers.id, { onDelete: 'cascade' }),
export const spaceMembers = sqliteTable('space_members', {
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
nickname: text('nickname'),
joinedAt: integer('joined_at').notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.serverId, table.userId] }),
pk: primaryKey({ columns: [table.spaceId, table.userId] }),
}));
export const channels = sqliteTable('channels', {
id: text('id').primaryKey(),
serverId: text('server_id').notNull().references(() => servers.id, { onDelete: 'cascade' }),
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
type: text('type').notNull(),
topic: text('topic'),
@@ -129,7 +129,7 @@ export const dmReactions = sqliteTable('dm_reactions', {
export const roles = sqliteTable('roles', {
id: text('id').primaryKey(),
serverId: text('server_id').notNull().references(() => servers.id, { onDelete: 'cascade' }),
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
color: text('color').default('#b9bbbe'),
position: integer('position').default(0),
@@ -138,11 +138,11 @@ export const roles = sqliteTable('roles', {
});
export const memberRoles = sqliteTable('member_roles', {
serverId: text('server_id').notNull().references(() => servers.id, { onDelete: 'cascade' }),
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
roleId: text('role_id').notNull().references(() => roles.id, { onDelete: 'cascade' }),
}, (table) => ({
pk: primaryKey({ columns: [table.serverId, table.userId, table.roleId] }),
pk: primaryKey({ columns: [table.spaceId, table.userId, table.roleId] }),
}));
export const channelOverrides = sqliteTable('channel_overrides', {
@@ -164,7 +164,7 @@ export const readStates = sqliteTable('read_states', {
pk: primaryKey({ columns: [table.userId, table.channelId] }),
}));
export const serverFolders = sqliteTable('server_folders', {
export const spaceFolders = sqliteTable('space_folders', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name'),
@@ -173,11 +173,11 @@ export const serverFolders = sqliteTable('server_folders', {
createdAt: integer('created_at').notNull(),
});
export const serverFolderMembers = sqliteTable('server_folder_members', {
folderId: text('folder_id').notNull().references(() => serverFolders.id, { onDelete: 'cascade' }),
serverId: text('server_id').notNull().references(() => servers.id, { onDelete: 'cascade' }),
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' }),
}, (table) => ({
pk: primaryKey({ columns: [table.folderId, table.serverId] }),
pk: primaryKey({ columns: [table.folderId, table.spaceId] }),
}));
export const instanceSettings = sqliteTable('instance_settings', {
@@ -197,7 +197,7 @@ export const instanceSettings = sqliteTable('instance_settings', {
export const joinRequests = sqliteTable('join_requests', {
id: text('id').primaryKey(),
serverId: text('server_id').notNull().references(() => servers.id, { onDelete: 'cascade' }),
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
message: text('message'),
status: text('status').notNull().default('pending'),
+13 -13
View File
@@ -7,8 +7,8 @@ import { DEFAULT_EVERYONE_PERMISSIONS, permissionsToString } from '@backspace/sh
export async function seedDatabase(): Promise<void> {
const db = getDb();
const existingServers = db.select().from(schema.servers).all();
if (existingServers.length > 0) {
const existingSpaces = db.select().from(schema.spaces).all();
if (existingSpaces.length > 0) {
console.log('Database already has data, skipping seed');
return;
}
@@ -28,17 +28,17 @@ export async function seedDatabase(): Promise<void> {
createdAt: Date.now(),
}).run();
const serverId = generateSnowflake();
db.insert(schema.servers).values({
id: serverId,
const spaceId = generateSnowflake();
db.insert(schema.spaces).values({
id: spaceId,
name: 'Backspace',
ownerId: adminId,
inviteCode: 'backspace',
createdAt: Date.now(),
}).run();
db.insert(schema.serverMembers).values({
serverId: serverId,
db.insert(schema.spaceMembers).values({
spaceId: spaceId,
userId: adminId,
joinedAt: Date.now(),
}).run();
@@ -46,7 +46,7 @@ export async function seedDatabase(): Promise<void> {
const generalChannelId = generateSnowflake();
db.insert(schema.channels).values({
id: generalChannelId,
serverId: serverId,
spaceId: spaceId,
name: 'general',
type: 'text',
topic: 'General discussion',
@@ -57,17 +57,17 @@ export async function seedDatabase(): Promise<void> {
const voiceChannelId = generateSnowflake();
db.insert(schema.channels).values({
id: voiceChannelId,
serverId: serverId,
spaceId: spaceId,
name: 'General Voice',
type: 'voice',
position: 1,
createdAt: Date.now(),
}).run();
// Create @everyone role (id === serverId convention)
// Create @everyone role (id === spaceId convention)
db.insert(schema.roles).values({
id: serverId,
serverId: serverId,
id: spaceId,
spaceId: spaceId,
name: '@everyone',
color: '#b9bbbe',
position: 0,
@@ -76,6 +76,6 @@ export async function seedDatabase(): Promise<void> {
}).run();
console.log('Database seeded successfully');
console.log(` Default server: Backspace (invite code: backspace)`);
console.log(` Default space: Backspace (invite code: backspace)`);
console.log(` Admin user: admin / admin123`);
}
+2 -2
View File
@@ -9,7 +9,7 @@ import { getDb } from './db/index.js';
import { seedDatabase } from './db/seed.js';
import { authRoutes } from './routes/auth.js';
import { userRoutes } from './routes/users.js';
import { serverRoutes } from './routes/servers.js';
import { spaceRoutes } from './routes/spaces.js';
import { channelRoutes } from './routes/channels.js';
import { messageRoutes } from './routes/messages.js';
import { uploadRoutes } from './routes/uploads.js';
@@ -68,7 +68,7 @@ async function main(): Promise<void> {
await app.register(authRoutes);
await app.register(userRoutes);
await app.register(serverRoutes);
await app.register(spaceRoutes);
await app.register(channelRoutes);
await app.register(messageRoutes);
await app.register(uploadRoutes);
+43 -43
View File
@@ -3,7 +3,7 @@ import { eq, and } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, hasPermission, getChannelServerId, PermissionBits, computePermissions } from '../utils/permissions.js';
import { isMember, hasPermission, getChannelSpaceId, PermissionBits, computePermissions } from '../utils/permissions.js';
import { permissionsToString } from '@backspace/shared/src/permissions.js';
import { connectionManager } from '../ws/handler.js';
import type {
@@ -15,7 +15,7 @@ import type {
function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
return {
id: row.id,
serverId: row.serverId,
spaceId: row.spaceId,
name: row.name,
type: row.type as Channel['type'],
topic: row.topic,
@@ -25,57 +25,57 @@ function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
}
/**
* After a channel override changes, notify each server member:
* After a channel override changes, notify each space member:
* - VIEW_CHANNEL holders receive channel_updated (with their myPermissions)
* - Non-viewers receive channel_deleted to remove the channel from their UI
*/
function broadcastOverrideChange(serverId: string, channelId: string): void {
function broadcastOverrideChange(spaceId: string, channelId: string): void {
const db = getDb();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, channelId)).get();
if (!channel) return;
const channelData = rowToChannel(channel);
for (const [userId, serverIds] of connectionManager.getUserServerEntries()) {
if (!serverIds.has(serverId)) continue;
for (const [userId, spaceIds] of connectionManager.getUserSpaceEntries()) {
if (!spaceIds.has(spaceId)) continue;
const perms = computePermissions(userId, serverId, channelId);
const perms = computePermissions(userId, spaceId, channelId);
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
connectionManager.sendToUser(userId, {
type: 'channel_updated',
channel: { ...channelData, myPermissions: permissionsToString(perms) },
serverId,
spaceId,
});
} else {
connectionManager.sendToUser(userId, {
type: 'channel_deleted',
channelId,
serverId,
spaceId,
});
}
}
}
export async function channelRoutes(app: FastifyInstance): Promise<void> {
// GET /api/servers/:id/channels - List channels in a server
app.get<{ Params: { id: string } }>('/api/servers/:id/channels', {
// GET /api/spaces/:id/channels - List channels in a space
app.get<{ Params: { id: string } }>('/api/spaces/:id/channels', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
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 (!isMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
return reply.code(403).send({ error: 'You are not a member of this space', statusCode: 403 });
}
const allChannels = db.select()
.from(schema.channels)
.where(eq(schema.channels.serverId, id))
.where(eq(schema.channels.spaceId, id))
.all();
// Filter by VIEW_CHANNEL permission per channel
@@ -90,17 +90,17 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send(visibleChannels.map(rowToChannel));
});
// POST /api/servers/:id/channels - Create a channel (admin+)
app.post<{ Params: { id: string }; Body: CreateChannelRequest }>('/api/servers/:id/channels', {
// POST /api/spaces/:id/channels - Create a channel (admin+)
app.post<{ Params: { id: string }; Body: CreateChannelRequest }>('/api/spaces/:id/channels', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { name, type, topic } = request.body;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
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)) {
@@ -123,7 +123,7 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
// Get max position for ordering
const existingChannels = db.select()
.from(schema.channels)
.where(eq(schema.channels.serverId, id))
.where(eq(schema.channels.spaceId, id))
.all();
const maxPosition = existingChannels.reduce((max, ch) => Math.max(max, ch.position ?? 0), -1);
@@ -133,7 +133,7 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
db.insert(schema.channels).values({
id: channelId,
serverId: id,
spaceId: id,
name: trimmedName,
type,
topic: topic?.trim() || null,
@@ -148,11 +148,11 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
const channelData = rowToChannel(channel);
// Broadcast channel_created to all server members
connectionManager.sendToServer(id, {
// Broadcast channel_created to all space members
connectionManager.sendToSpace(id, {
type: 'channel_created',
channel: channelData,
serverId: id,
spaceId: id,
});
return reply.code(201).send(channelData);
@@ -171,8 +171,8 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
const serverId = channel.serverId;
if (!hasPermission(request.userId, serverId, PermissionBits.MANAGE_CHANNELS, id)) {
const spaceId = channel.spaceId;
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_CHANNELS, id)) {
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
}
@@ -211,10 +211,10 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
const channelData = rowToChannel(updated);
// Broadcast channel_updated to members with VIEW_CHANNEL
connectionManager.sendToChannel(serverId, id, {
connectionManager.sendToChannel(spaceId, id, {
type: 'channel_updated',
channel: channelData,
serverId,
spaceId,
});
return reply.code(200).send(channelData);
@@ -232,16 +232,16 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
const serverId = channel.serverId;
if (!hasPermission(request.userId, serverId, PermissionBits.MANAGE_CHANNELS, id)) {
const spaceId = channel.spaceId;
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_CHANNELS, id)) {
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
}
// Collect viewers BEFORE deleting (overrides CASCADE-delete with the channel)
const viewerIds: string[] = [];
for (const [uid, serverIds] of connectionManager.getUserServerEntries()) {
if (serverIds.has(serverId)) {
const perms = computePermissions(uid, serverId, id);
for (const [uid, spaceIds] of connectionManager.getUserSpaceEntries()) {
if (spaceIds.has(spaceId)) {
const perms = computePermissions(uid, spaceId, id);
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
viewerIds.push(uid);
}
@@ -253,7 +253,7 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
db.delete(schema.channels).where(eq(schema.channels.id, id)).run();
// Broadcast channel_deleted only to users who could see the channel
const deleteEvent = { type: 'channel_deleted' as const, channelId: id, serverId };
const deleteEvent = { type: 'channel_deleted' as const, channelId: id, spaceId };
for (const uid of viewerIds) {
connectionManager.sendToUser(uid, deleteEvent);
}
@@ -275,7 +275,7 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!hasPermission(request.userId, channel.serverId, PermissionBits.MANAGE_ROLES)) {
if (!hasPermission(request.userId, channel.spaceId, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
}
@@ -315,7 +315,7 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!hasPermission(request.userId, channel.serverId, PermissionBits.MANAGE_ROLES)) {
if (!hasPermission(request.userId, channel.spaceId, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
}
@@ -346,8 +346,8 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
}).run();
});
// Notify all server members of the permission change
broadcastOverrideChange(channel.serverId, id);
// Notify all space members of the permission change
broadcastOverrideChange(channel.spaceId, id);
return reply.code(200).send({ success: true });
});
@@ -365,7 +365,7 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!hasPermission(request.userId, channel.serverId, PermissionBits.MANAGE_ROLES)) {
if (!hasPermission(request.userId, channel.spaceId, PermissionBits.MANAGE_ROLES)) {
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
}
@@ -377,8 +377,8 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
)
).run();
// Notify all server members of the permission change
broadcastOverrideChange(channel.serverId, id);
// Notify all space members of the permission change
broadcastOverrideChange(channel.spaceId, id);
return reply.code(200).send({ success: true });
},
+113 -113
View File
@@ -3,14 +3,14 @@ import { eq, and, sql, inArray } from 'drizzle-orm';
import { getDb, getRawDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, isServerOwner, hasPermission, computePermissions, PermissionBits, permissionsToString } from '../utils/permissions.js';
import { isMember, isSpaceOwner, hasPermission, computePermissions, PermissionBits, permissionsToString } from '../utils/permissions.js';
import { connectionManager } from '../ws/handler.js';
import { sanitizeUser } from '../utils/sanitize.js';
import type {
ExploreServer,
ExploreSpace,
JoinRequest,
MemberWithUser,
ServerWithChannelsAndMembers,
SpaceWithChannelsAndMembers,
Channel,
} from '@backspace/shared';
@@ -20,7 +20,7 @@ function rowToJoinRequest(
): JoinRequest {
return {
id: row.id,
serverId: row.serverId,
spaceId: row.spaceId,
userId: row.userId,
message: row.message,
status: row.status as JoinRequest['status'],
@@ -32,22 +32,22 @@ function rowToJoinRequest(
}
/**
* Find all online users for a server who have MANAGE_SERVER or are the owner.
* Find all online users for a space who have MANAGE_SPACE or are the owner.
* Used to route join_request_received events.
*/
function getServerManagers(serverId: string): string[] {
function getSpaceManagers(spaceId: string): string[] {
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get();
if (!server) return [];
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, spaceId)).get();
if (!space) return [];
const memberRows = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.serverId, serverId))
.from(schema.spaceMembers)
.where(eq(schema.spaceMembers.spaceId, spaceId))
.all();
const managers: string[] = [];
for (const member of memberRows) {
if (member.userId === server.ownerId || hasPermission(member.userId, serverId, PermissionBits.MANAGE_SERVER)) {
if (member.userId === space.ownerId || hasPermission(member.userId, spaceId, PermissionBits.MANAGE_SPACE)) {
managers.push(member.userId);
}
}
@@ -55,28 +55,28 @@ function getServerManagers(serverId: string): string[] {
}
/**
* Build a full ServerWithChannelsAndMembers for a newly joined user.
* Build a full SpaceWithChannelsAndMembers for a newly joined user.
* Used after accepting a join request or public join.
*/
function buildFullServer(serverId: string, forUserId: string): ServerWithChannelsAndMembers | null {
function buildFullSpace(spaceId: string, forUserId: string): SpaceWithChannelsAndMembers | null {
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get();
if (!server) return null;
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, spaceId)).get();
if (!space) return null;
const channels = db.select()
.from(schema.channels)
.where(eq(schema.channels.serverId, serverId))
.where(eq(schema.channels.spaceId, spaceId))
.all();
const roles = db.select()
.from(schema.roles)
.where(eq(schema.roles.serverId, serverId))
.where(eq(schema.roles.spaceId, spaceId))
.orderBy(schema.roles.position)
.all();
const memberRows = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.serverId, serverId))
.from(schema.spaceMembers)
.where(eq(schema.spaceMembers.spaceId, spaceId))
.all();
const memberUserIds = memberRows.map(m => m.userId);
@@ -87,7 +87,7 @@ function buildFullServer(serverId: string, forUserId: string): ServerWithChannel
const memberRoleRows = db.select()
.from(schema.memberRoles)
.where(eq(schema.memberRoles.serverId, serverId))
.where(eq(schema.memberRoles.spaceId, spaceId))
.all();
const members: MemberWithUser[] = memberRows
@@ -103,7 +103,7 @@ function buildFullServer(serverId: string, forUserId: string): ServerWithChannel
.filter(r => assignedRoleIds.includes(r.id))
.map(r => ({
id: r.id,
serverId: r.serverId,
spaceId: r.spaceId,
name: r.name,
color: r.color ?? '#b9bbbe',
position: r.position ?? 0,
@@ -111,7 +111,7 @@ function buildFullServer(serverId: string, forUserId: string): ServerWithChannel
}));
return {
serverId: m.serverId,
spaceId: m.spaceId,
userId: m.userId,
nickname: m.nickname,
joinedAt: m.joinedAt,
@@ -121,16 +121,16 @@ function buildFullServer(serverId: string, forUserId: string): ServerWithChannel
})
.filter((m): m is MemberWithUser => m !== null);
const serverPerms = computePermissions(forUserId, serverId);
const spacePerms = computePermissions(forUserId, spaceId);
const visibleChannels: Channel[] = [];
for (const ch of channels) {
const chPerms = computePermissions(forUserId, serverId, ch.id);
const chPerms = computePermissions(forUserId, spaceId, ch.id);
const hasView = (chPerms & PermissionBits.VIEW_CHANNEL) !== 0n || (chPerms & PermissionBits.ADMINISTRATOR) !== 0n;
if (hasView) {
visibleChannels.push({
id: ch.id,
serverId: ch.serverId,
spaceId: ch.spaceId,
name: ch.name,
type: ch.type as Channel['type'],
topic: ch.topic,
@@ -142,34 +142,34 @@ function buildFullServer(serverId: string, forUserId: string): ServerWithChannel
}
return {
id: server.id,
name: server.name,
icon: server.icon,
ownerId: server.ownerId,
inviteCode: server.inviteCode,
visibility: (server.visibility ?? 'private') as ServerWithChannelsAndMembers['visibility'],
description: server.description ?? null,
createdAt: server.createdAt,
id: space.id,
name: space.name,
icon: space.icon,
ownerId: space.ownerId,
inviteCode: space.inviteCode,
visibility: (space.visibility ?? 'private') as SpaceWithChannelsAndMembers['visibility'],
description: space.description ?? null,
createdAt: space.createdAt,
channels: visibleChannels,
members,
roles: roles.map(r => ({
id: r.id,
serverId: r.serverId,
spaceId: r.spaceId,
name: r.name,
color: r.color ?? '#b9bbbe',
position: r.position ?? 0,
permissions: r.permissions ?? undefined,
isEveryone: r.id === serverId,
isEveryone: r.id === spaceId,
createdAt: r.createdAt,
})),
myPermissions: permissionsToString(serverPerms),
myPermissions: permissionsToString(spacePerms),
};
}
export async function exploreRoutes(app: FastifyInstance): Promise<void> {
// GET /api/servers/explore — list discoverable servers
app.get<{ Querystring: { q?: string; limit?: string; offset?: string } }>('/api/servers/explore', {
// GET /api/spaces/explore — list discoverable spaces
app.get<{ Querystring: { q?: string; limit?: string; offset?: string } }>('/api/spaces/explore', {
preHandler: authenticate,
}, async (request, reply) => {
const db = getDb();
@@ -177,35 +177,35 @@ export async function exploreRoutes(app: FastifyInstance): Promise<void> {
// Check instance-level discovery toggle
const settings = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
if (!settings || settings.discoveryEnabled === 0) {
return reply.code(200).send({ servers: [], total: 0, discoveryEnabled: false });
return reply.code(200).send({ spaces: [], total: 0, discoveryEnabled: false });
}
const q = request.query.q?.trim() ?? '';
const limit = Math.min(Math.max(parseInt(request.query.limit ?? '50', 10) || 50, 1), 100);
const offset = Math.max(parseInt(request.query.offset ?? '0', 10) || 0, 0);
// Get user's current server memberships to exclude
const myMemberships = db.select({ serverId: schema.serverMembers.serverId })
.from(schema.serverMembers)
.where(eq(schema.serverMembers.userId, request.userId))
// Get user's current space memberships to exclude
const myMemberships = db.select({ spaceId: schema.spaceMembers.spaceId })
.from(schema.spaceMembers)
.where(eq(schema.spaceMembers.userId, request.userId))
.all();
const myServerIds = new Set(myMemberships.map(m => m.serverId));
const mySpaceIds = new Set(myMemberships.map(m => m.spaceId));
// Build raw SQL query for explore — we need a LEFT JOIN for member count
// which is more efficient to do with raw SQL
const rawDb = getRawDb();
// Count ALL discoverable servers (including ones the user has joined) for context
// Count ALL discoverable spaces (including ones the user has joined) for context
const totalAllRow = rawDb.prepare(
`SELECT COUNT(DISTINCT s.id) as total FROM servers s WHERE s.visibility IN ('public', 'request')`
`SELECT COUNT(DISTINCT s.id) as total FROM spaces s WHERE s.visibility IN ('public', 'request')`
).get() as { total: number };
let countSql = `SELECT COUNT(DISTINCT s.id) as total FROM servers s WHERE s.visibility IN ('public', 'request')`;
let countSql = `SELECT COUNT(DISTINCT s.id) as total FROM spaces s WHERE s.visibility IN ('public', 'request')`;
let querySql = `
SELECT s.id, s.name, s.icon, s.description, s.visibility, s.created_at,
COUNT(sm.user_id) as member_count
FROM servers s
LEFT JOIN server_members sm ON sm.server_id = s.id
FROM spaces s
LEFT JOIN space_members sm ON sm.space_id = s.id
WHERE s.visibility IN ('public', 'request')
`;
@@ -231,79 +231,79 @@ export async function exploreRoutes(app: FastifyInstance): Promise<void> {
member_count: number;
}[];
const servers: ExploreServer[] = rows.map(r => ({
const spaces: ExploreSpace[] = rows.map(r => ({
id: r.id,
name: r.name,
icon: r.icon,
description: r.description,
visibility: r.visibility as ExploreServer['visibility'],
visibility: r.visibility as ExploreSpace['visibility'],
memberCount: r.member_count,
createdAt: r.created_at,
joined: myServerIds.has(r.id),
joined: mySpaceIds.has(r.id),
}));
return reply.code(200).send({ servers, total: totalRow.total, totalAll: totalAllRow.total, discoveryEnabled: true });
return reply.code(200).send({ spaces, total: totalRow.total, totalAll: totalAllRow.total, discoveryEnabled: true });
});
// POST /api/servers/:id/public-join — join a public server without invite
app.post<{ Params: { id: string } }>('/api/servers/:id/public-join', {
// POST /api/spaces/:id/public-join — join a public space without invite
app.post<{ Params: { id: string } }>('/api/spaces/:id/public-join', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
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 (server.visibility !== 'public') {
return reply.code(403).send({ error: 'This server does not allow public joins', statusCode: 403 });
if (space.visibility !== 'public') {
return reply.code(403).send({ error: 'This space does not allow public joins', statusCode: 403 });
}
if (isMember(id, request.userId)) {
return reply.code(409).send({ error: 'You are already a member of this server', statusCode: 409 });
return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 });
}
const now = Date.now();
db.insert(schema.serverMembers).values({
serverId: id,
db.insert(schema.spaceMembers).values({
spaceId: id,
userId: request.userId,
joinedAt: now,
}).run();
// Register in connectionManager for WS broadcasts
connectionManager.addUserServer(request.userId, id);
connectionManager.addUserSpace(request.userId, id);
// Broadcast member_joined to existing server members
// Broadcast member_joined to existing space members
const joiningUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (joiningUser) {
const memberPayload: MemberWithUser = {
serverId: id,
spaceId: id,
userId: request.userId,
nickname: null,
joinedAt: now,
user: sanitizeUser(joiningUser),
roles: [],
};
connectionManager.sendToServer(id, {
connectionManager.sendToSpace(id, {
type: 'member_joined',
serverId: id,
spaceId: id,
member: memberPayload,
});
}
// Return full server data
const fullServer = buildFullServer(id, request.userId);
if (!fullServer) {
return reply.code(500).send({ error: 'Failed to load server', statusCode: 500 });
// Return full space data
const fullSpace = buildFullSpace(id, request.userId);
if (!fullSpace) {
return reply.code(500).send({ error: 'Failed to load space', statusCode: 500 });
}
return reply.code(200).send(fullServer);
return reply.code(200).send(fullSpace);
});
// POST /api/servers/:id/request-join — request to join a request-only server
app.post<{ Params: { id: string }; Body: { message?: string } }>('/api/servers/:id/request-join', {
// POST /api/spaces/:id/request-join — request to join a request-only space
app.post<{ Params: { id: string }; Body: { message?: string } }>('/api/spaces/:id/request-join', {
preHandler: authenticate,
config: {
rateLimit: {
@@ -315,30 +315,30 @@ export async function exploreRoutes(app: FastifyInstance): Promise<void> {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
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 (server.visibility !== 'request') {
return reply.code(403).send({ error: 'This server does not accept join requests', statusCode: 403 });
if (space.visibility !== 'request') {
return reply.code(403).send({ error: 'This space does not accept join requests', statusCode: 403 });
}
if (isMember(id, request.userId)) {
return reply.code(409).send({ error: 'You are already a member of this server', statusCode: 409 });
return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 });
}
// Check for existing pending request
const existingRequest = db.select().from(schema.joinRequests)
.where(and(
eq(schema.joinRequests.serverId, id),
eq(schema.joinRequests.spaceId, id),
eq(schema.joinRequests.userId, request.userId),
eq(schema.joinRequests.status, 'pending'),
))
.get();
if (existingRequest) {
return reply.code(409).send({ error: 'You already have a pending request for this server', statusCode: 409 });
return reply.code(409).send({ error: 'You already have a pending request for this space', statusCode: 409 });
}
const requestId = generateSnowflake();
@@ -347,7 +347,7 @@ export async function exploreRoutes(app: FastifyInstance): Promise<void> {
db.insert(schema.joinRequests).values({
id: requestId,
serverId: id,
spaceId: id,
userId: request.userId,
message: msgText,
status: 'pending',
@@ -360,8 +360,8 @@ export async function exploreRoutes(app: FastifyInstance): Promise<void> {
joiningUser ? sanitizeUser(joiningUser) : undefined,
);
// Send WS event to server managers (owner + MANAGE_SERVER users)
const managers = getServerManagers(id);
// Send WS event to space managers (owner + MANAGE_SPACE users)
const managers = getSpaceManagers(id);
for (const managerId of managers) {
connectionManager.sendToUser(managerId, {
type: 'join_request_received',
@@ -372,26 +372,26 @@ export async function exploreRoutes(app: FastifyInstance): Promise<void> {
return reply.code(201).send(joinRequest);
});
// GET /api/servers/:id/join-requests — list join requests for a server
app.get<{ Params: { id: string }; Querystring: { status?: string } }>('/api/servers/:id/join-requests', {
// GET /api/spaces/:id/join-requests — list join requests for a space
app.get<{ Params: { id: string }; Querystring: { status?: string } }>('/api/spaces/:id/join-requests', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
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 (!isServerOwner(id, request.userId) && !hasPermission(request.userId, id, PermissionBits.MANAGE_SERVER)) {
return reply.code(403).send({ error: 'Missing MANAGE_SERVER permission', statusCode: 403 });
if (!isSpaceOwner(id, request.userId) && !hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
}
const statusFilter = request.query.status ?? 'pending';
const rows = db.select().from(schema.joinRequests)
.where(and(
eq(schema.joinRequests.serverId, id),
eq(schema.joinRequests.spaceId, id),
eq(schema.joinRequests.status, statusFilter),
))
.all();
@@ -408,8 +408,8 @@ export async function exploreRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send({ requests });
});
// PATCH /api/servers/:id/join-requests/:requestId — accept or decline
app.patch<{ Params: { id: string; requestId: string }; Body: { action: 'accept' | 'decline' } }>('/api/servers/:id/join-requests/:requestId', {
// PATCH /api/spaces/:id/join-requests/:requestId — accept or decline
app.patch<{ Params: { id: string; requestId: string }; Body: { action: 'accept' | 'decline' } }>('/api/spaces/:id/join-requests/:requestId', {
preHandler: authenticate,
}, async (request, reply) => {
const { id, requestId } = request.params;
@@ -420,17 +420,17 @@ export async function exploreRoutes(app: FastifyInstance): Promise<void> {
return reply.code(400).send({ error: 'Action must be "accept" or "decline"', statusCode: 400 });
}
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
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 (!isServerOwner(id, request.userId) && !hasPermission(request.userId, id, PermissionBits.MANAGE_SERVER)) {
return reply.code(403).send({ error: 'Missing MANAGE_SERVER permission', statusCode: 403 });
if (!isSpaceOwner(id, request.userId) && !hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
}
const joinReq = db.select().from(schema.joinRequests).where(eq(schema.joinRequests.id, requestId)).get();
if (!joinReq || joinReq.serverId !== id) {
if (!joinReq || joinReq.spaceId !== id) {
return reply.code(404).send({ error: 'Join request not found', statusCode: 404 });
}
@@ -443,8 +443,8 @@ export async function exploreRoutes(app: FastifyInstance): Promise<void> {
if (action === 'accept') {
// Insert member and update request atomically
db.transaction((tx) => {
tx.insert(schema.serverMembers).values({
serverId: id,
tx.insert(schema.spaceMembers).values({
spaceId: id,
userId: joinReq.userId,
joinedAt: now,
}).run();
@@ -457,38 +457,38 @@ export async function exploreRoutes(app: FastifyInstance): Promise<void> {
});
// Register in connectionManager
connectionManager.addUserServer(joinReq.userId, id);
connectionManager.addUserSpace(joinReq.userId, id);
// Broadcast member_joined to server
// Broadcast member_joined to space
const joiningUser = db.select().from(schema.users).where(eq(schema.users.id, joinReq.userId)).get();
if (joiningUser) {
const memberPayload: MemberWithUser = {
serverId: id,
spaceId: id,
userId: joinReq.userId,
nickname: null,
joinedAt: now,
user: sanitizeUser(joiningUser),
roles: [],
};
connectionManager.sendToServer(id, {
connectionManager.sendToSpace(id, {
type: 'member_joined',
serverId: id,
spaceId: id,
member: memberPayload,
});
}
// Build full server for the accepted user
const fullServer = buildFullServer(id, joinReq.userId);
// Build full space for the accepted user
const fullSpace = buildFullSpace(id, joinReq.userId);
const updatedRow = db.select().from(schema.joinRequests).where(eq(schema.joinRequests.id, requestId)).get()!;
const updatedRequest = rowToJoinRequest(updatedRow, joiningUser ? sanitizeUser(joiningUser) : undefined);
// Send join_request_accepted to the requesting user
if (fullServer) {
if (fullSpace) {
connectionManager.sendToUser(joinReq.userId, {
type: 'join_request_accepted',
request: updatedRequest,
server: fullServer,
space: fullSpace,
});
}
+5 -5
View File
@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
import { AccessToken } from 'livekit-server-sdk';
import { authenticate } from '../utils/auth.js';
import { config } from '../config.js';
import { getChannelServerId, hasPermission, isDmMember, PermissionBits } from '../utils/permissions.js';
import { getChannelSpaceId, hasPermission, isDmMember, PermissionBits } from '../utils/permissions.js';
import type { LiveKitTokenRequest, LiveKitTokenResponse } from '@backspace/shared';
export async function livekitRoutes(app: FastifyInstance): Promise<void> {
@@ -25,12 +25,12 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
}
roomName = `dm-${dmChannelId}`;
} else if (channelId && typeof channelId === 'string') {
// Server voice channel token
const serverId = getChannelServerId(channelId);
if (!serverId) {
// Space voice channel token
const spaceId = getChannelSpaceId(channelId);
if (!spaceId) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!hasPermission(request.userId, serverId, PermissionBits.CONNECT, channelId)) {
if (!hasPermission(request.userId, spaceId, PermissionBits.CONNECT, channelId)) {
return reply.code(403).send({ error: 'Missing CONNECT permission', statusCode: 403 });
}
roomName = channelId;
+15 -15
View File
@@ -3,7 +3,7 @@ import { eq, and, desc, lt, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { hasPermission, getChannelServerId, PermissionBits } from '../utils/permissions.js';
import { hasPermission, getChannelSpaceId, PermissionBits } from '../utils/permissions.js';
import { connectionManager } from '../ws/handler.js';
import type {
CreateMessageRequest,
@@ -158,12 +158,12 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
const before = request.query.before;
const limit = Math.min(Math.max(Number(request.query.limit) || 50, 1), 100);
const serverId = getChannelServerId(id);
if (!serverId) {
const spaceId = getChannelSpaceId(id);
if (!spaceId) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!hasPermission(request.userId, serverId, PermissionBits.VIEW_CHANNEL | PermissionBits.READ_MESSAGE_HISTORY, id)) {
if (!hasPermission(request.userId, spaceId, PermissionBits.VIEW_CHANNEL | PermissionBits.READ_MESSAGE_HISTORY, id)) {
return reply.code(403).send({ error: 'Missing VIEW_CHANNEL or READ_MESSAGE_HISTORY permission', statusCode: 403 });
}
@@ -251,12 +251,12 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
const { id } = request.params;
const { content, attachments: attachmentIds, replyToId } = request.body;
const serverId = getChannelServerId(id);
if (!serverId) {
const spaceId = getChannelSpaceId(id);
if (!spaceId) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
if (!hasPermission(request.userId, serverId, PermissionBits.SEND_MESSAGES, id)) {
if (!hasPermission(request.userId, spaceId, PermissionBits.SEND_MESSAGES, id)) {
return reply.code(403).send({ error: 'Missing SEND_MESSAGES permission', statusCode: 403 });
}
@@ -315,7 +315,7 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
const messageWithUser = buildMessageWithUser(message, user, attachmentRows, [], replyTo);
// Broadcast via WebSocket
connectionManager.sendToServer(serverId, {
connectionManager.sendToSpace(spaceId, {
type: 'message_created',
message: messageWithUser,
});
@@ -377,9 +377,9 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
const messageWithUser = buildMessageWithUser(updatedMessage, user, attachmentRows, reactions, replyTo);
// Broadcast edit
const serverId = getChannelServerId(message.channelId);
if (serverId) {
connectionManager.sendToServer(serverId, {
const spaceId = getChannelSpaceId(message.channelId);
if (spaceId) {
connectionManager.sendToSpace(spaceId, {
type: 'message_updated',
message: messageWithUser,
});
@@ -400,13 +400,13 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
}
const serverId = getChannelServerId(message.channelId);
if (!serverId) {
const spaceId = getChannelSpaceId(message.channelId);
if (!spaceId) {
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
}
const isAuthor = message.userId === request.userId;
const canManageMessages = hasPermission(request.userId, serverId, PermissionBits.MANAGE_MESSAGES, message.channelId);
const canManageMessages = hasPermission(request.userId, spaceId, PermissionBits.MANAGE_MESSAGES, message.channelId);
if (!isAuthor && !canManageMessages) {
return reply.code(403).send({ error: 'You cannot delete this message', statusCode: 403 });
@@ -419,7 +419,7 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
});
// Broadcast deletion
connectionManager.sendToServer(serverId, {
connectionManager.sendToSpace(spaceId, {
type: 'message_deleted',
messageId: id,
channelId: message.channelId,
@@ -3,31 +3,31 @@ import { eq, and, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, isServerOwner, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
import { isMember, isSpaceOwner, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
import { DEFAULT_EVERYONE_PERMISSIONS, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
import crypto from 'crypto';
import { connectionManager } from '../ws/handler.js';
import type {
CreateServerRequest,
UpdateServerRequest,
JoinServerRequest,
CreateSpaceRequest,
UpdateSpaceRequest,
JoinSpaceRequest,
UpdateMemberRequest,
Server,
Space,
Channel,
MemberWithUser,
ServerWithChannelsAndMembers,
SpaceWithChannelsAndMembers,
Role,
} from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js';
function rowToServer(row: typeof schema.servers.$inferSelect): Server {
function rowToSpace(row: typeof schema.spaces.$inferSelect): Space {
return {
id: row.id,
name: row.name,
icon: row.icon,
ownerId: row.ownerId,
inviteCode: row.inviteCode,
visibility: (row.visibility ?? 'private') as Server['visibility'],
visibility: (row.visibility ?? 'private') as Space['visibility'],
description: row.description ?? null,
createdAt: row.createdAt,
};
@@ -36,7 +36,7 @@ function rowToServer(row: typeof schema.servers.$inferSelect): Server {
function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
return {
id: row.id,
serverId: row.serverId,
spaceId: row.spaceId,
name: row.name,
type: row.type as Channel['type'],
topic: row.topic,
@@ -49,32 +49,32 @@ function generateInviteCode(): string {
return crypto.randomBytes(4).toString('hex');
}
export async function serverRoutes(app: FastifyInstance): Promise<void> {
// POST /api/servers - Create a new server
app.post<{ Body: CreateServerRequest }>('/api/servers', {
export async function spaceRoutes(app: FastifyInstance): Promise<void> {
// POST /api/spaces - Create a new server
app.post<{ Body: CreateSpaceRequest }>('/api/spaces', {
preHandler: authenticate,
}, async (request, reply) => {
const { name, icon } = request.body;
if (!name || typeof name !== 'string') {
return reply.code(400).send({ error: 'Server name is required', statusCode: 400 });
return reply.code(400).send({ error: 'Space name is required', statusCode: 400 });
}
const trimmedName = name.trim();
if (trimmedName.length < 1 || trimmedName.length > 100) {
return reply.code(400).send({ error: 'Server name must be between 1 and 100 characters', statusCode: 400 });
return reply.code(400).send({ error: 'Space name must be between 1 and 100 characters', statusCode: 400 });
}
const db = getDb();
const serverId = generateSnowflake();
const spaceId = generateSnowflake();
const channelId = generateSnowflake();
const now = Date.now();
const inviteCode = generateInviteCode();
// Create server, owner membership, default channel, and @everyone role atomically
db.transaction((tx) => {
tx.insert(schema.servers).values({
id: serverId,
tx.insert(schema.spaces).values({
id: spaceId,
name: trimmedName,
icon: icon ?? null,
ownerId: request.userId,
@@ -82,25 +82,25 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
createdAt: now,
}).run();
tx.insert(schema.serverMembers).values({
serverId,
tx.insert(schema.spaceMembers).values({
spaceId,
userId: request.userId,
joinedAt: now,
}).run();
tx.insert(schema.channels).values({
id: channelId,
serverId,
spaceId,
name: 'general',
type: 'text',
position: 0,
createdAt: now,
}).run();
// Auto-create @everyone role (id = serverId)
// Auto-create @everyone role (id = spaceId)
tx.insert(schema.roles).values({
id: serverId,
serverId,
id: spaceId,
spaceId,
name: '@everyone',
color: '#b9bbbe',
position: 0,
@@ -109,68 +109,68 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
}).run();
});
const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get();
const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, spaceId)).get();
if (!server) {
return reply.code(500).send({ error: 'Failed to create server', statusCode: 500 });
return reply.code(500).send({ error: 'Failed to create space', statusCode: 500 });
}
return reply.code(201).send(rowToServer(server));
return reply.code(201).send(rowToSpace(server));
});
// GET /api/servers - List user's servers
app.get('/api/servers', {
// GET /api/spaces - List user's servers
app.get('/api/spaces', {
preHandler: authenticate,
}, async (request, reply) => {
const db = getDb();
const memberships = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.userId, request.userId))
.from(schema.spaceMembers)
.where(eq(schema.spaceMembers.userId, request.userId))
.all();
if (memberships.length === 0) {
return reply.code(200).send([]);
}
const serverIds = memberships.map(m => m.serverId);
const spaceIds = memberships.map(m => m.spaceId);
const servers = db.select()
.from(schema.servers)
.where(inArray(schema.servers.id, serverIds))
.from(schema.spaces)
.where(inArray(schema.spaces.id, spaceIds))
.all();
return reply.code(200).send(servers.map(rowToServer));
return reply.code(200).send(servers.map(rowToSpace));
});
// GET /api/servers/:id - Get server detail with channels and members
app.get<{ Params: { id: string } }>('/api/servers/:id', {
// GET /api/spaces/:id - Get server detail with channels and members
app.get<{ Params: { id: string } }>('/api/spaces/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
}
if (!isMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
return reply.code(403).send({ error: 'You are not a member of this space', statusCode: 403 });
}
const channels = db.select()
.from(schema.channels)
.where(eq(schema.channels.serverId, id))
.where(eq(schema.channels.spaceId, id))
.all();
const roles = db.select()
.from(schema.roles)
.where(eq(schema.roles.serverId, id))
.where(eq(schema.roles.spaceId, id))
.orderBy(schema.roles.position)
.all();
const memberRows = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.serverId, id))
.from(schema.spaceMembers)
.where(eq(schema.spaceMembers.spaceId, id))
.all();
const memberUserIds = memberRows.map(m => m.userId);
@@ -182,7 +182,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
const memberRoleRows = db.select()
.from(schema.memberRoles)
.where(eq(schema.memberRoles.serverId, id))
.where(eq(schema.memberRoles.spaceId, id))
.all();
const members: MemberWithUser[] = memberRows
@@ -198,7 +198,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
.filter(r => assignedRoleIds.includes(r.id))
.map(r => ({
id: r.id,
serverId: r.serverId,
spaceId: r.spaceId,
name: r.name,
color: r.color ?? '#b9bbbe',
position: r.position ?? 0,
@@ -206,7 +206,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
}));
return {
serverId: m.serverId,
spaceId: m.spaceId,
userId: m.userId,
nickname: m.nickname,
joinedAt: m.joinedAt,
@@ -222,13 +222,13 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
return (perms & PermissionBits.VIEW_CHANNEL) !== 0n;
});
const result: ServerWithChannelsAndMembers = {
...rowToServer(server),
const result: SpaceWithChannelsAndMembers = {
...rowToSpace(server),
channels: visibleChannels.map(rowToChannel),
members,
roles: roles.map(r => ({
id: r.id,
serverId: r.serverId,
spaceId: r.spaceId,
name: r.name,
color: r.color ?? '#b9bbbe',
position: r.position ?? 0,
@@ -239,29 +239,29 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send(result);
});
// PATCH /api/servers/:id - Update server (owner only)
app.patch<{ Params: { id: string }; Body: UpdateServerRequest }>('/api/servers/:id', {
// PATCH /api/spaces/:id - Update server (owner only)
app.patch<{ Params: { id: string }; Body: UpdateSpaceRequest }>('/api/spaces/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { name, icon, visibility, description } = request.body;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
}
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SERVER)) {
return reply.code(403).send({ error: 'Missing MANAGE_SERVER permission', statusCode: 403 });
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
}
const updates: Partial<typeof schema.servers.$inferInsert> = {};
const updates: Partial<typeof schema.spaces.$inferInsert> = {};
if (name !== undefined) {
const trimmedName = name.trim();
if (trimmedName.length < 1 || trimmedName.length > 100) {
return reply.code(400).send({ error: 'Server name must be between 1 and 100 characters', statusCode: 400 });
return reply.code(400).send({ error: 'Space name must be between 1 and 100 characters', statusCode: 400 });
}
updates.name = trimmedName;
}
@@ -287,60 +287,60 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
}
db.update(schema.servers).set(updates).where(eq(schema.servers.id, id)).run();
db.update(schema.spaces).set(updates).where(eq(schema.spaces.id, id)).run();
const updated = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
const updated = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
if (!updated) {
return reply.code(500).send({ error: 'Failed to update server', statusCode: 500 });
return reply.code(500).send({ error: 'Failed to update space', statusCode: 500 });
}
const serverData = rowToServer(updated);
const spaceData = rowToSpace(updated);
// Broadcast server_updated to all server members
connectionManager.sendToServer(id, {
type: 'server_updated',
server: serverData,
// Broadcast space_updated to all space members
connectionManager.sendToSpace(id, {
type: 'space_updated',
space: spaceData,
});
return reply.code(200).send(serverData);
return reply.code(200).send(spaceData);
});
// DELETE /api/servers/:id - Delete server (owner only)
app.delete<{ Params: { id: string } }>('/api/servers/:id', {
// DELETE /api/spaces/:id - Delete server (owner only)
app.delete<{ Params: { id: string } }>('/api/spaces/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
}
if (!isServerOwner(id, request.userId)) {
return reply.code(403).send({ error: 'Only the server owner can delete the server', statusCode: 403 });
if (!isSpaceOwner(id, request.userId)) {
return reply.code(403).send({ error: 'Only the space owner can delete the space', statusCode: 403 });
}
// Delete all channels (messages cascade), members, then server atomically
db.transaction((tx) => {
tx.delete(schema.channels).where(eq(schema.channels.serverId, id)).run();
tx.delete(schema.serverMembers).where(eq(schema.serverMembers.serverId, id)).run();
tx.delete(schema.servers).where(eq(schema.servers.id, id)).run();
tx.delete(schema.channels).where(eq(schema.channels.spaceId, id)).run();
tx.delete(schema.spaceMembers).where(eq(schema.spaceMembers.spaceId, id)).run();
tx.delete(schema.spaces).where(eq(schema.spaces.id, id)).run();
});
return reply.code(200).send({ success: true });
});
// POST /api/servers/:id/invite - Generate invite code (admin+)
app.post<{ Params: { id: string } }>('/api/servers/:id/invite', {
// POST /api/spaces/:id/invite - Generate invite code (admin+)
app.post<{ Params: { id: string } }>('/api/spaces/:id/invite', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
}
if (!hasPermission(request.userId, id, PermissionBits.CREATE_INVITE)) {
@@ -353,13 +353,13 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
}
const inviteCode = generateInviteCode();
db.update(schema.servers).set({ inviteCode }).where(eq(schema.servers.id, id)).run();
db.update(schema.spaces).set({ inviteCode }).where(eq(schema.spaces.id, id)).run();
return reply.code(200).send({ inviteCode });
});
// POST /api/servers/:id/join - Join server by invite code
app.post<{ Params: { id: string }; Body: JoinServerRequest }>('/api/servers/:id/join', {
// POST /api/spaces/:id/join - Join server by invite code
app.post<{ Params: { id: string }; Body: JoinSpaceRequest }>('/api/spaces/:id/join', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
@@ -371,9 +371,9 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
}
if (server.inviteCode !== inviteCode) {
@@ -381,42 +381,42 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
}
if (isMember(id, request.userId)) {
return reply.code(409).send({ error: 'You are already a member of this server', statusCode: 409 });
return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 });
}
const now = Date.now();
db.insert(schema.serverMembers).values({
serverId: id,
db.insert(schema.spaceMembers).values({
spaceId: id,
userId: request.userId,
joinedAt: now,
}).run();
// Register the user in connectionManager so they receive WS broadcasts for this server
connectionManager.addUserServer(request.userId, id);
connectionManager.addUserSpace(request.userId, id);
// Broadcast member_joined to existing server members
const joiningUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (joiningUser) {
const memberPayload: MemberWithUser = {
serverId: id,
spaceId: id,
userId: request.userId,
nickname: null,
joinedAt: now,
user: sanitizeUser(joiningUser),
roles: [],
};
connectionManager.sendToServer(id, {
connectionManager.sendToSpace(id, {
type: 'member_joined',
serverId: id,
spaceId: id,
member: memberPayload,
});
}
return reply.code(200).send(rowToServer(server));
return reply.code(200).send(rowToSpace(server));
});
// POST /api/servers/join - Join server by invite code (no server ID needed)
app.post<{ Body: JoinServerRequest }>('/api/servers/join', {
// POST /api/spaces/join - Join server by invite code (no server ID needed)
app.post<{ Body: JoinSpaceRequest }>('/api/spaces/join', {
preHandler: authenticate,
}, async (request, reply) => {
const { inviteCode } = request.body;
@@ -427,65 +427,65 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.inviteCode, inviteCode)).get();
const server = db.select().from(schema.spaces).where(eq(schema.spaces.inviteCode, inviteCode)).get();
if (!server) {
return reply.code(404).send({ error: 'Invalid invite code', statusCode: 404 });
}
if (isMember(server.id, request.userId)) {
return reply.code(409).send({ error: 'You are already a member of this server', statusCode: 409 });
return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 });
}
const now = Date.now();
db.insert(schema.serverMembers).values({
serverId: server.id,
db.insert(schema.spaceMembers).values({
spaceId: server.id,
userId: request.userId,
joinedAt: now,
}).run();
// Register the user in connectionManager so they receive WS broadcasts for this server
connectionManager.addUserServer(request.userId, server.id);
connectionManager.addUserSpace(request.userId, server.id);
// Broadcast member_joined to existing server members
const joiningUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (joiningUser) {
const memberPayload: MemberWithUser = {
serverId: server.id,
spaceId: server.id,
userId: request.userId,
nickname: null,
joinedAt: now,
user: sanitizeUser(joiningUser),
roles: [],
};
connectionManager.sendToServer(server.id, {
connectionManager.sendToSpace(server.id, {
type: 'member_joined',
serverId: server.id,
spaceId: server.id,
member: memberPayload,
});
}
return reply.code(200).send(rowToServer(server));
return reply.code(200).send(rowToSpace(server));
});
// GET /api/servers/:id/members - List server members
app.get<{ Params: { id: string } }>('/api/servers/:id/members', {
// GET /api/spaces/:id/members - List server members
app.get<{ Params: { id: string } }>('/api/spaces/:id/members', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
}
if (!isMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 });
return reply.code(403).send({ error: 'You are not a member of this space', statusCode: 403 });
}
const memberRows = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.serverId, id))
.from(schema.spaceMembers)
.where(eq(schema.spaceMembers.spaceId, id))
.all();
const memberUserIds = memberRows.map(m => m.userId);
@@ -497,13 +497,13 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
const roles = db.select()
.from(schema.roles)
.where(eq(schema.roles.serverId, id))
.where(eq(schema.roles.spaceId, id))
.orderBy(schema.roles.position)
.all();
const memberRoleRows = db.select()
.from(schema.memberRoles)
.where(eq(schema.memberRoles.serverId, id))
.where(eq(schema.memberRoles.spaceId, id))
.all();
const members: MemberWithUser[] = memberRows
@@ -519,7 +519,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
.filter(r => assignedRoleIds.includes(r.id))
.map(r => ({
id: r.id,
serverId: r.serverId,
spaceId: r.spaceId,
name: r.name,
color: r.color ?? '#b9bbbe',
position: r.position ?? 0,
@@ -527,7 +527,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
}));
return {
serverId: m.serverId,
spaceId: m.spaceId,
userId: m.userId,
nickname: m.nickname,
joinedAt: m.joinedAt,
@@ -540,17 +540,17 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send(members);
});
// PATCH /api/servers/:id/members/:uid - Update member roles
app.patch<{ Params: { id: string; uid: string }; Body: UpdateMemberRequest }>('/api/servers/:id/members/:uid', {
// PATCH /api/spaces/:id/members/:uid - Update member roles
app.patch<{ Params: { id: string; uid: string }; Body: UpdateMemberRequest }>('/api/spaces/:id/members/:uid', {
preHandler: authenticate,
}, async (request, reply) => {
const { id, uid } = request.params;
const { roleIds } = request.body;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
}
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
@@ -566,15 +566,15 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
}
// Cannot modify the server owner's roles unless you are the owner
if (isServerOwner(id, uid) && !isServerOwner(id, request.userId)) {
return reply.code(403).send({ error: 'Only the server owner can modify their own roles', statusCode: 403 });
if (isSpaceOwner(id, uid) && !isSpaceOwner(id, request.userId)) {
return reply.code(403).send({ error: 'Only the space owner can modify their own roles', statusCode: 403 });
}
const member = db.select()
.from(schema.serverMembers)
.from(schema.spaceMembers)
.where(and(
eq(schema.serverMembers.serverId, id),
eq(schema.serverMembers.userId, uid),
eq(schema.spaceMembers.spaceId, id),
eq(schema.spaceMembers.userId, uid),
))
.get();
@@ -584,16 +584,16 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
// Validate all roleIds belong to this server and are not @everyone
if (roleIds.length > 0) {
const serverRoles = db.select()
const spaceRoles = db.select()
.from(schema.roles)
.where(eq(schema.roles.serverId, id))
.where(eq(schema.roles.spaceId, id))
.all();
const serverRoleIds = new Set(serverRoles.map(r => r.id));
const spaceRoleIds = new Set(spaceRoles.map(r => r.id));
for (const roleId of roleIds) {
if (!serverRoleIds.has(roleId)) {
return reply.code(400).send({ error: `Role ${roleId} does not belong to this server`, statusCode: 400 });
if (!spaceRoleIds.has(roleId)) {
return reply.code(400).send({ error: `Role ${roleId} does not belong to this space`, statusCode: 400 });
}
if (roleId === id) {
return reply.code(400).send({ error: '@everyone role is implicit and cannot be assigned', statusCode: 400 });
@@ -606,7 +606,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
// Remove all existing role assignments for this member in this server
tx.delete(schema.memberRoles)
.where(and(
eq(schema.memberRoles.serverId, id),
eq(schema.memberRoles.spaceId, id),
eq(schema.memberRoles.userId, uid),
))
.run();
@@ -614,7 +614,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
// Insert new role assignments
for (const roleId of roleIds) {
tx.insert(schema.memberRoles).values({
serverId: id,
spaceId: id,
userId: uid,
roleId,
}).run();
@@ -626,10 +626,10 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
// Build response with populated roles
const updatedMember = db.select()
.from(schema.serverMembers)
.from(schema.spaceMembers)
.where(and(
eq(schema.serverMembers.serverId, id),
eq(schema.serverMembers.userId, uid),
eq(schema.spaceMembers.spaceId, id),
eq(schema.spaceMembers.userId, uid),
))
.get();
@@ -645,7 +645,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
const updatedRoleRows = db.select()
.from(schema.memberRoles)
.where(and(
eq(schema.memberRoles.serverId, id),
eq(schema.memberRoles.spaceId, id),
eq(schema.memberRoles.userId, uid),
))
.all();
@@ -653,7 +653,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
const updatedRoleIds = updatedRoleRows.map(r => r.roleId);
const allRoles = db.select()
.from(schema.roles)
.where(eq(schema.roles.serverId, id))
.where(eq(schema.roles.spaceId, id))
.orderBy(schema.roles.position)
.all();
@@ -661,7 +661,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
.filter(r => updatedRoleIds.includes(r.id))
.map(r => ({
id: r.id,
serverId: r.serverId,
spaceId: r.spaceId,
name: r.name,
color: r.color ?? '#b9bbbe',
position: r.position ?? 0,
@@ -669,7 +669,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
}));
const result: MemberWithUser = {
serverId: updatedMember.serverId,
spaceId: updatedMember.spaceId,
userId: updatedMember.userId,
nickname: updatedMember.nickname,
joinedAt: updatedMember.joinedAt,
@@ -680,20 +680,20 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send(result);
});
// DELETE /api/servers/:id/members/:uid - Kick member (owner) or leave (self)
app.delete<{ Params: { id: string; uid: string } }>('/api/servers/:id/members/:uid', {
// DELETE /api/spaces/:id/members/:uid - Kick member (owner) or leave (self)
app.delete<{ Params: { id: string; uid: string } }>('/api/spaces/:id/members/:uid', {
preHandler: authenticate,
}, async (request, reply) => {
const { id, uid } = request.params;
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get();
const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
if (!server) {
return reply.code(404).send({ error: 'Server not found', statusCode: 404 });
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
}
const isSelf = uid === request.userId;
const isOwnerUser = isServerOwner(id, request.userId);
const isOwnerUser = isSpaceOwner(id, request.userId);
const canKick = hasPermission(request.userId, id, PermissionBits.KICK_MEMBERS);
if (!isSelf && !canKick) {
@@ -702,14 +702,14 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
// Owner cannot leave their own server - they must delete it
if (isSelf && isOwnerUser) {
return reply.code(400).send({ error: 'Server owner cannot leave. Transfer ownership or delete the server.', statusCode: 400 });
return reply.code(400).send({ error: 'Space owner cannot leave. Transfer ownership or delete the space.', statusCode: 400 });
}
const member = db.select()
.from(schema.serverMembers)
.from(schema.spaceMembers)
.where(and(
eq(schema.serverMembers.serverId, id),
eq(schema.serverMembers.userId, uid),
eq(schema.spaceMembers.spaceId, id),
eq(schema.spaceMembers.userId, uid),
))
.get();
@@ -718,21 +718,21 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
}
// Cannot kick the owner
if (isServerOwner(id, uid)) {
return reply.code(400).send({ error: 'Cannot remove the server owner', statusCode: 400 });
if (isSpaceOwner(id, uid)) {
return reply.code(400).send({ error: 'Cannot remove the space owner', statusCode: 400 });
}
db.delete(schema.serverMembers)
db.delete(schema.spaceMembers)
.where(and(
eq(schema.serverMembers.serverId, id),
eq(schema.serverMembers.userId, uid),
eq(schema.spaceMembers.spaceId, id),
eq(schema.spaceMembers.userId, uid),
))
.run();
// Broadcast member_left event
connectionManager.sendToServer(id, {
connectionManager.sendToSpace(id, {
type: 'member_left',
serverId: id,
spaceId: id,
userId: uid,
});
@@ -741,8 +741,8 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
// Role Management
// POST /api/servers/:id/roles - Create a new role
app.post<{ Params: { id: string }; Body: { name: string; color?: string } }>('/api/servers/:id/roles', {
// POST /api/spaces/:id/roles - Create a new role
app.post<{ Params: { id: string }; Body: { name: string; color?: string } }>('/api/spaces/:id/roles', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
@@ -756,7 +756,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
const roleId = generateSnowflake();
db.insert(schema.roles).values({
id: roleId,
serverId: id,
spaceId: id,
name: name || 'new role',
color: color || '#b9bbbe',
position: 0,
@@ -767,8 +767,8 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
return reply.code(201).send(role);
});
// PATCH /api/servers/:id/roles/:roleId - Update a role
app.patch<{ Params: { id: string; roleId: string }; Body: { name?: string; color?: string; position?: number } }>('/api/servers/:id/roles/:roleId', {
// PATCH /api/spaces/:id/roles/:roleId - Update a role
app.patch<{ Params: { id: string; roleId: string }; Body: { name?: string; color?: string; position?: number } }>('/api/spaces/:id/roles/:roleId', {
preHandler: authenticate,
}, async (request, reply) => {
const { id, roleId } = request.params;
@@ -779,13 +779,13 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
}
db.update(schema.roles).set(updates).where(and(eq(schema.roles.id, roleId), eq(schema.roles.serverId, id))).run();
db.update(schema.roles).set(updates).where(and(eq(schema.roles.id, roleId), eq(schema.roles.spaceId, id))).run();
const updated = db.select().from(schema.roles).where(eq(schema.roles.id, roleId)).get();
return reply.code(200).send(updated);
});
// DELETE /api/servers/:id/roles/:roleId - Delete a role
app.delete<{ Params: { id: string; roleId: string } }>('/api/servers/:id/roles/:roleId', {
// DELETE /api/spaces/:id/roles/:roleId - Delete a role
app.delete<{ Params: { id: string; roleId: string } }>('/api/spaces/:id/roles/:roleId', {
preHandler: authenticate,
}, async (request, reply) => {
const { id, roleId } = request.params;
@@ -805,12 +805,12 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
and(eq(schema.channelOverrides.targetType, 'role'), eq(schema.channelOverrides.targetId, roleId))
).run();
db.delete(schema.roles).where(and(eq(schema.roles.id, roleId), eq(schema.roles.serverId, id))).run();
db.delete(schema.roles).where(and(eq(schema.roles.id, roleId), eq(schema.roles.spaceId, id))).run();
return reply.code(200).send({ success: true });
});
// POST /api/servers/:id/members/:uid/roles - Add role to member
app.post<{ Params: { id: string; uid: string }; Body: { roleId: string } }>('/api/servers/:id/members/:uid/roles', {
// POST /api/spaces/:id/members/:uid/roles - Add role to member
app.post<{ Params: { id: string; uid: string }; Body: { roleId: string } }>('/api/spaces/:id/members/:uid/roles', {
preHandler: authenticate,
}, async (request, reply) => {
const { id, uid } = request.params;
@@ -822,7 +822,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
}
db.insert(schema.memberRoles).values({
serverId: id,
spaceId: id,
userId: uid,
roleId,
}).run();
@@ -830,8 +830,8 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send({ success: true });
});
// DELETE /api/servers/:id/members/:uid/roles/:roleId - Remove role from member
app.delete<{ Params: { id: string; uid: string; roleId: string } }>('/api/servers/:id/members/:uid/roles/:roleId', {
// DELETE /api/spaces/:id/members/:uid/roles/:roleId - Remove role from member
app.delete<{ Params: { id: string; uid: string; roleId: string } }>('/api/spaces/:id/members/:uid/roles/:roleId', {
preHandler: authenticate,
}, async (request, reply) => {
const { id, uid, roleId } = request.params;
@@ -842,7 +842,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
}
db.delete(schema.memberRoles).where(and(
eq(schema.memberRoles.serverId, id),
eq(schema.memberRoles.spaceId, id),
eq(schema.memberRoles.userId, uid),
eq(schema.memberRoles.roleId, roleId)
)).run();
+3 -3
View File
@@ -120,9 +120,9 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
// Broadcast presence update if status changed
if (status !== undefined) {
const userServers = connectionManager.getUserServers(sanitized.id);
for (const serverId of userServers) {
connectionManager.sendToServer(serverId, {
const userSpaces = connectionManager.getUserSpaces(sanitized.id);
for (const spaceId of userSpaces) {
connectionManager.sendToSpace(spaceId, {
type: 'presence_update',
userId: sanitized.id,
status: status,
+26 -26
View File
@@ -13,7 +13,7 @@ export { PermissionBits, ALL_PERMISSIONS, permissionsToString, stringToPermissio
// ─── Core Resolution Engine ─────────────────────────────────────────────────
/**
* Compute the effective permissions for a user in a server, optionally scoped
* Compute the effective permissions for a user in a space, optionally scoped
* to a specific channel. Follows Discord's resolution order:
*
* 1. Owner → ALL_PERMISSIONS
@@ -24,24 +24,24 @@ export { PermissionBits, ALL_PERMISSIONS, permissionsToString, stringToPermissio
* b. Role overrides (combined)
* c. Member-specific override
*/
export function computePermissions(userId: string, serverId: string, channelId?: string): bigint {
export function computePermissions(userId: string, spaceId: string, channelId?: string): bigint {
const db = getDb();
// 1. Owner check
const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get();
if (!server) return 0n;
if (server.ownerId === userId) return ALL_PERMISSIONS;
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, spaceId)).get();
if (!space) return 0n;
if (space.ownerId === userId) return ALL_PERMISSIONS;
// 2. Base permissions from @everyone role (id === serverId)
// 2. Base permissions from @everyone role (id === spaceId)
const everyoneRole = db.select().from(schema.roles)
.where(and(eq(schema.roles.id, serverId), eq(schema.roles.serverId, serverId)))
.where(and(eq(schema.roles.id, spaceId), eq(schema.roles.spaceId, spaceId)))
.get();
let base = everyoneRole ? stringToPermissions(everyoneRole.permissions) : 0n;
// Get user's assigned roles via member_roles
const memberRoleRows = db.select().from(schema.memberRoles)
.where(and(
eq(schema.memberRoles.serverId, serverId),
eq(schema.memberRoles.spaceId, spaceId),
eq(schema.memberRoles.userId, userId),
))
.all();
@@ -61,7 +61,7 @@ export function computePermissions(userId: string, serverId: string, channelId?:
// 3. Admin shortcut
if ((base & PermissionBits.ADMINISTRATOR) !== 0n) return ALL_PERMISSIONS;
// If no channel, return server-level perms
// If no channel, return space-level perms
if (!channelId) return base;
// 4. Channel overrides
@@ -71,8 +71,8 @@ export function computePermissions(userId: string, serverId: string, channelId?:
if (overrides.length === 0) return base;
// 4a. @everyone role override (target_type='role', target_id=serverId)
const everyoneOverride = overrides.find(o => o.targetType === 'role' && o.targetId === serverId);
// 4a. @everyone role override (target_type='role', target_id=spaceId)
const everyoneOverride = overrides.find(o => o.targetType === 'role' && o.targetId === spaceId);
if (everyoneOverride) {
const deny = stringToPermissions(everyoneOverride.deny);
const allow = stringToPermissions(everyoneOverride.allow);
@@ -103,46 +103,46 @@ export function computePermissions(userId: string, serverId: string, channelId?:
}
/**
* Check if a user has a specific permission in a server/channel.
* Check if a user has a specific permission in a space/channel.
*/
export function hasPermission(
userId: string,
serverId: string,
spaceId: string,
permission: bigint,
channelId?: string,
): boolean {
const perms = computePermissions(userId, serverId, channelId);
const perms = computePermissions(userId, spaceId, channelId);
// ADMINISTRATOR grants everything
if ((perms & PermissionBits.ADMINISTRATOR) !== 0n) return true;
return (perms & permission) === permission;
}
// ─── Existing helpers (kept for backward compat) ────────────────────────────
// ─── Existing helpers ───────────────────────────────────────────────────────
export function getMember(serverId: string, userId: string) {
export function getMember(spaceId: string, userId: string) {
const db = getDb();
return db.select().from(schema.serverMembers)
return db.select().from(schema.spaceMembers)
.where(and(
eq(schema.serverMembers.serverId, serverId),
eq(schema.serverMembers.userId, userId),
eq(schema.spaceMembers.spaceId, spaceId),
eq(schema.spaceMembers.userId, userId),
))
.get();
}
export function isMember(serverId: string, userId: string): boolean {
return getMember(serverId, userId) !== undefined;
export function isMember(spaceId: string, userId: string): boolean {
return getMember(spaceId, userId) !== undefined;
}
export function isServerOwner(serverId: string, userId: string): boolean {
export function isSpaceOwner(spaceId: string, userId: string): boolean {
const db = getDb();
const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get();
return server?.ownerId === userId;
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, spaceId)).get();
return space?.ownerId === userId;
}
export function getChannelServerId(channelId: string): string | null {
export function getChannelSpaceId(channelId: string): string | null {
const db = getDb();
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, channelId)).get();
return channel?.serverId ?? null;
return channel?.spaceId ?? null;
}
export function isDmMember(dmChannelId: string, userId: string): boolean {
+39 -39
View File
@@ -2,8 +2,8 @@ import { eq, inArray, and } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { connectionManager } from './handler.js';
import type { VoiceRoom, DmRoomMeta, ServerRoomMeta } from './handler.js';
import { isMember, getChannelServerId, isDmMember, hasPermission, PermissionBits } from '../utils/permissions.js';
import type { VoiceRoom, DmRoomMeta, SpaceRoomMeta } from './handler.js';
import { isMember, getChannelSpaceId, isDmMember, hasPermission, PermissionBits } from '../utils/permissions.js';
import { broadcastDmMessage, getDmMessageWithUser } from '../routes/dm.js';
import type { MessageWithUser, Attachment, DmMessageWithUser } from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js';
@@ -173,13 +173,13 @@ function handleMessageCreate(event: Record<string, unknown>, userId: string): vo
return;
}
const serverId = getChannelServerId(channelId);
if (!serverId) {
const spaceId = getChannelSpaceId(channelId);
if (!spaceId) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Channel not found' });
return;
}
if (!hasPermission(userId, serverId, PermissionBits.SEND_MESSAGES, channelId)) {
if (!hasPermission(userId, spaceId, PermissionBits.SEND_MESSAGES, channelId)) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing SEND_MESSAGES permission' });
return;
}
@@ -200,7 +200,7 @@ function handleMessageCreate(event: Record<string, unknown>, userId: string): vo
const messageWithUser = getMessageWithUser(messageId);
if (messageWithUser) {
// Broadcast to members with VIEW_CHANNEL on this channel
connectionManager.sendToChannel(serverId, channelId, {
connectionManager.sendToChannel(spaceId, channelId, {
type: 'message_created',
message: messageWithUser,
});
@@ -239,12 +239,12 @@ function handleMessageEdit(event: Record<string, unknown>, userId: string): void
.where(eq(schema.messages.id, messageId))
.run();
const serverId = getChannelServerId(message.channelId);
if (!serverId) return;
const spaceId = getChannelSpaceId(message.channelId);
if (!spaceId) return;
const updatedMessage = getMessageWithUser(messageId);
if (updatedMessage) {
connectionManager.sendToChannel(serverId, message.channelId, {
connectionManager.sendToChannel(spaceId, message.channelId, {
type: 'message_updated',
message: updatedMessage,
});
@@ -266,12 +266,12 @@ function handleMessageDelete(event: Record<string, unknown>, userId: string): vo
return;
}
const serverId = getChannelServerId(message.channelId);
if (!serverId) return;
const spaceId = getChannelSpaceId(message.channelId);
if (!spaceId) return;
// Allow author or MANAGE_MESSAGES permission holder to delete
const isAuthor = message.userId === userId;
const canManageMessages = hasPermission(userId, serverId, PermissionBits.MANAGE_MESSAGES, message.channelId);
const canManageMessages = hasPermission(userId, spaceId, PermissionBits.MANAGE_MESSAGES, message.channelId);
if (!isAuthor && !canManageMessages) {
connectionManager.sendToUser(userId, { type: 'error', message: 'You cannot delete this message' });
@@ -282,7 +282,7 @@ function handleMessageDelete(event: Record<string, unknown>, userId: string): vo
db.delete(schema.attachments).where(eq(schema.attachments.messageId, messageId)).run();
db.delete(schema.messages).where(eq(schema.messages.id, messageId)).run();
connectionManager.sendToChannel(serverId, message.channelId, {
connectionManager.sendToChannel(spaceId, message.channelId, {
type: 'message_deleted',
messageId,
channelId: message.channelId,
@@ -294,10 +294,10 @@ function handleTypingStart(event: Record<string, unknown>, userId: string, usern
if (!channelId || typeof channelId !== 'string') return;
const serverId = getChannelServerId(channelId);
if (!serverId) return;
const spaceId = getChannelSpaceId(channelId);
if (!spaceId) return;
if (!hasPermission(userId, serverId, PermissionBits.SEND_MESSAGES, channelId)) return;
if (!hasPermission(userId, spaceId, PermissionBits.SEND_MESSAGES, channelId)) return;
// Clear previous typing timeout for this user+channel
const key = `${userId}:${channelId}`;
@@ -307,7 +307,7 @@ function handleTypingStart(event: Record<string, unknown>, userId: string, usern
}
// Broadcast typing event to channel viewers (exclude sender)
connectionManager.sendToChannel(serverId, channelId, {
connectionManager.sendToChannel(spaceId, channelId, {
type: 'typing',
channelId,
userId,
@@ -332,10 +332,10 @@ function handlePresenceUpdate(event: Record<string, unknown>, userId: string): v
const db = getDb();
db.update(schema.users).set({ status }).where(eq(schema.users.id, userId)).run();
// Broadcast to all servers user is in
const userServers = connectionManager.getUserServers(userId);
for (const serverId of userServers) {
connectionManager.sendToServer(serverId, {
// Broadcast to all spaces user is in
const userSpaces = connectionManager.getUserSpaces(userId);
for (const spaceId of userSpaces) {
connectionManager.sendToSpace(spaceId, {
type: 'presence_update',
userId,
status,
@@ -354,9 +354,9 @@ function handlePresenceUpdate(event: Record<string, unknown>, userId: string): v
/** Helper: broadcast a voice leave and auto-end empty DM calls. */
function broadcastRoomLeave(roomId: string, room: VoiceRoom, userId: string): void {
if (room.roomType === 'server') {
const meta = room.metadata as ServerRoomMeta;
connectionManager.sendToServer(meta.serverId, {
if (room.roomType === 'space') {
const meta = room.metadata as SpaceRoomMeta;
connectionManager.sendToSpace(meta.spaceId, {
type: 'voice_state_update',
channelId: roomId,
userId,
@@ -389,13 +389,13 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
return;
}
const serverId = getChannelServerId(channelId);
if (!serverId) {
const spaceId = getChannelSpaceId(channelId);
if (!spaceId) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Channel not found' });
return;
}
if (!hasPermission(userId, serverId, PermissionBits.CONNECT, channelId)) {
if (!hasPermission(userId, spaceId, PermissionBits.CONNECT, channelId)) {
connectionManager.sendToUser(userId, { type: 'error', message: 'Missing CONNECT permission' });
return;
}
@@ -419,7 +419,7 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
return;
}
// Leave current room (server OR DM)
// Leave current room (space OR DM)
const left = connectionManager.leaveCurrentRoom(userId);
if (left) {
broadcastRoomLeave(left.roomId, left.room, userId);
@@ -440,8 +440,8 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
}
}
// Lazy-create server room
connectionManager.createRoom(channelId, 'server', { type: 'server', serverId });
// Lazy-create space room
connectionManager.createRoom(channelId, 'space', { type: 'space', spaceId });
// Join room
connectionManager.joinRoom(channelId, userId);
@@ -490,7 +490,7 @@ function handleVoiceStatus(event: Record<string, unknown>, userId: string): void
connectionManager.setVoiceUserStatus(userId, isMuted, isDeafened, isCameraOn, isScreenSharing);
// sendToRoom routes to sendToServer for server rooms, sendToDmMembers for DM rooms
// sendToRoom routes to sendToSpace for space rooms, sendToDmMembers for DM rooms
connectionManager.sendToRoom(userRoom.roomId, {
type: 'voice_status_update',
userId,
@@ -702,11 +702,11 @@ function handleReactionAdd(event: Record<string, unknown>, userId: string): void
const db = getDb();
// Try server message first
// Try space message first
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
if (message) {
const serverId = getChannelServerId(message.channelId);
if (!serverId || !isMember(serverId, userId)) return;
const spaceId = getChannelSpaceId(message.channelId);
if (!spaceId || !isMember(spaceId, userId)) return;
const reactionId = generateSnowflake();
const now = Date.now();
@@ -723,7 +723,7 @@ function handleReactionAdd(event: Record<string, unknown>, userId: string): void
const reactionUser = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
const userObj = reactionUser ? sanitizeUser(reactionUser) : undefined;
connectionManager.sendToChannel(serverId, message.channelId, {
connectionManager.sendToChannel(spaceId, message.channelId, {
type: 'reaction_added',
messageId,
reaction: { id: reactionId, messageId, userId, emoji, createdAt: now, user: userObj },
@@ -771,11 +771,11 @@ function handleReactionRemove(event: Record<string, unknown>, userId: string): v
const db = getDb();
// Try server message first
// Try space message first
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
if (message) {
const serverId = getChannelServerId(message.channelId);
if (!serverId || !isMember(serverId, userId)) return;
const spaceId = getChannelSpaceId(message.channelId);
if (!spaceId || !isMember(spaceId, userId)) return;
const result = db.delete(schema.reactions)
.where(and(
@@ -786,7 +786,7 @@ function handleReactionRemove(event: Record<string, unknown>, userId: string): v
.run();
if (result.changes > 0) {
connectionManager.sendToChannel(serverId, message.channelId, {
connectionManager.sendToChannel(spaceId, message.channelId, {
type: 'reaction_removed',
messageId,
userId,
+108 -108
View File
@@ -7,12 +7,12 @@ import { handleClientEvent } from './events.js';
import { computePermissions, PermissionBits, permissionsToString } from '../utils/permissions.js';
import type {
User,
ServerWithChannelsAndMembers,
SpaceWithChannelsAndMembers,
MemberWithUser,
Channel,
DmChannel,
ServerEvent,
ServerFolder,
SpaceFolder,
ReadState,
ActiveCallInfo,
} from '@backspace/shared';
@@ -39,9 +39,9 @@ export interface AuthenticatedSocket {
// ─── VoiceRoom Abstraction ─────────────────────────────────────────────────
export interface ServerRoomMeta {
type: 'server';
serverId: string;
export interface SpaceRoomMeta {
type: 'space';
spaceId: string;
}
export interface DmRoomMeta {
@@ -52,9 +52,9 @@ export interface DmRoomMeta {
export interface VoiceRoom {
roomId: string;
roomType: 'server' | 'dm';
roomType: 'space' | 'dm';
participants: Set<string>;
metadata: ServerRoomMeta | DmRoomMeta;
metadata: SpaceRoomMeta | DmRoomMeta;
startedAt: number;
}
@@ -63,8 +63,8 @@ export interface VoiceRoom {
class ConnectionManager {
// userId → Set of WebSocket connections (multiple tabs)
private connections: Map<string, Set<WebSocket>> = new Map();
// userId → Set of server IDs the user belongs to
private userServers: Map<string, Set<string>> = new Map();
// userId → Set of space IDs the user belongs to
private userSpaces: Map<string, Set<string>> = new Map();
// ws → userId (reverse lookup)
private wsToUser: Map<WebSocket, string> = new Map();
// Unified voice room tracking (replaces voiceStates + activeCalls)
@@ -134,13 +134,13 @@ class ConnectionManager {
const db = getDb();
db.update(schema.users).set({ status: 'offline' }).where(eq(schema.users.id, userId)).run();
// Leave voice room if in one (handles both server and DM rooms)
// Leave voice room if in one (handles both space and DM rooms)
const left = this.leaveCurrentRoom(userId);
this.clearVoiceUserStatus(userId);
if (left) {
if (left.room.roomType === 'server') {
const meta = left.room.metadata as ServerRoomMeta;
this.sendToServer(meta.serverId, {
if (left.room.roomType === 'space') {
const meta = left.room.metadata as SpaceRoomMeta;
this.sendToSpace(meta.spaceId, {
type: 'voice_state_update',
channelId: left.roomId,
userId: userId,
@@ -179,10 +179,10 @@ class ConnectionManager {
}
}
// Broadcast offline to all servers
const userServers = this.getUserServers(userId);
for (const serverId of userServers) {
this.sendToServer(serverId, {
// Broadcast offline to all spaces
const userSpaces = this.getUserSpaces(userId);
for (const spaceId of userSpaces) {
this.sendToSpace(spaceId, {
type: 'presence_update',
userId: userId,
status: 'offline',
@@ -199,25 +199,25 @@ class ConnectionManager {
return conns !== undefined && conns.size > 0;
}
setUserServers(userId: string, serverIds: string[]): void {
this.userServers.set(userId, new Set(serverIds));
setUserSpaces(userId: string, spaceIds: string[]): void {
this.userSpaces.set(userId, new Set(spaceIds));
}
addUserServer(userId: string, serverId: string): void {
if (!this.userServers.has(userId)) {
this.userServers.set(userId, new Set());
addUserSpace(userId: string, spaceId: string): void {
if (!this.userSpaces.has(userId)) {
this.userSpaces.set(userId, new Set());
}
this.userServers.get(userId)!.add(serverId);
this.userSpaces.get(userId)!.add(spaceId);
}
getUserServers(userId: string): Set<string> {
return this.userServers.get(userId) ?? new Set();
getUserSpaces(userId: string): Set<string> {
return this.userSpaces.get(userId) ?? new Set();
}
// ─── Unified VoiceRoom API ─────────────────────────────────────────────────
/** Create a room. Returns false if room already exists. */
createRoom(roomId: string, roomType: 'server' | 'dm', metadata: ServerRoomMeta | DmRoomMeta): boolean {
createRoom(roomId: string, roomType: 'space' | 'dm', metadata: SpaceRoomMeta | DmRoomMeta): boolean {
if (this.voiceRooms.has(roomId)) return false;
this.voiceRooms.set(roomId, {
roomId,
@@ -283,7 +283,7 @@ class ConnectionManager {
const oldRoom = this.voiceRooms.get(currentRoomId);
if (oldRoom) {
oldRoom.participants.delete(userId);
if (oldRoom.participants.size === 0 && oldRoom.roomType === 'server') {
if (oldRoom.participants.size === 0 && oldRoom.roomType === 'space') {
this.voiceRooms.delete(currentRoomId);
}
}
@@ -302,8 +302,8 @@ class ConnectionManager {
room.participants.delete(userId);
this.userToRoom.delete(userId);
// Auto-cleanup empty server rooms (they're lazy-created)
if (room.participants.size === 0 && room.roomType === 'server') {
// Auto-cleanup empty space rooms (they're lazy-created)
if (room.participants.size === 0 && room.roomType === 'space') {
this.voiceRooms.delete(roomId);
}
@@ -399,11 +399,11 @@ class ConnectionManager {
}
}
/** Send to all members of a server. */
sendToServer(serverId: string, event: ServerEvent, excludeUserId?: string): void {
/** Send to all members of a space. */
sendToSpace(spaceId: string, event: ServerEvent, excludeUserId?: string): void {
const message = JSON.stringify(event);
for (const [userId, serverIds] of this.userServers) {
if (serverIds.has(serverId) && userId !== excludeUserId) {
for (const [userId, spaceIds] of this.userSpaces) {
if (spaceIds.has(spaceId) && userId !== excludeUserId) {
const connections = this.getUserConnections(userId);
for (const ws of connections) {
if (ws.readyState === 1) {
@@ -414,12 +414,12 @@ class ConnectionManager {
}
}
/** Send to server members who have VIEW_CHANNEL on the given channel. */
sendToChannel(serverId: string, channelId: string, event: ServerEvent, excludeUserId?: string): void {
/** Send to space members who have VIEW_CHANNEL on the given channel. */
sendToChannel(spaceId: string, channelId: string, event: ServerEvent, excludeUserId?: string): void {
const message = JSON.stringify(event);
for (const [userId, serverIds] of this.userServers) {
if (serverIds.has(serverId) && userId !== excludeUserId) {
const perms = computePermissions(userId, serverId, channelId);
for (const [userId, spaceIds] of this.userSpaces) {
if (spaceIds.has(spaceId) && userId !== excludeUserId) {
const perms = computePermissions(userId, spaceId, channelId);
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
const connections = this.getUserConnections(userId);
for (const ws of connections) {
@@ -432,9 +432,9 @@ class ConnectionManager {
}
}
/** Expose userServers iterator for pre-delete viewer collection. */
getUserServerEntries(): IterableIterator<[string, Set<string>]> {
return this.userServers.entries();
/** Expose userSpaces iterator for pre-delete viewer collection. */
getUserSpaceEntries(): IterableIterator<[string, Set<string>]> {
return this.userSpaces.entries();
}
/** Send to all DM channel members (queries dm_members table). */
@@ -452,14 +452,14 @@ class ConnectionManager {
}
}
/** Send to a room — routes to sendToServer (server rooms) or sendToDmMembers (DM rooms). */
/** Send to a room — routes to sendToSpace (space rooms) or sendToDmMembers (DM rooms). */
sendToRoom(roomId: string, event: ServerEvent, excludeUserId?: string): void {
const room = this.voiceRooms.get(roomId);
if (!room) return;
if (room.roomType === 'server') {
const meta = room.metadata as ServerRoomMeta;
this.sendToServer(meta.serverId, event, excludeUserId);
if (room.roomType === 'space') {
const meta = room.metadata as SpaceRoomMeta;
this.sendToSpace(meta.spaceId, event, excludeUserId);
} else {
this.sendToDmMembers(roomId, event, excludeUserId);
}
@@ -531,9 +531,9 @@ class WsRateLimiter {
function buildReadyPayload(userId: string): {
user: User;
servers: ServerWithChannelsAndMembers[];
spaces: SpaceWithChannelsAndMembers[];
dmChannels: DmChannel[];
folders: ServerFolder[];
folders: SpaceFolder[];
voiceStates: Record<string, string[]>;
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
readStates: ReadState[];
@@ -548,31 +548,31 @@ function buildReadyPayload(userId: string): {
}
const user = sanitizeUser(userRow);
// Get user's server memberships
// Get user's space memberships
const memberships = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.userId, userId))
.from(schema.spaceMembers)
.where(eq(schema.spaceMembers.userId, userId))
.all();
const serverIds = memberships.map(m => m.serverId);
const spaceIds = memberships.map(m => m.spaceId);
const servers: ServerWithChannelsAndMembers[] = [];
const spaces: SpaceWithChannelsAndMembers[] = [];
if (serverIds.length > 0) {
const serverRows = db.select()
.from(schema.servers)
.where(inArray(schema.servers.id, serverIds))
if (spaceIds.length > 0) {
const spaceRows = db.select()
.from(schema.spaces)
.where(inArray(schema.spaces.id, spaceIds))
.all();
// Batch: all channels for all servers (1 query instead of N)
// Batch: all channels for all spaces (1 query instead of N)
const allChannels = batchInArray(
serverIds,
ids => db.select().from(schema.channels).where(inArray(schema.channels.serverId, ids)).all(),
spaceIds,
ids => db.select().from(schema.channels).where(inArray(schema.channels.spaceId, ids)).all(),
);
const channelsByServer = new Map<string, (typeof allChannels)>();
const channelsBySpace = new Map<string, (typeof allChannels)>();
for (const ch of allChannels) {
let arr = channelsByServer.get(ch.serverId);
if (!arr) { arr = []; channelsByServer.set(ch.serverId, arr); }
let arr = channelsBySpace.get(ch.spaceId);
if (!arr) { arr = []; channelsBySpace.set(ch.spaceId, arr); }
arr.push(ch);
}
@@ -592,18 +592,18 @@ function buildReadyPayload(userId: string): {
}
}
for (const serverRow of serverRows) {
const channels = channelsByServer.get(serverRow.id) ?? [];
for (const spaceRow of spaceRows) {
const channels = channelsBySpace.get(spaceRow.id) ?? [];
const roles = db.select()
.from(schema.roles)
.where(eq(schema.roles.serverId, serverRow.id))
.where(eq(schema.roles.spaceId, spaceRow.id))
.orderBy(schema.roles.position)
.all();
const memberRows = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.serverId, serverRow.id))
.from(schema.spaceMembers)
.where(eq(schema.spaceMembers.spaceId, spaceRow.id))
.all();
const memberUserIds = memberRows.map(m => m.userId);
@@ -614,7 +614,7 @@ function buildReadyPayload(userId: string): {
const memberRoleRows = db.select()
.from(schema.memberRoles)
.where(eq(schema.memberRoles.serverId, serverRow.id))
.where(eq(schema.memberRoles.spaceId, spaceRow.id))
.all();
const members: MemberWithUser[] = memberRows
@@ -630,7 +630,7 @@ function buildReadyPayload(userId: string): {
.filter(r => assignedRoleIds.includes(r.id))
.map(r => ({
id: r.id,
serverId: r.serverId,
spaceId: r.spaceId,
name: r.name,
color: r.color ?? '#b9bbbe',
position: r.position ?? 0,
@@ -638,7 +638,7 @@ function buildReadyPayload(userId: string): {
}));
return {
serverId: m.serverId,
spaceId: m.spaceId,
userId: m.userId,
nickname: m.nickname,
joinedAt: m.joinedAt,
@@ -648,18 +648,18 @@ function buildReadyPayload(userId: string): {
})
.filter((m): m is MemberWithUser => m !== null);
// Compute server-level permissions for this user
const serverPerms = computePermissions(userId, serverRow.id);
// Compute space-level permissions for this user
const spacePerms = computePermissions(userId, spaceRow.id);
// Filter channels by VIEW_CHANNEL and attach per-channel permissions
const visibleChannels: Channel[] = [];
for (const ch of channels) {
const chPerms = computePermissions(userId, serverRow.id, ch.id);
const chPerms = computePermissions(userId, spaceRow.id, ch.id);
const hasView = (chPerms & PermissionBits.VIEW_CHANNEL) !== 0n || (chPerms & PermissionBits.ADMINISTRATOR) !== 0n;
if (hasView) {
visibleChannels.push({
id: ch.id,
serverId: ch.serverId,
spaceId: ch.spaceId,
name: ch.name,
type: ch.type as Channel['type'],
topic: ch.topic,
@@ -671,34 +671,34 @@ function buildReadyPayload(userId: string): {
}
}
servers.push({
id: serverRow.id,
name: serverRow.name,
icon: serverRow.icon,
ownerId: serverRow.ownerId,
inviteCode: serverRow.inviteCode,
visibility: (serverRow.visibility ?? 'private') as ServerWithChannelsAndMembers['visibility'],
description: serverRow.description ?? null,
createdAt: serverRow.createdAt,
spaces.push({
id: spaceRow.id,
name: spaceRow.name,
icon: spaceRow.icon,
ownerId: spaceRow.ownerId,
inviteCode: spaceRow.inviteCode,
visibility: (spaceRow.visibility ?? 'private') as SpaceWithChannelsAndMembers['visibility'],
description: spaceRow.description ?? null,
createdAt: spaceRow.createdAt,
channels: visibleChannels,
members,
roles: roles.map(r => ({
id: r.id,
serverId: r.serverId,
spaceId: r.spaceId,
name: r.name,
color: r.color ?? '#b9bbbe',
position: r.position ?? 0,
permissions: r.permissions ?? undefined,
isEveryone: r.id === serverRow.id,
isEveryone: r.id === spaceRow.id,
createdAt: r.createdAt,
})),
myPermissions: permissionsToString(serverPerms),
myPermissions: permissionsToString(spacePerms),
});
}
}
// Store user's server IDs for broadcasting
connectionManager.setUserServers(userId, serverIds);
// Store user's space IDs for broadcasting
connectionManager.setUserSpaces(userId, spaceIds);
// Get DM channels
const dmMemberships = db.select()
@@ -773,36 +773,36 @@ function buildReadyPayload(userId: string): {
}
}
// Get Server Folders
// Get Space Folders
const folderRows = db.select()
.from(schema.serverFolders)
.where(eq(schema.serverFolders.userId, userId))
.orderBy(schema.serverFolders.position)
.from(schema.spaceFolders)
.where(eq(schema.spaceFolders.userId, userId))
.orderBy(schema.spaceFolders.position)
.all();
const folders: any[] = [];
const folders: SpaceFolder[] = [];
for (const folder of folderRows) {
const serverIds = db.select()
.from(schema.serverFolderMembers)
.where(eq(schema.serverFolderMembers.folderId, folder.id))
const folderSpaceIds = db.select()
.from(schema.spaceFolderMembers)
.where(eq(schema.spaceFolderMembers.folderId, folder.id))
.all()
.map(m => m.serverId);
.map(m => m.spaceId);
folders.push({
id: folder.id,
userId: folder.userId,
name: folder.name,
color: folder.color,
position: folder.position,
serverIds,
position: folder.position ?? 0,
spaceIds: folderSpaceIds,
});
}
// Build voice states — tell the client who is currently in voice channels
// across all their servers
// across all their spaces
const voiceStates: Record<string, string[]> = {};
for (const srv of servers) {
for (const ch of srv.channels) {
for (const space of spaces) {
for (const ch of space.channels) {
if (ch.type === 'voice' || ch.type === 'video') {
const participants = connectionManager.getRoomParticipants(ch.id);
if (participants.size > 0) {
@@ -832,7 +832,7 @@ function buildReadyPayload(userId: string): {
}
}
// Build voice user states — includes both server and DM participants now
// Build voice user states — includes both space and DM participants now
const voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> = {};
for (const chId of Object.keys(voiceStates)) {
const usersInChannel = voiceStates[chId];
@@ -857,7 +857,7 @@ function buildReadyPayload(userId: string): {
lastReadMessageId: rs.lastReadMessageId,
}));
return { user, servers, dmChannels, folders, voiceStates, voiceUserStates, readStates, activeCalls };
return { user, spaces, dmChannels, folders, voiceStates, voiceUserStates, readStates, activeCalls };
}
export async function registerWebSocket(app: FastifyInstance): Promise<void> {
@@ -915,10 +915,10 @@ export async function registerWebSocket(app: FastifyInstance): Promise<void> {
...readyData,
}));
// Broadcast presence update to all servers
const userServers = connectionManager.getUserServers(userId);
for (const serverId of userServers) {
connectionManager.sendToServer(serverId, {
// Broadcast presence update to all spaces
const userSpaces = connectionManager.getUserSpaces(userId);
for (const spaceId of userSpaces) {
connectionManager.sendToSpace(spaceId, {
type: 'presence_update',
userId,
status: 'online',
+1 -1
View File
@@ -7,7 +7,7 @@ export const PermissionBits = {
VIEW_CHANNEL: 1n << 1n,
MANAGE_CHANNELS: 1n << 2n,
MANAGE_ROLES: 1n << 3n,
MANAGE_SERVER: 1n << 4n,
MANAGE_SPACE: 1n << 4n,
CREATE_INVITE: 1n << 5n,
KICK_MEMBERS: 1n << 6n,
BAN_MEMBERS: 1n << 7n,
+27 -27
View File
@@ -26,27 +26,27 @@ export interface UserWithPassword extends User {
passwordHash: string;
}
// ─── Server (Guild) Types ───────────────────────────────────────────────────
// ─── Space (Community) Types ─────────────────────────────────────────────────
export type ServerVisibility = 'public' | 'request' | 'private';
export type SpaceVisibility = 'public' | 'request' | 'private';
export interface Server {
export interface Space {
id: string;
name: string;
icon: string | null;
ownerId: string;
inviteCode: string | null;
visibility: ServerVisibility;
visibility: SpaceVisibility;
description: string | null;
createdAt: number;
}
export interface ExploreServer {
export interface ExploreSpace {
id: string;
name: string;
icon: string | null;
description: string | null;
visibility: ServerVisibility;
visibility: SpaceVisibility;
memberCount: number;
createdAt: number;
joined?: boolean;
@@ -54,7 +54,7 @@ export interface ExploreServer {
export interface JoinRequest {
id: string;
serverId: string;
spaceId: string;
userId: string;
message: string | null;
status: 'pending' | 'accepted' | 'declined';
@@ -64,17 +64,17 @@ export interface JoinRequest {
user?: User;
}
export interface ServerWithChannelsAndMembers extends Server {
export interface SpaceWithChannelsAndMembers extends Space {
channels: Channel[];
members: MemberWithUser[];
roles: Role[];
myPermissions?: string; // Computed per-user BigInt decimal string (server-level)
myPermissions?: string; // Computed per-user BigInt decimal string (space-level)
}
// ─── Member Types ───────────────────────────────────────────────────────────
export interface Member {
serverId: string;
spaceId: string;
userId: string;
nickname: string | null;
joinedAt: number;
@@ -89,24 +89,24 @@ export interface MemberWithUser extends Member {
export interface Role {
id: string;
serverId: string;
spaceId: string;
name: string;
color: string;
position: number;
permissions?: string; // BigInt decimal string (bitwise)
isEveryone?: boolean; // UI hint: true when role.id === server.id
isEveryone?: boolean; // UI hint: true when role.id === space.id
createdAt: number;
}
// ─── Folder Types ───────────────────────────────────────────────────────────
export interface ServerFolder {
export interface SpaceFolder {
id: string;
userId: string;
name: string | null;
color: string | null;
position: number;
serverIds: string[];
spaceIds: string[];
}
// ─── Channel Types ──────────────────────────────────────────────────────────
@@ -115,7 +115,7 @@ export type ChannelType = 'text' | 'voice' | 'video';
export interface Channel {
id: string;
serverId: string;
spaceId: string;
name: string;
type: ChannelType;
topic: string | null;
@@ -239,15 +239,15 @@ export type ClientEvent =
// Server → Client Events
export type ServerEvent =
| { type: 'ready'; user: User; servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: ServerFolder[]; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[] }
| { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[] }
| { type: 'message_created'; message: MessageWithUser }
| { type: 'message_updated'; message: MessageWithUser }
| { type: 'message_deleted'; messageId: string; channelId: string }
| { type: 'typing'; channelId: string; userId: string; username: string }
| { type: 'presence_update'; userId: string; status: string }
| { type: 'voice_state_update'; channelId: string; userId: string; action: 'join' | 'leave' }
| { type: 'member_joined'; serverId: string; member: MemberWithUser }
| { type: 'member_left'; serverId: string; userId: string }
| { type: 'member_joined'; spaceId: string; member: MemberWithUser }
| { type: 'member_left'; spaceId: string; userId: string }
| { type: 'dm_message_created'; message: DmMessageWithUser }
| { type: 'dm_message_updated'; message: DmMessageWithUser }
| { type: 'dm_message_deleted'; messageId: string; dmChannelId: string }
@@ -267,12 +267,12 @@ export type ServerEvent =
| { type: 'dm_member_added'; dmChannelId: string; user: User }
| { type: 'dm_member_removed'; dmChannelId: string; userId: string }
| { type: 'friend_removed'; userId: string }
| { type: 'channel_created'; channel: Channel; serverId: string }
| { type: 'channel_updated'; channel: Channel; serverId: string }
| { type: 'channel_deleted'; channelId: string; serverId: string }
| { type: 'server_updated'; server: Server }
| { type: 'channel_created'; channel: Channel; spaceId: string }
| { type: 'channel_updated'; channel: Channel; spaceId: string }
| { type: 'channel_deleted'; channelId: string; spaceId: string }
| { type: 'space_updated'; space: Space }
| { type: 'join_request_received'; request: JoinRequest }
| { type: 'join_request_accepted'; request: JoinRequest; server: ServerWithChannelsAndMembers }
| { type: 'join_request_accepted'; request: JoinRequest; space: SpaceWithChannelsAndMembers }
| { type: 'join_request_declined'; request: JoinRequest }
| { type: 'pong' }
| { type: 'error'; message: string };
@@ -297,7 +297,7 @@ export interface AuthResponse {
user: User;
}
export interface CreateServerRequest {
export interface CreateSpaceRequest {
name: string;
icon?: string;
}
@@ -314,10 +314,10 @@ export interface UpdateChannelRequest {
position?: number;
}
export interface UpdateServerRequest {
export interface UpdateSpaceRequest {
name?: string;
icon?: string;
visibility?: ServerVisibility;
visibility?: SpaceVisibility;
description?: string;
}
@@ -344,7 +344,7 @@ export interface UpdateMessageRequest {
content: string;
}
export interface JoinServerRequest {
export interface JoinSpaceRequest {
inviteCode: string;
}
+1 -1
View File
@@ -37,7 +37,7 @@ export function App() {
}
/>
<Route
path="/channels/:serverId/:channelId?"
path="/channels/:spaceId/:channelId?"
element={
<ProtectedRoute>
<AppLayout />
+49 -49
View File
@@ -3,22 +3,22 @@ import type {
RegisterRequest,
LoginRequest,
User,
Server,
ServerWithChannelsAndMembers,
Space,
SpaceWithChannelsAndMembers,
Channel,
MessageWithUser,
MemberWithUser,
Attachment,
DmChannel,
DmMessageWithUser,
CreateServerRequest,
UpdateServerRequest,
CreateSpaceRequest,
UpdateSpaceRequest,
CreateChannelRequest,
UpdateChannelRequest,
CreateMessageRequest,
UpdateMessageRequest,
UpdateUserRequest,
JoinServerRequest,
JoinSpaceRequest,
UpdateMemberRequest,
LiveKitTokenResponse,
CreateDmRequest,
@@ -28,7 +28,7 @@ import type {
InstanceStreamingLimits,
InstanceInfoResponse,
VerifyPasswordResponse,
ExploreServer,
ExploreSpace,
JoinRequest,
} from '@backspace/shared';
@@ -45,23 +45,23 @@ export class BackspaceApiClient {
verifyPassword: (password: string) => Promise<VerifyPasswordResponse>;
};
readonly servers: {
list: () => Promise<Server[]>;
get: (id: string) => Promise<ServerWithChannelsAndMembers>;
create: (data: CreateServerRequest) => Promise<Server>;
update: (id: string, data: UpdateServerRequest) => Promise<Server>;
readonly spaces: {
list: () => Promise<Space[]>;
get: (id: string) => Promise<SpaceWithChannelsAndMembers>;
create: (data: CreateSpaceRequest) => Promise<Space>;
update: (id: string, data: UpdateSpaceRequest) => Promise<Space>;
delete: (id: string) => Promise<{ success: boolean }>;
invite: (id: string) => Promise<{ inviteCode: string }>;
join: (id: string, data: JoinServerRequest) => Promise<Server>;
joinByCode: (inviteCode: string) => Promise<Server>;
join: (id: string, data: JoinSpaceRequest) => Promise<Space>;
joinByCode: (inviteCode: string) => Promise<Space>;
members: (id: string) => Promise<MemberWithUser[]>;
updateMember: (serverId: string, userId: string, data: UpdateMemberRequest) => Promise<MemberWithUser>;
removeMember: (serverId: string, userId: string) => Promise<{ success: boolean }>;
updateMember: (spaceId: string, userId: string, data: UpdateMemberRequest) => Promise<MemberWithUser>;
removeMember: (spaceId: string, userId: string) => Promise<{ success: boolean }>;
};
readonly channels: {
list: (serverId: string) => Promise<Channel[]>;
create: (serverId: string, data: CreateChannelRequest) => Promise<Channel>;
list: (spaceId: string) => Promise<Channel[]>;
create: (spaceId: string, data: CreateChannelRequest) => Promise<Channel>;
update: (id: string, data: UpdateChannelRequest) => Promise<Channel>;
delete: (id: string) => Promise<{ success: boolean }>;
messages: (id: string, before?: string, limit?: number) => Promise<MessageWithUser[]>;
@@ -118,11 +118,11 @@ export class BackspaceApiClient {
};
readonly explore: {
list: (q?: string, limit?: number, offset?: number) => Promise<{ servers: ExploreServer[]; total: number; totalAll: number; discoveryEnabled: boolean }>;
publicJoin: (serverId: string) => Promise<ServerWithChannelsAndMembers>;
requestJoin: (serverId: string, message?: string) => Promise<JoinRequest>;
getJoinRequests: (serverId: string, status?: string) => Promise<{ requests: JoinRequest[] }>;
decideJoinRequest: (serverId: string, requestId: string, action: 'accept' | 'decline') => Promise<JoinRequest>;
list: (q?: string, limit?: number, offset?: number) => Promise<{ spaces: ExploreSpace[]; total: number; totalAll: number; discoveryEnabled: boolean }>;
publicJoin: (spaceId: string) => Promise<SpaceWithChannelsAndMembers>;
requestJoin: (spaceId: string, message?: string) => Promise<JoinRequest>;
getJoinRequests: (spaceId: string, status?: string) => Promise<{ requests: JoinRequest[] }>;
decideJoinRequest: (spaceId: string, requestId: string, action: 'accept' | 'decline') => Promise<JoinRequest>;
myJoinRequests: (status?: string) => Promise<{ requests: JoinRequest[] }>;
};
@@ -199,26 +199,26 @@ export class BackspaceApiClient {
request<VerifyPasswordResponse>('POST', '/users/@me/verify-password', { password }),
};
this.servers = {
list: () => request<Server[]>('GET', '/servers'),
get: (id: string) => request<ServerWithChannelsAndMembers>('GET', `/servers/${id}`),
create: (data: CreateServerRequest) => request<Server>('POST', '/servers', data),
update: (id: string, data: UpdateServerRequest) => request<Server>('PATCH', `/servers/${id}`, data),
delete: (id: string) => request<{ success: boolean }>('DELETE', `/servers/${id}`),
invite: (id: string) => request<{ inviteCode: string }>('POST', `/servers/${id}/invite`),
join: (id: string, data: JoinServerRequest) => request<Server>('POST', `/servers/${id}/join`, data),
joinByCode: (inviteCode: string) => request<Server>('POST', '/servers/join', { inviteCode }),
members: (id: string) => request<MemberWithUser[]>('GET', `/servers/${id}/members`),
updateMember: (serverId: string, userId: string, data: UpdateMemberRequest) =>
request<MemberWithUser>('PATCH', `/servers/${serverId}/members/${userId}`, data),
removeMember: (serverId: string, userId: string) =>
request<{ success: boolean }>('DELETE', `/servers/${serverId}/members/${userId}`),
this.spaces = {
list: () => request<Space[]>('GET', '/spaces'),
get: (id: string) => request<SpaceWithChannelsAndMembers>('GET', `/spaces/${id}`),
create: (data: CreateSpaceRequest) => request<Space>('POST', '/spaces', data),
update: (id: string, data: UpdateSpaceRequest) => request<Space>('PATCH', `/spaces/${id}`, data),
delete: (id: string) => request<{ success: boolean }>('DELETE', `/spaces/${id}`),
invite: (id: string) => request<{ inviteCode: string }>('POST', `/spaces/${id}/invite`),
join: (id: string, data: JoinSpaceRequest) => request<Space>('POST', `/spaces/${id}/join`, data),
joinByCode: (inviteCode: string) => request<Space>('POST', '/spaces/join', { inviteCode }),
members: (id: string) => request<MemberWithUser[]>('GET', `/spaces/${id}/members`),
updateMember: (spaceId: string, userId: string, data: UpdateMemberRequest) =>
request<MemberWithUser>('PATCH', `/spaces/${spaceId}/members/${userId}`, data),
removeMember: (spaceId: string, userId: string) =>
request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/members/${userId}`),
};
this.channels = {
list: (serverId: string) => request<Channel[]>('GET', `/servers/${serverId}/channels`),
create: (serverId: string, data: CreateChannelRequest) =>
request<Channel>('POST', `/servers/${serverId}/channels`, data),
list: (spaceId: string) => request<Channel[]>('GET', `/spaces/${spaceId}/channels`),
create: (spaceId: string, data: CreateChannelRequest) =>
request<Channel>('POST', `/spaces/${spaceId}/channels`, data),
update: (id: string, data: UpdateChannelRequest) => request<Channel>('PATCH', `/channels/${id}`, data),
delete: (id: string) => request<{ success: boolean }>('DELETE', `/channels/${id}`),
messages: (id: string, before?: string, limit = 50) => {
@@ -305,21 +305,21 @@ export class BackspaceApiClient {
if (q) params.set('q', q);
params.set('limit', String(limit));
params.set('offset', String(offset));
return request<{ servers: ExploreServer[]; total: number; totalAll: number; discoveryEnabled: boolean }>(
'GET', `/servers/explore?${params}`
return request<{ spaces: ExploreSpace[]; total: number; totalAll: number; discoveryEnabled: boolean }>(
'GET', `/spaces/explore?${params}`
);
},
publicJoin: (serverId: string) =>
request<ServerWithChannelsAndMembers>('POST', `/servers/${serverId}/public-join`),
requestJoin: (serverId: string, message?: string) =>
request<JoinRequest>('POST', `/servers/${serverId}/request-join`, message ? { message } : {}),
getJoinRequests: (serverId: string, status?: string) => {
publicJoin: (spaceId: string) =>
request<SpaceWithChannelsAndMembers>('POST', `/spaces/${spaceId}/public-join`),
requestJoin: (spaceId: string, message?: string) =>
request<JoinRequest>('POST', `/spaces/${spaceId}/request-join`, message ? { message } : {}),
getJoinRequests: (spaceId: string, status?: string) => {
const params = new URLSearchParams();
if (status) params.set('status', status);
return request<{ requests: JoinRequest[] }>('GET', `/servers/${serverId}/join-requests?${params}`);
return request<{ requests: JoinRequest[] }>('GET', `/spaces/${spaceId}/join-requests?${params}`);
},
decideJoinRequest: (serverId: string, requestId: string, action: 'accept' | 'decline') =>
request<JoinRequest>('PATCH', `/servers/${serverId}/join-requests/${requestId}`, { action }),
decideJoinRequest: (spaceId: string, requestId: string, action: 'accept' | 'decline') =>
request<JoinRequest>('PATCH', `/spaces/${spaceId}/join-requests/${requestId}`, { action }),
myJoinRequests: (status?: string) => {
const params = new URLSearchParams();
if (status) params.set('status', status);
@@ -1,23 +1,23 @@
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useExploreStore, type TaggedExploreServer } from '../../stores/exploreStore';
import { useServerStore } from '../../stores/serverStore';
import { useExploreStore, type TaggedExploreSpace } from '../../stores/exploreStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { LoadingSpinner } from '../ui/LoadingSpinner';
import { getServerGradient } from '../../utils/gradients';
import { getSpaceGradient } from '../../utils/gradients';
import { MemberListToggleButton } from '../layout/MemberListToggleButton';
export function ExplorePage() {
const navigate = useNavigate();
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
const servers = useExploreStore((s) => s.servers);
const spaces = useExploreStore((s) => s.spaces);
const myRequests = useExploreStore((s) => s.myRequests);
const isLoading = useExploreStore((s) => s.isLoading);
const discoveryEnabled = useExploreStore((s) => s.discoveryEnabled);
const error = useExploreStore((s) => s.error);
const searchQuery = useExploreStore((s) => s.searchQuery);
const setSearchQuery = useExploreStore((s) => s.setSearchQuery);
const fetchServers = useExploreStore((s) => s.fetchServers);
const fetchSpaces = useExploreStore((s) => s.fetchSpaces);
const fetchMyRequests = useExploreStore((s) => s.fetchMyRequests);
const [joinedCollapsed, setJoinedCollapsed] = useState(false);
@@ -26,18 +26,18 @@ export function ExplorePage() {
// Fetch on mount
useEffect(() => {
fetchServers();
fetchSpaces();
fetchMyRequests();
}, [fetchServers, fetchMyRequests]);
}, [fetchSpaces, fetchMyRequests]);
// Debounced search
const handleSearchChange = useCallback((value: string) => {
setSearchQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
fetchServers(value || undefined);
fetchSpaces(value || undefined);
}, 300);
}, [setSearchQuery, fetchServers]);
}, [setSearchQuery, fetchSpaces]);
useEffect(() => {
return () => {
@@ -45,23 +45,23 @@ export function ExplorePage() {
};
}, []);
const handleJoinSuccess = (serverId: string) => {
setCurrentServer(serverId);
navigate(`/channels/${serverId}`);
const handleJoinSuccess = (spaceId: string) => {
setCurrentSpace(spaceId);
navigate(`/channels/${spaceId}`);
};
const unjoinedServers = useMemo(
() => servers.filter(s => !s.joined),
[servers],
const unjoinedSpaces = useMemo(
() => spaces.filter(s => !s.joined),
[spaces],
);
const joinedServers = useMemo(
() => servers.filter(s => s.joined),
[servers],
const joinedSpaces = useMemo(
() => spaces.filter(s => s.joined),
[spaces],
);
const hasAnyServers = servers.length > 0;
const hasUnjoined = unjoinedServers.length > 0;
const hasJoined = joinedServers.length > 0;
const hasAnySpaces = spaces.length > 0;
const hasUnjoined = unjoinedSpaces.length > 0;
const hasJoined = joinedSpaces.length > 0;
return (
<div className="flex-1 flex flex-col bg-surface-chat h-full">
@@ -79,7 +79,7 @@ export function ExplorePage() {
<div className="relative flex-1 max-w-xs ml-2">
<input
type="text"
placeholder="Search servers..."
placeholder="Search spaces..."
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
className="w-full bg-surface-base text-txt-primary text-sm px-3 py-1.5 rounded-[4px] outline-none placeholder:text-txt-tertiary/50 focus:ring-1 focus:ring-accent-primary transition-all"
@@ -105,11 +105,11 @@ export function ExplorePage() {
<div className="flex-1 overflow-y-auto">
{!discoveryEnabled && (
<div className="mx-6 mt-4 p-2.5 bg-accent-amber/10 border border-accent-amber/30 rounded text-[13px] text-accent-amber">
Server discovery is disabled by the instance administrator.
Space discovery is disabled by the instance administrator.
</div>
)}
{isLoading && servers.length === 0 ? (
{isLoading && spaces.length === 0 ? (
<div className="flex-1 flex items-center justify-center h-64">
<LoadingSpinner />
</div>
@@ -117,47 +117,47 @@ export function ExplorePage() {
<div className="mx-6 mt-4 p-3 bg-accent-rose/10 border border-accent-rose/30 rounded text-sm text-txt-danger">
{error}
</div>
) : !hasAnyServers ? (
/* True empty state — no discoverable servers at all */
) : !hasAnySpaces ? (
/* True empty state — no discoverable spaces at all */
<div className="flex flex-col items-center justify-center h-64 opacity-60">
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary mb-3">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-5.5-2.5l7.51-3.49L17.5 6.5 9.99 9.99 6.5 17.5zm5.5-6.6c.61 0 1.1.49 1.1 1.1s-.49 1.1-1.1 1.1-1.1-.49-1.1-1.1.49-1.1 1.1-1.1z" />
</svg>
<p className="text-txt-tertiary text-sm">
{searchQuery
? 'No servers match your search.'
: 'No servers have been made discoverable yet.'}
? 'No spaces match your search.'
: 'No spaces have been made discoverable yet.'}
</p>
</div>
) : (
<div className="p-6 space-y-6">
{/* All-joined success banner (only when no unjoined servers remain) */}
{/* All-joined success banner (only when no unjoined spaces remain) */}
{!hasUnjoined && hasJoined && !searchQuery && (
<div className="flex items-center gap-2.5 px-4 py-2.5 bg-accent-mint/10 border border-accent-mint/20 rounded-lg">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="text-accent-mint flex-shrink-0">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
<span className="text-[13px] text-accent-mint">
You're in all discoverable servers
You're in all discoverable spaces
</span>
</div>
)}
{/* Unjoined servers grid */}
{/* Unjoined spaces grid */}
{hasUnjoined && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{unjoinedServers.map((server) => (
<ServerCard
key={`${server.id}:${server._instanceOrigin}`}
server={server}
isPending={myRequests.some(r => r.serverId === server.id && r.status === 'pending')}
{unjoinedSpaces.map((space) => (
<SpaceCard
key={`${space.id}:${space._instanceOrigin}`}
space={space}
isPending={myRequests.some(r => r.spaceId === space.id && r.status === 'pending')}
onJoinSuccess={handleJoinSuccess}
/>
))}
</div>
)}
{/* Joined servers section */}
{/* Joined spaces section */}
{hasJoined && (
<div>
<button
@@ -177,16 +177,16 @@ export function ExplorePage() {
Joined
</span>
<span className="text-xs text-txt-tertiary/60">
{joinedServers.length}
{joinedSpaces.length}
</span>
</button>
{!joinedCollapsed && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{joinedServers.map((server) => (
<ServerCard
key={`${server.id}:${server._instanceOrigin}`}
server={server}
{joinedSpaces.map((space) => (
<SpaceCard
key={`${space.id}:${space._instanceOrigin}`}
space={space}
isPending={false}
onJoinSuccess={handleJoinSuccess}
/>
@@ -202,14 +202,14 @@ export function ExplorePage() {
);
}
function ServerCard({
server,
function SpaceCard({
space,
isPending,
onJoinSuccess,
}: {
server: TaggedExploreServer;
space: TaggedExploreSpace;
isPending: boolean;
onJoinSuccess: (serverId: string) => void;
onJoinSuccess: (spaceId: string) => void;
}) {
const publicJoin = useExploreStore((s) => s.publicJoin);
const requestJoin = useExploreStore((s) => s.requestJoin);
@@ -220,19 +220,19 @@ function ServerCard({
const [requestSent, setRequestSent] = useState(isPending);
const [joinError, setJoinError] = useState('');
const gradient = getServerGradient(server.id, server.name);
const isPublic = server.visibility === 'public';
const isJoined = server.joined === true;
const originLabel = server._instanceOrigin
? (() => { try { return new URL(server._instanceOrigin).host; } catch { return server._instanceOrigin; } })()
const gradient = getSpaceGradient(space.id, space.name);
const isPublic = space.visibility === 'public';
const isJoined = space.joined === true;
const originLabel = space._instanceOrigin
? (() => { try { return new URL(space._instanceOrigin).host; } catch { return space._instanceOrigin; } })()
: null;
const handlePublicJoin = async () => {
setJoining(true);
setJoinError('');
try {
const fullServer = await publicJoin(server);
onJoinSuccess(fullServer.id);
const fullSpace = await publicJoin(space);
onJoinSuccess(fullSpace.id);
} catch (err) {
setJoinError(err instanceof Error ? err.message : 'Failed to join');
setJoining(false);
@@ -243,7 +243,7 @@ function ServerCard({
setJoining(true);
setJoinError('');
try {
await requestJoin(server, requestMessage.trim() || undefined);
await requestJoin(space, requestMessage.trim() || undefined);
setRequestSent(true);
setShowRequestForm(false);
} catch (err) {
@@ -253,8 +253,8 @@ function ServerCard({
}
};
const handleViewServer = () => {
onJoinSuccess(server.id);
const handleViewSpace = () => {
onJoinSuccess(space.id);
};
return (
@@ -265,15 +265,15 @@ function ServerCard({
}`}>
{/* Banner / Icon area */}
<div className="h-32 relative flex items-center justify-center" style={{ background: gradient.gradient }}>
{server.icon ? (
{space.icon ? (
<img
src={server.icon.startsWith('http') ? server.icon : `/api/uploads/${server.icon}`}
alt={server.name}
src={space.icon.startsWith('http') ? space.icon : `/api/uploads/${space.icon}`}
alt={space.name}
className="w-16 h-16 rounded-2xl object-cover shadow-lg"
/>
) : (
<span className="text-3xl font-bold text-white/90 drop-shadow-md">
{server.name.charAt(0).toUpperCase()}
{space.name.charAt(0).toUpperCase()}
</span>
)}
@@ -312,11 +312,11 @@ function ServerCard({
{/* Content */}
<div className="p-4 flex flex-col flex-1">
<h3 className="text-[15px] font-bold text-txt-primary truncate mb-1">{server.name}</h3>
<h3 className="text-[15px] font-bold text-txt-primary truncate mb-1">{space.name}</h3>
{server.description ? (
{space.description ? (
<p className="text-[13px] text-txt-secondary line-clamp-2 mb-3 flex-1">
{server.description}
{space.description}
</p>
) : (
<p className="text-[13px] text-txt-tertiary italic mb-3 flex-1">No description</p>
@@ -327,7 +327,7 @@ function ServerCard({
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="opacity-60">
<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" />
</svg>
{server.memberCount} {server.memberCount === 1 ? 'member' : 'members'}
{space.memberCount} {space.memberCount === 1 ? 'member' : 'members'}
</span>
</div>
@@ -338,10 +338,10 @@ function ServerCard({
{isJoined ? (
<button
onClick={handleViewServer}
onClick={handleViewSpace}
className="w-full py-2 bg-accent-mint/15 hover:bg-accent-mint/25 text-accent-mint text-sm font-medium rounded transition-colors"
>
View Server
View Space
</button>
) : isPublic ? (
<button
@@ -355,7 +355,7 @@ function ServerCard({
Joining...
</span>
) : (
'Join Server'
'Join Space'
)}
</button>
) : requestSent ? (
@@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { FriendsPage } from './FriendsPage';
import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import type { Friend, FriendRequest } from '@backspace/shared';
// Mock the api module
@@ -100,7 +100,7 @@ beforeEach(() => {
loadFriends: vi.fn(),
loadRequests: vi.fn(),
});
useServerStore.setState({
useSpaceStore.setState({
dmChannels: [],
});
});
@@ -177,7 +177,7 @@ describe('FriendsPage', () => {
friends: [friend],
requests: [],
});
useServerStore.setState({
useSpaceStore.setState({
addDmChannel: mockAddDmChannel,
});
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useInstanceStore } from '../../stores/instanceStore';
import { useUIStore } from '../../stores/uiStore';
import { Avatar } from '../ui/Avatar';
@@ -16,7 +16,7 @@ export function FriendsPage() {
const [addUsername, setAddUsername] = useState('');
const [addStatus, setAddStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null);
const navigate = useNavigate();
const addDmChannel = useServerStore((s) => s.addDmChannel);
const addDmChannel = useSpaceStore((s) => s.addDmChannel);
const {
friends,
@@ -55,7 +55,7 @@ export function FriendsPage() {
const handleOpenDm = async (friendId: string, instanceOrigin: string) => {
try {
// Check if a DM already exists with this user (on any instance)
const existing = useServerStore.getState().findExistingDmForUser({ id: friendId });
const existing = useSpaceStore.getState().findExistingDmForUser({ id: friendId });
if (existing) {
useUIStore.getState().setShowDms(true);
navigate(`/channels/@me/${existing.dm.id}`);
@@ -1,5 +1,5 @@
import React from 'react';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore';
interface MentionBadgeProps {
@@ -7,14 +7,14 @@ interface MentionBadgeProps {
}
export const MentionBadge = React.memo(function MentionBadge({ userId }: MentionBadgeProps) {
const members = useServerStore((s) => s.members);
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const members = useSpaceStore((s) => s.members);
const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const openUserProfile = useUIStore((s) => s.openUserProfile);
const member = members.find((m) => m.userId === userId);
const server = servers.find((s) => s.id === currentServerId);
const ownerId = server?.ownerId;
const space = spaces.find((s) => s.id === currentSpaceId);
const ownerId = space?.ownerId;
let displayName: string;
let color: string;
@@ -1,7 +1,7 @@
import React, { useMemo, useRef, useEffect } from 'react';
import type { MemberWithUser } from '@backspace/shared';
import { Avatar } from '../ui/Avatar';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
const MAX_RESULTS = 8;
@@ -12,12 +12,12 @@ interface MentionPopoverProps {
}
export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopoverProps) {
const members = useServerStore((s) => s.members);
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const members = useSpaceStore((s) => s.members);
const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const selectedRef = useRef<HTMLDivElement>(null);
const ownerId = servers.find((s) => s.id === currentServerId)?.ownerId;
const ownerId = spaces.find((s) => s.id === currentSpaceId)?.ownerId;
const filtered = useMemo(() => {
const q = query.toLowerCase();
+6 -6
View File
@@ -5,7 +5,7 @@ import { Avatar } from '../ui/Avatar';
import { ContextMenu } from '../ui/ContextMenu';
import { useAuthStore } from '../../stores/authStore';
import { useChatStore } from '../../stores/chatStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore';
import { Embed } from './Embed';
import { Username } from '../ui/Username';
@@ -44,13 +44,13 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
const currentUser = useAuthStore((s) => s.user);
const editMessage = useChatStore((s) => s.editMessage);
const deleteMessage = useChatStore((s) => s.deleteMessage);
const members = useServerStore((s) => s.members);
const members = useSpaceStore((s) => s.members);
const openImagePreview = useUIStore((s) => s.openImagePreview);
const openUserProfile = useUIStore((s) => s.openUserProfile);
const channelKey = message.channelId || (message as any).dmChannelId;
const isAuthor = isSelf(message.user, currentUser);
const channelPermissions = useServerStore((s) => s.channelPermissions);
const channelPermissions = useSpaceStore((s) => s.channelPermissions);
const myChPerms = channelPermissions.get(message.channelId);
const canManageMessages = hasPermissionBit(myChPerms, PermissionBits.MANAGE_MESSAGES);
const canDelete = isAuthor || canManageMessages;
@@ -130,9 +130,9 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
const displayIdentity = resolveDisplayIdentity(message.user, currentUser);
const displayName = displayIdentity.displayName ?? displayIdentity.username;
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const ownerId = servers.find(s => s.id === currentServerId)?.ownerId;
const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const ownerId = spaces.find(s => s.id === currentSpaceId)?.ownerId;
const getMemberDisplayColor = (userId: string) => {
const member = members.find(m => m.userId === userId);
@@ -1,6 +1,6 @@
import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { useChatStore } from '../../stores/chatStore';
import { isDmChannel, getChannelOrigin, getApiForOrigin, useServerStore } from '../../stores/serverStore';
import { isDmChannel, getChannelOrigin, getApiForOrigin, useSpaceStore } from '../../stores/spaceStore';
import { wsSend } from '../../hooks/useWebSocket';
import { MentionPopover } from './MentionPopover';
import { TypingIndicator } from './TypingIndicator';
@@ -27,7 +27,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
const sendMessage = useChatStore((s) => s.sendMessage);
const replyTo = useChatStore((s) => s.replyTo);
const setReplyTo = useChatStore((s) => s.setReplyTo);
const members = useServerStore((s) => s.members);
const members = useSpaceStore((s) => s.members);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
// Auto-focus textarea on channel navigation
@@ -1,7 +1,7 @@
import React, { useEffect, useRef, useCallback, useState, useMemo } from 'react';
import { Message } from './Message';
import { useChatStore } from '../../stores/chatStore';
import { useServerStore, isDmChannel } from '../../stores/serverStore';
import { useSpaceStore, isDmChannel } from '../../stores/spaceStore';
import { useAuthStore } from '../../stores/authStore';
import { useSocialStore } from '../../stores/socialStore';
import { Avatar } from '../ui/Avatar';
@@ -169,7 +169,7 @@ export function MessageList({ channelId }: MessageListProps) {
}
function WelcomeHeader({ channelId }: { channelId: string }) {
const dmChannels = useServerStore((s) => s.dmChannels);
const dmChannels = useSpaceStore((s) => s.dmChannels);
const authUser = useAuthStore((s) => s.user);
const removeFriend = useSocialStore((s) => s.removeFriend);
const friends = useSocialStore((s) => s.friends);
@@ -1,17 +1,17 @@
import React, { useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ServerSidebar } from './ServerSidebar';
import { SpaceSidebar } from './SpaceSidebar';
import { ChannelSidebar } from './ChannelSidebar';
import { MainContent } from './MainContent';
import { RightPanel } from './RightPanel';
import { MobileNav } from './MobileNav';
import { ImagePreview } from '../chat/ImagePreview';
import { CreateServerModal } from '../modals/CreateServer';
import { JoinServerModal } from '../modals/JoinServer';
import { CreateSpaceModal } from '../modals/CreateSpace';
import { JoinSpaceModal } from '../modals/JoinSpace';
import { CreateChannelModal } from '../modals/CreateChannel';
import { InviteModal } from '../modals/InviteModal';
import { UserSettingsModal } from '../modals/UserSettings';
import { ServerSettingsModal } from '../modals/ServerSettings';
import { SpaceSettingsModal } from '../modals/SpaceSettings';
import { ChannelSettingsModal } from '../modals/ChannelSettingsModal';
import { NewDmModal } from '../modals/NewDmModal';
import { AddDmMemberModal } from '../modals/AddDmMemberModal';
@@ -25,14 +25,14 @@ import { useAuth } from '../../hooks/useAuth';
import { useWebSocket } from '../../hooks/useWebSocket';
import { useFederationToasts } from '../../hooks/useFederationToasts';
import { useLiveKit } from '../../hooks/useLiveKit';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { useVoiceStore } from '../../stores/voiceStore';
import { AudioManager } from '../../audio/AudioManager';
export function AppLayout() {
const { serverId, channelId, inviteCode } = useParams<{ serverId?: string; channelId?: string; inviteCode?: string }>();
const { spaceId, channelId, inviteCode } = useParams<{ spaceId?: string; channelId?: string; inviteCode?: string }>();
const navigate = useNavigate();
// Global interaction handler to resume AudioContext
@@ -83,8 +83,8 @@ export function AppLayout() {
}, [outputDeviceId]);
const { user, isLoading } = useAuth();
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const loadMessages = useChatStore((s) => s.loadMessages);
const setIsMobile = useUIStore((s) => s.setIsMobile);
@@ -96,7 +96,7 @@ export function AppLayout() {
const userProfilePopout = useUIStore((s) => s.userProfilePopout);
const closeUserProfile = useUIStore((s) => s.closeUserProfile);
const channels = useServerStore((s) => s.channels);
const channels = useSpaceStore((s) => s.channels);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
@@ -185,22 +185,22 @@ export function AppLayout() {
// Handle route params
useEffect(() => {
if (serverId === '@me') {
if (spaceId === '@me') {
setShowDms(true);
setCurrentServer(null);
} else if (serverId) {
setCurrentSpace(null);
} else if (spaceId) {
setShowDms(false);
setCurrentServer(serverId);
loadServerDetail(serverId);
setCurrentSpace(spaceId);
loadSpaceDetail(spaceId);
} else {
setShowDms(false);
setCurrentServer(null);
setCurrentSpace(null);
}
}, [serverId, setCurrentServer, loadServerDetail, setShowDms]);
}, [spaceId, setCurrentSpace, loadSpaceDetail, setShowDms]);
useEffect(() => {
if (inviteCode) {
openModal('joinServer');
openModal('joinSpace');
}
}, [inviteCode, openModal]);
@@ -208,35 +208,35 @@ export function AppLayout() {
if (channelId) {
setCurrentChannel(channelId);
loadMessages(channelId);
if (serverId && serverId !== '@me') {
useUIStore.getState().setLastChannel(serverId, channelId);
if (spaceId && spaceId !== '@me') {
useUIStore.getState().setLastChannel(spaceId, channelId);
}
} else {
setCurrentChannel(null);
}
}, [channelId, serverId, setCurrentChannel, loadMessages]);
}, [channelId, spaceId, setCurrentChannel, loadMessages]);
// Auto-select last visited (or first) channel when opening a server without a channelId
useEffect(() => {
if (!serverId || serverId === '@me' || channelId) return;
if (!spaceId || spaceId === '@me' || channelId) return;
if (channels.length === 0) return;
const firstChannel = channels[0];
if (!firstChannel) return;
// Guard: only redirect when channels belong to the target server.
// `channels` is shared per-view state set by loadServerDetail (async).
// `channels` is shared per-view state set by loadSpaceDetail (async).
// Without this check, stale channels from a previously viewed server
// would cause a wrong redirect during the fetch window.
const { channelToServerMap } = useServerStore.getState();
if (channelToServerMap.get(firstChannel.id) !== serverId) return;
const { channelToSpaceMap } = useSpaceStore.getState();
if (channelToSpaceMap.get(firstChannel.id) !== spaceId) return;
const lastId = useUIStore.getState().lastChannelPerServer[serverId];
const lastId = useUIStore.getState().lastChannelPerSpace[spaceId];
const target = (lastId && channels.find((c) => c.id === lastId)) || firstChannel;
if (target) {
navigate(`/channels/${serverId}/${target.id}`, { replace: true });
navigate(`/channels/${spaceId}/${target.id}`, { replace: true });
}
}, [serverId, channelId, channels, navigate]);
}, [spaceId, channelId, channels, navigate]);
if (isLoading || !user) {
return (
@@ -254,9 +254,9 @@ export function AppLayout() {
return (
<div className="h-screen flex flex-col md:grid md:grid-cols-[312px_1fr] md:grid-rows-[minmax(0,1fr)] bg-surface-base overflow-hidden">
{/* Server sidebar - always visible on desktop, toggled on mobile */}
{/* Space sidebar - always visible on desktop, toggled on mobile */}
<div className={`fixed inset-y-0 left-0 z-40 flex w-[312px] transition-transform duration-200 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'} md:static md:z-auto md:w-auto md:transform-none`}>
<ServerSidebar />
<SpaceSidebar />
<ChannelSidebar />
</div>
@@ -267,12 +267,12 @@ export function AppLayout() {
</div>
{/* Modals */}
<CreateServerModal />
<JoinServerModal />
<CreateSpaceModal />
<JoinSpaceModal />
<CreateChannelModal />
<InviteModal />
<UserSettingsModal />
<ServerSettingsModal />
<SpaceSettingsModal />
<ChannelSettingsModal />
<NewDmModal />
<AddDmMemberModal />
@@ -1,6 +1,6 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useServerStore, getChannelOrigin } from '../../stores/serverStore';
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
@@ -18,16 +18,16 @@ import { parseFederatedUsername, isSelf } from '../../utils/identity';
import { joinVoiceChannel } from '../../utils/voice';
export function ChannelSidebar() {
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const channels = useServerStore((s) => s.channels);
const dmChannels = useServerStore((s) => s.dmChannels);
const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const channels = useSpaceStore((s) => s.channels);
const dmChannels = useSpaceStore((s) => s.dmChannels);
const currentChannelId = useChatStore((s) => s.currentChannelId);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const unreadChannels = useChatStore((s) => s.unreadChannels);
const openModal = useUIStore((s) => s.openModal);
const user = useAuthStore((s) => s.user);
const members = useServerStore((s) => s.members);
const members = useSpaceStore((s) => s.members);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
const isMuted = useVoiceStore((s) => s.isMuted);
@@ -72,26 +72,26 @@ export function ChannelSidebar() {
}
};
const serverPermissions = useServerStore((s) => s.serverPermissions);
const server = servers.find(s => s.id === currentServerId);
const myServerPerms = currentServerId ? serverPermissions.get(currentServerId) : undefined;
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
const space = spaces.find(s => s.id === currentSpaceId);
const mySpacePerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined;
const federationInstances = useInstanceStore((s) => s.instances);
const instanceLabel = useMemo(() => {
const origin = (server as any)?._instanceOrigin;
const origin = (space as any)?._instanceOrigin;
if (!origin) return null;
const inst = federationInstances.find(i => i.origin === origin);
if (inst) return inst.label;
try { return new URL(origin).host; } catch { return origin; }
}, [server, federationInstances]);
const canManageChannels = hasPermissionBit(myServerPerms, PermissionBits.MANAGE_CHANNELS);
}, [space, federationInstances]);
const canManageChannels = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_CHANNELS);
const textChannels = channels.filter(c => c.type === 'text');
const voiceChannels = channels.filter(c => c.type === 'voice' || c.type === 'video');
const handleChannelClick = (channelId: string) => {
setCurrentChannel(channelId);
navigate(`/channels/${currentServerId || '@me'}/${channelId}`);
navigate(`/channels/${currentSpaceId || '@me'}/${channelId}`);
};
const handleHomeClick = () => {
@@ -102,11 +102,11 @@ export function ChannelSidebar() {
const handleVoiceJoin = (channelId: string) => {
// Don't re-join the same channel — prevents duplicate LiveKit connections
if (currentVoiceChannelId === channelId) {
navigate(`/channels/${currentServerId}/${channelId}`);
navigate(`/channels/${currentSpaceId}/${channelId}`);
return;
}
joinVoiceChannel(channelId);
navigate(`/channels/${currentServerId}/${channelId}`);
navigate(`/channels/${currentSpaceId}/${channelId}`);
};
// Floating bottom panel — shared between DM view and server view
@@ -130,7 +130,7 @@ export function ChannelSidebar() {
</div>
) : null;
if (!server) {
if (!space) {
return (
<>
<div className="w-60 md:w-full bg-surface-channel flex flex-col flex-shrink-0 select-none md:pl-[72px] border-r border-border-hard">
@@ -260,7 +260,7 @@ export function ChannelSidebar() {
navigate('/channels/@me');
setCurrentChannel(null);
}
useServerStore.getState().closeDm(dm.id);
useSpaceStore.getState().closeDm(dm.id);
}}
className="opacity-0 group-hover:opacity-100 text-txt-tertiary hover:text-txt-primary transition-opacity flex-shrink-0 ml-1"
title="Close DM"
@@ -287,14 +287,14 @@ export function ChannelSidebar() {
return (
<>
<div className="w-60 md:w-full bg-surface-channel flex flex-col flex-shrink-0 select-none md:pl-[72px] border-r border-border-hard">
{/* Server header */}
{/* Space header */}
<div className="h-12 flex items-center border-b border-border-hard z-10 group/header">
<button
onClick={() => openModal('serverSettings')}
onClick={() => openModal('spaceSettings')}
className="flex-1 h-full px-4 flex items-center justify-between hover:bg-interactive-hover transition-colors min-w-0"
>
<div className="min-w-0">
<span className="font-bold text-[15px] tracking-[-0.02em] text-txt-primary truncate leading-tight block">{server.name}</span>
<span className="font-bold text-[15px] tracking-[-0.02em] text-txt-primary truncate leading-tight block">{space.name}</span>
{instanceLabel && (
<span className="text-[10px] text-txt-tertiary font-medium truncate block leading-tight">
{instanceLabel}
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
@@ -20,9 +20,9 @@ import { joinVoiceChannel } from '../../utils/voice';
export function MainContent() {
// 1. ALL HOOKS AT THE TOP
const channels = useServerStore((s) => s.channels);
const channels = useSpaceStore((s) => s.channels);
const currentChannelId = useChatStore((s) => s.currentChannelId);
const currentServerId = useServerStore((s) => s.currentServerId);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const voiceChatOpen = useUIStore((s) => s.voiceChatOpen);
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
const setVoiceFullscreen = useUIStore((s) => s.setVoiceFullscreen);
@@ -35,7 +35,7 @@ export function MainContent() {
const isExplorePage = location.pathname === '/explore';
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
const outgoingCall = useVoiceStore((s) => s.outgoingCall);
const dmChannels = useServerStore((s) => s.dmChannels);
const dmChannels = useSpaceStore((s) => s.dmChannels);
const authUser = useAuthStore((s) => s.user);
const openModal = useUIStore((s) => s.openModal);
@@ -64,7 +64,7 @@ export function MainContent() {
const channel = channels.find(c => c.id === currentChannelId);
const isVoiceChannel = channel?.type === 'voice' || channel?.type === 'video';
if (showDms || isExplorePage || !currentServerId) {
if (showDms || isExplorePage || !currentSpaceId) {
if (!currentChannelId) {
if (isExplorePage) return <ExplorePage />;
return <FriendsPage />;
@@ -1,6 +1,6 @@
import React, { useMemo } from 'react';
import type { MemberWithUser } from '@backspace/shared';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore';
import { Avatar } from '../ui/Avatar';
import { Username } from '../ui/Username';
@@ -42,14 +42,14 @@ function getMemberGroup(member: MemberWithUser, ownerId: string | undefined) {
}
export function MemberSidebar() {
const members = useServerStore((s) => s.members);
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const members = useSpaceStore((s) => s.members);
const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const memberListOpen = useUIStore((s) => s.memberListOpen);
const openUserProfile = useUIStore((s) => s.openUserProfile);
const server = servers.find(s => s.id === currentServerId);
const ownerId = server?.ownerId;
const space = spaces.find(s => s.id === currentSpaceId);
const ownerId = space?.ownerId;
const { roleGroups, offlineMembers } = useMemo(() => {
const online = members.filter(m => m.user.status !== 'offline');
@@ -2,13 +2,13 @@ import React from 'react';
import { MemberSidebar } from './MemberSidebar';
import { ActivityPanel } from './ActivityPanel';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
export function RightPanel() {
const showDms = useUIStore((s) => s.showDms);
const currentServerId = useServerStore((s) => s.currentServerId);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
if (showDms || !currentServerId) {
if (showDms || !currentSpaceId) {
return <ActivityPanel />;
}
@@ -1,11 +1,11 @@
import React, { useState, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { useInstanceStore } from '../../stores/instanceStore';
import { getServerGradient, HOME_GRADIENT } from '../../utils/gradients';
import { getSpaceGradient, HOME_GRADIENT } from '../../utils/gradients';
interface SidebarItemProps {
id: string;
@@ -13,13 +13,13 @@ interface SidebarItemProps {
icon?: string | null;
active: boolean;
onClick: () => void;
type?: 'server' | 'dm' | 'action';
type?: 'space' | 'dm' | 'action';
actionType?: 'add' | 'join' | 'explore';
hasUnread?: boolean;
dimmed?: boolean;
}
function SidebarItem({ id, name, icon, active, onClick, type = 'server', actionType, hasUnread, dimmed }: SidebarItemProps) {
function SidebarItem({ id, name, icon, active, onClick, type = 'space', actionType, hasUnread, dimmed }: SidebarItemProps) {
const [isHovered, setIsHovered] = useState(false);
const firstLetter = name.charAt(0).toUpperCase();
@@ -41,11 +41,11 @@ function SidebarItem({ id, name, icon, active, onClick, type = 'server', actionT
return { background: HOME_GRADIENT.gradient };
}
// Server type — if it has a custom icon image, no gradient needed
// Space type — if it has a custom icon image, no gradient needed
if (icon) return undefined;
const serverGrad = getServerGradient(id, name);
return { background: serverGrad.gradient };
const spaceGrad = getSpaceGradient(id, name);
return { background: spaceGrad.gradient };
}, [type, id, name, icon, isHovered]);
const getButtonClasses = () => {
@@ -73,7 +73,7 @@ function SidebarItem({ id, name, icon, active, onClick, type = 'server', actionT
onMouseLeave={() => setIsHovered(false)}
>
{/* Pill Indicator */}
{(type === 'server' || type === 'dm') && (
{(type === 'space' || type === 'dm') && (
<div className="absolute -left-0 w-2 h-10 flex items-center">
<div
className={`bg-white rounded-r-full transition-all duration-200 origin-left ${getPillHeight()} w-1`}
@@ -112,12 +112,12 @@ function SidebarItem({ id, name, icon, active, onClick, type = 'server', actionT
);
}
export function ServerSidebar() {
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
const channelToServerMap = useServerStore((s) => s.channelToServerMap);
const dmChannels = useServerStore((s) => s.dmChannels);
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 showDms = useUIStore((s) => s.showDms);
const setShowDms = useUIStore((s) => s.setShowDms);
const openModal = useUIStore((s) => s.openModal);
@@ -128,11 +128,11 @@ export function ServerSidebar() {
const navigate = useNavigate();
const location = useLocation();
// Group servers by origin
const groupedServers = useMemo(() => {
const home = servers.filter(s => !(s as any)._instanceOrigin);
const remoteMap = new Map<string, typeof servers>();
for (const s of servers) {
// Group spaces by origin
const groupedSpaces = useMemo(() => {
const home = spaces.filter(s => !(s as any)._instanceOrigin);
const remoteMap = new Map<string, typeof spaces>();
for (const s of spaces) {
const origin = (s as any)._instanceOrigin;
if (!origin) continue;
const list = remoteMap.get(origin) || [];
@@ -140,7 +140,7 @@ export function ServerSidebar() {
remoteMap.set(origin, list);
}
return { home, remoteGroups: Array.from(remoteMap.entries()) };
}, [servers]);
}, [spaces]);
// Set of disconnected origins
const disconnectedOrigins = useMemo(() => {
@@ -153,15 +153,15 @@ export function ServerSidebar() {
return set;
}, [instances]);
// Compute which servers have unread channels
const unreadServerIds = useMemo(() => {
// Compute which spaces have unread channels
const unreadSpaceIds = useMemo(() => {
const ids = new Set<string>();
for (const channelId of unreadChannels) {
const serverId = channelToServerMap.get(channelId);
if (serverId) ids.add(serverId);
const spaceId = channelToSpaceMap.get(channelId);
if (spaceId) ids.add(spaceId);
}
return ids;
}, [unreadChannels, channelToServerMap]);
}, [unreadChannels, channelToSpaceMap]);
// Check if any DM channels are unread
const hasDmUnread = useMemo(() => {
@@ -171,28 +171,28 @@ export function ServerSidebar() {
return false;
}, [unreadChannels, dmChannels]);
const handleServerClick = (serverId: string) => {
const server = servers.find(s => s.id === serverId);
const origin = (server as any)?._instanceOrigin;
const handleSpaceClick = (spaceId: string) => {
const space = spaces.find(s => s.id === spaceId);
const origin = (space as any)?._instanceOrigin;
if (origin && disconnectedOrigins.has(origin)) {
const inst = instances.find(i => i.origin === origin);
addToast(`Reconnecting to ${inst?.label || 'remote instance'}...`, 'warning', 4000);
return;
}
setCurrentServer(serverId);
setCurrentSpace(spaceId);
setShowDms(false);
navigate(`/channels/${serverId}`);
navigate(`/channels/${spaceId}`);
};
const handleDmClick = () => {
setShowDms(true);
setCurrentServer(null);
setCurrentSpace(null);
setCurrentChannel(null);
navigate('/channels/@me');
};
const handleExploreClick = () => {
setCurrentServer(null);
setCurrentSpace(null);
setCurrentChannel(null);
navigate('/explore');
};
@@ -210,21 +210,21 @@ export function ServerSidebar() {
<div className="w-8 h-[2px] bg-interactive-muted rounded-full mb-1.5" />
{/* Home servers */}
{groupedServers.home.map((server) => (
{/* Home spaces */}
{groupedSpaces.home.map((space) => (
<SidebarItem
key={server.id}
id={server.id}
name={server.name}
icon={server.icon}
active={currentServerId === server.id}
onClick={() => handleServerClick(server.id)}
hasUnread={unreadServerIds.has(server.id)}
key={space.id}
id={space.id}
name={space.name}
icon={space.icon}
active={currentSpaceId === space.id}
onClick={() => handleSpaceClick(space.id)}
hasUnread={unreadSpaceIds.has(space.id)}
/>
))}
{/* Remote instance groups */}
{groupedServers.remoteGroups.map(([origin, groupServers]) => {
{groupedSpaces.remoteGroups.map(([origin, groupSpaces]) => {
const inst = instances.find(i => i.origin === origin);
const label = inst?.label || (() => { try { return new URL(origin).host; } catch { return '?'; } })();
const isDimmed = disconnectedOrigins.has(origin);
@@ -234,15 +234,15 @@ export function ServerSidebar() {
<div className="text-[9px] text-txt-tertiary font-medium uppercase tracking-wider mb-1 truncate max-w-[52px] text-center" title={label}>
{label}
</div>
{groupServers.map((server) => (
{groupSpaces.map((space) => (
<SidebarItem
key={server.id}
id={server.id}
name={server.name}
icon={server.icon}
active={currentServerId === server.id}
onClick={() => handleServerClick(server.id)}
hasUnread={unreadServerIds.has(server.id)}
key={space.id}
id={space.id}
name={space.name}
icon={space.icon}
active={currentSpaceId === space.id}
onClick={() => handleSpaceClick(space.id)}
hasUnread={unreadSpaceIds.has(space.id)}
dimmed={isDimmed}
/>
))}
@@ -253,26 +253,26 @@ export function ServerSidebar() {
<div className="w-8 h-[2px] bg-interactive-muted rounded-full mb-1.5" />
<SidebarItem
id="add-server"
name="Add a Server"
id="add-space"
name="Add a Space"
active={false}
onClick={() => openModal('createServer')}
onClick={() => openModal('createSpace')}
type="action"
actionType="add"
/>
<SidebarItem
id="join-server"
name="Join a Server"
id="join-space"
name="Join a Space"
active={false}
onClick={() => openModal('joinServer')}
onClick={() => openModal('joinSpace')}
type="action"
actionType="join"
/>
<SidebarItem
id="explore"
name="Explore Servers"
name="Explore Spaces"
active={location.pathname === '/explore'}
onClick={handleExploreClick}
type="action"
@@ -2,7 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { Avatar } from '../ui/Avatar';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore, getApiForOrigin } from '../../stores/serverStore';
import { useSpaceStore, getApiForOrigin } from '../../stores/spaceStore';
import { api } from '../../api/client';
import type { User } from '@backspace/shared';
@@ -15,8 +15,8 @@ export function AddDmMemberModal() {
const activeModal = useUIStore((s) => s.activeModal);
const modalData = useUIStore((s) => s.modalData);
const closeModal = useUIStore((s) => s.closeModal);
const dmChannels = useServerStore((s) => s.dmChannels);
const channelOriginMap = useServerStore((s) => s.channelOriginMap);
const dmChannels = useSpaceStore((s) => s.dmChannels);
const channelOriginMap = useSpaceStore((s) => s.channelOriginMap);
const inputRef = useRef<HTMLInputElement>(null);
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { api } from '../../api/client';
import { PermissionBits, permissionsToString, stringToPermissions } from '../../utils/permissions';
@@ -17,8 +17,8 @@ export function ChannelSettingsModal() {
const activeModal = useUIStore((s) => s.activeModal);
const modalData = useUIStore((s) => s.modalData);
const closeModal = useUIStore((s) => s.closeModal);
const currentServerId = useServerStore((s) => s.currentServerId);
const channels = useServerStore((s) => s.channels);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const channels = useSpaceStore((s) => s.channels);
const [isPrivate, setIsPrivate] = useState(false);
const [isLoading, setIsLoading] = useState(false);
@@ -31,7 +31,7 @@ export function ChannelSettingsModal() {
// Fetch overrides when modal opens
useEffect(() => {
if (!isOpen || !channelId || !currentServerId) {
if (!isOpen || !channelId || !currentSpaceId) {
setIsFetching(false);
return;
}
@@ -41,9 +41,9 @@ export function ChannelSettingsModal() {
api.channels.getOverrides(channelId)
.then((overrides: ChannelOverride[]) => {
// Check if @everyone role (id === serverId) has VIEW_CHANNEL denied
// Check if @everyone role (id === spaceId) has VIEW_CHANNEL denied
const everyoneOverride = overrides.find(
o => o.targetType === 'role' && o.targetId === currentServerId
o => o.targetType === 'role' && o.targetId === currentSpaceId
);
if (everyoneOverride) {
const denyBits = stringToPermissions(everyoneOverride.deny);
@@ -58,9 +58,9 @@ export function ChannelSettingsModal() {
.finally(() => {
setIsFetching(false);
});
}, [isOpen, channelId, currentServerId]);
}, [isOpen, channelId, currentSpaceId]);
if (!isOpen || !channel || !channelId || !currentServerId) return null;
if (!isOpen || !channel || !channelId || !currentSpaceId) return null;
const handleToggle = async () => {
setError('');
@@ -71,14 +71,14 @@ export function ChannelSettingsModal() {
// Make private: deny VIEW_CHANNEL for @everyone role
await api.channels.putOverride(channelId, {
targetType: 'role',
targetId: currentServerId,
targetId: currentSpaceId,
allow: '0',
deny: permissionsToString(PermissionBits.VIEW_CHANNEL),
});
setIsPrivate(true);
} else {
// Make public: remove the @everyone VIEW_CHANNEL deny override
await api.channels.deleteOverride(channelId, 'role', currentServerId);
await api.channels.deleteOverride(channelId, 'role', currentSpaceId);
setIsPrivate(false);
}
} catch (err) {
@@ -144,7 +144,7 @@ export function ChannelSettingsModal() {
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" />
</svg>
<span>
This channel is hidden from members without explicit access. Users with the Administrator permission or server owners can always see all channels.
This channel is hidden from members without explicit access. Users with the Administrator permission or space owners can always see all channels.
</span>
</div>
)}
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
export function CreateChannelModal() {
const [name, setName] = useState('');
@@ -11,8 +11,8 @@ export function CreateChannelModal() {
const [isLoading, setIsLoading] = useState(false);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const createChannel = useServerStore((s) => s.createChannel);
const currentServerId = useServerStore((s) => s.currentServerId);
const createChannel = useSpaceStore((s) => s.createChannel);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const isOpen = activeModal === 'createChannel';
@@ -25,14 +25,14 @@ export function CreateChannelModal() {
return;
}
if (!currentServerId) {
setError('No server selected');
if (!currentSpaceId) {
setError('No space selected');
return;
}
setIsLoading(true);
try {
await createChannel(currentServerId, name.trim(), type, topic.trim() || undefined);
await createChannel(currentSpaceId, name.trim(), type, topic.trim() || undefined);
closeModal();
setName('');
setTopic('');
@@ -1,44 +1,44 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore';
import { useNavigate } from 'react-router-dom';
export function CreateServerModal() {
export function CreateSpaceModal() {
const [name, setName] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const createServer = useServerStore((s) => s.createServer);
const createSpace = useSpaceStore((s) => s.createSpace);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const navigate = useNavigate();
const isOpen = activeModal === 'createServer';
const isOpen = activeModal === 'createSpace';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!name.trim()) {
setError('Server name is required');
setError('Space name is required');
return;
}
setIsLoading(true);
try {
const server = await createServer(name.trim());
const space = await createSpace(name.trim());
closeModal();
setName('');
navigate(`/channels/${server.id}`);
navigate(`/channels/${space.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create server');
setError(err instanceof Error ? err.message : 'Failed to create space');
} finally {
setIsLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Create a Server">
<Modal isOpen={isOpen} onClose={closeModal} title="Create a Space">
<form onSubmit={handleSubmit}>
{error && (
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
@@ -47,14 +47,14 @@ export function CreateServerModal() {
)}
<div className="mb-4">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Server Name
Space Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
placeholder="My Awesome Server"
placeholder="My Awesome Space"
autoFocus
/>
</div>
@@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { InviteModal } from './InviteModal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
// Mock the stores by spying on their getState
beforeEach(() => {
@@ -12,9 +12,9 @@ beforeEach(() => {
activeModal: null,
modalData: {},
});
useServerStore.setState({
currentServerId: null,
servers: [],
useSpaceStore.setState({
currentSpaceId: null,
spaces: [],
});
});
@@ -28,8 +28,8 @@ describe('InviteModal', () => {
it('calls generateInvite and displays the invite URL when opened', async () => {
const mockGenerateInvite = vi.fn().mockResolvedValue('test-invite-code');
useUIStore.setState({ activeModal: 'invite' });
useServerStore.setState({
currentServerId: 'server-123',
useSpaceStore.setState({
currentSpaceId: 'server-123',
generateInvite: mockGenerateInvite,
});
@@ -54,8 +54,8 @@ describe('InviteModal', () => {
it('displays an error when generateInvite fails', async () => {
const mockGenerateInvite = vi.fn().mockRejectedValue(new Error('Not authorized'));
useUIStore.setState({ activeModal: 'invite' });
useServerStore.setState({
currentServerId: 'server-123',
useSpaceStore.setState({
currentSpaceId: 'server-123',
generateInvite: mockGenerateInvite,
});
@@ -69,8 +69,8 @@ describe('InviteModal', () => {
it('Copy button is disabled while loading', () => {
const mockGenerateInvite = vi.fn().mockReturnValue(new Promise(() => {})); // never resolves
useUIStore.setState({ activeModal: 'invite' });
useServerStore.setState({
currentServerId: 'server-123',
useSpaceStore.setState({
currentSpaceId: 'server-123',
generateInvite: mockGenerateInvite,
});
@@ -91,8 +91,8 @@ describe('InviteModal', () => {
});
useUIStore.setState({ activeModal: 'invite' });
useServerStore.setState({
currentServerId: 'server-123',
useSpaceStore.setState({
currentSpaceId: 'server-123',
generateInvite: mockGenerateInvite,
});
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
export function InviteModal() {
const [inviteCode, setInviteCode] = useState('');
@@ -10,20 +10,20 @@ export function InviteModal() {
const [error, setError] = useState('');
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const generateInvite = useServerStore((s) => s.generateInvite);
const currentServerId = useServerStore((s) => s.currentServerId);
const servers = useServerStore((s) => s.servers);
const currentServer = servers.find(s => s.id === currentServerId);
const instanceOrigin = currentServer?._instanceOrigin ?? '';
const generateInvite = useSpaceStore((s) => s.generateInvite);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const spaces = useSpaceStore((s) => s.spaces);
const currentSpace = spaces.find(s => s.id === currentSpaceId);
const instanceOrigin = currentSpace?._instanceOrigin ?? '';
const isOpen = activeModal === 'invite';
const inviteUrl = inviteCode ? `${instanceOrigin || window.location.origin}/join/${inviteCode}` : '';
useEffect(() => {
if (isOpen && currentServerId) {
if (isOpen && currentSpaceId) {
setIsLoading(true);
setError('');
generateInvite(currentServerId)
generateInvite(currentSpaceId)
.then(code => {
setInviteCode(code);
setIsLoading(false);
@@ -33,7 +33,7 @@ export function InviteModal() {
setIsLoading(false);
});
}
}, [isOpen, currentServerId, generateInvite]);
}, [isOpen, currentSpaceId, generateInvite]);
const handleCopy = async () => {
if (!inviteUrl) return;
@@ -55,7 +55,7 @@ export function InviteModal() {
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Invite Friends">
<p className="text-txt-secondary text-sm mb-4">
Share this invite link with friends to let them join your server.
Share this invite link with friends to let them join your space.
</p>
{error && (
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
@@ -2,9 +2,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { JoinServerModal } from './JoinServer';
import { JoinSpaceModal } from './JoinSpace';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
@@ -18,41 +18,41 @@ vi.mock('react-router-dom', async () => {
beforeEach(() => {
mockNavigate.mockClear();
useUIStore.setState({ activeModal: null });
useServerStore.setState({
servers: [],
currentServerId: null,
useSpaceStore.setState({
spaces: [],
currentSpaceId: null,
});
});
function renderModal() {
return render(
<MemoryRouter>
<JoinServerModal />
<JoinSpaceModal />
</MemoryRouter>
);
}
describe('JoinServerModal', () => {
it('does not render when activeModal is not "joinServer"', () => {
describe('JoinSpaceModal', () => {
it('does not render when activeModal is not "joinSpace"', () => {
useUIStore.setState({ activeModal: null });
renderModal();
expect(screen.queryByText('Join a Server')).not.toBeInTheDocument();
expect(screen.queryByText('Join a Space')).not.toBeInTheDocument();
});
it('renders the form when opened', () => {
useUIStore.setState({ activeModal: 'joinServer' });
useUIStore.setState({ activeModal: 'joinSpace' });
renderModal();
expect(screen.getByText('Join a Server')).toBeInTheDocument();
expect(screen.getByText('Join a Space')).toBeInTheDocument();
expect(screen.getByPlaceholderText('e.g. abc123')).toBeInTheDocument();
expect(screen.getByText('Join Server')).toBeInTheDocument();
expect(screen.getByText('Join Space')).toBeInTheDocument();
});
it('shows validation error when submitting empty code', async () => {
const user = userEvent.setup();
useUIStore.setState({ activeModal: 'joinServer' });
useUIStore.setState({ activeModal: 'joinSpace' });
renderModal();
const submitButton = screen.getByText('Join Server');
const submitButton = screen.getByText('Join Space');
await user.click(submitButton);
expect(screen.getByText('Invite code is required')).toBeInTheDocument();
@@ -60,9 +60,9 @@ describe('JoinServerModal', () => {
it('calls joinByCode with the entered invite code and navigates on success', async () => {
const user = userEvent.setup();
const mockJoinByCode = vi.fn().mockResolvedValue({ id: 'new-server-id', name: 'Test Server' });
useUIStore.setState({ activeModal: 'joinServer' });
useServerStore.setState({ joinByCode: mockJoinByCode });
const mockJoinByCode = vi.fn().mockResolvedValue({ id: 'new-space-id', name: 'Test Space' });
useUIStore.setState({ activeModal: 'joinSpace' });
useSpaceStore.setState({ joinByCode: mockJoinByCode });
renderModal();
@@ -71,7 +71,7 @@ describe('JoinServerModal', () => {
await user.type(input, 'my-invite-code');
// Click join
const submitButton = screen.getByText('Join Server');
const submitButton = screen.getByText('Join Space');
await user.click(submitButton);
// joinByCode should be called with the code
@@ -79,9 +79,9 @@ describe('JoinServerModal', () => {
expect(mockJoinByCode).toHaveBeenCalledWith('my-invite-code');
});
// Should navigate to the new server
// Should navigate to the new space
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/channels/new-server-id');
expect(mockNavigate).toHaveBeenCalledWith('/channels/new-space-id');
});
// Modal should close (activeModal becomes null)
@@ -91,15 +91,15 @@ describe('JoinServerModal', () => {
it('shows error message when joinByCode fails', async () => {
const user = userEvent.setup();
const mockJoinByCode = vi.fn().mockRejectedValue(new Error('Invalid invite code'));
useUIStore.setState({ activeModal: 'joinServer' });
useServerStore.setState({ joinByCode: mockJoinByCode });
useUIStore.setState({ activeModal: 'joinSpace' });
useSpaceStore.setState({ joinByCode: mockJoinByCode });
renderModal();
const input = screen.getByPlaceholderText('e.g. abc123');
await user.type(input, 'bad-code');
const submitButton = screen.getByText('Join Server');
const submitButton = screen.getByText('Join Space');
await user.click(submitButton);
await waitFor(() => {
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore, NotConnectedError } from '../../stores/serverStore';
import { useSpaceStore, NotConnectedError } from '../../stores/spaceStore';
import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore';
import { useAuthStore } from '../../stores/authStore';
import { useNavigate, useParams } from 'react-router-dom';
@@ -9,7 +9,7 @@ import { parseInviteInput } from '../../utils/inviteParser';
type JoinPhase = 'input' | 'connect' | 'fallback';
export function JoinServerModal() {
export function JoinSpaceModal() {
const { inviteCode: urlInviteCode } = useParams<{ inviteCode?: string }>();
const [inviteCode, setInviteCode] = useState('');
const [error, setError] = useState('');
@@ -23,13 +23,13 @@ export function JoinServerModal() {
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const joinByCode = useServerStore((s) => s.joinByCode);
const joinByCode = useSpaceStore((s) => s.joinByCode);
const connectToRemote = useInstanceStore((s) => s.connectToRemote);
const loginToRemote = useInstanceStore((s) => s.loginToRemote);
const user = useAuthStore((s) => s.user);
const navigate = useNavigate();
const isOpen = activeModal === 'joinServer';
const isOpen = activeModal === 'joinSpace';
// Pre-fill from URL param and reset state on open/close
useEffect(() => {
@@ -49,9 +49,9 @@ export function JoinServerModal() {
}, [isOpen, urlInviteCode]);
const joinAndNavigate = async (code: string, origin?: string) => {
const server = await joinByCode(code, origin || undefined);
const space = await joinByCode(code, origin || undefined);
closeModal();
navigate(`/channels/${server.id}`);
navigate(`/channels/${space.id}`);
};
// Phase 1: Submit invite code/URL
@@ -78,7 +78,7 @@ export function JoinServerModal() {
setPhase('connect');
setError('');
} else {
setError(err instanceof Error ? err.message : 'Failed to join server');
setError(err instanceof Error ? err.message : 'Failed to join space');
}
} finally {
setIsLoading(false);
@@ -128,7 +128,7 @@ export function JoinServerModal() {
} catch { /* ignore */ }
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Join a Server">
<Modal isOpen={isOpen} onClose={closeModal} title="Join a Space">
{/* Error display (shared across all phases) */}
{error && (
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
@@ -140,7 +140,7 @@ export function JoinServerModal() {
{phase === 'input' && (
<form onSubmit={handleSubmit}>
<p className="text-txt-secondary text-sm mb-4">
Enter an invite code or link to join a server.
Enter an invite code or link to join a space.
</p>
<div className="mb-4">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
@@ -168,7 +168,7 @@ export function JoinServerModal() {
disabled={isLoading || !inviteCode.trim()}
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{isLoading ? 'Joining...' : 'Join Server'}
{isLoading ? 'Joining...' : 'Join Space'}
</button>
</div>
</form>
@@ -178,7 +178,7 @@ export function JoinServerModal() {
{phase === 'connect' && (
<form onSubmit={handleConnect}>
<p className="text-txt-secondary text-sm mb-4">
Connect to <span className="text-txt-primary font-medium">{hostDisplay}</span> to join this server.
Connect to <span className="text-txt-primary font-medium">{hostDisplay}</span> to join this space.
</p>
<div className="mb-4 space-y-2">
<div>
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { Modal } from '../ui/Modal';
import { Avatar } from '../ui/Avatar';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { api } from '../../api/client';
import type { User } from '@backspace/shared';
@@ -14,7 +14,7 @@ export function NewDmModal() {
const [error, setError] = useState('');
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const addDmChannel = useServerStore((s) => s.addDmChannel);
const addDmChannel = useSpaceStore((s) => s.addDmChannel);
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
@@ -59,7 +59,7 @@ export function NewDmModal() {
const handleSelectUser = async (user: User) => {
setError('');
try {
const existing = useServerStore.getState().findExistingDmForUser(user);
const existing = useSpaceStore.getState().findExistingDmForUser(user);
if (existing) {
closeModal();
useUIStore.getState().setShowDms(true);
@@ -1,14 +1,14 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useAuthStore } from '../../stores/authStore';
import { useSettingsStore } from '../../stores/settingsStore';
import { Avatar } from '../ui/Avatar';
import { api } from '../../api/client';
import { useNavigate } from 'react-router-dom';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import type { InstanceStreamingLimits, ServerVisibility, JoinRequest } from '@backspace/shared';
import type { InstanceStreamingLimits, SpaceVisibility, JoinRequest } from '@backspace/shared';
const VALID_RESOLUTIONS = [540, 720, 1080] as const;
const VALID_FRAMERATES = [30, 45, 60] as const;
@@ -215,40 +215,40 @@ function StreamingLimitsPanel() {
);
}
function DiscoveryPanel({ serverId }: { serverId: string }) {
const servers = useServerStore((s) => s.servers);
const updateServer = useServerStore((s) => s.updateServer);
function DiscoveryPanel({ spaceId }: { spaceId: string }) {
const spaces = useSpaceStore((s) => s.spaces);
const updateSpace = useSpaceStore((s) => s.updateSpace);
const discoveryEnabled = useSettingsStore((s) => s.streamingLimits?.discoveryEnabled ?? true);
const server = servers.find(s => s.id === serverId);
const space = spaces.find(s => s.id === spaceId);
const [visibility, setVisibility] = useState<ServerVisibility>(
(server?.visibility as ServerVisibility) ?? 'private'
const [visibility, setVisibility] = useState<SpaceVisibility>(
(space?.visibility as SpaceVisibility) ?? 'private'
);
const [description, setDescription] = useState(server?.description ?? '');
const [description, setDescription] = useState(space?.description ?? '');
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false);
useEffect(() => {
if (server) {
setVisibility((server.visibility as ServerVisibility) ?? 'private');
setDescription(server.description ?? '');
if (space) {
setVisibility((space.visibility as SpaceVisibility) ?? 'private');
setDescription(space.description ?? '');
}
}, [server]);
}, [space]);
if (!server) return null;
if (!space) return null;
const hasChanges =
visibility !== ((server.visibility as ServerVisibility) ?? 'private') ||
description !== (server.description ?? '');
visibility !== ((space.visibility as SpaceVisibility) ?? 'private') ||
description !== (space.description ?? '');
const handleSave = async () => {
setSaving(true);
setSaveError('');
setSaveSuccess(false);
try {
await api.servers.update(serverId, { visibility, description: description.trim() });
await api.spaces.update(spaceId, { visibility, description: description.trim() });
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (err) {
@@ -259,12 +259,12 @@ function DiscoveryPanel({ serverId }: { serverId: string }) {
};
const handleReset = () => {
setVisibility((server.visibility as ServerVisibility) ?? 'private');
setDescription(server.description ?? '');
setVisibility((space.visibility as SpaceVisibility) ?? 'private');
setDescription(space.description ?? '');
setSaveError('');
};
const visibilityOptions: { value: ServerVisibility; label: string; desc: string }[] = [
const visibilityOptions: { value: SpaceVisibility; label: string; desc: string }[] = [
{ value: 'private', label: 'Private', desc: 'Only people with an invite link can join' },
{ value: 'request', label: 'Request to Join', desc: 'Visible in Explore — people can request to join' },
{ value: 'public', label: 'Public', desc: 'Visible in Explore — anyone can join instantly' },
@@ -274,7 +274,7 @@ function DiscoveryPanel({ serverId }: { serverId: string }) {
<div className="space-y-4">
{!discoveryEnabled && (
<div className="p-2.5 bg-accent-amber/10 border border-accent-amber/30 rounded text-[13px] text-accent-amber">
Server discovery is disabled by the instance administrator. Changing visibility will have no effect until discovery is re-enabled.
Space discovery is disabled by the instance administrator. Changing visibility will have no effect until discovery is re-enabled.
</div>
)}
@@ -348,14 +348,14 @@ function DiscoveryPanel({ serverId }: { serverId: string }) {
)}
{/* Pending Join Requests — only shown when visibility is 'request' */}
{(visibility === 'request' || (server.visibility as ServerVisibility) === 'request') && (
<JoinRequestsSection serverId={serverId} />
{(visibility === 'request' || (space.visibility as SpaceVisibility) === 'request') && (
<JoinRequestsSection spaceId={spaceId} />
)}
</div>
);
}
function JoinRequestsSection({ serverId }: { serverId: string }) {
function JoinRequestsSection({ spaceId }: { spaceId: string }) {
const [requests, setRequests] = useState<JoinRequest[]>([]);
const [loading, setLoading] = useState(true);
const [actionError, setActionError] = useState('');
@@ -363,7 +363,7 @@ function JoinRequestsSection({ serverId }: { serverId: string }) {
useEffect(() => {
let cancelled = false;
setLoading(true);
api.explore.getJoinRequests(serverId, 'pending')
api.explore.getJoinRequests(spaceId, 'pending')
.then(({ requests: reqs }) => {
if (!cancelled) {
setRequests(reqs);
@@ -374,12 +374,12 @@ function JoinRequestsSection({ serverId }: { serverId: string }) {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [serverId]);
}, [spaceId]);
const handleDecide = async (requestId: string, action: 'accept' | 'decline') => {
setActionError('');
try {
await api.explore.decideJoinRequest(serverId, requestId, action);
await api.explore.decideJoinRequest(spaceId, requestId, action);
setRequests(prev => prev.filter(r => r.id !== requestId));
} catch (err) {
setActionError(err instanceof Error ? err.message : 'Action failed');
@@ -459,55 +459,55 @@ function JoinRequestsSection({ serverId }: { serverId: string }) {
);
}
export function ServerSettingsModal() {
export function SpaceSettingsModal() {
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const currentServerId = useServerStore((s) => s.currentServerId);
const servers = useServerStore((s) => s.servers);
const members = useServerStore((s) => s.members);
const roles = useServerStore((s) => s.roles);
const updateServer = useServerStore((s) => s.updateServer);
const deleteServer = useServerStore((s) => s.deleteServer);
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const spaces = useSpaceStore((s) => s.spaces);
const members = useSpaceStore((s) => s.members);
const roles = useSpaceStore((s) => s.roles);
const updateSpace = useSpaceStore((s) => s.updateSpace);
const deleteSpace = useSpaceStore((s) => s.deleteSpace);
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
const currentUser = useAuthStore((s) => s.user);
const isAdmin = useSettingsStore((s) => s.isAdmin);
const navigate = useNavigate();
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'streaming'>('overview');
const [serverName, setServerName] = useState('');
const [spaceName, setSpaceName] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [pendingRoleChanges, setPendingRoleChanges] = useState<Map<string, Set<string>>>(new Map());
const serverPermissions = useServerStore((s) => s.serverPermissions);
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
const isOpen = activeModal === 'serverSettings';
const server = servers.find(s => s.id === currentServerId);
const isOwnerUser = server?.ownerId === currentUser?.id;
const myServerPerms = currentServerId ? serverPermissions.get(currentServerId) : undefined;
const canManageServer = hasPermissionBit(myServerPerms, PermissionBits.MANAGE_SERVER);
const canManageRoles = hasPermissionBit(myServerPerms, PermissionBits.MANAGE_ROLES);
const isOpen = activeModal === 'spaceSettings';
const space = spaces.find(s => s.id === currentSpaceId);
const isOwnerUser = space?.ownerId === currentUser?.id;
const mySpacePerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined;
const canManageSpace = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_SPACE);
const canManageRoles = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_ROLES);
// Assignable roles: exclude @everyone (where role.id === serverId)
const assignableRoles = roles.filter(r => r.id !== currentServerId);
// Assignable roles: exclude @everyone (where role.id === spaceId)
const assignableRoles = roles.filter(r => r.id !== currentSpaceId);
React.useEffect(() => {
if (server) {
setServerName(server.name);
if (space) {
setSpaceName(space.name);
}
}, [server]);
}, [space]);
if (!server || !currentServerId) return null;
if (!space || !currentSpaceId) return null;
const handleSave = async () => {
setError('');
setIsLoading(true);
try {
await updateServer(currentServerId, { name: serverName.trim() });
await updateSpace(currentSpaceId, { name: spaceName.trim() });
setIsLoading(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update server');
setError(err instanceof Error ? err.message : 'Failed to update space');
setIsLoading(false);
}
};
@@ -518,11 +518,11 @@ export function ServerSettingsModal() {
return;
}
try {
await deleteServer(currentServerId);
await deleteSpace(currentSpaceId);
closeModal();
navigate('/channels/@me');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete server');
setError(err instanceof Error ? err.message : 'Failed to delete space');
}
};
@@ -548,13 +548,13 @@ export function ServerSettingsModal() {
if (!roleIds) return;
try {
await api.servers.updateMember(currentServerId, userId, { roleIds: Array.from(roleIds) });
await api.spaces.updateMember(currentSpaceId, userId, { roleIds: Array.from(roleIds) });
setPendingRoleChanges(prev => {
const next = new Map(prev);
next.delete(userId);
return next;
});
await loadServerDetail(currentServerId);
await loadSpaceDetail(currentSpaceId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update roles');
}
@@ -570,15 +570,15 @@ export function ServerSettingsModal() {
const handleKick = async (userId: string) => {
try {
await api.servers.removeMember(currentServerId, userId);
await loadServerDetail(currentServerId);
await api.spaces.removeMember(currentSpaceId, userId);
await loadSpaceDetail(currentSpaceId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to kick member');
}
};
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Server Settings" maxWidth="max-w-xl">
<Modal isOpen={isOpen} onClose={closeModal} title="Space Settings" maxWidth="max-w-xl">
<div className="flex gap-4">
{/* Tabs */}
<div className="w-32 flex-shrink-0 space-y-1">
@@ -590,7 +590,7 @@ export function ServerSettingsModal() {
>
Overview
</button>
{canManageServer && (
{canManageSpace && (
<button
onClick={() => setTab('discovery')}
className={`w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${
@@ -630,18 +630,18 @@ export function ServerSettingsModal() {
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
Server Name
Space Name
</label>
<input
type="text"
value={serverName}
onChange={(e) => setServerName(e.target.value)}
value={spaceName}
onChange={(e) => setSpaceName(e.target.value)}
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
disabled={!canManageServer}
disabled={!canManageSpace}
/>
</div>
{canManageServer && (
{canManageSpace && (
<>
<button
onClick={handleSave}
@@ -657,7 +657,7 @@ export function ServerSettingsModal() {
onClick={handleDelete}
className="px-4 py-2 bg-accent-rose hover:bg-accent-rose/80 text-white text-sm font-medium rounded transition-colors"
>
{confirmDelete ? 'Click again to confirm deletion' : 'Delete Server'}
{confirmDelete ? 'Click again to confirm deletion' : 'Delete Space'}
</button>
</div>
</>
@@ -665,15 +665,15 @@ export function ServerSettingsModal() {
</div>
)}
{tab === 'discovery' && canManageServer && currentServerId && (
<DiscoveryPanel serverId={currentServerId} />
{tab === 'discovery' && canManageSpace && currentSpaceId && (
<DiscoveryPanel spaceId={currentSpaceId} />
)}
{tab === 'members' && (
<div className="space-y-2 max-h-[400px] overflow-y-auto scrollbar-thin">
{members.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
const isOwner = member.userId === server.ownerId;
const isOwner = member.userId === space.ownerId;
const memberRoleIds = getMemberRoleIds(member);
const hasPendingChanges = pendingRoleChanges.has(member.userId);
@@ -696,7 +696,7 @@ export function ServerSettingsModal() {
Owner
</span>
)}
{member.roles?.filter(r => r.id !== currentServerId).map(r => (
{member.roles?.filter(r => r.id !== currentSpaceId).map(r => (
<span
key={r.id}
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
@@ -705,7 +705,7 @@ export function ServerSettingsModal() {
{r.name}
</span>
))}
{!isOwner && (!member.roles || member.roles.filter(r => r.id !== currentServerId).length === 0) && (
{!isOwner && (!member.roles || member.roles.filter(r => r.id !== currentSpaceId).length === 0) && (
<span className="text-[10px] text-txt-tertiary">No roles</span>
)}
</div>
@@ -4,7 +4,7 @@ import type { User } from '@backspace/shared';
import { Avatar } from '../ui/Avatar';
import { Username } from '../ui/Username';
import { api } from '../../api/client';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore';
import { getAvatarGradient } from '../../utils/gradients';
import { parseFederatedUsername } from '../../utils/identity';
@@ -17,7 +17,7 @@ interface UserProfilePopoutProps {
export function UserProfilePopout({ user, onClose, position }: UserProfilePopoutProps) {
const navigate = useNavigate();
const addDmChannel = useServerStore((s) => s.addDmChannel);
const addDmChannel = useSpaceStore((s) => s.addDmChannel);
const { baseName, domain } = parseFederatedUsername(user.username);
const displayName = user.displayName ?? baseName;
@@ -30,7 +30,7 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
const handleSendMessage = async () => {
try {
const existing = useServerStore.getState().findExistingDmForUser(user);
const existing = useSpaceStore.getState().findExistingDmForUser(user);
if (existing) {
useUIStore.getState().setShowDms(true);
onClose();
@@ -1,6 +1,6 @@
import React, { useEffect, useRef } from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { wsSend } from '../../hooks/useWebSocket';
import { getAvatarGradient } from '../../utils/gradients';
import { parseFederatedUsername } from '../../utils/identity';
@@ -27,7 +27,7 @@ export function IncomingCallModal() {
};
}, [incomingCall, setIncomingCall]);
const dmChannels = useServerStore((s) => s.dmChannels);
const dmChannels = useSpaceStore((s) => s.dmChannels);
if (!incomingCall) return null;
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useVoiceStore } from '../../stores/voiceStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { Avatar } from '../ui/Avatar';
import type { ParticipantInfo } from '../../hooks/useLiveKit';
@@ -111,8 +111,8 @@ export function PictureInPicture() {
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
const pipCollapsed = useUIStore((s) => s.pipCollapsed);
const setPipCollapsed = useUIStore((s) => s.setPipCollapsed);
const channelToServerMap = useServerStore((s) => s.channelToServerMap);
const channels = useServerStore((s) => s.channels);
const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap);
const channels = useSpaceStore((s) => s.channels);
// Drag state
const [position, setPosition] = useState<{ x: number; y: number }>({ x: -1, y: -1 });
@@ -291,16 +291,16 @@ export function PictureInPicture() {
if (activeDmCall) {
navigate(`/channels/@me/${activeDmCall.dmChannelId}`);
} else if (currentVoiceChannelId) {
const serverId = channelToServerMap.get(currentVoiceChannelId);
if (serverId) {
navigate(`/channels/${serverId}/${currentVoiceChannelId}`);
const spaceId = channelToSpaceMap.get(currentVoiceChannelId);
if (spaceId) {
navigate(`/channels/${spaceId}/${currentVoiceChannelId}`);
}
}
} else {
// Drag ended — snap to edge
snapToEdge(position.x, position.y);
}
}, [isDragging, activeDmCall, currentVoiceChannelId, channelToServerMap, navigate, snapToEdge, position]);
}, [isDragging, activeDmCall, currentVoiceChannelId, channelToSpaceMap, navigate, snapToEdge, position]);
const handleClose = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
@@ -2,7 +2,7 @@ import React from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
const EMPTY_VOICE_USERS: string[] = [];
import { useServerStore } from '../../stores/serverStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { Avatar } from '../ui/Avatar';
interface VoiceChannelProps {
@@ -23,7 +23,7 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
const local = s.participants.find(p => p.isLocal);
return local?.userId ?? null;
});
const members = useServerStore((s) => s.members);
const members = useSpaceStore((s) => s.members);
const isActive = currentVoiceChannel === channelId;
return (
@@ -3,7 +3,7 @@ import { useVoiceStore } from '../../stores/voiceStore';
import { useUIStore } from '../../stores/uiStore';
import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket';
import { getChannelOrigin } from '../../stores/serverStore';
import { getChannelOrigin } from '../../stores/spaceStore';
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
import { CAMERA_PRESET, startScreenShare, stopScreenShare } from '../../utils/screenShare';
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useServerStore, getChannelOrigin } from '../../stores/serverStore';
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket';
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
@@ -21,7 +21,7 @@ export function VoiceControls() {
const connectionError = useVoiceStore((s) => s.connectionError);
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
const connectionQuality = useVoiceStore((s) => s.connectionQuality);
const channels = useServerStore((s) => s.channels);
const channels = useSpaceStore((s) => s.channels);
const [showScreenShareSettings, setShowScreenShareSettings] = useState(false);
const [showConnectionInfo, setShowConnectionInfo] = useState(false);
+2 -2
View File
@@ -12,7 +12,7 @@ import {
LocalAudioTrack,
LocalTrackPublication,
} from 'livekit-client';
import { getApiForOrigin, getChannelOrigin, useServerStore } from '../stores/serverStore';
import { getApiForOrigin, getChannelOrigin, useSpaceStore } from '../stores/spaceStore';
import { wsSend } from './useWebSocket';
import { useVoiceStore } from '../stores/voiceStore';
import { AudioManager } from '../audio/AudioManager';
@@ -154,7 +154,7 @@ export function useLiveKit() {
const processParticipant = (p: Participant, isLocal: boolean) => {
if (!p.identity) return;
const { userId, username } = parseIdentity(p.identity);
const memberMatch = useServerStore.getState().members.find(m => m.userId === userId);
const memberMatch = useSpaceStore.getState().members.find(m => m.userId === userId);
const homeUserId = memberMatch?.user.homeUserId ?? null;
let audioTrack: MediaStreamTrack | null = null;
let videoTrack: MediaStreamTrack | null = null;
+37 -37
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useRef } from 'react';
import { useAuthStore } from '../stores/authStore';
import { useServerStore, getChannelOrigin } from '../stores/serverStore';
import { useSpaceStore, getChannelOrigin } from '../stores/spaceStore';
import { useChatStore } from '../stores/chatStore';
import { useVoiceStore } from '../stores/voiceStore';
import { useSocialStore } from '../stores/socialStore';
@@ -81,7 +81,7 @@ const HOME_ORIGIN = '';
function handleEvent(origin: string, event: ServerEvent): void {
const isHome = origin === HOME_ORIGIN;
const { setUser } = useAuthStore.getState();
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useServerStore.getState();
const { populateFromReady, loadSpaceDetail, currentSpaceId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useSpaceStore.getState();
const { addMessage, addRealtimeMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, clearVoiceUsersForOrigin, setVoiceUsers, setVoiceUserStatus, clearVoiceUserStatus } = useVoiceStore.getState();
@@ -95,17 +95,17 @@ function handleEvent(origin: string, event: ServerEvent): void {
// Normalize asset URLs for remote origins before dispatching to stores
if (!isHome) {
for (const server of event.servers) {
if (server.icon) server.icon = resolveAssetUrl(server.icon, origin) ?? server.icon;
if ((server as any).members) {
for (const member of (server as any).members) {
for (const space of event.spaces) {
if (space.icon) space.icon = resolveAssetUrl(space.icon, origin) ?? space.icon;
if ((space as any).members) {
for (const member of (space as any).members) {
if (member.user) normalizeUserAssets(member.user, origin);
}
}
}
}
populateFromReady(origin, event.servers, event.folders, event.dmChannels);
populateFromReady(origin, event.spaces, event.folders, event.dmChannels);
// Mark remote instance as connected in instanceStore
if (!isHome) {
@@ -114,16 +114,16 @@ function handleEvent(origin: string, event: ServerEvent): void {
});
}
if (isHome && currentServerId) {
loadServerDetail(currentServerId);
if (isHome && currentSpaceId) {
loadSpaceDetail(currentSpaceId);
}
// For remote instances: if user was viewing one of these servers, load its details
// (fixes race condition on page reload — route params effect fires before remote WS connects)
if (!isHome) {
const { currentServerId: curServerId, loadServerDetail: loadDetail } = useServerStore.getState();
if (curServerId && event.servers.some((s: any) => s.id === curServerId)) {
loadDetail(curServerId);
const { currentSpaceId: curSpaceId, loadSpaceDetail: loadDetail } = useSpaceStore.getState();
if (curSpaceId && event.spaces.some((s: any) => s.id === curSpaceId)) {
loadDetail(curSpaceId);
const { currentChannelId, loadMessages } = useChatStore.getState();
if (currentChannelId) {
loadMessages(currentChannelId, true);
@@ -138,7 +138,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
reloadMessages(currentChannelId, true);
}
// Initialize unread tracking from ready payload
const { channelLastMessageIds } = useServerStore.getState();
const { channelLastMessageIds } = useSpaceStore.getState();
if (event.readStates) {
setReadStates(event.readStates, channelLastMessageIds);
}
@@ -273,7 +273,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
case 'dm_message_created': {
if (!isHome) normalizeMessageAssets(event.message as any, origin);
addRealtimeMessage(event.message.dmChannelId, event.message as any);
const { dmChannels: currentDmChannels, setDmChannels: setDms, addDmChannel: addDmCh } = useServerStore.getState();
const { dmChannels: currentDmChannels, setDmChannels: setDms, addDmChannel: addDmCh } = useSpaceStore.getState();
const knownDm = currentDmChannels.find(dm => dm.id === event.message.dmChannelId);
if (!knownDm) {
addDmCh({
@@ -417,27 +417,27 @@ function handleEvent(origin: string, event: ServerEvent): void {
case 'dm_member_added': {
if (!isHome) normalizeUserAssets(event.user, origin);
const { addDmMember } = useServerStore.getState();
const { addDmMember } = useSpaceStore.getState();
addDmMember(event.dmChannelId, event.user);
break;
}
case 'dm_member_removed': {
const { removeDmMember } = useServerStore.getState();
const { removeDmMember } = useSpaceStore.getState();
removeDmMember(event.dmChannelId, event.userId);
break;
}
// ─── Channel/server events (all origins) ────────────────────────────────
// ─── Channel/space events (all origins) ────────────────────────────────
case 'channel_created': {
const { currentServerId: curServerId, channels: curChannels, setChannels, channelToServerMap, channelPermissions, channelOriginMap } = useServerStore.getState();
if (event.serverId === curServerId) {
const { currentSpaceId: curSpaceId, channels: curChannels, setChannels, channelToSpaceMap, channelPermissions, channelOriginMap } = useSpaceStore.getState();
if (event.spaceId === curSpaceId) {
if (!curChannels.find(c => c.id === event.channel.id)) {
setChannels([...curChannels, event.channel].sort((a, b) => a.position - b.position));
}
}
channelToServerMap.set(event.channel.id, event.serverId);
channelToSpaceMap.set(event.channel.id, event.spaceId);
channelOriginMap.set(event.channel.id, origin);
if (event.channel.myPermissions) {
channelPermissions.set(event.channel.id, event.channel.myPermissions);
@@ -446,15 +446,15 @@ function handleEvent(origin: string, event: ServerEvent): void {
}
case 'channel_updated': {
const { currentServerId: curServerId2, channels: curChannels2, setChannels: setChannels2, channelPermissions: chPermsMap2 } = useServerStore.getState();
if (event.serverId === curServerId2) {
const { currentSpaceId: curSpaceId2, channels: curChannels2, setChannels: setChannels2, channelPermissions: chPermsMap2 } = useSpaceStore.getState();
if (event.spaceId === curSpaceId2) {
const exists = curChannels2.some(c => c.id === event.channel.id);
if (exists) {
setChannels2(curChannels2.map(c => c.id === event.channel.id ? event.channel : c).sort((a, b) => a.position - b.position));
} else {
setChannels2([...curChannels2, event.channel].sort((a, b) => a.position - b.position));
const { channelToServerMap: ctsMmap, channelOriginMap: coMap } = useServerStore.getState();
ctsMmap.set(event.channel.id, event.serverId);
const { channelToSpaceMap: ctsMmap, channelOriginMap: coMap } = useSpaceStore.getState();
ctsMmap.set(event.channel.id, event.spaceId);
coMap.set(event.channel.id, origin);
}
}
@@ -465,8 +465,8 @@ function handleEvent(origin: string, event: ServerEvent): void {
}
case 'channel_deleted': {
const { currentServerId: curServerId3, channels: curChannels3, setChannels: setChannels3, channelPermissions: chPermsMap3, channelToServerMap: ctsMap3, channelOriginMap: coMap3 } = useServerStore.getState();
if (event.serverId === curServerId3) {
const { currentSpaceId: curSpaceId3, channels: curChannels3, setChannels: setChannels3, channelPermissions: chPermsMap3, channelToSpaceMap: ctsMap3, channelOriginMap: coMap3 } = useSpaceStore.getState();
if (event.spaceId === curSpaceId3) {
setChannels3(curChannels3.filter(c => c.id !== event.channelId));
}
chPermsMap3.delete(event.channelId);
@@ -475,7 +475,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
{
const { currentChannelId } = useChatStore.getState();
if (currentChannelId === event.channelId) {
const { channels: remainingChannels } = useServerStore.getState();
const { channels: remainingChannels } = useSpaceStore.getState();
const firstText = remainingChannels.find(c => c.type === 'text');
if (firstText) {
useChatStore.getState().setCurrentChannel(firstText.id);
@@ -487,12 +487,12 @@ function handleEvent(origin: string, event: ServerEvent): void {
break;
}
case 'server_updated': {
if (!isHome && event.server.icon) {
event.server.icon = resolveAssetUrl(event.server.icon, origin) ?? event.server.icon;
case 'space_updated': {
if (!isHome && event.space.icon) {
event.space.icon = resolveAssetUrl(event.space.icon, origin) ?? event.space.icon;
}
const { servers: currentServers, setServers } = useServerStore.getState();
setServers(currentServers.map(s => s.id === event.server.id ? { ...s, ...event.server } : s));
const { spaces: currentSpaces, setSpaces } = useSpaceStore.getState();
setSpaces(currentSpaces.map(s => s.id === event.space.id ? { ...s, ...event.space } : s));
break;
}
@@ -506,16 +506,16 @@ function handleEvent(origin: string, event: ServerEvent): void {
case 'join_request_accepted': {
if (!isHome) break;
// Add the server to our server list
const { addServerFromReady } = useServerStore.getState();
addServerFromReady(origin, event.server);
console.log('[WebSocket] Join request accepted for server', event.server.name);
// Add the space to our space list
const { addSpaceFromReady } = useSpaceStore.getState();
addSpaceFromReady(origin, event.space);
console.log('[WebSocket] Join request accepted for space', event.space.name);
break;
}
case 'join_request_declined': {
if (!isHome) break;
console.log('[WebSocket] Join request declined for server', event.request.serverId);
console.log('[WebSocket] Join request declined for space', event.request.spaceId);
break;
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand';
import type { User } from '@backspace/shared';
import { api } from '../api/client';
import { useChatStore } from './chatStore';
import { useServerStore } from './serverStore';
import { useSpaceStore } from './spaceStore';
import { useSocialStore } from './socialStore';
import { useVoiceStore } from './voiceStore';
import { useInstanceStore } from './instanceStore';
@@ -57,7 +57,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
localStorage.removeItem('backspace_token');
// Clear all user-scoped state to prevent data leaking between sessions
useChatStore.getState().clearAllMessages();
useServerStore.getState().populateFromReady('', [], [], []);
useSpaceStore.getState().populateFromReady('', [], [], []);
useSocialStore.getState().reset();
useVoiceStore.getState().clearAllVoiceUsers();
useInstanceStore.getState().reset();
+3 -3
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import type { MessageWithUser, Reaction, ReadState } from '@backspace/shared';
import { wsSend } from '../hooks/useWebSocket';
import { isDmChannel, getChannelOrigin, getApiForOrigin, useServerStore } from './serverStore';
import { isDmChannel, getChannelOrigin, getApiForOrigin, useSpaceStore } from './spaceStore';
import { useAuthStore } from './authStore';
import { normalizeMessageAssets } from '../utils/assetUrls';
@@ -135,7 +135,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
const isDm = isDmChannel(channelId);
// For server channels, bail if we don't know which instance owns this channel yet.
// The remote WS ready handler will call loadMessages once the map is populated.
if (!isDm && !useServerStore.getState().channelOriginMap.has(channelId)) return;
if (!isDm && !useSpaceStore.getState().channelOriginMap.has(channelId)) return;
set({ isLoading: true, loadError: null });
try {
const origin = getChannelOrigin(channelId);
@@ -229,7 +229,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
// For DMs, update lastMessage on the DM channel so sidebar re-sorts
if (isDm) {
const { dmChannels, setDmChannels } = useServerStore.getState();
const { dmChannels, setDmChannels } = useSpaceStore.getState();
const updatedDms = dmChannels.map(dm =>
dm.id === channelId
? { ...dm, lastMessage: { id: tempId, dmChannelId: channelId, userId: currentUser.id, content, createdAt: Date.now() } }
+31 -31
View File
@@ -1,17 +1,17 @@
import { create } from 'zustand';
import type { ExploreServer, JoinRequest, ServerWithChannelsAndMembers } from '@backspace/shared';
import type { ExploreSpace, JoinRequest, SpaceWithChannelsAndMembers } from '@backspace/shared';
import { api } from '../api/client';
import { useInstanceStore } from './instanceStore';
import { useServerStore } from './serverStore';
import { useSpaceStore } from './spaceStore';
// ─── Types ──────────────────────────────────────────────────────────────────
export interface TaggedExploreServer extends ExploreServer {
export interface TaggedExploreSpace extends ExploreSpace {
_instanceOrigin: string; // '' = home instance
}
interface ExploreState {
servers: TaggedExploreServer[];
spaces: TaggedExploreSpace[];
myRequests: JoinRequest[];
searchQuery: string;
isLoading: boolean;
@@ -19,10 +19,10 @@ interface ExploreState {
totalAll: number;
error: string | null;
fetchServers: (query?: string) => Promise<void>;
fetchSpaces: (query?: string) => Promise<void>;
fetchMyRequests: () => Promise<void>;
publicJoin: (server: TaggedExploreServer) => Promise<ServerWithChannelsAndMembers>;
requestJoin: (server: TaggedExploreServer, message?: string) => Promise<JoinRequest>;
publicJoin: (space: TaggedExploreSpace) => Promise<SpaceWithChannelsAndMembers>;
requestJoin: (space: TaggedExploreSpace, message?: string) => Promise<JoinRequest>;
setSearchQuery: (q: string) => void;
reset: () => void;
}
@@ -38,7 +38,7 @@ function getApiForOrigin(origin: string) {
// ─── Store ──────────────────────────────────────────────────────────────────
export const useExploreStore = create<ExploreState>((set, get) => ({
servers: [],
spaces: [],
myRequests: [],
searchQuery: '',
isLoading: false,
@@ -46,7 +46,7 @@ export const useExploreStore = create<ExploreState>((set, get) => ({
totalAll: 0,
error: null,
fetchServers: async (query?: string) => {
fetchSpaces: async (query?: string) => {
set({ isLoading: true, error: null });
try {
@@ -61,22 +61,22 @@ export const useExploreStore = create<ExploreState>((set, get) => ({
),
]);
const fulfilled = results.filter(r => r.status === 'fulfilled') as PromiseFulfilledResult<{ servers: ExploreServer[]; total: number; totalAll?: number; discoveryEnabled: boolean; origin: string }>[];
const fulfilled = results.filter(r => r.status === 'fulfilled') as PromiseFulfilledResult<{ spaces: ExploreSpace[]; total: number; totalAll?: number; discoveryEnabled: boolean; origin: string }>[];
const rejected = results.filter(r => r.status === 'rejected');
// If ALL instances failed, surface an error
if (fulfilled.length === 0 && rejected.length > 0) {
set({ isLoading: false, error: 'Failed to reach any server for discovery' });
set({ isLoading: false, error: 'Failed to reach any instance for discovery' });
return;
}
const allServers: TaggedExploreServer[] = [];
const seen = new Set<string>(); // dedup by serverId+origin
const allSpaces: TaggedExploreSpace[] = [];
const seen = new Set<string>(); // dedup by spaceId+origin
let homeDiscoveryEnabled = true;
let totalAllSum = 0;
for (const result of fulfilled) {
const { servers, discoveryEnabled, totalAll, origin } = result.value;
const { spaces, discoveryEnabled, totalAll, origin } = result.value;
// Track home instance discovery state
if (!origin) {
@@ -85,16 +85,16 @@ export const useExploreStore = create<ExploreState>((set, get) => ({
totalAllSum += totalAll ?? 0;
for (const server of servers) {
const key = `${server.id}:${origin}`;
for (const space of spaces) {
const key = `${space.id}:${origin}`;
if (seen.has(key)) continue;
seen.add(key);
allServers.push({ ...server, _instanceOrigin: origin, joined: server.joined ?? false });
allSpaces.push({ ...space, _instanceOrigin: origin, joined: space.joined ?? false });
}
}
set({
servers: allServers,
spaces: allSpaces,
discoveryEnabled: homeDiscoveryEnabled,
totalAll: totalAllSum,
isLoading: false,
@@ -102,7 +102,7 @@ export const useExploreStore = create<ExploreState>((set, get) => ({
} catch (err) {
set({
isLoading: false,
error: err instanceof Error ? err.message : 'Failed to fetch servers',
error: err instanceof Error ? err.message : 'Failed to fetch spaces',
});
}
},
@@ -116,28 +116,28 @@ export const useExploreStore = create<ExploreState>((set, get) => ({
}
},
publicJoin: async (server: TaggedExploreServer) => {
const client = getApiForOrigin(server._instanceOrigin);
const fullServer = await client.explore.publicJoin(server.id);
publicJoin: async (space: TaggedExploreSpace) => {
const client = getApiForOrigin(space._instanceOrigin);
const fullSpace = await client.explore.publicJoin(space.id);
// Add to server store
useServerStore.getState().addServerFromReady(server._instanceOrigin, fullServer);
// Add to space store
useSpaceStore.getState().addSpaceFromReady(space._instanceOrigin, fullSpace);
// Mark as joined in explore list
set((state) => ({
servers: state.servers.map(s =>
s.id === server.id && s._instanceOrigin === server._instanceOrigin
spaces: state.spaces.map(s =>
s.id === space.id && s._instanceOrigin === space._instanceOrigin
? { ...s, joined: true }
: s
),
}));
return fullServer;
return fullSpace;
},
requestJoin: async (server: TaggedExploreServer, message?: string) => {
const client = getApiForOrigin(server._instanceOrigin);
const request = await client.explore.requestJoin(server.id, message);
requestJoin: async (space: TaggedExploreSpace, message?: string) => {
const client = getApiForOrigin(space._instanceOrigin);
const request = await client.explore.requestJoin(space.id, message);
set((state) => ({
myRequests: [...state.myRequests, request],
@@ -149,7 +149,7 @@ export const useExploreStore = create<ExploreState>((set, get) => ({
setSearchQuery: (q: string) => set({ searchQuery: q }),
reset: () => set({
servers: [],
spaces: [],
myRequests: [],
searchQuery: '',
isLoading: false,
+7 -7
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand';
import type { User, InstanceInfoResponse, ReplicatedInstance, AuthResponse } from '@backspace/shared';
import { BackspaceApiClient, createApiClient, api } from '../api/client';
import { useAuthStore } from './authStore';
import { setApiForOriginResolver, setUserIdForOriginResolver, useServerStore } from './serverStore';
import { setApiForOriginResolver, setUserIdForOriginResolver, useSpaceStore } from './spaceStore';
import { connectInstance, disconnectInstance, disconnectAllRemote } from '../hooks/useWebSocket';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -282,8 +282,8 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
return { instances: updated };
});
// Remove servers from this instance from the server store
useServerStore.getState().removeInstanceServers(origin);
// Remove spaces from this instance from the space store
useSpaceStore.getState().removeInstanceSpaces(origin);
// Sync updated list to remaining instances (fire-and-forget)
get().syncInstanceList().catch(() => {});
@@ -479,10 +479,10 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
},
reset: () => {
// Clean up server store for each connected remote instance before tearing down
// Clean up space store for each connected remote instance before tearing down
const { instances } = get();
for (const inst of instances) {
useServerStore.getState().removeInstanceServers(inst.origin);
useSpaceStore.getState().removeInstanceSpaces(inst.origin);
}
// Tear down all remote WebSocket connections
@@ -494,9 +494,9 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
}));
// ─── API client resolution ───────────────────────────────────────────────────
// Register the resolver with serverStore so getApiForOrigin() works everywhere.
// Register the resolver with spaceStore so getApiForOrigin() works everywhere.
// Placed after store creation so useInstanceStore is definitely initialized.
// This breaks the circular dependency: chatStore → serverStore ← instanceStore
// This breaks the circular dependency: chatStore → spaceStore ← instanceStore
// instead of: chatStore → instanceStore → useWebSocket → chatStore (cycle).
setApiForOriginResolver((origin: string): BackspaceApiClient => {
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import type { Server, Channel, MemberWithUser, ServerWithChannelsAndMembers, Role, ServerFolder, DmChannel, User, UpdateServerRequest } from '@backspace/shared';
import type { Space, Channel, MemberWithUser, SpaceWithChannelsAndMembers, Role, SpaceFolder, DmChannel, User, UpdateSpaceRequest } from '@backspace/shared';
import { api, BackspaceApiClient } from '../api/client';
import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls';
import { isSelf } from '../utils/identity';
@@ -8,7 +8,7 @@ import { useAuthStore } from './authStore';
// ─── Instance-aware types ─────────────────────────────────────────────────────
/** Server augmented with instance origin tracking (client-only, not in shared types). */
export type TaggedServer = Server & { _instanceOrigin: string };
export type TaggedSpace = Space & { _instanceOrigin: string };
// ─── Error types ─────────────────────────────────────────────────────────────
@@ -22,21 +22,21 @@ export class NotConnectedError extends Error {
// ─── Store interface ──────────────────────────────────────────────────────────
interface ServerState {
servers: TaggedServer[];
currentServerId: string | null;
interface SpaceState {
spaces: TaggedSpace[];
currentSpaceId: string | null;
channels: Channel[];
members: MemberWithUser[];
roles: Role[];
folders: ServerFolder[];
folders: SpaceFolder[];
dmChannels: DmChannel[];
channelToServerMap: Map<string, string>;
channelToSpaceMap: Map<string, string>;
channelLastMessageIds: Map<string, string>;
serverPermissions: Map<string, string>; // serverId → myPermissions decimal string
spacePermissions: Map<string, string>; // spaceId → myPermissions decimal string
channelPermissions: Map<string, string>; // channelId → myPermissions decimal string
channelOriginMap: Map<string, string>; // channelId → instance origin ('' = home)
setServers: (servers: TaggedServer[]) => void;
setCurrentServer: (serverId: string | null) => void;
setSpaces: (spaces: TaggedSpace[]) => void;
setCurrentSpace: (spaceId: string | null) => void;
setChannels: (channels: Channel[]) => void;
setMembers: (members: MemberWithUser[]) => void;
setRoles: (roles: Role[]) => void;
@@ -46,44 +46,44 @@ interface ServerState {
addDmMember: (dmChannelId: string, user: User) => void;
removeDmMember: (dmChannelId: string, userId: string) => void;
closeDm: (id: string) => Promise<void>;
loadServers: () => Promise<void>;
loadServerDetail: (serverId: string) => Promise<void>;
loadSpaces: () => Promise<void>;
loadSpaceDetail: (spaceId: string) => Promise<void>;
loadDmChannels: () => Promise<void>;
createServer: (name: string, icon?: string) => Promise<Server>;
updateServer: (serverId: string, data: UpdateServerRequest) => Promise<void>;
deleteServer: (serverId: string) => Promise<void>;
joinServer: (serverId: string, inviteCode: string) => Promise<void>;
joinByCode: (inviteCode: string, origin?: string) => Promise<Server>;
generateInvite: (serverId: string) => Promise<string>;
createChannel: (serverId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => Promise<Channel>;
createSpace: (name: string, icon?: string) => Promise<Space>;
updateSpace: (spaceId: string, data: UpdateSpaceRequest) => Promise<void>;
deleteSpace: (spaceId: string) => Promise<void>;
joinSpace: (spaceId: string, inviteCode: string) => Promise<void>;
joinByCode: (inviteCode: string, origin?: string) => Promise<Space>;
generateInvite: (spaceId: string) => Promise<string>;
createChannel: (spaceId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => Promise<Channel>;
deleteChannel: (channelId: string) => Promise<void>;
addServer: (server: Server) => void;
removeServer: (serverId: string) => void;
addSpace: (space: Space) => void;
removeSpace: (spaceId: string) => void;
updateMemberPresence: (userId: string, status: string) => void;
addMember: (member: MemberWithUser) => void;
removeMember: (userId: string) => void;
populateFromReady: (origin: string, servers: ServerWithChannelsAndMembers[], folders?: ServerFolder[], dmChannels?: DmChannel[]) => void;
addServerFromReady: (origin: string, server: ServerWithChannelsAndMembers) => void;
removeInstanceServers: (origin: string) => void;
populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[]) => void;
addSpaceFromReady: (origin: string, space: SpaceWithChannelsAndMembers) => void;
removeInstanceSpaces: (origin: string) => void;
findExistingDmForUser: (targetUser: { id: string; homeUserId?: string | null }) => { dm: DmChannel; origin: string } | null;
}
export const useServerStore = create<ServerState>((set, get) => ({
servers: [],
currentServerId: null,
export const useSpaceStore = create<SpaceState>((set, get) => ({
spaces: [],
currentSpaceId: null,
channels: [],
members: [],
roles: [],
folders: [],
dmChannels: [],
channelToServerMap: new Map(),
channelToSpaceMap: new Map(),
channelLastMessageIds: new Map(),
serverPermissions: new Map(),
spacePermissions: new Map(),
channelPermissions: new Map(),
channelOriginMap: new Map(),
setServers: (servers) => set({ servers }),
setCurrentServer: (serverId) => set({ currentServerId: serverId }),
setSpaces: (spaces) => set({ spaces }),
setCurrentSpace: (spaceId) => set({ currentSpaceId: spaceId }),
setChannels: (channels) => set({ channels }),
setMembers: (members) => set({ members }),
setRoles: (roles) => set({ roles }),
@@ -129,26 +129,26 @@ export const useServerStore = create<ServerState>((set, get) => ({
}));
},
loadServers: async () => {
loadSpaces: async () => {
try {
const servers = await api.servers.list();
const spaces =await api.spaces.list();
set((state) => ({
servers: servers.map(s => ({ ...s, _instanceOrigin: '' })) as TaggedServer[],
spaces: spaces.map(s => ({ ...s, _instanceOrigin: '' })) as TaggedSpace[],
}));
} catch {
// Silently fail - will be populated from WS ready
}
},
loadServerDetail: async (serverId: string) => {
loadSpaceDetail: async (spaceId: string) => {
try {
// Resolve the correct API client based on the server's instance origin
const server = get().servers.find(s => s.id === serverId);
if (!server) return; // Not populated yet — remote WS ready will trigger reload
const origin = server._instanceOrigin ?? '';
const space =get().spaces.find(s => s.id === spaceId);
if (!space) return; // Not populated yet — remote WS ready will trigger reload
const origin = space._instanceOrigin ?? '';
const client = getApiForOrigin(origin);
const detail = await client.servers.get(serverId);
const detail = await client.spaces.get(spaceId);
// Normalize remote asset URLs (avatars, server icon)
if (origin) {
if (detail.icon) detail.icon = resolveAssetUrl(detail.icon, origin) ?? detail.icon;
@@ -157,7 +157,7 @@ export const useServerStore = create<ServerState>((set, get) => ({
}
}
set({
currentServerId: serverId,
currentSpaceId: spaceId,
channels: detail.channels.sort((a, b) => a.position - b.position),
members: detail.members,
roles: detail.roles.sort((a, b) => b.position - a.position),
@@ -176,33 +176,33 @@ export const useServerStore = create<ServerState>((set, get) => ({
}
},
createServer: async (name: string, icon?: string) => {
const server = await api.servers.create({ name, icon });
const tagged: TaggedServer = { ...server, _instanceOrigin: '' };
set((state) => ({ servers: [...state.servers, tagged] }));
return server;
createSpace: async (name: string, icon?: string) => {
const space =await api.spaces.create({ name, icon });
const tagged: TaggedSpace = { ...space, _instanceOrigin: '' };
set((state) => ({ spaces: [...state.spaces, tagged] }));
return space;
},
updateServer: async (serverId: string, data: UpdateServerRequest) => {
const updated = await api.servers.update(serverId, data);
updateSpace: async (spaceId: string, data: UpdateSpaceRequest) => {
const updated = await api.spaces.update(spaceId, data);
set((state) => ({
servers: state.servers.map(s => s.id === serverId ? { ...s, ...updated } : s),
spaces: state.spaces.map(s => s.id === spaceId ? { ...s, ...updated } : s),
}));
},
deleteServer: async (serverId: string) => {
await api.servers.delete(serverId);
deleteSpace: async (spaceId: string) => {
await api.spaces.delete(spaceId);
set((state) => ({
servers: state.servers.filter(s => s.id !== serverId),
currentServerId: state.currentServerId === serverId ? null : state.currentServerId,
spaces: state.spaces.filter(s => s.id !== spaceId),
currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId,
}));
},
joinServer: async (serverId: string, inviteCode: string) => {
const server = await api.servers.join(serverId, { inviteCode });
joinSpace: async (spaceId: string, inviteCode: string) => {
const space =await api.spaces.join(spaceId, { inviteCode });
set((state) => {
if (state.servers.find(s => s.id === server.id)) return state;
return { servers: [...state.servers, { ...server, _instanceOrigin: '' } as TaggedServer] };
if (state.spaces.find(s => s.id === space.id)) return state;
return { spaces: [...state.spaces, { ...space, _instanceOrigin: '' } as TaggedSpace] };
});
},
@@ -216,33 +216,33 @@ export const useServerStore = create<ServerState>((set, get) => ({
if (!connected) throw new NotConnectedError(origin);
const remoteApi = getApiForOrigin(origin);
const server = await remoteApi.servers.joinByCode(inviteCode);
const space =await remoteApi.spaces.joinByCode(inviteCode);
set((state) => {
if (state.servers.find(s => s.id === server.id)) return state;
return { servers: [...state.servers, { ...server, _instanceOrigin: origin } as TaggedServer] };
if (state.spaces.find(s => s.id === space.id)) return state;
return { spaces: [...state.spaces, { ...space, _instanceOrigin: origin } as TaggedSpace] };
});
return server;
return space;
}
// Home instance
const server = await api.servers.joinByCode(inviteCode);
const space =await api.spaces.joinByCode(inviteCode);
set((state) => {
if (state.servers.find(s => s.id === server.id)) return state;
return { servers: [...state.servers, { ...server, _instanceOrigin: '' } as TaggedServer] };
if (state.spaces.find(s => s.id === space.id)) return state;
return { spaces: [...state.spaces, { ...space, _instanceOrigin: '' } as TaggedSpace] };
});
return server;
return space;
},
generateInvite: async (serverId: string) => {
const server = get().servers.find(s => s.id === serverId);
const origin = server?._instanceOrigin ?? '';
generateInvite: async (spaceId: string) => {
const space =get().spaces.find(s => s.id === spaceId);
const origin = space?._instanceOrigin ?? '';
const client = getApiForOrigin(origin);
const result = await client.servers.invite(serverId);
const result = await client.spaces.invite(spaceId);
return result.inviteCode;
},
createChannel: async (serverId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => {
const channel = await api.channels.create(serverId, { name, type, topic });
createChannel: async (spaceId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => {
const channel = await api.channels.create(spaceId, { name, type, topic });
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) };
@@ -257,17 +257,17 @@ export const useServerStore = create<ServerState>((set, get) => ({
}));
},
addServer: (server: Server) => {
addSpace: (space: Space) => {
set((state) => {
if (state.servers.find(s => s.id === server.id)) return state;
return { servers: [...state.servers, { ...server, _instanceOrigin: '' } as TaggedServer] };
if (state.spaces.find(s => s.id === space.id)) return state;
return { spaces: [...state.spaces, { ...space, _instanceOrigin: '' } as TaggedSpace] };
});
},
removeServer: (serverId: string) => {
removeSpace: (spaceId: string) => {
set((state) => ({
servers: state.servers.filter(s => s.id !== serverId),
currentServerId: state.currentServerId === serverId ? null : state.currentServerId,
spaces: state.spaces.filter(s => s.id !== spaceId),
currentSpaceId: state.currentSpaceId === spaceId ? null : state.currentSpaceId,
}));
},
@@ -291,11 +291,11 @@ export const useServerStore = create<ServerState>((set, get) => ({
}));
},
populateFromReady: (origin: string, servers: ServerWithChannelsAndMembers[], folders?: ServerFolder[], dmChannels?: DmChannel[]) => {
populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[]) => {
const isHome = !origin;
// Tag all incoming servers with their instance origin
const taggedServers: TaggedServer[] = servers.map(s => ({
const taggedSpaces: TaggedSpace[] = spaces.map(s => ({
id: s.id,
name: s.name,
icon: s.icon,
@@ -308,13 +308,13 @@ export const useServerStore = create<ServerState>((set, get) => ({
}));
// Merge by origin: keep servers from other origins, replace all from this origin
const existingFromOtherOrigins = get().servers.filter(s => s._instanceOrigin !== origin);
const mergedServers = [...existingFromOtherOrigins, ...taggedServers];
const existingFromOtherOrigins = get().spaces.filter(s => s._instanceOrigin !== origin);
const mergedSpaces = [...existingFromOtherOrigins, ...taggedSpaces];
// Build/merge maps for incoming channels
const channelToServerMap = new Map(get().channelToServerMap);
const channelToSpaceMap = new Map(get().channelToSpaceMap);
const channelLastMessageIds = new Map(get().channelLastMessageIds);
const serverPermissions = new Map(get().serverPermissions);
const spacePermissions = new Map(get().spacePermissions);
const channelPermissions = new Map(get().channelPermissions);
const channelOriginMap = new Map(get().channelOriginMap);
@@ -322,42 +322,42 @@ export const useServerStore = create<ServerState>((set, get) => ({
if (isHome) {
for (const [key, val] of get().channelOriginMap) {
if (val === origin) {
channelToServerMap.delete(key);
channelToSpaceMap.delete(key);
channelLastMessageIds.delete(key);
channelPermissions.delete(key);
channelOriginMap.delete(key);
}
}
// Also clear server permissions for this origin
for (const s of get().servers) {
for (const s of get().spaces) {
if (s._instanceOrigin === origin) {
serverPermissions.delete(s.id);
spacePermissions.delete(s.id);
}
}
} else {
// Remote: clear entries that belonged to this origin
for (const [key, val] of get().channelOriginMap) {
if (val === origin) {
channelToServerMap.delete(key);
channelToSpaceMap.delete(key);
channelLastMessageIds.delete(key);
channelPermissions.delete(key);
channelOriginMap.delete(key);
}
}
for (const s of get().servers) {
for (const s of get().spaces) {
if (s._instanceOrigin === origin) {
serverPermissions.delete(s.id);
spacePermissions.delete(s.id);
}
}
}
// Populate maps from incoming servers
for (const srv of servers) {
for (const srv of spaces) {
if (srv.myPermissions) {
serverPermissions.set(srv.id, srv.myPermissions);
spacePermissions.set(srv.id, srv.myPermissions);
}
for (const ch of srv.channels) {
channelToServerMap.set(ch.id, srv.id);
channelToSpaceMap.set(ch.id, srv.id);
channelOriginMap.set(ch.id, origin);
if (ch.lastMessageId) {
channelLastMessageIds.set(ch.id, ch.lastMessageId);
@@ -390,12 +390,12 @@ export const useServerStore = create<ServerState>((set, get) => ({
});
const mergedDms = [...existingDmsFromOtherOrigins, ...incomingDms];
const update: Partial<ServerState> = {
servers: mergedServers,
const update: Partial<SpaceState> = {
spaces: mergedSpaces,
dmChannels: mergedDms,
channelToServerMap,
channelToSpaceMap,
channelLastMessageIds,
serverPermissions,
spacePermissions,
channelPermissions,
channelOriginMap,
};
@@ -408,30 +408,30 @@ export const useServerStore = create<ServerState>((set, get) => ({
set(update as any);
},
addServerFromReady: (origin: string, server: ServerWithChannelsAndMembers) => {
const tagged: TaggedServer = {
id: server.id,
name: server.name,
icon: server.icon,
ownerId: server.ownerId,
inviteCode: server.inviteCode,
visibility: server.visibility,
description: server.description,
createdAt: server.createdAt,
addSpaceFromReady: (origin: string, space: SpaceWithChannelsAndMembers) => {
const tagged: TaggedSpace = {
id: space.id,
name: space.name,
icon: space.icon,
ownerId: space.ownerId,
inviteCode: space.inviteCode,
visibility: space.visibility,
description: space.description,
createdAt: space.createdAt,
_instanceOrigin: origin,
};
const channelToServerMap = new Map(get().channelToServerMap);
const channelToSpaceMap = new Map(get().channelToSpaceMap);
const channelLastMessageIds = new Map(get().channelLastMessageIds);
const serverPermissions = new Map(get().serverPermissions);
const spacePermissions = new Map(get().spacePermissions);
const channelPermissions = new Map(get().channelPermissions);
const channelOriginMap = new Map(get().channelOriginMap);
if (server.myPermissions) {
serverPermissions.set(server.id, server.myPermissions);
if (space.myPermissions) {
spacePermissions.set(space.id, space.myPermissions);
}
for (const ch of server.channels) {
channelToServerMap.set(ch.id, server.id);
for (const ch of space.channels) {
channelToSpaceMap.set(ch.id, space.id);
channelOriginMap.set(ch.id, origin);
if (ch.lastMessageId) {
channelLastMessageIds.set(ch.id, ch.lastMessageId);
@@ -442,10 +442,10 @@ export const useServerStore = create<ServerState>((set, get) => ({
}
set((state) => ({
servers: [...state.servers.filter(s => s.id !== server.id), tagged],
channelToServerMap,
spaces: [...state.spaces.filter(s => s.id !== space.id), tagged],
channelToSpaceMap,
channelLastMessageIds,
serverPermissions,
spacePermissions,
channelPermissions,
channelOriginMap,
}));
@@ -470,40 +470,40 @@ export const useServerStore = create<ServerState>((set, get) => ({
return null;
},
removeInstanceServers: (origin: string) => {
removeInstanceSpaces: (origin: string) => {
set((state) => {
const remainingServers = state.servers.filter(s => s._instanceOrigin !== origin);
const remainingSpaces = state.spaces.filter(s => s._instanceOrigin !== origin);
// Clean up maps for channels that belonged to this origin
const channelToServerMap = new Map(state.channelToServerMap);
const channelToSpaceMap = new Map(state.channelToSpaceMap);
const channelLastMessageIds = new Map(state.channelLastMessageIds);
const channelPermissions = new Map(state.channelPermissions);
const channelOriginMap = new Map(state.channelOriginMap);
const serverPermissions = new Map(state.serverPermissions);
const spacePermissions = new Map(state.spacePermissions);
for (const [channelId, chOrigin] of state.channelOriginMap) {
if (chOrigin === origin) {
channelToServerMap.delete(channelId);
channelToSpaceMap.delete(channelId);
channelLastMessageIds.delete(channelId);
channelPermissions.delete(channelId);
channelOriginMap.delete(channelId);
}
}
for (const s of state.servers) {
for (const s of state.spaces) {
if (s._instanceOrigin === origin) {
serverPermissions.delete(s.id);
spacePermissions.delete(s.id);
}
}
return {
servers: remainingServers,
channelToServerMap,
spaces: remainingSpaces,
channelToSpaceMap,
channelLastMessageIds,
channelPermissions,
channelOriginMap,
serverPermissions,
currentServerId: remainingServers.find(s => s.id === state.currentServerId)
? state.currentServerId
spacePermissions,
currentSpaceId: remainingSpaces.find(s => s.id === state.currentSpaceId)
? state.currentSpaceId
: null,
};
});
@@ -516,7 +516,7 @@ export const useServerStore = create<ServerState>((set, get) => ({
* from the WS ready event and DM/server channel IDs never overlap.
*/
export function isDmChannel(channelId: string): boolean {
const dmChannels = useServerStore.getState().dmChannels;
const dmChannels = useSpaceStore.getState().dmChannels;
if (dmChannels.length > 0) {
return dmChannels.some(dm => dm.id === channelId);
}
@@ -532,12 +532,12 @@ export function isDmChannel(channelId: string): boolean {
* '' = home instance, 'https://...' = remote instance.
*/
export function getChannelOrigin(channelId: string): string {
return useServerStore.getState().channelOriginMap.get(channelId) ?? '';
return useSpaceStore.getState().channelOriginMap.get(channelId) ?? '';
}
// ─── API client resolution ────────────────────────────────────────────────────
// The actual resolver is registered by instanceStore on import, avoiding a
// circular dependency (instanceStore → useWebSocket → chatStore → serverStore).
// circular dependency (instanceStore → useWebSocket → chatStore → spaceStore).
let _getApiForOrigin: ((origin: string) => BackspaceApiClient) | null = null;
+9 -9
View File
@@ -3,12 +3,12 @@ import { persist, createJSONStorage } from 'zustand/middleware';
import type { User } from '@backspace/shared';
type ModalType =
| 'createServer'
| 'joinServer'
| 'createSpace'
| 'joinSpace'
| 'createChannel'
| 'invite'
| 'userSettings'
| 'serverSettings'
| 'spaceSettings'
| 'channelSettings'
| 'imagePreview'
| 'newDm'
@@ -46,8 +46,8 @@ interface UIState {
closeUserProfile: () => void;
addToast: (message: string, type?: 'info' | 'warning' | 'success', duration?: number) => void;
removeToast: (id: string) => void;
lastChannelPerServer: Record<string, string>;
setLastChannel: (serverId: string, channelId: string) => void;
lastChannelPerSpace: Record<string, string>;
setLastChannel: (spaceId: string, channelId: string) => void;
voiceChatOpen: boolean;
voiceFullscreen: boolean;
pipCollapsed: boolean;
@@ -112,9 +112,9 @@ export const useUIStore = create<UIState>()(
},
removeToast: (id) => set((state) => ({ toasts: state.toasts.filter(t => t.id !== id) })),
lastChannelPerServer: {},
setLastChannel: (serverId, channelId) => set((state) => ({
lastChannelPerServer: { ...state.lastChannelPerServer, [serverId]: channelId },
lastChannelPerSpace: {},
setLastChannel: (spaceId, channelId) => set((state) => ({
lastChannelPerSpace: { ...state.lastChannelPerSpace, [spaceId]: channelId },
})),
voiceChatOpen: false,
@@ -130,7 +130,7 @@ export const useUIStore = create<UIState>()(
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
memberListOpen: state.memberListOpen,
lastChannelPerServer: state.lastChannelPerServer,
lastChannelPerSpace: state.lastChannelPerSpace,
}),
}
)
+2 -2
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import type { ParticipantInfo } from '../hooks/useLiveKit';
import { AudioManager } from '../audio/AudioManager';
import { useServerStore, getChannelOrigin, getMyUserIdForOrigin } from './serverStore';
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from './spaceStore';
export interface ScreenShareConfig {
height: 1080 | 720 | 540;
@@ -278,7 +278,7 @@ export const useVoiceStore = create<VoiceState>()(
clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }),
clearVoiceUsersForOrigin: (origin: string) => {
const { channelOriginMap } = useServerStore.getState();
const { channelOriginMap } = useSpaceStore.getState();
set((state) => {
const newVoiceUsers = new Map(state.voiceUsers);
for (const [channelId] of newVoiceUsers) {
+1 -1
View File
@@ -130,7 +130,7 @@
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
/* Server strip glass — no border-radius, rightward shadow */
/* Space strip glass — no border-radius, rightward shadow */
.glass-strip {
backdrop-filter: blur(20px) saturate(120%);
-webkit-backdrop-filter: blur(20px) saturate(120%);
+1 -1
View File
@@ -1,4 +1,4 @@
import { getApiForOrigin } from '../stores/serverStore';
import { getApiForOrigin } from '../stores/spaceStore';
/**
* Resolve a relative asset filename to an absolute URL for remote origins.
+5 -5
View File
@@ -19,8 +19,8 @@ const AVATAR_GRADIENTS: GradientEntry[] = [
{ gradient: 'linear-gradient(135deg, #f59e0b, #eab308)', glow: '#f59e0b' }, // amber
];
// ── Server icon gradients (server fallbacks) ──
const SERVER_GRADIENTS: GradientEntry[] = [
// ── Space icon gradients (space fallbacks) ──
const SPACE_GRADIENTS: GradientEntry[] = [
{ gradient: 'linear-gradient(135deg, #ef4444, #f97316)', glow: '#f97316' }, // red-orange
{ gradient: 'linear-gradient(135deg, #ec4899, #f472b6)', glow: '#ec4899' }, // pink
{ gradient: 'linear-gradient(135deg, #14b8a6, #06b6d4)', glow: '#06b6d4' }, // teal-cyan
@@ -51,8 +51,8 @@ export function getAvatarGradient(id?: string | null, name?: string): GradientEn
return AVATAR_GRADIENTS[hashString(key) % AVATAR_GRADIENTS.length]!;
}
/** Deterministic gradient for a server icon. Prefers ID for stability; falls back to name. */
export function getServerGradient(id?: string | null, name?: string): GradientEntry {
/** Deterministic gradient for a space icon. Prefers ID for stability; falls back to name. */
export function getSpaceGradient(id?: string | null, name?: string): GradientEntry {
const key = id || name || 'unknown';
return SERVER_GRADIENTS[hashString(key) % SERVER_GRADIENTS.length]!;
return SPACE_GRADIENTS[hashString(key) % SPACE_GRADIENTS.length]!;
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { useVoiceStore } from '../stores/voiceStore';
import { getChannelOrigin, getMyUserIdForOrigin } from '../stores/serverStore';
import { getChannelOrigin, getMyUserIdForOrigin } from '../stores/spaceStore';
import { wsSend } from '../hooks/useWebSocket';
/**