Big commit
This commit is contained in:
4
.env.example
Normal file
4
.env.example
Normal 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
4
.gitignore
vendored
@@ -45,3 +45,7 @@ __screenshots__/
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment & certs
|
||||
.env
|
||||
.certs/
|
||||
|
||||
36
dev.sh
Executable file
36
dev.sh
Executable 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
626
electron/database.js
Normal 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 };
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
28
generate-cert.sh
Executable 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"
|
||||
@@ -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.
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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
43
server/dist/db.d.ts
vendored
Normal 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
1
server/dist/db.d.ts.map
vendored
Normal 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
277
server/dist/db.js
vendored
Normal 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
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
305
server/dist/index.js
vendored
@@ -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
|
||||
2
server/dist/index.js.map
vendored
2
server/dist/index.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
208
server/src/db.ts
208
server/src/db.ts
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
302
src/app/core/services/browser-database.service.ts
Normal file
302
src/app/core/services/browser-database.service.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
|
||||
173
src/app/core/services/electron-database.service.ts
Normal file
173
src/app/core/services/electron-database.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
20
src/app/core/services/platform.service.ts
Normal file
20
src/app/core/services/platform.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
13
src/app/core/services/webrtc/index.ts
Normal file
13
src/app/core/services/webrtc/index.ts
Normal 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';
|
||||
311
src/app/core/services/webrtc/media.manager.ts
Normal file
311
src/app/core/services/webrtc/media.manager.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
623
src/app/core/services/webrtc/peer-connection.manager.ts
Normal file
623
src/app/core/services/webrtc/peer-connection.manager.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
275
src/app/core/services/webrtc/screen-share.manager.ts
Normal file
275
src/app/core/services/webrtc/screen-share.manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
219
src/app/core/services/webrtc/signaling.manager.ts
Normal file
219
src/app/core/services/webrtc/signaling.manager.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
66
src/app/core/services/webrtc/webrtc-logger.ts
Normal file
66
src/app/core/services/webrtc/webrtc-logger.ts
Normal 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}`));
|
||||
}
|
||||
}
|
||||
106
src/app/core/services/webrtc/webrtc.constants.ts
Normal file
106
src/app/core/services/webrtc/webrtc.constants.ts
Normal 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;
|
||||
43
src/app/core/services/webrtc/webrtc.types.ts
Normal file
43
src/app/core/services/webrtc/webrtc.types.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user