Big commit

This commit is contained in:
2026-03-02 00:13:34 +01:00
parent d146138fca
commit 6d7465ff18
54 changed files with 5999 additions and 2291 deletions

4
.env.example Normal file
View File

@@ -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

4
.gitignore vendored
View File

@@ -45,3 +45,7 @@ __screenshots__/
# System files
.DS_Store
Thumbs.db
# Environment & certs
.env
.certs/

36
dev.sh Executable file
View File

@@ -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 ."

626
electron/database.js Normal file
View File

@@ -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 };

View File

@@ -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', () => {

View File

@@ -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'),
},
});

28
generate-cert.sh Executable file
View File

@@ -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"

View File

@@ -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",

Binary file not shown.

View File

@@ -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
}
]

View File

@@ -1,9 +0,0 @@
[
{
"id": "54c0953a-1e54-4c07-8da9-06c143d9354f",
"username": "azaaxin",
"passwordHash": "9f3a38af5ddad28abc9a273e2481883b245e5a908266c2ce1f0e42c7fa175d6c",
"displayName": "azaaxin",
"createdAt": 1766902824975
}
]

43
server/dist/db.d.ts vendored Normal file
View File

@@ -0,0 +1,43 @@
export declare function initDB(): Promise<void>;
export interface AuthUser {
id: string;
username: string;
passwordHash: string;
displayName: string;
createdAt: number;
}
export declare function getUserByUsername(username: string): Promise<AuthUser | null>;
export declare function getUserById(id: string): Promise<AuthUser | null>;
export declare function createUser(user: AuthUser): Promise<void>;
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<ServerInfo[]>;
export declare function getServerById(id: string): Promise<ServerInfo | null>;
export declare function upsertServer(server: ServerInfo): Promise<void>;
export declare function deleteServer(id: string): Promise<void>;
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<void>;
export declare function getJoinRequestById(id: string): Promise<JoinRequest | null>;
export declare function getPendingRequestsForServer(serverId: string): Promise<JoinRequest[]>;
export declare function updateJoinRequestStatus(id: string, status: JoinRequest['status']): Promise<void>;
export declare function deleteStaleJoinRequests(maxAgeMs: number): Promise<void>;
//# sourceMappingURL=db.d.ts.map

1
server/dist/db.d.ts.map vendored Normal file
View File

@@ -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"}

277
server/dist/db.js vendored Normal file
View File

@@ -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

1
server/dist/db.js.map vendored Normal file

File diff suppressed because one or more lines are too long

305
server/dist/index.js vendored
View File

@@ -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

File diff suppressed because one or more lines are too long

View File

@@ -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",

View File

