diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..254ad5a --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Toggle SSL for local development (true/false) +# When true: ng serve uses --ssl, Express API uses HTTPS, Electron loads https:// +# When false: plain HTTP everywhere (only works on localhost) +SSL=true diff --git a/.gitignore b/.gitignore index 8005f1d..85373e9 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,7 @@ __screenshots__/ # System files .DS_Store Thumbs.db + +# Environment & certs +.env +.certs/ diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..4b20e06 --- /dev/null +++ b/dev.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Launch the full dev stack, respecting SSL= from .env +set -e + +DIR="$(cd "$(dirname "$0")" && pwd)" + +# Load .env +if [ -f "$DIR/.env" ]; then + set -a + source "$DIR/.env" + set +a +fi + +SSL="${SSL:-false}" + +if [ "$SSL" = "true" ]; then + # Ensure certs exist + if [ ! -f "$DIR/.certs/localhost.crt" ]; then + echo "SSL=true but no certs found. Generating..." + "$DIR/generate-cert.sh" + fi + + NG_SERVE="ng serve --host=0.0.0.0 --ssl --ssl-cert=.certs/localhost.crt --ssl-key=.certs/localhost.key" + WAIT_URL="https://localhost:4200" + HEALTH_URL="https://localhost:3001/api/health" + export NODE_TLS_REJECT_UNAUTHORIZED=0 +else + NG_SERVE="ng serve --host=0.0.0.0" + WAIT_URL="http://localhost:4200" + HEALTH_URL="http://localhost:3001/api/health" +fi + +exec npx concurrently --kill-others \ + "cd server && npm run dev" \ + "$NG_SERVE" \ + "wait-on $WAIT_URL $HEALTH_URL && cross-env NODE_ENV=development SSL=$SSL electron ." diff --git a/electron/database.js b/electron/database.js new file mode 100644 index 0000000..a106347 --- /dev/null +++ b/electron/database.js @@ -0,0 +1,626 @@ +/** + * Electron main-process SQLite database module. + * + * All SQL queries live here – the renderer communicates exclusively via IPC. + * Uses sql.js (WASM SQLite) loaded in Node.js. + */ + +const { ipcMain, app } = require('electron'); +const fs = require('fs'); +const fsp = fs.promises; +const path = require('path'); + +let db = null; +let dbPath = ''; + +/* ------------------------------------------------------------------ */ +/* Migrations */ +/* ------------------------------------------------------------------ */ + +const migrations = [ + { + version: 1, + description: 'Initial schema – messages, users, rooms, reactions, bans, meta', + up(database) { + database.run(` + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + roomId TEXT NOT NULL, + channelId TEXT, + senderId TEXT NOT NULL, + senderName TEXT NOT NULL, + content TEXT NOT NULL, + timestamp INTEGER NOT NULL, + editedAt INTEGER, + reactions TEXT NOT NULL DEFAULT '[]', + isDeleted INTEGER NOT NULL DEFAULT 0, + replyToId TEXT + ); + `); + database.run('CREATE INDEX IF NOT EXISTS idx_messages_roomId ON messages(roomId);'); + + database.run(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + oderId TEXT, + username TEXT, + displayName TEXT, + avatarUrl TEXT, + status TEXT, + role TEXT, + joinedAt INTEGER, + peerId TEXT, + isOnline INTEGER, + isAdmin INTEGER, + isRoomOwner INTEGER, + voiceState TEXT, + screenShareState TEXT + ); + `); + + database.run(` + CREATE TABLE IF NOT EXISTS rooms ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + topic TEXT, + hostId TEXT NOT NULL, + password TEXT, + isPrivate INTEGER NOT NULL DEFAULT 0, + createdAt INTEGER NOT NULL, + userCount INTEGER NOT NULL DEFAULT 0, + maxUsers INTEGER, + icon TEXT, + iconUpdatedAt INTEGER, + permissions TEXT, + channels TEXT + ); + `); + + database.run(` + CREATE TABLE IF NOT EXISTS reactions ( + id TEXT PRIMARY KEY, + messageId TEXT NOT NULL, + oderId TEXT, + userId TEXT, + emoji TEXT NOT NULL, + timestamp INTEGER NOT NULL + ); + `); + database.run('CREATE INDEX IF NOT EXISTS idx_reactions_messageId ON reactions(messageId);'); + + database.run(` + CREATE TABLE IF NOT EXISTS bans ( + oderId TEXT NOT NULL, + userId TEXT, + roomId TEXT NOT NULL, + bannedBy TEXT NOT NULL, + displayName TEXT, + reason TEXT, + expiresAt INTEGER, + timestamp INTEGER NOT NULL, + PRIMARY KEY (oderId, roomId) + ); + `); + database.run('CREATE INDEX IF NOT EXISTS idx_bans_roomId ON bans(roomId);'); + + database.run(` + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT + ); + `); + }, + }, + { + version: 2, + description: 'Attachments table', + up(database) { + database.run(` + CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + messageId TEXT NOT NULL, + filename TEXT NOT NULL, + size INTEGER NOT NULL, + mime TEXT NOT NULL, + isImage INTEGER NOT NULL DEFAULT 0, + uploaderPeerId TEXT, + filePath TEXT, + savedPath TEXT + ); + `); + database.run('CREATE INDEX IF NOT EXISTS idx_attachments_messageId ON attachments(messageId);'); + }, + }, +]; + +function runMigrations() { + db.run(` + CREATE TABLE IF NOT EXISTS schema_version ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version INTEGER NOT NULL DEFAULT 0 + ); + `); + + const row = db.exec('SELECT version FROM schema_version WHERE id = 1'); + let currentVersion = row.length > 0 ? row[0].values[0][0] : 0; + + if (row.length === 0) { + db.run('INSERT INTO schema_version (id, version) VALUES (1, 0)'); + } + + for (const migration of migrations) { + if (migration.version > currentVersion) { + console.log(`[ElectronDB] Running migration v${migration.version}: ${migration.description}`); + migration.up(db); + currentVersion = migration.version; + db.run('UPDATE schema_version SET version = ? WHERE id = 1', [currentVersion]); + } + } +} + +/* ------------------------------------------------------------------ */ +/* Persistence */ +/* ------------------------------------------------------------------ */ + +function persist() { + if (!db) return; + const data = db.export(); + const buffer = Buffer.from(data); + fs.writeFileSync(dbPath, buffer); +} + +/* ------------------------------------------------------------------ */ +/* Initialisation */ +/* ------------------------------------------------------------------ */ + +async function initDatabase() { + const initSqlJs = require('sql.js'); + const SQL = await initSqlJs(); + + const dbDir = path.join(app.getPath('userData'), 'metoyou'); + await fsp.mkdir(dbDir, { recursive: true }); + dbPath = path.join(dbDir, 'metoyou.sqlite'); + + if (fs.existsSync(dbPath)) { + const fileBuffer = fs.readFileSync(dbPath); + db = new SQL.Database(fileBuffer); + } else { + db = new SQL.Database(); + } + + db.run('PRAGMA journal_mode = MEMORY;'); + db.run('PRAGMA synchronous = NORMAL;'); + + runMigrations(); + persist(); +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +/** Run a prepared-statement query and return rows as plain objects. */ +function queryAll(sql, params = []) { + const stmt = db.prepare(sql); + stmt.bind(params); + const results = []; + while (stmt.step()) results.push(stmt.getAsObject()); + stmt.free(); + return results; +} + +/** Return a single row as object or null. */ +function queryOne(sql, params = []) { + const stmt = db.prepare(sql); + stmt.bind(params); + let result = null; + if (stmt.step()) result = stmt.getAsObject(); + stmt.free(); + return result; +} + +/* ------------------------------------------------------------------ */ +/* Row → model mappers */ +/* ------------------------------------------------------------------ */ + +function rowToMessage(r) { + return { + id: String(r.id), + roomId: String(r.roomId), + channelId: r.channelId ? String(r.channelId) : undefined, + senderId: String(r.senderId), + senderName: String(r.senderName), + content: String(r.content), + timestamp: Number(r.timestamp), + editedAt: r.editedAt != null ? Number(r.editedAt) : undefined, + reactions: JSON.parse(String(r.reactions || '[]')), + isDeleted: !!r.isDeleted, + replyToId: r.replyToId ? String(r.replyToId) : undefined, + }; +} + +function rowToUser(r) { + return { + id: String(r.id), + oderId: String(r.oderId ?? ''), + username: String(r.username ?? ''), + displayName: String(r.displayName ?? ''), + avatarUrl: r.avatarUrl ? String(r.avatarUrl) : undefined, + status: String(r.status ?? 'offline'), + role: String(r.role ?? 'member'), + joinedAt: Number(r.joinedAt ?? 0), + peerId: r.peerId ? String(r.peerId) : undefined, + isOnline: !!r.isOnline, + isAdmin: !!r.isAdmin, + isRoomOwner: !!r.isRoomOwner, + voiceState: r.voiceState ? JSON.parse(String(r.voiceState)) : undefined, + screenShareState: r.screenShareState ? JSON.parse(String(r.screenShareState)) : undefined, + }; +} + +function rowToRoom(r) { + return { + id: String(r.id), + name: String(r.name), + description: r.description ? String(r.description) : undefined, + topic: r.topic ? String(r.topic) : undefined, + hostId: String(r.hostId), + password: r.password ? String(r.password) : undefined, + isPrivate: !!r.isPrivate, + createdAt: Number(r.createdAt), + userCount: Number(r.userCount), + maxUsers: r.maxUsers != null ? Number(r.maxUsers) : undefined, + icon: r.icon ? String(r.icon) : undefined, + iconUpdatedAt: r.iconUpdatedAt != null ? Number(r.iconUpdatedAt) : undefined, + permissions: r.permissions ? JSON.parse(String(r.permissions)) : undefined, + channels: r.channels ? JSON.parse(String(r.channels)) : undefined, + }; +} + +function rowToReaction(r) { + return { + id: String(r.id), + messageId: String(r.messageId), + oderId: String(r.oderId ?? ''), + userId: String(r.userId ?? ''), + emoji: String(r.emoji), + timestamp: Number(r.timestamp), + }; +} + +function rowToAttachment(r) { + return { + id: String(r.id), + messageId: String(r.messageId), + filename: String(r.filename), + size: Number(r.size), + mime: String(r.mime), + isImage: !!r.isImage, + uploaderPeerId: r.uploaderPeerId ? String(r.uploaderPeerId) : undefined, + filePath: r.filePath ? String(r.filePath) : undefined, + savedPath: r.savedPath ? String(r.savedPath) : undefined, + }; +} + +function rowToBan(r) { + return { + oderId: String(r.oderId), + userId: String(r.userId ?? ''), + roomId: String(r.roomId), + bannedBy: String(r.bannedBy), + displayName: r.displayName ? String(r.displayName) : undefined, + reason: r.reason ? String(r.reason) : undefined, + expiresAt: r.expiresAt != null ? Number(r.expiresAt) : undefined, + timestamp: Number(r.timestamp), + }; +} + +/* ------------------------------------------------------------------ */ +/* IPC handler registration */ +/* ------------------------------------------------------------------ */ + +function registerDatabaseIpc() { + // ── Lifecycle ────────────────────────────────────────────────────── + ipcMain.handle('db:initialize', async () => { + await initDatabase(); + return true; + }); + + // ── Messages ─────────────────────────────────────────────────────── + ipcMain.handle('db:saveMessage', (_e, message) => { + db.run( + `INSERT OR REPLACE INTO messages + (id, roomId, channelId, senderId, senderName, content, timestamp, editedAt, reactions, isDeleted, replyToId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + message.id, + message.roomId, + message.channelId ?? null, + message.senderId, + message.senderName, + message.content, + message.timestamp, + message.editedAt ?? null, + JSON.stringify(message.reactions ?? []), + message.isDeleted ? 1 : 0, + message.replyToId ?? null, + ], + ); + persist(); + }); + + ipcMain.handle('db:getMessages', (_e, roomId, limit = 100, offset = 0) => { + const rows = queryAll( + 'SELECT * FROM messages WHERE roomId = ? ORDER BY timestamp ASC LIMIT ? OFFSET ?', + [roomId, limit, offset], + ); + return rows.map(rowToMessage); + }); + + ipcMain.handle('db:deleteMessage', (_e, messageId) => { + db.run('DELETE FROM messages WHERE id = ?', [messageId]); + persist(); + }); + + ipcMain.handle('db:updateMessage', (_e, messageId, updates) => { + const row = queryOne('SELECT * FROM messages WHERE id = ? LIMIT 1', [messageId]); + if (!row) return; + const msg = { ...rowToMessage(row), ...updates }; + db.run( + `INSERT OR REPLACE INTO messages + (id, roomId, channelId, senderId, senderName, content, timestamp, editedAt, reactions, isDeleted, replyToId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + msg.id, msg.roomId, msg.channelId ?? null, msg.senderId, msg.senderName, + msg.content, msg.timestamp, msg.editedAt ?? null, + JSON.stringify(msg.reactions ?? []), msg.isDeleted ? 1 : 0, msg.replyToId ?? null, + ], + ); + persist(); + }); + + ipcMain.handle('db:getMessageById', (_e, messageId) => { + const row = queryOne('SELECT * FROM messages WHERE id = ? LIMIT 1', [messageId]); + return row ? rowToMessage(row) : null; + }); + + ipcMain.handle('db:clearRoomMessages', (_e, roomId) => { + db.run('DELETE FROM messages WHERE roomId = ?', [roomId]); + persist(); + }); + + // ── Reactions ────────────────────────────────────────────────────── + ipcMain.handle('db:saveReaction', (_e, reaction) => { + const check = db.exec( + 'SELECT 1 FROM reactions WHERE messageId = ? AND userId = ? AND emoji = ?', + [reaction.messageId, reaction.userId, reaction.emoji], + ); + if (check.length > 0 && check[0].values.length > 0) return; + + db.run( + `INSERT OR REPLACE INTO reactions (id, messageId, oderId, userId, emoji, timestamp) + VALUES (?, ?, ?, ?, ?, ?)`, + [reaction.id, reaction.messageId, reaction.oderId, reaction.userId, reaction.emoji, reaction.timestamp], + ); + persist(); + }); + + ipcMain.handle('db:removeReaction', (_e, messageId, userId, emoji) => { + db.run('DELETE FROM reactions WHERE messageId = ? AND userId = ? AND emoji = ?', [messageId, userId, emoji]); + persist(); + }); + + ipcMain.handle('db:getReactionsForMessage', (_e, messageId) => { + const rows = queryAll('SELECT * FROM reactions WHERE messageId = ?', [messageId]); + return rows.map(rowToReaction); + }); + + // ── Users ────────────────────────────────────────────────────────── + ipcMain.handle('db:saveUser', (_e, user) => { + db.run( + `INSERT OR REPLACE INTO users + (id, oderId, username, displayName, avatarUrl, status, role, joinedAt, + peerId, isOnline, isAdmin, isRoomOwner, voiceState, screenShareState) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + user.id, + user.oderId ?? null, + user.username ?? null, + user.displayName ?? null, + user.avatarUrl ?? null, + user.status ?? null, + user.role ?? null, + user.joinedAt ?? null, + user.peerId ?? null, + user.isOnline ? 1 : 0, + user.isAdmin ? 1 : 0, + user.isRoomOwner ? 1 : 0, + user.voiceState ? JSON.stringify(user.voiceState) : null, + user.screenShareState ? JSON.stringify(user.screenShareState) : null, + ], + ); + persist(); + }); + + ipcMain.handle('db:getUser', (_e, userId) => { + const row = queryOne('SELECT * FROM users WHERE id = ? LIMIT 1', [userId]); + return row ? rowToUser(row) : null; + }); + + ipcMain.handle('db:getCurrentUser', () => { + const rows = db.exec("SELECT value FROM meta WHERE key = 'currentUserId'"); + if (rows.length === 0 || rows[0].values.length === 0) return null; + const userId = String(rows[0].values[0][0]); + const row = queryOne('SELECT * FROM users WHERE id = ? LIMIT 1', [userId]); + return row ? rowToUser(row) : null; + }); + + ipcMain.handle('db:setCurrentUserId', (_e, userId) => { + db.run("INSERT OR REPLACE INTO meta (key, value) VALUES ('currentUserId', ?)", [userId]); + persist(); + }); + + ipcMain.handle('db:getUsersByRoom', (_e, _roomId) => { + const rows = queryAll('SELECT * FROM users'); + return rows.map(rowToUser); + }); + + ipcMain.handle('db:updateUser', (_e, userId, updates) => { + const row = queryOne('SELECT * FROM users WHERE id = ? LIMIT 1', [userId]); + if (!row) return; + const user = { ...rowToUser(row), ...updates }; + db.run( + `INSERT OR REPLACE INTO users + (id, oderId, username, displayName, avatarUrl, status, role, joinedAt, + peerId, isOnline, isAdmin, isRoomOwner, voiceState, screenShareState) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + user.id, user.oderId ?? null, user.username ?? null, user.displayName ?? null, + user.avatarUrl ?? null, user.status ?? null, user.role ?? null, user.joinedAt ?? null, + user.peerId ?? null, user.isOnline ? 1 : 0, user.isAdmin ? 1 : 0, user.isRoomOwner ? 1 : 0, + user.voiceState ? JSON.stringify(user.voiceState) : null, + user.screenShareState ? JSON.stringify(user.screenShareState) : null, + ], + ); + persist(); + }); + + // ── Rooms ────────────────────────────────────────────────────────── + ipcMain.handle('db:saveRoom', (_e, room) => { + db.run( + `INSERT OR REPLACE INTO rooms + (id, name, description, topic, hostId, password, isPrivate, createdAt, + userCount, maxUsers, icon, iconUpdatedAt, permissions, channels) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + room.id, room.name, room.description ?? null, room.topic ?? null, + room.hostId, room.password ?? null, room.isPrivate ? 1 : 0, room.createdAt, + room.userCount, room.maxUsers ?? null, room.icon ?? null, + room.iconUpdatedAt ?? null, + room.permissions ? JSON.stringify(room.permissions) : null, + room.channels ? JSON.stringify(room.channels) : null, + ], + ); + persist(); + }); + + ipcMain.handle('db:getRoom', (_e, roomId) => { + const row = queryOne('SELECT * FROM rooms WHERE id = ? LIMIT 1', [roomId]); + return row ? rowToRoom(row) : null; + }); + + ipcMain.handle('db:getAllRooms', () => { + const rows = queryAll('SELECT * FROM rooms'); + return rows.map(rowToRoom); + }); + + ipcMain.handle('db:deleteRoom', (_e, roomId) => { + db.run('DELETE FROM rooms WHERE id = ?', [roomId]); + db.run('DELETE FROM messages WHERE roomId = ?', [roomId]); + persist(); + }); + + ipcMain.handle('db:updateRoom', (_e, roomId, updates) => { + const row = queryOne('SELECT * FROM rooms WHERE id = ? LIMIT 1', [roomId]); + if (!row) return; + const room = { ...rowToRoom(row), ...updates }; + db.run( + `INSERT OR REPLACE INTO rooms + (id, name, description, topic, hostId, password, isPrivate, createdAt, + userCount, maxUsers, icon, iconUpdatedAt, permissions, channels) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + room.id, room.name, room.description ?? null, room.topic ?? null, + room.hostId, room.password ?? null, room.isPrivate ? 1 : 0, room.createdAt, + room.userCount, room.maxUsers ?? null, room.icon ?? null, + room.iconUpdatedAt ?? null, + room.permissions ? JSON.stringify(room.permissions) : null, + room.channels ? JSON.stringify(room.channels) : null, + ], + ); + persist(); + }); + + // ── Bans ─────────────────────────────────────────────────────────── + ipcMain.handle('db:saveBan', (_e, ban) => { + db.run( + `INSERT OR REPLACE INTO bans + (oderId, userId, roomId, bannedBy, displayName, reason, expiresAt, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + ban.oderId, ban.userId ?? null, ban.roomId, ban.bannedBy, + ban.displayName ?? null, ban.reason ?? null, ban.expiresAt ?? null, ban.timestamp, + ], + ); + persist(); + }); + + ipcMain.handle('db:removeBan', (_e, oderId) => { + db.run('DELETE FROM bans WHERE oderId = ?', [oderId]); + persist(); + }); + + ipcMain.handle('db:getBansForRoom', (_e, roomId) => { + const now = Date.now(); + const rows = queryAll( + 'SELECT * FROM bans WHERE roomId = ? AND (expiresAt IS NULL OR expiresAt > ?)', + [roomId, now], + ); + return rows.map(rowToBan); + }); + + ipcMain.handle('db:isUserBanned', (_e, userId, roomId) => { + const now = Date.now(); + const rows = queryAll( + 'SELECT * FROM bans WHERE roomId = ? AND (expiresAt IS NULL OR expiresAt > ?)', + [roomId, now], + ); + return rows.some((r) => String(r.oderId) === userId); + }); + + // ── Attachments ───────────────────────────────────────────────────── + ipcMain.handle('db:saveAttachment', (_e, attachment) => { + db.run( + `INSERT OR REPLACE INTO attachments + (id, messageId, filename, size, mime, isImage, uploaderPeerId, filePath, savedPath) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + attachment.id, attachment.messageId, attachment.filename, + attachment.size, attachment.mime, attachment.isImage ? 1 : 0, + attachment.uploaderPeerId ?? null, attachment.filePath ?? null, + attachment.savedPath ?? null, + ], + ); + persist(); + }); + + ipcMain.handle('db:getAttachmentsForMessage', (_e, messageId) => { + const rows = queryAll('SELECT * FROM attachments WHERE messageId = ?', [messageId]); + return rows.map(rowToAttachment); + }); + + ipcMain.handle('db:getAllAttachments', () => { + const rows = queryAll('SELECT * FROM attachments'); + return rows.map(rowToAttachment); + }); + + ipcMain.handle('db:deleteAttachmentsForMessage', (_e, messageId) => { + db.run('DELETE FROM attachments WHERE messageId = ?', [messageId]); + persist(); + }); + + // ── Utilities ────────────────────────────────────────────────────── + ipcMain.handle('db:clearAllData', () => { + db.run('DELETE FROM messages'); + db.run('DELETE FROM users'); + db.run('DELETE FROM rooms'); + db.run('DELETE FROM reactions'); + db.run('DELETE FROM bans'); + db.run('DELETE FROM attachments'); + db.run('DELETE FROM meta'); + persist(); + }); +} + +module.exports = { registerDatabaseIpc }; diff --git a/electron/main.js b/electron/main.js index 8b18431..95947e4 100644 --- a/electron/main.js +++ b/electron/main.js @@ -2,6 +2,7 @@ const { app, BrowserWindow, ipcMain, desktopCapturer } = require('electron'); const fs = require('fs'); const fsp = fs.promises; const path = require('path'); +const { registerDatabaseIpc } = require('./database'); let mainWindow; @@ -9,6 +10,10 @@ let mainWindow; app.commandLine.appendSwitch('disable-features', 'Autofill,AutofillAssistant,AutofillServerCommunication'); // Allow media autoplay without user gesture (bypasses Chromium autoplay policy) app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); +// Accept self-signed certificates in development (for --ssl dev server) +if (process.env.SSL === 'true') { + app.commandLine.appendSwitch('ignore-certificate-errors'); +} function createWindow() { mainWindow = new BrowserWindow({ @@ -29,7 +34,10 @@ function createWindow() { // In development, load from Angular dev server if (process.env.NODE_ENV === 'development') { - mainWindow.loadURL('http://localhost:4200'); + const devUrl = process.env.SSL === 'true' + ? 'https://localhost:4200' + : 'http://localhost:4200'; + mainWindow.loadURL(devUrl); if (process.env.DEBUG_DEVTOOLS === '1') { mainWindow.webContents.openDevTools(); } @@ -44,6 +52,9 @@ function createWindow() { }); } +// Register database IPC handlers before app is ready +registerDatabaseIpc(); + app.whenReady().then(createWindow); app.on('window-all-closed', () => { diff --git a/electron/preload.js b/electron/preload.js index 73ff98a..6443e2e 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -17,4 +17,52 @@ contextBridge.exposeInMainWorld('electronAPI', { writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data), fileExists: (filePath) => ipcRenderer.invoke('file-exists', filePath), ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath), + + // ── Database operations (all SQL lives in main process) ─────────── + db: { + initialize: () => ipcRenderer.invoke('db:initialize'), + + // Messages + saveMessage: (message) => ipcRenderer.invoke('db:saveMessage', message), + getMessages: (roomId, limit, offset) => ipcRenderer.invoke('db:getMessages', roomId, limit, offset), + deleteMessage: (messageId) => ipcRenderer.invoke('db:deleteMessage', messageId), + updateMessage: (messageId, updates) => ipcRenderer.invoke('db:updateMessage', messageId, updates), + getMessageById: (messageId) => ipcRenderer.invoke('db:getMessageById', messageId), + clearRoomMessages: (roomId) => ipcRenderer.invoke('db:clearRoomMessages', roomId), + + // Reactions + saveReaction: (reaction) => ipcRenderer.invoke('db:saveReaction', reaction), + removeReaction: (messageId, userId, emoji) => ipcRenderer.invoke('db:removeReaction', messageId, userId, emoji), + getReactionsForMessage: (messageId) => ipcRenderer.invoke('db:getReactionsForMessage', messageId), + + // Users + saveUser: (user) => ipcRenderer.invoke('db:saveUser', user), + getUser: (userId) => ipcRenderer.invoke('db:getUser', userId), + getCurrentUser: () => ipcRenderer.invoke('db:getCurrentUser'), + setCurrentUserId: (userId) => ipcRenderer.invoke('db:setCurrentUserId', userId), + getUsersByRoom: (roomId) => ipcRenderer.invoke('db:getUsersByRoom', roomId), + updateUser: (userId, updates) => ipcRenderer.invoke('db:updateUser', userId, updates), + + // Rooms + saveRoom: (room) => ipcRenderer.invoke('db:saveRoom', room), + getRoom: (roomId) => ipcRenderer.invoke('db:getRoom', roomId), + getAllRooms: () => ipcRenderer.invoke('db:getAllRooms'), + deleteRoom: (roomId) => ipcRenderer.invoke('db:deleteRoom', roomId), + updateRoom: (roomId, updates) => ipcRenderer.invoke('db:updateRoom', roomId, updates), + + // Bans + saveBan: (ban) => ipcRenderer.invoke('db:saveBan', ban), + removeBan: (oderId) => ipcRenderer.invoke('db:removeBan', oderId), + getBansForRoom: (roomId) => ipcRenderer.invoke('db:getBansForRoom', roomId), + isUserBanned: (userId, roomId) => ipcRenderer.invoke('db:isUserBanned', userId, roomId), + + // Attachments + saveAttachment: (attachment) => ipcRenderer.invoke('db:saveAttachment', attachment), + getAttachmentsForMessage: (messageId) => ipcRenderer.invoke('db:getAttachmentsForMessage', messageId), + getAllAttachments: () => ipcRenderer.invoke('db:getAllAttachments'), + deleteAttachmentsForMessage: (messageId) => ipcRenderer.invoke('db:deleteAttachmentsForMessage', messageId), + + // Utilities + clearAllData: () => ipcRenderer.invoke('db:clearAllData'), + }, }); diff --git a/generate-cert.sh b/generate-cert.sh new file mode 100755 index 0000000..42375a3 --- /dev/null +++ b/generate-cert.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Generate a self-signed certificate for local development. +# The cert is shared by both ng serve (--ssl-cert/--ssl-key) and the Express API. + +set -e + +DIR="$(cd "$(dirname "$0")" && pwd)" +CERT_DIR="$DIR/.certs" + +if [ -f "$CERT_DIR/localhost.crt" ] && [ -f "$CERT_DIR/localhost.key" ]; then + echo "Certs already exist at $CERT_DIR – skipping generation." + echo " Delete .certs/ and re-run to regenerate." + exit 0 +fi + +mkdir -p "$CERT_DIR" + +echo "Generating self-signed certificate..." +openssl req -x509 -nodes -days 3650 \ + -newkey rsa:2048 \ + -keyout "$CERT_DIR/localhost.key" \ + -out "$CERT_DIR/localhost.crt" \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:0.0.0.0" + +echo "Done. Certificate written to:" +echo " $CERT_DIR/localhost.crt" +echo " $CERT_DIR/localhost.key" diff --git a/package.json b/package.json index 72de9a0..676590f 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "server:dev": "cd server && npm run dev", "electron": "ng build && electron .", "electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && cross-env NODE_ENV=development electron .\"", - "electron:full": "concurrently --kill-others \"cd server && npm run dev\" \"ng serve\" \"wait-on http://localhost:4200 http://localhost:3001/api/health && cross-env NODE_ENV=development electron .\"", + "electron:full": "./dev.sh", "electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production electron .\"", "electron:build": "npm run build:prod && electron-builder", "electron:build:win": "npm run build:prod && electron-builder --win", diff --git a/server/data/metoyou.sqlite b/server/data/metoyou.sqlite index de66c8b..6252c44 100644 Binary files a/server/data/metoyou.sqlite and b/server/data/metoyou.sqlite differ diff --git a/server/data/servers.json b/server/data/servers.json index f835538..90f7946 100644 --- a/server/data/servers.json +++ b/server/data/servers.json @@ -1,39 +1,14 @@ [ { - "id": "5b9ee424-dcfc-4bcb-a0cd-175b0d4dc3d7", - "name": "hello", - "ownerId": "7c74c680-09ca-42ee-b5fb-650e8eaa1622", - "ownerPublicKey": "5b870756-bdfd-47c0-9a27-90f5838b66ac", + "id": "274b8cec-83cf-41b6-981f-f5116c90696e", + "name": "Opem", + "ownerId": "a01f9b26-b443-49a7-82a1-4d75c9bc9824", + "ownerPublicKey": "a01f9b26-b443-49a7-82a1-4d75c9bc9824", "isPrivate": false, "maxUsers": 50, "currentUsers": 0, "tags": [], - "createdAt": 1766898986953, - "lastSeen": 1766898986953 - }, - { - "id": "39071c2e-6715-45a7-ac56-9e82ec4fae03", - "name": "HeePassword", - "description": "ME ME", - "ownerId": "53b1172a-acff-4e19-9773-a2a23408b3c0", - "ownerPublicKey": "53b1172a-acff-4e19-9773-a2a23408b3c0", - "isPrivate": true, - "maxUsers": 50, - "currentUsers": 0, - "tags": [], - "createdAt": 1766902260144, - "lastSeen": 1766902260144 - }, - { - "id": "337ad599-736e-49c6-bf01-fb94c1b82a6d", - "name": "ASDASD", - "ownerId": "54c0953a-1e54-4c07-8da9-06c143d9354f", - "ownerPublicKey": "54c0953a-1e54-4c07-8da9-06c143d9354f", - "isPrivate": false, - "maxUsers": 50, - "currentUsers": 0, - "tags": [], - "createdAt": 1767240654523, - "lastSeen": 1767240654523 + "createdAt": 1772382716566, + "lastSeen": 1772382716566 } ] \ No newline at end of file diff --git a/server/data/users.json b/server/data/users.json deleted file mode 100644 index 8240841..0000000 --- a/server/data/users.json +++ /dev/null @@ -1,9 +0,0 @@ -[ - { - "id": "54c0953a-1e54-4c07-8da9-06c143d9354f", - "username": "azaaxin", - "passwordHash": "9f3a38af5ddad28abc9a273e2481883b245e5a908266c2ce1f0e42c7fa175d6c", - "displayName": "azaaxin", - "createdAt": 1766902824975 - } -] \ No newline at end of file diff --git a/server/dist/db.d.ts b/server/dist/db.d.ts new file mode 100644 index 0000000..d3fbcaa --- /dev/null +++ b/server/dist/db.d.ts @@ -0,0 +1,43 @@ +export declare function initDB(): Promise; +export interface AuthUser { + id: string; + username: string; + passwordHash: string; + displayName: string; + createdAt: number; +} +export declare function getUserByUsername(username: string): Promise; +export declare function getUserById(id: string): Promise; +export declare function createUser(user: AuthUser): Promise; +export interface ServerInfo { + id: string; + name: string; + description?: string; + ownerId: string; + ownerPublicKey: string; + isPrivate: boolean; + maxUsers: number; + currentUsers: number; + tags: string[]; + createdAt: number; + lastSeen: number; +} +export declare function getAllPublicServers(): Promise; +export declare function getServerById(id: string): Promise; +export declare function upsertServer(server: ServerInfo): Promise; +export declare function deleteServer(id: string): Promise; +export interface JoinRequest { + id: string; + serverId: string; + userId: string; + userPublicKey: string; + displayName: string; + status: 'pending' | 'approved' | 'rejected'; + createdAt: number; +} +export declare function createJoinRequest(req: JoinRequest): Promise; +export declare function getJoinRequestById(id: string): Promise; +export declare function getPendingRequestsForServer(serverId: string): Promise; +export declare function updateJoinRequestStatus(id: string, status: JoinRequest['status']): Promise; +export declare function deleteStaleJoinRequests(maxAgeMs: number): Promise; +//# sourceMappingURL=db.d.ts.map \ No newline at end of file diff --git a/server/dist/db.d.ts.map b/server/dist/db.d.ts.map new file mode 100644 index 0000000..f3b0f51 --- /dev/null +++ b/server/dist/db.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AAeA,wBAAsB,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAoD5C;AAaD,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wBAAsB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAiBlF;AAED,wBAAsB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAiBtE;AAED,wBAAsB,UAAU,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAO9D;AAMD,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,OAAO,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAkBD,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,UAAU,EAAE,CAAC,CASjE;AAED,wBAAsB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAU1E;AAED,wBAAsB,YAAY,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAsBpE;AAED,wBAAsB,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAY5D;AAMD,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,SAAS,GAAG,UAAU,GAAG,UAAU,CAAC;IAC5C,SAAS,EAAE,MAAM,CAAC;CACnB;AAcD,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAUvE;AAED,wBAAsB,kBAAkB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAUhF;AAED,wBAAsB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAU1F;AAED,wBAAsB,uBAAuB,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAOtG;AAED,wBAAsB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAQ7E"} \ No newline at end of file diff --git a/server/dist/db.js b/server/dist/db.js new file mode 100644 index 0000000..cde1c6b --- /dev/null +++ b/server/dist/db.js @@ -0,0 +1,277 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.initDB = initDB; +exports.getUserByUsername = getUserByUsername; +exports.getUserById = getUserById; +exports.createUser = createUser; +exports.getAllPublicServers = getAllPublicServers; +exports.getServerById = getServerById; +exports.upsertServer = upsertServer; +exports.deleteServer = deleteServer; +exports.createJoinRequest = createJoinRequest; +exports.getJoinRequestById = getJoinRequestById; +exports.getPendingRequestsForServer = getPendingRequestsForServer; +exports.updateJoinRequestStatus = updateJoinRequestStatus; +exports.deleteStaleJoinRequests = deleteStaleJoinRequests; +const fs_1 = __importDefault(require("fs")); +const path_1 = __importDefault(require("path")); +const sql_js_1 = __importDefault(require("sql.js")); +// Simple SQLite via sql.js persisted to a single file +const DATA_DIR = path_1.default.join(process.cwd(), 'data'); +const DB_FILE = path_1.default.join(DATA_DIR, 'metoyou.sqlite'); +function ensureDataDir() { + if (!fs_1.default.existsSync(DATA_DIR)) + fs_1.default.mkdirSync(DATA_DIR, { recursive: true }); +} +let SQL = null; +let db = null; +async function initDB() { + if (db) + return; + SQL = await (0, sql_js_1.default)({ locateFile: (file) => require.resolve('sql.js/dist/sql-wasm.wasm') }); + ensureDataDir(); + if (fs_1.default.existsSync(DB_FILE)) { + const fileBuffer = fs_1.default.readFileSync(DB_FILE); + db = new SQL.Database(new Uint8Array(fileBuffer)); + } + else { + db = new SQL.Database(); + } + // Initialize schema + db.run(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + passwordHash TEXT NOT NULL, + displayName TEXT NOT NULL, + createdAt INTEGER NOT NULL + ); + `); + db.run(` + CREATE TABLE IF NOT EXISTS servers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + ownerId TEXT NOT NULL, + ownerPublicKey TEXT NOT NULL, + isPrivate INTEGER NOT NULL DEFAULT 0, + maxUsers INTEGER NOT NULL DEFAULT 0, + currentUsers INTEGER NOT NULL DEFAULT 0, + tags TEXT NOT NULL DEFAULT '[]', + createdAt INTEGER NOT NULL, + lastSeen INTEGER NOT NULL + ); + `); + db.run(` + CREATE TABLE IF NOT EXISTS join_requests ( + id TEXT PRIMARY KEY, + serverId TEXT NOT NULL, + userId TEXT NOT NULL, + userPublicKey TEXT NOT NULL, + displayName TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + createdAt INTEGER NOT NULL + ); + `); + persist(); +} +function persist() { + if (!db) + return; + const data = db.export(); + const buffer = Buffer.from(data); + fs_1.default.writeFileSync(DB_FILE, buffer); +} +async function getUserByUsername(username) { + if (!db) + await initDB(); + const stmt = db.prepare('SELECT id, username, passwordHash, displayName, createdAt FROM users WHERE username = ? LIMIT 1'); + stmt.bind([username]); + let row = null; + if (stmt.step()) { + const r = stmt.getAsObject(); + row = { + id: String(r.id), + username: String(r.username), + passwordHash: String(r.passwordHash), + displayName: String(r.displayName), + createdAt: Number(r.createdAt), + }; + } + stmt.free(); + return row; +} +async function getUserById(id) { + if (!db) + await initDB(); + const stmt = db.prepare('SELECT id, username, passwordHash, displayName, createdAt FROM users WHERE id = ? LIMIT 1'); + stmt.bind([id]); + let row = null; + if (stmt.step()) { + const r = stmt.getAsObject(); + row = { + id: String(r.id), + username: String(r.username), + passwordHash: String(r.passwordHash), + displayName: String(r.displayName), + createdAt: Number(r.createdAt), + }; + } + stmt.free(); + return row; +} +async function createUser(user) { + if (!db) + await initDB(); + const stmt = db.prepare('INSERT INTO users (id, username, passwordHash, displayName, createdAt) VALUES (?, ?, ?, ?, ?)'); + stmt.bind([user.id, user.username, user.passwordHash, user.displayName, user.createdAt]); + stmt.step(); + stmt.free(); + persist(); +} +function rowToServer(r) { + return { + id: String(r.id), + name: String(r.name), + description: r.description ? String(r.description) : undefined, + ownerId: String(r.ownerId), + ownerPublicKey: String(r.ownerPublicKey), + isPrivate: !!r.isPrivate, + maxUsers: Number(r.maxUsers), + currentUsers: Number(r.currentUsers), + tags: JSON.parse(String(r.tags || '[]')), + createdAt: Number(r.createdAt), + lastSeen: Number(r.lastSeen), + }; +} +async function getAllPublicServers() { + if (!db) + await initDB(); + const stmt = db.prepare('SELECT * FROM servers WHERE isPrivate = 0'); + const results = []; + while (stmt.step()) { + results.push(rowToServer(stmt.getAsObject())); + } + stmt.free(); + return results; +} +async function getServerById(id) { + if (!db) + await initDB(); + const stmt = db.prepare('SELECT * FROM servers WHERE id = ? LIMIT 1'); + stmt.bind([id]); + let row = null; + if (stmt.step()) { + row = rowToServer(stmt.getAsObject()); + } + stmt.free(); + return row; +} +async function upsertServer(server) { + if (!db) + await initDB(); + const stmt = db.prepare(` + INSERT OR REPLACE INTO servers (id, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, currentUsers, tags, createdAt, lastSeen) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + stmt.bind([ + server.id, + server.name, + server.description ?? null, + server.ownerId, + server.ownerPublicKey, + server.isPrivate ? 1 : 0, + server.maxUsers, + server.currentUsers, + JSON.stringify(server.tags), + server.createdAt, + server.lastSeen, + ]); + stmt.step(); + stmt.free(); + persist(); +} +async function deleteServer(id) { + if (!db) + await initDB(); + const stmt = db.prepare('DELETE FROM servers WHERE id = ?'); + stmt.bind([id]); + stmt.step(); + stmt.free(); + // Also clean up related join requests + const jStmt = db.prepare('DELETE FROM join_requests WHERE serverId = ?'); + jStmt.bind([id]); + jStmt.step(); + jStmt.free(); + persist(); +} +function rowToJoinRequest(r) { + return { + id: String(r.id), + serverId: String(r.serverId), + userId: String(r.userId), + userPublicKey: String(r.userPublicKey), + displayName: String(r.displayName), + status: String(r.status), + createdAt: Number(r.createdAt), + }; +} +async function createJoinRequest(req) { + if (!db) + await initDB(); + const stmt = db.prepare(` + INSERT INTO join_requests (id, serverId, userId, userPublicKey, displayName, status, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + stmt.bind([req.id, req.serverId, req.userId, req.userPublicKey, req.displayName, req.status, req.createdAt]); + stmt.step(); + stmt.free(); + persist(); +} +async function getJoinRequestById(id) { + if (!db) + await initDB(); + const stmt = db.prepare('SELECT * FROM join_requests WHERE id = ? LIMIT 1'); + stmt.bind([id]); + let row = null; + if (stmt.step()) { + row = rowToJoinRequest(stmt.getAsObject()); + } + stmt.free(); + return row; +} +async function getPendingRequestsForServer(serverId) { + if (!db) + await initDB(); + const stmt = db.prepare('SELECT * FROM join_requests WHERE serverId = ? AND status = ?'); + stmt.bind([serverId, 'pending']); + const results = []; + while (stmt.step()) { + results.push(rowToJoinRequest(stmt.getAsObject())); + } + stmt.free(); + return results; +} +async function updateJoinRequestStatus(id, status) { + if (!db) + await initDB(); + const stmt = db.prepare('UPDATE join_requests SET status = ? WHERE id = ?'); + stmt.bind([status, id]); + stmt.step(); + stmt.free(); + persist(); +} +async function deleteStaleJoinRequests(maxAgeMs) { + if (!db) + await initDB(); + const cutoff = Date.now() - maxAgeMs; + const stmt = db.prepare('DELETE FROM join_requests WHERE createdAt < ?'); + stmt.bind([cutoff]); + stmt.step(); + stmt.free(); + persist(); +} +//# sourceMappingURL=db.js.map \ No newline at end of file diff --git a/server/dist/db.js.map b/server/dist/db.js.map new file mode 100644 index 0000000..42f876c --- /dev/null +++ b/server/dist/db.js.map @@ -0,0 +1 @@ +{"version":3,"file":"db.js","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/server/dist/index.js b/server/dist/index.js index d6be9ac..946a05c 100644 --- a/server/dist/index.js +++ b/server/dist/index.js @@ -12,71 +12,85 @@ const app = (0, express_1.default)(); const PORT = process.env.PORT || 3001; app.use((0, cors_1.default)()); app.use(express_1.default.json()); -const servers = new Map(); -const joinRequests = new Map(); const connectedUsers = new Map(); -// Persistence -const fs_1 = __importDefault(require("fs")); -const path_1 = __importDefault(require("path")); -const DATA_DIR = path_1.default.join(process.cwd(), 'data'); -const SERVERS_FILE = path_1.default.join(DATA_DIR, 'servers.json'); -const USERS_FILE = path_1.default.join(DATA_DIR, 'users.json'); -function ensureDataDir() { - if (!fs_1.default.existsSync(DATA_DIR)) - fs_1.default.mkdirSync(DATA_DIR, { recursive: true }); -} -function saveServers() { - ensureDataDir(); - fs_1.default.writeFileSync(SERVERS_FILE, JSON.stringify(Array.from(servers.values()), null, 2)); -} -function loadServers() { - ensureDataDir(); - if (fs_1.default.existsSync(SERVERS_FILE)) { - const raw = fs_1.default.readFileSync(SERVERS_FILE, 'utf-8'); - const list = JSON.parse(raw); - list.forEach(s => servers.set(s.id, s)); - } -} +// Database +const crypto_1 = __importDefault(require("crypto")); +const db_1 = require("./db"); +function hashPassword(pw) { return crypto_1.default.createHash('sha256').update(pw).digest('hex'); } // REST API Routes // Health check endpoint -app.get('/api/health', (req, res) => { +app.get('/api/health', async (req, res) => { + const allServers = await (0, db_1.getAllPublicServers)(); res.json({ status: 'ok', timestamp: Date.now(), - serverCount: servers.size, + serverCount: allServers.length, connectedUsers: connectedUsers.size, }); }); -let authUsers = []; -function saveUsers() { ensureDataDir(); fs_1.default.writeFileSync(USERS_FILE, JSON.stringify(authUsers, null, 2)); } -function loadUsers() { ensureDataDir(); if (fs_1.default.existsSync(USERS_FILE)) { - authUsers = JSON.parse(fs_1.default.readFileSync(USERS_FILE, 'utf-8')); -} } -const crypto_1 = __importDefault(require("crypto")); -function hashPassword(pw) { return crypto_1.default.createHash('sha256').update(pw).digest('hex'); } -app.post('/api/users/register', (req, res) => { +// Time endpoint for clock synchronization +app.get('/api/time', (req, res) => { + res.json({ now: Date.now() }); +}); +// Image proxy to allow rendering external images within CSP (img-src 'self' data: blob:) +app.get('/api/image-proxy', async (req, res) => { + try { + const url = String(req.query.url || ''); + if (!/^https?:\/\//i.test(url)) { + return res.status(400).json({ error: 'Invalid URL' }); + } + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + const response = await fetch(url, { redirect: 'follow', signal: controller.signal }); + clearTimeout(timeout); + if (!response.ok) { + return res.status(response.status).end(); + } + const contentType = response.headers.get('content-type') || ''; + if (!contentType.toLowerCase().startsWith('image/')) { + return res.status(415).json({ error: 'Unsupported content type' }); + } + const arrayBuffer = await response.arrayBuffer(); + const MAX_BYTES = 8 * 1024 * 1024; // 8MB limit + if (arrayBuffer.byteLength > MAX_BYTES) { + return res.status(413).json({ error: 'Image too large' }); + } + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'public, max-age=3600'); + res.send(Buffer.from(arrayBuffer)); + } + catch (err) { + if (err?.name === 'AbortError') { + return res.status(504).json({ error: 'Timeout fetching image' }); + } + console.error('Image proxy error:', err); + res.status(502).json({ error: 'Failed to fetch image' }); + } +}); +// Auth +app.post('/api/users/register', async (req, res) => { const { username, password, displayName } = req.body; if (!username || !password) return res.status(400).json({ error: 'Missing username/password' }); - if (authUsers.find(u => u.username === username)) + const exists = await (0, db_1.getUserByUsername)(username); + if (exists) return res.status(409).json({ error: 'Username taken' }); const user = { id: (0, uuid_1.v4)(), username, passwordHash: hashPassword(password), displayName: displayName || username, createdAt: Date.now() }; - authUsers.push(user); - saveUsers(); + await (0, db_1.createUser)(user); res.status(201).json({ id: user.id, username: user.username, displayName: user.displayName }); }); -app.post('/api/users/login', (req, res) => { +app.post('/api/users/login', async (req, res) => { const { username, password } = req.body; - const user = authUsers.find(u => u.username === username && u.passwordHash === hashPassword(password)); - if (!user) + const user = await (0, db_1.getUserByUsername)(username); + if (!user || user.passwordHash !== hashPassword(password)) return res.status(401).json({ error: 'Invalid credentials' }); res.json({ id: user.id, username: user.username, displayName: user.displayName }); }); // Search servers -app.get('/api/servers', (req, res) => { +app.get('/api/servers', async (req, res) => { const { q, tags, limit = 20, offset = 0 } = req.query; - let results = Array.from(servers.values()) - .filter(s => !s.isPrivate) + let results = await (0, db_1.getAllPublicServers)(); + results = results .filter(s => { if (q) { const query = String(q).toLowerCase(); @@ -92,18 +106,16 @@ app.get('/api/servers', (req, res) => { } return true; }); - // Keep servers visible permanently until deleted; do not filter by lastSeen const total = results.length; results = results.slice(Number(offset), Number(offset) + Number(limit)); res.json({ servers: results, total, limit: Number(limit), offset: Number(offset) }); }); // Register a server -app.post('/api/servers', (req, res) => { +app.post('/api/servers', async (req, res) => { const { id: clientId, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, tags } = req.body; if (!name || !ownerId || !ownerPublicKey) { return res.status(400).json({ error: 'Missing required fields' }); } - // Use client-provided ID if available, otherwise generate one const id = clientId || (0, uuid_1.v4)(); const server = { id, @@ -118,15 +130,14 @@ app.post('/api/servers', (req, res) => { createdAt: Date.now(), lastSeen: Date.now(), }; - servers.set(id, server); - saveServers(); + await (0, db_1.upsertServer)(server); res.status(201).json(server); }); // Update server -app.put('/api/servers/:id', (req, res) => { +app.put('/api/servers/:id', async (req, res) => { const { id } = req.params; const { ownerId, ...updates } = req.body; - const server = servers.get(id); + const server = await (0, db_1.getServerById)(id); if (!server) { return res.status(404).json({ error: 'Server not found' }); } @@ -134,15 +145,14 @@ app.put('/api/servers/:id', (req, res) => { return res.status(403).json({ error: 'Not authorized' }); } const updated = { ...server, ...updates, lastSeen: Date.now() }; - servers.set(id, updated); - saveServers(); + await (0, db_1.upsertServer)(updated); res.json(updated); }); // Heartbeat - keep server alive -app.post('/api/servers/:id/heartbeat', (req, res) => { +app.post('/api/servers/:id/heartbeat', async (req, res) => { const { id } = req.params; const { currentUsers } = req.body; - const server = servers.get(id); + const server = await (0, db_1.getServerById)(id); if (!server) { return res.status(404).json({ error: 'Server not found' }); } @@ -150,30 +160,28 @@ app.post('/api/servers/:id/heartbeat', (req, res) => { if (typeof currentUsers === 'number') { server.currentUsers = currentUsers; } - servers.set(id, server); - saveServers(); + await (0, db_1.upsertServer)(server); res.json({ ok: true }); }); // Remove server -app.delete('/api/servers/:id', (req, res) => { +app.delete('/api/servers/:id', async (req, res) => { const { id } = req.params; const { ownerId } = req.body; - const server = servers.get(id); + const server = await (0, db_1.getServerById)(id); if (!server) { return res.status(404).json({ error: 'Server not found' }); } if (server.ownerId !== ownerId) { return res.status(403).json({ error: 'Not authorized' }); } - servers.delete(id); - saveServers(); + await (0, db_1.deleteServer)(id); res.json({ ok: true }); }); // Request to join a server -app.post('/api/servers/:id/join', (req, res) => { +app.post('/api/servers/:id/join', async (req, res) => { const { id: serverId } = req.params; const { userId, userPublicKey, displayName } = req.body; - const server = servers.get(serverId); + const server = await (0, db_1.getServerById)(serverId); if (!server) { return res.status(404).json({ error: 'Server not found' }); } @@ -187,7 +195,7 @@ app.post('/api/servers/:id/join', (req, res) => { status: server.isPrivate ? 'pending' : 'approved', createdAt: Date.now(), }; - joinRequests.set(requestId, request); + await (0, db_1.createJoinRequest)(request); // Notify server owner via WebSocket if (server.isPrivate) { notifyServerOwner(server.ownerId, { @@ -198,70 +206,72 @@ app.post('/api/servers/:id/join', (req, res) => { res.status(201).json(request); }); // Get join requests for a server -app.get('/api/servers/:id/requests', (req, res) => { +app.get('/api/servers/:id/requests', async (req, res) => { const { id: serverId } = req.params; const { ownerId } = req.query; - const server = servers.get(serverId); + const server = await (0, db_1.getServerById)(serverId); if (!server) { return res.status(404).json({ error: 'Server not found' }); } if (server.ownerId !== ownerId) { return res.status(403).json({ error: 'Not authorized' }); } - const requests = Array.from(joinRequests.values()) - .filter(r => r.serverId === serverId && r.status === 'pending'); + const requests = await (0, db_1.getPendingRequestsForServer)(serverId); res.json({ requests }); }); // Approve/reject join request -app.put('/api/requests/:id', (req, res) => { +app.put('/api/requests/:id', async (req, res) => { const { id } = req.params; const { ownerId, status } = req.body; - const request = joinRequests.get(id); + const request = await (0, db_1.getJoinRequestById)(id); if (!request) { return res.status(404).json({ error: 'Request not found' }); } - const server = servers.get(request.serverId); + const server = await (0, db_1.getServerById)(request.serverId); if (!server || server.ownerId !== ownerId) { return res.status(403).json({ error: 'Not authorized' }); } - request.status = status; - joinRequests.set(id, request); + await (0, db_1.updateJoinRequestStatus)(id, status); + const updated = { ...request, status }; // Notify the requester notifyUser(request.userId, { type: 'request_update', - request, + request: updated, }); - res.json(request); + res.json(updated); }); // WebSocket Server for real-time signaling const server = (0, http_1.createServer)(app); const wss = new ws_1.WebSocketServer({ server }); wss.on('connection', (ws) => { - const oderId = (0, uuid_1.v4)(); - connectedUsers.set(oderId, { oderId, ws }); + const connectionId = (0, uuid_1.v4)(); + connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set() }); ws.on('message', (data) => { try { const message = JSON.parse(data.toString()); - handleWebSocketMessage(oderId, message); + handleWebSocketMessage(connectionId, message); } catch (err) { console.error('Invalid WebSocket message:', err); } }); ws.on('close', () => { - const user = connectedUsers.get(oderId); - if (user?.serverId) { - // Notify others in the room - broadcastToServer(user.serverId, { - type: 'user_left', - oderId, - displayName: user.displayName, - }, oderId); + const user = connectedUsers.get(connectionId); + if (user) { + // Notify all servers the user was a member of + user.serverIds.forEach((sid) => { + broadcastToServer(sid, { + type: 'user_left', + oderId: user.oderId, + displayName: user.displayName, + serverId: sid, + }, user.oderId); + }); } - connectedUsers.delete(oderId); + connectedUsers.delete(connectionId); }); - // Send connection acknowledgment - ws.send(JSON.stringify({ type: 'connected', oderId })); + // Send connection acknowledgment with the connectionId (client will identify with their actual oderId) + ws.send(JSON.stringify({ type: 'connected', connectionId, serverTime: Date.now() })); }); function handleWebSocketMessage(connectionId, message) { const user = connectedUsers.get(connectionId); @@ -276,38 +286,68 @@ function handleWebSocketMessage(connectionId, message) { connectedUsers.set(connectionId, user); console.log(`User identified: ${user.displayName} (${user.oderId})`); break; - case 'join_server': - user.serverId = message.serverId; + case 'join_server': { + const sid = message.serverId; + const isNew = !user.serverIds.has(sid); + user.serverIds.add(sid); + user.viewedServerId = sid; connectedUsers.set(connectionId, user); - console.log(`User ${user.displayName} (${user.oderId}) joined server ${message.serverId}`); - // Get list of current users in server (exclude this user by oderId) + console.log(`User ${user.displayName || 'Anonymous'} (${user.oderId}) joined server ${sid} (new=${isNew})`); + // Always send the current user list for this server const usersInServer = Array.from(connectedUsers.values()) - .filter(u => u.serverId === message.serverId && u.oderId !== user.oderId) - .map(u => ({ oderId: u.oderId, displayName: u.displayName })); - console.log(`Sending server_users to ${user.displayName}:`, usersInServer); + .filter(u => u.serverIds.has(sid) && u.oderId !== user.oderId && u.displayName) + .map(u => ({ oderId: u.oderId, displayName: u.displayName || 'Anonymous' })); + console.log(`Sending server_users to ${user.displayName || 'Anonymous'}:`, usersInServer); user.ws.send(JSON.stringify({ type: 'server_users', + serverId: sid, users: usersInServer, })); - // Notify others (exclude by oderId, not connectionId) - broadcastToServer(message.serverId, { - type: 'user_joined', - oderId: user.oderId, - displayName: user.displayName, - }, user.oderId); - break; - case 'leave_server': - const oldServerId = user.serverId; - user.serverId = undefined; - connectedUsers.set(connectionId, user); - if (oldServerId) { - broadcastToServer(oldServerId, { - type: 'user_left', + // Only broadcast user_joined if this is a brand-new join (not a re-view) + if (isNew) { + broadcastToServer(sid, { + type: 'user_joined', oderId: user.oderId, - displayName: user.displayName, + displayName: user.displayName || 'Anonymous', + serverId: sid, }, user.oderId); } break; + } + case 'view_server': { + // Just switch the viewed server without joining/leaving + const viewSid = message.serverId; + user.viewedServerId = viewSid; + connectedUsers.set(connectionId, user); + console.log(`User ${user.displayName || 'Anonymous'} (${user.oderId}) viewing server ${viewSid}`); + // Send current user list for the viewed server + const viewUsers = Array.from(connectedUsers.values()) + .filter(u => u.serverIds.has(viewSid) && u.oderId !== user.oderId && u.displayName) + .map(u => ({ oderId: u.oderId, displayName: u.displayName || 'Anonymous' })); + user.ws.send(JSON.stringify({ + type: 'server_users', + serverId: viewSid, + users: viewUsers, + })); + break; + } + case 'leave_server': { + const leaveSid = message.serverId || user.viewedServerId; + if (leaveSid) { + user.serverIds.delete(leaveSid); + if (user.viewedServerId === leaveSid) { + user.viewedServerId = undefined; + } + connectedUsers.set(connectionId, user); + broadcastToServer(leaveSid, { + type: 'user_left', + oderId: user.oderId, + displayName: user.displayName || 'Anonymous', + serverId: leaveSid, + }, user.oderId); + } + break; + } case 'offer': case 'answer': case 'ice_candidate': @@ -325,11 +365,13 @@ function handleWebSocketMessage(connectionId, message) { console.log(`Target user ${message.targetUserId} not found. Connected users:`, Array.from(connectedUsers.values()).map(u => ({ oderId: u.oderId, displayName: u.displayName }))); } break; - case 'chat_message': + case 'chat_message': { // Broadcast chat message to all users in the server - if (user.serverId) { - broadcastToServer(user.serverId, { + const chatSid = message.serverId || user.viewedServerId; + if (chatSid && user.serverIds.has(chatSid)) { + broadcastToServer(chatSid, { type: 'chat_message', + serverId: chatSid, message: message.message, senderId: user.oderId, senderName: user.displayName, @@ -337,16 +379,20 @@ function handleWebSocketMessage(connectionId, message) { }); } break; - case 'typing': + } + case 'typing': { // Broadcast typing indicator - if (user.serverId) { - broadcastToServer(user.serverId, { + const typingSid = message.serverId || user.viewedServerId; + if (typingSid && user.serverIds.has(typingSid)) { + broadcastToServer(typingSid, { type: 'user_typing', + serverId: typingSid, oderId: user.oderId, displayName: user.displayName, }, user.oderId); } break; + } default: console.log('Unknown message type:', message.type); } @@ -354,7 +400,7 @@ function handleWebSocketMessage(connectionId, message) { function broadcastToServer(serverId, message, excludeOderId) { console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type); connectedUsers.forEach((user) => { - if (user.serverId === serverId && user.oderId !== excludeOderId) { + if (user.serverIds.has(serverId) && user.oderId !== excludeOderId) { console.log(` -> Sending to ${user.displayName} (${user.oderId})`); user.ws.send(JSON.stringify(message)); } @@ -375,21 +421,18 @@ function notifyUser(oderId, message) { function findUserByUserId(oderId) { return Array.from(connectedUsers.values()).find(u => u.oderId === oderId); } -// Cleanup old data periodically -// Simple cleanup only for stale join requests (keep servers permanent) +// Cleanup stale join requests periodically (older than 24 h) setInterval(() => { - const now = Date.now(); - joinRequests.forEach((request, id) => { - if (now - request.createdAt > 24 * 60 * 60 * 1000) { - joinRequests.delete(id); - } - }); + (0, db_1.deleteStaleJoinRequests)(24 * 60 * 60 * 1000).catch(err => console.error('Failed to clean up stale join requests:', err)); }, 60 * 1000); -server.listen(PORT, () => { - console.log(`🚀 MetoYou signaling server running on port ${PORT}`); - console.log(` REST API: http://localhost:${PORT}/api`); - console.log(` WebSocket: ws://localhost:${PORT}`); - // Load servers on startup - loadServers(); +(0, db_1.initDB)().then(() => { + server.listen(PORT, () => { + console.log(`🚀 MetoYou signaling server running on port ${PORT}`); + console.log(` REST API: http://localhost:${PORT}/api`); + console.log(` WebSocket: ws://localhost:${PORT}`); + }); +}).catch((err) => { + console.error('Failed to initialize database:', err); + process.exit(1); }); //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/server/dist/index.js.map b/server/dist/index.js.map index 429cdb6..a9d43cf 100644 --- a/server/dist/index.js.map +++ b/server/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/server/package.json b/server/package.json index 9e6c8a6..84e51db 100644 --- a/server/package.json +++ b/server/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "cors": "^2.8.5", + "dotenv": "^17.3.1", "express": "^4.18.2", "sql.js": "^1.9.0", "uuid": "^9.0.0", diff --git a/server/src/db.ts b/server/src/db.ts index b63bd5c..3c683c4 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -36,6 +36,34 @@ export async function initDB(): Promise { ); `); + db.run(` + CREATE TABLE IF NOT EXISTS servers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + ownerId TEXT NOT NULL, + ownerPublicKey TEXT NOT NULL, + isPrivate INTEGER NOT NULL DEFAULT 0, + maxUsers INTEGER NOT NULL DEFAULT 0, + currentUsers INTEGER NOT NULL DEFAULT 0, + tags TEXT NOT NULL DEFAULT '[]', + createdAt INTEGER NOT NULL, + lastSeen INTEGER NOT NULL + ); + `); + + db.run(` + CREATE TABLE IF NOT EXISTS join_requests ( + id TEXT PRIMARY KEY, + serverId TEXT NOT NULL, + userId TEXT NOT NULL, + userPublicKey TEXT NOT NULL, + displayName TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + createdAt INTEGER NOT NULL + ); + `); + persist(); } @@ -46,6 +74,10 @@ function persist(): void { fs.writeFileSync(DB_FILE, buffer); } +/* ------------------------------------------------------------------ */ +/* Auth Users */ +/* ------------------------------------------------------------------ */ + export interface AuthUser { id: string; username: string; @@ -100,3 +132,179 @@ export async function createUser(user: AuthUser): Promise { stmt.free(); persist(); } + +/* ------------------------------------------------------------------ */ +/* Servers */ +/* ------------------------------------------------------------------ */ + +export interface ServerInfo { + id: string; + name: string; + description?: string; + ownerId: string; + ownerPublicKey: string; + isPrivate: boolean; + maxUsers: number; + currentUsers: number; + tags: string[]; + createdAt: number; + lastSeen: number; +} + +function rowToServer(r: any): ServerInfo { + return { + id: String(r.id), + name: String(r.name), + description: r.description ? String(r.description) : undefined, + ownerId: String(r.ownerId), + ownerPublicKey: String(r.ownerPublicKey), + isPrivate: !!r.isPrivate, + maxUsers: Number(r.maxUsers), + currentUsers: Number(r.currentUsers), + tags: JSON.parse(String(r.tags || '[]')), + createdAt: Number(r.createdAt), + lastSeen: Number(r.lastSeen), + }; +} + +export async function getAllPublicServers(): Promise { + if (!db) await initDB(); + const stmt: any = db!.prepare('SELECT * FROM servers WHERE isPrivate = 0'); + const results: ServerInfo[] = []; + while (stmt.step()) { + results.push(rowToServer(stmt.getAsObject())); + } + stmt.free(); + return results; +} + +export async function getServerById(id: string): Promise { + if (!db) await initDB(); + const stmt: any = db!.prepare('SELECT * FROM servers WHERE id = ? LIMIT 1'); + stmt.bind([id]); + let row: ServerInfo | null = null; + if (stmt.step()) { + row = rowToServer(stmt.getAsObject()); + } + stmt.free(); + return row; +} + +export async function upsertServer(server: ServerInfo): Promise { + if (!db) await initDB(); + const stmt = db!.prepare(` + INSERT OR REPLACE INTO servers (id, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, currentUsers, tags, createdAt, lastSeen) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + stmt.bind([ + server.id, + server.name, + server.description ?? null, + server.ownerId, + server.ownerPublicKey, + server.isPrivate ? 1 : 0, + server.maxUsers, + server.currentUsers, + JSON.stringify(server.tags), + server.createdAt, + server.lastSeen, + ]); + stmt.step(); + stmt.free(); + persist(); +} + +export async function deleteServer(id: string): Promise { + if (!db) await initDB(); + const stmt = db!.prepare('DELETE FROM servers WHERE id = ?'); + stmt.bind([id]); + stmt.step(); + stmt.free(); + // Also clean up related join requests + const jStmt = db!.prepare('DELETE FROM join_requests WHERE serverId = ?'); + jStmt.bind([id]); + jStmt.step(); + jStmt.free(); + persist(); +} + +/* ------------------------------------------------------------------ */ +/* Join Requests */ +/* ------------------------------------------------------------------ */ + +export interface JoinRequest { + id: string; + serverId: string; + userId: string; + userPublicKey: string; + displayName: string; + status: 'pending' | 'approved' | 'rejected'; + createdAt: number; +} + +function rowToJoinRequest(r: any): JoinRequest { + return { + id: String(r.id), + serverId: String(r.serverId), + userId: String(r.userId), + userPublicKey: String(r.userPublicKey), + displayName: String(r.displayName), + status: String(r.status) as JoinRequest['status'], + createdAt: Number(r.createdAt), + }; +} + +export async function createJoinRequest(req: JoinRequest): Promise { + if (!db) await initDB(); + const stmt = db!.prepare(` + INSERT INTO join_requests (id, serverId, userId, userPublicKey, displayName, status, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + stmt.bind([req.id, req.serverId, req.userId, req.userPublicKey, req.displayName, req.status, req.createdAt]); + stmt.step(); + stmt.free(); + persist(); +} + +export async function getJoinRequestById(id: string): Promise { + if (!db) await initDB(); + const stmt: any = db!.prepare('SELECT * FROM join_requests WHERE id = ? LIMIT 1'); + stmt.bind([id]); + let row: JoinRequest | null = null; + if (stmt.step()) { + row = rowToJoinRequest(stmt.getAsObject()); + } + stmt.free(); + return row; +} + +export async function getPendingRequestsForServer(serverId: string): Promise { + if (!db) await initDB(); + const stmt: any = db!.prepare('SELECT * FROM join_requests WHERE serverId = ? AND status = ?'); + stmt.bind([serverId, 'pending']); + const results: JoinRequest[] = []; + while (stmt.step()) { + results.push(rowToJoinRequest(stmt.getAsObject())); + } + stmt.free(); + return results; +} + +export async function updateJoinRequestStatus(id: string, status: JoinRequest['status']): Promise { + if (!db) await initDB(); + const stmt = db!.prepare('UPDATE join_requests SET status = ? WHERE id = ?'); + stmt.bind([status, id]); + stmt.step(); + stmt.free(); + persist(); +} + +export async function deleteStaleJoinRequests(maxAgeMs: number): Promise { + if (!db) await initDB(); + const cutoff = Date.now() - maxAgeMs; + const stmt = db!.prepare('DELETE FROM join_requests WHERE createdAt < ?'); + stmt.bind([cutoff]); + stmt.step(); + stmt.free(); + persist(); +} diff --git a/server/src/index.ts b/server/src/index.ts index 404bc72..29d1148 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,40 +1,25 @@ +import dotenv from 'dotenv'; +import path from 'path'; +import fs from 'fs'; + +// Load .env from project root (one level up from server/) +dotenv.config({ path: path.resolve(__dirname, '..', '..', '.env') }); + import express from 'express'; import cors from 'cors'; -import { createServer } from 'http'; +import { createServer as createHttpServer } from 'http'; +import { createServer as createHttpsServer } from 'https'; import { WebSocketServer, WebSocket } from 'ws'; import { v4 as uuidv4 } from 'uuid'; +const USE_SSL = (process.env.SSL ?? 'false').toLowerCase() === 'true'; const app = express(); const PORT = process.env.PORT || 3001; app.use(cors()); app.use(express.json()); -// In-memory storage for servers and users -interface ServerInfo { - id: string; - name: string; - description?: string; - ownerId: string; - ownerPublicKey: string; - isPrivate: boolean; - maxUsers: number; - currentUsers: number; - tags: string[]; - createdAt: number; - lastSeen: number; -} - -interface JoinRequest { - id: string; - serverId: string; - userId: string; - userPublicKey: string; - displayName: string; - status: 'pending' | 'approved' | 'rejected'; - createdAt: number; -} - +// In-memory runtime state (WebSocket connections only – not persisted) interface ConnectedUser { oderId: string; ws: WebSocket; @@ -43,43 +28,38 @@ interface ConnectedUser { displayName?: string; } -const servers = new Map(); -const joinRequests = new Map(); const connectedUsers = new Map(); -// Persistence -import fs from 'fs'; -import path from 'path'; -const DATA_DIR = path.join(process.cwd(), 'data'); -const SERVERS_FILE = path.join(DATA_DIR, 'servers.json'); -const USERS_FILE = path.join(DATA_DIR, 'users.json'); +// Database +import crypto from 'crypto'; +import { + initDB, + getUserByUsername, + createUser, + getAllPublicServers, + getServerById, + upsertServer, + deleteServer as dbDeleteServer, + createJoinRequest, + getJoinRequestById, + getPendingRequestsForServer, + updateJoinRequestStatus, + deleteStaleJoinRequests, + ServerInfo, + JoinRequest, +} from './db'; -function ensureDataDir() { - if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); -} - -function saveServers() { - ensureDataDir(); - fs.writeFileSync(SERVERS_FILE, JSON.stringify(Array.from(servers.values()), null, 2)); -} - -function loadServers() { - ensureDataDir(); - if (fs.existsSync(SERVERS_FILE)) { - const raw = fs.readFileSync(SERVERS_FILE, 'utf-8'); - const list: ServerInfo[] = JSON.parse(raw); - list.forEach(s => servers.set(s.id, s)); - } -} +function hashPassword(pw: string) { return crypto.createHash('sha256').update(pw).digest('hex'); } // REST API Routes // Health check endpoint -app.get('/api/health', (req, res) => { +app.get('/api/health', async (req, res) => { + const allServers = await getAllPublicServers(); res.json({ status: 'ok', timestamp: Date.now(), - serverCount: servers.size, + serverCount: allServers.length, connectedUsers: connectedUsers.size, }); }); @@ -129,40 +109,31 @@ app.get('/api/image-proxy', async (req, res) => { } }); -// Basic auth (demo - file-based) -interface AuthUser { id: string; username: string; passwordHash: string; displayName: string; createdAt: number; } -let authUsers: AuthUser[] = []; -function saveUsers() { ensureDataDir(); fs.writeFileSync(USERS_FILE, JSON.stringify(authUsers, null, 2)); } -function loadUsers() { ensureDataDir(); if (fs.existsSync(USERS_FILE)) { authUsers = JSON.parse(fs.readFileSync(USERS_FILE,'utf-8')); } } -import crypto from 'crypto'; -import { initDB, getUserByUsername, createUser } from './db'; -function hashPassword(pw: string) { return crypto.createHash('sha256').update(pw).digest('hex'); } - +// Auth app.post('/api/users/register', async (req, res) => { const { username, password, displayName } = req.body; if (!username || !password) return res.status(400).json({ error: 'Missing username/password' }); - await initDB(); const exists = await getUserByUsername(username); if (exists) return res.status(409).json({ error: 'Username taken' }); - const user: AuthUser = { id: uuidv4(), username, passwordHash: hashPassword(password), displayName: displayName || username, createdAt: Date.now() }; + const user = { id: uuidv4(), username, passwordHash: hashPassword(password), displayName: displayName || username, createdAt: Date.now() }; await createUser(user); res.status(201).json({ id: user.id, username: user.username, displayName: user.displayName }); }); app.post('/api/users/login', async (req, res) => { const { username, password } = req.body; - await initDB(); const user = await getUserByUsername(username); if (!user || user.passwordHash !== hashPassword(password)) return res.status(401).json({ error: 'Invalid credentials' }); res.json({ id: user.id, username: user.username, displayName: user.displayName }); }); // Search servers -app.get('/api/servers', (req, res) => { +app.get('/api/servers', async (req, res) => { const { q, tags, limit = 20, offset = 0 } = req.query; - let results = Array.from(servers.values()) - .filter(s => !s.isPrivate) + let results = await getAllPublicServers(); + + results = results .filter(s => { if (q) { const query = String(q).toLowerCase(); @@ -179,8 +150,6 @@ app.get('/api/servers', (req, res) => { return true; }); - // Keep servers visible permanently until deleted; do not filter by lastSeen - const total = results.length; results = results.slice(Number(offset), Number(offset) + Number(limit)); @@ -188,14 +157,13 @@ app.get('/api/servers', (req, res) => { }); // Register a server -app.post('/api/servers', (req, res) => { +app.post('/api/servers', async (req, res) => { const { id: clientId, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, tags } = req.body; if (!name || !ownerId || !ownerPublicKey) { return res.status(400).json({ error: 'Missing required fields' }); } - // Use client-provided ID if available, otherwise generate one const id = clientId || uuidv4(); const server: ServerInfo = { id, @@ -211,17 +179,16 @@ app.post('/api/servers', (req, res) => { lastSeen: Date.now(), }; - servers.set(id, server); - saveServers(); + await upsertServer(server); res.status(201).json(server); }); // Update server -app.put('/api/servers/:id', (req, res) => { +app.put('/api/servers/:id', async (req, res) => { const { id } = req.params; const { ownerId, ...updates } = req.body; - const server = servers.get(id); + const server = await getServerById(id); if (!server) { return res.status(404).json({ error: 'Server not found' }); } @@ -230,18 +197,17 @@ app.put('/api/servers/:id', (req, res) => { return res.status(403).json({ error: 'Not authorized' }); } - const updated = { ...server, ...updates, lastSeen: Date.now() }; - servers.set(id, updated); - saveServers(); + const updated: ServerInfo = { ...server, ...updates, lastSeen: Date.now() }; + await upsertServer(updated); res.json(updated); }); // Heartbeat - keep server alive -app.post('/api/servers/:id/heartbeat', (req, res) => { +app.post('/api/servers/:id/heartbeat', async (req, res) => { const { id } = req.params; const { currentUsers } = req.body; - const server = servers.get(id); + const server = await getServerById(id); if (!server) { return res.status(404).json({ error: 'Server not found' }); } @@ -250,18 +216,17 @@ app.post('/api/servers/:id/heartbeat', (req, res) => { if (typeof currentUsers === 'number') { server.currentUsers = currentUsers; } - servers.set(id, server); - saveServers(); + await upsertServer(server); res.json({ ok: true }); }); // Remove server -app.delete('/api/servers/:id', (req, res) => { +app.delete('/api/servers/:id', async (req, res) => { const { id } = req.params; const { ownerId } = req.body; - const server = servers.get(id); + const server = await getServerById(id); if (!server) { return res.status(404).json({ error: 'Server not found' }); } @@ -270,17 +235,16 @@ app.delete('/api/servers/:id', (req, res) => { return res.status(403).json({ error: 'Not authorized' }); } - servers.delete(id); - saveServers(); + await dbDeleteServer(id); res.json({ ok: true }); }); // Request to join a server -app.post('/api/servers/:id/join', (req, res) => { +app.post('/api/servers/:id/join', async (req, res) => { const { id: serverId } = req.params; const { userId, userPublicKey, displayName } = req.body; - const server = servers.get(serverId); + const server = await getServerById(serverId); if (!server) { return res.status(404).json({ error: 'Server not found' }); } @@ -296,7 +260,7 @@ app.post('/api/servers/:id/join', (req, res) => { createdAt: Date.now(), }; - joinRequests.set(requestId, request); + await createJoinRequest(request); // Notify server owner via WebSocket if (server.isPrivate) { @@ -310,11 +274,11 @@ app.post('/api/servers/:id/join', (req, res) => { }); // Get join requests for a server -app.get('/api/servers/:id/requests', (req, res) => { +app.get('/api/servers/:id/requests', async (req, res) => { const { id: serverId } = req.params; const { ownerId } = req.query; - const server = servers.get(serverId); + const server = await getServerById(serverId); if (!server) { return res.status(404).json({ error: 'Server not found' }); } @@ -323,41 +287,58 @@ app.get('/api/servers/:id/requests', (req, res) => { return res.status(403).json({ error: 'Not authorized' }); } - const requests = Array.from(joinRequests.values()) - .filter(r => r.serverId === serverId && r.status === 'pending'); - + const requests = await getPendingRequestsForServer(serverId); res.json({ requests }); }); // Approve/reject join request -app.put('/api/requests/:id', (req, res) => { +app.put('/api/requests/:id', async (req, res) => { const { id } = req.params; const { ownerId, status } = req.body; - const request = joinRequests.get(id); + const request = await getJoinRequestById(id); if (!request) { return res.status(404).json({ error: 'Request not found' }); } - const server = servers.get(request.serverId); + const server = await getServerById(request.serverId); if (!server || server.ownerId !== ownerId) { return res.status(403).json({ error: 'Not authorized' }); } - request.status = status; - joinRequests.set(id, request); + await updateJoinRequestStatus(id, status); + const updated = { ...request, status }; // Notify the requester notifyUser(request.userId, { type: 'request_update', - request, + request: updated, }); - res.json(request); + res.json(updated); }); // WebSocket Server for real-time signaling -const server = createServer(app); +function buildServer() { + if (USE_SSL) { + // Look for certs relative to project root (one level up from server/) + const certDir = path.resolve(__dirname, '..', '..', '.certs'); + const certFile = path.join(certDir, 'localhost.crt'); + const keyFile = path.join(certDir, 'localhost.key'); + if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) { + console.error(`SSL=true but certs not found in ${certDir}`); + console.error('Run ./generate-cert.sh first.'); + process.exit(1); + } + return createHttpsServer( + { cert: fs.readFileSync(certFile), key: fs.readFileSync(keyFile) }, + app, + ); + } + return createHttpServer(app); +} + +const server = buildServer(); const wss = new WebSocketServer({ server }); wss.on('connection', (ws: WebSocket) => { @@ -559,24 +540,20 @@ function findUserByUserId(oderId: string): ConnectedUser | undefined { return Array.from(connectedUsers.values()).find(u => u.oderId === oderId); } -// Cleanup old data periodically -// Simple cleanup only for stale join requests (keep servers permanent) +// Cleanup stale join requests periodically (older than 24 h) setInterval(() => { - const now = Date.now(); - joinRequests.forEach((request, id) => { - if (now - request.createdAt > 24 * 60 * 60 * 1000) { - joinRequests.delete(id); - } - }); + deleteStaleJoinRequests(24 * 60 * 60 * 1000).catch(err => + console.error('Failed to clean up stale join requests:', err), + ); }, 60 * 1000); initDB().then(() => { server.listen(PORT, () => { - console.log(`🚀 MetoYou signaling server running on port ${PORT}`); - console.log(` REST API: http://localhost:${PORT}/api`); - console.log(` WebSocket: ws://localhost:${PORT}`); - // Load servers on startup - loadServers(); + const proto = USE_SSL ? 'https' : 'http'; + const wsProto = USE_SSL ? 'wss' : 'ws'; + console.log(`🚀 MetoYou signaling server running on port ${PORT} (SSL=${USE_SSL})`); + console.log(` REST API: ${proto}://localhost:${PORT}/api`); + console.log(` WebSocket: ${wsProto}://localhost:${PORT}`); }); }).catch((err) => { console.error('Failed to initialize database:', err); diff --git a/src/app/core/models/index.ts b/src/app/core/models/index.ts index 28f77b4..ec66ed0 100644 --- a/src/app/core/models/index.ts +++ b/src/app/core/models/index.ts @@ -17,9 +17,17 @@ export interface User { screenShareState?: ScreenShareState; } +export interface Channel { + id: string; + name: string; + type: 'text' | 'voice'; + position: number; // ordering within its type group +} + export interface Message { id: string; roomId: string; + channelId?: string; // which text channel the message belongs to (default: 'general') senderId: string; senderName: string; content: string; @@ -55,6 +63,8 @@ export interface Room { iconUpdatedAt?: number; // last update timestamp for conflict resolution // Role-based management permissions permissions?: RoomPermissions; + // Channels within the server + channels?: Channel[]; } export interface RoomSettings { @@ -129,7 +139,7 @@ export interface SignalingMessage { } export interface ChatEvent { - type: 'message' | 'chat-message' | 'edit' | 'message-edited' | 'delete' | 'message-deleted' | 'reaction' | 'reaction-added' | 'reaction-removed' | 'kick' | 'ban' | 'room-deleted' | 'room-settings-update' | 'voice-state' | 'voice-state-request' | 'state-request' | 'screen-state'; + type: 'message' | 'chat-message' | 'edit' | 'message-edited' | 'delete' | 'message-deleted' | 'reaction' | 'reaction-added' | 'reaction-removed' | 'kick' | 'ban' | 'room-deleted' | 'room-settings-update' | 'voice-state' | 'voice-state-request' | 'state-request' | 'screen-state' | 'role-change' | 'channels-update'; messageId?: string; message?: Message; reaction?: Reaction; @@ -149,6 +159,8 @@ export interface ChatEvent { settings?: RoomSettings; voiceState?: Partial; isScreenSharing?: boolean; + role?: 'host' | 'admin' | 'moderator' | 'member'; + channels?: Channel[]; } export interface ServerInfo { diff --git a/src/app/core/services/attachment.service.ts b/src/app/core/services/attachment.service.ts index d78ebbf..e1c3484 100644 --- a/src/app/core/services/attachment.service.ts +++ b/src/app/core/services/attachment.service.ts @@ -1,9 +1,9 @@ -import { Injectable, inject, signal } from '@angular/core'; -import { Subject } from 'rxjs'; +import { Injectable, inject, signal, effect } from '@angular/core'; import { v4 as uuidv4 } from 'uuid'; import { WebRTCService } from './webrtc.service'; import { Store } from '@ngrx/store'; import { selectCurrentRoomName } from '../../store/rooms/rooms.selectors'; +import { DatabaseService } from './database.service'; export interface AttachmentMeta { id: string; @@ -13,7 +13,8 @@ export interface AttachmentMeta { mime: string; isImage: boolean; uploaderPeerId?: string; - filePath?: string; // Electron-only: absolute path to original file + filePath?: string; // Electron-only: absolute path to original file + savedPath?: string; // Electron-only: disk cache path where image was saved } export interface Attachment extends AttachmentMeta { @@ -29,19 +30,15 @@ export interface Attachment extends AttachmentMeta { @Injectable({ providedIn: 'root' }) export class AttachmentService { private readonly webrtc = inject(WebRTCService); - // Injected NgRx store private readonly ngrxStore = inject(Store); - private readonly STORAGE_KEY = 'metoyou_attachments'; + private readonly db = inject(DatabaseService); // messageId -> attachments private attachmentsByMessage = new Map(); - // expose updates if needed updated = signal(0); // Keep original files for uploaders to fulfill requests private originals = new Map(); // key: messageId:fileId - // Notify UI when original is missing and uploader needs to reselect - readonly onMissingOriginal = new Subject<{ messageId: string; fileId: string; fromPeerId: string }>(); // Track cancelled transfers (uploader side) keyed by messageId:fileId:peerId private cancelledTransfers = new Set(); private makeKey(messageId: string, fileId: string, peerId: string): string { return `${messageId}:${fileId}:${peerId}`; } @@ -49,14 +46,197 @@ export class AttachmentService { return this.cancelledTransfers.has(this.makeKey(messageId, fileId, targetPeerId)); } + /** Check whether a file is an image or video. */ + private isMedia(att: { mime: string }): boolean { + return att.mime.startsWith('image/') || att.mime.startsWith('video/'); + } + + private dbInitDone = false; + constructor() { - this.loadPersisted(); + effect(() => { + if (this.db.isReady() && !this.dbInitDone) { + this.dbInitDone = true; + this.initFromDb(); + } + }); + } + + private async initFromDb(): Promise { + await this.loadFromDb(); + await this.migrateFromLocalStorage(); + await this.tryLoadSavedFiles(); } getForMessage(messageId: string): Attachment[] { return this.attachmentsByMessage.get(messageId) || []; } + /** Return minimal attachment metadata for a set of message IDs (for sync). */ + getAttachmentMetasForMessages(messageIds: string[]): Record { + const result: Record = {}; + for (const mid of messageIds) { + const list = this.attachmentsByMessage.get(mid); + if (list && list.length > 0) { + result[mid] = list.map(a => ({ + id: a.id, + messageId: a.messageId, + filename: a.filename, + size: a.size, + mime: a.mime, + isImage: a.isImage, + uploaderPeerId: a.uploaderPeerId, + filePath: undefined, // never share local paths + savedPath: undefined, // never share local paths + })); + } + } + return result; + } + + /** Register attachments received via message sync (metadata only). */ + registerSyncedAttachments(attachmentMap: Record): void { + const newAtts: Attachment[] = []; + for (const [messageId, metas] of Object.entries(attachmentMap)) { + const existing = this.attachmentsByMessage.get(messageId) || []; + for (const meta of metas) { + if (!existing.find(e => e.id === meta.id)) { + const att: Attachment = { ...meta, available: false, receivedBytes: 0 }; + existing.push(att); + newAtts.push(att); + } + } + if (existing.length > 0) { + this.attachmentsByMessage.set(messageId, existing); + } + } + if (newAtts.length > 0) { + this.updated.set(this.updated() + 1); + for (const att of newAtts) { + void this.persistAttachmentMeta(att); + } + } + } + + // Track pending requests so we can retry with other peers + // key: messageId:fileId -> Set of peer IDs already tried + private pendingRequests = new Map>(); + + /** Request a file from any connected peer that might have it. */ + requestFromAnyPeer(messageId: string, att: Attachment): void { + const connected = this.webrtc.getConnectedPeers(); + if (connected.length === 0) { + console.warn('[Attachments] No connected peers to request file from'); + return; + } + const reqKey = `${messageId}:${att.id}`; + // Reset tried-peers for a fresh request + this.pendingRequests.set(reqKey, new Set()); + this.sendFileRequestToNextPeer(messageId, att.id, att.uploaderPeerId); + } + + /** Send file-request to the next untried peer. Returns true if a request was sent. */ + private sendFileRequestToNextPeer(messageId: string, fileId: string, preferredPeerId?: string): boolean { + const connected = this.webrtc.getConnectedPeers(); + const reqKey = `${messageId}:${fileId}`; + const tried = this.pendingRequests.get(reqKey) || new Set(); + + // Pick the best untried peer: preferred first, then any + let target: string | undefined; + if (preferredPeerId && connected.includes(preferredPeerId) && !tried.has(preferredPeerId)) { + target = preferredPeerId; + } else { + target = connected.find(p => !tried.has(p)); + } + + if (!target) { + console.warn(`[Attachments] All ${tried.size} peers tried for ${reqKey}, none could serve`); + this.pendingRequests.delete(reqKey); + return false; + } + + tried.add(target); + this.pendingRequests.set(reqKey, tried); + console.log(`[Attachments] Requesting ${fileId} from peer ${target} (tried ${tried.size}/${connected.length})`); + this.webrtc.sendToPeer(target, { + type: 'file-request', + messageId, + fileId, + } as any); + return true; + } + + /** Handle a file-not-found response – try the next peer. */ + handleFileNotFound(payload: any): void { + const { messageId, fileId } = payload; + if (!messageId || !fileId) return; + const list = this.attachmentsByMessage.get(messageId) || []; + const att = list.find(a => a.id === fileId); + this.sendFileRequestToNextPeer(messageId, fileId, att?.uploaderPeerId); + } + + /** @deprecated Use requestFromAnyPeer instead */ + requestImageFromAnyPeer(messageId: string, att: Attachment): void { + this.requestFromAnyPeer(messageId, att); + } + + /** On startup, try loading previously saved files from disk (Electron). */ + private async tryLoadSavedFiles(): Promise { + const w: any = window as any; + if (!w?.electronAPI?.fileExists || !w?.electronAPI?.readFile) return; + try { + let changed = false; + for (const [, attachments] of this.attachmentsByMessage) { + for (const att of attachments) { + if (att.available) continue; + // 1. Try savedPath (disk cache — all file types) + if (att.savedPath) { + try { + const exists = await w.electronAPI.fileExists(att.savedPath); + if (exists) { + const base64 = await w.electronAPI.readFile(att.savedPath); + const bytes = this.base64ToUint8Array(base64); + const blob = new Blob([bytes.buffer as ArrayBuffer], { type: att.mime }); + att.objectUrl = URL.createObjectURL(blob); + att.available = true; + // Re-populate originals so handleFileRequest step 1 works after restart + const file = new File([blob], att.filename, { type: att.mime }); + this.originals.set(`${att.messageId}:${att.id}`, file); + changed = true; + continue; + } + } catch {} + } + // 2. Try filePath (uploader's original) + if (att.filePath) { + try { + const exists = await w.electronAPI.fileExists(att.filePath); + if (exists) { + const base64 = await w.electronAPI.readFile(att.filePath); + const bytes = this.base64ToUint8Array(base64); + const blob = new Blob([bytes.buffer as ArrayBuffer], { type: att.mime }); + att.objectUrl = URL.createObjectURL(blob); + att.available = true; + // Re-populate originals so handleFileRequest step 1 works after restart + const file = new File([blob], att.filename, { type: att.mime }); + this.originals.set(`${att.messageId}:${att.id}`, file); + changed = true; + // Save to disk cache for future use + if (att.size <= 10 * 1024 * 1024) { + void this.saveFileToDisk(att, blob); + } + continue; + } + } catch {} + } + } + } + if (changed) { + this.updated.set(this.updated() + 1); + } + } catch {} + } + // Publish attachments for a sent message and stream images <=10MB async publishAttachments(messageId: string, files: File[], uploaderPeerId?: string): Promise { const attachments: Attachment[] = []; @@ -77,18 +257,18 @@ export class AttachmentService { // Save original for request-based transfer this.originals.set(`${messageId}:${id}`, file); + console.log(`[Attachments] publishAttachments: stored original key="${messageId}:${id}" (${file.name}, ${file.size} bytes)`); - // Ensure uploader sees their own image immediately - if (meta.isImage) { - try { - const url = URL.createObjectURL(file); - meta.objectUrl = url; - meta.available = true; - // Auto-save only for images ≤10MB - if (meta.size <= 10 * 1024 * 1024) { - void this.saveImageToDisk(meta, file); - } - } catch {} + // Ensure uploader sees their own files immediately (all types, not just images) + try { + const url = URL.createObjectURL(file); + meta.objectUrl = url; + meta.available = true; + } catch {} + + // Save ALL files ≤10MB to disk (Electron) for persistence across restarts + if (meta.size <= 10 * 1024 * 1024) { + void this.saveFileToDisk(meta, file); } // Announce to peers @@ -113,7 +293,9 @@ export class AttachmentService { this.attachmentsByMessage.set(messageId, [ ...(this.attachmentsByMessage.get(messageId) || []), ...attachments ]); this.updated.set(this.updated() + 1); - this.persist(); + for (const att of attachments) { + void this.persistAttachmentMeta(att); + } } private async streamFileToPeers(messageId: string, fileId: string, file: File): Promise { @@ -146,7 +328,7 @@ export class AttachmentService { const list = this.attachmentsByMessage.get(messageId) || []; const exists = list.find((a: Attachment) => a.id === file.id); if (!exists) { - list.push({ + const att: Attachment = { id: file.id, messageId, filename: file.filename, @@ -156,10 +338,11 @@ export class AttachmentService { uploaderPeerId: file.uploaderPeerId, available: false, receivedBytes: 0, - }); + }; + list.push(att); this.attachmentsByMessage.set(messageId, list); this.updated.set(this.updated() + 1); - this.persist(); + void this.persistAttachmentMeta(att); } } @@ -205,20 +388,20 @@ export class AttachmentService { const blob = new Blob(finalParts, { type: att.mime }); att.available = true; att.objectUrl = URL.createObjectURL(blob); - // Auto-save small images to disk under app data: server//image - if (att.isImage && att.size <= 10 * 1024 * 1024) { - void this.saveImageToDisk(att, blob); + // Auto-save ALL received files to disk under app data (Electron) + if (att.size <= 10 * 1024 * 1024) { + void this.saveFileToDisk(att, blob); } // Final update delete (this as any)[partsKey]; delete (this as any)[countKey]; this.updated.set(this.updated() + 1); - this.persist(); + void this.persistAttachmentMeta(att); } } } - private async saveImageToDisk(att: Attachment, blob: Blob): Promise { + private async saveFileToDisk(att: Attachment, blob: Blob): Promise { try { const w: any = window as any; const appData: string | undefined = await w?.electronAPI?.getAppDataPath?.(); @@ -228,28 +411,20 @@ export class AttachmentService { const sub = this.ngrxStore.select(selectCurrentRoomName).subscribe((n) => { name = n || ''; resolve(name); sub.unsubscribe(); }); }); const safeRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room'; - const dir = `${appData}/server/${safeRoom}/image`; + const subDir = att.mime.startsWith('video/') ? 'video' : att.mime.startsWith('image/') ? 'image' : 'files'; + const dir = `${appData}/server/${safeRoom}/${subDir}`; await w.electronAPI.ensureDir(dir); const arrayBuffer = await blob.arrayBuffer(); const base64 = this.arrayBufferToBase64(arrayBuffer); - const path = `${dir}/${att.filename}`; - await w.electronAPI.writeFile(path, base64); + const diskPath = `${dir}/${att.filename}`; + await w.electronAPI.writeFile(diskPath, base64); + att.savedPath = diskPath; + void this.persistAttachmentMeta(att); } catch {} } requestFile(messageId: string, att: Attachment): void { - const target = att.uploaderPeerId; - if (!target) return; - const connected = this.webrtc.getConnectedPeers(); - if (!connected.includes(target)) { - console.warn('Uploader peer not connected:', target); - return; - } - this.webrtc.sendToPeer(target, { - type: 'file-request', - messageId, - fileId: att.id, - } as any); + this.requestFromAnyPeer(messageId, att); } // Cancel an in-progress request from the requester side @@ -281,47 +456,105 @@ export class AttachmentService { } catch {} } - // When we receive a request and we are the uploader, stream the original file if available + // When we receive a request, stream the file if we have it (uploader or any peer with cached copy) async handleFileRequest(payload: any): Promise { const { messageId, fileId, fromPeerId } = payload; - if (!messageId || !fileId || !fromPeerId) return; - const original = this.originals.get(`${messageId}:${fileId}`); + if (!messageId || !fileId || !fromPeerId) { + console.warn('[Attachments] handleFileRequest: missing fields', { messageId, fileId, fromPeerId }); + return; + } + console.log(`[Attachments] handleFileRequest for ${fileId} (msg=${messageId}) from peer ${fromPeerId}`); + console.log(`[Attachments] originals map has ${this.originals.size} entries: [${[...this.originals.keys()].join(', ')}]`); + + // 1. Check in-memory originals (uploader case) + const exactKey = `${messageId}:${fileId}`; + let original = this.originals.get(exactKey); + + // 1b. Fallback: search originals by fileId alone (handles rare messageId drift) + if (!original) { + for (const [key, file] of this.originals) { + if (key.endsWith(`:${fileId}`)) { + console.warn(`[Attachments] Exact key "${exactKey}" not found, but matched by fileId via key "${key}"`); + original = file; + break; + } + } + } + if (original) { + console.log(`[Attachments] Serving ${fileId} from in-memory original (${original.size} bytes)`); await this.streamFileToPeer(fromPeerId, messageId, fileId, original); return; } - // Try Electron file path fallback + const list = this.attachmentsByMessage.get(messageId) || []; const att = list.find((a: Attachment) => a.id === fileId); const w: any = window as any; + + // 2. Check Electron file-path fallback (uploader's original path) if (att?.filePath && w?.electronAPI?.fileExists && w?.electronAPI?.readFile) { try { const exists = await w.electronAPI.fileExists(att.filePath); if (exists) { - const base64 = await w.electronAPI.readFile(att.filePath); - const bytes = this.base64ToUint8Array(base64); - const chunkSize = 64 * 1024; - const totalChunks = Math.ceil(bytes.byteLength / chunkSize); - for (let i = 0; i < totalChunks; i++) { - if (this.isCancelled(fromPeerId, messageId, fileId)) break; - const slice = bytes.subarray(i * chunkSize, Math.min(bytes.byteLength, (i + 1) * chunkSize)); - const slicedBuffer = (slice.buffer as ArrayBuffer).slice(slice.byteOffset, slice.byteOffset + slice.byteLength); - const b64 = this.arrayBufferToBase64(slicedBuffer); - this.webrtc.sendToPeer(fromPeerId, { - type: 'file-chunk', - messageId, - fileId, - index: i, - total: totalChunks, - data: b64, - } as any); - } + console.log(`[Attachments] Serving ${fileId} from original filePath: ${att.filePath}`); + await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, att.filePath); return; } } catch {} } - // Fallback: prompt reselect - this.onMissingOriginal.next({ messageId, fileId, fromPeerId }); + + // 3. Check savedPath (disk cache recorded path) + if (att?.savedPath && w?.electronAPI?.fileExists && w?.electronAPI?.readFile) { + try { + const exists = await w.electronAPI.fileExists(att.savedPath); + if (exists) { + console.log(`[Attachments] Serving ${fileId} from savedPath: ${att.savedPath}`); + await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, att.savedPath); + return; + } + } catch {} + } + + // 3b. Fallback: Check Electron disk cache by room name (backward compat) + if (att?.isImage && w?.electronAPI?.getAppDataPath && w?.electronAPI?.fileExists && w?.electronAPI?.readFile) { + try { + const appData = await w.electronAPI.getAppDataPath(); + if (appData) { + const roomName = await new Promise((resolve) => { + const sub = this.ngrxStore.select(selectCurrentRoomName).subscribe((n) => { resolve(n || ''); sub.unsubscribe(); }); + }); + const safeRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room'; + const path = `${appData}/server/${safeRoom}/image/${att.filename}`; + const exists = await w.electronAPI.fileExists(path); + if (exists) { + console.log(`[Attachments] Serving ${fileId} from disk cache: ${path}`); + await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, path); + return; + } + } + } catch {} + } + + // 4. Check in-memory blob (received this session but not saved to disk, e.g. browser mode) + if (att?.available && att.objectUrl) { + try { + const resp = await fetch(att.objectUrl); + const blob = await resp.blob(); + const file = new File([blob], att.filename, { type: att.mime }); + console.log(`[Attachments] Serving ${fileId} from in-memory blob (${blob.size} bytes)`); + await this.streamFileToPeer(fromPeerId, messageId, fileId, file); + return; + } catch {} + } + + // 5. Cannot serve – notify requester so they can try another peer + console.warn(`[Attachments] Cannot fulfill file-request for ${fileId} (msg=${messageId}) – no source found. ` + + `originals=${this.originals.size}, att=${att ? `available=${att.available},savedPath=${att.savedPath},filePath=${att.filePath}` : 'not in map'}`); + this.webrtc.sendToPeer(fromPeerId, { + type: 'file-not-found', + messageId, + fileId, + } as any); } private async streamFileToPeer(targetPeerId: string, messageId: string, fileId: string, file: File): Promise { @@ -355,37 +588,57 @@ export class AttachmentService { // Optionally clear original if desired (keep for re-request) } + /** Stream a file from Electron disk to a peer. */ + private async streamFileFromDiskToPeer(targetPeerId: string, messageId: string, fileId: string, filePath: string): Promise { + const w: any = window as any; + const base64 = await w.electronAPI.readFile(filePath); + const bytes = this.base64ToUint8Array(base64); + const chunkSize = 64 * 1024; + const totalChunks = Math.ceil(bytes.byteLength / chunkSize); + for (let i = 0; i < totalChunks; i++) { + if (this.isCancelled(targetPeerId, messageId, fileId)) break; + const slice = bytes.subarray(i * chunkSize, Math.min(bytes.byteLength, (i + 1) * chunkSize)); + const slicedBuffer = (slice.buffer as ArrayBuffer).slice(slice.byteOffset, slice.byteOffset + slice.byteLength); + const b64 = this.arrayBufferToBase64(slicedBuffer); + this.webrtc.sendToPeer(targetPeerId, { + type: 'file-chunk', + messageId, + fileId, + index: i, + total: totalChunks, + data: b64, + } as any); + } + } + // Fulfill a pending request with a user-provided file (uploader side) async fulfillRequestWithFile(messageId: string, fileId: string, targetPeerId: string, file: File): Promise { this.originals.set(`${messageId}:${fileId}`, file); await this.streamFileToPeer(targetPeerId, messageId, fileId, file); } - private persist(): void { + private async persistAttachmentMeta(att: Attachment): Promise { + if (!this.db.isReady()) return; try { - const all: Attachment[] = Array.from(this.attachmentsByMessage.values()).flat(); - const minimal = all.map((a: Attachment) => ({ - id: a.id, - messageId: a.messageId, - filename: a.filename, - size: a.size, - mime: a.mime, - isImage: a.isImage, - uploaderPeerId: a.uploaderPeerId, - filePath: a.filePath, - available: false, - })); - localStorage.setItem(this.STORAGE_KEY, JSON.stringify(minimal)); + await this.db.saveAttachment({ + id: att.id, + messageId: att.messageId, + filename: att.filename, + size: att.size, + mime: att.mime, + isImage: att.isImage, + uploaderPeerId: att.uploaderPeerId, + filePath: att.filePath, + savedPath: att.savedPath, + }); } catch {} } - private loadPersisted(): void { + private async loadFromDb(): Promise { try { - const raw = localStorage.getItem(this.STORAGE_KEY); - if (!raw) return; - const list: AttachmentMeta[] = JSON.parse(raw); + const all: AttachmentMeta[] = await this.db.getAllAttachments(); const grouped = new Map(); - for (const a of list) { + for (const a of all) { const att: Attachment = { ...a, available: false }; const arr = grouped.get(a.messageId) || []; arr.push(att); @@ -396,6 +649,26 @@ export class AttachmentService { } catch {} } + /** One-time migration from localStorage to database. */ + private async migrateFromLocalStorage(): Promise { + try { + const raw = localStorage.getItem('metoyou_attachments'); + if (!raw) return; + const list: AttachmentMeta[] = JSON.parse(raw); + for (const meta of list) { + const existing = this.attachmentsByMessage.get(meta.messageId) || []; + if (!existing.find(e => e.id === meta.id)) { + const att: Attachment = { ...meta, available: false }; + existing.push(att); + this.attachmentsByMessage.set(meta.messageId, existing); + void this.persistAttachmentMeta(att); + } + } + localStorage.removeItem('metoyou_attachments'); + this.updated.set(this.updated() + 1); + } catch {} + } + private arrayBufferToBase64(buffer: ArrayBuffer): string { let binary = ''; const bytes = new Uint8Array(buffer); diff --git a/src/app/core/services/browser-database.service.ts b/src/app/core/services/browser-database.service.ts new file mode 100644 index 0000000..1848711 --- /dev/null +++ b/src/app/core/services/browser-database.service.ts @@ -0,0 +1,302 @@ +import { Injectable } from '@angular/core'; +import { Message, User, Room, Reaction, BanEntry } from '../models'; + +const DB_NAME = 'metoyou'; +const DB_VERSION = 2; + +/** + * IndexedDB-backed database service used when the app runs in a + * plain browser (i.e. without Electron). + * + * Every public method mirrors the DatabaseService API so the + * facade can delegate transparently. + */ +@Injectable({ providedIn: 'root' }) +export class BrowserDatabaseService { + private db: IDBDatabase | null = null; + + /* ------------------------------------------------------------------ */ + /* Lifecycle */ + /* ------------------------------------------------------------------ */ + + async initialize(): Promise { + if (this.db) return; + this.db = await this.openDatabase(); + } + + /* ------------------------------------------------------------------ */ + /* Messages */ + /* ------------------------------------------------------------------ */ + + async saveMessage(message: Message): Promise { + await this.put('messages', message); + } + + async getMessages(roomId: string, limit = 100, offset = 0): Promise { + const all = await this.getAllFromIndex('messages', 'roomId', roomId); + return all + .sort((a, b) => a.timestamp - b.timestamp) + .slice(offset, offset + limit); + } + + async deleteMessage(messageId: string): Promise { + await this.delete('messages', messageId); + } + + async updateMessage(messageId: string, updates: Partial): Promise { + const msg = await this.get('messages', messageId); + if (msg) await this.put('messages', { ...msg, ...updates }); + } + + async getMessageById(messageId: string): Promise { + return (await this.get('messages', messageId)) ?? null; + } + + async clearRoomMessages(roomId: string): Promise { + const msgs = await this.getAllFromIndex('messages', 'roomId', roomId); + const tx = this.transaction('messages', 'readwrite'); + for (const m of msgs) tx.objectStore('messages').delete(m.id); + await this.complete(tx); + } + + /* ------------------------------------------------------------------ */ + /* Reactions */ + /* ------------------------------------------------------------------ */ + + async saveReaction(reaction: Reaction): Promise { + const existing = await this.getAllFromIndex('reactions', 'messageId', reaction.messageId); + const dup = existing.some( + (r) => r.userId === reaction.userId && r.emoji === reaction.emoji, + ); + if (!dup) await this.put('reactions', reaction); + } + + async removeReaction(messageId: string, userId: string, emoji: string): Promise { + const all = await this.getAllFromIndex('reactions', 'messageId', messageId); + const target = all.find((r) => r.userId === userId && r.emoji === emoji); + if (target) await this.delete('reactions', target.id); + } + + async getReactionsForMessage(messageId: string): Promise { + return this.getAllFromIndex('reactions', 'messageId', messageId); + } + + /* ------------------------------------------------------------------ */ + /* Users */ + /* ------------------------------------------------------------------ */ + + async saveUser(user: User): Promise { + await this.put('users', user); + } + + async getUser(userId: string): Promise { + return (await this.get('users', userId)) ?? null; + } + + async getCurrentUser(): Promise { + const meta = await this.get<{ id: string; value: string }>('meta', 'currentUserId'); + if (!meta) return null; + return this.getUser(meta.value); + } + + async setCurrentUserId(userId: string): Promise { + await this.put('meta', { id: 'currentUserId', value: userId }); + } + + async getUsersByRoom(_roomId: string): Promise { + return this.getAll('users'); + } + + async updateUser(userId: string, updates: Partial): Promise { + const user = await this.get('users', userId); + if (user) await this.put('users', { ...user, ...updates }); + } + + /* ------------------------------------------------------------------ */ + /* Rooms */ + /* ------------------------------------------------------------------ */ + + async saveRoom(room: Room): Promise { + await this.put('rooms', room); + } + + async getRoom(roomId: string): Promise { + return (await this.get('rooms', roomId)) ?? null; + } + + async getAllRooms(): Promise { + return this.getAll('rooms'); + } + + async deleteRoom(roomId: string): Promise { + await this.delete('rooms', roomId); + await this.clearRoomMessages(roomId); + } + + async updateRoom(roomId: string, updates: Partial): Promise { + const room = await this.get('rooms', roomId); + if (room) await this.put('rooms', { ...room, ...updates }); + } + + /* ------------------------------------------------------------------ */ + /* Bans */ + /* ------------------------------------------------------------------ */ + + async saveBan(ban: BanEntry): Promise { + await this.put('bans', ban); + } + + async removeBan(oderId: string): Promise { + const all = await this.getAll('bans'); + const match = all.find((b) => b.oderId === oderId); + if (match) await this.delete('bans', (match as any).id ?? match.oderId); + } + + async getBansForRoom(roomId: string): Promise { + const all = await this.getAllFromIndex('bans', 'roomId', roomId); + const now = Date.now(); + return all.filter((b) => !b.expiresAt || b.expiresAt > now); + } + + async isUserBanned(userId: string, roomId: string): Promise { + const bans = await this.getBansForRoom(roomId); + return bans.some((b) => b.oderId === userId); + } + + /* ------------------------------------------------------------------ */ + /* Attachments */ + /* ------------------------------------------------------------------ */ + + async saveAttachment(attachment: any): Promise { + await this.put('attachments', attachment); + } + + async getAttachmentsForMessage(messageId: string): Promise { + return this.getAllFromIndex('attachments', 'messageId', messageId); + } + + async getAllAttachments(): Promise { + return this.getAll('attachments'); + } + + async deleteAttachmentsForMessage(messageId: string): Promise { + const atts = await this.getAllFromIndex('attachments', 'messageId', messageId); + if (atts.length === 0) return; + const tx = this.transaction('attachments', 'readwrite'); + for (const a of atts) tx.objectStore('attachments').delete(a.id); + await this.complete(tx); + } + + async clearAllData(): Promise { + const storeNames: string[] = ['messages', 'users', 'rooms', 'reactions', 'bans', 'attachments', 'meta']; + const tx = this.transaction(storeNames, 'readwrite'); + for (const name of storeNames) tx.objectStore(name).clear(); + await this.complete(tx); + } + + /* ================================================================== */ + /* Private helpers – thin wrappers around IndexedDB */ + /* ================================================================== */ + + private openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains('messages')) { + const msgs = db.createObjectStore('messages', { keyPath: 'id' }); + msgs.createIndex('roomId', 'roomId', { unique: false }); + } + if (!db.objectStoreNames.contains('users')) { + db.createObjectStore('users', { keyPath: 'id' }); + } + if (!db.objectStoreNames.contains('rooms')) { + db.createObjectStore('rooms', { keyPath: 'id' }); + } + if (!db.objectStoreNames.contains('reactions')) { + const rxns = db.createObjectStore('reactions', { keyPath: 'id' }); + rxns.createIndex('messageId', 'messageId', { unique: false }); + } + if (!db.objectStoreNames.contains('bans')) { + const bans = db.createObjectStore('bans', { keyPath: 'oderId' }); + bans.createIndex('roomId', 'roomId', { unique: false }); + } + if (!db.objectStoreNames.contains('meta')) { + db.createObjectStore('meta', { keyPath: 'id' }); + } + if (!db.objectStoreNames.contains('attachments')) { + const atts = db.createObjectStore('attachments', { keyPath: 'id' }); + atts.createIndex('messageId', 'messageId', { unique: false }); + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + private transaction( + stores: string | string[], + mode: IDBTransactionMode = 'readonly', + ): IDBTransaction { + return this.db!.transaction(stores, mode); + } + + private complete(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + private get(store: string, key: IDBValidKey): Promise { + return new Promise((resolve, reject) => { + const tx = this.transaction(store); + const req = tx.objectStore(store).get(key); + req.onsuccess = () => resolve(req.result as T | undefined); + req.onerror = () => reject(req.error); + }); + } + + private getAll(store: string): Promise { + return new Promise((resolve, reject) => { + const tx = this.transaction(store); + const req = tx.objectStore(store).getAll(); + req.onsuccess = () => resolve(req.result as T[]); + req.onerror = () => reject(req.error); + }); + } + + private getAllFromIndex( + store: string, + indexName: string, + key: IDBValidKey, + ): Promise { + return new Promise((resolve, reject) => { + const tx = this.transaction(store); + const idx = tx.objectStore(store).index(indexName); + const req = idx.getAll(key); + req.onsuccess = () => resolve(req.result as T[]); + req.onerror = () => reject(req.error); + }); + } + + private put(store: string, value: any): Promise { + return new Promise((resolve, reject) => { + const tx = this.transaction(store, 'readwrite'); + tx.objectStore(store).put(value); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + private delete(store: string, key: IDBValidKey): Promise { + return new Promise((resolve, reject) => { + const tx = this.transaction(store, 'readwrite'); + tx.objectStore(store).delete(key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } +} diff --git a/src/app/core/services/database.service.ts b/src/app/core/services/database.service.ts index d66d540..d4a0840 100644 --- a/src/app/core/services/database.service.ts +++ b/src/app/core/services/database.service.ts @@ -1,230 +1,105 @@ -import { Injectable, signal } from '@angular/core'; +import { inject, Injectable, signal } from '@angular/core'; import { Message, User, Room, Reaction, BanEntry } from '../models'; +import { PlatformService } from './platform.service'; +import { BrowserDatabaseService } from './browser-database.service'; +import { ElectronDatabaseService } from './electron-database.service'; /** - * Database service using localStorage for persistence. - * In a production Electron app, this would use sql.js with file system access. + * Facade database service. + * + * - **Electron** → delegates to {@link ElectronDatabaseService} which + * persists data in a local SQLite file (via sql.js + Electron IPC). + * - **Browser** → delegates to {@link BrowserDatabaseService} which + * persists data in IndexedDB. + * + * All consumers keep injecting `DatabaseService` – the underlying storage + * engine is selected automatically at startup. */ @Injectable({ providedIn: 'root', }) export class DatabaseService { - private readonly STORAGE_PREFIX = 'metoyou_'; + private readonly platform = inject(PlatformService); + private readonly browserDb = inject(BrowserDatabaseService); + private readonly electronDb = inject(ElectronDatabaseService); isReady = signal(false); + /** The active backend for the current platform. */ + private get backend() { + return this.platform.isBrowser ? this.browserDb : this.electronDb; + } + + /* ------------------------------------------------------------------ */ + /* Lifecycle */ + /* ------------------------------------------------------------------ */ + async initialize(): Promise { - // Initialize storage structure if needed - if (!localStorage.getItem(this.key('initialized'))) { - this.initializeStorage(); - } + await this.backend.initialize(); this.isReady.set(true); } - private initializeStorage(): void { - localStorage.setItem(this.key('messages'), JSON.stringify([])); - localStorage.setItem(this.key('users'), JSON.stringify([])); - localStorage.setItem(this.key('rooms'), JSON.stringify([])); - localStorage.setItem(this.key('reactions'), JSON.stringify([])); - localStorage.setItem(this.key('bans'), JSON.stringify([])); - localStorage.setItem(this.key('initialized'), 'true'); - } + /* ------------------------------------------------------------------ */ + /* Messages */ + /* ------------------------------------------------------------------ */ - private key(name: string): string { - return this.STORAGE_PREFIX + name; - } + saveMessage(message: Message) { return this.backend.saveMessage(message); } + getMessages(roomId: string, limit = 100, offset = 0) { return this.backend.getMessages(roomId, limit, offset); } + deleteMessage(messageId: string) { return this.backend.deleteMessage(messageId); } + updateMessage(messageId: string, updates: Partial) { return this.backend.updateMessage(messageId, updates); } + getMessageById(messageId: string) { return this.backend.getMessageById(messageId); } + clearRoomMessages(roomId: string) { return this.backend.clearRoomMessages(roomId); } - private getArray(key: string): T[] { - const data = localStorage.getItem(this.key(key)); - return data ? JSON.parse(data) : []; - } + /* ------------------------------------------------------------------ */ + /* Reactions */ + /* ------------------------------------------------------------------ */ - private setArray(key: string, data: T[]): void { - localStorage.setItem(this.key(key), JSON.stringify(data)); - } + saveReaction(reaction: Reaction) { return this.backend.saveReaction(reaction); } + removeReaction(messageId: string, userId: string, emoji: string) { return this.backend.removeReaction(messageId, userId, emoji); } + getReactionsForMessage(messageId: string) { return this.backend.getReactionsForMessage(messageId); } - // Messages - async saveMessage(message: Message): Promise { - const messages = this.getArray('messages'); - const index = messages.findIndex((m) => m.id === message.id); - if (index >= 0) { - messages[index] = message; - } else { - messages.push(message); - } - this.setArray('messages', messages); - } + /* ------------------------------------------------------------------ */ + /* Users */ + /* ------------------------------------------------------------------ */ - async getMessages(roomId: string, limit = 100, offset = 0): Promise { - const messages = this.getArray('messages'); - return messages - .filter((m) => m.roomId === roomId) - .sort((a, b) => a.timestamp - b.timestamp) - .slice(offset, offset + limit); - } + saveUser(user: User) { return this.backend.saveUser(user); } + getUser(userId: string) { return this.backend.getUser(userId); } + getCurrentUser() { return this.backend.getCurrentUser(); } + setCurrentUserId(userId: string) { return this.backend.setCurrentUserId(userId); } + getUsersByRoom(roomId: string) { return this.backend.getUsersByRoom(roomId); } + updateUser(userId: string, updates: Partial) { return this.backend.updateUser(userId, updates); } - async deleteMessage(messageId: string): Promise { - const messages = this.getArray('messages'); - const filtered = messages.filter((m) => m.id !== messageId); - this.setArray('messages', filtered); - } + /* ------------------------------------------------------------------ */ + /* Rooms */ + /* ------------------------------------------------------------------ */ - async updateMessage(messageId: string, updates: Partial): Promise { - const messages = this.getArray('messages'); - const index = messages.findIndex((m) => m.id === messageId); - if (index >= 0) { - messages[index] = { ...messages[index], ...updates }; - this.setArray('messages', messages); - } - } + saveRoom(room: Room) { return this.backend.saveRoom(room); } + getRoom(roomId: string) { return this.backend.getRoom(roomId); } + getAllRooms() { return this.backend.getAllRooms(); } + deleteRoom(roomId: string) { return this.backend.deleteRoom(roomId); } + updateRoom(roomId: string, updates: Partial) { return this.backend.updateRoom(roomId, updates); } - async getMessageById(messageId: string): Promise { - const messages = this.getArray('messages'); - return messages.find((m) => m.id === messageId) || null; - } + /* ------------------------------------------------------------------ */ + /* Bans */ + /* ------------------------------------------------------------------ */ - async clearRoomMessages(roomId: string): Promise { - const messages = this.getArray('messages'); - const filtered = messages.filter((m) => m.roomId !== roomId); - this.setArray('messages', filtered); - } + saveBan(ban: BanEntry) { return this.backend.saveBan(ban); } + removeBan(oderId: string) { return this.backend.removeBan(oderId); } + getBansForRoom(roomId: string) { return this.backend.getBansForRoom(roomId); } + isUserBanned(userId: string, roomId: string) { return this.backend.isUserBanned(userId, roomId); } - // Reactions - async saveReaction(reaction: Reaction): Promise { - const reactions = this.getArray('reactions'); - const exists = reactions.some( - (r) => - r.messageId === reaction.messageId && - r.userId === reaction.userId && - r.emoji === reaction.emoji - ); - if (!exists) { - reactions.push(reaction); - this.setArray('reactions', reactions); - } - } + /* ------------------------------------------------------------------ */ + /* Attachments */ + /* ------------------------------------------------------------------ */ - async removeReaction(messageId: string, userId: string, emoji: string): Promise { - const reactions = this.getArray('reactions'); - const filtered = reactions.filter( - (r) => !(r.messageId === messageId && r.userId === userId && r.emoji === emoji) - ); - this.setArray('reactions', filtered); - } + saveAttachment(attachment: any) { return this.backend.saveAttachment(attachment); } + getAttachmentsForMessage(messageId: string) { return this.backend.getAttachmentsForMessage(messageId); } + getAllAttachments() { return this.backend.getAllAttachments(); } + deleteAttachmentsForMessage(messageId: string) { return this.backend.deleteAttachmentsForMessage(messageId); } - async getReactionsForMessage(messageId: string): Promise { - const reactions = this.getArray('reactions'); - return reactions.filter((r) => r.messageId === messageId); - } + /* ------------------------------------------------------------------ */ + /* Utilities */ + /* ------------------------------------------------------------------ */ - // Users - async saveUser(user: User): Promise { - const users = this.getArray('users'); - const index = users.findIndex((u) => u.id === user.id); - if (index >= 0) { - users[index] = user; - } else { - users.push(user); - } - this.setArray('users', users); - } - - async getUser(userId: string): Promise { - const users = this.getArray('users'); - return users.find((u) => u.id === userId) || null; - } - - async getCurrentUser(): Promise { - const currentUserId = localStorage.getItem(this.key('currentUserId')); - if (!currentUserId) return null; - return this.getUser(currentUserId); - } - - async setCurrentUserId(userId: string): Promise { - localStorage.setItem(this.key('currentUserId'), userId); - } - - async getUsersByRoom(roomId: string): Promise { - return this.getArray('users'); - } - - async updateUser(userId: string, updates: Partial): Promise { - const users = this.getArray('users'); - const index = users.findIndex((u) => u.id === userId); - if (index >= 0) { - users[index] = { ...users[index], ...updates }; - this.setArray('users', users); - } - } - - // Rooms - async saveRoom(room: Room): Promise { - const rooms = this.getArray('rooms'); - const index = rooms.findIndex((r) => r.id === room.id); - if (index >= 0) { - rooms[index] = room; - } else { - rooms.push(room); - } - this.setArray('rooms', rooms); - } - - async getRoom(roomId: string): Promise { - const rooms = this.getArray('rooms'); - return rooms.find((r) => r.id === roomId) || null; - } - - async getAllRooms(): Promise { - return this.getArray('rooms'); - } - - async deleteRoom(roomId: string): Promise { - const rooms = this.getArray('rooms'); - const filtered = rooms.filter((r) => r.id !== roomId); - this.setArray('rooms', filtered); - await this.clearRoomMessages(roomId); - } - - async updateRoom(roomId: string, updates: Partial): Promise { - const rooms = this.getArray('rooms'); - const index = rooms.findIndex((r) => r.id === roomId); - if (index >= 0) { - rooms[index] = { ...rooms[index], ...updates }; - this.setArray('rooms', rooms); - } - } - - // Bans - async saveBan(ban: BanEntry): Promise { - const bans = this.getArray('bans'); - bans.push(ban); - this.setArray('bans', bans); - } - - async removeBan(oderId: string): Promise { - const bans = this.getArray('bans'); - const filtered = bans.filter((b) => b.oderId !== oderId); - this.setArray('bans', filtered); - } - - async getBansForRoom(roomId: string): Promise { - const bans = this.getArray('bans'); - const now = Date.now(); - return bans.filter( - (b) => b.roomId === roomId && (!b.expiresAt || b.expiresAt > now) - ); - } - - async isUserBanned(userId: string, roomId: string): Promise { - const bans = await this.getBansForRoom(roomId); - return bans.some((b) => b.oderId === userId); - } - - async clearAllData(): Promise { - const keys = Object.keys(localStorage).filter((k) => - k.startsWith(this.STORAGE_PREFIX) - ); - keys.forEach((k) => localStorage.removeItem(k)); - this.initializeStorage(); - } + clearAllData() { return this.backend.clearAllData(); } } diff --git a/src/app/core/services/electron-database.service.ts b/src/app/core/services/electron-database.service.ts new file mode 100644 index 0000000..83f13df --- /dev/null +++ b/src/app/core/services/electron-database.service.ts @@ -0,0 +1,173 @@ +import { Injectable } from '@angular/core'; +import { Message, User, Room, Reaction, BanEntry } from '../models'; + +/** + * Database service for the Electron (desktop) runtime. + * + * All SQLite queries run in the Electron **main process** + * (`electron/database.js`). This service is a thin IPC client that + * delegates every operation to `window.electronAPI.db.*`. + */ +@Injectable({ providedIn: 'root' }) +export class ElectronDatabaseService { + private initialized = false; + + /** Shorthand for the preload-exposed database API. */ + private get api() { + return (window as any).electronAPI.db; + } + + /* ------------------------------------------------------------------ */ + /* Lifecycle */ + /* ------------------------------------------------------------------ */ + + async initialize(): Promise { + if (this.initialized) return; + await this.api.initialize(); + this.initialized = true; + } + + /* ------------------------------------------------------------------ */ + /* Messages */ + /* ------------------------------------------------------------------ */ + + saveMessage(message: Message): Promise { + return this.api.saveMessage(message); + } + + getMessages(roomId: string, limit = 100, offset = 0): Promise { + return this.api.getMessages(roomId, limit, offset); + } + + deleteMessage(messageId: string): Promise { + return this.api.deleteMessage(messageId); + } + + updateMessage(messageId: string, updates: Partial): Promise { + return this.api.updateMessage(messageId, updates); + } + + getMessageById(messageId: string): Promise { + return this.api.getMessageById(messageId); + } + + clearRoomMessages(roomId: string): Promise { + return this.api.clearRoomMessages(roomId); + } + + /* ------------------------------------------------------------------ */ + /* Reactions */ + /* ------------------------------------------------------------------ */ + + saveReaction(reaction: Reaction): Promise { + return this.api.saveReaction(reaction); + } + + removeReaction(messageId: string, userId: string, emoji: string): Promise { + return this.api.removeReaction(messageId, userId, emoji); + } + + getReactionsForMessage(messageId: string): Promise { + return this.api.getReactionsForMessage(messageId); + } + + /* ------------------------------------------------------------------ */ + /* Users */ + /* ------------------------------------------------------------------ */ + + saveUser(user: User): Promise { + return this.api.saveUser(user); + } + + getUser(userId: string): Promise { + return this.api.getUser(userId); + } + + getCurrentUser(): Promise { + return this.api.getCurrentUser(); + } + + setCurrentUserId(userId: string): Promise { + return this.api.setCurrentUserId(userId); + } + + getUsersByRoom(roomId: string): Promise { + return this.api.getUsersByRoom(roomId); + } + + updateUser(userId: string, updates: Partial): Promise { + return this.api.updateUser(userId, updates); + } + + /* ------------------------------------------------------------------ */ + /* Rooms */ + /* ------------------------------------------------------------------ */ + + saveRoom(room: Room): Promise { + return this.api.saveRoom(room); + } + + getRoom(roomId: string): Promise { + return this.api.getRoom(roomId); + } + + getAllRooms(): Promise { + return this.api.getAllRooms(); + } + + deleteRoom(roomId: string): Promise { + return this.api.deleteRoom(roomId); + } + + updateRoom(roomId: string, updates: Partial): Promise { + return this.api.updateRoom(roomId, updates); + } + + /* ------------------------------------------------------------------ */ + /* Bans */ + /* ------------------------------------------------------------------ */ + + saveBan(ban: BanEntry): Promise { + return this.api.saveBan(ban); + } + + removeBan(oderId: string): Promise { + return this.api.removeBan(oderId); + } + + getBansForRoom(roomId: string): Promise { + return this.api.getBansForRoom(roomId); + } + + isUserBanned(userId: string, roomId: string): Promise { + return this.api.isUserBanned(userId, roomId); + } + + /* ------------------------------------------------------------------ */ + /* Attachments */ + /* ------------------------------------------------------------------ */ + + saveAttachment(attachment: any): Promise { + return this.api.saveAttachment(attachment); + } + + getAttachmentsForMessage(messageId: string): Promise { + return this.api.getAttachmentsForMessage(messageId); + } + + getAllAttachments(): Promise { + return this.api.getAllAttachments(); + } + + deleteAttachmentsForMessage(messageId: string): Promise { + return this.api.deleteAttachmentsForMessage(messageId); + } + + /* ------------------------------------------------------------------ */ + /* Utilities */ + /* ------------------------------------------------------------------ */ + + clearAllData(): Promise { + return this.api.clearAllData(); + } +} diff --git a/src/app/core/services/index.ts b/src/app/core/services/index.ts index 190f939..2e1a67e 100644 --- a/src/app/core/services/index.ts +++ b/src/app/core/services/index.ts @@ -1,3 +1,6 @@ +export * from './platform.service'; +export * from './browser-database.service'; +export * from './electron-database.service'; export * from './database.service'; export * from './webrtc.service'; export * from './server-directory.service'; diff --git a/src/app/core/services/platform.service.ts b/src/app/core/services/platform.service.ts new file mode 100644 index 0000000..5a39a2a --- /dev/null +++ b/src/app/core/services/platform.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; + +/** + * Detects the runtime platform so other services can branch behaviour + * between Electron (desktop) and a plain browser tab. + */ +@Injectable({ providedIn: 'root' }) +export class PlatformService { + /** True when the app is hosted inside an Electron renderer process. */ + readonly isElectron: boolean; + + /** True when the app is running in an ordinary browser (no Electron shell). */ + readonly isBrowser: boolean; + + constructor() { + this.isElectron = + typeof window !== 'undefined' && !!(window as any).electronAPI; + this.isBrowser = !this.isElectron; + } +} diff --git a/src/app/core/services/server-directory.service.ts b/src/app/core/services/server-directory.service.ts index 891d348..15a6a9e 100644 --- a/src/app/core/services/server-directory.service.ts +++ b/src/app/core/services/server-directory.service.ts @@ -3,6 +3,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable, of, throwError, forkJoin } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; import { ServerInfo, JoinRequest, User } from '../models'; +import { v4 as uuidv4 } from 'uuid'; export interface ServerEndpoint { id: string; @@ -15,9 +16,19 @@ export interface ServerEndpoint { } const STORAGE_KEY = 'metoyou_server_endpoints'; + +/** Derive default server URL from current page protocol (handles SSL toggle). */ +function getDefaultServerUrl(): string { + if (typeof window !== 'undefined' && window.location) { + const proto = window.location.protocol === 'https:' ? 'https' : 'http'; + return `${proto}://localhost:3001`; + } + return 'http://localhost:3001'; +} + const DEFAULT_SERVER: Omit = { name: 'Local Server', - url: 'http://localhost:3001', + url: getDefaultServerUrl(), isActive: true, isDefault: true, status: 'unknown', @@ -41,12 +52,21 @@ export class ServerDirectoryService { const stored = localStorage.getItem(STORAGE_KEY); if (stored) { try { - const servers = JSON.parse(stored) as ServerEndpoint[]; + let servers = JSON.parse(stored) as ServerEndpoint[]; // Ensure at least one is active if (!servers.some((s) => s.isActive) && servers.length > 0) { servers[0].isActive = true; } + // Migrate default localhost entries to match current protocol + const expectedProto = (typeof window !== 'undefined' && window.location?.protocol === 'https:') ? 'https' : 'http'; + servers = servers.map((s) => { + if (s.isDefault && /^https?:\/\/localhost:\d+$/.test(s.url)) { + return { ...s, url: s.url.replace(/^https?/, expectedProto) }; + } + return s; + }); this._servers.set(servers); + this.saveServers(); } catch { this.initializeDefaultServer(); } @@ -58,7 +78,7 @@ export class ServerDirectoryService { private initializeDefaultServer(): void { const defaultServer: ServerEndpoint = { ...DEFAULT_SERVER, - id: crypto.randomUUID(), + id: uuidv4(), }; this._servers.set([defaultServer]); this.saveServers(); @@ -70,7 +90,7 @@ export class ServerDirectoryService { private get baseUrl(): string { const active = this.activeServer(); - const raw = active ? active.url : 'http://localhost:3001'; + const raw = active ? active.url : getDefaultServerUrl(); // Strip trailing slashes and any accidental '/api' let base = raw.replace(/\/+$/,''); if (base.toLowerCase().endsWith('/api')) { @@ -87,7 +107,7 @@ export class ServerDirectoryService { // Server management methods addServer(server: { name: string; url: string }): void { const newServer: ServerEndpoint = { - id: crypto.randomUUID(), + id: uuidv4(), name: server.name, // Sanitize: remove trailing slashes and any '/api' url: (() => { @@ -396,7 +416,10 @@ export class ServerDirectoryService { // Get the WebSocket URL for the active server getWebSocketUrl(): string { const active = this.activeServer(); - if (!active) return 'ws://localhost:3001'; + if (!active) { + const proto = (typeof window !== 'undefined' && window.location?.protocol === 'https:') ? 'wss' : 'ws'; + return `${proto}://localhost:3001`; + } // Convert http(s) to ws(s) return active.url.replace(/^http/, 'ws'); diff --git a/src/app/core/services/webrtc.service.ts b/src/app/core/services/webrtc.service.ts index 7158efe..9537b55 100644 --- a/src/app/core/services/webrtc.service.ts +++ b/src/app/core/services/webrtc.service.ts @@ -1,54 +1,70 @@ -import { Injectable, signal, computed, inject } from '@angular/core'; +/** + * WebRTCService — thin Angular service that composes specialised managers. + * + * Each concern lives in its own file under `./webrtc/`: + * • SignalingManager – WebSocket lifecycle & reconnection + * • PeerConnectionManager – RTCPeerConnection, offers/answers, ICE, data channels + * • MediaManager – mic voice, mute, deafen, bitrate + * • ScreenShareManager – screen capture & mixed audio + * • WebRTCLogger – debug / diagnostic logging + * + * This file wires them together and exposes a public API that is + * identical to the old monolithic service so consumers don't change. + */ +import { Injectable, signal, computed, inject, OnDestroy } from '@angular/core'; import { Observable, Subject } from 'rxjs'; import { v4 as uuidv4 } from 'uuid'; import { SignalingMessage, ChatEvent } from '../models'; import { TimeSyncService } from './time-sync.service'; -// ICE server configuration for NAT traversal -const ICE_SERVERS: RTCIceServer[] = [ - { urls: 'stun:stun.l.google.com:19302' }, - { urls: 'stun:stun1.l.google.com:19302' }, - { urls: 'stun:stun2.l.google.com:19302' }, - { urls: 'stun:stun3.l.google.com:19302' }, - { urls: 'stun:stun4.l.google.com:19302' }, -]; - -interface PeerData { - connection: RTCPeerConnection; - dataChannel: RTCDataChannel | null; - isInitiator: boolean; - pendingCandidates: RTCIceCandidateInit[]; - audioSender?: RTCRtpSender; - videoSender?: RTCRtpSender; - screenVideoSender?: RTCRtpSender; - screenAudioSender?: RTCRtpSender; -} +import { + // Managers + SignalingManager, + PeerConnectionManager, + MediaManager, + ScreenShareManager, + WebRTCLogger, + // Types + IdentifyCredentials, + JoinedServerInfo, + VoiceStateSnapshot, + LatencyProfile, + // Constants + SIGNALING_TYPE_IDENTIFY, + SIGNALING_TYPE_JOIN_SERVER, + SIGNALING_TYPE_VIEW_SERVER, + SIGNALING_TYPE_LEAVE_SERVER, + SIGNALING_TYPE_OFFER, + SIGNALING_TYPE_ANSWER, + SIGNALING_TYPE_ICE_CANDIDATE, + SIGNALING_TYPE_CONNECTED, + SIGNALING_TYPE_SERVER_USERS, + SIGNALING_TYPE_USER_JOINED, + SIGNALING_TYPE_USER_LEFT, + DEFAULT_DISPLAY_NAME, + P2P_TYPE_VOICE_STATE, + P2P_TYPE_SCREEN_STATE, +} from './webrtc'; @Injectable({ providedIn: 'root', }) -export class WebRTCService { - private timeSync = inject(TimeSyncService); - private peers = new Map(); - private localStream: MediaStream | null = null; - private _screenStream: MediaStream | null = null; - private remoteStreams = new Map(); - private signalingSocket: WebSocket | null = null; - private lastWsUrl: string | null = null; - private reconnectAttempts = 0; - private reconnectTimer: any = null; - private heartbeatTimer: any = null; - private destroy$ = new Subject(); - private outputVolume = 1; - private currentServerId: string | null = null; - private lastIdentify: { oderId: string; displayName: string } | null = null; - private lastJoin: { serverId: string; userId: string } | null = null; - // Track all servers the user has joined (for multi-server membership) - private joinedServerIds = new Set(); +export class WebRTCService implements OnDestroy { + private readonly timeSync = inject(TimeSyncService); - // Signals for reactive state - private readonly _peerId = signal(uuidv4()); - private readonly _isConnected = signal(false); + // ─── Logger ──────────────────────────────────────────────────────── + private readonly logger = new WebRTCLogger(/* debugEnabled */ true); + + // ─── Identity & server membership ────────────────────────────────── + private lastIdentifyCredentials: IdentifyCredentials | null = null; + private lastJoinedServer: JoinedServerInfo | null = null; + private readonly memberServerIds = new Set(); + private activeServerId: string | null = null; + private readonly serviceDestroyed$ = new Subject(); + + // ─── Angular signals (reactive state) ────────────────────────────── + private readonly _localPeerId = signal(uuidv4()); + private readonly _isSignalingConnected = signal(false); private readonly _isVoiceConnected = signal(false); private readonly _connectedPeers = signal([]); private readonly _isMuted = signal(false); @@ -57,76 +73,10 @@ export class WebRTCService { private readonly _screenStreamSignal = signal(null); private readonly _hasConnectionError = signal(false); private readonly _connectionErrorMessage = signal(null); - private mixedAudioStream: MediaStream | null = null; - private audioContext: AudioContext | null = null; - private mixedAudioSender?: RTCRtpSender; - private p2pReconnectTimers = new Map>(); - private disconnectedPeers = new Map(); - private voiceHeartbeatTimer: ReturnType | null = null; - // Debug logging toggle (can be extended to read from settings/localStorage) - private readonly debugLogsEnabled = true; - - // Lightweight logging helpers - private log(prefix: string, ...args: any[]): void { - if (!this.debugLogsEnabled) return; - try { console.log(`[WebRTC] ${prefix}`, ...args); } catch {} - } - - private warn(prefix: string, ...args: any[]): void { - if (!this.debugLogsEnabled) return; - try { console.warn(`[WebRTC] ${prefix}`, ...args); } catch {} - } - - private error(prefix: string, err: any, extra?: Record): void { - const payload = { - name: err?.name, - message: err?.message, - stack: err?.stack, - ...extra, - }; - try { console.error(`[WebRTC] ${prefix}`, payload); } catch {} - } - - // Attach diagnostics to a track: settings + lifecycle events - private attachTrackDiagnostics(track: MediaStreamTrack, label: string): void { - const settings = typeof track.getSettings === 'function' ? track.getSettings() : {} as any; - this.log(`Track attached: ${label}`, { - id: track.id, - kind: track.kind, - readyState: track.readyState, - contentHint: track.contentHint, - settings, - }); - const onended = () => this.warn(`Track ended: ${label}`, { id: track.id, kind: track.kind }); - const onmute = () => this.warn(`Track muted: ${label}`, { id: track.id, kind: track.kind }); - const onunmute = () => this.log(`Track unmuted: ${label}`, { id: track.id, kind: track.kind }); - track.addEventListener('ended', onended as any); - track.addEventListener('mute', onmute as any); - track.addEventListener('unmute', onunmute as any); - } - - // Log a media stream and attach diagnostics to its tracks - private logStream(label: string, stream: MediaStream | null): void { - if (!stream) { - this.warn(`Stream missing: ${label}`); - return; - } - const audioTracks = stream.getAudioTracks(); - const videoTracks = stream.getVideoTracks(); - this.log(`Stream ready: ${label}`, { - id: (stream as any).id, - audioCount: audioTracks.length, - videoCount: videoTracks.length, - allTrackIds: stream.getTracks().map(t => ({ id: t.id, kind: t.kind })), - }); - audioTracks.forEach((t, i) => this.attachTrackDiagnostics(t, `${label}:audio#${i}`)); - videoTracks.forEach((t, i) => this.attachTrackDiagnostics(t, `${label}:video#${i}`)); - } - - // Public computed signals - readonly peerId = computed(() => this._peerId()); - readonly isConnected = computed(() => this._isConnected()); + // Public computed signals (unchanged external API) + readonly peerId = computed(() => this._localPeerId()); + readonly isConnected = computed(() => this._isSignalingConnected()); readonly isVoiceConnected = computed(() => this._isVoiceConnected()); readonly connectedPeers = computed(() => this._connectedPeers()); readonly isMuted = computed(() => this._isMuted()); @@ -135,1426 +85,380 @@ export class WebRTCService { readonly screenStream = computed(() => this._screenStreamSignal()); readonly hasConnectionError = computed(() => this._hasConnectionError()); readonly connectionErrorMessage = computed(() => this._connectionErrorMessage()); - // Bypass connection error if we have at least one P2P peer connected while in voice readonly shouldShowConnectionError = computed(() => { if (!this._hasConnectionError()) return false; - // If we're in voice and have at least one connected peer, bypass the error display if (this._isVoiceConnected() && this._connectedPeers().length > 0) return false; return true; }); - // Subjects for events - private readonly messageReceived$ = new Subject(); - private readonly peerConnected$ = new Subject(); - private readonly peerDisconnected$ = new Subject(); + // ─── Public observables (unchanged external API) ─────────────────── private readonly signalingMessage$ = new Subject(); - private readonly remoteStream$ = new Subject<{ peerId: string; stream: MediaStream }>(); - private readonly voiceConnected$ = new Subject(); - - // Public observables - readonly onMessageReceived = this.messageReceived$.asObservable(); - readonly onPeerConnected = this.peerConnected$.asObservable(); - readonly onPeerDisconnected = this.peerDisconnected$.asObservable(); readonly onSignalingMessage = this.signalingMessage$.asObservable(); - readonly onRemoteStream = this.remoteStream$.asObservable(); - readonly onVoiceConnected = this.voiceConnected$.asObservable(); - // Accessor for remote screen/media streams by peer ID - getRemoteStream(peerId: string): MediaStream | null { - return this.remoteStreams.get(peerId) ?? null; + // Delegates to managers + get onMessageReceived(): Observable { return this.peerManager.messageReceived$.asObservable(); } + get onPeerConnected(): Observable { return this.peerManager.peerConnected$.asObservable(); } + get onPeerDisconnected(): Observable { return this.peerManager.peerDisconnected$.asObservable(); } + get onRemoteStream(): Observable<{ peerId: string; stream: MediaStream }> { return this.peerManager.remoteStream$.asObservable(); } + get onVoiceConnected(): Observable { return this.mediaManager.voiceConnected$.asObservable(); } + + // ─── Sub-managers ────────────────────────────────────────────────── + + private readonly signalingManager: SignalingManager; + private readonly peerManager: PeerConnectionManager; + private readonly mediaManager: MediaManager; + private readonly screenShareManager: ScreenShareManager; + + constructor() { + // Create managers with null callbacks first to break circular initialization + this.signalingManager = new SignalingManager( + this.logger, + () => this.lastIdentifyCredentials, + () => this.lastJoinedServer, + () => this.memberServerIds, + ); + + this.peerManager = new PeerConnectionManager( + this.logger, + null!, + ); + + this.mediaManager = new MediaManager( + this.logger, + null!, + ); + + this.screenShareManager = new ScreenShareManager( + this.logger, + null!, + ); + + // Now wire up cross-references (all managers are instantiated) + this.peerManager.setCallbacks({ + sendRawMessage: (msg: Record) => this.signalingManager.sendRawMessage(msg), + getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(), + isSignalingConnected: (): boolean => this._isSignalingConnected(), + getVoiceStateSnapshot: (): VoiceStateSnapshot => this.getCurrentVoiceState(), + getIdentifyCredentials: (): IdentifyCredentials | null => this.lastIdentifyCredentials, + getLocalPeerId: (): string => this._localPeerId(), + isScreenSharingActive: (): boolean => this._isScreenSharing(), + }); + + this.mediaManager.setCallbacks({ + getActivePeers: (): Map => this.peerManager.activePeerConnections, + renegotiate: (peerId: string): Promise => this.peerManager.renegotiate(peerId), + broadcastMessage: (event: any): void => this.peerManager.broadcastMessage(event), + getIdentifyOderId: (): string => this.lastIdentifyCredentials?.oderId || this._localPeerId(), + getIdentifyDisplayName: (): string => this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME, + }); + + this.screenShareManager.setCallbacks({ + getActivePeers: (): Map => this.peerManager.activePeerConnections, + getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(), + renegotiate: (peerId: string): Promise => this.peerManager.renegotiate(peerId), + broadcastCurrentStates: (): void => this.peerManager.broadcastCurrentStates(), + }); + + this.wireManagerEvents(); } - // Connect to signaling server - connectToSignalingServer(serverUrl: string): Observable { - this.lastWsUrl = serverUrl || this.lastWsUrl; - return new Observable((observer) => { - try { - // Close existing connection if any - if (this.signalingSocket) { - this.signalingSocket.close(); - } + // ─── Event wiring ────────────────────────────────────────────────── - this.lastWsUrl = serverUrl; - this.signalingSocket = new WebSocket(serverUrl); + private wireManagerEvents(): void { + // Signaling → connection status + this.signalingManager.connectionStatus$.subscribe(({ connected, errorMessage }) => { + this._isSignalingConnected.set(connected); + this._hasConnectionError.set(!connected); + this._connectionErrorMessage.set(connected ? null : (errorMessage ?? null)); + }); - this.signalingSocket.onopen = () => { - console.log('Connected to signaling server'); - this._isConnected.set(true); - this._hasConnectionError.set(false); - this._connectionErrorMessage.set(null); - this.clearReconnect(); - this.startHeartbeat(); - // Re-identify and rejoin if we have prior context - if (this.lastIdentify) { - this.sendRawMessage({ - type: 'identify', - oderId: this.lastIdentify.oderId, - displayName: this.lastIdentify.displayName, - }); - } - // Rejoin ALL servers the user was a member of (multi-server) - if (this.joinedServerIds.size > 0) { - this.joinedServerIds.forEach((sid) => { - this.sendRawMessage({ - type: 'join_server', - serverId: sid, - }); - }); - // Re-view the last viewed server - if (this.lastJoin) { - this.sendRawMessage({ - type: 'view_server', - serverId: this.lastJoin.serverId, - }); - } - } else if (this.lastJoin) { - this.sendRawMessage({ - type: 'join_server', - serverId: this.lastJoin.serverId, - }); - } - observer.next(true); - }; + // Signaling → message routing + this.signalingManager.messageReceived$.subscribe((msg) => this.handleSignalingMessage(msg)); - this.signalingSocket.onmessage = (event) => { - try { - const message = JSON.parse(event.data); - this.handleSignalingMessage(message); - } catch (error) { - console.error('Failed to parse signaling message:', error); - } - }; + // Signaling → heartbeat → broadcast states + this.signalingManager.heartbeatTick$.subscribe(() => this.peerManager.broadcastCurrentStates()); - this.signalingSocket.onerror = (error) => { - console.error('Signaling socket error:', error); - this._hasConnectionError.set(true); - this._connectionErrorMessage.set('Connection to signaling server failed'); - observer.error(error); - }; + // Peer manager → connected peers signal + this.peerManager.connectedPeersChanged$.subscribe((peers: string[]) => this._connectedPeers.set(peers)); - this.signalingSocket.onclose = () => { - console.log('Disconnected from signaling server'); - this._isConnected.set(false); - this._hasConnectionError.set(true); - this._connectionErrorMessage.set('Disconnected from signaling server'); - this.stopHeartbeat(); - this.scheduleReconnect(); - }; - } catch (error) { - observer.error(error); - } + // Media manager → voice connected signal + this.mediaManager.voiceConnected$.subscribe(() => { + this._isVoiceConnected.set(true); }); } - // Ensure signaling is connected before proceeding; tries reconnect if possible - async ensureSignalingConnected(timeoutMs: number = 5000): Promise { - if (this._isConnected()) return true; - if (!this.lastWsUrl) return false; - return new Promise((resolve) => { - let settled = false; - const timeout = setTimeout(() => { - if (!settled) { - settled = true; - resolve(false); - } - }, timeoutMs); - this.connectToSignalingServer(this.lastWsUrl!).subscribe({ - next: () => { - if (!settled) { - settled = true; - clearTimeout(timeout); - resolve(true); - } - }, - error: () => { - if (!settled) { - settled = true; - clearTimeout(timeout); - resolve(false); - } - }, - }); - }); - } + // ─── Signaling message routing ───────────────────────────────────── - // Send signaling message - sendSignalingMessage(message: Omit): void { - if (!this.signalingSocket || this.signalingSocket.readyState !== WebSocket.OPEN) { - console.error('Signaling socket not connected'); - return; - } - - const fullMessage: SignalingMessage = { - ...message, - from: this._peerId(), - timestamp: Date.now(), - }; - - this.signalingSocket.send(JSON.stringify(fullMessage)); - } - - // Send raw message to server (for identify, join_server, etc.) - sendRawMessage(message: Record): void { - if (!this.signalingSocket || this.signalingSocket.readyState !== WebSocket.OPEN) { - console.error('Signaling socket not connected'); - return; - } - this.signalingSocket.send(JSON.stringify(message)); - } - - // Create peer connection using native WebRTC - private createPeerConnection(remotePeerId: string, isInitiator: boolean): PeerData { - this.log(`Creating peer connection`, { remotePeerId, isInitiator }); - - const connection = new RTCPeerConnection({ iceServers: ICE_SERVERS }); - let dataChannel: RTCDataChannel | null = null; - - // Handle ICE candidates - connection.onicecandidate = (event) => { - if (event.candidate) { - this.log('ICE candidate gathered', { remotePeerId, candidateType: (event.candidate as any)?.type }); - this.sendRawMessage({ - type: 'ice_candidate', - targetUserId: remotePeerId, - payload: { candidate: event.candidate }, - }); - } - }; - - // Handle connection state changes - connection.onconnectionstatechange = () => { - this.log('connectionstatechange', { remotePeerId, state: connection.connectionState }); - if (connection.connectionState === 'connected') { - this._connectedPeers.update((peers) => - peers.includes(remotePeerId) ? peers : [...peers, remotePeerId] - ); - this.peerConnected$.next(remotePeerId); - // Clear any reconnection attempts for this peer - this.clearP2PReconnectTimer(remotePeerId); - this.disconnectedPeers.delete(remotePeerId); - // Request voice state from the newly connected peer - this.requestVoiceStateFromPeer(remotePeerId); - } else if (connection.connectionState === 'disconnected' || - connection.connectionState === 'failed') { - // Track the disconnected peer for reconnection - this.trackDisconnectedPeer(remotePeerId); - this.removePeer(remotePeerId); - // Start P2P reconnection attempts - this.scheduleP2PReconnect(remotePeerId); - } else if (connection.connectionState === 'closed') { - this.removePeer(remotePeerId); - } - }; - - // Additional state logs - connection.oniceconnectionstatechange = () => { - this.log('iceconnectionstatechange', { remotePeerId, state: connection.iceConnectionState }); - }; - connection.onsignalingstatechange = () => { - this.log('signalingstatechange', { remotePeerId, state: connection.signalingState }); - }; - connection.onnegotiationneeded = () => { - this.log('negotiationneeded', { remotePeerId }); - }; - - // Handle incoming tracks (audio/video) - connection.ontrack = (event) => { - const track = event.track; - const settings = typeof track.getSettings === 'function' ? track.getSettings() : {} as any; - this.log('Remote track', { remotePeerId, kind: track.kind, id: track.id, enabled: track.enabled, readyState: track.readyState, settings }); - this.attachTrackDiagnostics(track, `remote:${remotePeerId}:${track.kind}`); - - // Skip video tracks that are not live or enabled (e.g., from recvonly transceivers) - if (track.kind === 'video' && (!track.enabled || track.readyState !== 'live')) { - this.log('Skipping inactive video track', { remotePeerId, enabled: track.enabled, readyState: track.readyState }); - return; - } - - // Merge all incoming tracks into a single composite stream per peer - let composite = this.remoteStreams.get(remotePeerId) || new MediaStream(); - const alreadyHas = composite.getTracks().some(t => t.id === event.track.id); - if (!alreadyHas) { - try { composite.addTrack(event.track); } catch (e) { this.warn('Failed to add track to composite stream', e as any); } - } - this.remoteStreams.set(remotePeerId, composite); - this.remoteStream$.next({ peerId: remotePeerId, stream: composite }); - }; - - // If initiator, create data channel - if (isInitiator) { - dataChannel = connection.createDataChannel('chat', { ordered: true }); - this.setupDataChannel(dataChannel, remotePeerId); - } else { - // If not initiator, wait for data channel from remote peer - connection.ondatachannel = (event) => { - this.log('Received data channel', { remotePeerId }); - dataChannel = event.channel; - // Update the peer data with the new channel BEFORE setting up handlers - // This ensures sendCurrentStatesToPeer can access the channel in onopen - const existing = this.peers.get(remotePeerId); - if (existing) { - existing.dataChannel = dataChannel; - } - this.setupDataChannel(dataChannel, remotePeerId); - }; - } - - // Create and register peer data before adding local tracks - const peerData: PeerData = { - connection, - dataChannel, - isInitiator, - pendingCandidates: [], - audioSender: undefined, - videoSender: undefined, - }; - - // Only pre-create transceivers for the initiator (offerer). - // The answerer will get transceivers created automatically when setRemoteDescription is called. - // Pre-creating transceivers on the answerer side causes codec collisions (payload_type conflicts). - // Start audio in sendrecv (for voice) but video in recvonly (to avoid triggering screen share UI). - // Video direction will be changed to sendrecv when user actually starts screen sharing. - if (isInitiator) { - const audioTrx = connection.addTransceiver('audio', { direction: 'sendrecv' }); - const videoTrx = connection.addTransceiver('video', { direction: 'recvonly' }); - peerData.audioSender = audioTrx.sender; - peerData.videoSender = videoTrx.sender; - } - - this.peers.set(remotePeerId, peerData); - - // Add local stream if available (only for initiator - answerer will add after setRemoteDescription) - if (this.localStream && isInitiator) { - this.logStream(`localStream->${remotePeerId}`, this.localStream); - this.localStream.getTracks().forEach((track) => { - if (track.kind === 'audio' && peerData.audioSender) { - peerData.audioSender.replaceTrack(track) - .then(() => this.log('audio replaceTrack (init) ok', { remotePeerId })) - .catch((e) => this.error('audio replaceTrack failed at createPeerConnection', e)); - } else if (track.kind === 'video' && peerData.videoSender) { - peerData.videoSender.replaceTrack(track) - .then(() => this.log('video replaceTrack (init) ok', { remotePeerId })) - .catch((e) => this.error('video replaceTrack failed at createPeerConnection', e)); - } else { - // Fallback addTrack if sender missing - const sender = connection.addTrack(track, this.localStream!); - if (track.kind === 'audio') peerData.audioSender = sender; - if (track.kind === 'video') peerData.videoSender = sender; - } - }); - } - - return peerData; - } - - // Setup data channel event handlers - private setupDataChannel(channel: RTCDataChannel, remotePeerId: string): void { - channel.onopen = () => { - console.log(`Data channel open with ${remotePeerId}`); - // Send our current states and request theirs - // Use the channel directly instead of looking it up, in case peer data isn't fully registered yet - this.sendCurrentStatesToChannel(channel, remotePeerId); - try { - channel.send(JSON.stringify({ type: 'state-request' })); - } catch {} - }; - - channel.onclose = () => { - console.log(`Data channel closed with ${remotePeerId}`); - }; - - channel.onerror = (error) => { - console.error(`Data channel error with ${remotePeerId}:`, error); - }; - - channel.onmessage = (event) => { - try { - const message = JSON.parse(event.data); - this.handlePeerMessage(remotePeerId, message); - } catch (error) { - console.error('Failed to parse peer message:', error); - } - }; - } - - // Create and send offer - private async createOffer(remotePeerId: string): Promise { - const peerData = this.peers.get(remotePeerId); - if (!peerData) return; - - try { - const offer = await peerData.connection.createOffer(); - await peerData.connection.setLocalDescription(offer); - this.log('Sending offer', { remotePeerId, type: offer.type, sdpLen: offer.sdp?.length }); - this.sendRawMessage({ - type: 'offer', - targetUserId: remotePeerId, - payload: { sdp: offer }, - }); - } catch (error) { - this.error('Failed to create offer', error); - } - } - - // Handle incoming offer - private async handleOffer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise { - this.log('Handling offer', { fromUserId }); - - let peerData = this.peers.get(fromUserId); - if (!peerData) { - peerData = this.createPeerConnection(fromUserId, false); - } - - try { - await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp)); - - // After setRemoteDescription, transceivers are created by the browser. - // Find and store the senders for audio/video so we can add tracks later. - const transceivers = peerData.connection.getTransceivers(); - for (const trx of transceivers) { - if (trx.receiver.track?.kind === 'audio' && !peerData.audioSender) { - peerData.audioSender = trx.sender; - } else if (trx.receiver.track?.kind === 'video' && !peerData.videoSender) { - peerData.videoSender = trx.sender; - } - } - - // Now add local tracks if we have a local stream - if (this.localStream) { - this.logStream(`localStream->${fromUserId} (answerer)`, this.localStream); - for (const track of this.localStream.getTracks()) { - if (track.kind === 'audio' && peerData.audioSender) { - await peerData.audioSender.replaceTrack(track); - this.log('audio replaceTrack (answerer) ok', { fromUserId }); - } else if (track.kind === 'video' && peerData.videoSender) { - await peerData.videoSender.replaceTrack(track); - this.log('video replaceTrack (answerer) ok', { fromUserId }); - } - } - } - - // Process any pending ICE candidates - for (const candidate of peerData.pendingCandidates) { - await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate)); - } - peerData.pendingCandidates = []; - - const answer = await peerData.connection.createAnswer(); - await peerData.connection.setLocalDescription(answer); - - this.log('Sending answer', { to: fromUserId, type: answer.type, sdpLen: answer.sdp?.length }); - this.sendRawMessage({ - type: 'answer', - targetUserId: fromUserId, - payload: { sdp: answer }, - }); - } catch (error) { - this.error('Failed to handle offer', error); - } - } - - // Handle incoming answer - private async handleAnswer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise { - this.log('Handling answer', { fromUserId }); - - const peerData = this.peers.get(fromUserId); - if (!peerData) { - this.error('No peer for answer', new Error('Missing peer'), { fromUserId }); - return; - } - - try { - // Only set remote description if we're in the right state - if (peerData.connection.signalingState === 'have-local-offer') { - await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp)); - - // Process any pending ICE candidates - for (const candidate of peerData.pendingCandidates) { - await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate)); - } - peerData.pendingCandidates = []; - } else { - this.warn('Ignoring answer - wrong signaling state', { state: peerData.connection.signalingState }); - } - } catch (error) { - this.error('Failed to handle answer', error); - } - } - - // Handle incoming ICE candidate - private async handleIceCandidate(fromUserId: string, candidate: RTCIceCandidateInit): Promise { - let peerData = this.peers.get(fromUserId); - if (!peerData) { - // Create peer connection if it doesn't exist yet (candidate arrived before offer) - this.log('Create pc for early ICE', { fromUserId }); - peerData = this.createPeerConnection(fromUserId, false); - } - - try { - if (peerData.connection.remoteDescription) { - await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate)); - } else { - // Queue the candidate for later - this.log('Queue ICE', { fromUserId }); - peerData.pendingCandidates.push(candidate); - } - } catch (error) { - this.error('Failed to add ICE candidate', error); - } - } - - // Handle incoming signaling messages private handleSignalingMessage(message: any): void { this.signalingMessage$.next(message); - this.log('Signaling message', { type: message.type }); + this.logger.info('Signaling message', { type: message.type }); switch (message.type) { - case 'connected': - this.log('Server connected', { oderId: message.oderId }); + case SIGNALING_TYPE_CONNECTED: + this.logger.info('Server connected', { oderId: message.oderId }); if (typeof message.serverTime === 'number') { this.timeSync.setFromServerTime(message.serverTime); } break; - case 'server_users': - this.log('Server users', { count: Array.isArray(message.users) ? message.users.length : 0 }); + case SIGNALING_TYPE_SERVER_USERS: + this.logger.info('Server users', { count: Array.isArray(message.users) ? message.users.length : 0 }); if (message.users && Array.isArray(message.users)) { message.users.forEach((user: { oderId: string; displayName: string }) => { - if (user.oderId && !this.peers.has(user.oderId)) { - this.log('Create pc to existing user', { oderId: user.oderId }); - this.createPeerConnection(user.oderId, true); - // Create and send offer - this.createOffer(user.oderId); + if (user.oderId && !this.peerManager.activePeerConnections.has(user.oderId)) { + this.logger.info('Create peer connection to existing user', { oderId: user.oderId }); + this.peerManager.createPeerConnection(user.oderId, true); + this.peerManager.createAndSendOffer(user.oderId); } }); } break; - case 'user_joined': - this.log('User joined', { displayName: message.displayName, oderId: message.oderId }); - // Don't create connection here - the new user will initiate to us + case SIGNALING_TYPE_USER_JOINED: + this.logger.info('User joined', { displayName: message.displayName, oderId: message.oderId }); break; - case 'user_left': - this.log('User left', { displayName: message.displayName, oderId: message.oderId, serverId: message.serverId }); - // With multi-server membership, only remove peer if they are leaving the - // currently viewed server AND we don't share any other server with them. - // For now, don't remove peers on server-specific leave – - // the signaling server will send a proper disconnect when the WS closes. - // The store effect handles removing the user from the user list. + case SIGNALING_TYPE_USER_LEFT: + this.logger.info('User left', { displayName: message.displayName, oderId: message.oderId, serverId: message.serverId }); break; - case 'offer': + case SIGNALING_TYPE_OFFER: if (message.fromUserId && message.payload?.sdp) { - this.handleOffer(message.fromUserId, message.payload.sdp); + this.peerManager.handleOffer(message.fromUserId, message.payload.sdp); } break; - case 'answer': + case SIGNALING_TYPE_ANSWER: if (message.fromUserId && message.payload?.sdp) { - this.handleAnswer(message.fromUserId, message.payload.sdp); + this.peerManager.handleAnswer(message.fromUserId, message.payload.sdp); } break; - case 'ice_candidate': + case SIGNALING_TYPE_ICE_CANDIDATE: if (message.fromUserId && message.payload?.candidate) { - this.handleIceCandidate(message.fromUserId, message.payload.candidate); + this.peerManager.handleIceCandidate(message.fromUserId, message.payload.candidate); } break; } } - // Set current server ID for message routing - setCurrentServer(serverId: string): void { - this.currentServerId = serverId; - } + // ─── Voice state snapshot ────────────────────────────────────────── - // Get a snapshot of currently connected peer IDs - getConnectedPeers(): string[] { - return this._connectedPeers(); - } - - // Identify and remember credentials - identify(oderId: string, displayName: string): void { - this.lastIdentify = { oderId, displayName }; - this.sendRawMessage({ type: 'identify', oderId, displayName }); - } - - // Handle messages from peers - private handlePeerMessage(peerId: string, message: any): void { - console.log('Received P2P message from', peerId, ':', message); - - // Handle state requests internally - if (message.type === 'state-request' || message.type === 'voice-state-request') { - // Respond with our current voice state - this.sendCurrentStatesToPeer(peerId); - return; - } - - const enriched = { ...message, fromPeerId: peerId }; - this.messageReceived$.next(enriched); - } - - // Send message to all connected peers via P2P only - broadcastMessage(event: ChatEvent): void { - const data = JSON.stringify(event); - - this.peers.forEach((peerData, peerId) => { - try { - if (peerData.dataChannel && peerData.dataChannel.readyState === 'open') { - peerData.dataChannel.send(data); - console.log('Sent message via P2P to:', peerId); - } - } catch (error) { - console.error(`Failed to send to peer ${peerId}:`, error); - } - }); - } - - // Send message to specific peer - sendToPeer(peerId: string, event: ChatEvent): void { - const peerData = this.peers.get(peerId); - if (!peerData?.dataChannel || peerData.dataChannel.readyState !== 'open') { - console.error(`Peer ${peerId} not connected`); - return; - } - - try { - const data = JSON.stringify(event); - peerData.dataChannel.send(data); - } catch (error) { - console.error(`Failed to send to peer ${peerId}:`, error); - } - } - - // Send large messages with backpressure handling to avoid data channel overflow - async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise { - const peerData = this.peers.get(peerId); - if (!peerData?.dataChannel || peerData.dataChannel.readyState !== 'open') { - console.error(`Peer ${peerId} not connected`); - return; - } - - const channel = peerData.dataChannel; - const data = JSON.stringify(event); - const HIGH_WATER = 4 * 1024 * 1024; // 4MB - const LOW_WATER = 1 * 1024 * 1024; // 1MB - - if (typeof channel.bufferedAmountLowThreshold === 'number') { - channel.bufferedAmountLowThreshold = LOW_WATER; - } - - if (channel.bufferedAmount > HIGH_WATER) { - await new Promise((resolve) => { - const handler = () => { - if (channel.bufferedAmount <= LOW_WATER) { - channel.removeEventListener('bufferedamountlow', handler as any); - resolve(); - } - }; - channel.addEventListener('bufferedamountlow', handler as any, { once: true } as any); - }); - } - - try { - channel.send(data); - } catch (error) { - console.error(`Failed to send to peer ${peerId}:`, error); - } - } - - // Remove peer connection - private removePeer(peerId: string): void { - const peerData = this.peers.get(peerId); - if (peerData) { - if (peerData.dataChannel) { - peerData.dataChannel.close(); - } - peerData.connection.close(); - this.peers.delete(peerId); - this._connectedPeers.update((peers) => peers.filter((p) => p !== peerId)); - this.peerDisconnected$.next(peerId); - } - } - - // Track a peer that disconnected for potential reconnection - private trackDisconnectedPeer(peerId: string): void { - this.disconnectedPeers.set(peerId, { - lastSeen: Date.now(), - attempts: 0, - }); - } - - // Clear the P2P reconnection timer for a specific peer - private clearP2PReconnectTimer(peerId: string): void { - const timer = this.p2pReconnectTimers.get(peerId); - if (timer) { - clearInterval(timer); - this.p2pReconnectTimers.delete(peerId); - } - } - - // Clear all P2P reconnection timers - private clearAllP2PReconnectTimers(): void { - this.p2pReconnectTimers.forEach((timer) => clearInterval(timer)); - this.p2pReconnectTimers.clear(); - this.disconnectedPeers.clear(); - } - - // Schedule P2P reconnection attempts every 5 seconds - private scheduleP2PReconnect(peerId: string): void { - // Don't schedule if already trying to reconnect - if (this.p2pReconnectTimers.has(peerId)) return; - - const MAX_ATTEMPTS = 12; // 1 minute of attempts - const INTERVAL_MS = 5000; // 5 seconds - - this.log('Scheduling P2P reconnect', { peerId }); - - const timer = setInterval(() => { - const info = this.disconnectedPeers.get(peerId); - if (!info) { - this.clearP2PReconnectTimer(peerId); - return; - } - - info.attempts++; - this.log('P2P reconnect attempt', { peerId, attempt: info.attempts }); - - if (info.attempts >= MAX_ATTEMPTS) { - this.log('P2P reconnect max attempts reached', { peerId }); - this.clearP2PReconnectTimer(peerId); - this.disconnectedPeers.delete(peerId); - return; - } - - // Only attempt reconnection if we have signaling connection - if (!this._isConnected()) { - this.log('Skipping P2P reconnect - no signaling connection', { peerId }); - return; - } - - // Create a new peer connection and send offer - this.attemptP2PReconnect(peerId); - }, INTERVAL_MS); - - this.p2pReconnectTimers.set(peerId, timer); - } - - // Attempt to reconnect to a specific peer - private attemptP2PReconnect(peerId: string): void { - // Clean up old connection if exists - const existing = this.peers.get(peerId); - if (existing) { - try { - existing.connection.close(); - } catch {} - this.peers.delete(peerId); - } - - // Create new connection as initiator - this.createPeerConnection(peerId, true); - this.createOffer(peerId); - } - - // Request voice state from a newly connected peer - private requestVoiceStateFromPeer(peerId: string): void { - const peerData = this.peers.get(peerId); - if (peerData?.dataChannel?.readyState === 'open') { - try { - peerData.dataChannel.send(JSON.stringify({ type: 'voice-state-request' })); - } catch (e) { - this.warn('Failed to request voice state', e as any); - } - } - } - - // Voice chat - get user media - async enableVoice(): Promise { - try { - // Stop any existing local stream before getting a new one - // This prevents multiple streams being active simultaneously - if (this.localStream) { - this.log('Stopping existing local stream before enabling voice'); - this.localStream.getTracks().forEach((track) => track.stop()); - this.localStream = null; - } - - const constraints = { - audio: { - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true, - }, - video: false, - } as MediaStreamConstraints; - this.log('getUserMedia constraints', constraints); - const stream = await navigator.mediaDevices.getUserMedia(constraints); - - this.localStream = stream; - this.logStream('localVoice', stream); - - // Bind tracks to pre-created transceivers via replaceTrack to avoid extra m-lines - this.peers.forEach((peerData, peerId) => { - if (!this.localStream) return; - const audioTrack = this.localStream.getAudioTracks()[0]; - const videoTrack = this.localStream.getVideoTracks()[0]; - - if (audioTrack) { - let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === 'audio'); - if (!audioSender) { - audioSender = peerData.connection.addTransceiver('audio', { direction: 'sendrecv' }).sender; - } - peerData.audioSender = audioSender; - audioSender.replaceTrack(audioTrack) - .then(() => this.log('voice audio replaceTrack ok', { peerId })) - .catch((e) => this.error('voice audio replaceTrack failed', e)); - } - - if (videoTrack) { - let videoSender = peerData.videoSender || peerData.connection.getSenders().find(s => s.track?.kind === 'video'); - if (!videoSender) { - videoSender = peerData.connection.addTransceiver('video', { direction: 'sendrecv' }).sender; - } - peerData.videoSender = videoSender; - videoSender.replaceTrack(videoTrack) - .then(() => this.log('voice video replaceTrack ok', { peerId })) - .catch((e) => this.error('voice video replaceTrack failed', e)); - } - - // Renegotiate to send the updated tracks - this.renegotiate(peerId); - }); - - this._isVoiceConnected.set(true); - // Emit voice connected event so components can play pending audio - this.voiceConnected$.next(); - return this.localStream; - } catch (error) { - this.error('Failed to getUserMedia', error); - throw error; - } - } - - // Disable voice (stop and remove audio tracks) - disableVoice(): void { - if (this.localStream) { - this.localStream.getTracks().forEach((track) => { - track.stop(); - }); - this.localStream = null; - } - - // Remove audio senders from peer connections but keep connections open - this.peers.forEach((peerData) => { - const senders = peerData.connection.getSenders(); - senders.forEach(sender => { - if (sender.track?.kind === 'audio') { - peerData.connection.removeTrack(sender); - } - }); - }); - - // Update voice connection state - this._isVoiceConnected.set(false); - } - - // Screen sharing - async startScreenShare(includeAudio: boolean = false): Promise { - try { - this.log('startScreenShare invoked', { includeAudio }); - // Check if Electron API is available for desktop capturer - if (typeof window !== 'undefined' && (window as any).electronAPI?.getSources) { - try { - const sources = await (window as any).electronAPI.getSources(); - const screenSource = sources.find((s: any) => s.name === 'Entire Screen') || sources[0]; - - const electronConstraints: any = { - video: { - mandatory: { - chromeMediaSource: 'desktop', - chromeMediaSourceId: screenSource.id, - }, - }, - }; - if (includeAudio) { - electronConstraints.audio = { - mandatory: { - chromeMediaSource: 'desktop', - chromeMediaSourceId: screenSource.id, - }, - } as any; - } else { - electronConstraints.audio = false; - } - this.log('desktopCapturer constraints', electronConstraints); - this._screenStream = await navigator.mediaDevices.getUserMedia(electronConstraints); - } catch (e) { - this.warn('Electron desktop capture failed; falling back to getDisplayMedia', e as any); - } - } - - if (!this._screenStream) { - // Fallback to standard getDisplayMedia (system audio may be unavailable) - const displayConstraints: DisplayMediaStreamOptions = { - video: { - width: { ideal: 1920 }, - height: { ideal: 1080 }, - frameRate: { ideal: 30 }, - }, - audio: includeAudio ? { - echoCancellation: false, - noiseSuppression: false, - autoGainControl: false, - } : false, - } as any; - this.log('getDisplayMedia constraints', displayConstraints); - this._screenStream = await (navigator.mediaDevices as any).getDisplayMedia(displayConstraints); - } - - this.logStream('screen', this._screenStream); - // Prepare optional screen audio track and mix with mic if available - const screenAudioTrack = includeAudio ? (this._screenStream?.getAudioTracks()[0] || null) : null; - const micAudioTrack = this.localStream?.getAudioTracks()[0] || null; - - if (includeAudio && screenAudioTrack) { - try { - // Initialize AudioContext if available and not yet created - let ac: AudioContext | null = this.audioContext; - if (!ac && (window as any).AudioContext) { - ac = new (window as any).AudioContext(); - } - if (!ac) { - throw new Error('AudioContext not available'); - } - this.audioContext = ac; - const dest = ac.createMediaStreamDestination(); - - // Mix screen audio into destination - const scrStream = new MediaStream([screenAudioTrack]); - const scrSource = ac.createMediaStreamSource(scrStream); - scrSource.connect(dest); - - // Also mix microphone audio if available (allows mic + desktop audio simultaneously) - if (micAudioTrack) { - const micStream = new MediaStream([micAudioTrack]); - const micSource = ac.createMediaStreamSource(micStream); - micSource.connect(dest); - this.log('Mixed mic + screen audio together'); - } - - this.mixedAudioStream = dest.stream; - this.logStream('mixedAudio(screen+mic)', this.mixedAudioStream); - } catch (e) { - this.warn('Mixed audio creation failed; fallback to screen audio only', e as any); - this.mixedAudioStream = screenAudioTrack ? new MediaStream([screenAudioTrack]) : null; - this.logStream('mixedAudio(fallback)', this.mixedAudioStream); - } - } else { - this.mixedAudioStream = null; - } - - // Add/replace screen video track to all peers and set audio to mixed - this.peers.forEach((peerData, peerId) => { - if (this._screenStream) { - const videoTrack = this._screenStream.getVideoTracks()[0]; - if (!videoTrack) return; - this.attachTrackDiagnostics(videoTrack, `screenVideo:${peerId}`); - - // Use primary video sender/transceiver for screen share - let videoSender = peerData.videoSender || peerData.connection.getSenders().find(s => s.track?.kind === 'video'); - if (!videoSender) { - const vTrx = peerData.connection.addTransceiver('video', { direction: 'sendrecv' }); - videoSender = vTrx.sender; - peerData.videoSender = videoSender; - } else { - // Change transceiver direction to sendrecv if it was recvonly - const transceivers = peerData.connection.getTransceivers(); - const videoTrx = transceivers.find(t => t.sender === videoSender); - if (videoTrx && videoTrx.direction === 'recvonly') { - videoTrx.direction = 'sendrecv'; - } - } - peerData.screenVideoSender = videoSender; - videoSender.replaceTrack(videoTrack) - .then(() => this.log('screen video replaceTrack ok', { peerId })) - .catch((e) => this.error('screen video replaceTrack failed', e)); - - // Audio handling: prefer single audio m-line via transceiver - const micTrack = this.localStream?.getAudioTracks()[0] || null; - if (includeAudio) { - const mixedTrack = this.mixedAudioStream?.getAudioTracks()[0] || null; - if (mixedTrack) { - this.attachTrackDiagnostics(mixedTrack, `mixedAudio:${peerId}`); - let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === 'audio'); - if (!audioSender) { - const trx = peerData.connection.addTransceiver('audio', { direction: 'sendrecv' }); - audioSender = trx.sender; - } - peerData.audioSender = audioSender; - audioSender.replaceTrack(mixedTrack) - .then(() => this.log('screen audio(mixed) replaceTrack ok', { peerId })) - .catch((e) => this.error('audio replaceTrack (mixed) failed', e)); - } - } else if (micTrack) { - this.attachTrackDiagnostics(micTrack, `micAudio:${peerId}`); - let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === 'audio'); - if (!audioSender) { - const trx = peerData.connection.addTransceiver('audio', { direction: 'sendrecv' }); - audioSender = trx.sender; - } - peerData.audioSender = audioSender; - audioSender.replaceTrack(micTrack) - .then(() => this.log('screen audio(mic) replaceTrack ok', { peerId })) - .catch((e) => this.error('audio replaceTrack (mic) failed', e)); - } - - // Renegotiate to ensure remote receives video - this.renegotiate(peerId); - } - }); - - this._isScreenSharing.set(true); - const screenStream = this._screenStream!; - this._screenStreamSignal.set(screenStream); - - // Handle when user stops sharing via browser UI - screenStream.getVideoTracks()[0].onended = () => { - this.warn('Screen video track ended'); - this.stopScreenShare(); - }; - - return screenStream; - } catch (error) { - this.error('Failed to start screen share', error); - throw error; - } - } - - // Stop screen sharing - stopScreenShare(): void { - if (this._screenStream) { - this._screenStream.getTracks().forEach((track) => { - track.stop(); - }); - this._screenStream = null; - this._screenStreamSignal.set(null); - this._isScreenSharing.set(false); - - // Immediately broadcast that we stopped sharing - this.broadcastCurrentStates(); - } - - // Stop mixed audio (screen) and keep mic as-is - if (this.mixedAudioStream) { - try { this.mixedAudioStream.getTracks().forEach(t => t.stop()); } catch {} - this.mixedAudioStream = null; - } - // Optionally close audioContext if no longer needed - // Keep context alive to avoid recreating frequently - - // Remove video track and set transceiver back to recvonly - this.peers.forEach((peerData, peerId) => { - // Find and update video transceiver - const transceivers = peerData.connection.getTransceivers(); - const videoTrx = transceivers.find(t => t.sender === peerData.videoSender || t.sender === peerData.screenVideoSender); - if (videoTrx) { - // Remove the track and set direction to recvonly - videoTrx.sender.replaceTrack(null).catch(() => {}); - if (videoTrx.direction === 'sendrecv') { - videoTrx.direction = 'recvonly'; - } - } - peerData.screenVideoSender = undefined; - peerData.screenAudioSender = undefined; - - // Restore mic track on audio sender if available - const micTrack = this.localStream?.getAudioTracks()[0] || null; - if (micTrack) { - let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === 'audio'); - if (!audioSender) { - const trx = peerData.connection.addTransceiver('audio', { direction: 'sendrecv' }); - audioSender = trx.sender; - } - peerData.audioSender = audioSender; - audioSender.replaceTrack(micTrack).catch((e) => console.error('restore mic replaceTrack failed:', e)); - } - this.renegotiate(peerId); - }); - } - - // Join a room (first-time join – adds to multi-server membership) - joinRoom(roomId: string, userId: string): void { - this.lastJoin = { serverId: roomId, userId }; - this.joinedServerIds.add(roomId); - this.sendRawMessage({ - type: 'join_server', - serverId: roomId, - }); - } - - /** - * Switch the viewed server without affecting multi-server membership. - * If the server hasn't been joined yet, performs a full join. - * If already joined, just sends view_server to refresh user list. - */ - switchServer(serverId: string, userId: string): void { - // Update the last join info for reconnection purposes - this.lastJoin = { serverId, userId }; - - if (this.joinedServerIds.has(serverId)) { - // Already a member – just switch the view - this.sendRawMessage({ - type: 'view_server', - serverId: serverId, - }); - this.log('Viewed server (already joined)', { serverId, userId, voiceConnected: this._isVoiceConnected() }); - } else { - // Not yet joined – do a full join - this.joinedServerIds.add(serverId); - this.sendRawMessage({ - type: 'join_server', - serverId: serverId, - }); - this.log('Joined new server via switch', { serverId, userId, voiceConnected: this._isVoiceConnected() }); - } - } - - private scheduleReconnect(): void { - if (this.reconnectTimer || !this.lastWsUrl) return; - const delay = Math.min(30000, 1000 * Math.pow(2, this.reconnectAttempts)); // 1s,2s,4s.. up to 30s - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null; - this.reconnectAttempts++; - console.log('Attempting to reconnect to signaling...'); - this.connectToSignalingServer(this.lastWsUrl!).subscribe({ - next: () => { - this.reconnectAttempts = 0; - }, - error: () => { - // schedule next attempt - this.scheduleReconnect(); - }, - }); - }, delay); - } - - private clearReconnect(): void { - if (this.reconnectTimer) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - this.reconnectAttempts = 0; - } - - // Periodically broadcast our current voice/screen states to peers - private startHeartbeat(intervalMs: number = 5000): void { - this.stopHeartbeat(); - this.heartbeatTimer = setInterval(() => { - this.broadcastCurrentStates(); - }, intervalMs); - } - - private stopHeartbeat(): void { - if (this.heartbeatTimer) { - clearInterval(this.heartbeatTimer); - this.heartbeatTimer = null; - } - // Also stop voice heartbeat - this.stopVoiceHeartbeat(); - } - - // Start dedicated voice heartbeat (broadcasts voice state to peers every 5 seconds) - startVoiceHeartbeat(roomId?: string): void { - this.stopVoiceHeartbeat(); - const VOICE_HEARTBEAT_INTERVAL = 5000; // 5 seconds - - this.voiceHeartbeatTimer = setInterval(() => { - if (this._isVoiceConnected()) { - this.broadcastVoicePresence(roomId); - } - }, VOICE_HEARTBEAT_INTERVAL); - - // Also send an immediate heartbeat - if (this._isVoiceConnected()) { - this.broadcastVoicePresence(roomId); - } - } - - // Stop dedicated voice heartbeat - stopVoiceHeartbeat(): void { - if (this.voiceHeartbeatTimer) { - clearInterval(this.voiceHeartbeatTimer); - this.voiceHeartbeatTimer = null; - } - } - - // Broadcast voice presence to all peers - private broadcastVoicePresence(roomId?: string): void { - const oderId = this.lastIdentify?.oderId || this._peerId(); - const displayName = this.lastIdentify?.displayName || 'User'; - const voiceState = { - ...this.getCurrentVoiceState(), - roomId, - }; - this.broadcastMessage({ - type: 'voice-state', - oderId, - displayName, - voiceState, - } as any); - } - - private getCurrentVoiceState() { + private getCurrentVoiceState(): VoiceStateSnapshot { return { isConnected: this._isVoiceConnected(), isMuted: this._isMuted(), isDeafened: this._isDeafened(), isScreenSharing: this._isScreenSharing(), + roomId: this.mediaManager.getCurrentVoiceRoomId(), + serverId: this.mediaManager.getCurrentVoiceServerId(), }; } - private broadcastCurrentStates(): void { - const oderId = this.lastIdentify?.oderId || this._peerId(); - const displayName = this.lastIdentify?.displayName || 'User'; - const voiceState = this.getCurrentVoiceState(); - this.broadcastMessage({ - type: 'voice-state', - oderId, - displayName, - voiceState, - } as any); - this.broadcastMessage({ - type: 'screen-state', - oderId, - displayName, - isScreenSharing: this._isScreenSharing(), - } as any); + // ═══════════════════════════════════════════════════════════════════ + // PUBLIC API – matches the old monolithic service's interface + // ═══════════════════════════════════════════════════════════════════ + + // ─── Signaling ───────────────────────────────────────────────────── + + connectToSignalingServer(serverUrl: string): Observable { + return this.signalingManager.connect(serverUrl); } - private sendCurrentStatesToPeer(peerId: string): void { - const oderId = this.lastIdentify?.oderId || this._peerId(); - const displayName = this.lastIdentify?.displayName || 'User'; - const voiceState = this.getCurrentVoiceState(); - this.sendToPeer(peerId, { type: 'voice-state', oderId, displayName, voiceState } as any); - this.sendToPeer(peerId, { type: 'screen-state', oderId, displayName, isScreenSharing: this._isScreenSharing() } as any); + async ensureSignalingConnected(timeoutMs?: number): Promise { + return this.signalingManager.ensureConnected(timeoutMs); } - // Send current states directly to a channel (used when channel just opened and peer data might not be fully set up) - private sendCurrentStatesToChannel(channel: RTCDataChannel, remotePeerId: string): void { - if (channel.readyState !== 'open') { - this.warn('Cannot send states - channel not open', { remotePeerId, state: channel.readyState }); - return; - } - const oderId = this.lastIdentify?.oderId || this._peerId(); - const displayName = this.lastIdentify?.displayName || 'User'; - const voiceState = this.getCurrentVoiceState(); - try { - channel.send(JSON.stringify({ type: 'voice-state', oderId, displayName, voiceState })); - channel.send(JSON.stringify({ type: 'screen-state', oderId, displayName, isScreenSharing: this._isScreenSharing() })); - this.log('Sent initial states to channel', { remotePeerId, voiceState }); - } catch (e) { - this.error('Failed to send initial states to channel', e); + sendSignalingMessage(message: Omit): void { + this.signalingManager.sendSignalingMessage(message, this._localPeerId()); + } + + sendRawMessage(message: Record): void { + this.signalingManager.sendRawMessage(message); + } + + // ─── Server membership ───────────────────────────────────────────── + + setCurrentServer(serverId: string): void { + this.activeServerId = serverId; + } + + identify(oderId: string, displayName: string): void { + this.lastIdentifyCredentials = { oderId, displayName }; + this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, oderId, displayName }); + } + + joinRoom(roomId: string, userId: string): void { + this.lastJoinedServer = { serverId: roomId, userId }; + this.memberServerIds.add(roomId); + this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId: roomId }); + } + + switchServer(serverId: string, userId: string): void { + this.lastJoinedServer = { serverId, userId }; + + if (this.memberServerIds.has(serverId)) { + this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER, serverId }); + this.logger.info('Viewed server (already joined)', { serverId, userId, voiceConnected: this._isVoiceConnected() }); + } else { + this.memberServerIds.add(serverId); + this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId }); + this.logger.info('Joined new server via switch', { serverId, userId, voiceConnected: this._isVoiceConnected() }); } } - // Leave a specific server (or all if no serverId given) leaveRoom(serverId?: string): void { if (serverId) { - // Leave a specific server - this.joinedServerIds.delete(serverId); - this.sendRawMessage({ - type: 'leave_server', - serverId: serverId, - }); - this.log('Left server', { serverId }); - - // If no servers left, do full cleanup - if (this.joinedServerIds.size === 0) { - this.fullCleanup(); - } + this.memberServerIds.delete(serverId); + this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER, serverId }); + this.logger.info('Left server', { serverId }); + if (this.memberServerIds.size === 0) { this.fullCleanup(); } return; } - // Leave ALL servers and clean up - this.joinedServerIds.forEach((sid) => { - this.sendRawMessage({ - type: 'leave_server', - serverId: sid, - }); + this.memberServerIds.forEach((sid) => { + this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER, serverId: sid }); }); - this.joinedServerIds.clear(); + this.memberServerIds.clear(); this.fullCleanup(); } - // Internal full cleanup – close peers, stop media - private fullCleanup(): void { - // Clear all P2P reconnect timers - this.clearAllP2PReconnectTimers(); - - // Close all peer connections - this.peers.forEach((peerData, peerId) => { - if (peerData.dataChannel) { - peerData.dataChannel.close(); - } - peerData.connection.close(); - }); - this.peers.clear(); - this._connectedPeers.set([]); - - // Stop all media - this.disableVoice(); - this.stopScreenShare(); - } - - /** Check whether we are already a member of the given server */ hasJoinedServer(serverId: string): boolean { - return this.joinedServerIds.has(serverId); + return this.memberServerIds.has(serverId); } - /** Get all server IDs we are currently a member of */ getJoinedServerIds(): ReadonlySet { - return this.joinedServerIds; + return this.memberServerIds; } - // Disconnect from signaling server + // ─── Peer messaging ──────────────────────────────────────────────── + + broadcastMessage(event: ChatEvent): void { + this.peerManager.broadcastMessage(event); + } + + sendToPeer(peerId: string, event: ChatEvent): void { + this.peerManager.sendToPeer(peerId, event); + } + + async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise { + return this.peerManager.sendToPeerBuffered(peerId, event); + } + + getConnectedPeers(): string[] { + return this.peerManager.getConnectedPeerIds(); + } + + getRemoteStream(peerId: string): MediaStream | null { + return this.peerManager.remotePeerStreams.get(peerId) ?? null; + } + + // ─── Voice / Media ───────────────────────────────────────────────── + + async enableVoice(): Promise { + const stream = await this.mediaManager.enableVoice(); + this.syncMediaSignals(); + return stream; + } + + disableVoice(): void { + this.mediaManager.disableVoice(); + this._isVoiceConnected.set(false); + } + + setLocalStream(stream: MediaStream): void { + this.mediaManager.setLocalStream(stream); + this.syncMediaSignals(); + } + + toggleMute(muted?: boolean): void { + this.mediaManager.toggleMute(muted); + this._isMuted.set(this.mediaManager.getIsMicMuted()); + } + + toggleDeafen(deafened?: boolean): void { + this.mediaManager.toggleDeafen(deafened); + this._isDeafened.set(this.mediaManager.getIsSelfDeafened()); + } + + setOutputVolume(volume: number): void { + this.mediaManager.setOutputVolume(volume); + } + + async setAudioBitrate(kbps: number): Promise { + return this.mediaManager.setAudioBitrate(kbps); + } + + async setLatencyProfile(profile: LatencyProfile): Promise { + return this.mediaManager.setLatencyProfile(profile); + } + + startVoiceHeartbeat(roomId?: string, serverId?: string): void { + this.mediaManager.startVoiceHeartbeat(roomId, serverId); + } + + stopVoiceHeartbeat(): void { + this.mediaManager.stopVoiceHeartbeat(); + } + + // ─── Screen share ────────────────────────────────────────────────── + + async startScreenShare(includeAudio: boolean = false): Promise { + const stream = await this.screenShareManager.startScreenShare(includeAudio); + this._isScreenSharing.set(true); + this._screenStreamSignal.set(stream); + return stream; + } + + stopScreenShare(): void { + this.screenShareManager.stopScreenShare(); + this._isScreenSharing.set(false); + this._screenStreamSignal.set(null); + } + + // ─── Disconnect / cleanup ───────────────────────────────────────── + disconnect(): void { - this.leaveRoom(); // leaves all servers - this.stopVoiceHeartbeat(); - - if (this.signalingSocket) { - this.signalingSocket.close(); - this.signalingSocket = null; - } - - this._isConnected.set(false); + this.leaveRoom(); + this.mediaManager.stopVoiceHeartbeat(); + this.signalingManager.close(); + this._isSignalingConnected.set(false); this._hasConnectionError.set(false); this._connectionErrorMessage.set(null); - this.destroy$.next(); + this.serviceDestroyed$.next(); } - // Alias for disconnect - used by components disconnectAll(): void { this.disconnect(); } - // Set local media stream from external source - setLocalStream(stream: MediaStream): void { - this.localStream = stream; - - // Bind new local stream tracks via replaceTrack to existing transceivers - this.peers.forEach((peerData, peerId) => { - if (!this.localStream) return; - - const audioTrack = this.localStream.getAudioTracks()[0] || null; - const videoTrack = this.localStream.getVideoTracks()[0] || null; - - if (audioTrack) { - let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === 'audio'); - if (!audioSender) { - audioSender = peerData.connection.addTransceiver('audio', { direction: 'sendrecv' }).sender; - } - peerData.audioSender = audioSender; - audioSender.replaceTrack(audioTrack) - .then(() => this.log('setLocalStream audio replaceTrack ok', { peerId })) - .catch((e) => this.error('setLocalStream audio replaceTrack failed', e)); - } - - if (videoTrack) { - let videoSender = peerData.videoSender || peerData.connection.getSenders().find(s => s.track?.kind === 'video'); - if (!videoSender) { - videoSender = peerData.connection.addTransceiver('video', { direction: 'sendrecv' }).sender; - } - peerData.videoSender = videoSender; - videoSender.replaceTrack(videoTrack) - .then(() => this.log('setLocalStream video replaceTrack ok', { peerId })) - .catch((e) => this.error('setLocalStream video replaceTrack failed', e)); - } - - this.renegotiate(peerId); - }); - - this._isVoiceConnected.set(true); - // Emit voice connected event so components can play pending audio - this.voiceConnected$.next(); + private fullCleanup(): void { + this.peerManager.closeAllPeers(); + this._connectedPeers.set([]); + this.mediaManager.disableVoice(); + this._isVoiceConnected.set(false); + this.screenShareManager.stopScreenShare(); + this._isScreenSharing.set(false); + this._screenStreamSignal.set(null); } - // Renegotiate connection (for adding/removing tracks) - private async renegotiate(peerId: string): Promise { - const peerData = this.peers.get(peerId); - if (!peerData) return; + // ─── Helpers ─────────────────────────────────────────────────────── - try { - const offer = await peerData.connection.createOffer(); - await peerData.connection.setLocalDescription(offer); - this.log('Renegotiate offer', { peerId, type: offer.type, sdpLen: offer.sdp?.length }); - this.sendRawMessage({ - type: 'offer', - targetUserId: peerId, - payload: { sdp: offer }, - }); - } catch (error) { - this.error('Failed to renegotiate', error); - } + /** Synchronise Angular signals from the MediaManager's internal state. */ + private syncMediaSignals(): void { + this._isVoiceConnected.set(this.mediaManager.getIsVoiceActive()); + this._isMuted.set(this.mediaManager.getIsMicMuted()); + this._isDeafened.set(this.mediaManager.getIsSelfDeafened()); } - // Toggle mute with explicit state - toggleMute(muted?: boolean): void { - if (this.localStream) { - const audioTracks = this.localStream.getAudioTracks(); - const newMutedState = muted !== undefined ? muted : !this._isMuted(); - audioTracks.forEach((track) => { - track.enabled = !newMutedState; - }); - this._isMuted.set(newMutedState); - } - } + // ─── Lifecycle ───────────────────────────────────────────────────── - // Toggle deafen state - toggleDeafen(deafened?: boolean): void { - const newDeafenedState = deafened !== undefined ? deafened : !this._isDeafened(); - this._isDeafened.set(newDeafenedState); - } - - // Set output volume for remote streams - setOutputVolume(volume: number): void { - this.outputVolume = Math.max(0, Math.min(1, volume)); - } - - // Latency/bitrate controls for audio - async setAudioBitrate(kbps: number): Promise { - const bps = Math.max(16000, Math.min(256000, Math.floor(kbps * 1000))); - this.peers.forEach(async (peerData) => { - const sender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === 'audio'); - if (!sender || !sender.track) return; - // Apply only when signaling is stable to avoid InvalidStateError - if (peerData.connection.signalingState !== 'stable') return; - let params: RTCRtpSendParameters; - try { - params = sender.getParameters(); - } catch (e) { - console.warn('getParameters failed; skipping bitrate apply', e); - return; - } - params.encodings = params.encodings || [{}]; - params.encodings[0].maxBitrate = bps; - try { - await sender.setParameters(params); - console.log('Applied audio bitrate:', bps); - } catch (e) { - console.warn('Failed to set audio bitrate', e); - } - }); - } - - async setLatencyProfile(profile: 'low' | 'balanced' | 'high'): Promise { - const map = { low: 64000, balanced: 96000, high: 128000 } as const; - await this.setAudioBitrate(map[profile]); - } - - // Cleanup ngOnDestroy(): void { this.disconnect(); - this.destroy$.complete(); + this.serviceDestroyed$.complete(); + this.signalingManager.destroy(); + this.peerManager.destroy(); + this.mediaManager.destroy(); + this.screenShareManager.destroy(); } } diff --git a/src/app/core/services/webrtc/index.ts b/src/app/core/services/webrtc/index.ts new file mode 100644 index 0000000..537acde --- /dev/null +++ b/src/app/core/services/webrtc/index.ts @@ -0,0 +1,13 @@ +/** + * Barrel export for the WebRTC sub-module. + * + * Other modules should import from here: + * import { ... } from './webrtc'; + */ +export * from './webrtc.constants'; +export * from './webrtc.types'; +export * from './webrtc-logger'; +export * from './signaling.manager'; +export * from './peer-connection.manager'; +export * from './media.manager'; +export * from './screen-share.manager'; diff --git a/src/app/core/services/webrtc/media.manager.ts b/src/app/core/services/webrtc/media.manager.ts new file mode 100644 index 0000000..cac34fc --- /dev/null +++ b/src/app/core/services/webrtc/media.manager.ts @@ -0,0 +1,311 @@ +/** + * Manages local voice media: getUserMedia, mute, deafen, + * attaching/detaching audio tracks to peer connections, and bitrate tuning. + */ +import { Subject } from 'rxjs'; +import { WebRTCLogger } from './webrtc-logger'; +import { PeerData } from './webrtc.types'; +import { + TRACK_KIND_AUDIO, + TRACK_KIND_VIDEO, + TRANSCEIVER_SEND_RECV, + TRANSCEIVER_RECV_ONLY, + TRANSCEIVER_INACTIVE, + AUDIO_BITRATE_MIN_BPS, + AUDIO_BITRATE_MAX_BPS, + KBPS_TO_BPS, + LATENCY_PROFILE_BITRATES, + VOLUME_MIN, + VOLUME_MAX, + VOICE_HEARTBEAT_INTERVAL_MS, + DEFAULT_DISPLAY_NAME, + P2P_TYPE_VOICE_STATE, + LatencyProfile, +} from './webrtc.constants'; + +/** + * Callbacks the MediaManager needs from the owning service / peer manager. + */ +export interface MediaManagerCallbacks { + /** All active peer connections (for attaching tracks). */ + getActivePeers(): Map; + /** Trigger SDP renegotiation for a specific peer. */ + renegotiate(peerId: string): Promise; + /** Broadcast a message to all peers. */ + broadcastMessage(event: any): void; + /** Get identify credentials (for broadcasting). */ + getIdentifyOderId(): string; + getIdentifyDisplayName(): string; +} + +export class MediaManager { + /** The current local media stream (mic audio). */ + private localMediaStream: MediaStream | null = null; + + /** Remote audio output volume (0-1). */ + private remoteAudioVolume = VOLUME_MAX; + + /** Voice-presence heartbeat timer. */ + private voicePresenceTimer: ReturnType | null = null; + + /** Emitted when voice is successfully connected. */ + readonly voiceConnected$ = new Subject(); + + // State tracked locally (the service exposes these via signals) + private isVoiceActive = false; + private isMicMuted = false; + private isSelfDeafened = false; + + /** Current voice channel room ID (set when joining voice). */ + private currentVoiceRoomId: string | undefined; + /** Current voice channel server ID (set when joining voice). */ + private currentVoiceServerId: string | undefined; + + constructor( + private readonly logger: WebRTCLogger, + private callbacks: MediaManagerCallbacks, + ) {} + + setCallbacks(cb: MediaManagerCallbacks): void { + this.callbacks = cb; + } + + // ─── Accessors ───────────────────────────────────────────────────── + + getLocalStream(): MediaStream | null { return this.localMediaStream; } + getIsVoiceActive(): boolean { return this.isVoiceActive; } + getIsMicMuted(): boolean { return this.isMicMuted; } + getIsSelfDeafened(): boolean { return this.isSelfDeafened; } + getRemoteAudioVolume(): number { return this.remoteAudioVolume; } + getCurrentVoiceRoomId(): string | undefined { return this.currentVoiceRoomId; } + getCurrentVoiceServerId(): string | undefined { return this.currentVoiceServerId; } + + // ─── Enable / Disable voice ──────────────────────────────────────── + + async enableVoice(): Promise { + try { + // Stop any existing stream first + if (this.localMediaStream) { + this.logger.info('Stopping existing local stream before enabling voice'); + this.localMediaStream.getTracks().forEach((track) => track.stop()); + this.localMediaStream = null; + } + + const mediaConstraints: MediaStreamConstraints = { + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + video: false, + }; + this.logger.info('getUserMedia constraints', mediaConstraints); + + if (!navigator.mediaDevices?.getUserMedia) { + throw new Error( + 'navigator.mediaDevices is not available. ' + + 'This requires a secure context (HTTPS or localhost). ' + + 'If accessing from an external device, use HTTPS.' + ); + } + + const stream = await navigator.mediaDevices.getUserMedia(mediaConstraints); + + this.localMediaStream = stream; + this.logger.logStream('localVoice', stream); + + this.bindLocalTracksToAllPeers(); + + this.isVoiceActive = true; + this.voiceConnected$.next(); + return this.localMediaStream; + } catch (error) { + this.logger.error('Failed to getUserMedia', error); + throw error; + } + } + + disableVoice(): void { + if (this.localMediaStream) { + this.localMediaStream.getTracks().forEach((track) => track.stop()); + this.localMediaStream = null; + } + + // Remove audio senders but keep connections alive + this.callbacks.getActivePeers().forEach((peerData) => { + const senders = peerData.connection.getSenders(); + senders.forEach(sender => { + if (sender.track?.kind === TRACK_KIND_AUDIO) { + peerData.connection.removeTrack(sender); + } + }); + }); + + this.isVoiceActive = false; + this.currentVoiceRoomId = undefined; + this.currentVoiceServerId = undefined; + } + + /** Set the local stream from an external source (e.g. voice-controls component). */ + setLocalStream(stream: MediaStream): void { + this.localMediaStream = stream; + this.bindLocalTracksToAllPeers(); + this.isVoiceActive = true; + this.voiceConnected$.next(); + } + + // ─── Mute / Deafen ──────────────────────────────────────────────── + + toggleMute(muted?: boolean): void { + if (this.localMediaStream) { + const audioTracks = this.localMediaStream.getAudioTracks(); + const newMutedState = muted !== undefined ? muted : !this.isMicMuted; + audioTracks.forEach((track) => { track.enabled = !newMutedState; }); + this.isMicMuted = newMutedState; + } + } + + toggleDeafen(deafened?: boolean): void { + this.isSelfDeafened = deafened !== undefined ? deafened : !this.isSelfDeafened; + } + + // ─── Volume ──────────────────────────────────────────────────────── + + setOutputVolume(volume: number): void { + this.remoteAudioVolume = Math.max(VOLUME_MIN, Math.min(VOLUME_MAX, volume)); + } + + // ─── Audio bitrate ──────────────────────────────────────────────── + + async setAudioBitrate(kbps: number): Promise { + const targetBps = Math.max(AUDIO_BITRATE_MIN_BPS, Math.min(AUDIO_BITRATE_MAX_BPS, Math.floor(kbps * KBPS_TO_BPS))); + + this.callbacks.getActivePeers().forEach(async (peerData) => { + const sender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO); + if (!sender?.track) return; + if (peerData.connection.signalingState !== 'stable') return; + + let params: RTCRtpSendParameters; + try { params = sender.getParameters(); } catch (e) { console.warn('getParameters failed; skipping bitrate apply', e); return; } + params.encodings = params.encodings || [{}]; + params.encodings[0].maxBitrate = targetBps; + + try { + await sender.setParameters(params); + console.log('Applied audio bitrate:', targetBps); + } catch (e) { + console.warn('Failed to set audio bitrate', e); + } + }); + } + + async setLatencyProfile(profile: LatencyProfile): Promise { + await this.setAudioBitrate(LATENCY_PROFILE_BITRATES[profile]); + } + + // ─── Voice-presence heartbeat ───────────────────────────────────── + + startVoiceHeartbeat(roomId?: string, serverId?: string): void { + this.stopVoiceHeartbeat(); + + // Persist voice channel context so heartbeats and state snapshots include it + if (roomId !== undefined) this.currentVoiceRoomId = roomId; + if (serverId !== undefined) this.currentVoiceServerId = serverId; + + this.voicePresenceTimer = setInterval(() => { + if (this.isVoiceActive) { + this.broadcastVoicePresence(); + } + }, VOICE_HEARTBEAT_INTERVAL_MS); + + // Also send an immediate heartbeat + if (this.isVoiceActive) { + this.broadcastVoicePresence(); + } + } + + stopVoiceHeartbeat(): void { + if (this.voicePresenceTimer) { + clearInterval(this.voicePresenceTimer); + this.voicePresenceTimer = null; + } + } + + // ─── Internal helpers ────────────────────────────────────────────── + + /** + * Bind local audio/video tracks to all existing peer transceivers. + * Restores transceiver direction to sendrecv if previously set to recvonly + * (which happens when disableVoice calls removeTrack). + */ + private bindLocalTracksToAllPeers(): void { + const peers = this.callbacks.getActivePeers(); + if (!this.localMediaStream) return; + + const localAudioTrack = this.localMediaStream.getAudioTracks()[0] || null; + const localVideoTrack = this.localMediaStream.getVideoTracks()[0] || null; + + peers.forEach((peerData, peerId) => { + if (localAudioTrack) { + let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO); + if (!audioSender) { + audioSender = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV }).sender; + } + peerData.audioSender = audioSender; + + // Restore direction after removeTrack (which sets it to recvonly) + const audioTransceiver = peerData.connection.getTransceivers().find(t => t.sender === audioSender); + if (audioTransceiver && (audioTransceiver.direction === TRANSCEIVER_RECV_ONLY || audioTransceiver.direction === TRANSCEIVER_INACTIVE)) { + audioTransceiver.direction = TRANSCEIVER_SEND_RECV; + } + + audioSender.replaceTrack(localAudioTrack) + .then(() => this.logger.info('audio replaceTrack ok', { peerId })) + .catch((e) => this.logger.error('audio replaceTrack failed', e)); + } + + if (localVideoTrack) { + let videoSender = peerData.videoSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_VIDEO); + if (!videoSender) { + videoSender = peerData.connection.addTransceiver(TRACK_KIND_VIDEO, { direction: TRANSCEIVER_SEND_RECV }).sender; + } + peerData.videoSender = videoSender; + + const videoTransceiver = peerData.connection.getTransceivers().find(t => t.sender === videoSender); + if (videoTransceiver && (videoTransceiver.direction === TRANSCEIVER_RECV_ONLY || videoTransceiver.direction === TRANSCEIVER_INACTIVE)) { + videoTransceiver.direction = TRANSCEIVER_SEND_RECV; + } + + videoSender.replaceTrack(localVideoTrack) + .then(() => this.logger.info('video replaceTrack ok', { peerId })) + .catch((e) => this.logger.error('video replaceTrack failed', e)); + } + + this.callbacks.renegotiate(peerId); + }); + } + + private broadcastVoicePresence(): void { + const oderId = this.callbacks.getIdentifyOderId(); + const displayName = this.callbacks.getIdentifyDisplayName(); + this.callbacks.broadcastMessage({ + type: P2P_TYPE_VOICE_STATE, + oderId, + displayName, + voiceState: { + isConnected: this.isVoiceActive, + isMuted: this.isMicMuted, + isDeafened: this.isSelfDeafened, + roomId: this.currentVoiceRoomId, + serverId: this.currentVoiceServerId, + }, + }); + } + + /** Clean up all resources. */ + destroy(): void { + this.disableVoice(); + this.stopVoiceHeartbeat(); + this.voiceConnected$.complete(); + } +} diff --git a/src/app/core/services/webrtc/peer-connection.manager.ts b/src/app/core/services/webrtc/peer-connection.manager.ts new file mode 100644 index 0000000..c7ead0e --- /dev/null +++ b/src/app/core/services/webrtc/peer-connection.manager.ts @@ -0,0 +1,623 @@ +/** + * Creates and manages RTCPeerConnections, data channels, + * offer/answer negotiation, ICE candidates, and P2P reconnection. + */ +import { Subject } from 'rxjs'; +import { ChatEvent } from '../../models'; +import { WebRTCLogger } from './webrtc-logger'; +import { PeerData, DisconnectedPeerEntry, VoiceStateSnapshot, IdentifyCredentials } from './webrtc.types'; +import { + ICE_SERVERS, + DATA_CHANNEL_LABEL, + DATA_CHANNEL_HIGH_WATER_BYTES, + DATA_CHANNEL_LOW_WATER_BYTES, + DATA_CHANNEL_STATE_OPEN, + CONNECTION_STATE_CONNECTED, + CONNECTION_STATE_DISCONNECTED, + CONNECTION_STATE_FAILED, + CONNECTION_STATE_CLOSED, + TRACK_KIND_AUDIO, + TRACK_KIND_VIDEO, + TRANSCEIVER_SEND_RECV, + TRANSCEIVER_RECV_ONLY, + PEER_RECONNECT_MAX_ATTEMPTS, + PEER_RECONNECT_INTERVAL_MS, + P2P_TYPE_STATE_REQUEST, + P2P_TYPE_VOICE_STATE_REQUEST, + P2P_TYPE_VOICE_STATE, + P2P_TYPE_SCREEN_STATE, + SIGNALING_TYPE_OFFER, + SIGNALING_TYPE_ANSWER, + SIGNALING_TYPE_ICE_CANDIDATE, + DEFAULT_DISPLAY_NAME, +} from './webrtc.constants'; + +/** + * Callbacks the PeerConnectionManager needs from the owning service. + * This keeps the manager decoupled from Angular DI / signals. + */ +export interface PeerConnectionCallbacks { + /** Send a raw JSON message via the signaling server. */ + sendRawMessage(msg: Record): void; + /** Get the current local media stream (mic audio). */ + getLocalMediaStream(): MediaStream | null; + /** Whether signaling is currently connected. */ + isSignalingConnected(): boolean; + /** Returns the current voice/screen state snapshot for broadcasting. */ + getVoiceStateSnapshot(): VoiceStateSnapshot; + /** Returns the identify credentials (oderId + displayName). */ + getIdentifyCredentials(): IdentifyCredentials | null; + /** Returns the local peer ID. */ + getLocalPeerId(): string; + /** Whether screen sharing is active. */ + isScreenSharingActive(): boolean; +} + +export class PeerConnectionManager { + /** Active peer connections keyed by remote peer ID. */ + readonly activePeerConnections = new Map(); + + /** Remote composite streams keyed by remote peer ID. */ + readonly remotePeerStreams = new Map(); + + /** Tracks disconnected peers for P2P reconnection scheduling. */ + private disconnectedPeerTracker = new Map(); + private peerReconnectTimers = new Map>(); + + // ─── Public event subjects ───────────────────────────────────────── + readonly peerConnected$ = new Subject(); + readonly peerDisconnected$ = new Subject(); + readonly remoteStream$ = new Subject<{ peerId: string; stream: MediaStream }>(); + readonly messageReceived$ = new Subject(); + /** Emitted whenever the connected peer list changes. */ + readonly connectedPeersChanged$ = new Subject(); + + constructor( + private readonly logger: WebRTCLogger, + private callbacks: PeerConnectionCallbacks, + ) {} + + /** Allow hot-swapping callbacks (e.g. after service wiring). */ + setCallbacks(cb: PeerConnectionCallbacks): void { + this.callbacks = cb; + } + + // ─── Peer connection lifecycle ───────────────────────────────────── + + createPeerConnection(remotePeerId: string, isInitiator: boolean): PeerData { + this.logger.info('Creating peer connection', { remotePeerId, isInitiator }); + + const connection = new RTCPeerConnection({ iceServers: ICE_SERVERS }); + let dataChannel: RTCDataChannel | null = null; + + // ICE candidates → signaling + connection.onicecandidate = (event) => { + if (event.candidate) { + this.logger.info('ICE candidate gathered', { remotePeerId, candidateType: (event.candidate as any)?.type }); + this.callbacks.sendRawMessage({ + type: SIGNALING_TYPE_ICE_CANDIDATE, + targetUserId: remotePeerId, + payload: { candidate: event.candidate }, + }); + } + }; + + // Connection state + connection.onconnectionstatechange = () => { + this.logger.info('connectionstatechange', { remotePeerId, state: connection.connectionState }); + + switch (connection.connectionState) { + case CONNECTION_STATE_CONNECTED: + this.addToConnectedPeers(remotePeerId); + this.peerConnected$.next(remotePeerId); + this.clearPeerReconnectTimer(remotePeerId); + this.disconnectedPeerTracker.delete(remotePeerId); + this.requestVoiceStateFromPeer(remotePeerId); + break; + + case CONNECTION_STATE_DISCONNECTED: + case CONNECTION_STATE_FAILED: + this.trackDisconnectedPeer(remotePeerId); + this.removePeer(remotePeerId); + this.schedulePeerReconnect(remotePeerId); + break; + + case CONNECTION_STATE_CLOSED: + this.removePeer(remotePeerId); + break; + } + }; + + // Additional state logs + connection.oniceconnectionstatechange = () => { + this.logger.info('iceconnectionstatechange', { remotePeerId, state: connection.iceConnectionState }); + }; + connection.onsignalingstatechange = () => { + this.logger.info('signalingstatechange', { remotePeerId, state: connection.signalingState }); + }; + connection.onnegotiationneeded = () => { + this.logger.info('negotiationneeded', { remotePeerId }); + }; + + // Incoming remote tracks + connection.ontrack = (event) => { + this.handleRemoteTrack(event, remotePeerId); + }; + + // Data channel + if (isInitiator) { + dataChannel = connection.createDataChannel(DATA_CHANNEL_LABEL, { ordered: true }); + this.setupDataChannel(dataChannel, remotePeerId); + } else { + connection.ondatachannel = (event) => { + this.logger.info('Received data channel', { remotePeerId }); + dataChannel = event.channel; + const existing = this.activePeerConnections.get(remotePeerId); + if (existing) { existing.dataChannel = dataChannel; } + this.setupDataChannel(dataChannel, remotePeerId); + }; + } + + const peerData: PeerData = { + connection, + dataChannel, + isInitiator, + pendingIceCandidates: [], + audioSender: undefined, + videoSender: undefined, + }; + + // Pre-create transceivers only for the initiator (offerer). + if (isInitiator) { + const audioTransceiver = connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV }); + const videoTransceiver = connection.addTransceiver(TRACK_KIND_VIDEO, { direction: TRANSCEIVER_RECV_ONLY }); + peerData.audioSender = audioTransceiver.sender; + peerData.videoSender = videoTransceiver.sender; + } + + this.activePeerConnections.set(remotePeerId, peerData); + + // Attach local stream to initiator + const localStream = this.callbacks.getLocalMediaStream(); + if (localStream && isInitiator) { + this.logger.logStream(`localStream->${remotePeerId}`, localStream); + localStream.getTracks().forEach((track) => { + if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) { + peerData.audioSender.replaceTrack(track) + .then(() => this.logger.info('audio replaceTrack (init) ok', { remotePeerId })) + .catch((e) => this.logger.error('audio replaceTrack failed at createPeerConnection', e)); + } else if (track.kind === TRACK_KIND_VIDEO && peerData.videoSender) { + peerData.videoSender.replaceTrack(track) + .then(() => this.logger.info('video replaceTrack (init) ok', { remotePeerId })) + .catch((e) => this.logger.error('video replaceTrack failed at createPeerConnection', e)); + } else { + const sender = connection.addTrack(track, localStream); + if (track.kind === TRACK_KIND_AUDIO) peerData.audioSender = sender; + if (track.kind === TRACK_KIND_VIDEO) peerData.videoSender = sender; + } + }); + } + + return peerData; + } + + // ─── Offer / Answer / ICE ────────────────────────────────────────── + + async createAndSendOffer(remotePeerId: string): Promise { + const peerData = this.activePeerConnections.get(remotePeerId); + if (!peerData) return; + + try { + const offer = await peerData.connection.createOffer(); + await peerData.connection.setLocalDescription(offer); + this.logger.info('Sending offer', { remotePeerId, type: offer.type, sdpLength: offer.sdp?.length }); + this.callbacks.sendRawMessage({ + type: SIGNALING_TYPE_OFFER, + targetUserId: remotePeerId, + payload: { sdp: offer }, + }); + } catch (error) { + this.logger.error('Failed to create offer', error); + } + } + + async handleOffer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise { + this.logger.info('Handling offer', { fromUserId }); + + let peerData = this.activePeerConnections.get(fromUserId); + if (!peerData) { + peerData = this.createPeerConnection(fromUserId, false); + } + + try { + await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp)); + + // Discover transceivers the browser created on the answerer side + const transceivers = peerData.connection.getTransceivers(); + for (const transceiver of transceivers) { + if (transceiver.receiver.track?.kind === TRACK_KIND_AUDIO && !peerData.audioSender) { + peerData.audioSender = transceiver.sender; + } else if (transceiver.receiver.track?.kind === TRACK_KIND_VIDEO && !peerData.videoSender) { + peerData.videoSender = transceiver.sender; + } + } + + // Attach local tracks (answerer side) + const localStream = this.callbacks.getLocalMediaStream(); + if (localStream) { + this.logger.logStream(`localStream->${fromUserId} (answerer)`, localStream); + for (const track of localStream.getTracks()) { + if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) { + await peerData.audioSender.replaceTrack(track); + this.logger.info('audio replaceTrack (answerer) ok', { fromUserId }); + } else if (track.kind === TRACK_KIND_VIDEO && peerData.videoSender) { + await peerData.videoSender.replaceTrack(track); + this.logger.info('video replaceTrack (answerer) ok', { fromUserId }); + } + } + } + + // Flush queued ICE candidates + for (const candidate of peerData.pendingIceCandidates) { + await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate)); + } + peerData.pendingIceCandidates = []; + + const answer = await peerData.connection.createAnswer(); + await peerData.connection.setLocalDescription(answer); + + this.logger.info('Sending answer', { to: fromUserId, type: answer.type, sdpLength: answer.sdp?.length }); + this.callbacks.sendRawMessage({ + type: SIGNALING_TYPE_ANSWER, + targetUserId: fromUserId, + payload: { sdp: answer }, + }); + } catch (error) { + this.logger.error('Failed to handle offer', error); + } + } + + async handleAnswer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise { + this.logger.info('Handling answer', { fromUserId }); + const peerData = this.activePeerConnections.get(fromUserId); + if (!peerData) { + this.logger.error('No peer for answer', new Error('Missing peer'), { fromUserId }); + return; + } + + try { + if (peerData.connection.signalingState === 'have-local-offer') { + await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp)); + for (const candidate of peerData.pendingIceCandidates) { + await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate)); + } + peerData.pendingIceCandidates = []; + } else { + this.logger.warn('Ignoring answer – wrong signaling state', { state: peerData.connection.signalingState }); + } + } catch (error) { + this.logger.error('Failed to handle answer', error); + } + } + + async handleIceCandidate(fromUserId: string, candidate: RTCIceCandidateInit): Promise { + let peerData = this.activePeerConnections.get(fromUserId); + if (!peerData) { + this.logger.info('Creating peer for early ICE', { fromUserId }); + peerData = this.createPeerConnection(fromUserId, false); + } + + try { + if (peerData.connection.remoteDescription) { + await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate)); + } else { + this.logger.info('Queuing ICE candidate', { fromUserId }); + peerData.pendingIceCandidates.push(candidate); + } + } catch (error) { + this.logger.error('Failed to add ICE candidate', error); + } + } + + /** Re-negotiate (create offer) to push track changes to remote. */ + async renegotiate(peerId: string): Promise { + const peerData = this.activePeerConnections.get(peerId); + if (!peerData) return; + + try { + const offer = await peerData.connection.createOffer(); + await peerData.connection.setLocalDescription(offer); + this.logger.info('Renegotiate offer', { peerId, type: offer.type, sdpLength: offer.sdp?.length }); + this.callbacks.sendRawMessage({ + type: SIGNALING_TYPE_OFFER, + targetUserId: peerId, + payload: { sdp: offer }, + }); + } catch (error) { + this.logger.error('Failed to renegotiate', error); + } + } + + // ─── Data channel ────────────────────────────────────────────────── + + private setupDataChannel(channel: RTCDataChannel, remotePeerId: string): void { + channel.onopen = () => { + console.log(`Data channel open with ${remotePeerId}`); + this.sendCurrentStatesToChannel(channel, remotePeerId); + try { channel.send(JSON.stringify({ type: P2P_TYPE_STATE_REQUEST })); } catch { /* ignore */ } + }; + + channel.onclose = () => { + console.log(`Data channel closed with ${remotePeerId}`); + }; + + channel.onerror = (error) => { + console.error(`Data channel error with ${remotePeerId}:`, error); + }; + + channel.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + this.handlePeerMessage(remotePeerId, message); + } catch (error) { + this.logger.error('Failed to parse peer message', error); + } + }; + } + + private handlePeerMessage(peerId: string, message: any): void { + console.log('Received P2P message from', peerId, ':', message); + + if (message.type === P2P_TYPE_STATE_REQUEST || message.type === P2P_TYPE_VOICE_STATE_REQUEST) { + this.sendCurrentStatesToPeer(peerId); + return; + } + + const enriched = { ...message, fromPeerId: peerId }; + this.messageReceived$.next(enriched); + } + + // ─── Messaging helpers ───────────────────────────────────────────── + + /** Broadcast a ChatEvent to every peer with an open data channel. */ + broadcastMessage(event: ChatEvent): void { + const data = JSON.stringify(event); + this.activePeerConnections.forEach((peerData, peerId) => { + try { + if (peerData.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) { + peerData.dataChannel.send(data); + console.log('Sent message via P2P to:', peerId); + } + } catch (error) { + console.error(`Failed to send to peer ${peerId}:`, error); + } + }); + } + + /** Send a ChatEvent to a single peer. */ + sendToPeer(peerId: string, event: ChatEvent): void { + const peerData = this.activePeerConnections.get(peerId); + if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) { + console.error(`Peer ${peerId} not connected`); + return; + } + try { + peerData.dataChannel.send(JSON.stringify(event)); + } catch (error) { + console.error(`Failed to send to peer ${peerId}:`, error); + } + } + + /** Send with back-pressure awareness (for large payloads). */ + async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise { + const peerData = this.activePeerConnections.get(peerId); + if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) { + console.error(`Peer ${peerId} not connected`); + return; + } + + const channel = peerData.dataChannel; + const data = JSON.stringify(event); + + if (typeof channel.bufferedAmountLowThreshold === 'number') { + channel.bufferedAmountLowThreshold = DATA_CHANNEL_LOW_WATER_BYTES; + } + + if (channel.bufferedAmount > DATA_CHANNEL_HIGH_WATER_BYTES) { + await new Promise((resolve) => { + const handler = () => { + if (channel.bufferedAmount <= DATA_CHANNEL_LOW_WATER_BYTES) { + channel.removeEventListener('bufferedamountlow', handler as any); + resolve(); + } + }; + channel.addEventListener('bufferedamountlow', handler as any, { once: true } as any); + }); + } + + try { channel.send(data); } catch (error) { console.error(`Failed to send to peer ${peerId}:`, error); } + } + + // ─── State broadcasts ───────────────────────────────────────────── + + sendCurrentStatesToPeer(peerId: string): void { + const credentials = this.callbacks.getIdentifyCredentials(); + const oderId = credentials?.oderId || this.callbacks.getLocalPeerId(); + const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME; + const voiceState = this.callbacks.getVoiceStateSnapshot(); + + this.sendToPeer(peerId, { type: P2P_TYPE_VOICE_STATE, oderId, displayName, voiceState } as any); + this.sendToPeer(peerId, { type: P2P_TYPE_SCREEN_STATE, oderId, displayName, isScreenSharing: this.callbacks.isScreenSharingActive() } as any); + } + + private sendCurrentStatesToChannel(channel: RTCDataChannel, remotePeerId: string): void { + if (channel.readyState !== DATA_CHANNEL_STATE_OPEN) { + this.logger.warn('Cannot send states – channel not open', { remotePeerId, state: channel.readyState }); + return; + } + const credentials = this.callbacks.getIdentifyCredentials(); + const oderId = credentials?.oderId || this.callbacks.getLocalPeerId(); + const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME; + const voiceState = this.callbacks.getVoiceStateSnapshot(); + + try { + channel.send(JSON.stringify({ type: P2P_TYPE_VOICE_STATE, oderId, displayName, voiceState })); + channel.send(JSON.stringify({ type: P2P_TYPE_SCREEN_STATE, oderId, displayName, isScreenSharing: this.callbacks.isScreenSharingActive() })); + this.logger.info('Sent initial states to channel', { remotePeerId, voiceState }); + } catch (e) { + this.logger.error('Failed to send initial states to channel', e); + } + } + + broadcastCurrentStates(): void { + const credentials = this.callbacks.getIdentifyCredentials(); + const oderId = credentials?.oderId || this.callbacks.getLocalPeerId(); + const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME; + const voiceState = this.callbacks.getVoiceStateSnapshot(); + + this.broadcastMessage({ type: P2P_TYPE_VOICE_STATE, oderId, displayName, voiceState } as any); + this.broadcastMessage({ type: P2P_TYPE_SCREEN_STATE, oderId, displayName, isScreenSharing: this.callbacks.isScreenSharingActive() } as any); + } + + // ─── Remote tracks ───────────────────────────────────────────────── + + private handleRemoteTrack(event: RTCTrackEvent, remotePeerId: string): void { + const track = event.track; + const settings = typeof track.getSettings === 'function' ? track.getSettings() : {} as MediaTrackSettings; + this.logger.info('Remote track', { remotePeerId, kind: track.kind, id: track.id, enabled: track.enabled, readyState: track.readyState, settings }); + this.logger.attachTrackDiagnostics(track, `remote:${remotePeerId}:${track.kind}`); + + // Skip inactive video placeholder tracks + if (track.kind === TRACK_KIND_VIDEO && (!track.enabled || track.readyState !== 'live')) { + this.logger.info('Skipping inactive video track', { remotePeerId, enabled: track.enabled, readyState: track.readyState }); + return; + } + + // Merge into composite stream per peer + let compositeStream = this.remotePeerStreams.get(remotePeerId) || new MediaStream(); + const trackAlreadyAdded = compositeStream.getTracks().some(t => t.id === track.id); + if (!trackAlreadyAdded) { + try { compositeStream.addTrack(track); } catch (e) { this.logger.warn('Failed to add track to composite stream', e as any); } + } + this.remotePeerStreams.set(remotePeerId, compositeStream); + this.remoteStream$.next({ peerId: remotePeerId, stream: compositeStream }); + } + + // ─── Peer removal / cleanup ──────────────────────────────────────── + + removePeer(peerId: string): void { + const peerData = this.activePeerConnections.get(peerId); + if (peerData) { + if (peerData.dataChannel) peerData.dataChannel.close(); + peerData.connection.close(); + this.activePeerConnections.delete(peerId); + this.removeFromConnectedPeers(peerId); + this.peerDisconnected$.next(peerId); + } + } + + closeAllPeers(): void { + this.clearAllPeerReconnectTimers(); + this.activePeerConnections.forEach((peerData) => { + if (peerData.dataChannel) peerData.dataChannel.close(); + peerData.connection.close(); + }); + this.activePeerConnections.clear(); + this.connectedPeersChanged$.next([]); + } + + // ─── P2P reconnection ───────────────────────────────────────────── + + private trackDisconnectedPeer(peerId: string): void { + this.disconnectedPeerTracker.set(peerId, { lastSeenTimestamp: Date.now(), reconnectAttempts: 0 }); + } + + private clearPeerReconnectTimer(peerId: string): void { + const timer = this.peerReconnectTimers.get(peerId); + if (timer) { clearInterval(timer); this.peerReconnectTimers.delete(peerId); } + } + + clearAllPeerReconnectTimers(): void { + this.peerReconnectTimers.forEach((timer) => clearInterval(timer)); + this.peerReconnectTimers.clear(); + this.disconnectedPeerTracker.clear(); + } + + private schedulePeerReconnect(peerId: string): void { + if (this.peerReconnectTimers.has(peerId)) return; + this.logger.info('Scheduling P2P reconnect', { peerId }); + + const timer = setInterval(() => { + const info = this.disconnectedPeerTracker.get(peerId); + if (!info) { this.clearPeerReconnectTimer(peerId); return; } + + info.reconnectAttempts++; + this.logger.info('P2P reconnect attempt', { peerId, attempt: info.reconnectAttempts }); + + if (info.reconnectAttempts >= PEER_RECONNECT_MAX_ATTEMPTS) { + this.logger.info('P2P reconnect max attempts reached', { peerId }); + this.clearPeerReconnectTimer(peerId); + this.disconnectedPeerTracker.delete(peerId); + return; + } + + if (!this.callbacks.isSignalingConnected()) { + this.logger.info('Skipping P2P reconnect – no signaling connection', { peerId }); + return; + } + + this.attemptPeerReconnect(peerId); + }, PEER_RECONNECT_INTERVAL_MS); + + this.peerReconnectTimers.set(peerId, timer); + } + + private attemptPeerReconnect(peerId: string): void { + const existing = this.activePeerConnections.get(peerId); + if (existing) { try { existing.connection.close(); } catch { /* ignore */ } this.activePeerConnections.delete(peerId); } + this.createPeerConnection(peerId, true); + this.createAndSendOffer(peerId); + } + + private requestVoiceStateFromPeer(peerId: string): void { + const peerData = this.activePeerConnections.get(peerId); + if (peerData?.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) { + try { peerData.dataChannel.send(JSON.stringify({ type: P2P_TYPE_VOICE_STATE_REQUEST })); } catch (e) { this.logger.warn('Failed to request voice state', e as any); } + } + } + + // ─── Connected-peer helpers ──────────────────────────────────────── + + private connectedPeersList: string[] = []; + + getConnectedPeerIds(): string[] { + return [...this.connectedPeersList]; + } + + private addToConnectedPeers(peerId: string): void { + if (!this.connectedPeersList.includes(peerId)) { + this.connectedPeersList = [...this.connectedPeersList, peerId]; + this.connectedPeersChanged$.next(this.connectedPeersList); + } + } + + private removeFromConnectedPeers(peerId: string): void { + this.connectedPeersList = this.connectedPeersList.filter(p => p !== peerId); + this.connectedPeersChanged$.next(this.connectedPeersList); + } + + resetConnectedPeers(): void { + this.connectedPeersList = []; + this.connectedPeersChanged$.next([]); + } + + /** Clean up all resources. */ + destroy(): void { + this.closeAllPeers(); + this.peerConnected$.complete(); + this.peerDisconnected$.complete(); + this.remoteStream$.complete(); + this.messageReceived$.complete(); + this.connectedPeersChanged$.complete(); + } +} diff --git a/src/app/core/services/webrtc/screen-share.manager.ts b/src/app/core/services/webrtc/screen-share.manager.ts new file mode 100644 index 0000000..b28afa6 --- /dev/null +++ b/src/app/core/services/webrtc/screen-share.manager.ts @@ -0,0 +1,275 @@ +/** + * Manages screen sharing: getDisplayMedia / Electron desktop capturer, + * mixed audio (screen + mic), and attaching screen tracks to peers. + */ +import { WebRTCLogger } from './webrtc-logger'; +import { PeerData } from './webrtc.types'; +import { + TRACK_KIND_AUDIO, + TRACK_KIND_VIDEO, + TRANSCEIVER_SEND_RECV, + TRANSCEIVER_RECV_ONLY, + SCREEN_SHARE_IDEAL_WIDTH, + SCREEN_SHARE_IDEAL_HEIGHT, + SCREEN_SHARE_IDEAL_FRAME_RATE, + ELECTRON_ENTIRE_SCREEN_SOURCE_NAME, +} from './webrtc.constants'; + +/** + * Callbacks the ScreenShareManager needs from the owning service. + */ +export interface ScreenShareCallbacks { + getActivePeers(): Map; + getLocalMediaStream(): MediaStream | null; + renegotiate(peerId: string): Promise; + broadcastCurrentStates(): void; +} + +export class ScreenShareManager { + /** The active screen-capture stream. */ + private activeScreenStream: MediaStream | null = null; + + /** Mixed audio stream (screen audio + mic). */ + private combinedAudioStream: MediaStream | null = null; + + /** AudioContext used to mix screen + mic audio. */ + private audioMixingContext: AudioContext | null = null; + + /** Whether screen sharing is currently active. */ + private isScreenActive = false; + + constructor( + private readonly logger: WebRTCLogger, + private callbacks: ScreenShareCallbacks, + ) {} + + setCallbacks(cb: ScreenShareCallbacks): void { + this.callbacks = cb; + } + + // ─── Accessors ───────────────────────────────────────────────────── + + getScreenStream(): MediaStream | null { return this.activeScreenStream; } + getIsScreenActive(): boolean { return this.isScreenActive; } + + // ─── Start / Stop ────────────────────────────────────────────────── + + async startScreenShare(includeSystemAudio: boolean = false): Promise { + try { + this.logger.info('startScreenShare invoked', { includeSystemAudio }); + + // Try Electron desktop capturer first + if (typeof window !== 'undefined' && (window as any).electronAPI?.getSources) { + try { + const sources = await (window as any).electronAPI.getSources(); + const screenSource = sources.find((s: any) => s.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME) || sources[0]; + + const electronConstraints: any = { + video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id } }, + }; + if (includeSystemAudio) { + electronConstraints.audio = { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id } }; + } else { + electronConstraints.audio = false; + } + this.logger.info('desktopCapturer constraints', electronConstraints); + if (!navigator.mediaDevices?.getUserMedia) { + throw new Error('navigator.mediaDevices is not available (requires HTTPS or localhost).'); + } + this.activeScreenStream = await navigator.mediaDevices.getUserMedia(electronConstraints); + } catch (e) { + this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', e as any); + } + } + + // Fallback to standard getDisplayMedia + if (!this.activeScreenStream) { + const displayConstraints: DisplayMediaStreamOptions = { + video: { + width: { ideal: SCREEN_SHARE_IDEAL_WIDTH }, + height: { ideal: SCREEN_SHARE_IDEAL_HEIGHT }, + frameRate: { ideal: SCREEN_SHARE_IDEAL_FRAME_RATE }, + }, + audio: includeSystemAudio ? { echoCancellation: false, noiseSuppression: false, autoGainControl: false } : false, + } as any; + this.logger.info('getDisplayMedia constraints', displayConstraints); + if (!navigator.mediaDevices) { + throw new Error('navigator.mediaDevices is not available (requires HTTPS or localhost).'); + } + this.activeScreenStream = await (navigator.mediaDevices as any).getDisplayMedia(displayConstraints); + } + + this.logger.logStream('screen', this.activeScreenStream); + + // Prepare mixed audio if system audio is included + this.prepareMixedAudio(includeSystemAudio); + + // Attach tracks to peers + this.attachScreenTracksToPeers(includeSystemAudio); + + this.isScreenActive = true; + + // Auto-stop when user ends share via browser UI + const screenVideoTrack = this.activeScreenStream!.getVideoTracks()[0]; + if (screenVideoTrack) { + screenVideoTrack.onended = () => { + this.logger.warn('Screen video track ended'); + this.stopScreenShare(); + }; + } + + return this.activeScreenStream!; + } catch (error) { + this.logger.error('Failed to start screen share', error); + throw error; + } + } + + stopScreenShare(): void { + if (this.activeScreenStream) { + this.activeScreenStream.getTracks().forEach((track) => track.stop()); + this.activeScreenStream = null; + this.isScreenActive = false; + this.callbacks.broadcastCurrentStates(); + } + + // Clean up mixed audio + if (this.combinedAudioStream) { + try { this.combinedAudioStream.getTracks().forEach(t => t.stop()); } catch { /* ignore */ } + this.combinedAudioStream = null; + } + + // Remove video track and restore mic on all peers + this.callbacks.getActivePeers().forEach((peerData, peerId) => { + const transceivers = peerData.connection.getTransceivers(); + const videoTransceiver = transceivers.find(t => t.sender === peerData.videoSender || t.sender === peerData.screenVideoSender); + if (videoTransceiver) { + videoTransceiver.sender.replaceTrack(null).catch(() => {}); + if (videoTransceiver.direction === TRANSCEIVER_SEND_RECV) { + videoTransceiver.direction = TRANSCEIVER_RECV_ONLY; + } + } + peerData.screenVideoSender = undefined; + peerData.screenAudioSender = undefined; + + // Restore mic track + const micTrack = this.callbacks.getLocalMediaStream()?.getAudioTracks()[0] || null; + if (micTrack) { + let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO); + if (!audioSender) { + const transceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV }); + audioSender = transceiver.sender; + } + peerData.audioSender = audioSender; + audioSender.replaceTrack(micTrack).catch((e) => console.error('restore mic replaceTrack failed:', e)); + } + this.callbacks.renegotiate(peerId); + }); + } + + // ─── Internal helpers ────────────────────────────────────────────── + + /** Create a mixed audio stream from screen audio + mic audio. */ + private prepareMixedAudio(includeSystemAudio: boolean): void { + const screenAudioTrack = includeSystemAudio ? (this.activeScreenStream?.getAudioTracks()[0] || null) : null; + const micAudioTrack = this.callbacks.getLocalMediaStream()?.getAudioTracks()[0] || null; + + if (includeSystemAudio && screenAudioTrack) { + try { + if (!this.audioMixingContext && (window as any).AudioContext) { + this.audioMixingContext = new (window as any).AudioContext(); + } + if (!this.audioMixingContext) throw new Error('AudioContext not available'); + + const destination = this.audioMixingContext.createMediaStreamDestination(); + + const screenAudioSource = this.audioMixingContext.createMediaStreamSource(new MediaStream([screenAudioTrack])); + screenAudioSource.connect(destination); + + if (micAudioTrack) { + const micAudioSource = this.audioMixingContext.createMediaStreamSource(new MediaStream([micAudioTrack])); + micAudioSource.connect(destination); + this.logger.info('Mixed mic + screen audio together'); + } + + this.combinedAudioStream = destination.stream; + this.logger.logStream('combinedAudio(screen+mic)', this.combinedAudioStream); + } catch (e) { + this.logger.warn('Mixed audio creation failed; fallback to screen audio only', e as any); + this.combinedAudioStream = screenAudioTrack ? new MediaStream([screenAudioTrack]) : null; + this.logger.logStream('combinedAudio(fallback)', this.combinedAudioStream); + } + } else { + this.combinedAudioStream = null; + } + } + + /** Attach screen video + audio tracks to all active peers. */ + private attachScreenTracksToPeers(includeSystemAudio: boolean): void { + this.callbacks.getActivePeers().forEach((peerData, peerId) => { + if (!this.activeScreenStream) return; + + const screenVideoTrack = this.activeScreenStream.getVideoTracks()[0]; + if (!screenVideoTrack) return; + this.logger.attachTrackDiagnostics(screenVideoTrack, `screenVideo:${peerId}`); + + // Use primary video sender/transceiver + let videoSender = peerData.videoSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_VIDEO); + if (!videoSender) { + const videoTransceiver = peerData.connection.addTransceiver(TRACK_KIND_VIDEO, { direction: TRANSCEIVER_SEND_RECV }); + videoSender = videoTransceiver.sender; + peerData.videoSender = videoSender; + } else { + const transceivers = peerData.connection.getTransceivers(); + const videoTransceiver = transceivers.find(t => t.sender === videoSender); + if (videoTransceiver?.direction === TRANSCEIVER_RECV_ONLY) { + videoTransceiver.direction = TRANSCEIVER_SEND_RECV; + } + } + peerData.screenVideoSender = videoSender; + videoSender.replaceTrack(screenVideoTrack) + .then(() => this.logger.info('screen video replaceTrack ok', { peerId })) + .catch((e) => this.logger.error('screen video replaceTrack failed', e)); + + // Audio handling + const micTrack = this.callbacks.getLocalMediaStream()?.getAudioTracks()[0] || null; + if (includeSystemAudio) { + const combinedTrack = this.combinedAudioStream?.getAudioTracks()[0] || null; + if (combinedTrack) { + this.logger.attachTrackDiagnostics(combinedTrack, `combinedAudio:${peerId}`); + let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO); + if (!audioSender) { + const transceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV }); + audioSender = transceiver.sender; + } + peerData.audioSender = audioSender; + audioSender.replaceTrack(combinedTrack) + .then(() => this.logger.info('screen audio(combined) replaceTrack ok', { peerId })) + .catch((e) => this.logger.error('audio replaceTrack (combined) failed', e)); + } + } else if (micTrack) { + this.logger.attachTrackDiagnostics(micTrack, `micAudio:${peerId}`); + let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO); + if (!audioSender) { + const transceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV }); + audioSender = transceiver.sender; + } + peerData.audioSender = audioSender; + audioSender.replaceTrack(micTrack) + .then(() => this.logger.info('screen audio(mic) replaceTrack ok', { peerId })) + .catch((e) => this.logger.error('audio replaceTrack (mic) failed', e)); + } + + this.callbacks.renegotiate(peerId); + }); + } + + /** Clean up all resources. */ + destroy(): void { + this.stopScreenShare(); + if (this.audioMixingContext) { + try { this.audioMixingContext.close(); } catch { /* ignore */ } + this.audioMixingContext = null; + } + } +} diff --git a/src/app/core/services/webrtc/signaling.manager.ts b/src/app/core/services/webrtc/signaling.manager.ts new file mode 100644 index 0000000..c636a05 --- /dev/null +++ b/src/app/core/services/webrtc/signaling.manager.ts @@ -0,0 +1,219 @@ +/** + * Manages the WebSocket connection to the signaling server, + * including automatic reconnection and heartbeats. + */ +import { Observable, Subject } from 'rxjs'; +import { SignalingMessage } from '../../models'; +import { WebRTCLogger } from './webrtc-logger'; +import { IdentifyCredentials, JoinedServerInfo } from './webrtc.types'; +import { + SIGNALING_RECONNECT_BASE_DELAY_MS, + SIGNALING_RECONNECT_MAX_DELAY_MS, + SIGNALING_CONNECT_TIMEOUT_MS, + STATE_HEARTBEAT_INTERVAL_MS, + SIGNALING_TYPE_IDENTIFY, + SIGNALING_TYPE_JOIN_SERVER, + SIGNALING_TYPE_VIEW_SERVER, +} from './webrtc.constants'; + +export class SignalingManager { + private signalingWebSocket: WebSocket | null = null; + private lastSignalingUrl: string | null = null; + private signalingReconnectAttempts = 0; + private signalingReconnectTimer: ReturnType | null = null; + private stateHeartbeatTimer: ReturnType | null = null; + + /** Fires every heartbeat tick – the main service hooks this to broadcast state. */ + readonly heartbeatTick$ = new Subject(); + + /** Fires whenever a raw signaling message arrives from the server. */ + readonly messageReceived$ = new Subject(); + + /** Fires when connection status changes (true = open, false = closed/error). */ + readonly connectionStatus$ = new Subject<{ connected: boolean; errorMessage?: string }>(); + + constructor( + private readonly logger: WebRTCLogger, + private readonly getLastIdentify: () => IdentifyCredentials | null, + private readonly getLastJoinedServer: () => JoinedServerInfo | null, + private readonly getMemberServerIds: () => ReadonlySet, + ) {} + + // ─── Public API ──────────────────────────────────────────────────── + + /** Open (or re-open) a WebSocket to the signaling server. */ + connect(serverUrl: string): Observable { + this.lastSignalingUrl = serverUrl; + return new Observable((observer) => { + try { + if (this.signalingWebSocket) { + this.signalingWebSocket.close(); + } + + this.lastSignalingUrl = serverUrl; + this.signalingWebSocket = new WebSocket(serverUrl); + + this.signalingWebSocket.onopen = () => { + this.logger.info('Connected to signaling server'); + this.clearReconnect(); + this.startHeartbeat(); + this.connectionStatus$.next({ connected: true }); + this.reIdentifyAndRejoin(); + observer.next(true); + }; + + this.signalingWebSocket.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + this.messageReceived$.next(message); + } catch (error) { + this.logger.error('Failed to parse signaling message', error); + } + }; + + this.signalingWebSocket.onerror = (error) => { + this.logger.error('Signaling socket error', error); + this.connectionStatus$.next({ connected: false, errorMessage: 'Connection to signaling server failed' }); + observer.error(error); + }; + + this.signalingWebSocket.onclose = () => { + this.logger.info('Disconnected from signaling server'); + this.stopHeartbeat(); + this.connectionStatus$.next({ connected: false, errorMessage: 'Disconnected from signaling server' }); + this.scheduleReconnect(); + }; + } catch (error) { + observer.error(error); + } + }); + } + + /** Ensure signaling is connected; try reconnecting if not. */ + async ensureConnected(timeoutMs: number = SIGNALING_CONNECT_TIMEOUT_MS): Promise { + if (this.isSocketOpen()) return true; + if (!this.lastSignalingUrl) return false; + + return new Promise((resolve) => { + let settled = false; + const timeout = setTimeout(() => { + if (!settled) { settled = true; resolve(false); } + }, timeoutMs); + + this.connect(this.lastSignalingUrl!).subscribe({ + next: () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(true); } }, + error: () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(false); } }, + }); + }); + } + + /** Send a signaling message (with `from` / `timestamp` populated). */ + sendSignalingMessage(message: Omit, localPeerId: string): void { + if (!this.isSocketOpen()) { + this.logger.error('Signaling socket not connected', new Error('Socket not open')); + return; + } + const fullMessage: SignalingMessage = { ...message, from: localPeerId, timestamp: Date.now() }; + this.signalingWebSocket!.send(JSON.stringify(fullMessage)); + } + + /** Send a raw JSON payload (for identify, join_server, etc.). */ + sendRawMessage(message: Record): void { + if (!this.isSocketOpen()) { + this.logger.error('Signaling socket not connected', new Error('Socket not open')); + return; + } + this.signalingWebSocket!.send(JSON.stringify(message)); + } + + /** Gracefully close the WebSocket. */ + close(): void { + this.stopHeartbeat(); + this.clearReconnect(); + if (this.signalingWebSocket) { + this.signalingWebSocket.close(); + this.signalingWebSocket = null; + } + } + + /** Whether the underlying WebSocket is currently open. */ + isSocketOpen(): boolean { + return this.signalingWebSocket !== null && this.signalingWebSocket.readyState === WebSocket.OPEN; + } + + /** The URL last used to connect (needed for reconnection). */ + getLastUrl(): string | null { + return this.lastSignalingUrl; + } + + // ─── Internals ───────────────────────────────────────────────────── + + /** Re-identify and rejoin servers after a reconnect. */ + private reIdentifyAndRejoin(): void { + const credentials = this.getLastIdentify(); + if (credentials) { + this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, oderId: credentials.oderId, displayName: credentials.displayName }); + } + + const memberIds = this.getMemberServerIds(); + if (memberIds.size > 0) { + memberIds.forEach((serverId) => { + this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId }); + }); + const lastJoined = this.getLastJoinedServer(); + if (lastJoined) { + this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER, serverId: lastJoined.serverId }); + } + } else { + const lastJoined = this.getLastJoinedServer(); + if (lastJoined) { + this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId: lastJoined.serverId }); + } + } + } + + private scheduleReconnect(): void { + if (this.signalingReconnectTimer || !this.lastSignalingUrl) return; + const delay = Math.min( + SIGNALING_RECONNECT_MAX_DELAY_MS, + SIGNALING_RECONNECT_BASE_DELAY_MS * Math.pow(2, this.signalingReconnectAttempts), + ); + this.signalingReconnectTimer = setTimeout(() => { + this.signalingReconnectTimer = null; + this.signalingReconnectAttempts++; + this.logger.info('Attempting to reconnect to signaling...'); + this.connect(this.lastSignalingUrl!).subscribe({ + next: () => { this.signalingReconnectAttempts = 0; }, + error: () => { this.scheduleReconnect(); }, + }); + }, delay); + } + + private clearReconnect(): void { + if (this.signalingReconnectTimer) { + clearTimeout(this.signalingReconnectTimer); + this.signalingReconnectTimer = null; + } + this.signalingReconnectAttempts = 0; + } + + private startHeartbeat(): void { + this.stopHeartbeat(); + this.stateHeartbeatTimer = setInterval(() => this.heartbeatTick$.next(), STATE_HEARTBEAT_INTERVAL_MS); + } + + private stopHeartbeat(): void { + if (this.stateHeartbeatTimer) { + clearInterval(this.stateHeartbeatTimer); + this.stateHeartbeatTimer = null; + } + } + + /** Clean up all resources. */ + destroy(): void { + this.close(); + this.heartbeatTick$.complete(); + this.messageReceived$.complete(); + this.connectionStatus$.complete(); + } +} diff --git a/src/app/core/services/webrtc/webrtc-logger.ts b/src/app/core/services/webrtc/webrtc-logger.ts new file mode 100644 index 0000000..72afc09 --- /dev/null +++ b/src/app/core/services/webrtc/webrtc-logger.ts @@ -0,0 +1,66 @@ +/** + * Lightweight logging utility for the WebRTC subsystem. + * All log lines are prefixed with `[WebRTC]`. + */ +export class WebRTCLogger { + constructor(private readonly isEnabled: boolean = true) {} + + /** Informational log (only when debug is enabled). */ + info(prefix: string, ...args: unknown[]): void { + if (!this.isEnabled) return; + try { console.log(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ } + } + + /** Warning log (only when debug is enabled). */ + warn(prefix: string, ...args: unknown[]): void { + if (!this.isEnabled) return; + try { console.warn(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ } + } + + /** Error log (always emitted regardless of debug flag). */ + error(prefix: string, err: unknown, extra?: Record): void { + const payload = { + name: (err as any)?.name, + message: (err as any)?.message, + stack: (err as any)?.stack, + ...extra, + }; + try { console.error(`[WebRTC] ${prefix}`, payload); } catch { /* swallow */ } + } + + // ─── Track / Stream diagnostics ────────────────────────────────── + + /** Attach lifecycle event listeners to a track for debugging. */ + attachTrackDiagnostics(track: MediaStreamTrack, label: string): void { + const settings = typeof track.getSettings === 'function' ? track.getSettings() : {} as MediaTrackSettings; + this.info(`Track attached: ${label}`, { + id: track.id, + kind: track.kind, + readyState: track.readyState, + contentHint: track.contentHint, + settings, + }); + + track.addEventListener('ended', () => this.warn(`Track ended: ${label}`, { id: track.id, kind: track.kind })); + track.addEventListener('mute', () => this.warn(`Track muted: ${label}`, { id: track.id, kind: track.kind })); + track.addEventListener('unmute', () => this.info(`Track unmuted: ${label}`, { id: track.id, kind: track.kind })); + } + + /** Log a MediaStream summary and attach diagnostics to every track. */ + logStream(label: string, stream: MediaStream | null): void { + if (!stream) { + this.warn(`Stream missing: ${label}`); + return; + } + const audioTracks = stream.getAudioTracks(); + const videoTracks = stream.getVideoTracks(); + this.info(`Stream ready: ${label}`, { + id: (stream as any).id, + audioTrackCount: audioTracks.length, + videoTrackCount: videoTracks.length, + allTrackIds: stream.getTracks().map(t => ({ id: t.id, kind: t.kind })), + }); + audioTracks.forEach((t, i) => this.attachTrackDiagnostics(t, `${label}:audio#${i}`)); + videoTracks.forEach((t, i) => this.attachTrackDiagnostics(t, `${label}:video#${i}`)); + } +} diff --git a/src/app/core/services/webrtc/webrtc.constants.ts b/src/app/core/services/webrtc/webrtc.constants.ts new file mode 100644 index 0000000..6312a6d --- /dev/null +++ b/src/app/core/services/webrtc/webrtc.constants.ts @@ -0,0 +1,106 @@ +/** + * All magic numbers and strings used across the WebRTC subsystem. + * Centralised here so nothing is hard-coded inline. + */ + +// ─── ICE / STUN ────────────────────────────────────────────────────── +export const ICE_SERVERS: RTCIceServer[] = [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' }, + { urls: 'stun:stun2.l.google.com:19302' }, + { urls: 'stun:stun3.l.google.com:19302' }, + { urls: 'stun:stun4.l.google.com:19302' }, +]; + +// ─── Signaling reconnection ────────────────────────────────────────── +/** Base delay (ms) for exponential backoff on signaling reconnect */ +export const SIGNALING_RECONNECT_BASE_DELAY_MS = 1_000; +/** Maximum delay (ms) between signaling reconnect attempts */ +export const SIGNALING_RECONNECT_MAX_DELAY_MS = 30_000; +/** Default timeout (ms) for `ensureSignalingConnected` */ +export const SIGNALING_CONNECT_TIMEOUT_MS = 5_000; + +// ─── Peer-to-peer reconnection ────────────────────────────────────── +/** Maximum P2P reconnect attempts before giving up */ +export const PEER_RECONNECT_MAX_ATTEMPTS = 12; +/** Interval (ms) between P2P reconnect attempts */ +export const PEER_RECONNECT_INTERVAL_MS = 5_000; + +// ─── Heartbeat / presence ──────────────────────────────────────────── +/** Interval (ms) for broadcasting state heartbeats */ +export const STATE_HEARTBEAT_INTERVAL_MS = 5_000; +/** Interval (ms) for broadcasting voice presence */ +export const VOICE_HEARTBEAT_INTERVAL_MS = 5_000; + +// ─── Data-channel back-pressure ────────────────────────────────────── +/** Data channel name used for P2P chat */ +export const DATA_CHANNEL_LABEL = 'chat'; +/** High-water mark (bytes) – pause sending when buffered amount exceeds this */ +export const DATA_CHANNEL_HIGH_WATER_BYTES = 4 * 1024 * 1024; // 4 MB +/** Low-water mark (bytes) – resume sending once buffered amount drops below this */ +export const DATA_CHANNEL_LOW_WATER_BYTES = 1 * 1024 * 1024; // 1 MB + +// ─── Screen share defaults ─────────────────────────────────────────── +export const SCREEN_SHARE_IDEAL_WIDTH = 1920; +export const SCREEN_SHARE_IDEAL_HEIGHT = 1080; +export const SCREEN_SHARE_IDEAL_FRAME_RATE = 30; +/** Electron source name to prefer for whole-screen capture */ +export const ELECTRON_ENTIRE_SCREEN_SOURCE_NAME = 'Entire Screen'; + +// ─── Audio bitrate ─────────────────────────────────────────────────── +/** Minimum audio bitrate (bps) */ +export const AUDIO_BITRATE_MIN_BPS = 16_000; +/** Maximum audio bitrate (bps) */ +export const AUDIO_BITRATE_MAX_BPS = 256_000; +/** Multiplier to convert kbps → bps */ +export const KBPS_TO_BPS = 1_000; +/** Pre-defined latency-to-bitrate mappings (bps) */ +export const LATENCY_PROFILE_BITRATES = { + low: 64_000, + balanced: 96_000, + high: 128_000, +} as const; +export type LatencyProfile = keyof typeof LATENCY_PROFILE_BITRATES; + +// ─── RTC transceiver directions ────────────────────────────────────── +export const TRANSCEIVER_SEND_RECV: RTCRtpTransceiverDirection = 'sendrecv'; +export const TRANSCEIVER_RECV_ONLY: RTCRtpTransceiverDirection = 'recvonly'; +export const TRANSCEIVER_INACTIVE: RTCRtpTransceiverDirection = 'inactive'; + +// ─── Connection / data-channel states (for readability) ────────────── +export const CONNECTION_STATE_CONNECTED = 'connected'; +export const CONNECTION_STATE_DISCONNECTED = 'disconnected'; +export const CONNECTION_STATE_FAILED = 'failed'; +export const CONNECTION_STATE_CLOSED = 'closed'; +export const DATA_CHANNEL_STATE_OPEN = 'open'; + +// ─── Track kinds ───────────────────────────────────────────────────── +export const TRACK_KIND_AUDIO = 'audio'; +export const TRACK_KIND_VIDEO = 'video'; + +// ─── Signaling message types ───────────────────────────────────────── +export const SIGNALING_TYPE_IDENTIFY = 'identify'; +export const SIGNALING_TYPE_JOIN_SERVER = 'join_server'; +export const SIGNALING_TYPE_VIEW_SERVER = 'view_server'; +export const SIGNALING_TYPE_LEAVE_SERVER = 'leave_server'; +export const SIGNALING_TYPE_OFFER = 'offer'; +export const SIGNALING_TYPE_ANSWER = 'answer'; +export const SIGNALING_TYPE_ICE_CANDIDATE = 'ice_candidate'; +export const SIGNALING_TYPE_CONNECTED = 'connected'; +export const SIGNALING_TYPE_SERVER_USERS = 'server_users'; +export const SIGNALING_TYPE_USER_JOINED = 'user_joined'; +export const SIGNALING_TYPE_USER_LEFT = 'user_left'; + +// ─── P2P message types ────────────────────────────────────────────── +export const P2P_TYPE_STATE_REQUEST = 'state-request'; +export const P2P_TYPE_VOICE_STATE_REQUEST = 'voice-state-request'; +export const P2P_TYPE_VOICE_STATE = 'voice-state'; +export const P2P_TYPE_SCREEN_STATE = 'screen-state'; + +// ─── Misc ──────────────────────────────────────────────────────────── +/** Default display name fallback */ +export const DEFAULT_DISPLAY_NAME = 'User'; +/** Minimum volume (normalised 0-1) */ +export const VOLUME_MIN = 0; +/** Maximum volume (normalised 0-1) */ +export const VOLUME_MAX = 1; diff --git a/src/app/core/services/webrtc/webrtc.types.ts b/src/app/core/services/webrtc/webrtc.types.ts new file mode 100644 index 0000000..64d2dc9 --- /dev/null +++ b/src/app/core/services/webrtc/webrtc.types.ts @@ -0,0 +1,43 @@ +/** + * Shared type definitions for the WebRTC subsystem. + */ + +/** Tracks a single peer's connection, data channel, and RTP senders. */ +export interface PeerData { + connection: RTCPeerConnection; + dataChannel: RTCDataChannel | null; + isInitiator: boolean; + pendingIceCandidates: RTCIceCandidateInit[]; + audioSender?: RTCRtpSender; + videoSender?: RTCRtpSender; + screenVideoSender?: RTCRtpSender; + screenAudioSender?: RTCRtpSender; +} + +/** Credentials cached for automatic re-identification after reconnect. */ +export interface IdentifyCredentials { + oderId: string; + displayName: string; +} + +/** Last-joined server info, used for reconnection. */ +export interface JoinedServerInfo { + serverId: string; + userId: string; +} + +/** Entry in the disconnected-peer tracker for P2P reconnect scheduling. */ +export interface DisconnectedPeerEntry { + lastSeenTimestamp: number; + reconnectAttempts: number; +} + +/** Snapshot of current voice / screen state (broadcast to peers). */ +export interface VoiceStateSnapshot { + isConnected: boolean; + isMuted: boolean; + isDeafened: boolean; + isScreenSharing: boolean; + roomId?: string; + serverId?: string; +} diff --git a/src/app/features/admin/admin-panel/admin-panel.component.html b/src/app/features/admin/admin-panel/admin-panel.component.html index 2060bbb..73db809 100644 --- a/src/app/features/admin/admin-panel/admin-panel.component.html +++ b/src/app/features/admin/admin-panel/admin-panel.component.html @@ -19,6 +19,17 @@ Settings + @@ -125,6 +136,65 @@ } + @case ('members') { +
+

Server Members

+ + @if (membersFiltered().length === 0) { +

+ No other members online +

+ } @else { + @for (user of membersFiltered(); track user.id) { +
+
+ {{ user.displayName ? user.displayName.charAt(0).toUpperCase() : '?' }} +
+
+
+

{{ user.displayName }}

+ @if (user.role === 'host') { + Owner + } @else if (user.role === 'admin') { + Admin + } @else if (user.role === 'moderator') { + Mod + } +
+
+ + @if (user.role !== 'host') { +
+ + + +
+ } +
+ } + } +
+ } @case ('bans') {

Banned Users

diff --git a/src/app/features/admin/admin-panel/admin-panel.component.ts b/src/app/features/admin/admin-panel/admin-panel.component.ts index 6bba8e4..8ae3a86 100644 --- a/src/app/features/admin/admin-panel/admin-panel.component.ts +++ b/src/app/features/admin/admin-panel/admin-panel.component.ts @@ -23,10 +23,12 @@ import { selectBannedUsers, selectIsCurrentUserAdmin, selectCurrentUser, + selectOnlineUsers, } from '../../../store/users/users.selectors'; -import { BanEntry, Room } from '../../../core/models'; +import { BanEntry, Room, User } from '../../../core/models'; +import { WebRTCService } from '../../../core/services/webrtc.service'; -type AdminTab = 'settings' | 'bans' | 'permissions'; +type AdminTab = 'settings' | 'members' | 'bans' | 'permissions'; @Component({ selector: 'app-admin-panel', @@ -50,11 +52,13 @@ type AdminTab = 'settings' | 'bans' | 'permissions'; }) export class AdminPanelComponent { private store = inject(Store); + private webrtc = inject(WebRTCService); currentRoom = this.store.selectSignal(selectCurrentRoom); currentUser = this.store.selectSignal(selectCurrentUser); isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin); bannedUsers = this.store.selectSignal(selectBannedUsers); + onlineUsers = this.store.selectSignal(selectOnlineUsers); activeTab = signal('settings'); showDeleteConfirm = signal(false); @@ -157,4 +161,37 @@ export class AdminPanelComponent { const date = new Date(timestamp); return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } + + // Members tab: get all users except self + membersFiltered(): User[] { + const me = this.currentUser(); + return this.onlineUsers().filter(u => u.id !== me?.id && u.oderId !== me?.oderId); + } + + changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void { + this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role })); + this.webrtc.broadcastMessage({ + type: 'role-change', + targetUserId: user.id, + role, + }); + } + + kickMember(user: User): void { + this.store.dispatch(UsersActions.kickUser({ userId: user.id })); + this.webrtc.broadcastMessage({ + type: 'kick', + targetUserId: user.id, + kickedBy: this.currentUser()?.id, + }); + } + + banMember(user: User): void { + this.store.dispatch(UsersActions.banUser({ userId: user.id })); + this.webrtc.broadcastMessage({ + type: 'ban', + targetUserId: user.id, + bannedBy: this.currentUser()?.id, + }); + } } diff --git a/src/app/features/chat/chat-messages.component.ts b/src/app/features/chat/chat-messages.component.ts index 9b3223d..f72758a 100644 --- a/src/app/features/chat/chat-messages.component.ts +++ b/src/app/features/chat/chat-messages.component.ts @@ -1,4 +1,4 @@ -import { Component, inject, signal, computed, effect, ElementRef, ViewChild, AfterViewChecked, OnInit, OnDestroy } from '@angular/core'; +import { Component, inject, signal, computed, effect, ElementRef, ViewChild, AfterViewChecked, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Store } from '@ngrx/store'; @@ -13,11 +13,16 @@ import { lucideMoreVertical, lucideCheck, lucideX, + lucideDownload, + lucideExpand, + lucideImage, + lucideCopy, } from '@ng-icons/lucide'; import * as MessagesActions from '../../store/messages/messages.actions'; -import { selectAllMessages, selectMessagesLoading } from '../../store/messages/messages.selectors'; +import { selectAllMessages, selectMessagesLoading, selectMessagesSyncing } from '../../store/messages/messages.selectors'; import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../store/users/users.selectors'; +import { selectCurrentRoom, selectActiveChannelId } from '../../store/rooms/rooms.selectors'; import { Message } from '../../core/models'; import { WebRTCService } from '../../core/services/webrtc.service'; import { Subscription } from 'rxjs'; @@ -42,12 +47,23 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥', lucideMoreVertical, lucideCheck, lucideX, + lucideDownload, + lucideExpand, + lucideImage, + lucideCopy, }), ], template: `
+ + @if (syncing() && !loading()) { +
+
+ Syncing messages… +
+ } @if (loading()) {
@@ -58,8 +74,21 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',

Be the first to say something!

} @else { + + @if (hasMoreMessages()) { +
+ @if (loadingMore()) { +
+ } @else { + + } +
+ } @for (message of messages(); track message.id) {
@@ -70,6 +99,20 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
+ + @if (message.replyToId) { + @let repliedMsg = getRepliedMessage(message.replyToId); +
+
+ + @if (repliedMsg) { + {{ repliedMsg.senderName }} + {{ repliedMsg.content }} + } @else { + Original message not found + } +
+ }
{{ message.senderName }} @@ -110,20 +153,69 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥', @for (att of getAttachments(message.id); track att.id) { @if (att.isImage) { @if (att.available && att.objectUrl) { - image - } @else { -
-
-
-
{{ att.filename }}
-
{{ formatBytes(att.size) }}
+ +
+ +
+
+ + +
+
+ } @else if ((att.receivedBytes || 0) > 0) { + +
+
+
+
-
{{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}%
+
+
{{ att.filename }}
+
{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}
+
+
{{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}%
-
-
+
+
+ } @else { + +
+
+
+ +
+
+
{{ att.filename }}
+
{{ formatBytes(att.size) }}
+
Waiting for image source…
+
+
+ +
} } @else {
@@ -357,6 +449,76 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
+ + + @if (lightboxAttachment()) { +
+
+ + +
+ + +
+ +
+
+ {{ lightboxAttachment()!.filename }} + {{ formatBytes(lightboxAttachment()!.size) }} +
+
+
+
+ } + + + @if (imageContextMenu()) { +
+
+ + +
+ } `, }) export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestroy { @@ -368,11 +530,40 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro private sanitizer = inject(DomSanitizer); private serverDirectory = inject(ServerDirectoryService); private attachmentsSvc = inject(AttachmentService); + private cdr = inject(ChangeDetectorRef); - messages = this.store.selectSignal(selectAllMessages); + private allMessages = this.store.selectSignal(selectAllMessages); + private activeChannelId = this.store.selectSignal(selectActiveChannelId); + + // --- Infinite scroll (upwards) pagination --- + private readonly PAGE_SIZE = 50; + displayLimit = signal(this.PAGE_SIZE); + loadingMore = signal(false); + + /** All messages for the current channel (full list, unsliced) */ + private allChannelMessages = computed(() => { + const channelId = this.activeChannelId(); + const roomId = this.currentRoom()?.id; + return this.allMessages().filter(m => + m.roomId === roomId && (m.channelId || 'general') === channelId + ); + }); + + /** Paginated view — only the most recent `displayLimit` messages */ + messages = computed(() => { + const all = this.allChannelMessages(); + const limit = this.displayLimit(); + if (all.length <= limit) return all; + return all.slice(all.length - limit); + }); + + /** Whether there are older messages that can be loaded */ + hasMoreMessages = computed(() => this.allChannelMessages().length > this.displayLimit()); loading = this.store.selectSignal(selectMessagesLoading); + syncing = this.store.selectSignal(selectMessagesSyncing); currentUser = this.store.selectSignal(selectCurrentUser); isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin); + private currentRoom = this.store.selectSignal(selectCurrentRoom); messageContent = ''; editContent = ''; @@ -383,6 +574,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro readonly commonEmojis = COMMON_EMOJIS; private shouldScrollToBottom = true; + /** Keeps us pinned to bottom while images/attachments load after initial open */ + private initialScrollObserver: MutationObserver | null = null; + private initialScrollTimer: any = null; + private boundOnImageLoad: (() => void) | null = null; + /** True while a programmatic scroll-to-bottom is in progress (suppresses onScroll). */ + private isAutoScrolling = false; private typingSub?: Subscription; private lastTypingSentAt = 0; private readonly typingTTL = 3000; // ms to keep a user as typing @@ -396,8 +593,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro typingOthersCount = signal(0); // New messages snackbar state showNewMessagesBar = signal(false); - // Stable reference time to avoid ExpressionChanged errors (updated every minute) - nowRef = signal(Date.now()); + // Plain (non-reactive) reference time used only by formatTimestamp. + // Updated periodically but NOT a signal, so it won't re-render every message. + private nowRef = Date.now(); private nowTimer: any; toolbarVisible = signal(false); private toolbarHovering = false; @@ -405,16 +603,46 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro dragActive = signal(false); // Cache blob URLs for proxied images to prevent repeated network fetches on re-render private imageBlobCache = new Map(); - // Re-render when attachments update - private attachmentsUpdatedEffect = effect(() => { - // Subscribe to updates; no-op body - void this.attachmentsSvc.updated(); + // Cache rendered markdown to preserve text selection across re-renders + private markdownCache = new Map(); + + // Image lightbox modal state + lightboxAttachment = signal(null); + // Image right-click context menu state + imageContextMenu = signal<{ x: number; y: number; attachment: Attachment } | null>(null); + private boundOnKeydown: ((e: KeyboardEvent) => void) | null = null; + + // Reset scroll state when room/server changes (handles reuse of component on navigation) + private onRoomChanged = effect(() => { + void this.currentRoom(); // track room signal + this.initialScrollPending = true; + this.stopInitialScrollWatch(); + this.showNewMessagesBar.set(false); + this.lastMessageCount = 0; + this.displayLimit.set(this.PAGE_SIZE); + this.markdownCache.clear(); }); - // Messages length signal and effect to detect new messages without blocking change detection + // Reset pagination when switching channels within the same room + private onChannelChanged = effect(() => { + void this.activeChannelId(); // track channel signal + this.displayLimit.set(this.PAGE_SIZE); + this.initialScrollPending = true; + this.showNewMessagesBar.set(false); + this.lastMessageCount = 0; + this.markdownCache.clear(); + }); + // Re-render when attachments update (e.g. download progress from WebRTC callbacks) + private attachmentsUpdatedEffect = effect(() => { + void this.attachmentsSvc.updated(); + this.cdr.markForCheck(); + }); + + // Track total channel messages (not paginated) for new-message detection + private totalChannelMessagesLength = computed(() => this.allChannelMessages().length); messagesLength = computed(() => this.messages().length); private onMessagesChanged = effect(() => { - const currentCount = this.messagesLength(); + const currentCount = this.totalChannelMessagesLength(); const el = this.messagesContainer?.nativeElement; if (!el) { this.lastMessageCount = currentCount; @@ -446,12 +674,23 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro const el = this.messagesContainer?.nativeElement; if (!el) return; - // First render after connect: scroll to bottom by default (no animation) + // First render after connect: scroll to bottom instantly (no animation) + // Only proceed once messages are actually rendered in the DOM if (this.initialScrollPending) { - this.initialScrollPending = false; - this.scrollToBottom(); - this.showNewMessagesBar.set(false); - this.lastMessageCount = this.messages().length; + if (this.messages().length > 0) { + this.initialScrollPending = false; + // Snap to bottom immediately, then keep watching for late layout changes + this.isAutoScrolling = true; + el.scrollTop = el.scrollHeight; + requestAnimationFrame(() => { this.isAutoScrolling = false; }); + this.startInitialScrollWatch(); + this.showNewMessagesBar.set(false); + this.lastMessageCount = this.messages().length; + } else if (!this.loading()) { + // Room has no messages and loading is done + this.initialScrollPending = false; + this.lastMessageCount = 0; + } this.loadCspImages(); return; } @@ -468,21 +707,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro } }); - // If we're the uploader and our original file was lost (e.g., after navigation), prompt reselect - this.attachmentsSvc.onMissingOriginal.subscribe(({ messageId, fileId, fromPeerId }) => { - try { - const input = document.createElement('input'); - input.type = 'file'; - input.onchange = async () => { - const file = input.files?.[0]; - if (file) { - await this.attachmentsSvc.fulfillRequestWithFile(messageId, fileId, fromPeerId, file); - } - }; - input.click(); - } catch {} - }); - // Periodically purge expired typing entries const purge = () => { const now = Date.now(); @@ -502,18 +726,32 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro // Initialize message count for snackbar trigger this.lastMessageCount = this.messages().length; - // Update reference time periodically (minute granularity) + // Update reference time silently (non-reactive) so formatTimestamp + // uses a reasonably fresh "now" without re-rendering every message. this.nowTimer = setInterval(() => { - this.nowRef.set(Date.now()); + this.nowRef = Date.now(); }, 60000); + + // Global Escape key listener for lightbox & context menu + this.boundOnKeydown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (this.imageContextMenu()) { this.closeImageContextMenu(); return; } + if (this.lightboxAttachment()) { this.closeLightbox(); return; } + } + }; + document.addEventListener('keydown', this.boundOnKeydown); } ngOnDestroy(): void { this.typingSub?.unsubscribe(); + this.stopInitialScrollWatch(); if (this.nowTimer) { clearInterval(this.nowTimer); this.nowTimer = null; } + if (this.boundOnKeydown) { + document.removeEventListener('keydown', this.boundOnKeydown); + } } sendMessage(): void { @@ -526,6 +764,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro MessagesActions.sendMessage({ content, replyToId: this.replyTo()?.id, + channelId: this.activeChannelId(), }) ); @@ -589,6 +828,21 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro this.replyTo.set(null); } + getRepliedMessage(messageId: string): Message | undefined { + return this.allMessages().find(m => m.id === messageId); + } + + scrollToMessage(messageId: string): void { + const container = this.messagesContainer?.nativeElement; + if (!container) return; + const el = container.querySelector(`[data-message-id="${messageId}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.classList.add('bg-primary/10'); + setTimeout(() => el.classList.remove('bg-primary/10'), 2000); + } + } + toggleEmojiPicker(messageId: string): void { this.showEmojiPicker.update((current) => current === messageId ? null : messageId @@ -641,20 +895,21 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro formatTimestamp(timestamp: number): string { const date = new Date(timestamp); - const now = new Date(this.nowRef()); - const diff = now.getTime() - date.getTime(); - const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + const now = new Date(this.nowRef); + const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - if (days === 0) { - return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } else if (days === 1) { - return 'Yesterday ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } else if (days < 7) { - return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + - date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + // Compare calendar days (midnight-aligned) to avoid NG0100 flicker + const toDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24)); + + if (dayDiff === 0) { + return time; + } else if (dayDiff === 1) { + return 'Yesterday ' + time; + } else if (dayDiff < 7) { + return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time; } else { - return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + - date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time; } } @@ -666,6 +921,63 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro } } + /** + * Start observing the messages container for DOM mutations + * and image load events. Every time the container's content + * changes size (new nodes, images finishing load) we instantly + * snap to the bottom. Automatically stops after a timeout or + * when the user scrolls up. + */ + private startInitialScrollWatch(): void { + this.stopInitialScrollWatch(); // clean up any prior watcher + + const el = this.messagesContainer?.nativeElement; + if (!el) return; + + const snap = () => { + if (this.messagesContainer) { + const e = this.messagesContainer.nativeElement; + this.isAutoScrolling = true; + e.scrollTop = e.scrollHeight; + // Clear flag after browser fires the synchronous scroll event + requestAnimationFrame(() => { this.isAutoScrolling = false; }); + } + }; + + // 1. MutationObserver catches new DOM nodes (attachments rendered, etc.) + this.initialScrollObserver = new MutationObserver(() => { + requestAnimationFrame(snap); + }); + this.initialScrollObserver.observe(el, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['src'], // img src swaps + }); + + // 2. Capture-phase 'load' listener catches images finishing load + this.boundOnImageLoad = () => requestAnimationFrame(snap); + el.addEventListener('load', this.boundOnImageLoad, true); + + // 3. Auto-stop after 5s so we don't fight user scrolling + this.initialScrollTimer = setTimeout(() => this.stopInitialScrollWatch(), 5000); + } + + private stopInitialScrollWatch(): void { + if (this.initialScrollObserver) { + this.initialScrollObserver.disconnect(); + this.initialScrollObserver = null; + } + if (this.boundOnImageLoad && this.messagesContainer) { + this.messagesContainer.nativeElement.removeEventListener('load', this.boundOnImageLoad, true); + this.boundOnImageLoad = null; + } + if (this.initialScrollTimer) { + clearTimeout(this.initialScrollTimer); + this.initialScrollTimer = null; + } + } + private scrollToBottomSmooth(): void { if (this.messagesContainer) { const el = this.messagesContainer.nativeElement; @@ -688,12 +1000,46 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro onScroll(): void { if (!this.messagesContainer) return; + // Ignore scroll events caused by programmatic snap-to-bottom + if (this.isAutoScrolling) return; + const el = this.messagesContainer.nativeElement; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; this.shouldScrollToBottom = distanceFromBottom <= 300; if (this.shouldScrollToBottom) { this.showNewMessagesBar.set(false); } + // Any user-initiated scroll during the initial load period + // immediately hands control back to the user + if (this.initialScrollObserver) { + this.stopInitialScrollWatch(); + } + // Infinite scroll upwards — load older messages when near the top + if (el.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) { + this.loadMore(); + } + } + + /** Load older messages by expanding the display window, preserving scroll position */ + loadMore(): void { + if (this.loadingMore() || !this.hasMoreMessages()) return; + this.loadingMore.set(true); + + const el = this.messagesContainer?.nativeElement; + const prevScrollHeight = el?.scrollHeight ?? 0; + + this.displayLimit.update(limit => limit + this.PAGE_SIZE); + + // After Angular renders the new messages, restore scroll position + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (el) { + const newScrollHeight = el.scrollHeight; + el.scrollTop += newScrollHeight - prevScrollHeight; + } + this.loadingMore.set(false); + }); + }); } private recomputeTypingDisplay(now: number): void { @@ -707,8 +1053,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro this.typingOthersCount.set(others); } - // Markdown rendering + // Markdown rendering (cached so re-renders don't replace innerHTML and kill text selection) renderMarkdown(content: string): SafeHtml { + const cached = this.markdownCache.get(content); + if (cached) return cached; + marked.setOptions({ breaks: true }); const html = marked.parse(content ?? '') as string; // Sanitize to a DOM fragment so we can post-process disallowed images @@ -750,7 +1099,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro } const safeHtml = DOMPurify.sanitize(container.innerHTML); - return this.sanitizer.bypassSecurityTrustHtml(safeHtml); + const result = this.sanitizer.bypassSecurityTrustHtml(safeHtml); + this.markdownCache.set(content, result); + return result; } // Resolve images marked for CSP-safe loading by converting to blob URLs @@ -995,6 +1346,74 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro return !!att.uploaderPeerId && !!myUserId && att.uploaderPeerId === myUserId; } + // ---- Image lightbox ---- + openLightbox(att: Attachment): void { + if (att.available && att.objectUrl) { + this.lightboxAttachment.set(att); + } + } + + closeLightbox(): void { + this.lightboxAttachment.set(null); + } + + // ---- Image context menu ---- + openImageContextMenu(event: MouseEvent, att: Attachment): void { + event.preventDefault(); + event.stopPropagation(); + this.imageContextMenu.set({ x: event.clientX, y: event.clientY, attachment: att }); + } + + closeImageContextMenu(): void { + this.imageContextMenu.set(null); + } + + async copyImageToClipboard(att: Attachment): Promise { + this.closeImageContextMenu(); + if (!att.objectUrl) return; + try { + const resp = await fetch(att.objectUrl); + const blob = await resp.blob(); + // Convert to PNG for clipboard compatibility + const pngBlob = await this.convertToPng(blob); + await navigator.clipboard.write([ + new ClipboardItem({ 'image/png': pngBlob }), + ]); + } catch (err) { + console.error('Failed to copy image to clipboard:', err); + } + } + + private convertToPng(blob: Blob): Promise { + return new Promise((resolve, reject) => { + if (blob.type === 'image/png') { + resolve(blob); + return; + } + const img = new Image(); + const url = URL.createObjectURL(blob); + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext('2d'); + if (!ctx) { reject(new Error('Canvas not supported')); return; } + ctx.drawImage(img, 0, 0); + canvas.toBlob((pngBlob) => { + URL.revokeObjectURL(url); + if (pngBlob) resolve(pngBlob); + else reject(new Error('PNG conversion failed')); + }, 'image/png'); + }; + img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Image load failed')); }; + img.src = url; + }); + } + + retryImageRequest(att: Attachment, messageId: string): void { + this.attachmentsSvc.requestImageFromAnyPeer(messageId, att); + } + private attachFilesToLastOwnMessage(content: string): void { const me = this.currentUser()?.id; if (!me) return; diff --git a/src/app/features/room/chat-room/chat-room.component.html b/src/app/features/room/chat-room/chat-room.component.html index 54bb5c4..52e4921 100644 --- a/src/app/features/room/chat-room/chat-room.component.html +++ b/src/app/features/room/chat-room/chat-room.component.html @@ -1,9 +1,23 @@
@if (currentRoom()) { + +
+ # + {{ activeChannelName }} +
+ @if (isAdmin()) { + + } +
+
- -
@@ -15,15 +29,24 @@
+ + @if (showAdminPanel() && isAdmin()) { + + } +
- - - - } @else {
diff --git a/src/app/features/room/chat-room/chat-room.component.ts b/src/app/features/room/chat-room/chat-room.component.ts index 565498d..d8954d4 100644 --- a/src/app/features/room/chat-room/chat-room.component.ts +++ b/src/app/features/room/chat-room/chat-room.component.ts @@ -18,7 +18,7 @@ import { ScreenShareViewerComponent } from '../../voice/screen-share-viewer/scre import { AdminPanelComponent } from '../../admin/admin-panel/admin-panel.component'; import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.component'; -import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; +import { selectCurrentRoom, selectActiveChannelId, selectTextChannels } from '../../../store/rooms/rooms.selectors'; import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors'; type SidebarPanel = 'rooms' | 'users' | 'admin' | null; @@ -32,6 +32,7 @@ type SidebarPanel = 'rooms' | 'users' | 'admin' | null; ChatMessagesComponent, ScreenShareViewerComponent, RoomsSidePanelComponent, + AdminPanelComponent, ], viewProviders: [ provideIcons({ @@ -49,11 +50,20 @@ export class ChatRoomComponent { private store = inject(Store); private router = inject(Router); showMenu = signal(false); + showAdminPanel = signal(false); currentRoom = this.store.selectSignal(selectCurrentRoom); isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin); + activeChannelId = this.store.selectSignal(selectActiveChannelId); + textChannels = this.store.selectSignal(selectTextChannels); - // Sidebar always visible; panel toggles removed + get activeChannelName(): string { + const id = this.activeChannelId(); + const ch = this.textChannels().find(c => c.id === id); + return ch ? ch.name : id; + } - // Header moved to TitleBar + toggleAdminPanel() { + this.showAdminPanel.update(v => !v); + } } diff --git a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html index 688c583..8d7f7f7 100644 --- a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html +++ b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html @@ -36,135 +36,133 @@
-

Text Channels

-
- - +
+

Text Channels

+ @if (canManageChannels()) { + + } +
+
+ @for (ch of textChannels(); track ch.id) { + + }
-

Voice Channels

+
+

Voice Channels

+ @if (canManageChannels()) { + + } +
@if (!voiceEnabled()) {

Voice is disabled by host

}
- -
- - @if (voiceUsersInRoom('general').length > 0) { -
- @for (u of voiceUsersInRoom('general'); track u.id) { -
- @if (u.avatarUrl) { - - } @else { -
- {{ u.displayName.charAt(0).toUpperCase() }} -
- } - {{ u.displayName }} - @if (u.screenShareState?.isSharing || isUserSharing(u.id)) { - - } - @if (u.voiceState?.isMuted) { - - } -
+ @for (ch of voiceChannels(); track ch.id) { +
+
- } -
- - -
- + + @if (voiceUsersInRoom(ch.id).length > 0) { +
+ @for (u of voiceUsersInRoom(ch.id); track u.id) { +
+ @if (u.avatarUrl) { + + } @else { +
+ {{ u.displayName.charAt(0).toUpperCase() }} +
+ } + {{ u.displayName }} + @if (u.screenShareState?.isSharing || isUserSharing(u.id)) { + + } + @if (u.voiceState?.isMuted) { + + } +
+ } +
} - - @if (voiceUsersInRoom('afk').length > 0) { -
- @for (u of voiceUsersInRoom('afk'); track u.id) { -
- @if (u.avatarUrl) { - - } @else { -
- {{ u.displayName.charAt(0).toUpperCase() }} -
- } - {{ u.displayName }} - @if (u.screenShareState?.isSharing || isUserSharing(u.id)) { - - } - @if (u.voiceState?.isMuted) { - - } -
- } -
- } -
+
+ }
@@ -217,7 +215,10 @@
@for (user of onlineUsersFiltered(); track user.id) { -
+
@if (user.avatarUrl) { @@ -229,7 +230,16 @@
-

{{ user.displayName }}

+
+

{{ user.displayName }}

+ @if (user.role === 'host') { + Owner + } @else if (user.role === 'admin') { + Admin + } @else if (user.role === 'moderator') { + Mod + } +
@if (user.voiceState?.isConnected) {

@@ -270,3 +280,80 @@

} + + +@if (showChannelMenu()) { +
+
+ + @if (canManageChannels()) { +
+ + + } +
+} + + +@if (showUserMenu()) { +
+
+ @if (isAdmin()) { + + @if (contextMenuUser()?.role === 'member') { + + + } + @if (contextMenuUser()?.role === 'moderator') { + + + } + @if (contextMenuUser()?.role === 'admin') { + + } +
+ + } @else { +
No actions available
+ } +
+} + + +@if (showCreateChannelDialog()) { +
+
+
+

Create {{ createChannelType() === 'text' ? 'Text' : 'Voice' }} Channel

+ +
+
+ + +
+
+} diff --git a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts index 6452108..e609f8a 100644 --- a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts +++ b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts @@ -1,23 +1,28 @@ import { Component, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; import { Store } from '@ngrx/store'; import { NgIcon, provideIcons } from '@ng-icons/core'; -import { lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers } from '@ng-icons/lucide'; -import { selectOnlineUsers, selectCurrentUser } from '../../../store/users/users.selectors'; -import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; +import { lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers, lucidePlus } from '@ng-icons/lucide'; +import { selectOnlineUsers, selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors'; +import { selectCurrentRoom, selectActiveChannelId, selectTextChannels, selectVoiceChannels } from '../../../store/rooms/rooms.selectors'; import * as UsersActions from '../../../store/users/users.actions'; +import * as RoomsActions from '../../../store/rooms/rooms.actions'; +import * as MessagesActions from '../../../store/messages/messages.actions'; import { WebRTCService } from '../../../core/services/webrtc.service'; import { VoiceSessionService } from '../../../core/services/voice-session.service'; import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component'; +import { Channel, User } from '../../../core/models'; +import { v4 as uuidv4 } from 'uuid'; type TabView = 'channels' | 'users'; @Component({ selector: 'app-rooms-side-panel', standalone: true, - imports: [CommonModule, NgIcon, VoiceControlsComponent], + imports: [CommonModule, FormsModule, NgIcon, VoiceControlsComponent], viewProviders: [ - provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers }) + provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers, lucidePlus }) ], templateUrl: './rooms-side-panel.component.html', }) @@ -31,6 +36,30 @@ export class RoomsSidePanelComponent { onlineUsers = this.store.selectSignal(selectOnlineUsers); currentUser = this.store.selectSignal(selectCurrentUser); currentRoom = this.store.selectSignal(selectCurrentRoom); + isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin); + activeChannelId = this.store.selectSignal(selectActiveChannelId); + textChannels = this.store.selectSignal(selectTextChannels); + voiceChannels = this.store.selectSignal(selectVoiceChannels); + + // Channel context menu state + showChannelMenu = signal(false); + channelMenuX = signal(0); + channelMenuY = signal(0); + contextChannel = signal(null); + + // Rename state + renamingChannelId = signal(null); + + // Create channel dialog state + showCreateChannelDialog = signal(false); + createChannelType = signal<'text' | 'voice'>('text'); + newChannelName = ''; + + // User context menu state + showUserMenu = signal(false); + userMenuX = signal(0); + userMenuY = signal(0); + contextMenuUser = signal(null); // Filter out current user from online users list onlineUsersFiltered() { @@ -40,6 +69,162 @@ export class RoomsSidePanelComponent { return this.onlineUsers().filter(u => u.id !== currentId && u.oderId !== currentOderId); } + canManageChannels(): boolean { + const room = this.currentRoom(); + const user = this.currentUser(); + if (!room || !user) return false; + // Owner always can + if (room.hostId === user.id) return true; + const perms = room.permissions || {}; + if (user.role === 'admin' && perms.adminsManageRooms) return true; + if (user.role === 'moderator' && perms.moderatorsManageRooms) return true; + return false; + } + + // ---- Text channel selection ---- + selectTextChannel(channelId: string) { + if (this.renamingChannelId()) return; // don't switch while renaming + this.store.dispatch(RoomsActions.selectChannel({ channelId })); + } + + // ---- Channel context menu ---- + openChannelContextMenu(evt: MouseEvent, channel: Channel) { + evt.preventDefault(); + this.contextChannel.set(channel); + this.channelMenuX.set(evt.clientX); + this.channelMenuY.set(evt.clientY); + this.showChannelMenu.set(true); + } + + closeChannelMenu() { + this.showChannelMenu.set(false); + } + + startRename() { + const ch = this.contextChannel(); + this.closeChannelMenu(); + if (ch) { + this.renamingChannelId.set(ch.id); + } + } + + confirmRename(event: Event) { + const input = event.target as HTMLInputElement; + const name = input.value.trim(); + const channelId = this.renamingChannelId(); + if (channelId && name) { + this.store.dispatch(RoomsActions.renameChannel({ channelId, name })); + } + this.renamingChannelId.set(null); + } + + cancelRename() { + this.renamingChannelId.set(null); + } + + deleteChannel() { + const ch = this.contextChannel(); + this.closeChannelMenu(); + if (ch) { + this.store.dispatch(RoomsActions.removeChannel({ channelId: ch.id })); + } + } + + resyncMessages() { + this.closeChannelMenu(); + const room = this.currentRoom(); + if (!room) { + console.warn('[Resync] No current room'); + return; + } + + // Dispatch startSync for UI spinner + this.store.dispatch(MessagesActions.startSync()); + + // Request inventory from all connected peers + const peers = this.webrtc.getConnectedPeers(); + console.log(`[Resync] Requesting inventory from ${peers.length} peer(s) for room ${room.id}`); + if (peers.length === 0) { + console.warn('[Resync] No connected peers — sync will time out'); + } + peers.forEach((pid) => { + try { + this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: room.id } as any); + } catch (e) { + console.error(`[Resync] Failed to send to peer ${pid}:`, e); + } + }); + } + + // ---- Create channel ---- + createChannel(type: 'text' | 'voice') { + this.createChannelType.set(type); + this.newChannelName = ''; + this.showCreateChannelDialog.set(true); + } + + confirmCreateChannel() { + const name = this.newChannelName.trim(); + if (!name) return; + const type = this.createChannelType(); + const existing = type === 'text' ? this.textChannels() : this.voiceChannels(); + const channel: Channel = { + id: type === 'voice' ? `vc-${uuidv4().slice(0, 8)}` : uuidv4().slice(0, 8), + name, + type, + position: existing.length, + }; + this.store.dispatch(RoomsActions.addChannel({ channel })); + this.showCreateChannelDialog.set(false); + } + + cancelCreateChannel() { + this.showCreateChannelDialog.set(false); + } + + // ---- User context menu (kick/role) ---- + openUserContextMenu(evt: MouseEvent, user: User) { + evt.preventDefault(); + if (!this.isAdmin()) return; + this.contextMenuUser.set(user); + this.userMenuX.set(evt.clientX); + this.userMenuY.set(evt.clientY); + this.showUserMenu.set(true); + } + + closeUserMenu() { + this.showUserMenu.set(false); + } + + changeUserRole(role: 'admin' | 'moderator' | 'member') { + const user = this.contextMenuUser(); + this.closeUserMenu(); + if (user) { + this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role })); + // Broadcast role change to peers + this.webrtc.broadcastMessage({ + type: 'role-change', + targetUserId: user.id, + role, + }); + } + } + + kickUserAction() { + const user = this.contextMenuUser(); + this.closeUserMenu(); + if (user) { + this.store.dispatch(UsersActions.kickUser({ userId: user.id })); + // Broadcast kick to peers + this.webrtc.broadcastMessage({ + type: 'kick', + targetUserId: user.id, + kickedBy: this.currentUser()?.id, + }); + } + } + + // ---- Voice ---- joinVoice(roomId: string) { // Gate by room permissions const room = this.currentRoom(); @@ -51,10 +236,21 @@ export class RoomsSidePanelComponent { const current = this.currentUser(); // Check if already connected to voice in a DIFFERENT server - must disconnect first + // Also handle stale voice state: if the store says connected but voice isn't actually active, + // clear it so the user can join. if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) { - // Connected to voice in a different server - user must disconnect first - console.warn('Already connected to voice in another server. Disconnect first before joining.'); - return; + if (!this.webrtc.isVoiceConnected()) { + // Stale state – clear it so the user can proceed + if (current.id) { + this.store.dispatch(UsersActions.updateVoiceState({ + userId: current.id, + voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined } + })); + } + } else { + console.warn('Already connected to voice in another server. Disconnect first before joining.'); + return; + } } // If switching channels within the same server, just update the room @@ -73,7 +269,7 @@ export class RoomsSidePanelComponent { })); } // Start voice heartbeat to broadcast presence every 5 seconds - this.webrtc.startVoiceHeartbeat(roomId); + this.webrtc.startVoiceHeartbeat(roomId, room?.id); this.webrtc.broadcastMessage({ type: 'voice-state', oderId: current?.oderId || current?.id, @@ -83,7 +279,9 @@ export class RoomsSidePanelComponent { // Update voice session for floating controls if (room) { - const voiceRoomName = roomId === 'general' ? '🔊 General' : roomId === 'afk' ? '🔕 AFK' : roomId; + // Find label from channel list + const vc = this.voiceChannels().find(c => c.id === roomId); + const voiceRoomName = vc ? `🔊 ${vc.name}` : roomId; this.voiceSessionService.startSession({ serverId: room.id, serverName: room.name, @@ -131,7 +329,6 @@ export class RoomsSidePanelComponent { voiceOccupancy(roomId: string): number { const users = this.onlineUsers(); const room = this.currentRoom(); - // Only count users connected to voice in this specific server and room return users.filter(u => !!u.voiceState?.isConnected && u.voiceState?.roomId === roomId && @@ -140,14 +337,11 @@ export class RoomsSidePanelComponent { } viewShare(userId: string) { - // Focus viewer on a user's stream if present - // Requires WebRTCService to expose a remote streams registry. const evt = new CustomEvent('viewer:focus', { detail: { userId } }); window.dispatchEvent(evt); } viewStream(userId: string) { - // Focus viewer on a user's stream - dispatches event to screen-share-viewer const evt = new CustomEvent('viewer:focus', { detail: { userId } }); window.dispatchEvent(evt); } @@ -155,25 +349,18 @@ export class RoomsSidePanelComponent { isUserSharing(userId: string): boolean { const me = this.currentUser(); if (me?.id === userId) { - // Local user: use signal return this.webrtc.isScreenSharing(); } - - // For remote users, check the store state first (authoritative) const user = this.onlineUsers().find(u => u.id === userId || u.oderId === userId); if (user?.screenShareState?.isSharing === false) { - // Store says not sharing - trust this over stream presence return false; } - - // Fall back to checking stream if store state is undefined const stream = this.webrtc.getRemoteStream(userId); return !!stream && stream.getVideoTracks().length > 0; } voiceUsersInRoom(roomId: string) { const room = this.currentRoom(); - // Only show users connected to voice in this specific server and room return this.onlineUsers().filter(u => !!u.voiceState?.isConnected && u.voiceState?.roomId === roomId && @@ -184,7 +371,6 @@ export class RoomsSidePanelComponent { isCurrentRoom(roomId: string): boolean { const me = this.currentUser(); const room = this.currentRoom(); - // Check that voice is connected AND both the server AND room match return !!( me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && diff --git a/src/app/features/shell/title-bar.component.ts b/src/app/features/shell/title-bar.component.ts index ecf40ff..41a16c7 100644 --- a/src/app/features/shell/title-bar.component.ts +++ b/src/app/features/shell/title-bar.component.ts @@ -77,6 +77,9 @@ export class TitleBarComponent { logout() { this._showMenu.set(false); + // Disconnect from signaling server – this broadcasts "user_left" to all + // servers the user was a member of, so other users see them go offline. + this.webrtc.disconnect(); try { localStorage.removeItem('metoyou_currentUserId'); } catch {} diff --git a/src/app/features/voice/voice-controls/voice-controls.component.ts b/src/app/features/voice/voice-controls/voice-controls.component.ts index 9b6c238..b4c2c00 100644 --- a/src/app/features/voice/voice-controls/voice-controls.component.ts +++ b/src/app/features/voice/voice-controls/voice-controls.component.ts @@ -226,6 +226,10 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { async loadAudioDevices(): Promise { try { + if (!navigator.mediaDevices?.enumerateDevices) { + console.warn('navigator.mediaDevices not available (requires HTTPS or localhost)'); + return; + } const devices = await navigator.mediaDevices.enumerateDevices(); this.inputDevices.set( devices @@ -251,6 +255,11 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { return; } + if (!navigator.mediaDevices?.getUserMedia) { + console.error('Cannot join call: navigator.mediaDevices not available (requires HTTPS or localhost)'); + return; + } + const stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: this.selectedInputDevice() || undefined, diff --git a/src/app/store/messages/messages.actions.ts b/src/app/store/messages/messages.actions.ts index b3464e9..1b44059 100644 --- a/src/app/store/messages/messages.actions.ts +++ b/src/app/store/messages/messages.actions.ts @@ -20,7 +20,7 @@ export const loadMessagesFailure = createAction( // Send message export const sendMessage = createAction( '[Messages] Send Message', - props<{ content: string; replyToId?: string }>() + props<{ content: string; replyToId?: string; channelId?: string }>() ); export const sendMessageSuccess = createAction( @@ -104,5 +104,9 @@ export const syncMessages = createAction( props<{ messages: Message[] }>() ); +// Sync lifecycle +export const startSync = createAction('[Messages] Start Sync'); +export const syncComplete = createAction('[Messages] Sync Complete'); + // Clear messages export const clearMessages = createAction('[Messages] Clear Messages'); diff --git a/src/app/store/messages/messages.effects.ts b/src/app/store/messages/messages.effects.ts index ca496e8..2712570 100644 --- a/src/app/store/messages/messages.effects.ts +++ b/src/app/store/messages/messages.effects.ts @@ -1,10 +1,11 @@ import { Injectable, inject } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; -import { of, from } from 'rxjs'; -import { map, mergeMap, catchError, withLatestFrom, tap, switchMap } from 'rxjs/operators'; +import { of, from, timer, Subject } from 'rxjs'; +import { map, mergeMap, catchError, withLatestFrom, tap, switchMap, filter, exhaustMap, repeat, takeUntil } from 'rxjs/operators'; import { v4 as uuidv4 } from 'uuid'; import * as MessagesActions from './messages.actions'; +import { selectMessagesSyncing } from './messages.selectors'; import { selectCurrentUser } from '../users/users.selectors'; import { selectCurrentRoom } from '../rooms/rooms.selectors'; import { DatabaseService } from '../../core/services/database.service'; @@ -26,14 +27,26 @@ export class MessagesEffects { private readonly INVENTORY_LIMIT = 1000; // number of recent messages to consider private readonly CHUNK_SIZE = 200; // chunk size for inventory/batch transfers + private readonly SYNC_POLL_FAST_MS = 10_000; // 10s — aggressive poll + private readonly SYNC_POLL_SLOW_MS = 900_000; // 15min — idle poll after clean sync + private lastSyncClean = false; // true after a sync cycle with no new messages - // Load messages from local database + // Load messages from local database (hydrate reactions from separate table) loadMessages$ = createEffect(() => this.actions$.pipe( ofType(MessagesActions.loadMessages), switchMap(({ roomId }) => from(this.db.getMessages(roomId)).pipe( - map((messages) => MessagesActions.loadMessagesSuccess({ messages })), + mergeMap(async (messages) => { + // Hydrate each message with its reactions from the reactions table + const hydrated = await Promise.all( + messages.map(async (m) => { + const reactions = await this.db.getReactionsForMessage(m.id); + return reactions.length > 0 ? { ...m, reactions } : m; + }) + ); + return MessagesActions.loadMessagesSuccess({ messages: hydrated }); + }), catchError((error) => of(MessagesActions.loadMessagesFailure({ error: error.message })) ) @@ -50,7 +63,7 @@ export class MessagesEffects { this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom) ), - mergeMap(([{ content, replyToId }, currentUser, currentRoom]) => { + mergeMap(([{ content, replyToId, channelId }, currentUser, currentRoom]) => { if (!currentUser || !currentRoom) { return of(MessagesActions.sendMessageFailure({ error: 'Not connected to a room' })); } @@ -58,6 +71,7 @@ export class MessagesEffects { const message: Message = { id: uuidv4(), roomId: currentRoom.id, + channelId: channelId || 'general', senderId: currentUser.id, senderName: currentUser.displayName || currentUser.username, content, @@ -226,6 +240,7 @@ export class MessagesEffects { // Broadcast to peers this.webrtc.broadcastMessage({ type: 'reaction-added', + messageId, reaction, }); @@ -273,17 +288,23 @@ export class MessagesEffects { switch (event.type) { // Precise sync via ID inventory and targeted requests case 'chat-inventory-request': { - if (!currentRoom || !event.fromPeerId) return of({ type: 'NO_OP' }); - return from(this.db.getMessages(currentRoom.id, this.INVENTORY_LIMIT, 0)).pipe( - tap((messages) => { - const items = messages - .map((m) => ({ id: m.id, ts: m.editedAt || m.timestamp || 0 })) - .sort((a, b) => a.ts - b.ts); + const reqRoomId = event.roomId; + if (!reqRoomId || !event.fromPeerId) return of({ type: 'NO_OP' }); + return from(this.db.getMessages(reqRoomId, this.INVENTORY_LIMIT, 0)).pipe( + mergeMap(async (messages) => { + const items = await Promise.all( + messages.map(async (m) => { + const reactions = await this.db.getReactionsForMessage(m.id); + return { id: m.id, ts: m.editedAt || m.timestamp || 0, rc: reactions.length }; + }) + ); + items.sort((a, b) => a.ts - b.ts); + console.log(`[Sync] Sending inventory of ${items.length} items for room ${reqRoomId}`); for (let i = 0; i < items.length; i += this.CHUNK_SIZE) { const chunk = items.slice(i, i + this.CHUNK_SIZE); this.webrtc.sendToPeer(event.fromPeerId, { type: 'chat-inventory', - roomId: currentRoom.id, + roomId: reqRoomId, items: chunk, total: items.length, index: i, @@ -295,24 +316,37 @@ export class MessagesEffects { } case 'chat-inventory': { - if (!currentRoom || !Array.isArray(event.items) || !event.fromPeerId) return of({ type: 'NO_OP' }); + const invRoomId = event.roomId; + if (!invRoomId || !Array.isArray(event.items) || !event.fromPeerId) return of({ type: 'NO_OP' }); // Determine which IDs we are missing or have older versions of - return from(this.db.getMessages(currentRoom.id, this.INVENTORY_LIMIT, 0)).pipe( + return from(this.db.getMessages(invRoomId, this.INVENTORY_LIMIT, 0)).pipe( mergeMap(async (local) => { - const localMap = new Map(local.map((m) => [m.id, m.editedAt || m.timestamp || 0])); + // Build local map with timestamps and reaction counts + const localMap = new Map(); + await Promise.all( + local.map(async (m) => { + const reactions = await this.db.getReactionsForMessage(m.id); + localMap.set(m.id, { ts: m.editedAt || m.timestamp || 0, rc: reactions.length }); + }) + ); const missing: string[] = []; - for (const { id, ts } of event.items as Array<{ id: string; ts: number }>) { - const lts = localMap.get(id); - if (lts === undefined || ts > lts) { - missing.push(id); + for (const item of event.items as Array<{ id: string; ts: number; rc?: number }>) { + const localEntry = localMap.get(item.id); + if (!localEntry) { + missing.push(item.id); + } else if (item.ts > localEntry.ts) { + missing.push(item.id); + } else if (item.rc !== undefined && item.rc !== localEntry.rc) { + missing.push(item.id); } } + console.log(`[Sync] Inventory received: ${event.items.length} remote, ${missing.length} missing/stale`); // Request in chunks from the sender for (let i = 0; i < missing.length; i += this.CHUNK_SIZE) { const chunk = missing.slice(i, i + this.CHUNK_SIZE); this.webrtc.sendToPeer(event.fromPeerId, { type: 'chat-sync-request-ids', - roomId: currentRoom.id, + roomId: invRoomId, ids: chunk, } as any); } @@ -322,18 +356,36 @@ export class MessagesEffects { } case 'chat-sync-request-ids': { - if (!currentRoom || !Array.isArray(event.ids) || !event.fromPeerId) return of({ type: 'NO_OP' }); + const syncReqRoomId = event.roomId; + if (!Array.isArray(event.ids) || !event.fromPeerId) return of({ type: 'NO_OP' }); const ids: string[] = event.ids; return from(Promise.all(ids.map((id: string) => this.db.getMessageById(id)))).pipe( - tap((maybeMessages) => { + mergeMap(async (maybeMessages) => { const messages = maybeMessages.filter((m): m is Message => !!m); + // Hydrate reactions from the separate reactions table + const hydrated = await Promise.all( + messages.map(async (m) => { + const reactions = await this.db.getReactionsForMessage(m.id); + return { ...m, reactions }; + }) + ); + // Collect attachment metadata for synced messages + const msgIds = hydrated.map(m => m.id); + const attachmentMetas = this.attachments.getAttachmentMetasForMessages(msgIds); + console.log(`[Sync] Sending ${hydrated.length} messages for ${ids.length} requested IDs`); // Send in chunks to avoid large payloads - for (let i = 0; i < messages.length; i += this.CHUNK_SIZE) { - const chunk = messages.slice(i, i + this.CHUNK_SIZE); + for (let i = 0; i < hydrated.length; i += this.CHUNK_SIZE) { + const chunk = hydrated.slice(i, i + this.CHUNK_SIZE); + // Include only attachments for this chunk + const chunkAttachments: Record = {}; + for (const m of chunk) { + if (attachmentMetas[m.id]) chunkAttachments[m.id] = attachmentMetas[m.id]; + } this.webrtc.sendToPeer(event.fromPeerId, { type: 'chat-sync-batch', - roomId: currentRoom.id, + roomId: syncReqRoomId || '', messages: chunk, + attachments: Object.keys(chunkAttachments).length > 0 ? chunkAttachments : undefined, } as any); } }), @@ -342,21 +394,54 @@ export class MessagesEffects { } case 'chat-sync-batch': { - if (!currentRoom || !Array.isArray(event.messages)) return of({ type: 'NO_OP' }); + if (!Array.isArray(event.messages)) return of({ type: 'NO_OP' }); + // Register synced attachment metadata so the UI knows about them + if (event.attachments && typeof event.attachments === 'object') { + this.attachments.registerSyncedAttachments(event.attachments); + } return from((async () => { - const accepted: Message[] = []; + const toUpsert: Message[] = []; for (const m of event.messages as Message[]) { const existing = await this.db.getMessageById(m.id); const ets = existing ? (existing.editedAt || existing.timestamp || 0) : -1; const its = m.editedAt || m.timestamp || 0; - if (!existing || its > ets) { + const isNewer = !existing || its > ets; + + if (isNewer) { await this.db.saveMessage(m); - accepted.push(m); + } + + // Persist incoming reactions to the reactions table (deduped) + const incomingReactions = m.reactions ?? []; + for (const r of incomingReactions) { + await this.db.saveReaction(r); + } + + // Hydrate merged reactions from DB and upsert if anything changed + if (isNewer || incomingReactions.length > 0) { + const reactions = await this.db.getReactionsForMessage(m.id); + toUpsert.push({ ...(isNewer ? m : existing!), reactions }); } } - return accepted; + + // Auto-request unavailable images from the sender + if (event.attachments && event.fromPeerId) { + for (const [msgId, metas] of Object.entries(event.attachments) as [string, any[]][]) { + for (const meta of metas) { + if (meta.isImage) { + const atts = this.attachments.getForMessage(msgId); + const att = atts.find((a: any) => a.id === meta.id); + if (att && !att.available && !(att.receivedBytes && att.receivedBytes > 0)) { + this.attachments.requestImageFromAnyPeer(msgId, att); + } + } + } + } + } + + return toUpsert; })()).pipe( - mergeMap((accepted) => accepted.length ? of(MessagesActions.syncMessages({ messages: accepted })) : of({ type: 'NO_OP' })) + mergeMap((toUpsert) => toUpsert.length ? of(MessagesActions.syncMessages({ messages: toUpsert })) : of({ type: 'NO_OP' })) ); } case 'voice-state': @@ -394,6 +479,11 @@ export class MessagesEffects { this.attachments.handleFileCancel(event); return of({ type: 'NO_OP' }); + case 'file-not-found': + // Peer couldn't serve the file – try another peer automatically + this.attachments.handleFileNotFound(event); + return of({ type: 'NO_OP' }); + case 'message-edited': if (event.messageId && event.content) { this.db.updateMessage(event.messageId, { content: event.content, editedAt: event.editedAt }); @@ -526,4 +616,66 @@ export class MessagesEffects { ), { dispatch: false } ); + + // Periodic sync poll – 10s when catching up, 15min after a clean sync + private syncReset$ = new Subject(); + + periodicSyncPoll$ = createEffect(() => + timer(this.SYNC_POLL_FAST_MS).pipe( + // After each emission, decide the next delay based on last result + repeat({ delay: () => timer(this.lastSyncClean ? this.SYNC_POLL_SLOW_MS : this.SYNC_POLL_FAST_MS) }), + takeUntil(this.syncReset$), // restart via syncReset$ is handled externally if needed + withLatestFrom( + this.store.select(selectCurrentRoom) + ), + filter(([, room]) => !!room && this.webrtc.getConnectedPeers().length > 0), + exhaustMap(([, room]) => { + const peers = this.webrtc.getConnectedPeers(); + if (!room || peers.length === 0) return of(MessagesActions.syncComplete()); + + return from(this.db.getMessages(room.id, this.INVENTORY_LIMIT, 0)).pipe( + map((messages) => { + peers.forEach((pid) => { + try { + this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: room.id } as any); + } catch {} + }); + return MessagesActions.startSync(); + }), + catchError(() => { + this.lastSyncClean = false; + return of(MessagesActions.syncComplete()); + }) + ); + }) + ) + ); + + // Auto-complete sync after a timeout if no sync messages arrive + syncTimeout$ = createEffect(() => + this.actions$.pipe( + ofType(MessagesActions.startSync), + switchMap(() => { + // If no syncMessages or syncComplete within 5s, auto-complete + return new Promise((resolve) => setTimeout(resolve, 5000)); + }), + withLatestFrom(this.store.select(selectMessagesSyncing)), + filter(([, syncing]) => syncing), + map(() => { + // No new messages arrived during this cycle → clean sync, slow down + this.lastSyncClean = true; + return MessagesActions.syncComplete(); + }) + ) + ); + + // When new messages actually arrive via sync, switch back to fast polling + syncReceivedMessages$ = createEffect( + () => + this.webrtc.onPeerConnected.pipe( + // A peer (re)connecting means we may have been offline — revert to aggressive polling + tap(() => { this.lastSyncClean = false; }) + ), + { dispatch: false } + ); } diff --git a/src/app/store/messages/messages.reducer.ts b/src/app/store/messages/messages.reducer.ts index e374be8..f7d2149 100644 --- a/src/app/store/messages/messages.reducer.ts +++ b/src/app/store/messages/messages.reducer.ts @@ -5,6 +5,7 @@ import * as MessagesActions from './messages.actions'; export interface MessagesState extends EntityState { loading: boolean; + syncing: boolean; error: string | null; currentRoomId: string | null; } @@ -16,6 +17,7 @@ export const messagesAdapter: EntityAdapter = createEntityAdapter ({ - ...state, - loading: true, - error: null, - currentRoomId: roomId, - })), + // Load messages — clear stale messages when switching to a different room + on(MessagesActions.loadMessages, (state, { roomId }) => { + if (state.currentRoomId && state.currentRoomId !== roomId) { + return messagesAdapter.removeAll({ + ...state, + loading: true, + error: null, + currentRoomId: roomId, + }); + } + return { + ...state, + loading: true, + error: null, + currentRoomId: roomId, + }; + }), on(MessagesActions.loadMessagesSuccess, (state, { messages }) => messagesAdapter.setAll(messages, { @@ -130,10 +142,37 @@ export const messagesReducer = createReducer( ); }), - // Sync messages from peer - on(MessagesActions.syncMessages, (state, { messages }) => - messagesAdapter.upsertMany(messages, state) - ), + // Sync lifecycle + on(MessagesActions.startSync, (state) => ({ + ...state, + syncing: true, + })), + + on(MessagesActions.syncComplete, (state) => ({ + ...state, + syncing: false, + })), + + // Sync messages from peer (merge reactions to avoid losing local-only reactions) + on(MessagesActions.syncMessages, (state, { messages }) => { + const merged = messages.map(m => { + const existing = state.entities[m.id]; + if (existing?.reactions?.length) { + const combined = [...(m.reactions ?? [])]; + for (const r of existing.reactions) { + if (!combined.some(c => c.userId === r.userId && c.emoji === r.emoji && c.messageId === r.messageId)) { + combined.push(r); + } + } + return { ...m, reactions: combined }; + } + return m; + }); + return messagesAdapter.upsertMany(merged, { + ...state, + syncing: false, + }); + }), // Clear messages on(MessagesActions.clearMessages, (state) => diff --git a/src/app/store/messages/messages.selectors.ts b/src/app/store/messages/messages.selectors.ts index 977940f..0f3048b 100644 --- a/src/app/store/messages/messages.selectors.ts +++ b/src/app/store/messages/messages.selectors.ts @@ -23,6 +23,11 @@ export const selectMessagesError = createSelector( (state) => state.error ); +export const selectMessagesSyncing = createSelector( + selectMessagesState, + (state) => state.syncing +); + export const selectCurrentRoomId = createSelector( selectMessagesState, (state) => state.currentRoomId @@ -34,6 +39,19 @@ export const selectCurrentRoomMessages = createSelector( (messages, roomId) => roomId ? messages.filter((m) => m.roomId === roomId) : [] ); +/** Select messages for the currently active text channel */ +export const selectChannelMessages = (channelId: string) => + createSelector( + selectAllMessages, + selectCurrentRoomId, + (messages, roomId) => { + if (!roomId) return []; + return messages.filter( + (m) => m.roomId === roomId && (m.channelId || 'general') === channelId + ); + } + ); + export const selectMessageById = (id: string) => createSelector(selectMessagesEntities, (entities) => entities[id]); diff --git a/src/app/store/rooms/rooms.actions.ts b/src/app/store/rooms/rooms.actions.ts index c0f7092..8cced90 100644 --- a/src/app/store/rooms/rooms.actions.ts +++ b/src/app/store/rooms/rooms.actions.ts @@ -1,5 +1,5 @@ import { createAction, props } from '@ngrx/store'; -import { Room, RoomSettings, ServerInfo, RoomPermissions } from '../../core/models'; +import { Room, RoomSettings, ServerInfo, RoomPermissions, Channel } from '../../core/models'; // Load rooms from storage export const loadRooms = createAction('[Rooms] Load Rooms'); @@ -159,6 +159,27 @@ export const receiveRoomUpdate = createAction( props<{ room: Partial }>() ); +// Channel management +export const selectChannel = createAction( + '[Rooms] Select Channel', + props<{ channelId: string }>() +); + +export const addChannel = createAction( + '[Rooms] Add Channel', + props<{ channel: Channel }>() +); + +export const removeChannel = createAction( + '[Rooms] Remove Channel', + props<{ channelId: string }>() +); + +export const renameChannel = createAction( + '[Rooms] Rename Channel', + props<{ channelId: string; name: string }>() +); + // Clear search results export const clearSearchResults = createAction('[Rooms] Clear Search Results'); diff --git a/src/app/store/rooms/rooms.reducer.ts b/src/app/store/rooms/rooms.reducer.ts index f1af5e1..7eeed3e 100644 --- a/src/app/store/rooms/rooms.reducer.ts +++ b/src/app/store/rooms/rooms.reducer.ts @@ -1,7 +1,37 @@ import { createReducer, on } from '@ngrx/store'; -import { Room, ServerInfo, RoomSettings } from '../../core/models'; +import { Room, ServerInfo, RoomSettings, Channel } from '../../core/models'; import * as RoomsActions from './rooms.actions'; +/** Default channels for a new server */ +export function defaultChannels(): Channel[] { + return [ + { id: 'general', name: 'general', type: 'text', position: 0 }, + { id: 'random', name: 'random', type: 'text', position: 1 }, + { id: 'vc-general', name: 'General', type: 'voice', position: 0 }, + { id: 'vc-afk', name: 'AFK', type: 'voice', position: 1 }, + ]; +} + +/** Deduplicate rooms by id, keeping the last occurrence */ +function deduplicateRooms(rooms: Room[]): Room[] { + const seen = new Map(); + for (const r of rooms) { + seen.set(r.id, r); + } + return Array.from(seen.values()); +} + +/** Upsert a room into a saved-rooms list (add or replace by id) */ +function upsertRoom(savedRooms: Room[], room: Room): Room[] { + const idx = savedRooms.findIndex(r => r.id === room.id); + if (idx >= 0) { + const updated = [...savedRooms]; + updated[idx] = room; + return updated; + } + return [...savedRooms, room]; +} + export interface RoomsState { currentRoom: Room | null; savedRooms: Room[]; @@ -12,6 +42,7 @@ export interface RoomsState { isConnected: boolean; loading: boolean; error: string | null; + activeChannelId: string; // currently selected text channel } export const initialState: RoomsState = { @@ -24,6 +55,7 @@ export const initialState: RoomsState = { isConnected: false, loading: false, error: null, + activeChannelId: 'general', }; export const roomsReducer = createReducer( @@ -38,7 +70,7 @@ export const roomsReducer = createReducer( on(RoomsActions.loadRoomsSuccess, (state, { rooms }) => ({ ...state, - savedRooms: rooms, + savedRooms: deduplicateRooms(rooms), loading: false, })), @@ -74,12 +106,17 @@ export const roomsReducer = createReducer( error: null, })), - on(RoomsActions.createRoomSuccess, (state, { room }) => ({ - ...state, - currentRoom: room, - isConnecting: false, - isConnected: true, - })), + on(RoomsActions.createRoomSuccess, (state, { room }) => { + const enriched = { ...room, channels: room.channels || defaultChannels() }; + return { + ...state, + currentRoom: enriched, + savedRooms: upsertRoom(state.savedRooms, enriched), + isConnecting: false, + isConnected: true, + activeChannelId: 'general', + }; + }), on(RoomsActions.createRoomFailure, (state, { error }) => ({ ...state, @@ -94,12 +131,17 @@ export const roomsReducer = createReducer( error: null, })), - on(RoomsActions.joinRoomSuccess, (state, { room }) => ({ - ...state, - currentRoom: room, - isConnecting: false, - isConnected: true, - })), + on(RoomsActions.joinRoomSuccess, (state, { room }) => { + const enriched = { ...room, channels: room.channels || defaultChannels() }; + return { + ...state, + currentRoom: enriched, + savedRooms: upsertRoom(state.savedRooms, enriched), + isConnecting: false, + isConnected: true, + activeChannelId: 'general', + }; + }), on(RoomsActions.joinRoomFailure, (state, { error }) => ({ ...state, @@ -128,12 +170,17 @@ export const roomsReducer = createReducer( error: null, })), - on(RoomsActions.viewServerSuccess, (state, { room }) => ({ - ...state, - currentRoom: room, - isConnecting: false, - isConnected: true, - })), + on(RoomsActions.viewServerSuccess, (state, { room }) => { + const enriched = { ...room, channels: room.channels || defaultChannels() }; + return { + ...state, + currentRoom: enriched, + savedRooms: upsertRoom(state.savedRooms, enriched), + isConnecting: false, + isConnected: true, + activeChannelId: 'general', + }; + }), // Update room settings on(RoomsActions.updateRoomSettings, (state) => ({ @@ -225,5 +272,48 @@ export const roomsReducer = createReducer( on(RoomsActions.setConnecting, (state, { isConnecting }) => ({ ...state, isConnecting, - })) + })), + + // Channel management + on(RoomsActions.selectChannel, (state, { channelId }) => ({ + ...state, + activeChannelId: channelId, + })), + + on(RoomsActions.addChannel, (state, { channel }) => { + if (!state.currentRoom) return state; + const existing = state.currentRoom.channels || defaultChannels(); + const updatedChannels = [...existing, channel]; + const updatedRoom = { ...state.currentRoom, channels: updatedChannels }; + return { + ...state, + currentRoom: updatedRoom, + savedRooms: upsertRoom(state.savedRooms, updatedRoom), + }; + }), + + on(RoomsActions.removeChannel, (state, { channelId }) => { + if (!state.currentRoom) return state; + const existing = state.currentRoom.channels || defaultChannels(); + const updatedChannels = existing.filter(c => c.id !== channelId); + const updatedRoom = { ...state.currentRoom, channels: updatedChannels }; + return { + ...state, + currentRoom: updatedRoom, + savedRooms: upsertRoom(state.savedRooms, updatedRoom), + activeChannelId: state.activeChannelId === channelId ? 'general' : state.activeChannelId, + }; + }), + + on(RoomsActions.renameChannel, (state, { channelId, name }) => { + if (!state.currentRoom) return state; + const existing = state.currentRoom.channels || defaultChannels(); + const updatedChannels = existing.map(c => c.id === channelId ? { ...c, name } : c); + const updatedRoom = { ...state.currentRoom, channels: updatedChannels }; + return { + ...state, + currentRoom: updatedRoom, + savedRooms: upsertRoom(state.savedRooms, updatedRoom), + }; + }) ); diff --git a/src/app/store/rooms/rooms.selectors.ts b/src/app/store/rooms/rooms.selectors.ts index a58dabd..b3939b7 100644 --- a/src/app/store/rooms/rooms.selectors.ts +++ b/src/app/store/rooms/rooms.selectors.ts @@ -62,3 +62,23 @@ export const selectRoomsLoading = createSelector( selectRoomsState, (state) => state.loading ); + +export const selectActiveChannelId = createSelector( + selectRoomsState, + (state) => state.activeChannelId +); + +export const selectCurrentRoomChannels = createSelector( + selectCurrentRoom, + (room) => room?.channels ?? [] +); + +export const selectTextChannels = createSelector( + selectCurrentRoomChannels, + (channels) => channels.filter(c => c.type === 'text').sort((a, b) => a.position - b.position) +); + +export const selectVoiceChannels = createSelector( + selectCurrentRoomChannels, + (channels) => channels.filter(c => c.type === 'voice').sort((a, b) => a.position - b.position) +); diff --git a/src/app/store/users/users.selectors.ts b/src/app/store/users/users.selectors.ts index 88f35eb..7c7c923 100644 --- a/src/app/store/users/users.selectors.ts +++ b/src/app/store/users/users.selectors.ts @@ -78,3 +78,8 @@ export const selectAdmins = createSelector( selectAllUsers, (users) => users.filter((u) => u.role === 'host' || u.role === 'admin' || u.role === 'moderator') ); + +export const selectIsCurrentUserOwner = createSelector( + selectCurrentUser, + (user) => user?.role === 'host' +);