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
This commit is contained in:
Jannis Braun
2026-03-04 18:02:43 +01:00
parent 72d1fab930
commit 6e44a4ef2f
29 changed files with 1614 additions and 108 deletions
+46 -1
View File
@@ -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}"`);
}
}
+14
View File
@@ -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'),
});