@@ -36,6 +36,34 @@ export async function initDB(): Promise<void> {
);
`);
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<void> {
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<ServerInfo[]> {
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<ServerInfo | null> {
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<void> {
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<void> {
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<void> {
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<JoinRequest | null> {
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<JoinRequest[]> {
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<void> {
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<void> {
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();
}

View File

@@ -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<string, ServerInfo>();
const joinRequests = new Map<string, JoinRequest>();
const connectedUsers = new Map<string, ConnectedUser>();
// 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);

View File

@@ -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<VoiceState>;
isScreenSharing?: boolean;
role?: 'host' | 'admin' | 'moderator' | 'member';
channels?: Channel[];
}
export interface ServerInfo {

View File

@@ -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<string, Attachment[]>();
// expose updates if needed
updated = signal<number>(0);
// Keep original files for uploaders to fulfill requests
private originals = new Map<string, File>(); // 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<string>();
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<void> {
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<string, AttachmentMeta[]> {
const result: Record<string, AttachmentMeta[]> = {};
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<string, AttachmentMeta[]>): 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<string, Set<string>>();
/** 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<void> {
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<void> {
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<void> {
@@ -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/<room>/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<void> {
private async saveFileToDisk(att: Attachment, blob: Blob): Promise<void> {
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<void> {
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<string>((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<void> {
@@ -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<void> {
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<void> {
this.originals.set(`${messageId}:${fileId}`, file);
await this.streamFileToPeer(targetPeerId, messageId, fileId, file);
}
private persist(): void {
private async persistAttachmentMeta(att: Attachment): Promise<void> {
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<void> {
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<string, Attachment[]>();
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<void> {
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);

View File

@@ -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<void> {
if (this.db) return;
this.db = await this.openDatabase();
}
/* ------------------------------------------------------------------ */
/* Messages */
/* ------------------------------------------------------------------ */
async saveMessage(message: Message): Promise<void> {
await this.put('messages', message);
}
async getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> {
const all = await this.getAllFromIndex<Message>('messages', 'roomId', roomId);
return all
.sort((a, b) => a.timestamp - b.timestamp)
.slice(offset, offset + limit);
}
async deleteMessage(messageId: string): Promise<void> {
await this.delete('messages', messageId);
}
async updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
const msg = await this.get<Message>('messages', messageId);
if (msg) await this.put('messages', { ...msg, ...updates });
}
async getMessageById(messageId: string): Promise<Message | null> {
return (await this.get<Message>('messages', messageId)) ?? null;
}
async clearRoomMessages(roomId: string): Promise<void> {
const msgs = await this.getAllFromIndex<Message>('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<void> {
const existing = await this.getAllFromIndex<Reaction>('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<void> {
const all = await this.getAllFromIndex<Reaction>('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<Reaction[]> {
return this.getAllFromIndex<Reaction>('reactions', 'messageId', messageId);
}
/* ------------------------------------------------------------------ */
/* Users */
/* ------------------------------------------------------------------ */
async saveUser(user: User): Promise<void> {
await this.put('users', user);
}
async getUser(userId: string): Promise<User | null> {
return (await this.get<User>('users', userId)) ?? null;
}
async getCurrentUser(): Promise<User | null> {
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<void> {
await this.put('meta', { id: 'currentUserId', value: userId });
}
async getUsersByRoom(_roomId: string): Promise<User[]> {
return this.getAll<User>('users');
}
async updateUser(userId: string, updates: Partial<User>): Promise<void> {
const user = await this.get<User>('users', userId);
if (user) await this.put('users', { ...user, ...updates });
}
/* ------------------------------------------------------------------ */
/* Rooms */
/* ------------------------------------------------------------------ */
async saveRoom(room: Room): Promise<void> {
await this.put('rooms', room);
}
async getRoom(roomId: string): Promise<Room | null> {
return (await this.get<Room>('rooms', roomId)) ?? null;
}
async getAllRooms(): Promise<Room[]> {
return this.getAll<Room>('rooms');
}
async deleteRoom(roomId: string): Promise<void> {
await this.delete('rooms', roomId);
await this.clearRoomMessages(roomId);
}
async updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
const room = await this.get<Room>('rooms', roomId);
if (room) await this.put('rooms', { ...room, ...updates });
}
/* ------------------------------------------------------------------ */
/* Bans */
/* ------------------------------------------------------------------ */
async saveBan(ban: BanEntry): Promise<void> {
await this.put('bans', ban);
}
async removeBan(oderId: string): Promise<void> {
const all = await this.getAll<BanEntry>('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<BanEntry[]> {
const all = await this.getAllFromIndex<BanEntry>('bans', 'roomId', roomId);
const now = Date.now();
return all.filter((b) => !b.expiresAt || b.expiresAt > now);
}
async isUserBanned(userId: string, roomId: string): Promise<boolean> {
const bans = await this.getBansForRoom(roomId);
return bans.some((b) => b.oderId === userId);
}
/* ------------------------------------------------------------------ */
/* Attachments */
/* ------------------------------------------------------------------ */
async saveAttachment(attachment: any): Promise<void> {
await this.put('attachments', attachment);
}
async getAttachmentsForMessage(messageId: string): Promise<any[]> {
return this.getAllFromIndex<any>('attachments', 'messageId', messageId);
}
async getAllAttachments(): Promise<any[]> {
return this.getAll<any>('attachments');
}
async deleteAttachmentsForMessage(messageId: string): Promise<void> {
const atts = await this.getAllFromIndex<any>('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<void> {
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<IDBDatabase> {
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<void> {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
private get<T>(store: string, key: IDBValidKey): Promise<T | undefined> {
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<T>(store: string): Promise<T[]> {
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<T>(
store: string,
indexName: string,
key: IDBValidKey,
): Promise<T[]> {
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<void> {
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<void> {
return new Promise((resolve, reject) => {
const tx = this.transaction(store, 'readwrite');
tx.objectStore(store).delete(key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
}

View File

@@ -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<void> {
// 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<Message>) { return this.backend.updateMessage(messageId, updates); }
getMessageById(messageId: string) { return this.backend.getMessageById(messageId); }
clearRoomMessages(roomId: string) { return this.backend.clearRoomMessages(roomId); }
private getArray<T>(key: string): T[] {
const data = localStorage.getItem(this.key(key));
return data ? JSON.parse(data) : [];
}
/* ------------------------------------------------------------------ */
/* Reactions */
/* ------------------------------------------------------------------ */
private setArray<T>(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<void> {
const messages = this.getArray<Message>('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<Message[]> {
const messages = this.getArray<Message>('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<User>) { return this.backend.updateUser(userId, updates); }
async deleteMessage(messageId: string): Promise<void> {
const messages = this.getArray<Message>('messages');
const filtered = messages.filter((m) => m.id !== messageId);
this.setArray('messages', filtered);
}
/* ------------------------------------------------------------------ */
/* Rooms */
/* ------------------------------------------------------------------ */
async updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
const messages = this.getArray<Message>('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<Room>) { return this.backend.updateRoom(roomId, updates); }
async getMessageById(messageId: string): Promise<Message | null> {
const messages = this.getArray<Message>('messages');
return messages.find((m) => m.id === messageId) || null;
}
/* ------------------------------------------------------------------ */
/* Bans */
/* ------------------------------------------------------------------ */
async clearRoomMessages(roomId: string): Promise<void> {
const messages = this.getArray<Message>('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<void> {
const reactions = this.getArray<Reaction>('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<void> {
const reactions = this.getArray<Reaction>('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<Reaction[]> {
const reactions = this.getArray<Reaction>('reactions');
return reactions.filter((r) => r.messageId === messageId);
}
/* ------------------------------------------------------------------ */
/* Utilities */
/* ------------------------------------------------------------------ */
// Users
async saveUser(user: User): Promise<void> {
const users = this.getArray<User>('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<User | null> {
const users = this.getArray<User>('users');
return users.find((u) => u.id === userId) || null;
}
async getCurrentUser(): Promise<User | null> {
const currentUserId = localStorage.getItem(this.key('currentUserId'));
if (!currentUserId) return null;
return this.getUser(currentUserId);
}
async setCurrentUserId(userId: string): Promise<void> {
localStorage.setItem(this.key('currentUserId'), userId);
}
async getUsersByRoom(roomId: string): Promise<User[]> {
return this.getArray<User>('users');
}
async updateUser(userId: string, updates: Partial<User>): Promise<void> {
const users = this.getArray<User>('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<void> {
const rooms = this.getArray<Room>('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<Room | null> {
const rooms = this.getArray<Room>('rooms');
return rooms.find((r) => r.id === roomId) || null;
}
async getAllRooms(): Promise<Room[]> {
return this.getArray<Room>('rooms');
}
async deleteRoom(roomId: string): Promise<void> {
const rooms = this.getArray<Room>('rooms');
const filtered = rooms.filter((r) => r.id !== roomId);
this.setArray('rooms', filtered);
await this.clearRoomMessages(roomId);
}
async updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
const rooms = this.getArray<Room>('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<void> {
const bans = this.getArray<BanEntry>('bans');
bans.push(ban);
this.setArray('bans', bans);
}
async removeBan(oderId: string): Promise<void> {
const bans = this.getArray<BanEntry>('bans');
const filtered = bans.filter((b) => b.oderId !== oderId);
this.setArray('bans', filtered);
}
async getBansForRoom(roomId: string): Promise<BanEntry[]> {
const bans = this.getArray<BanEntry>('bans');
const now = Date.now();
return bans.filter(
(b) => b.roomId === roomId && (!b.expiresAt || b.expiresAt > now)
);
}
async isUserBanned(userId: string, roomId: string): Promise<boolean> {
const bans = await this.getBansForRoom(roomId);
return bans.some((b) => b.oderId === userId);
}
async clearAllData(): Promise<void> {
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(); }
}

View File

@@ -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<void> {
if (this.initialized) return;
await this.api.initialize();
this.initialized = true;
}
/* ------------------------------------------------------------------ */
/* Messages */
/* ------------------------------------------------------------------ */
saveMessage(message: Message): Promise<void> {
return this.api.saveMessage(message);
}
getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> {
return this.api.getMessages(roomId, limit, offset);
}
deleteMessage(messageId: string): Promise<void> {
return this.api.deleteMessage(messageId);
}
updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
return this.api.updateMessage(messageId, updates);
}
getMessageById(messageId: string): Promise<Message | null> {
return this.api.getMessageById(messageId);
}
clearRoomMessages(roomId: string): Promise<void> {
return this.api.clearRoomMessages(roomId);
}
/* ------------------------------------------------------------------ */
/* Reactions */
/* ------------------------------------------------------------------ */
saveReaction(reaction: Reaction): Promise<void> {
return this.api.saveReaction(reaction);
}
removeReaction(messageId: string, userId: string, emoji: string): Promise<void> {
return this.api.removeReaction(messageId, userId, emoji);
}
getReactionsForMessage(messageId: string): Promise<Reaction[]> {
return this.api.getReactionsForMessage(messageId);
}
/* ------------------------------------------------------------------ */
/* Users */
/* ------------------------------------------------------------------ */
saveUser(user: User): Promise<void> {
return this.api.saveUser(user);
}
getUser(userId: string): Promise<User | null> {
return this.api.getUser(userId);
}
getCurrentUser(): Promise<User | null> {
return this.api.getCurrentUser();
}
setCurrentUserId(userId: string): Promise<void> {
return this.api.setCurrentUserId(userId);
}
getUsersByRoom(roomId: string): Promise<User[]> {
return this.api.getUsersByRoom(roomId);
}
updateUser(userId: string, updates: Partial<User>): Promise<void> {
return this.api.updateUser(userId, updates);
}
/* ------------------------------------------------------------------ */
/* Rooms */
/* ------------------------------------------------------------------ */
saveRoom(room: Room): Promise<void> {
return this.api.saveRoom(room);
}
getRoom(roomId: string): Promise<Room | null> {
return this.api.getRoom(roomId);
}
getAllRooms(): Promise<Room[]> {
return this.api.getAllRooms();
}
deleteRoom(roomId: string): Promise<void> {
return this.api.deleteRoom(roomId);
}
updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
return this.api.updateRoom(roomId, updates);
}
/* ------------------------------------------------------------------ */
/* Bans */
/* ------------------------------------------------------------------ */
saveBan(ban: BanEntry): Promise<void> {
return this.api.saveBan(ban);
}
removeBan(oderId: string): Promise<void> {
return this.api.removeBan(oderId);
}
getBansForRoom(roomId: string): Promise<BanEntry[]> {
return this.api.getBansForRoom(roomId);
}
isUserBanned(userId: string, roomId: string): Promise<boolean> {
return this.api.isUserBanned(userId, roomId);
}
/* ------------------------------------------------------------------ */
/* Attachments */
/* ------------------------------------------------------------------ */
saveAttachment(attachment: any): Promise<void> {
return this.api.saveAttachment(attachment);
}
getAttachmentsForMessage(messageId: string): Promise<any[]> {
return this.api.getAttachmentsForMessage(messageId);
}
getAllAttachments(): Promise<any[]> {
return this.api.getAllAttachments();
}
deleteAttachmentsForMessage(messageId: string): Promise<void> {
return this.api.deleteAttachmentsForMessage(messageId);
}
/* ------------------------------------------------------------------ */
/* Utilities */
/* ------------------------------------------------------------------ */
clearAllData(): Promise<void> {
return this.api.clearAllData();
}
}

View File

@@ -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';

View File

@@ -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;
}
}

View File

@@ -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<ServerEndpoint, 'id'> = {
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');

File diff suppressed because it is too large Load Diff

View File

@@ -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';

View File

@@ -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<string, PeerData>;
/** Trigger SDP renegotiation for a specific peer. */
renegotiate(peerId: string): Promise<void>;
/** 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<typeof setInterval> | null = null;
/** Emitted when voice is successfully connected. */
readonly voiceConnected$ = new Subject<void>();
// 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<MediaStream> {
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<void> {
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<void> {
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();
}
}

View File

@@ -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<string, unknown>): 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<string, PeerData>();
/** Remote composite streams keyed by remote peer ID. */
readonly remotePeerStreams = new Map<string, MediaStream>();
/** Tracks disconnected peers for P2P reconnection scheduling. */
private disconnectedPeerTracker = new Map<string, DisconnectedPeerEntry>();
private peerReconnectTimers = new Map<string, ReturnType<typeof setInterval>>();
// ─── Public event subjects ─────────────────────────────────────────
readonly peerConnected$ = new Subject<string>();
readonly peerDisconnected$ = new Subject<string>();
readonly remoteStream$ = new Subject<{ peerId: string; stream: MediaStream }>();
readonly messageReceived$ = new Subject<ChatEvent>();
/** Emitted whenever the connected peer list changes. */
readonly connectedPeersChanged$ = new Subject<string[]>();
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void>((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();
}
}

View File

@@ -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<string, PeerData>;
getLocalMediaStream(): MediaStream | null;
renegotiate(peerId: string): Promise<void>;
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<MediaStream> {
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;
}
}
}

View File

@@ -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<typeof setTimeout> | null = null;
private stateHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
/** Fires every heartbeat tick the main service hooks this to broadcast state. */
readonly heartbeatTick$ = new Subject<void>();
/** Fires whenever a raw signaling message arrives from the server. */
readonly messageReceived$ = new Subject<any>();
/** 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<string>,
) {}
// ─── Public API ────────────────────────────────────────────────────
/** Open (or re-open) a WebSocket to the signaling server. */
connect(serverUrl: string): Observable<boolean> {
this.lastSignalingUrl = serverUrl;
return new Observable<boolean>((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<boolean> {
if (this.isSocketOpen()) return true;
if (!this.lastSignalingUrl) return false;
return new Promise<boolean>((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<SignalingMessage, 'from' | 'timestamp'>, 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<string, unknown>): 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();
}
}

View File

@@ -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<string, unknown>): 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}`));
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -19,6 +19,17 @@
<ng-icon name="lucideSettings" class="w-4 h-4 inline mr-1" />
Settings
</button>
<button
(click)="activeTab.set('members')"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
[class.text-primary]="activeTab() === 'members'"
[class.border-b-2]="activeTab() === 'members'"
[class.border-primary]="activeTab() === 'members'"
[class.text-muted-foreground]="activeTab() !== 'members'"
>
<ng-icon name="lucideUsers" class="w-4 h-4 inline mr-1" />
Members
</button>
<button
(click)="activeTab.set('bans')"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
@@ -38,8 +49,8 @@
[class.border-primary]="activeTab() === 'permissions'"
[class.text-muted-foreground]="activeTab() !== 'permissions'"
>
<ng-icon name="lucideUsers" class="w-4 h-4 inline mr-1" />
Permissions
<ng-icon name="lucideShield" class="w-4 h-4 inline mr-1" />
Perms
</button>
</div>
@@ -125,6 +136,65 @@
</div>
</div>
}
@case ('members') {
<div class="space-y-4">
<h3 class="text-sm font-medium text-foreground">Server Members</h3>
@if (membersFiltered().length === 0) {
<p class="text-sm text-muted-foreground text-center py-8">
No other members online
</p>
} @else {
@for (user of membersFiltered(); track user.id) {
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold text-sm">
{{ user.displayName ? user.displayName.charAt(0).toUpperCase() : '?' }}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<p class="text-sm font-medium text-foreground truncate">{{ user.displayName }}</p>
@if (user.role === 'host') {
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded">Owner</span>
} @else if (user.role === 'admin') {
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span>
} @else if (user.role === 'moderator') {
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded">Mod</span>
}
</div>
</div>
<!-- Role actions (only for non-hosts) -->
@if (user.role !== 'host') {
<div class="flex items-center gap-1">
<select
[ngModel]="user.role"
(ngModelChange)="changeRole(user, $event)"
class="text-xs px-2 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="member">Member</option>
<option value="moderator">Moderator</option>
<option value="admin">Admin</option>
</select>
<button
(click)="kickMember(user)"
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Kick"
>
<ng-icon name="lucideUserX" class="w-4 h-4" />
</button>
<button
(click)="banMember(user)"
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Ban"
>
<ng-icon name="lucideBan" class="w-4 h-4" />
</button>
</div>
}
</div>
}
}
</div>
}
@case ('bans') {
<div class="space-y-4">
<h3 class="text-sm font-medium text-foreground">Banned Users</h3>

View File

@@ -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<AdminTab>('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,
});
}
}

