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