From 6e44a4ef2f6edfa120be7c3e15ede33b7d44b954 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:02:43 +0100 Subject: [PATCH] fix: DM avatar color and reactions in federation + explore/server discovery - Fix DM welcome header avatar using home identity for correct gradient color - Register DM channel IDs in channelOriginMap so federated DM operations (reactions, messages, typing) route to the correct instance - Pass origin when creating DM channels from friends list and WS events - Add server discovery/explore page with public server listings - Add server visibility and description fields --- packages/server/src/db/migrate.ts | 47 +- packages/server/src/db/schema.ts | 14 + packages/server/src/index.ts | 2 + packages/server/src/routes/auth.ts | 10 +- packages/server/src/routes/explore.ts | 539 ++++++++++++++++++ packages/server/src/routes/servers.ts | 17 +- packages/server/src/routes/settings.ts | 5 + packages/server/src/ws/handler.ts | 2 + packages/shared/src/types.ts | 34 ++ packages/web/src/api/client.ts | 39 ++ .../web/src/components/chat/ExplorePage.tsx | 300 ++++++++++ .../web/src/components/chat/FriendsPage.tsx | 2 +- .../web/src/components/chat/MessageList.tsx | 2 +- .../src/components/layout/ActivityPanel.tsx | 60 +- .../src/components/layout/ChannelSidebar.tsx | 23 +- .../web/src/components/layout/MainContent.tsx | 5 +- .../src/components/layout/MemberSidebar.tsx | 7 +- .../src/components/layout/ServerSidebar.tsx | 24 +- .../src/components/modals/ServerSettings.tsx | 262 ++++++++- .../src/components/ui/UserProfilePopout.tsx | 20 +- .../components/voice/IncomingCallModal.tsx | 16 +- .../src/components/voice/PictureInPicture.tsx | 2 +- packages/web/src/hooks/useWebSocket.ts | 4 +- packages/web/src/stores/exploreStore.ts | 144 +++++ packages/web/src/stores/instanceStore.ts | 52 +- packages/web/src/stores/serverStore.ts | 68 ++- packages/web/src/stores/settingsStore.ts | 1 + packages/web/src/stores/uiStore.ts | 6 +- packages/web/src/utils/identity.ts | 15 +- 29 files changed, 1614 insertions(+), 108 deletions(-) create mode 100644 packages/server/src/routes/explore.ts create mode 100644 packages/web/src/components/chat/ExplorePage.tsx create mode 100644 packages/web/src/stores/exploreStore.ts diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 1c1ec615..6f8327fd 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -68,7 +68,15 @@ export function runMigrations(db: Database.Database): void { name: 'instance_settings', columns: [ { name: 'instance_name', type: "TEXT DEFAULT 'Backspace'" }, - { name: 'worker_id', type: 'INTEGER' } + { name: 'worker_id', type: 'INTEGER' }, + { name: 'discovery_enabled', type: 'INTEGER NOT NULL DEFAULT 1' } + ] + }, + { + name: 'servers', + columns: [ + { name: 'visibility', type: "TEXT DEFAULT 'private'" }, + { name: 'description', type: 'TEXT' } ] } ]; @@ -101,6 +109,20 @@ export function runMigrations(db: Database.Database): void { ); `); + // Ensure join_requests table exists (idempotent) + db.exec(` + CREATE TABLE IF NOT EXISTS join_requests ( + id TEXT PRIMARY KEY, + server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + message TEXT, + status TEXT NOT NULL DEFAULT 'pending', + decided_by TEXT REFERENCES users(id), + created_at INTEGER NOT NULL, + decided_at INTEGER + ); + `); + // ─── RBAC Migration: Ensure @everyone roles exist for all servers ───────── migrateEveryoneRoles(db); @@ -110,6 +132,9 @@ export function runMigrations(db: Database.Database): void { // ─── Worker ID: ensure a unique Snowflake worker ID is persisted ─────────── migrateWorkerId(db); + // ─── Namespace replicated users: ensure all federated users use user@domain ─ + migrateReplicatedUsernames(db); + // ─── Admin flag: ensure at least one admin exists (first registered user) ── migrateFirstAdmin(db); @@ -210,3 +235,23 @@ function migrateEveryoneRoles(db: Database.Database): void { } } } + +/** + * Rename non-namespaced replicated users: e.g. "test" → "test@nova.ddns.net" + * Frees plain usernames for native user creation and makes all federated users + * visually consistent. Safe because JWTs validate by userId, not username. + */ +function migrateReplicatedUsernames(db: Database.Database): void { + const rows = db.prepare( + "SELECT id, username, home_instance FROM users WHERE home_instance IS NOT NULL AND username NOT LIKE '%@%'" + ).all() as { id: string; username: string; home_instance: string }[]; + + if (rows.length === 0) return; + + const update = db.prepare('UPDATE users SET username = ? WHERE id = ?'); + for (const row of rows) { + const newUsername = `${row.username}@${row.home_instance}`; + update.run(newUsername, row.id); + console.log(`Migrating: Renamed replicated user "${row.username}" → "${newUsername}"`); + } +} diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 899e93e3..a9888d1c 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -21,6 +21,8 @@ export const servers = sqliteTable('servers', { icon: text('icon'), ownerId: text('owner_id').notNull().references(() => users.id), inviteCode: text('invite_code').unique(), + visibility: text('visibility').default('private'), + description: text('description'), createdAt: integer('created_at').notNull(), }); @@ -182,6 +184,7 @@ export const instanceSettings = sqliteTable('instance_settings', { id: integer('id').primaryKey().default(1), instanceName: text('instance_name').default('Backspace'), workerId: integer('worker_id'), + discoveryEnabled: integer('discovery_enabled').notNull().default(1), maxBitrateKbps: integer('max_bitrate_kbps').notNull().default(20000), minBitrateKbps: integer('min_bitrate_kbps').notNull().default(500), bitrateStepKbps: integer('bitrate_step_kbps').notNull().default(500), @@ -191,3 +194,14 @@ export const instanceSettings = sqliteTable('instance_settings', { maxFramerate: integer('max_framerate').notNull().default(60), updatedAt: integer('updated_at').notNull(), }); + +export const joinRequests = sqliteTable('join_requests', { + id: text('id').primaryKey(), + serverId: text('server_id').notNull().references(() => servers.id, { onDelete: 'cascade' }), + userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + message: text('message'), + status: text('status').notNull().default('pending'), + decidedBy: text('decided_by').references(() => users.id), + createdAt: integer('created_at').notNull(), + decidedAt: integer('decided_at'), +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index a74a0b78..c73f61d5 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -19,6 +19,7 @@ import { socialRoutes } from './routes/social.js'; import { settingsRoutes } from './routes/settings.js'; import { utilRoutes } from './routes/utils.js'; import { instanceRoutes } from './routes/instance.js'; +import { exploreRoutes } from './routes/explore.js'; import { registerWebSocket } from './ws/handler.js'; import path from 'path'; import fs from 'fs'; @@ -77,6 +78,7 @@ async function main(): Promise { await app.register(settingsRoutes); await app.register(utilRoutes); await app.register(instanceRoutes); + await app.register(exploreRoutes); await app.register(registerWebSocket); app.get('/api/health', async () => { diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index a67108ac..bed718db 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -53,13 +53,9 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.code(400).send({ error: 'Username must be 100 characters or less', statusCode: 400 }); } } else { - // Plain username from a replicated registration — same rules as local - if (trimmedUsername.length < 3 || trimmedUsername.length > 32) { - return reply.code(400).send({ error: 'Username must be between 3 and 32 characters', statusCode: 400 }); - } - if (!/^[a-zA-Z0-9_]+$/.test(trimmedUsername)) { - return reply.code(400).send({ error: 'Username can only contain letters, numbers, and underscores', statusCode: 400 }); - } + // Replicated users MUST use username@domain format — plain usernames + // are reserved exclusively for native users of this instance + return reply.code(400).send({ error: 'Replicated users must use username@domain format', statusCode: 400 }); } } else { // Local registration — strict validation diff --git a/packages/server/src/routes/explore.ts b/packages/server/src/routes/explore.ts new file mode 100644 index 00000000..5db81084 --- /dev/null +++ b/packages/server/src/routes/explore.ts @@ -0,0 +1,539 @@ +import type { FastifyInstance } from 'fastify'; +import { eq, and, sql, inArray } from 'drizzle-orm'; +import { getDb, schema } from '../db/index.js'; +import { authenticate } from '../utils/auth.js'; +import { generateSnowflake } from '../utils/snowflake.js'; +import { isMember, isServerOwner, hasPermission, computePermissions, PermissionBits, permissionsToString } from '../utils/permissions.js'; +import { connectionManager } from '../ws/handler.js'; +import { sanitizeUser } from '../utils/sanitize.js'; +import type { + ExploreServer, + JoinRequest, + MemberWithUser, + ServerWithChannelsAndMembers, + Channel, +} from '@backspace/shared'; + +function rowToJoinRequest( + row: typeof schema.joinRequests.$inferSelect, + user?: ReturnType, +): JoinRequest { + return { + id: row.id, + serverId: row.serverId, + userId: row.userId, + message: row.message, + status: row.status as JoinRequest['status'], + decidedBy: row.decidedBy, + createdAt: row.createdAt, + decidedAt: row.decidedAt, + user: user ?? undefined, + }; +} + +/** + * Find all online users for a server who have MANAGE_SERVER or are the owner. + * Used to route join_request_received events. + */ +function getServerManagers(serverId: string): string[] { + const db = getDb(); + const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get(); + if (!server) return []; + + const memberRows = db.select() + .from(schema.serverMembers) + .where(eq(schema.serverMembers.serverId, serverId)) + .all(); + + const managers: string[] = []; + for (const member of memberRows) { + if (member.userId === server.ownerId || hasPermission(member.userId, serverId, PermissionBits.MANAGE_SERVER)) { + managers.push(member.userId); + } + } + return managers; +} + +/** + * Build a full ServerWithChannelsAndMembers for a newly joined user. + * Used after accepting a join request or public join. + */ +function buildFullServer(serverId: string, forUserId: string): ServerWithChannelsAndMembers | null { + const db = getDb(); + const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get(); + if (!server) return null; + + const channels = db.select() + .from(schema.channels) + .where(eq(schema.channels.serverId, serverId)) + .all(); + + const roles = db.select() + .from(schema.roles) + .where(eq(schema.roles.serverId, serverId)) + .orderBy(schema.roles.position) + .all(); + + const memberRows = db.select() + .from(schema.serverMembers) + .where(eq(schema.serverMembers.serverId, serverId)) + .all(); + + const memberUserIds = memberRows.map(m => m.userId); + const users = memberUserIds.length > 0 + ? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all() + : []; + const userMap = new Map(users.map(u => [u.id, u])); + + const memberRoleRows = db.select() + .from(schema.memberRoles) + .where(eq(schema.memberRoles.serverId, serverId)) + .all(); + + const members: MemberWithUser[] = memberRows + .map(m => { + const u = userMap.get(m.userId); + if (!u) return null; + + const assignedRoleIds = memberRoleRows + .filter(mr => mr.userId === m.userId) + .map(mr => mr.roleId); + + const memberRoles = roles + .filter(r => assignedRoleIds.includes(r.id)) + .map(r => ({ + id: r.id, + serverId: r.serverId, + name: r.name, + color: r.color ?? '#b9bbbe', + position: r.position ?? 0, + createdAt: r.createdAt, + })); + + return { + serverId: m.serverId, + userId: m.userId, + nickname: m.nickname, + joinedAt: m.joinedAt, + user: sanitizeUser(u), + roles: memberRoles, + }; + }) + .filter((m): m is MemberWithUser => m !== null); + + const serverPerms = computePermissions(forUserId, serverId); + + const visibleChannels: Channel[] = []; + for (const ch of channels) { + const chPerms = computePermissions(forUserId, serverId, ch.id); + const hasView = (chPerms & PermissionBits.VIEW_CHANNEL) !== 0n || (chPerms & PermissionBits.ADMINISTRATOR) !== 0n; + if (hasView) { + visibleChannels.push({ + id: ch.id, + serverId: ch.serverId, + name: ch.name, + type: ch.type as Channel['type'], + topic: ch.topic, + position: ch.position ?? 0, + createdAt: ch.createdAt, + myPermissions: permissionsToString(chPerms), + }); + } + } + + return { + id: server.id, + name: server.name, + icon: server.icon, + ownerId: server.ownerId, + inviteCode: server.inviteCode, + visibility: (server.visibility ?? 'private') as ServerWithChannelsAndMembers['visibility'], + description: server.description ?? null, + createdAt: server.createdAt, + channels: visibleChannels, + members, + roles: roles.map(r => ({ + id: r.id, + serverId: r.serverId, + name: r.name, + color: r.color ?? '#b9bbbe', + position: r.position ?? 0, + permissions: r.permissions ?? undefined, + isEveryone: r.id === serverId, + createdAt: r.createdAt, + })), + myPermissions: permissionsToString(serverPerms), + }; +} + +export async function exploreRoutes(app: FastifyInstance): Promise { + + // GET /api/servers/explore — list discoverable servers + app.get<{ Querystring: { q?: string; limit?: string; offset?: string } }>('/api/servers/explore', { + preHandler: authenticate, + }, async (request, reply) => { + const db = getDb(); + + // Check instance-level discovery toggle + const settings = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get(); + if (!settings || settings.discoveryEnabled === 0) { + return reply.code(200).send({ servers: [], total: 0, discoveryEnabled: false }); + } + + const q = request.query.q?.trim() ?? ''; + const limit = Math.min(Math.max(parseInt(request.query.limit ?? '50', 10) || 50, 1), 100); + const offset = Math.max(parseInt(request.query.offset ?? '0', 10) || 0, 0); + + // Get user's current server memberships to exclude + const myMemberships = db.select({ serverId: schema.serverMembers.serverId }) + .from(schema.serverMembers) + .where(eq(schema.serverMembers.userId, request.userId)) + .all(); + const myServerIds = new Set(myMemberships.map(m => m.serverId)); + + // Build raw SQL query for explore — we need a LEFT JOIN for member count + // which is more efficient to do with raw SQL + const rawDb = (db as any).$client as import('better-sqlite3').Database; + + let countSql = `SELECT COUNT(DISTINCT s.id) as total FROM servers s WHERE s.visibility IN ('public', 'request')`; + let querySql = ` + SELECT s.id, s.name, s.icon, s.description, s.visibility, s.created_at, + COUNT(sm.user_id) as member_count + FROM servers s + LEFT JOIN server_members sm ON sm.server_id = s.id + WHERE s.visibility IN ('public', 'request') + `; + + const params: string[] = []; + + if (q) { + const likePattern = `%${q}%`; + querySql += ` AND (s.name LIKE ? COLLATE NOCASE OR s.description LIKE ? COLLATE NOCASE)`; + countSql += ` AND (s.name LIKE ? COLLATE NOCASE OR s.description LIKE ? COLLATE NOCASE)`; + params.push(likePattern, likePattern); + } + + querySql += ` GROUP BY s.id ORDER BY member_count DESC, s.created_at DESC LIMIT ? OFFSET ?`; + + const totalRow = rawDb.prepare(countSql).get(...params) as { total: number }; + const rows = rawDb.prepare(querySql).all(...params, limit, offset) as { + id: string; + name: string; + icon: string | null; + description: string | null; + visibility: string; + created_at: number; + member_count: number; + }[]; + + // Filter out servers the user is already a member of + const servers: ExploreServer[] = rows + .filter(r => !myServerIds.has(r.id)) + .map(r => ({ + id: r.id, + name: r.name, + icon: r.icon, + description: r.description, + visibility: r.visibility as ExploreServer['visibility'], + memberCount: r.member_count, + createdAt: r.created_at, + })); + + return reply.code(200).send({ servers, total: totalRow.total, discoveryEnabled: true }); + }); + + // POST /api/servers/:id/public-join — join a public server without invite + app.post<{ Params: { id: string } }>('/api/servers/:id/public-join', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const db = getDb(); + + const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get(); + if (!server) { + return reply.code(404).send({ error: 'Server not found', statusCode: 404 }); + } + + if (server.visibility !== 'public') { + return reply.code(403).send({ error: 'This server does not allow public joins', statusCode: 403 }); + } + + if (isMember(id, request.userId)) { + return reply.code(409).send({ error: 'You are already a member of this server', statusCode: 409 }); + } + + const now = Date.now(); + db.insert(schema.serverMembers).values({ + serverId: id, + userId: request.userId, + joinedAt: now, + }).run(); + + // Register in connectionManager for WS broadcasts + connectionManager.addUserServer(request.userId, id); + + // Broadcast member_joined to existing server members + const joiningUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + if (joiningUser) { + const memberPayload: MemberWithUser = { + serverId: id, + userId: request.userId, + nickname: null, + joinedAt: now, + user: sanitizeUser(joiningUser), + roles: [], + }; + connectionManager.sendToServer(id, { + type: 'member_joined', + serverId: id, + member: memberPayload, + }); + } + + // Return full server data + const fullServer = buildFullServer(id, request.userId); + if (!fullServer) { + return reply.code(500).send({ error: 'Failed to load server', statusCode: 500 }); + } + + return reply.code(200).send(fullServer); + }); + + // POST /api/servers/:id/request-join — request to join a request-only server + app.post<{ Params: { id: string }; Body: { message?: string } }>('/api/servers/:id/request-join', { + preHandler: authenticate, + config: { + rateLimit: { + max: 5, + timeWindow: '1 minute', + }, + }, + }, async (request, reply) => { + const { id } = request.params; + const db = getDb(); + + const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get(); + if (!server) { + return reply.code(404).send({ error: 'Server not found', statusCode: 404 }); + } + + if (server.visibility !== 'request') { + return reply.code(403).send({ error: 'This server does not accept join requests', statusCode: 403 }); + } + + if (isMember(id, request.userId)) { + return reply.code(409).send({ error: 'You are already a member of this server', statusCode: 409 }); + } + + // Check for existing pending request + const existingRequest = db.select().from(schema.joinRequests) + .where(and( + eq(schema.joinRequests.serverId, id), + eq(schema.joinRequests.userId, request.userId), + eq(schema.joinRequests.status, 'pending'), + )) + .get(); + + if (existingRequest) { + return reply.code(409).send({ error: 'You already have a pending request for this server', statusCode: 409 }); + } + + const requestId = generateSnowflake(); + const now = Date.now(); + const msgText = request.body?.message?.trim().slice(0, 500) ?? null; + + db.insert(schema.joinRequests).values({ + id: requestId, + serverId: id, + userId: request.userId, + message: msgText, + status: 'pending', + createdAt: now, + }).run(); + + const joiningUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + const joinRequest = rowToJoinRequest( + db.select().from(schema.joinRequests).where(eq(schema.joinRequests.id, requestId)).get()!, + joiningUser ? sanitizeUser(joiningUser) : undefined, + ); + + // Send WS event to server managers (owner + MANAGE_SERVER users) + const managers = getServerManagers(id); + for (const managerId of managers) { + connectionManager.sendToUser(managerId, { + type: 'join_request_received', + request: joinRequest, + }); + } + + return reply.code(201).send(joinRequest); + }); + + // GET /api/servers/:id/join-requests — list join requests for a server + app.get<{ Params: { id: string }; Querystring: { status?: string } }>('/api/servers/:id/join-requests', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const db = getDb(); + + const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get(); + if (!server) { + return reply.code(404).send({ error: 'Server not found', statusCode: 404 }); + } + + if (!isServerOwner(id, request.userId) && !hasPermission(request.userId, id, PermissionBits.MANAGE_SERVER)) { + return reply.code(403).send({ error: 'Missing MANAGE_SERVER permission', statusCode: 403 }); + } + + const statusFilter = request.query.status ?? 'pending'; + const rows = db.select().from(schema.joinRequests) + .where(and( + eq(schema.joinRequests.serverId, id), + eq(schema.joinRequests.status, statusFilter), + )) + .all(); + + // Populate user data + const userIds = rows.map(r => r.userId); + const users = userIds.length > 0 + ? db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all() + : []; + const userMap = new Map(users.map(u => [u.id, sanitizeUser(u)])); + + const requests: JoinRequest[] = rows.map(r => rowToJoinRequest(r, userMap.get(r.userId))); + + return reply.code(200).send({ requests }); + }); + + // PATCH /api/servers/:id/join-requests/:requestId — accept or decline + app.patch<{ Params: { id: string; requestId: string }; Body: { action: 'accept' | 'decline' } }>('/api/servers/:id/join-requests/:requestId', { + preHandler: authenticate, + }, async (request, reply) => { + const { id, requestId } = request.params; + const { action } = request.body; + const db = getDb(); + + if (!action || (action !== 'accept' && action !== 'decline')) { + return reply.code(400).send({ error: 'Action must be "accept" or "decline"', statusCode: 400 }); + } + + const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get(); + if (!server) { + return reply.code(404).send({ error: 'Server not found', statusCode: 404 }); + } + + if (!isServerOwner(id, request.userId) && !hasPermission(request.userId, id, PermissionBits.MANAGE_SERVER)) { + return reply.code(403).send({ error: 'Missing MANAGE_SERVER permission', statusCode: 403 }); + } + + const joinReq = db.select().from(schema.joinRequests).where(eq(schema.joinRequests.id, requestId)).get(); + if (!joinReq || joinReq.serverId !== id) { + return reply.code(404).send({ error: 'Join request not found', statusCode: 404 }); + } + + if (joinReq.status !== 'pending') { + return reply.code(400).send({ error: 'This request has already been decided', statusCode: 400 }); + } + + const now = Date.now(); + + if (action === 'accept') { + // Insert member and update request atomically + db.transaction((tx) => { + tx.insert(schema.serverMembers).values({ + serverId: id, + userId: joinReq.userId, + joinedAt: now, + }).run(); + + tx.update(schema.joinRequests).set({ + status: 'accepted', + decidedBy: request.userId, + decidedAt: now, + }).where(eq(schema.joinRequests.id, requestId)).run(); + }); + + // Register in connectionManager + connectionManager.addUserServer(joinReq.userId, id); + + // Broadcast member_joined to server + const joiningUser = db.select().from(schema.users).where(eq(schema.users.id, joinReq.userId)).get(); + if (joiningUser) { + const memberPayload: MemberWithUser = { + serverId: id, + userId: joinReq.userId, + nickname: null, + joinedAt: now, + user: sanitizeUser(joiningUser), + roles: [], + }; + connectionManager.sendToServer(id, { + type: 'member_joined', + serverId: id, + member: memberPayload, + }); + } + + // Build full server for the accepted user + const fullServer = buildFullServer(id, joinReq.userId); + + const updatedRow = db.select().from(schema.joinRequests).where(eq(schema.joinRequests.id, requestId)).get()!; + const updatedRequest = rowToJoinRequest(updatedRow, joiningUser ? sanitizeUser(joiningUser) : undefined); + + // Send join_request_accepted to the requesting user + if (fullServer) { + connectionManager.sendToUser(joinReq.userId, { + type: 'join_request_accepted', + request: updatedRequest, + server: fullServer, + }); + } + + return reply.code(200).send(updatedRequest); + } else { + // Decline + db.update(schema.joinRequests).set({ + status: 'declined', + decidedBy: request.userId, + decidedAt: now, + }).where(eq(schema.joinRequests.id, requestId)).run(); + + const updatedRow = db.select().from(schema.joinRequests).where(eq(schema.joinRequests.id, requestId)).get()!; + const updatedRequest = rowToJoinRequest(updatedRow); + + // Send join_request_declined to the requesting user + connectionManager.sendToUser(joinReq.userId, { + type: 'join_request_declined', + request: updatedRequest, + }); + + return reply.code(200).send(updatedRequest); + } + }); + + // GET /api/users/@me/join-requests — list the current user's join requests + app.get<{ Querystring: { status?: string } }>('/api/users/@me/join-requests', { + preHandler: authenticate, + }, async (request, reply) => { + const db = getDb(); + const statusFilter = request.query.status; + + let rows; + if (statusFilter) { + rows = db.select().from(schema.joinRequests) + .where(and( + eq(schema.joinRequests.userId, request.userId), + eq(schema.joinRequests.status, statusFilter), + )) + .all(); + } else { + rows = db.select().from(schema.joinRequests) + .where(eq(schema.joinRequests.userId, request.userId)) + .all(); + } + + const requests: JoinRequest[] = rows.map(r => rowToJoinRequest(r)); + + return reply.code(200).send({ requests }); + }); +} diff --git a/packages/server/src/routes/servers.ts b/packages/server/src/routes/servers.ts index 5bdf87cf..53fe20db 100644 --- a/packages/server/src/routes/servers.ts +++ b/packages/server/src/routes/servers.ts @@ -27,6 +27,8 @@ function rowToServer(row: typeof schema.servers.$inferSelect): Server { icon: row.icon, ownerId: row.ownerId, inviteCode: row.inviteCode, + visibility: (row.visibility ?? 'private') as Server['visibility'], + description: row.description ?? null, createdAt: row.createdAt, }; } @@ -242,7 +244,7 @@ export async function serverRoutes(app: FastifyInstance): Promise { preHandler: authenticate, }, async (request, reply) => { const { id } = request.params; - const { name, icon } = request.body; + const { name, icon, visibility, description } = request.body; const db = getDb(); const server = db.select().from(schema.servers).where(eq(schema.servers.id, id)).get(); @@ -268,6 +270,19 @@ export async function serverRoutes(app: FastifyInstance): Promise { updates.icon = icon; } + if (visibility !== undefined) { + const validVisibilities = ['public', 'request', 'private']; + if (!validVisibilities.includes(visibility)) { + return reply.code(400).send({ error: 'Visibility must be "public", "request", or "private"', statusCode: 400 }); + } + updates.visibility = visibility; + } + + if (description !== undefined) { + const trimmed = description.trim().slice(0, 200); + updates.description = trimmed || null; + } + if (Object.keys(updates).length === 0) { return reply.code(400).send({ error: 'No fields to update', statusCode: 400 }); } diff --git a/packages/server/src/routes/settings.ts b/packages/server/src/routes/settings.ts index 19ac1494..1431d6b2 100644 --- a/packages/server/src/routes/settings.ts +++ b/packages/server/src/routes/settings.ts @@ -16,6 +16,7 @@ function rowToLimits(row: typeof schema.instanceSettings.$inferSelect): Instance allowedFramerates: row.allowedFramerates.split(',').map(Number).filter((n) => VALID_FRAMERATES.includes(n)), maxResolution: row.maxResolution, maxFramerate: row.maxFramerate, + discoveryEnabled: row.discoveryEnabled === 1, }; } @@ -100,6 +101,10 @@ export async function settingsRoutes(app: FastifyInstance): Promise { updateData.maxFramerate = body.maxFramerate; } + if (body.discoveryEnabled !== undefined) { + updateData.discoveryEnabled = body.discoveryEnabled ? 1 : 0; + } + // Cross-field validation: min < max const currentRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get(); if (!currentRow) { diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index aae878c0..8aefd345 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -677,6 +677,8 @@ function buildReadyPayload(userId: string): { icon: serverRow.icon, ownerId: serverRow.ownerId, inviteCode: serverRow.inviteCode, + visibility: (serverRow.visibility ?? 'private') as ServerWithChannelsAndMembers['visibility'], + description: serverRow.description ?? null, createdAt: serverRow.createdAt, channels: visibleChannels, members, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 8a31ab72..8fc52f3f 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -28,15 +28,41 @@ export interface UserWithPassword extends User { // ─── Server (Guild) Types ─────────────────────────────────────────────────── +export type ServerVisibility = 'public' | 'request' | 'private'; + export interface Server { id: string; name: string; icon: string | null; ownerId: string; inviteCode: string | null; + visibility: ServerVisibility; + description: string | null; createdAt: number; } +export interface ExploreServer { + id: string; + name: string; + icon: string | null; + description: string | null; + visibility: ServerVisibility; + memberCount: number; + createdAt: number; +} + +export interface JoinRequest { + id: string; + serverId: string; + userId: string; + message: string | null; + status: 'pending' | 'accepted' | 'declined'; + decidedBy: string | null; + createdAt: number; + decidedAt: number | null; + user?: User; +} + export interface ServerWithChannelsAndMembers extends Server { channels: Channel[]; members: MemberWithUser[]; @@ -244,6 +270,9 @@ export type ServerEvent = | { type: 'channel_updated'; channel: Channel; serverId: string } | { type: 'channel_deleted'; channelId: string; serverId: string } | { type: 'server_updated'; server: Server } + | { type: 'join_request_received'; request: JoinRequest } + | { type: 'join_request_accepted'; request: JoinRequest; server: ServerWithChannelsAndMembers } + | { type: 'join_request_declined'; request: JoinRequest } | { type: 'pong' } | { type: 'error'; message: string }; @@ -287,6 +316,8 @@ export interface UpdateChannelRequest { export interface UpdateServerRequest { name?: string; icon?: string; + visibility?: ServerVisibility; + description?: string; } export interface UpdateUserRequest { @@ -360,6 +391,8 @@ export interface Friend { customStatus: string | null; createdAt: number; addedAt: number; + homeUserId: string | null; + homeInstance: string | null; } export type FriendRequestStatus = 'pending' | 'accepted' | 'declined'; @@ -391,6 +424,7 @@ export interface InstanceStreamingLimits { allowedFramerates: number[]; maxResolution: number; maxFramerate: number; + discoveryEnabled: boolean; } // ─── Federation Types ────────────────────────────────────────────────────── diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 92e274c0..16ff87eb 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -28,6 +28,8 @@ import type { InstanceStreamingLimits, InstanceInfoResponse, VerifyPasswordResponse, + ExploreServer, + JoinRequest, } from '@backspace/shared'; export class BackspaceApiClient { @@ -115,6 +117,15 @@ export class BackspaceApiClient { info: () => Promise; }; + readonly explore: { + list: (q?: string, limit?: number, offset?: number) => Promise<{ servers: ExploreServer[]; total: number; discoveryEnabled: boolean }>; + publicJoin: (serverId: string) => Promise; + requestJoin: (serverId: string, message?: string) => Promise; + getJoinRequests: (serverId: string, status?: string) => Promise<{ requests: JoinRequest[] }>; + decideJoinRequest: (serverId: string, requestId: string, action: 'accept' | 'decline') => Promise; + myJoinRequests: (status?: string) => Promise<{ requests: JoinRequest[] }>; + }; + constructor(baseUrl: string, getToken: () => string | null) { async function request( method: string, @@ -287,6 +298,34 @@ export class BackspaceApiClient { this.instance = { info: () => request('GET', '/instance/info', undefined, false), }; + + this.explore = { + list: (q?: string, limit = 50, offset = 0) => { + const params = new URLSearchParams(); + if (q) params.set('q', q); + params.set('limit', String(limit)); + params.set('offset', String(offset)); + return request<{ servers: ExploreServer[]; total: number; discoveryEnabled: boolean }>( + 'GET', `/servers/explore?${params}` + ); + }, + publicJoin: (serverId: string) => + request('POST', `/servers/${serverId}/public-join`), + requestJoin: (serverId: string, message?: string) => + request('POST', `/servers/${serverId}/request-join`, message ? { message } : {}), + getJoinRequests: (serverId: string, status?: string) => { + const params = new URLSearchParams(); + if (status) params.set('status', status); + return request<{ requests: JoinRequest[] }>('GET', `/servers/${serverId}/join-requests?${params}`); + }, + decideJoinRequest: (serverId: string, requestId: string, action: 'accept' | 'decline') => + request('PATCH', `/servers/${serverId}/join-requests/${requestId}`, { action }), + myJoinRequests: (status?: string) => { + const params = new URLSearchParams(); + if (status) params.set('status', status); + return request<{ requests: JoinRequest[] }>('GET', `/users/@me/join-requests?${params}`); + }, + }; } } diff --git a/packages/web/src/components/chat/ExplorePage.tsx b/packages/web/src/components/chat/ExplorePage.tsx new file mode 100644 index 00000000..9916de43 --- /dev/null +++ b/packages/web/src/components/chat/ExplorePage.tsx @@ -0,0 +1,300 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useExploreStore, type TaggedExploreServer } from '../../stores/exploreStore'; +import { useServerStore } from '../../stores/serverStore'; +import { useUIStore } from '../../stores/uiStore'; +import { LoadingSpinner } from '../ui/LoadingSpinner'; +import { getServerGradient } from '../../utils/gradients'; + +export function ExplorePage() { + const navigate = useNavigate(); + const setCurrentServer = useServerStore((s) => s.setCurrentServer); + const setShowExplore = useUIStore((s) => s.setShowExplore); + + const servers = useExploreStore((s) => s.servers); + const myRequests = useExploreStore((s) => s.myRequests); + const isLoading = useExploreStore((s) => s.isLoading); + const discoveryEnabled = useExploreStore((s) => s.discoveryEnabled); + const error = useExploreStore((s) => s.error); + const searchQuery = useExploreStore((s) => s.searchQuery); + const setSearchQuery = useExploreStore((s) => s.setSearchQuery); + const fetchServers = useExploreStore((s) => s.fetchServers); + const fetchMyRequests = useExploreStore((s) => s.fetchMyRequests); + + const debounceRef = useRef | null>(null); + + // Fetch on mount + useEffect(() => { + fetchServers(); + fetchMyRequests(); + }, [fetchServers, fetchMyRequests]); + + // Debounced search + const handleSearchChange = useCallback((value: string) => { + setSearchQuery(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + fetchServers(value || undefined); + }, 300); + }, [setSearchQuery, fetchServers]); + + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, []); + + const handleJoinSuccess = (serverId: string) => { + setShowExplore(false); + setCurrentServer(serverId); + navigate(`/channels/${serverId}`); + }; + + return ( +
+ {/* Header */} +
+
+ + + + Explore +
+ +
+ +
+ 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" + /> + {searchQuery && ( + + )} +
+
+ + {/* Content */} +
+ {!discoveryEnabled && ( +
+ Server discovery is disabled by the instance administrator. +
+ )} + + {isLoading && servers.length === 0 ? ( +
+ +
+ ) : error ? ( +
+ {error} +
+ ) : servers.length === 0 ? ( +
+ + + +

+ {searchQuery ? 'No servers match your search.' : 'No discoverable servers found.'} +

+
+ ) : ( +
+ {servers.map((server) => ( + r.serverId === server.id && r.status === 'pending')} + onJoinSuccess={handleJoinSuccess} + /> + ))} +
+ )} +
+
+ ); +} + +function ServerCard({ + server, + isPending, + onJoinSuccess, +}: { + server: TaggedExploreServer; + isPending: boolean; + onJoinSuccess: (serverId: string) => void; +}) { + const publicJoin = useExploreStore((s) => s.publicJoin); + const requestJoin = useExploreStore((s) => s.requestJoin); + + const [joining, setJoining] = useState(false); + const [showRequestForm, setShowRequestForm] = useState(false); + const [requestMessage, setRequestMessage] = useState(''); + const [requestSent, setRequestSent] = useState(isPending); + const [joinError, setJoinError] = useState(''); + + const gradient = getServerGradient(server.id, server.name); + const isPublic = server.visibility === 'public'; + const originLabel = server._instanceOrigin + ? (() => { try { return new URL(server._instanceOrigin).host; } catch { return server._instanceOrigin; } })() + : null; + + const handlePublicJoin = async () => { + setJoining(true); + setJoinError(''); + try { + const fullServer = await publicJoin(server); + onJoinSuccess(fullServer.id); + } catch (err) { + setJoinError(err instanceof Error ? err.message : 'Failed to join'); + setJoining(false); + } + }; + + const handleRequestJoin = async () => { + setJoining(true); + setJoinError(''); + try { + await requestJoin(server, requestMessage.trim() || undefined); + setRequestSent(true); + setShowRequestForm(false); + } catch (err) { + setJoinError(err instanceof Error ? err.message : 'Failed to send request'); + } finally { + setJoining(false); + } + }; + + return ( +
+ {/* Banner / Icon area */} +
+ {server.icon ? ( + {server.name} + ) : ( + + {server.name.charAt(0).toUpperCase()} + + )} + + {/* Visibility badge */} +
+ + {isPublic ? 'Public' : 'Request'} + +
+ + {/* Instance origin */} + {originLabel && ( +
+ + {originLabel} + +
+ )} +
+ + {/* Content */} +
+

{server.name}

+ + {server.description ? ( +

+ {server.description} +

+ ) : ( +

No description

+ )} + +
+ + + + + {server.memberCount} {server.memberCount === 1 ? 'member' : 'members'} + +
+ + {/* Action area */} + {joinError && ( +
{joinError}
+ )} + + {isPublic ? ( + + ) : requestSent ? ( + + ) : showRequestForm ? ( +
+