View File

@@ -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: `
<div class="flex flex-col h-full">
<!-- Messages List -->
<div #messagesContainer class="flex-1 overflow-y-auto p-4 space-y-4 relative" (scroll)="onScroll()">
<!-- Syncing indicator -->
@if (syncing() && !loading()) {
<div class="flex items-center justify-center gap-2 py-1.5 text-xs text-muted-foreground">
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-primary"></div>
<span>Syncing messages…</span>
</div>
}
@if (loading()) {
<div class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
@@ -58,8 +74,21 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
<p class="text-sm">Be the first to say something!</p>
</div>
} @else {
<!-- Infinite scroll: load-more sentinel at top -->
@if (hasMoreMessages()) {
<div class="flex items-center justify-center py-3">
@if (loadingMore()) {
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-primary"></div>
} @else {
<button (click)="loadMore()" class="text-xs text-muted-foreground hover:text-foreground transition-colors px-3 py-1 rounded-md hover:bg-secondary">
Load older messages
</button>
}
</div>
}
@for (message of messages(); track message.id) {
<div
[attr.data-message-id]="message.id"
class="group relative flex gap-3 p-2 rounded-lg hover:bg-secondary/30 transition-colors"
[class.opacity-50]="message.isDeleted"
>
@@ -70,6 +99,20 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
<!-- Message Content -->
<div class="flex-1 min-w-0">
<!-- Reply indicator -->
@if (message.replyToId) {
@let repliedMsg = getRepliedMessage(message.replyToId);
<div class="flex items-center gap-1.5 mb-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground transition-colors" (click)="scrollToMessage(message.replyToId)">
<div class="w-4 h-3 border-l-2 border-t-2 border-muted-foreground/50 rounded-tl-md"></div>
<ng-icon name="lucideReply" class="w-3 h-3" />
@if (repliedMsg) {
<span class="font-medium">{{ repliedMsg.senderName }}</span>
<span class="truncate max-w-[200px]">{{ repliedMsg.content }}</span>
} @else {
<span class="italic">Original message not found</span>
}
</div>
}
<div class="flex items-baseline gap-2">
<span class="font-semibold text-foreground">{{ message.senderName }}</span>
<span class="text-xs text-muted-foreground">
@@ -110,20 +153,69 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
@for (att of getAttachments(message.id); track att.id) {
@if (att.isImage) {
@if (att.available && att.objectUrl) {
<img [src]="att.objectUrl" alt="image" class="rounded-md max-h-80 w-auto" />
} @else {
<div class="border border-border rounded-md p-2 bg-secondary/40">
<div class="flex items-center justify-between">
<div class="min-w-0">
<div class="text-sm font-medium truncate">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
<!-- Available image with hover overlay -->
<div class="relative group/img inline-block" (contextmenu)="openImageContextMenu($event, att)">
<img
[src]="att.objectUrl"
[alt]="att.filename"
class="rounded-md max-h-80 w-auto cursor-pointer"
(click)="openLightbox(att)"
/>
<div class="absolute inset-0 bg-black/0 group-hover/img:bg-black/20 transition-colors rounded-md pointer-events-none"></div>
<div class="absolute top-2 right-2 opacity-0 group-hover/img:opacity-100 transition-opacity flex gap-1">
<button
(click)="openLightbox(att); $event.stopPropagation()"
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md backdrop-blur-sm transition-colors"
title="View full size"
>
<ng-icon name="lucideExpand" class="w-4 h-4" />
</button>
<button
(click)="downloadAttachment(att); $event.stopPropagation()"
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md backdrop-blur-sm transition-colors"
title="Download"
>
<ng-icon name="lucideDownload" class="w-4 h-4" />
</button>
</div>
</div>
} @else if ((att.receivedBytes || 0) > 0) {
<!-- Downloading in progress -->
<div class="border border-border rounded-md p-3 bg-secondary/40 max-w-xs">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center">
<ng-icon name="lucideImage" class="w-5 h-5 text-primary" />
</div>
<div class="text-xs text-muted-foreground">{{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}%</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
</div>
<div class="text-xs font-medium text-primary">{{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}%</div>
</div>
<div class="mt-2 h-1.5 rounded bg-muted">
<div class="h-1.5 rounded bg-primary" [style.width.%]="(att.receivedBytes || 0) * 100 / att.size"></div>
<div class="mt-2 h-1.5 rounded-full bg-muted overflow-hidden">
<div class="h-full rounded-full bg-primary transition-all duration-300" [style.width.%]="(att.receivedBytes || 0) * 100 / att.size"></div>
</div>
</div>
} @else {
<!-- Unavailable — waiting for source -->
<div class="border border-dashed border-border rounded-md p-4 bg-secondary/20 max-w-xs">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-md bg-muted flex items-center justify-center">
<ng-icon name="lucideImage" class="w-5 h-5 text-muted-foreground" />
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate text-foreground">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
<div class="text-xs text-muted-foreground/70 mt-0.5 italic">Waiting for image source…</div>
</div>
</div>
<button
(click)="retryImageRequest(att, message.id)"
class="mt-2 w-full px-3 py-1.5 text-xs bg-secondary hover:bg-secondary/80 text-foreground rounded-md transition-colors"
>
Retry
</button>
</div>
}
} @else {
<div class="border border-border rounded-md p-2 bg-secondary/40">
@@ -357,6 +449,76 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
</div>
</div>
</div>
<!-- Image Lightbox Modal -->
@if (lightboxAttachment()) {
<div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
(click)="closeLightbox()"
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!)"
(keydown.escape)="closeLightbox()"
tabindex="0"
#lightboxBackdrop
>
<div class="relative max-w-[90vw] max-h-[90vh]" (click)="$event.stopPropagation()">
<img
[src]="lightboxAttachment()!.objectUrl"
[alt]="lightboxAttachment()!.filename"
class="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!); $event.stopPropagation()"
/>
<!-- Top-right action bar -->
<div class="absolute top-3 right-3 flex gap-2">
<button
(click)="downloadAttachment(lightboxAttachment()!)"
class="p-2 bg-black/60 hover:bg-black/80 text-white rounded-lg backdrop-blur-sm transition-colors"
title="Download"
>
<ng-icon name="lucideDownload" class="w-5 h-5" />
</button>
<button
(click)="closeLightbox()"
class="p-2 bg-black/60 hover:bg-black/80 text-white rounded-lg backdrop-blur-sm transition-colors"
title="Close"
>
<ng-icon name="lucideX" class="w-5 h-5" />
</button>
</div>
<!-- Bottom info bar -->
<div class="absolute bottom-3 left-3 right-3 flex items-center justify-between">
<div class="px-3 py-1.5 bg-black/60 backdrop-blur-sm rounded-lg">
<span class="text-white text-sm">{{ lightboxAttachment()!.filename }}</span>
<span class="text-white/60 text-xs ml-2">{{ formatBytes(lightboxAttachment()!.size) }}</span>
</div>
</div>
</div>
</div>
}
<!-- Image Context Menu -->
@if (imageContextMenu()) {
<div class="fixed inset-0 z-[110]" (click)="closeImageContextMenu()"></div>
<div
class="fixed z-[120] bg-card border border-border rounded-lg shadow-lg w-48 py-1"
[style.left.px]="imageContextMenu()!.x"
[style.top.px]="imageContextMenu()!.y"
>
<button
(click)="copyImageToClipboard(imageContextMenu()!.attachment)"
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground flex items-center gap-2"
>
<ng-icon name="lucideCopy" class="w-4 h-4 text-muted-foreground" />
Copy Image
</button>
<button
(click)="downloadAttachment(imageContextMenu()!.attachment); closeImageContextMenu()"
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground flex items-center gap-2"
>
<ng-icon name="lucideDownload" class="w-4 h-4 text-muted-foreground" />
Save Image
</button>
</div>
}
`,
})
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<number>(0);
// New messages snackbar state
showNewMessagesBar = signal(false);
// Stable reference time to avoid ExpressionChanged errors (updated every minute)
nowRef = signal<number>(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<string, string>();
// 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<string, SafeHtml>();
// Image lightbox modal state
lightboxAttachment = signal<Attachment | null>(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<void> {
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<Blob> {
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;

View File

@@ -1,9 +1,23 @@
<div class="h-full flex flex-col bg-background">
@if (currentRoom()) {
<!-- Channel header bar -->
<div class="h-12 flex items-center gap-2 px-4 border-b border-border bg-card flex-shrink-0">
<span class="text-muted-foreground text-lg">#</span>
<span class="font-medium text-foreground text-sm">{{ activeChannelName }}</span>
<div class="flex-1"></div>
@if (isAdmin()) {
<button
(click)="toggleAdminPanel()"
class="p-1.5 rounded hover:bg-secondary transition-colors text-muted-foreground hover:text-foreground"
title="Server Settings"
>
<ng-icon name="lucideSettings" class="w-4 h-4" />
</button>
}
</div>
<!-- Main Content -->
<div class="flex-1 flex overflow-hidden">
<!-- Left rail is global; chat area fills remaining space -->
<!-- Chat Area -->
<main class="flex-1 flex flex-col min-w-0">
<!-- Screen Share Viewer -->
@@ -15,15 +29,24 @@
</div>
</main>
<!-- Admin Panel (slide-over) -->
@if (showAdminPanel() && isAdmin()) {
<aside class="w-80 flex-shrink-0 border-l border-border overflow-y-auto">
<div class="flex items-center justify-between px-4 py-2 border-b border-border bg-card">
<span class="text-sm font-medium text-foreground">Server Settings</span>
<button (click)="toggleAdminPanel()" class="p-1 rounded hover:bg-secondary text-muted-foreground hover:text-foreground">
<ng-icon name="lucideX" class="w-4 h-4" />
</button>
</div>
<app-admin-panel />
</aside>
}
<!-- Sidebar always visible -->
<aside class="w-80 flex-shrink-0 border-l border-border">
<app-rooms-side-panel class="h-full" />
</aside>
</div>
<!-- Voice Controls moved to sidebar bottom -->
<!-- Mobile overlay removed; sidebar remains visible by default -->
} @else {
<!-- No Room Selected -->
<div class="flex-1 flex items-center justify-center">

View File

@@ -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);
}
}

View File

@@ -36,135 +36,133 @@
<div class="flex-1 overflow-auto">
<!-- Text Channels -->
<div class="p-3">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Text Channels</h4>
<div class="space-y-1">
<button class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center gap-2 text-left text-foreground/80 hover:text-foreground">
<span class="text-muted-foreground">#</span> general
</button>
<button class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center gap-2 text-left text-foreground/80 hover:text-foreground">
<span class="text-muted-foreground">#</span> random
</button>
<div class="flex items-center justify-between mb-2 px-1">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Text Channels</h4>
@if (canManageChannels()) {
<button (click)="createChannel('text')" class="text-muted-foreground hover:text-foreground transition-colors" title="Create Text Channel">
<ng-icon name="lucidePlus" class="w-3.5 h-3.5" />
</button>
}
</div>
<div class="space-y-0.5">
@for (ch of textChannels(); track ch.id) {
<button
class="w-full px-2 py-1.5 text-sm rounded flex items-center gap-2 text-left transition-colors"
[class.bg-secondary]="activeChannelId() === ch.id"
[class.text-foreground]="activeChannelId() === ch.id"
[class.font-medium]="activeChannelId() === ch.id"
[class.text-foreground/60]="activeChannelId() !== ch.id"
[class.hover:bg-secondary/60]="activeChannelId() !== ch.id"
[class.hover:text-foreground/80]="activeChannelId() !== ch.id"
(click)="selectTextChannel(ch.id)"
(contextmenu)="openChannelContextMenu($event, ch)"
>
<span class="text-muted-foreground text-base">#</span>
@if (renamingChannelId() === ch.id) {
<input
#renameInput
type="text"
[value]="ch.name"
(keydown.enter)="confirmRename($event)"
(keydown.escape)="cancelRename()"
(blur)="confirmRename($event)"
class="flex-1 bg-secondary border border-border rounded px-1 py-0.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
(click)="$event.stopPropagation()"
/>
} @else {
<span class="truncate">{{ ch.name }}</span>
}
</button>
}
</div>
</div>
<!-- Voice Channels -->
<div class="p-3 pt-0">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Voice Channels</h4>
<div class="flex items-center justify-between mb-2 px-1">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Voice Channels</h4>
@if (canManageChannels()) {
<button (click)="createChannel('voice')" class="text-muted-foreground hover:text-foreground transition-colors" title="Create Voice Channel">
<ng-icon name="lucidePlus" class="w-3.5 h-3.5" />
</button>
}
</div>
@if (!voiceEnabled()) {
<p class="text-sm text-muted-foreground px-2 py-2">Voice is disabled by host</p>
}
<div class="space-y-1">
<!-- General Voice -->
<div>
<button
class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center justify-between text-left"
(click)="joinVoice('general')"
[class.bg-secondary/40]="isCurrentRoom('general')"
[disabled]="!voiceEnabled()"
>
<span class="flex items-center gap-2 text-foreground/80">
<span>🔊</span> General
</span>
@if (voiceOccupancy('general') > 0) {
<span class="text-sm text-muted-foreground">{{ voiceOccupancy('general') }}</span>
}
</button>
@if (voiceUsersInRoom('general').length > 0) {
<div class="ml-5 mt-1 space-y-1">
@for (u of voiceUsersInRoom('general'); track u.id) {
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
@if (u.avatarUrl) {
<img
[src]="u.avatarUrl"
alt=""
class="w-7 h-7 rounded-full ring-2 object-cover"
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-red-500]="u.voiceState?.isDeafened"
/>
} @else {
<div
class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-medium ring-2"
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-red-500]="u.voiceState?.isDeafened"
>
{{ u.displayName.charAt(0).toUpperCase() }}
</div>
}
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
<button
(click)="viewStream(u.id); $event.stopPropagation()"
class="px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
>
LIVE
</button>
}
@if (u.voiceState?.isMuted) {
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
}
</div>
@for (ch of voiceChannels(); track ch.id) {
<div>
<button
class="w-full px-2 py-1.5 text-sm rounded hover:bg-secondary/60 flex items-center justify-between text-left transition-colors"
(click)="joinVoice(ch.id)"
(contextmenu)="openChannelContextMenu($event, ch)"
[class.bg-secondary/40]="isCurrentRoom(ch.id)"
[disabled]="!voiceEnabled()"
>
<span class="flex items-center gap-2 text-foreground/80">
<ng-icon name="lucideMic" class="w-4 h-4 text-muted-foreground" />
@if (renamingChannelId() === ch.id) {
<input
#renameInput
type="text"
[value]="ch.name"
(keydown.enter)="confirmRename($event)"
(keydown.escape)="cancelRename()"
(blur)="confirmRename($event)"
class="flex-1 bg-secondary border border-border rounded px-1 py-0.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
(click)="$event.stopPropagation()"
/>
} @else {
<span>{{ ch.name }}</span>
}
</span>
@if (voiceOccupancy(ch.id) > 0) {
<span class="text-xs text-muted-foreground">{{ voiceOccupancy(ch.id) }}</span>
}
</div>
}
</div>
<!-- AFK Voice -->
<div>
<button
class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center justify-between text-left"
(click)="joinVoice('afk')"
[class.bg-secondary/40]="isCurrentRoom('afk')"
[disabled]="!voiceEnabled()"
>
<span class="flex items-center gap-2 text-foreground/80">
<span>🔕</span> AFK
</span>
@if (voiceOccupancy('afk') > 0) {
<span class="text-sm text-muted-foreground">{{ voiceOccupancy('afk') }}</span>
</button>
<!-- Voice users connected to this channel -->
@if (voiceUsersInRoom(ch.id).length > 0) {
<div class="ml-5 mt-1 space-y-1">
@for (u of voiceUsersInRoom(ch.id); track u.id) {
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
@if (u.avatarUrl) {
<img
[src]="u.avatarUrl"
alt=""
class="w-7 h-7 rounded-full ring-2 object-cover"
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-red-500]="u.voiceState?.isDeafened"
/>
} @else {
<div
class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-medium ring-2"
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-red-500]="u.voiceState?.isDeafened"
>
{{ u.displayName.charAt(0).toUpperCase() }}
</div>
}
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
<button
(click)="viewStream(u.id); $event.stopPropagation()"
class="px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
>
LIVE
</button>
}
@if (u.voiceState?.isMuted) {
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
}
</div>
}
</div>
}
</button>
@if (voiceUsersInRoom('afk').length > 0) {
<div class="ml-5 mt-1 space-y-1">
@for (u of voiceUsersInRoom('afk'); track u.id) {
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
@if (u.avatarUrl) {
<img
[src]="u.avatarUrl"
alt=""
class="w-7 h-7 rounded-full ring-2 object-cover"
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-red-500]="u.voiceState?.isDeafened"
/>
} @else {
<div
class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-medium ring-2"
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-red-500]="u.voiceState?.isDeafened"
>
{{ u.displayName.charAt(0).toUpperCase() }}
</div>
}
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
<button
(click)="viewStream(u.id); $event.stopPropagation()"
class="px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
>
LIVE
</button>
}
@if (u.voiceState?.isMuted) {
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
}
</div>
}
</div>
}
</div>
</div>
}
</div>
</div>
</div>
@@ -217,7 +215,10 @@
</h4>
<div class="space-y-1">
@for (user of onlineUsersFiltered(); track user.id) {
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
<div
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40 group/user"
(contextmenu)="openUserContextMenu($event, user)"
>
<div class="relative">
@if (user.avatarUrl) {
<img [src]="user.avatarUrl" alt="" class="w-8 h-8 rounded-full object-cover" />
@@ -229,7 +230,16 @@
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm text-foreground truncate">{{ user.displayName }}</p>
<div class="flex items-center gap-1.5">
<p class="text-sm text-foreground truncate">{{ user.displayName }}</p>
@if (user.role === 'host') {
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium">Owner</span>
} @else if (user.role === 'admin') {
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium">Admin</span>
} @else if (user.role === 'moderator') {
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium">Mod</span>
}
</div>
<div class="flex items-center gap-2">
@if (user.voiceState?.isConnected) {
<p class="text-[10px] text-muted-foreground flex items-center gap-1">
@@ -270,3 +280,80 @@
</div>
}
</aside>
<!-- Channel context menu -->
@if (showChannelMenu()) {
<div class="fixed inset-0 z-40" (click)="closeChannelMenu()"></div>
<div class="fixed z-50 bg-card border border-border rounded-lg shadow-lg w-44 py-1" [style.left.px]="channelMenuX()" [style.top.px]="channelMenuY()">
<button (click)="resyncMessages()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Resync Messages
</button>
@if (canManageChannels()) {
<div class="border-t border-border my-1"></div>
<button (click)="startRename()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Rename Channel
</button>
<button (click)="deleteChannel()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-destructive">
Delete Channel
</button>
}
</div>
}
<!-- User context menu (kick / role management) -->
@if (showUserMenu()) {
<div class="fixed inset-0 z-40" (click)="closeUserMenu()"></div>
<div class="fixed z-50 bg-card border border-border rounded-lg shadow-lg w-48 py-1" [style.left.px]="userMenuX()" [style.top.px]="userMenuY()">
@if (isAdmin()) {
<!-- Role management -->
@if (contextMenuUser()?.role === 'member') {
<button (click)="changeUserRole('moderator')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Promote to Moderator
</button>
<button (click)="changeUserRole('admin')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Promote to Admin
</button>
}
@if (contextMenuUser()?.role === 'moderator') {
<button (click)="changeUserRole('admin')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Promote to Admin
</button>
<button (click)="changeUserRole('member')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Demote to Member
</button>
}
@if (contextMenuUser()?.role === 'admin') {
<button (click)="changeUserRole('member')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Demote to Member
</button>
}
<div class="border-t border-border my-1"></div>
<button (click)="kickUserAction()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-destructive">
Kick User
</button>
} @else {
<div class="px-3 py-1.5 text-sm text-muted-foreground">No actions available</div>
}
</div>
}
<!-- Create channel dialog -->
@if (showCreateChannelDialog()) {
<div class="fixed inset-0 z-40 bg-black/30" (click)="cancelCreateChannel()"></div>
<div class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg w-[320px]">
<div class="p-4">
<h4 class="font-semibold text-foreground mb-3">Create {{ createChannelType() === 'text' ? 'Text' : 'Voice' }} Channel</h4>
<input
type="text"
[(ngModel)]="newChannelName"
placeholder="Channel name"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary text-sm"
(keydown.enter)="confirmCreateChannel()"
/>
</div>
<div class="flex gap-2 p-3 border-t border-border">
<button (click)="cancelCreateChannel()" class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm">Cancel</button>
<button (click)="confirmCreateChannel()" class="flex-1 px-3 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors text-sm">Create</button>
</div>
</div>
}

View File

@@ -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<Channel | null>(null);
// Rename state
renamingChannelId = signal<string | null>(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<User | null>(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 &&

View File

@@ -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 {}

View File

@@ -226,6 +226,10 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
async loadAudioDevices(): Promise<void> {
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,

View File

@@ -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');

View File

@@ -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<string, { ts: number; rc: number }>();
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<string, any> = {};
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<void>();
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<void>((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 }
);
}

View File

@@ -5,6 +5,7 @@ import * as MessagesActions from './messages.actions';
export interface MessagesState extends EntityState<Message> {
loading: boolean;
syncing: boolean;
error: string | null;
currentRoomId: string | null;
}
@@ -16,6 +17,7 @@ export const messagesAdapter: EntityAdapter<Message> = createEntityAdapter<Messa
export const initialState: MessagesState = messagesAdapter.getInitialState({
loading: false,
syncing: false,
error: null,
currentRoomId: null,
});
@@ -23,13 +25,23 @@ export const initialState: MessagesState = messagesAdapter.getInitialState({
export const messagesReducer = createReducer(
initialState,
// Load messages
on(MessagesActions.loadMessages, (state, { roomId }) => ({
...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) =>

View File

@@ -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]);

View File

@@ -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<Room> }>()
);
// 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');

View File

@@ -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<string, Room>();
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),
};
})
);

View File

@@ -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)
);

View File

@@ -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'
);