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
|
# System files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
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 fs = require('fs');
|
||||||
const fsp = fs.promises;
|
const fsp = fs.promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { registerDatabaseIpc } = require('./database');
|
||||||
|
|
||||||
let mainWindow;
|
let mainWindow;
|
||||||
|
|
||||||
@@ -9,6 +10,10 @@ let mainWindow;
|
|||||||
app.commandLine.appendSwitch('disable-features', 'Autofill,AutofillAssistant,AutofillServerCommunication');
|
app.commandLine.appendSwitch('disable-features', 'Autofill,AutofillAssistant,AutofillServerCommunication');
|
||||||
// Allow media autoplay without user gesture (bypasses Chromium autoplay policy)
|
// Allow media autoplay without user gesture (bypasses Chromium autoplay policy)
|
||||||
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
|
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() {
|
function createWindow() {
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
@@ -29,7 +34,10 @@ function createWindow() {
|
|||||||
|
|
||||||
// In development, load from Angular dev server
|
// In development, load from Angular dev server
|
||||||
if (process.env.NODE_ENV === 'development') {
|
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') {
|
if (process.env.DEBUG_DEVTOOLS === '1') {
|
||||||
mainWindow.webContents.openDevTools();
|
mainWindow.webContents.openDevTools();
|
||||||
}
|
}
|
||||||
@@ -44,6 +52,9 @@ function createWindow() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register database IPC handlers before app is ready
|
||||||
|
registerDatabaseIpc();
|
||||||
|
|
||||||
app.whenReady().then(createWindow);
|
app.whenReady().then(createWindow);
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
|
|||||||
@@ -17,4 +17,52 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
||||||
fileExists: (filePath) => ipcRenderer.invoke('file-exists', filePath),
|
fileExists: (filePath) => ipcRenderer.invoke('file-exists', filePath),
|
||||||
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
|
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",
|
"server:dev": "cd server && npm run dev",
|
||||||
"electron": "ng build && electron .",
|
"electron": "ng build && electron .",
|
||||||
"electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && cross-env NODE_ENV=development 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: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": "npm run build:prod && electron-builder",
|
||||||
"electron:build:win": "npm run build:prod && electron-builder --win",
|
"electron:build:win": "npm run build:prod && electron-builder --win",
|
||||||
|
|||||||
Binary file not shown.
@@ -1,39 +1,14 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": "5b9ee424-dcfc-4bcb-a0cd-175b0d4dc3d7",
|
"id": "274b8cec-83cf-41b6-981f-f5116c90696e",
|
||||||
"name": "hello",
|
"name": "Opem",
|
||||||
"ownerId": "7c74c680-09ca-42ee-b5fb-650e8eaa1622",
|
"ownerId": "a01f9b26-b443-49a7-82a1-4d75c9bc9824",
|
||||||
"ownerPublicKey": "5b870756-bdfd-47c0-9a27-90f5838b66ac",
|
"ownerPublicKey": "a01f9b26-b443-49a7-82a1-4d75c9bc9824",
|
||||||
"isPrivate": false,
|
"isPrivate": false,
|
||||||
"maxUsers": 50,
|
"maxUsers": 50,
|
||||||
"currentUsers": 0,
|
"currentUsers": 0,
|
||||||
"tags": [],
|
"tags": [],
|
||||||
"createdAt": 1766898986953,
|
"createdAt": 1772382716566,
|
||||||
"lastSeen": 1766898986953
|
"lastSeen": 1772382716566
|
||||||
},
|
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -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;
|
const PORT = process.env.PORT || 3001;
|
||||||
app.use((0, cors_1.default)());
|
app.use((0, cors_1.default)());
|
||||||
app.use(express_1.default.json());
|
app.use(express_1.default.json());
|
||||||
const servers = new Map();
|
|
||||||
const joinRequests = new Map();
|
|
||||||
const connectedUsers = new Map();
|
const connectedUsers = new Map();
|
||||||
// Persistence
|
// Database
|
||||||
const fs_1 = __importDefault(require("fs"));
|
const crypto_1 = __importDefault(require("crypto"));
|
||||||
const path_1 = __importDefault(require("path"));
|
const db_1 = require("./db");
|
||||||
const DATA_DIR = path_1.default.join(process.cwd(), 'data');
|
function hashPassword(pw) { return crypto_1.default.createHash('sha256').update(pw).digest('hex'); }
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// REST API Routes
|
// REST API Routes
|
||||||
// Health check endpoint
|
// 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({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
serverCount: servers.size,
|
serverCount: allServers.length,
|
||||||
connectedUsers: connectedUsers.size,
|
connectedUsers: connectedUsers.size,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
let authUsers = [];
|
// Time endpoint for clock synchronization
|
||||||
function saveUsers() { ensureDataDir(); fs_1.default.writeFileSync(USERS_FILE, JSON.stringify(authUsers, null, 2)); }
|
app.get('/api/time', (req, res) => {
|
||||||
function loadUsers() { ensureDataDir(); if (fs_1.default.existsSync(USERS_FILE)) {
|
res.json({ now: Date.now() });
|
||||||
authUsers = JSON.parse(fs_1.default.readFileSync(USERS_FILE, 'utf-8'));
|
});
|
||||||
} }
|
// Image proxy to allow rendering external images within CSP (img-src 'self' data: blob:)
|
||||||
const crypto_1 = __importDefault(require("crypto"));
|
app.get('/api/image-proxy', async (req, res) => {
|
||||||
function hashPassword(pw) { return crypto_1.default.createHash('sha256').update(pw).digest('hex'); }
|
try {
|
||||||
app.post('/api/users/register', (req, res) => {
|
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;
|
const { username, password, displayName } = req.body;
|
||||||
if (!username || !password)
|
if (!username || !password)
|
||||||
return res.status(400).json({ error: 'Missing 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' });
|
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() };
|
const user = { id: (0, uuid_1.v4)(), username, passwordHash: hashPassword(password), displayName: displayName || username, createdAt: Date.now() };
|
||||||
authUsers.push(user);
|
await (0, db_1.createUser)(user);
|
||||||
saveUsers();
|
|
||||||
res.status(201).json({ id: user.id, username: user.username, displayName: user.displayName });
|
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 { username, password } = req.body;
|
||||||
const user = authUsers.find(u => u.username === username && u.passwordHash === hashPassword(password));
|
const user = await (0, db_1.getUserByUsername)(username);
|
||||||
if (!user)
|
if (!user || user.passwordHash !== hashPassword(password))
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
res.json({ id: user.id, username: user.username, displayName: user.displayName });
|
res.json({ id: user.id, username: user.username, displayName: user.displayName });
|
||||||
});
|
});
|
||||||
// Search servers
|
// Search servers
|
||||||
app.get('/api/servers', (req, res) => {
|
app.get('/api/servers', async (req, res) => {
|
||||||
const { q, tags, limit = 20, offset = 0 } = req.query;
|
const { q, tags, limit = 20, offset = 0 } = req.query;
|
||||||
let results = Array.from(servers.values())
|
let results = await (0, db_1.getAllPublicServers)();
|
||||||
.filter(s => !s.isPrivate)
|
results = results
|
||||||
.filter(s => {
|
.filter(s => {
|
||||||
if (q) {
|
if (q) {
|
||||||
const query = String(q).toLowerCase();
|
const query = String(q).toLowerCase();
|
||||||
@@ -92,18 +106,16 @@ app.get('/api/servers', (req, res) => {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
// Keep servers visible permanently until deleted; do not filter by lastSeen
|
|
||||||
const total = results.length;
|
const total = results.length;
|
||||||
results = results.slice(Number(offset), Number(offset) + Number(limit));
|
results = results.slice(Number(offset), Number(offset) + Number(limit));
|
||||||
res.json({ servers: results, total, limit: Number(limit), offset: Number(offset) });
|
res.json({ servers: results, total, limit: Number(limit), offset: Number(offset) });
|
||||||
});
|
});
|
||||||
// Register a server
|
// 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;
|
const { id: clientId, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, tags } = req.body;
|
||||||
if (!name || !ownerId || !ownerPublicKey) {
|
if (!name || !ownerId || !ownerPublicKey) {
|
||||||
return res.status(400).json({ error: 'Missing required fields' });
|
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 id = clientId || (0, uuid_1.v4)();
|
||||||
const server = {
|
const server = {
|
||||||
id,
|
id,
|
||||||
@@ -118,15 +130,14 @@ app.post('/api/servers', (req, res) => {
|
|||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
lastSeen: Date.now(),
|
lastSeen: Date.now(),
|
||||||
};
|
};
|
||||||
servers.set(id, server);
|
await (0, db_1.upsertServer)(server);
|
||||||
saveServers();
|
|
||||||
res.status(201).json(server);
|
res.status(201).json(server);
|
||||||
});
|
});
|
||||||
// Update server
|
// Update server
|
||||||
app.put('/api/servers/:id', (req, res) => {
|
app.put('/api/servers/:id', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { ownerId, ...updates } = req.body;
|
const { ownerId, ...updates } = req.body;
|
||||||
const server = servers.get(id);
|
const server = await (0, db_1.getServerById)(id);
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return res.status(404).json({ error: 'Server not found' });
|
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' });
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
}
|
}
|
||||||
const updated = { ...server, ...updates, lastSeen: Date.now() };
|
const updated = { ...server, ...updates, lastSeen: Date.now() };
|
||||||
servers.set(id, updated);
|
await (0, db_1.upsertServer)(updated);
|
||||||
saveServers();
|
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
});
|
});
|
||||||
// Heartbeat - keep server alive
|
// 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 { id } = req.params;
|
||||||
const { currentUsers } = req.body;
|
const { currentUsers } = req.body;
|
||||||
const server = servers.get(id);
|
const server = await (0, db_1.getServerById)(id);
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return res.status(404).json({ error: 'Server not found' });
|
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') {
|
if (typeof currentUsers === 'number') {
|
||||||
server.currentUsers = currentUsers;
|
server.currentUsers = currentUsers;
|
||||||
}
|
}
|
||||||
servers.set(id, server);
|
await (0, db_1.upsertServer)(server);
|
||||||
saveServers();
|
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
// Remove server
|
// Remove server
|
||||||
app.delete('/api/servers/:id', (req, res) => {
|
app.delete('/api/servers/:id', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { ownerId } = req.body;
|
const { ownerId } = req.body;
|
||||||
const server = servers.get(id);
|
const server = await (0, db_1.getServerById)(id);
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return res.status(404).json({ error: 'Server not found' });
|
return res.status(404).json({ error: 'Server not found' });
|
||||||
}
|
}
|
||||||
if (server.ownerId !== ownerId) {
|
if (server.ownerId !== ownerId) {
|
||||||
return res.status(403).json({ error: 'Not authorized' });
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
}
|
}
|
||||||
servers.delete(id);
|
await (0, db_1.deleteServer)(id);
|
||||||
saveServers();
|
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
// Request to join a server
|
// 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 { id: serverId } = req.params;
|
||||||
const { userId, userPublicKey, displayName } = req.body;
|
const { userId, userPublicKey, displayName } = req.body;
|
||||||
const server = servers.get(serverId);
|
const server = await (0, db_1.getServerById)(serverId);
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return res.status(404).json({ error: 'Server not found' });
|
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',
|
status: server.isPrivate ? 'pending' : 'approved',
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
};
|
};
|
||||||
joinRequests.set(requestId, request);
|
await (0, db_1.createJoinRequest)(request);
|
||||||
// Notify server owner via WebSocket
|
// Notify server owner via WebSocket
|
||||||
if (server.isPrivate) {
|
if (server.isPrivate) {
|
||||||
notifyServerOwner(server.ownerId, {
|
notifyServerOwner(server.ownerId, {
|
||||||
@@ -198,70 +206,72 @@ app.post('/api/servers/:id/join', (req, res) => {
|
|||||||
res.status(201).json(request);
|
res.status(201).json(request);
|
||||||
});
|
});
|
||||||
// Get join requests for a server
|
// 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 { id: serverId } = req.params;
|
||||||
const { ownerId } = req.query;
|
const { ownerId } = req.query;
|
||||||
const server = servers.get(serverId);
|
const server = await (0, db_1.getServerById)(serverId);
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return res.status(404).json({ error: 'Server not found' });
|
return res.status(404).json({ error: 'Server not found' });
|
||||||
}
|
}
|
||||||
if (server.ownerId !== ownerId) {
|
if (server.ownerId !== ownerId) {
|
||||||
return res.status(403).json({ error: 'Not authorized' });
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
}
|
}
|
||||||
const requests = Array.from(joinRequests.values())
|
const requests = await (0, db_1.getPendingRequestsForServer)(serverId);
|
||||||
.filter(r => r.serverId === serverId && r.status === 'pending');
|
|
||||||
res.json({ requests });
|
res.json({ requests });
|
||||||
});
|
});
|
||||||
// Approve/reject join request
|
// Approve/reject join request
|
||||||
app.put('/api/requests/:id', (req, res) => {
|
app.put('/api/requests/:id', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { ownerId, status } = req.body;
|
const { ownerId, status } = req.body;
|
||||||
const request = joinRequests.get(id);
|
const request = await (0, db_1.getJoinRequestById)(id);
|
||||||
if (!request) {
|
if (!request) {
|
||||||
return res.status(404).json({ error: 'Request not found' });
|
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) {
|
if (!server || server.ownerId !== ownerId) {
|
||||||
return res.status(403).json({ error: 'Not authorized' });
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
}
|
}
|
||||||
request.status = status;
|
await (0, db_1.updateJoinRequestStatus)(id, status);
|
||||||
joinRequests.set(id, request);
|
const updated = { ...request, status };
|
||||||
// Notify the requester
|
// Notify the requester
|
||||||
notifyUser(request.userId, {
|
notifyUser(request.userId, {
|
||||||
type: 'request_update',
|
type: 'request_update',
|
||||||
request,
|
request: updated,
|
||||||
});
|
});
|
||||||
res.json(request);
|
res.json(updated);
|
||||||
});
|
});
|
||||||
// WebSocket Server for real-time signaling
|
// WebSocket Server for real-time signaling
|
||||||
const server = (0, http_1.createServer)(app);
|
const server = (0, http_1.createServer)(app);
|
||||||
const wss = new ws_1.WebSocketServer({ server });
|
const wss = new ws_1.WebSocketServer({ server });
|
||||||
wss.on('connection', (ws) => {
|
wss.on('connection', (ws) => {
|
||||||
const oderId = (0, uuid_1.v4)();
|
const connectionId = (0, uuid_1.v4)();
|
||||||
connectedUsers.set(oderId, { oderId, ws });
|
connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set() });
|
||||||
ws.on('message', (data) => {
|
ws.on('message', (data) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(data.toString());
|
const message = JSON.parse(data.toString());
|
||||||
handleWebSocketMessage(oderId, message);
|
handleWebSocketMessage(connectionId, message);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error('Invalid WebSocket message:', err);
|
console.error('Invalid WebSocket message:', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
const user = connectedUsers.get(oderId);
|
const user = connectedUsers.get(connectionId);
|
||||||
if (user?.serverId) {
|
if (user) {
|
||||||
// Notify others in the room
|
// Notify all servers the user was a member of
|
||||||
broadcastToServer(user.serverId, {
|
user.serverIds.forEach((sid) => {
|
||||||
type: 'user_left',
|
broadcastToServer(sid, {
|
||||||
oderId,
|
type: 'user_left',
|
||||||
displayName: user.displayName,
|
oderId: user.oderId,
|
||||||
}, oderId);
|
displayName: user.displayName,
|
||||||
|
serverId: sid,
|
||||||
|
}, user.oderId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
connectedUsers.delete(oderId);
|
connectedUsers.delete(connectionId);
|
||||||
});
|
});
|
||||||
// Send connection acknowledgment
|
// Send connection acknowledgment with the connectionId (client will identify with their actual oderId)
|
||||||
ws.send(JSON.stringify({ type: 'connected', oderId }));
|
ws.send(JSON.stringify({ type: 'connected', connectionId, serverTime: Date.now() }));
|
||||||
});
|
});
|
||||||
function handleWebSocketMessage(connectionId, message) {
|
function handleWebSocketMessage(connectionId, message) {
|
||||||
const user = connectedUsers.get(connectionId);
|
const user = connectedUsers.get(connectionId);
|
||||||
@@ -276,38 +286,68 @@ function handleWebSocketMessage(connectionId, message) {
|
|||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||||
break;
|
break;
|
||||||
case 'join_server':
|
case 'join_server': {
|
||||||
user.serverId = message.serverId;
|
const sid = message.serverId;
|
||||||
|
const isNew = !user.serverIds.has(sid);
|
||||||
|
user.serverIds.add(sid);
|
||||||
|
user.viewedServerId = sid;
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User ${user.displayName} (${user.oderId}) joined server ${message.serverId}`);
|
console.log(`User ${user.displayName || 'Anonymous'} (${user.oderId}) joined server ${sid} (new=${isNew})`);
|
||||||
// Get list of current users in server (exclude this user by oderId)
|
// Always send the current user list for this server
|
||||||
const usersInServer = Array.from(connectedUsers.values())
|
const usersInServer = Array.from(connectedUsers.values())
|
||||||
.filter(u => u.serverId === message.serverId && u.oderId !== user.oderId)
|
.filter(u => u.serverIds.has(sid) && u.oderId !== user.oderId && u.displayName)
|
||||||
.map(u => ({ oderId: u.oderId, displayName: u.displayName }));
|
.map(u => ({ oderId: u.oderId, displayName: u.displayName || 'Anonymous' }));
|
||||||
console.log(`Sending server_users to ${user.displayName}:`, usersInServer);
|
console.log(`Sending server_users to ${user.displayName || 'Anonymous'}:`, usersInServer);
|
||||||
user.ws.send(JSON.stringify({
|
user.ws.send(JSON.stringify({
|
||||||
type: 'server_users',
|
type: 'server_users',
|
||||||
|
serverId: sid,
|
||||||
users: usersInServer,
|
users: usersInServer,
|
||||||
}));
|
}));
|
||||||
// Notify others (exclude by oderId, not connectionId)
|
// Only broadcast user_joined if this is a brand-new join (not a re-view)
|
||||||
broadcastToServer(message.serverId, {
|
if (isNew) {
|
||||||
type: 'user_joined',
|
broadcastToServer(sid, {
|
||||||
oderId: user.oderId,
|
type: 'user_joined',
|
||||||
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',
|
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName,
|
displayName: user.displayName || 'Anonymous',
|
||||||
|
serverId: sid,
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
}
|
}
|
||||||
break;
|
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 'offer':
|
||||||
case 'answer':
|
case 'answer':
|
||||||
case 'ice_candidate':
|
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 })));
|
console.log(`Target user ${message.targetUserId} not found. Connected users:`, Array.from(connectedUsers.values()).map(u => ({ oderId: u.oderId, displayName: u.displayName })));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'chat_message':
|
case 'chat_message': {
|
||||||
// Broadcast chat message to all users in the server
|
// Broadcast chat message to all users in the server
|
||||||
if (user.serverId) {
|
const chatSid = message.serverId || user.viewedServerId;
|
||||||
broadcastToServer(user.serverId, {
|
if (chatSid && user.serverIds.has(chatSid)) {
|
||||||
|
broadcastToServer(chatSid, {
|
||||||
type: 'chat_message',
|
type: 'chat_message',
|
||||||
|
serverId: chatSid,
|
||||||
message: message.message,
|
message: message.message,
|
||||||
senderId: user.oderId,
|
senderId: user.oderId,
|
||||||
senderName: user.displayName,
|
senderName: user.displayName,
|
||||||
@@ -337,16 +379,20 @@ function handleWebSocketMessage(connectionId, message) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'typing':
|
}
|
||||||
|
case 'typing': {
|
||||||
// Broadcast typing indicator
|
// Broadcast typing indicator
|
||||||
if (user.serverId) {
|
const typingSid = message.serverId || user.viewedServerId;
|
||||||
broadcastToServer(user.serverId, {
|
if (typingSid && user.serverIds.has(typingSid)) {
|
||||||
|
broadcastToServer(typingSid, {
|
||||||
type: 'user_typing',
|
type: 'user_typing',
|
||||||
|
serverId: typingSid,
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
console.log('Unknown message type:', message.type);
|
console.log('Unknown message type:', message.type);
|
||||||
}
|
}
|
||||||
@@ -354,7 +400,7 @@ function handleWebSocketMessage(connectionId, message) {
|
|||||||
function broadcastToServer(serverId, message, excludeOderId) {
|
function broadcastToServer(serverId, message, excludeOderId) {
|
||||||
console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type);
|
console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type);
|
||||||
connectedUsers.forEach((user) => {
|
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})`);
|
console.log(` -> Sending to ${user.displayName} (${user.oderId})`);
|
||||||
user.ws.send(JSON.stringify(message));
|
user.ws.send(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
@@ -375,21 +421,18 @@ function notifyUser(oderId, message) {
|
|||||||
function findUserByUserId(oderId) {
|
function findUserByUserId(oderId) {
|
||||||
return Array.from(connectedUsers.values()).find(u => u.oderId === oderId);
|
return Array.from(connectedUsers.values()).find(u => u.oderId === oderId);
|
||||||
}
|
}
|
||||||
// Cleanup old data periodically
|
// Cleanup stale join requests periodically (older than 24 h)
|
||||||
// Simple cleanup only for stale join requests (keep servers permanent)
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const now = Date.now();
|
(0, db_1.deleteStaleJoinRequests)(24 * 60 * 60 * 1000).catch(err => console.error('Failed to clean up stale join requests:', err));
|
||||||
joinRequests.forEach((request, id) => {
|
|
||||||
if (now - request.createdAt > 24 * 60 * 60 * 1000) {
|
|
||||||
joinRequests.delete(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
server.listen(PORT, () => {
|
(0, db_1.initDB)().then(() => {
|
||||||
console.log(`🚀 MetoYou signaling server running on port ${PORT}`);
|
server.listen(PORT, () => {
|
||||||
console.log(` REST API: http://localhost:${PORT}/api`);
|
console.log(`🚀 MetoYou signaling server running on port ${PORT}`);
|
||||||
console.log(` WebSocket: ws://localhost:${PORT}`);
|
console.log(` REST API: http://localhost:${PORT}/api`);
|
||||||
// Load servers on startup
|
console.log(` WebSocket: ws://localhost:${PORT}`);
|
||||||
loadServers();
|
});
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('Failed to initialize database:', err);
|
||||||
|
process.exit(1);
|
||||||
});
|
});
|
||||||
//# sourceMappingURL=index.js.map
|
//# 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": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"sql.js": "^1.9.0",
|
"sql.js": "^1.9.0",
|
||||||
"uuid": "^9.0.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();
|
persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +74,10 @@ function persist(): void {
|
|||||||
fs.writeFileSync(DB_FILE, buffer);
|
fs.writeFileSync(DB_FILE, buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Auth Users */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -100,3 +132,179 @@ export async function createUser(user: AuthUser): Promise<void> {
|
|||||||
stmt.free();
|
stmt.free();
|
||||||
persist();
|
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 express from 'express';
|
||||||
import cors from 'cors';
|
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 { WebSocketServer, WebSocket } from 'ws';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
const USE_SSL = (process.env.SSL ?? 'false').toLowerCase() === 'true';
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// In-memory storage for servers and users
|
// In-memory runtime state (WebSocket connections only – not persisted)
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConnectedUser {
|
interface ConnectedUser {
|
||||||
oderId: string;
|
oderId: string;
|
||||||
ws: WebSocket;
|
ws: WebSocket;
|
||||||
@@ -43,43 +28,38 @@ interface ConnectedUser {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const servers = new Map<string, ServerInfo>();
|
|
||||||
const joinRequests = new Map<string, JoinRequest>();
|
|
||||||
const connectedUsers = new Map<string, ConnectedUser>();
|
const connectedUsers = new Map<string, ConnectedUser>();
|
||||||
|
|
||||||
// Persistence
|
// Database
|
||||||
import fs from 'fs';
|
import crypto from 'crypto';
|
||||||
import path from 'path';
|
import {
|
||||||
const DATA_DIR = path.join(process.cwd(), 'data');
|
initDB,
|
||||||
const SERVERS_FILE = path.join(DATA_DIR, 'servers.json');
|
getUserByUsername,
|
||||||
const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
createUser,
|
||||||
|
getAllPublicServers,
|
||||||
|
getServerById,
|
||||||
|
upsertServer,
|
||||||
|
deleteServer as dbDeleteServer,
|
||||||
|
createJoinRequest,
|
||||||
|
getJoinRequestById,
|
||||||
|
getPendingRequestsForServer,
|
||||||
|
updateJoinRequestStatus,
|
||||||
|
deleteStaleJoinRequests,
|
||||||
|
ServerInfo,
|
||||||
|
JoinRequest,
|
||||||
|
} from './db';
|
||||||
|
|
||||||
function ensureDataDir() {
|
function hashPassword(pw: string) { return crypto.createHash('sha256').update(pw).digest('hex'); }
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// REST API Routes
|
// REST API Routes
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', async (req, res) => {
|
||||||
|
const allServers = await getAllPublicServers();
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
serverCount: servers.size,
|
serverCount: allServers.length,
|
||||||
connectedUsers: connectedUsers.size,
|
connectedUsers: connectedUsers.size,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -129,40 +109,31 @@ app.get('/api/image-proxy', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Basic auth (demo - file-based)
|
// Auth
|
||||||
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'); }
|
|
||||||
|
|
||||||
app.post('/api/users/register', async (req, res) => {
|
app.post('/api/users/register', async (req, res) => {
|
||||||
const { username, password, displayName } = req.body;
|
const { username, password, displayName } = req.body;
|
||||||
if (!username || !password) return res.status(400).json({ error: 'Missing username/password' });
|
if (!username || !password) return res.status(400).json({ error: 'Missing username/password' });
|
||||||
await initDB();
|
|
||||||
const exists = await getUserByUsername(username);
|
const exists = await getUserByUsername(username);
|
||||||
if (exists) return res.status(409).json({ error: 'Username taken' });
|
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);
|
await createUser(user);
|
||||||
res.status(201).json({ id: user.id, username: user.username, displayName: user.displayName });
|
res.status(201).json({ id: user.id, username: user.username, displayName: user.displayName });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/users/login', async (req, res) => {
|
app.post('/api/users/login', async (req, res) => {
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body;
|
||||||
await initDB();
|
|
||||||
const user = await getUserByUsername(username);
|
const user = await getUserByUsername(username);
|
||||||
if (!user || user.passwordHash !== hashPassword(password)) return res.status(401).json({ error: 'Invalid credentials' });
|
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 });
|
res.json({ id: user.id, username: user.username, displayName: user.displayName });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Search servers
|
// Search servers
|
||||||
app.get('/api/servers', (req, res) => {
|
app.get('/api/servers', async (req, res) => {
|
||||||
const { q, tags, limit = 20, offset = 0 } = req.query;
|
const { q, tags, limit = 20, offset = 0 } = req.query;
|
||||||
|
|
||||||
let results = Array.from(servers.values())
|
let results = await getAllPublicServers();
|
||||||
.filter(s => !s.isPrivate)
|
|
||||||
|
results = results
|
||||||
.filter(s => {
|
.filter(s => {
|
||||||
if (q) {
|
if (q) {
|
||||||
const query = String(q).toLowerCase();
|
const query = String(q).toLowerCase();
|
||||||
@@ -179,8 +150,6 @@ app.get('/api/servers', (req, res) => {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep servers visible permanently until deleted; do not filter by lastSeen
|
|
||||||
|
|
||||||
const total = results.length;
|
const total = results.length;
|
||||||
results = results.slice(Number(offset), Number(offset) + Number(limit));
|
results = results.slice(Number(offset), Number(offset) + Number(limit));
|
||||||
|
|
||||||
@@ -188,14 +157,13 @@ app.get('/api/servers', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Register a server
|
// 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;
|
const { id: clientId, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, tags } = req.body;
|
||||||
|
|
||||||
if (!name || !ownerId || !ownerPublicKey) {
|
if (!name || !ownerId || !ownerPublicKey) {
|
||||||
return res.status(400).json({ error: 'Missing required fields' });
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use client-provided ID if available, otherwise generate one
|
|
||||||
const id = clientId || uuidv4();
|
const id = clientId || uuidv4();
|
||||||
const server: ServerInfo = {
|
const server: ServerInfo = {
|
||||||
id,
|
id,
|
||||||
@@ -211,17 +179,16 @@ app.post('/api/servers', (req, res) => {
|
|||||||
lastSeen: Date.now(),
|
lastSeen: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
servers.set(id, server);
|
await upsertServer(server);
|
||||||
saveServers();
|
|
||||||
res.status(201).json(server);
|
res.status(201).json(server);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update server
|
// Update server
|
||||||
app.put('/api/servers/:id', (req, res) => {
|
app.put('/api/servers/:id', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { ownerId, ...updates } = req.body;
|
const { ownerId, ...updates } = req.body;
|
||||||
|
|
||||||
const server = servers.get(id);
|
const server = await getServerById(id);
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return res.status(404).json({ error: 'Server not found' });
|
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' });
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = { ...server, ...updates, lastSeen: Date.now() };
|
const updated: ServerInfo = { ...server, ...updates, lastSeen: Date.now() };
|
||||||
servers.set(id, updated);
|
await upsertServer(updated);
|
||||||
saveServers();
|
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Heartbeat - keep server alive
|
// 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 { id } = req.params;
|
||||||
const { currentUsers } = req.body;
|
const { currentUsers } = req.body;
|
||||||
|
|
||||||
const server = servers.get(id);
|
const server = await getServerById(id);
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return res.status(404).json({ error: 'Server not found' });
|
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') {
|
if (typeof currentUsers === 'number') {
|
||||||
server.currentUsers = currentUsers;
|
server.currentUsers = currentUsers;
|
||||||
}
|
}
|
||||||
servers.set(id, server);
|
await upsertServer(server);
|
||||||
saveServers();
|
|
||||||
|
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove server
|
// Remove server
|
||||||
app.delete('/api/servers/:id', (req, res) => {
|
app.delete('/api/servers/:id', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { ownerId } = req.body;
|
const { ownerId } = req.body;
|
||||||
|
|
||||||
const server = servers.get(id);
|
const server = await getServerById(id);
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return res.status(404).json({ error: 'Server not found' });
|
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' });
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
}
|
}
|
||||||
|
|
||||||
servers.delete(id);
|
await dbDeleteServer(id);
|
||||||
saveServers();
|
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Request to join a server
|
// 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 { id: serverId } = req.params;
|
||||||
const { userId, userPublicKey, displayName } = req.body;
|
const { userId, userPublicKey, displayName } = req.body;
|
||||||
|
|
||||||
const server = servers.get(serverId);
|
const server = await getServerById(serverId);
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return res.status(404).json({ error: 'Server not found' });
|
return res.status(404).json({ error: 'Server not found' });
|
||||||
}
|
}
|
||||||
@@ -296,7 +260,7 @@ app.post('/api/servers/:id/join', (req, res) => {
|
|||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
joinRequests.set(requestId, request);
|
await createJoinRequest(request);
|
||||||
|
|
||||||
// Notify server owner via WebSocket
|
// Notify server owner via WebSocket
|
||||||
if (server.isPrivate) {
|
if (server.isPrivate) {
|
||||||
@@ -310,11 +274,11 @@ app.post('/api/servers/:id/join', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get join requests for a server
|
// 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 { id: serverId } = req.params;
|
||||||
const { ownerId } = req.query;
|
const { ownerId } = req.query;
|
||||||
|
|
||||||
const server = servers.get(serverId);
|
const server = await getServerById(serverId);
|
||||||
if (!server) {
|
if (!server) {
|
||||||
return res.status(404).json({ error: 'Server not found' });
|
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' });
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const requests = Array.from(joinRequests.values())
|
const requests = await getPendingRequestsForServer(serverId);
|
||||||
.filter(r => r.serverId === serverId && r.status === 'pending');
|
|
||||||
|
|
||||||
res.json({ requests });
|
res.json({ requests });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Approve/reject join request
|
// Approve/reject join request
|
||||||
app.put('/api/requests/:id', (req, res) => {
|
app.put('/api/requests/:id', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { ownerId, status } = req.body;
|
const { ownerId, status } = req.body;
|
||||||
|
|
||||||
const request = joinRequests.get(id);
|
const request = await getJoinRequestById(id);
|
||||||
if (!request) {
|
if (!request) {
|
||||||
return res.status(404).json({ error: 'Request not found' });
|
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) {
|
if (!server || server.ownerId !== ownerId) {
|
||||||
return res.status(403).json({ error: 'Not authorized' });
|
return res.status(403).json({ error: 'Not authorized' });
|
||||||
}
|
}
|
||||||
|
|
||||||
request.status = status;
|
await updateJoinRequestStatus(id, status);
|
||||||
joinRequests.set(id, request);
|
const updated = { ...request, status };
|
||||||
|
|
||||||
// Notify the requester
|
// Notify the requester
|
||||||
notifyUser(request.userId, {
|
notifyUser(request.userId, {
|
||||||
type: 'request_update',
|
type: 'request_update',
|
||||||
request,
|
request: updated,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(request);
|
res.json(updated);
|
||||||
});
|
});
|
||||||
|
|
||||||
// WebSocket Server for real-time signaling
|
// 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 });
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
wss.on('connection', (ws: WebSocket) => {
|
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);
|
return Array.from(connectedUsers.values()).find(u => u.oderId === oderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup old data periodically
|
// Cleanup stale join requests periodically (older than 24 h)
|
||||||
// Simple cleanup only for stale join requests (keep servers permanent)
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const now = Date.now();
|
deleteStaleJoinRequests(24 * 60 * 60 * 1000).catch(err =>
|
||||||
joinRequests.forEach((request, id) => {
|
console.error('Failed to clean up stale join requests:', err),
|
||||||
if (now - request.createdAt > 24 * 60 * 60 * 1000) {
|
);
|
||||||
joinRequests.delete(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|
||||||
initDB().then(() => {
|
initDB().then(() => {
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`🚀 MetoYou signaling server running on port ${PORT}`);
|
const proto = USE_SSL ? 'https' : 'http';
|
||||||
console.log(` REST API: http://localhost:${PORT}/api`);
|
const wsProto = USE_SSL ? 'wss' : 'ws';
|
||||||
console.log(` WebSocket: ws://localhost:${PORT}`);
|
console.log(`🚀 MetoYou signaling server running on port ${PORT} (SSL=${USE_SSL})`);
|
||||||
// Load servers on startup
|
console.log(` REST API: ${proto}://localhost:${PORT}/api`);
|
||||||
loadServers();
|
console.log(` WebSocket: ${wsProto}://localhost:${PORT}`);
|
||||||
});
|
});
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
console.error('Failed to initialize database:', err);
|
console.error('Failed to initialize database:', err);
|
||||||
|
|||||||
@@ -17,9 +17,17 @@ export interface User {
|
|||||||
screenShareState?: ScreenShareState;
|
screenShareState?: ScreenShareState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Channel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'text' | 'voice';
|
||||||
|
position: number; // ordering within its type group
|
||||||
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
|
channelId?: string; // which text channel the message belongs to (default: 'general')
|
||||||
senderId: string;
|
senderId: string;
|
||||||
senderName: string;
|
senderName: string;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -55,6 +63,8 @@ export interface Room {
|
|||||||
iconUpdatedAt?: number; // last update timestamp for conflict resolution
|
iconUpdatedAt?: number; // last update timestamp for conflict resolution
|
||||||
// Role-based management permissions
|
// Role-based management permissions
|
||||||
permissions?: RoomPermissions;
|
permissions?: RoomPermissions;
|
||||||
|
// Channels within the server
|
||||||
|
channels?: Channel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomSettings {
|
export interface RoomSettings {
|
||||||
@@ -129,7 +139,7 @@ export interface SignalingMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatEvent {
|
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;
|
messageId?: string;
|
||||||
message?: Message;
|
message?: Message;
|
||||||
reaction?: Reaction;
|
reaction?: Reaction;
|
||||||
@@ -149,6 +159,8 @@ export interface ChatEvent {
|
|||||||
settings?: RoomSettings;
|
settings?: RoomSettings;
|
||||||
voiceState?: Partial<VoiceState>;
|
voiceState?: Partial<VoiceState>;
|
||||||
isScreenSharing?: boolean;
|
isScreenSharing?: boolean;
|
||||||
|
role?: 'host' | 'admin' | 'moderator' | 'member';
|
||||||
|
channels?: Channel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerInfo {
|
export interface ServerInfo {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Injectable, inject, signal } from '@angular/core';
|
import { Injectable, inject, signal, effect } from '@angular/core';
|
||||||
import { Subject } from 'rxjs';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { WebRTCService } from './webrtc.service';
|
import { WebRTCService } from './webrtc.service';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { selectCurrentRoomName } from '../../store/rooms/rooms.selectors';
|
import { selectCurrentRoomName } from '../../store/rooms/rooms.selectors';
|
||||||
|
import { DatabaseService } from './database.service';
|
||||||
|
|
||||||
export interface AttachmentMeta {
|
export interface AttachmentMeta {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,7 +13,8 @@ export interface AttachmentMeta {
|
|||||||
mime: string;
|
mime: string;
|
||||||
isImage: boolean;
|
isImage: boolean;
|
||||||
uploaderPeerId?: string;
|
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 {
|
export interface Attachment extends AttachmentMeta {
|
||||||
@@ -29,19 +30,15 @@ export interface Attachment extends AttachmentMeta {
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AttachmentService {
|
export class AttachmentService {
|
||||||
private readonly webrtc = inject(WebRTCService);
|
private readonly webrtc = inject(WebRTCService);
|
||||||
// Injected NgRx store
|
|
||||||
private readonly ngrxStore = inject(Store);
|
private readonly ngrxStore = inject(Store);
|
||||||
private readonly STORAGE_KEY = 'metoyou_attachments';
|
private readonly db = inject(DatabaseService);
|
||||||
|
|
||||||
// messageId -> attachments
|
// messageId -> attachments
|
||||||
private attachmentsByMessage = new Map<string, Attachment[]>();
|
private attachmentsByMessage = new Map<string, Attachment[]>();
|
||||||
// expose updates if needed
|
|
||||||
updated = signal<number>(0);
|
updated = signal<number>(0);
|
||||||
|
|
||||||
// Keep original files for uploaders to fulfill requests
|
// Keep original files for uploaders to fulfill requests
|
||||||
private originals = new Map<string, File>(); // key: messageId:fileId
|
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
|
// Track cancelled transfers (uploader side) keyed by messageId:fileId:peerId
|
||||||
private cancelledTransfers = new Set<string>();
|
private cancelledTransfers = new Set<string>();
|
||||||
private makeKey(messageId: string, fileId: string, peerId: string): string { return `${messageId}:${fileId}:${peerId}`; }
|
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));
|
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() {
|
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[] {
|
getForMessage(messageId: string): Attachment[] {
|
||||||
return this.attachmentsByMessage.get(messageId) || [];
|
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
|
// Publish attachments for a sent message and stream images <=10MB
|
||||||
async publishAttachments(messageId: string, files: File[], uploaderPeerId?: string): Promise<void> {
|
async publishAttachments(messageId: string, files: File[], uploaderPeerId?: string): Promise<void> {
|
||||||
const attachments: Attachment[] = [];
|
const attachments: Attachment[] = [];
|
||||||
@@ -77,18 +257,18 @@ export class AttachmentService {
|
|||||||
|
|
||||||
// Save original for request-based transfer
|
// Save original for request-based transfer
|
||||||
this.originals.set(`${messageId}:${id}`, file);
|
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
|
// Ensure uploader sees their own files immediately (all types, not just images)
|
||||||
if (meta.isImage) {
|
try {
|
||||||
try {
|
const url = URL.createObjectURL(file);
|
||||||
const url = URL.createObjectURL(file);
|
meta.objectUrl = url;
|
||||||
meta.objectUrl = url;
|
meta.available = true;
|
||||||
meta.available = true;
|
} catch {}
|
||||||
// Auto-save only for images ≤10MB
|
|
||||||
if (meta.size <= 10 * 1024 * 1024) {
|
// Save ALL files ≤10MB to disk (Electron) for persistence across restarts
|
||||||
void this.saveImageToDisk(meta, file);
|
if (meta.size <= 10 * 1024 * 1024) {
|
||||||
}
|
void this.saveFileToDisk(meta, file);
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Announce to peers
|
// Announce to peers
|
||||||
@@ -113,7 +293,9 @@ export class AttachmentService {
|
|||||||
|
|
||||||
this.attachmentsByMessage.set(messageId, [ ...(this.attachmentsByMessage.get(messageId) || []), ...attachments ]);
|
this.attachmentsByMessage.set(messageId, [ ...(this.attachmentsByMessage.get(messageId) || []), ...attachments ]);
|
||||||
this.updated.set(this.updated() + 1);
|
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> {
|
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 list = this.attachmentsByMessage.get(messageId) || [];
|
||||||
const exists = list.find((a: Attachment) => a.id === file.id);
|
const exists = list.find((a: Attachment) => a.id === file.id);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
list.push({
|
const att: Attachment = {
|
||||||
id: file.id,
|
id: file.id,
|
||||||
messageId,
|
messageId,
|
||||||
filename: file.filename,
|
filename: file.filename,
|
||||||
@@ -156,10 +338,11 @@ export class AttachmentService {
|
|||||||
uploaderPeerId: file.uploaderPeerId,
|
uploaderPeerId: file.uploaderPeerId,
|
||||||
available: false,
|
available: false,
|
||||||
receivedBytes: 0,
|
receivedBytes: 0,
|
||||||
});
|
};
|
||||||
|
list.push(att);
|
||||||
this.attachmentsByMessage.set(messageId, list);
|
this.attachmentsByMessage.set(messageId, list);
|
||||||
this.updated.set(this.updated() + 1);
|
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 });
|
const blob = new Blob(finalParts, { type: att.mime });
|
||||||
att.available = true;
|
att.available = true;
|
||||||
att.objectUrl = URL.createObjectURL(blob);
|
att.objectUrl = URL.createObjectURL(blob);
|
||||||
// Auto-save small images to disk under app data: server/<room>/image
|
// Auto-save ALL received files to disk under app data (Electron)
|
||||||
if (att.isImage && att.size <= 10 * 1024 * 1024) {
|
if (att.size <= 10 * 1024 * 1024) {
|
||||||
void this.saveImageToDisk(att, blob);
|
void this.saveFileToDisk(att, blob);
|
||||||
}
|
}
|
||||||
// Final update
|
// Final update
|
||||||
delete (this as any)[partsKey];
|
delete (this as any)[partsKey];
|
||||||
delete (this as any)[countKey];
|
delete (this as any)[countKey];
|
||||||
this.updated.set(this.updated() + 1);
|
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 {
|
try {
|
||||||
const w: any = window as any;
|
const w: any = window as any;
|
||||||
const appData: string | undefined = await w?.electronAPI?.getAppDataPath?.();
|
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 sub = this.ngrxStore.select(selectCurrentRoomName).subscribe((n) => { name = n || ''; resolve(name); sub.unsubscribe(); });
|
||||||
});
|
});
|
||||||
const safeRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room';
|
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);
|
await w.electronAPI.ensureDir(dir);
|
||||||
const arrayBuffer = await blob.arrayBuffer();
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
const base64 = this.arrayBufferToBase64(arrayBuffer);
|
const base64 = this.arrayBufferToBase64(arrayBuffer);
|
||||||
const path = `${dir}/${att.filename}`;
|
const diskPath = `${dir}/${att.filename}`;
|
||||||
await w.electronAPI.writeFile(path, base64);
|
await w.electronAPI.writeFile(diskPath, base64);
|
||||||
|
att.savedPath = diskPath;
|
||||||
|
void this.persistAttachmentMeta(att);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
requestFile(messageId: string, att: Attachment): void {
|
requestFile(messageId: string, att: Attachment): void {
|
||||||
const target = att.uploaderPeerId;
|
this.requestFromAnyPeer(messageId, att);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel an in-progress request from the requester side
|
// Cancel an in-progress request from the requester side
|
||||||
@@ -281,47 +456,105 @@ export class AttachmentService {
|
|||||||
} catch {}
|
} 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> {
|
async handleFileRequest(payload: any): Promise<void> {
|
||||||
const { messageId, fileId, fromPeerId } = payload;
|
const { messageId, fileId, fromPeerId } = payload;
|
||||||
if (!messageId || !fileId || !fromPeerId) return;
|
if (!messageId || !fileId || !fromPeerId) {
|
||||||
const original = this.originals.get(`${messageId}:${fileId}`);
|
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) {
|
if (original) {
|
||||||
|
console.log(`[Attachments] Serving ${fileId} from in-memory original (${original.size} bytes)`);
|
||||||
await this.streamFileToPeer(fromPeerId, messageId, fileId, original);
|
await this.streamFileToPeer(fromPeerId, messageId, fileId, original);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Try Electron file path fallback
|
|
||||||
const list = this.attachmentsByMessage.get(messageId) || [];
|
const list = this.attachmentsByMessage.get(messageId) || [];
|
||||||
const att = list.find((a: Attachment) => a.id === fileId);
|
const att = list.find((a: Attachment) => a.id === fileId);
|
||||||
const w: any = window as any;
|
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) {
|
if (att?.filePath && w?.electronAPI?.fileExists && w?.electronAPI?.readFile) {
|
||||||
try {
|
try {
|
||||||
const exists = await w.electronAPI.fileExists(att.filePath);
|
const exists = await w.electronAPI.fileExists(att.filePath);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
const base64 = await w.electronAPI.readFile(att.filePath);
|
console.log(`[Attachments] Serving ${fileId} from original filePath: ${att.filePath}`);
|
||||||
const bytes = this.base64ToUint8Array(base64);
|
await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, att.filePath);
|
||||||
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);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {}
|
} 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> {
|
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)
|
// 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)
|
// Fulfill a pending request with a user-provided file (uploader side)
|
||||||
async fulfillRequestWithFile(messageId: string, fileId: string, targetPeerId: string, file: File): Promise<void> {
|
async fulfillRequestWithFile(messageId: string, fileId: string, targetPeerId: string, file: File): Promise<void> {
|
||||||
this.originals.set(`${messageId}:${fileId}`, file);
|
this.originals.set(`${messageId}:${fileId}`, file);
|
||||||
await this.streamFileToPeer(targetPeerId, 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 {
|
try {
|
||||||
const all: Attachment[] = Array.from(this.attachmentsByMessage.values()).flat();
|
await this.db.saveAttachment({
|
||||||
const minimal = all.map((a: Attachment) => ({
|
id: att.id,
|
||||||
id: a.id,
|
messageId: att.messageId,
|
||||||
messageId: a.messageId,
|
filename: att.filename,
|
||||||
filename: a.filename,
|
size: att.size,
|
||||||
size: a.size,
|
mime: att.mime,
|
||||||
mime: a.mime,
|
isImage: att.isImage,
|
||||||
isImage: a.isImage,
|
uploaderPeerId: att.uploaderPeerId,
|
||||||
uploaderPeerId: a.uploaderPeerId,
|
filePath: att.filePath,
|
||||||
filePath: a.filePath,
|
savedPath: att.savedPath,
|
||||||
available: false,
|
});
|
||||||
}));
|
|
||||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(minimal));
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadPersisted(): void {
|
private async loadFromDb(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(this.STORAGE_KEY);
|
const all: AttachmentMeta[] = await this.db.getAllAttachments();
|
||||||
if (!raw) return;
|
|
||||||
const list: AttachmentMeta[] = JSON.parse(raw);
|
|
||||||
const grouped = new Map<string, Attachment[]>();
|
const grouped = new Map<string, Attachment[]>();
|
||||||
for (const a of list) {
|
for (const a of all) {
|
||||||
const att: Attachment = { ...a, available: false };
|
const att: Attachment = { ...a, available: false };
|
||||||
const arr = grouped.get(a.messageId) || [];
|
const arr = grouped.get(a.messageId) || [];
|
||||||
arr.push(att);
|
arr.push(att);
|
||||||
@@ -396,6 +649,26 @@ export class AttachmentService {
|
|||||||
} catch {}
|
} 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 {
|
private arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||||
let binary = '';
|
let binary = '';
|
||||||
const bytes = new Uint8Array(buffer);
|
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 { 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.
|
* Facade database service.
|
||||||
* In a production Electron app, this would use sql.js with file system access.
|
*
|
||||||
|
* - **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({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class DatabaseService {
|
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);
|
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> {
|
async initialize(): Promise<void> {
|
||||||
// Initialize storage structure if needed
|
await this.backend.initialize();
|
||||||
if (!localStorage.getItem(this.key('initialized'))) {
|
|
||||||
this.initializeStorage();
|
|
||||||
}
|
|
||||||
this.isReady.set(true);
|
this.isReady.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private initializeStorage(): void {
|
/* ------------------------------------------------------------------ */
|
||||||
localStorage.setItem(this.key('messages'), JSON.stringify([]));
|
/* Messages */
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
private key(name: string): string {
|
saveMessage(message: Message) { return this.backend.saveMessage(message); }
|
||||||
return this.STORAGE_PREFIX + name;
|
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));
|
/* Reactions */
|
||||||
return data ? JSON.parse(data) : [];
|
/* ------------------------------------------------------------------ */
|
||||||
}
|
|
||||||
|
|
||||||
private setArray<T>(key: string, data: T[]): void {
|
saveReaction(reaction: Reaction) { return this.backend.saveReaction(reaction); }
|
||||||
localStorage.setItem(this.key(key), JSON.stringify(data));
|
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> {
|
/* Users */
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> {
|
saveUser(user: User) { return this.backend.saveUser(user); }
|
||||||
const messages = this.getArray<Message>('messages');
|
getUser(userId: string) { return this.backend.getUser(userId); }
|
||||||
return messages
|
getCurrentUser() { return this.backend.getCurrentUser(); }
|
||||||
.filter((m) => m.roomId === roomId)
|
setCurrentUserId(userId: string) { return this.backend.setCurrentUserId(userId); }
|
||||||
.sort((a, b) => a.timestamp - b.timestamp)
|
getUsersByRoom(roomId: string) { return this.backend.getUsersByRoom(roomId); }
|
||||||
.slice(offset, offset + limit);
|
updateUser(userId: string, updates: Partial<User>) { return this.backend.updateUser(userId, updates); }
|
||||||
}
|
|
||||||
|
|
||||||
async deleteMessage(messageId: string): Promise<void> {
|
/* ------------------------------------------------------------------ */
|
||||||
const messages = this.getArray<Message>('messages');
|
/* Rooms */
|
||||||
const filtered = messages.filter((m) => m.id !== messageId);
|
/* ------------------------------------------------------------------ */
|
||||||
this.setArray('messages', filtered);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
|
saveRoom(room: Room) { return this.backend.saveRoom(room); }
|
||||||
const messages = this.getArray<Message>('messages');
|
getRoom(roomId: string) { return this.backend.getRoom(roomId); }
|
||||||
const index = messages.findIndex((m) => m.id === messageId);
|
getAllRooms() { return this.backend.getAllRooms(); }
|
||||||
if (index >= 0) {
|
deleteRoom(roomId: string) { return this.backend.deleteRoom(roomId); }
|
||||||
messages[index] = { ...messages[index], ...updates };
|
updateRoom(roomId: string, updates: Partial<Room>) { return this.backend.updateRoom(roomId, updates); }
|
||||||
this.setArray('messages', messages);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getMessageById(messageId: string): Promise<Message | null> {
|
/* ------------------------------------------------------------------ */
|
||||||
const messages = this.getArray<Message>('messages');
|
/* Bans */
|
||||||
return messages.find((m) => m.id === messageId) || null;
|
/* ------------------------------------------------------------------ */
|
||||||
}
|
|
||||||
|
|
||||||
async clearRoomMessages(roomId: string): Promise<void> {
|
saveBan(ban: BanEntry) { return this.backend.saveBan(ban); }
|
||||||
const messages = this.getArray<Message>('messages');
|
removeBan(oderId: string) { return this.backend.removeBan(oderId); }
|
||||||
const filtered = messages.filter((m) => m.roomId !== roomId);
|
getBansForRoom(roomId: string) { return this.backend.getBansForRoom(roomId); }
|
||||||
this.setArray('messages', filtered);
|
isUserBanned(userId: string, roomId: string) { return this.backend.isUserBanned(userId, roomId); }
|
||||||
}
|
|
||||||
|
|
||||||
// Reactions
|
/* ------------------------------------------------------------------ */
|
||||||
async saveReaction(reaction: Reaction): Promise<void> {
|
/* Attachments */
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeReaction(messageId: string, userId: string, emoji: string): Promise<void> {
|
saveAttachment(attachment: any) { return this.backend.saveAttachment(attachment); }
|
||||||
const reactions = this.getArray<Reaction>('reactions');
|
getAttachmentsForMessage(messageId: string) { return this.backend.getAttachmentsForMessage(messageId); }
|
||||||
const filtered = reactions.filter(
|
getAllAttachments() { return this.backend.getAllAttachments(); }
|
||||||
(r) => !(r.messageId === messageId && r.userId === userId && r.emoji === emoji)
|
deleteAttachmentsForMessage(messageId: string) { return this.backend.deleteAttachmentsForMessage(messageId); }
|
||||||
);
|
|
||||||
this.setArray('reactions', filtered);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getReactionsForMessage(messageId: string): Promise<Reaction[]> {
|
/* ------------------------------------------------------------------ */
|
||||||
const reactions = this.getArray<Reaction>('reactions');
|
/* Utilities */
|
||||||
return reactions.filter((r) => r.messageId === messageId);
|
/* ------------------------------------------------------------------ */
|
||||||
}
|
|
||||||
|
|
||||||
// Users
|
clearAllData() { return this.backend.clearAllData(); }
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
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 './database.service';
|
||||||
export * from './webrtc.service';
|
export * from './webrtc.service';
|
||||||
export * from './server-directory.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 { Observable, of, throwError, forkJoin } from 'rxjs';
|
||||||
import { catchError, map } from 'rxjs/operators';
|
import { catchError, map } from 'rxjs/operators';
|
||||||
import { ServerInfo, JoinRequest, User } from '../models';
|
import { ServerInfo, JoinRequest, User } from '../models';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
export interface ServerEndpoint {
|
export interface ServerEndpoint {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,9 +16,19 @@ export interface ServerEndpoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'metoyou_server_endpoints';
|
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'> = {
|
const DEFAULT_SERVER: Omit<ServerEndpoint, 'id'> = {
|
||||||
name: 'Local Server',
|
name: 'Local Server',
|
||||||
url: 'http://localhost:3001',
|
url: getDefaultServerUrl(),
|
||||||
isActive: true,
|
isActive: true,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
status: 'unknown',
|
status: 'unknown',
|
||||||
@@ -41,12 +52,21 @@ export class ServerDirectoryService {
|
|||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
try {
|
try {
|
||||||
const servers = JSON.parse(stored) as ServerEndpoint[];
|
let servers = JSON.parse(stored) as ServerEndpoint[];
|
||||||
// Ensure at least one is active
|
// Ensure at least one is active
|
||||||
if (!servers.some((s) => s.isActive) && servers.length > 0) {
|
if (!servers.some((s) => s.isActive) && servers.length > 0) {
|
||||||
servers[0].isActive = true;
|
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._servers.set(servers);
|
||||||
|
this.saveServers();
|
||||||
} catch {
|
} catch {
|
||||||
this.initializeDefaultServer();
|
this.initializeDefaultServer();
|
||||||
}
|
}
|
||||||
@@ -58,7 +78,7 @@ export class ServerDirectoryService {
|
|||||||
private initializeDefaultServer(): void {
|
private initializeDefaultServer(): void {
|
||||||
const defaultServer: ServerEndpoint = {
|
const defaultServer: ServerEndpoint = {
|
||||||
...DEFAULT_SERVER,
|
...DEFAULT_SERVER,
|
||||||
id: crypto.randomUUID(),
|
id: uuidv4(),
|
||||||
};
|
};
|
||||||
this._servers.set([defaultServer]);
|
this._servers.set([defaultServer]);
|
||||||
this.saveServers();
|
this.saveServers();
|
||||||
@@ -70,7 +90,7 @@ export class ServerDirectoryService {
|
|||||||
|
|
||||||
private get baseUrl(): string {
|
private get baseUrl(): string {
|
||||||
const active = this.activeServer();
|
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'
|
// Strip trailing slashes and any accidental '/api'
|
||||||
let base = raw.replace(/\/+$/,'');
|
let base = raw.replace(/\/+$/,'');
|
||||||
if (base.toLowerCase().endsWith('/api')) {
|
if (base.toLowerCase().endsWith('/api')) {
|
||||||
@@ -87,7 +107,7 @@ export class ServerDirectoryService {
|
|||||||
// Server management methods
|
// Server management methods
|
||||||
addServer(server: { name: string; url: string }): void {
|
addServer(server: { name: string; url: string }): void {
|
||||||
const newServer: ServerEndpoint = {
|
const newServer: ServerEndpoint = {
|
||||||
id: crypto.randomUUID(),
|
id: uuidv4(),
|
||||||
name: server.name,
|
name: server.name,
|
||||||
// Sanitize: remove trailing slashes and any '/api'
|
// Sanitize: remove trailing slashes and any '/api'
|
||||||
url: (() => {
|
url: (() => {
|
||||||
@@ -396,7 +416,10 @@ export class ServerDirectoryService {
|
|||||||
// Get the WebSocket URL for the active server
|
// Get the WebSocket URL for the active server
|
||||||
getWebSocketUrl(): string {
|
getWebSocketUrl(): string {
|
||||||
const active = this.activeServer();
|
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)
|
// Convert http(s) to ws(s)
|
||||||
return active.url.replace(/^http/, 'ws');
|
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" />
|
<ng-icon name="lucideSettings" class="w-4 h-4 inline mr-1" />
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</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
|
<button
|
||||||
(click)="activeTab.set('bans')"
|
(click)="activeTab.set('bans')"
|
||||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||||
@@ -38,8 +49,8 @@
|
|||||||
[class.border-primary]="activeTab() === 'permissions'"
|
[class.border-primary]="activeTab() === 'permissions'"
|
||||||
[class.text-muted-foreground]="activeTab() !== 'permissions'"
|
[class.text-muted-foreground]="activeTab() !== 'permissions'"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideUsers" class="w-4 h-4 inline mr-1" />
|
<ng-icon name="lucideShield" class="w-4 h-4 inline mr-1" />
|
||||||
Permissions
|
Perms
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -125,6 +136,65 @@
|
|||||||
</div>
|
</div>
|
||||||
</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') {
|
@case ('bans') {
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h3 class="text-sm font-medium text-foreground">Banned Users</h3>
|
<h3 class="text-sm font-medium text-foreground">Banned Users</h3>
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ import {
|
|||||||
selectBannedUsers,
|
selectBannedUsers,
|
||||||
selectIsCurrentUserAdmin,
|
selectIsCurrentUserAdmin,
|
||||||
selectCurrentUser,
|
selectCurrentUser,
|
||||||
|
selectOnlineUsers,
|
||||||
} from '../../../store/users/users.selectors';
|
} 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({
|
@Component({
|
||||||
selector: 'app-admin-panel',
|
selector: 'app-admin-panel',
|
||||||
@@ -50,11 +52,13 @@ type AdminTab = 'settings' | 'bans' | 'permissions';
|
|||||||
})
|
})
|
||||||
export class AdminPanelComponent {
|
export class AdminPanelComponent {
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
|
private webrtc = inject(WebRTCService);
|
||||||
|
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||||
bannedUsers = this.store.selectSignal(selectBannedUsers);
|
bannedUsers = this.store.selectSignal(selectBannedUsers);
|
||||||
|
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||||
|
|
||||||
activeTab = signal<AdminTab>('settings');
|
activeTab = signal<AdminTab>('settings');
|
||||||
showDeleteConfirm = signal(false);
|
showDeleteConfirm = signal(false);
|
||||||
@@ -157,4 +161,37 @@ export class AdminPanelComponent {
|
|||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
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 { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
@@ -13,11 +13,16 @@ import {
|
|||||||
lucideMoreVertical,
|
lucideMoreVertical,
|
||||||
lucideCheck,
|
lucideCheck,
|
||||||
lucideX,
|
lucideX,
|
||||||
|
lucideDownload,
|
||||||
|
lucideExpand,
|
||||||
|
lucideImage,
|
||||||
|
lucideCopy,
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import * as MessagesActions from '../../store/messages/messages.actions';
|
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 { selectCurrentUser, selectIsCurrentUserAdmin } from '../../store/users/users.selectors';
|
||||||
|
import { selectCurrentRoom, selectActiveChannelId } from '../../store/rooms/rooms.selectors';
|
||||||
import { Message } from '../../core/models';
|
import { Message } from '../../core/models';
|
||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
@@ -42,12 +47,23 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
|
|||||||
lucideMoreVertical,
|
lucideMoreVertical,
|
||||||
lucideCheck,
|
lucideCheck,
|
||||||
lucideX,
|
lucideX,
|
||||||
|
lucideDownload,
|
||||||
|
lucideExpand,
|
||||||
|
lucideImage,
|
||||||
|
lucideCopy,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<!-- Messages List -->
|
<!-- Messages List -->
|
||||||
<div #messagesContainer class="flex-1 overflow-y-auto p-4 space-y-4 relative" (scroll)="onScroll()">
|
<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()) {
|
@if (loading()) {
|
||||||
<div class="flex items-center justify-center py-8">
|
<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>
|
<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>
|
<p class="text-sm">Be the first to say something!</p>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @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) {
|
@for (message of messages(); track message.id) {
|
||||||
<div
|
<div
|
||||||
|
[attr.data-message-id]="message.id"
|
||||||
class="group relative flex gap-3 p-2 rounded-lg hover:bg-secondary/30 transition-colors"
|
class="group relative flex gap-3 p-2 rounded-lg hover:bg-secondary/30 transition-colors"
|
||||||
[class.opacity-50]="message.isDeleted"
|
[class.opacity-50]="message.isDeleted"
|
||||||
>
|
>
|
||||||
@@ -70,6 +99,20 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
|
|||||||
|
|
||||||
<!-- Message Content -->
|
<!-- Message Content -->
|
||||||
<div class="flex-1 min-w-0">
|
<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">
|
<div class="flex items-baseline gap-2">
|
||||||
<span class="font-semibold text-foreground">{{ message.senderName }}</span>
|
<span class="font-semibold text-foreground">{{ message.senderName }}</span>
|
||||||
<span class="text-xs text-muted-foreground">
|
<span class="text-xs text-muted-foreground">
|
||||||
@@ -110,20 +153,69 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
|
|||||||
@for (att of getAttachments(message.id); track att.id) {
|
@for (att of getAttachments(message.id); track att.id) {
|
||||||
@if (att.isImage) {
|
@if (att.isImage) {
|
||||||
@if (att.available && att.objectUrl) {
|
@if (att.available && att.objectUrl) {
|
||||||
<img [src]="att.objectUrl" alt="image" class="rounded-md max-h-80 w-auto" />
|
<!-- Available image with hover overlay -->
|
||||||
} @else {
|
<div class="relative group/img inline-block" (contextmenu)="openImageContextMenu($event, att)">
|
||||||
<div class="border border-border rounded-md p-2 bg-secondary/40">
|
<img
|
||||||
<div class="flex items-center justify-between">
|
[src]="att.objectUrl"
|
||||||
<div class="min-w-0">
|
[alt]="att.filename"
|
||||||
<div class="text-sm font-medium truncate">{{ att.filename }}</div>
|
class="rounded-md max-h-80 w-auto cursor-pointer"
|
||||||
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
|
(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>
|
||||||
<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>
|
||||||
<div class="mt-2 h-1.5 rounded bg-muted">
|
<div class="mt-2 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||||
<div class="h-1.5 rounded bg-primary" [style.width.%]="(att.receivedBytes || 0) * 100 / att.size"></div>
|
<div class="h-full rounded-full bg-primary transition-all duration-300" [style.width.%]="(att.receivedBytes || 0) * 100 / att.size"></div>
|
||||||
</div>
|
</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 {
|
} @else {
|
||||||
<div class="border border-border rounded-md p-2 bg-secondary/40">
|
<div class="border border-border rounded-md p-2 bg-secondary/40">
|
||||||
@@ -357,6 +449,76 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 {
|
export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestroy {
|
||||||
@@ -368,11 +530,40 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
private sanitizer = inject(DomSanitizer);
|
private sanitizer = inject(DomSanitizer);
|
||||||
private serverDirectory = inject(ServerDirectoryService);
|
private serverDirectory = inject(ServerDirectoryService);
|
||||||
private attachmentsSvc = inject(AttachmentService);
|
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);
|
loading = this.store.selectSignal(selectMessagesLoading);
|
||||||
|
syncing = this.store.selectSignal(selectMessagesSyncing);
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||||
|
private currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
messageContent = '';
|
messageContent = '';
|
||||||
editContent = '';
|
editContent = '';
|
||||||
@@ -383,6 +574,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
readonly commonEmojis = COMMON_EMOJIS;
|
readonly commonEmojis = COMMON_EMOJIS;
|
||||||
|
|
||||||
private shouldScrollToBottom = true;
|
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 typingSub?: Subscription;
|
||||||
private lastTypingSentAt = 0;
|
private lastTypingSentAt = 0;
|
||||||
private readonly typingTTL = 3000; // ms to keep a user as typing
|
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);
|
typingOthersCount = signal<number>(0);
|
||||||
// New messages snackbar state
|
// New messages snackbar state
|
||||||
showNewMessagesBar = signal(false);
|
showNewMessagesBar = signal(false);
|
||||||
// Stable reference time to avoid ExpressionChanged errors (updated every minute)
|
// Plain (non-reactive) reference time used only by formatTimestamp.
|
||||||
nowRef = signal<number>(Date.now());
|
// Updated periodically but NOT a signal, so it won't re-render every message.
|
||||||
|
private nowRef = Date.now();
|
||||||
private nowTimer: any;
|
private nowTimer: any;
|
||||||
toolbarVisible = signal(false);
|
toolbarVisible = signal(false);
|
||||||
private toolbarHovering = false;
|
private toolbarHovering = false;
|
||||||
@@ -405,16 +603,46 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
dragActive = signal(false);
|
dragActive = signal(false);
|
||||||
// Cache blob URLs for proxied images to prevent repeated network fetches on re-render
|
// Cache blob URLs for proxied images to prevent repeated network fetches on re-render
|
||||||
private imageBlobCache = new Map<string, string>();
|
private imageBlobCache = new Map<string, string>();
|
||||||
// Re-render when attachments update
|
// Cache rendered markdown to preserve text selection across re-renders
|
||||||
private attachmentsUpdatedEffect = effect(() => {
|
private markdownCache = new Map<string, SafeHtml>();
|
||||||
// Subscribe to updates; no-op body
|
|
||||||
void this.attachmentsSvc.updated();
|
// 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);
|
messagesLength = computed(() => this.messages().length);
|
||||||
private onMessagesChanged = effect(() => {
|
private onMessagesChanged = effect(() => {
|
||||||
const currentCount = this.messagesLength();
|
const currentCount = this.totalChannelMessagesLength();
|
||||||
const el = this.messagesContainer?.nativeElement;
|
const el = this.messagesContainer?.nativeElement;
|
||||||
if (!el) {
|
if (!el) {
|
||||||
this.lastMessageCount = currentCount;
|
this.lastMessageCount = currentCount;
|
||||||
@@ -446,12 +674,23 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
const el = this.messagesContainer?.nativeElement;
|
const el = this.messagesContainer?.nativeElement;
|
||||||
if (!el) return;
|
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) {
|
if (this.initialScrollPending) {
|
||||||
this.initialScrollPending = false;
|
if (this.messages().length > 0) {
|
||||||
this.scrollToBottom();
|
this.initialScrollPending = false;
|
||||||
this.showNewMessagesBar.set(false);
|
// Snap to bottom immediately, then keep watching for late layout changes
|
||||||
this.lastMessageCount = this.messages().length;
|
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();
|
this.loadCspImages();
|
||||||
return;
|
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
|
// Periodically purge expired typing entries
|
||||||
const purge = () => {
|
const purge = () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -502,18 +726,32 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
// Initialize message count for snackbar trigger
|
// Initialize message count for snackbar trigger
|
||||||
this.lastMessageCount = this.messages().length;
|
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.nowTimer = setInterval(() => {
|
||||||
this.nowRef.set(Date.now());
|
this.nowRef = Date.now();
|
||||||
}, 60000);
|
}, 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 {
|
ngOnDestroy(): void {
|
||||||
this.typingSub?.unsubscribe();
|
this.typingSub?.unsubscribe();
|
||||||
|
this.stopInitialScrollWatch();
|
||||||
if (this.nowTimer) {
|
if (this.nowTimer) {
|
||||||
clearInterval(this.nowTimer);
|
clearInterval(this.nowTimer);
|
||||||
this.nowTimer = null;
|
this.nowTimer = null;
|
||||||
}
|
}
|
||||||
|
if (this.boundOnKeydown) {
|
||||||
|
document.removeEventListener('keydown', this.boundOnKeydown);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMessage(): void {
|
sendMessage(): void {
|
||||||
@@ -526,6 +764,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
MessagesActions.sendMessage({
|
MessagesActions.sendMessage({
|
||||||
content,
|
content,
|
||||||
replyToId: this.replyTo()?.id,
|
replyToId: this.replyTo()?.id,
|
||||||
|
channelId: this.activeChannelId(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -589,6 +828,21 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
this.replyTo.set(null);
|
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 {
|
toggleEmojiPicker(messageId: string): void {
|
||||||
this.showEmojiPicker.update((current) =>
|
this.showEmojiPicker.update((current) =>
|
||||||
current === messageId ? null : messageId
|
current === messageId ? null : messageId
|
||||||
@@ -641,20 +895,21 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
|
|
||||||
formatTimestamp(timestamp: number): string {
|
formatTimestamp(timestamp: number): string {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
const now = new Date(this.nowRef());
|
const now = new Date(this.nowRef);
|
||||||
const diff = now.getTime() - date.getTime();
|
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (days === 0) {
|
// Compare calendar days (midnight-aligned) to avoid NG0100 flicker
|
||||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
const toDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||||
} else if (days === 1) {
|
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
|
||||||
return 'Yesterday ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
||||||
} else if (days < 7) {
|
if (dayDiff === 0) {
|
||||||
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' +
|
return time;
|
||||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
} else if (dayDiff === 1) {
|
||||||
|
return 'Yesterday ' + time;
|
||||||
|
} else if (dayDiff < 7) {
|
||||||
|
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
|
||||||
} else {
|
} else {
|
||||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
|
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time;
|
||||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 {
|
private scrollToBottomSmooth(): void {
|
||||||
if (this.messagesContainer) {
|
if (this.messagesContainer) {
|
||||||
const el = this.messagesContainer.nativeElement;
|
const el = this.messagesContainer.nativeElement;
|
||||||
@@ -688,12 +1000,46 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
|
|
||||||
onScroll(): void {
|
onScroll(): void {
|
||||||
if (!this.messagesContainer) return;
|
if (!this.messagesContainer) return;
|
||||||
|
// Ignore scroll events caused by programmatic snap-to-bottom
|
||||||
|
if (this.isAutoScrolling) return;
|
||||||
|
|
||||||
const el = this.messagesContainer.nativeElement;
|
const el = this.messagesContainer.nativeElement;
|
||||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
this.shouldScrollToBottom = distanceFromBottom <= 300;
|
this.shouldScrollToBottom = distanceFromBottom <= 300;
|
||||||
if (this.shouldScrollToBottom) {
|
if (this.shouldScrollToBottom) {
|
||||||
this.showNewMessagesBar.set(false);
|
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 {
|
private recomputeTypingDisplay(now: number): void {
|
||||||
@@ -707,8 +1053,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
this.typingOthersCount.set(others);
|
this.typingOthersCount.set(others);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Markdown rendering
|
// Markdown rendering (cached so re-renders don't replace innerHTML and kill text selection)
|
||||||
renderMarkdown(content: string): SafeHtml {
|
renderMarkdown(content: string): SafeHtml {
|
||||||
|
const cached = this.markdownCache.get(content);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
marked.setOptions({ breaks: true });
|
marked.setOptions({ breaks: true });
|
||||||
const html = marked.parse(content ?? '') as string;
|
const html = marked.parse(content ?? '') as string;
|
||||||
// Sanitize to a DOM fragment so we can post-process disallowed images
|
// 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);
|
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
|
// 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;
|
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 {
|
private attachFilesToLastOwnMessage(content: string): void {
|
||||||
const me = this.currentUser()?.id;
|
const me = this.currentUser()?.id;
|
||||||
if (!me) return;
|
if (!me) return;
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
<div class="h-full flex flex-col bg-background">
|
<div class="h-full flex flex-col bg-background">
|
||||||
@if (currentRoom()) {
|
@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 -->
|
<!-- Main Content -->
|
||||||
<div class="flex-1 flex overflow-hidden">
|
<div class="flex-1 flex overflow-hidden">
|
||||||
<!-- Left rail is global; chat area fills remaining space -->
|
|
||||||
|
|
||||||
<!-- Chat Area -->
|
<!-- Chat Area -->
|
||||||
<main class="flex-1 flex flex-col min-w-0">
|
<main class="flex-1 flex flex-col min-w-0">
|
||||||
<!-- Screen Share Viewer -->
|
<!-- Screen Share Viewer -->
|
||||||
@@ -15,15 +29,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</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 -->
|
<!-- Sidebar always visible -->
|
||||||
<aside class="w-80 flex-shrink-0 border-l border-border">
|
<aside class="w-80 flex-shrink-0 border-l border-border">
|
||||||
<app-rooms-side-panel class="h-full" />
|
<app-rooms-side-panel class="h-full" />
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Voice Controls moved to sidebar bottom -->
|
|
||||||
|
|
||||||
<!-- Mobile overlay removed; sidebar remains visible by default -->
|
|
||||||
} @else {
|
} @else {
|
||||||
<!-- No Room Selected -->
|
<!-- No Room Selected -->
|
||||||
<div class="flex-1 flex items-center justify-center">
|
<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 { AdminPanelComponent } from '../../admin/admin-panel/admin-panel.component';
|
||||||
import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-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';
|
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||||
|
|
||||||
type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
|
type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
|
||||||
@@ -32,6 +32,7 @@ type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
|
|||||||
ChatMessagesComponent,
|
ChatMessagesComponent,
|
||||||
ScreenShareViewerComponent,
|
ScreenShareViewerComponent,
|
||||||
RoomsSidePanelComponent,
|
RoomsSidePanelComponent,
|
||||||
|
AdminPanelComponent,
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
@@ -49,11 +50,20 @@ export class ChatRoomComponent {
|
|||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
showMenu = signal(false);
|
showMenu = signal(false);
|
||||||
|
showAdminPanel = signal(false);
|
||||||
|
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
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">
|
<div class="flex-1 overflow-auto">
|
||||||
<!-- Text Channels -->
|
<!-- Text Channels -->
|
||||||
<div class="p-3">
|
<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="flex items-center justify-between mb-2 px-1">
|
||||||
<div class="space-y-1">
|
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Text Channels</h4>
|
||||||
<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">
|
@if (canManageChannels()) {
|
||||||
<span class="text-muted-foreground">#</span> general
|
<button (click)="createChannel('text')" class="text-muted-foreground hover:text-foreground transition-colors" title="Create Text Channel">
|
||||||
</button>
|
<ng-icon name="lucidePlus" class="w-3.5 h-3.5" />
|
||||||
<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">
|
</button>
|
||||||
<span class="text-muted-foreground">#</span> random
|
}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Voice Channels -->
|
<!-- Voice Channels -->
|
||||||
<div class="p-3 pt-0">
|
<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()) {
|
@if (!voiceEnabled()) {
|
||||||
<p class="text-sm text-muted-foreground px-2 py-2">Voice is disabled by host</p>
|
<p class="text-sm text-muted-foreground px-2 py-2">Voice is disabled by host</p>
|
||||||
}
|
}
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<!-- General Voice -->
|
@for (ch of voiceChannels(); track ch.id) {
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center justify-between text-left"
|
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('general')"
|
(click)="joinVoice(ch.id)"
|
||||||
[class.bg-secondary/40]="isCurrentRoom('general')"
|
(contextmenu)="openChannelContextMenu($event, ch)"
|
||||||
[disabled]="!voiceEnabled()"
|
[class.bg-secondary/40]="isCurrentRoom(ch.id)"
|
||||||
>
|
[disabled]="!voiceEnabled()"
|
||||||
<span class="flex items-center gap-2 text-foreground/80">
|
>
|
||||||
<span>🔊</span> General
|
<span class="flex items-center gap-2 text-foreground/80">
|
||||||
</span>
|
<ng-icon name="lucideMic" class="w-4 h-4 text-muted-foreground" />
|
||||||
@if (voiceOccupancy('general') > 0) {
|
@if (renamingChannelId() === ch.id) {
|
||||||
<span class="text-sm text-muted-foreground">{{ voiceOccupancy('general') }}</span>
|
<input
|
||||||
}
|
#renameInput
|
||||||
</button>
|
type="text"
|
||||||
@if (voiceUsersInRoom('general').length > 0) {
|
[value]="ch.name"
|
||||||
<div class="ml-5 mt-1 space-y-1">
|
(keydown.enter)="confirmRename($event)"
|
||||||
@for (u of voiceUsersInRoom('general'); track u.id) {
|
(keydown.escape)="cancelRename()"
|
||||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
|
(blur)="confirmRename($event)"
|
||||||
@if (u.avatarUrl) {
|
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"
|
||||||
<img
|
(click)="$event.stopPropagation()"
|
||||||
[src]="u.avatarUrl"
|
/>
|
||||||
alt=""
|
} @else {
|
||||||
class="w-7 h-7 rounded-full ring-2 object-cover"
|
<span>{{ ch.name }}</span>
|
||||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
}
|
||||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
</span>
|
||||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
@if (voiceOccupancy(ch.id) > 0) {
|
||||||
/>
|
<span class="text-xs text-muted-foreground">{{ voiceOccupancy(ch.id) }}</span>
|
||||||
} @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>
|
||||||
}
|
<!-- Voice users connected to this channel -->
|
||||||
</div>
|
@if (voiceUsersInRoom(ch.id).length > 0) {
|
||||||
|
<div class="ml-5 mt-1 space-y-1">
|
||||||
<!-- AFK Voice -->
|
@for (u of voiceUsersInRoom(ch.id); track u.id) {
|
||||||
<div>
|
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
|
||||||
<button
|
@if (u.avatarUrl) {
|
||||||
class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center justify-between text-left"
|
<img
|
||||||
(click)="joinVoice('afk')"
|
[src]="u.avatarUrl"
|
||||||
[class.bg-secondary/40]="isCurrentRoom('afk')"
|
alt=""
|
||||||
[disabled]="!voiceEnabled()"
|
class="w-7 h-7 rounded-full ring-2 object-cover"
|
||||||
>
|
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||||
<span class="flex items-center gap-2 text-foreground/80">
|
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||||
<span>🔕</span> AFK
|
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||||
</span>
|
/>
|
||||||
@if (voiceOccupancy('afk') > 0) {
|
} @else {
|
||||||
<span class="text-sm text-muted-foreground">{{ voiceOccupancy('afk') }}</span>
|
<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>
|
</div>
|
||||||
@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>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,7 +215,10 @@
|
|||||||
</h4>
|
</h4>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
@for (user of onlineUsersFiltered(); track user.id) {
|
@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">
|
<div class="relative">
|
||||||
@if (user.avatarUrl) {
|
@if (user.avatarUrl) {
|
||||||
<img [src]="user.avatarUrl" alt="" class="w-8 h-8 rounded-full object-cover" />
|
<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>
|
<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>
|
||||||
<div class="flex-1 min-w-0">
|
<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">
|
<div class="flex items-center gap-2">
|
||||||
@if (user.voiceState?.isConnected) {
|
@if (user.voiceState?.isConnected) {
|
||||||
<p class="text-[10px] text-muted-foreground flex items-center gap-1">
|
<p class="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||||
@@ -270,3 +280,80 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</aside>
|
</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 { Component, inject, signal } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers } from '@ng-icons/lucide';
|
import { lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers, lucidePlus } from '@ng-icons/lucide';
|
||||||
import { selectOnlineUsers, selectCurrentUser } from '../../../store/users/users.selectors';
|
import { selectOnlineUsers, selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
import { selectCurrentRoom, selectActiveChannelId, selectTextChannels, selectVoiceChannels } from '../../../store/rooms/rooms.selectors';
|
||||||
import * as UsersActions from '../../../store/users/users.actions';
|
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 { WebRTCService } from '../../../core/services/webrtc.service';
|
||||||
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
||||||
import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component';
|
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';
|
type TabView = 'channels' | 'users';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-rooms-side-panel',
|
selector: 'app-rooms-side-panel',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, NgIcon, VoiceControlsComponent],
|
imports: [CommonModule, FormsModule, NgIcon, VoiceControlsComponent],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers })
|
provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers, lucidePlus })
|
||||||
],
|
],
|
||||||
templateUrl: './rooms-side-panel.component.html',
|
templateUrl: './rooms-side-panel.component.html',
|
||||||
})
|
})
|
||||||
@@ -31,6 +36,30 @@ export class RoomsSidePanelComponent {
|
|||||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
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
|
// Filter out current user from online users list
|
||||||
onlineUsersFiltered() {
|
onlineUsersFiltered() {
|
||||||
@@ -40,6 +69,162 @@ export class RoomsSidePanelComponent {
|
|||||||
return this.onlineUsers().filter(u => u.id !== currentId && u.oderId !== currentOderId);
|
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) {
|
joinVoice(roomId: string) {
|
||||||
// Gate by room permissions
|
// Gate by room permissions
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
@@ -51,10 +236,21 @@ export class RoomsSidePanelComponent {
|
|||||||
const current = this.currentUser();
|
const current = this.currentUser();
|
||||||
|
|
||||||
// Check if already connected to voice in a DIFFERENT server - must disconnect first
|
// 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) {
|
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
|
||||||
// Connected to voice in a different server - user must disconnect first
|
if (!this.webrtc.isVoiceConnected()) {
|
||||||
console.warn('Already connected to voice in another server. Disconnect first before joining.');
|
// Stale state – clear it so the user can proceed
|
||||||
return;
|
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
|
// 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
|
// Start voice heartbeat to broadcast presence every 5 seconds
|
||||||
this.webrtc.startVoiceHeartbeat(roomId);
|
this.webrtc.startVoiceHeartbeat(roomId, room?.id);
|
||||||
this.webrtc.broadcastMessage({
|
this.webrtc.broadcastMessage({
|
||||||
type: 'voice-state',
|
type: 'voice-state',
|
||||||
oderId: current?.oderId || current?.id,
|
oderId: current?.oderId || current?.id,
|
||||||
@@ -83,7 +279,9 @@ export class RoomsSidePanelComponent {
|
|||||||
|
|
||||||
// Update voice session for floating controls
|
// Update voice session for floating controls
|
||||||
if (room) {
|
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({
|
this.voiceSessionService.startSession({
|
||||||
serverId: room.id,
|
serverId: room.id,
|
||||||
serverName: room.name,
|
serverName: room.name,
|
||||||
@@ -131,7 +329,6 @@ export class RoomsSidePanelComponent {
|
|||||||
voiceOccupancy(roomId: string): number {
|
voiceOccupancy(roomId: string): number {
|
||||||
const users = this.onlineUsers();
|
const users = this.onlineUsers();
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
// Only count users connected to voice in this specific server and room
|
|
||||||
return users.filter(u =>
|
return users.filter(u =>
|
||||||
!!u.voiceState?.isConnected &&
|
!!u.voiceState?.isConnected &&
|
||||||
u.voiceState?.roomId === roomId &&
|
u.voiceState?.roomId === roomId &&
|
||||||
@@ -140,14 +337,11 @@ export class RoomsSidePanelComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
viewShare(userId: string) {
|
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 } });
|
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
||||||
window.dispatchEvent(evt);
|
window.dispatchEvent(evt);
|
||||||
}
|
}
|
||||||
|
|
||||||
viewStream(userId: string) {
|
viewStream(userId: string) {
|
||||||
// Focus viewer on a user's stream - dispatches event to screen-share-viewer
|
|
||||||
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
||||||
window.dispatchEvent(evt);
|
window.dispatchEvent(evt);
|
||||||
}
|
}
|
||||||
@@ -155,25 +349,18 @@ export class RoomsSidePanelComponent {
|
|||||||
isUserSharing(userId: string): boolean {
|
isUserSharing(userId: string): boolean {
|
||||||
const me = this.currentUser();
|
const me = this.currentUser();
|
||||||
if (me?.id === userId) {
|
if (me?.id === userId) {
|
||||||
// Local user: use signal
|
|
||||||
return this.webrtc.isScreenSharing();
|
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);
|
const user = this.onlineUsers().find(u => u.id === userId || u.oderId === userId);
|
||||||
if (user?.screenShareState?.isSharing === false) {
|
if (user?.screenShareState?.isSharing === false) {
|
||||||
// Store says not sharing - trust this over stream presence
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to checking stream if store state is undefined
|
|
||||||
const stream = this.webrtc.getRemoteStream(userId);
|
const stream = this.webrtc.getRemoteStream(userId);
|
||||||
return !!stream && stream.getVideoTracks().length > 0;
|
return !!stream && stream.getVideoTracks().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
voiceUsersInRoom(roomId: string) {
|
voiceUsersInRoom(roomId: string) {
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
// Only show users connected to voice in this specific server and room
|
|
||||||
return this.onlineUsers().filter(u =>
|
return this.onlineUsers().filter(u =>
|
||||||
!!u.voiceState?.isConnected &&
|
!!u.voiceState?.isConnected &&
|
||||||
u.voiceState?.roomId === roomId &&
|
u.voiceState?.roomId === roomId &&
|
||||||
@@ -184,7 +371,6 @@ export class RoomsSidePanelComponent {
|
|||||||
isCurrentRoom(roomId: string): boolean {
|
isCurrentRoom(roomId: string): boolean {
|
||||||
const me = this.currentUser();
|
const me = this.currentUser();
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
// Check that voice is connected AND both the server AND room match
|
|
||||||
return !!(
|
return !!(
|
||||||
me?.voiceState?.isConnected &&
|
me?.voiceState?.isConnected &&
|
||||||
me.voiceState?.roomId === roomId &&
|
me.voiceState?.roomId === roomId &&
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ export class TitleBarComponent {
|
|||||||
|
|
||||||
logout() {
|
logout() {
|
||||||
this._showMenu.set(false);
|
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 {
|
try {
|
||||||
localStorage.removeItem('metoyou_currentUserId');
|
localStorage.removeItem('metoyou_currentUserId');
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|||||||
@@ -226,6 +226,10 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
async loadAudioDevices(): Promise<void> {
|
async loadAudioDevices(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
if (!navigator.mediaDevices?.enumerateDevices) {
|
||||||
|
console.warn('navigator.mediaDevices not available (requires HTTPS or localhost)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
this.inputDevices.set(
|
this.inputDevices.set(
|
||||||
devices
|
devices
|
||||||
@@ -251,6 +255,11 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
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({
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: {
|
audio: {
|
||||||
deviceId: this.selectedInputDevice() || undefined,
|
deviceId: this.selectedInputDevice() || undefined,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const loadMessagesFailure = createAction(
|
|||||||
// Send message
|
// Send message
|
||||||
export const sendMessage = createAction(
|
export const sendMessage = createAction(
|
||||||
'[Messages] Send Message',
|
'[Messages] Send Message',
|
||||||
props<{ content: string; replyToId?: string }>()
|
props<{ content: string; replyToId?: string; channelId?: string }>()
|
||||||
);
|
);
|
||||||
|
|
||||||
export const sendMessageSuccess = createAction(
|
export const sendMessageSuccess = createAction(
|
||||||
@@ -104,5 +104,9 @@ export const syncMessages = createAction(
|
|||||||
props<{ messages: Message[] }>()
|
props<{ messages: Message[] }>()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Sync lifecycle
|
||||||
|
export const startSync = createAction('[Messages] Start Sync');
|
||||||
|
export const syncComplete = createAction('[Messages] Sync Complete');
|
||||||
|
|
||||||
// Clear messages
|
// Clear messages
|
||||||
export const clearMessages = createAction('[Messages] Clear Messages');
|
export const clearMessages = createAction('[Messages] Clear Messages');
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { of, from } from 'rxjs';
|
import { of, from, timer, Subject } from 'rxjs';
|
||||||
import { map, mergeMap, catchError, withLatestFrom, tap, switchMap } from 'rxjs/operators';
|
import { map, mergeMap, catchError, withLatestFrom, tap, switchMap, filter, exhaustMap, repeat, takeUntil } from 'rxjs/operators';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import * as MessagesActions from './messages.actions';
|
import * as MessagesActions from './messages.actions';
|
||||||
|
import { selectMessagesSyncing } from './messages.selectors';
|
||||||
import { selectCurrentUser } from '../users/users.selectors';
|
import { selectCurrentUser } from '../users/users.selectors';
|
||||||
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
||||||
import { DatabaseService } from '../../core/services/database.service';
|
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 INVENTORY_LIMIT = 1000; // number of recent messages to consider
|
||||||
private readonly CHUNK_SIZE = 200; // chunk size for inventory/batch transfers
|
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(() =>
|
loadMessages$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(MessagesActions.loadMessages),
|
ofType(MessagesActions.loadMessages),
|
||||||
switchMap(({ roomId }) =>
|
switchMap(({ roomId }) =>
|
||||||
from(this.db.getMessages(roomId)).pipe(
|
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) =>
|
catchError((error) =>
|
||||||
of(MessagesActions.loadMessagesFailure({ error: error.message }))
|
of(MessagesActions.loadMessagesFailure({ error: error.message }))
|
||||||
)
|
)
|
||||||
@@ -50,7 +63,7 @@ export class MessagesEffects {
|
|||||||
this.store.select(selectCurrentUser),
|
this.store.select(selectCurrentUser),
|
||||||
this.store.select(selectCurrentRoom)
|
this.store.select(selectCurrentRoom)
|
||||||
),
|
),
|
||||||
mergeMap(([{ content, replyToId }, currentUser, currentRoom]) => {
|
mergeMap(([{ content, replyToId, channelId }, currentUser, currentRoom]) => {
|
||||||
if (!currentUser || !currentRoom) {
|
if (!currentUser || !currentRoom) {
|
||||||
return of(MessagesActions.sendMessageFailure({ error: 'Not connected to a room' }));
|
return of(MessagesActions.sendMessageFailure({ error: 'Not connected to a room' }));
|
||||||
}
|
}
|
||||||
@@ -58,6 +71,7 @@ export class MessagesEffects {
|
|||||||
const message: Message = {
|
const message: Message = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
roomId: currentRoom.id,
|
roomId: currentRoom.id,
|
||||||
|
channelId: channelId || 'general',
|
||||||
senderId: currentUser.id,
|
senderId: currentUser.id,
|
||||||
senderName: currentUser.displayName || currentUser.username,
|
senderName: currentUser.displayName || currentUser.username,
|
||||||
content,
|
content,
|
||||||
@@ -226,6 +240,7 @@ export class MessagesEffects {
|
|||||||
// Broadcast to peers
|
// Broadcast to peers
|
||||||
this.webrtc.broadcastMessage({
|
this.webrtc.broadcastMessage({
|
||||||
type: 'reaction-added',
|
type: 'reaction-added',
|
||||||
|
messageId,
|
||||||
reaction,
|
reaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -273,17 +288,23 @@ export class MessagesEffects {
|
|||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
// Precise sync via ID inventory and targeted requests
|
// Precise sync via ID inventory and targeted requests
|
||||||
case 'chat-inventory-request': {
|
case 'chat-inventory-request': {
|
||||||
if (!currentRoom || !event.fromPeerId) return of({ type: 'NO_OP' });
|
const reqRoomId = event.roomId;
|
||||||
return from(this.db.getMessages(currentRoom.id, this.INVENTORY_LIMIT, 0)).pipe(
|
if (!reqRoomId || !event.fromPeerId) return of({ type: 'NO_OP' });
|
||||||
tap((messages) => {
|
return from(this.db.getMessages(reqRoomId, this.INVENTORY_LIMIT, 0)).pipe(
|
||||||
const items = messages
|
mergeMap(async (messages) => {
|
||||||
.map((m) => ({ id: m.id, ts: m.editedAt || m.timestamp || 0 }))
|
const items = await Promise.all(
|
||||||
.sort((a, b) => a.ts - b.ts);
|
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) {
|
for (let i = 0; i < items.length; i += this.CHUNK_SIZE) {
|
||||||
const chunk = items.slice(i, i + this.CHUNK_SIZE);
|
const chunk = items.slice(i, i + this.CHUNK_SIZE);
|
||||||
this.webrtc.sendToPeer(event.fromPeerId, {
|
this.webrtc.sendToPeer(event.fromPeerId, {
|
||||||
type: 'chat-inventory',
|
type: 'chat-inventory',
|
||||||
roomId: currentRoom.id,
|
roomId: reqRoomId,
|
||||||
items: chunk,
|
items: chunk,
|
||||||
total: items.length,
|
total: items.length,
|
||||||
index: i,
|
index: i,
|
||||||
@@ -295,24 +316,37 @@ export class MessagesEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'chat-inventory': {
|
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
|
// 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) => {
|
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[] = [];
|
const missing: string[] = [];
|
||||||
for (const { id, ts } of event.items as Array<{ id: string; ts: number }>) {
|
for (const item of event.items as Array<{ id: string; ts: number; rc?: number }>) {
|
||||||
const lts = localMap.get(id);
|
const localEntry = localMap.get(item.id);
|
||||||
if (lts === undefined || ts > lts) {
|
if (!localEntry) {
|
||||||
missing.push(id);
|
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
|
// Request in chunks from the sender
|
||||||
for (let i = 0; i < missing.length; i += this.CHUNK_SIZE) {
|
for (let i = 0; i < missing.length; i += this.CHUNK_SIZE) {
|
||||||
const chunk = missing.slice(i, i + this.CHUNK_SIZE);
|
const chunk = missing.slice(i, i + this.CHUNK_SIZE);
|
||||||
this.webrtc.sendToPeer(event.fromPeerId, {
|
this.webrtc.sendToPeer(event.fromPeerId, {
|
||||||
type: 'chat-sync-request-ids',
|
type: 'chat-sync-request-ids',
|
||||||
roomId: currentRoom.id,
|
roomId: invRoomId,
|
||||||
ids: chunk,
|
ids: chunk,
|
||||||
} as any);
|
} as any);
|
||||||
}
|
}
|
||||||
@@ -322,18 +356,36 @@ export class MessagesEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'chat-sync-request-ids': {
|
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;
|
const ids: string[] = event.ids;
|
||||||
return from(Promise.all(ids.map((id: string) => this.db.getMessageById(id)))).pipe(
|
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);
|
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
|
// Send in chunks to avoid large payloads
|
||||||
for (let i = 0; i < messages.length; i += this.CHUNK_SIZE) {
|
for (let i = 0; i < hydrated.length; i += this.CHUNK_SIZE) {
|
||||||
const chunk = messages.slice(i, 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, {
|
this.webrtc.sendToPeer(event.fromPeerId, {
|
||||||
type: 'chat-sync-batch',
|
type: 'chat-sync-batch',
|
||||||
roomId: currentRoom.id,
|
roomId: syncReqRoomId || '',
|
||||||
messages: chunk,
|
messages: chunk,
|
||||||
|
attachments: Object.keys(chunkAttachments).length > 0 ? chunkAttachments : undefined,
|
||||||
} as any);
|
} as any);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -342,21 +394,54 @@ export class MessagesEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'chat-sync-batch': {
|
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 () => {
|
return from((async () => {
|
||||||
const accepted: Message[] = [];
|
const toUpsert: Message[] = [];
|
||||||
for (const m of event.messages as Message[]) {
|
for (const m of event.messages as Message[]) {
|
||||||
const existing = await this.db.getMessageById(m.id);
|
const existing = await this.db.getMessageById(m.id);
|
||||||
const ets = existing ? (existing.editedAt || existing.timestamp || 0) : -1;
|
const ets = existing ? (existing.editedAt || existing.timestamp || 0) : -1;
|
||||||
const its = m.editedAt || m.timestamp || 0;
|
const its = m.editedAt || m.timestamp || 0;
|
||||||
if (!existing || its > ets) {
|
const isNewer = !existing || its > ets;
|
||||||
|
|
||||||
|
if (isNewer) {
|
||||||
await this.db.saveMessage(m);
|
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(
|
})()).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':
|
case 'voice-state':
|
||||||
@@ -394,6 +479,11 @@ export class MessagesEffects {
|
|||||||
this.attachments.handleFileCancel(event);
|
this.attachments.handleFileCancel(event);
|
||||||
return of({ type: 'NO_OP' });
|
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':
|
case 'message-edited':
|
||||||
if (event.messageId && event.content) {
|
if (event.messageId && event.content) {
|
||||||
this.db.updateMessage(event.messageId, { content: event.content, editedAt: event.editedAt });
|
this.db.updateMessage(event.messageId, { content: event.content, editedAt: event.editedAt });
|
||||||
@@ -526,4 +616,66 @@ export class MessagesEffects {
|
|||||||
),
|
),
|
||||||
{ dispatch: false }
|
{ 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> {
|
export interface MessagesState extends EntityState<Message> {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
syncing: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
currentRoomId: string | null;
|
currentRoomId: string | null;
|
||||||
}
|
}
|
||||||
@@ -16,6 +17,7 @@ export const messagesAdapter: EntityAdapter<Message> = createEntityAdapter<Messa
|
|||||||
|
|
||||||
export const initialState: MessagesState = messagesAdapter.getInitialState({
|
export const initialState: MessagesState = messagesAdapter.getInitialState({
|
||||||
loading: false,
|
loading: false,
|
||||||
|
syncing: false,
|
||||||
error: null,
|
error: null,
|
||||||
currentRoomId: null,
|
currentRoomId: null,
|
||||||
});
|
});
|
||||||
@@ -23,13 +25,23 @@ export const initialState: MessagesState = messagesAdapter.getInitialState({
|
|||||||
export const messagesReducer = createReducer(
|
export const messagesReducer = createReducer(
|
||||||
initialState,
|
initialState,
|
||||||
|
|
||||||
// Load messages
|
// Load messages — clear stale messages when switching to a different room
|
||||||
on(MessagesActions.loadMessages, (state, { roomId }) => ({
|
on(MessagesActions.loadMessages, (state, { roomId }) => {
|
||||||
...state,
|
if (state.currentRoomId && state.currentRoomId !== roomId) {
|
||||||
loading: true,
|
return messagesAdapter.removeAll({
|
||||||
error: null,
|
...state,
|
||||||
currentRoomId: roomId,
|
loading: true,
|
||||||
})),
|
error: null,
|
||||||
|
currentRoomId: roomId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
currentRoomId: roomId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
on(MessagesActions.loadMessagesSuccess, (state, { messages }) =>
|
on(MessagesActions.loadMessagesSuccess, (state, { messages }) =>
|
||||||
messagesAdapter.setAll(messages, {
|
messagesAdapter.setAll(messages, {
|
||||||
@@ -130,10 +142,37 @@ export const messagesReducer = createReducer(
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Sync messages from peer
|
// Sync lifecycle
|
||||||
on(MessagesActions.syncMessages, (state, { messages }) =>
|
on(MessagesActions.startSync, (state) => ({
|
||||||
messagesAdapter.upsertMany(messages, 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
|
// Clear messages
|
||||||
on(MessagesActions.clearMessages, (state) =>
|
on(MessagesActions.clearMessages, (state) =>
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export const selectMessagesError = createSelector(
|
|||||||
(state) => state.error
|
(state) => state.error
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const selectMessagesSyncing = createSelector(
|
||||||
|
selectMessagesState,
|
||||||
|
(state) => state.syncing
|
||||||
|
);
|
||||||
|
|
||||||
export const selectCurrentRoomId = createSelector(
|
export const selectCurrentRoomId = createSelector(
|
||||||
selectMessagesState,
|
selectMessagesState,
|
||||||
(state) => state.currentRoomId
|
(state) => state.currentRoomId
|
||||||
@@ -34,6 +39,19 @@ export const selectCurrentRoomMessages = createSelector(
|
|||||||
(messages, roomId) => roomId ? messages.filter((m) => m.roomId === roomId) : []
|
(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) =>
|
export const selectMessageById = (id: string) =>
|
||||||
createSelector(selectMessagesEntities, (entities) => entities[id]);
|
createSelector(selectMessagesEntities, (entities) => entities[id]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createAction, props } from '@ngrx/store';
|
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
|
// Load rooms from storage
|
||||||
export const loadRooms = createAction('[Rooms] Load Rooms');
|
export const loadRooms = createAction('[Rooms] Load Rooms');
|
||||||
@@ -159,6 +159,27 @@ export const receiveRoomUpdate = createAction(
|
|||||||
props<{ room: Partial<Room> }>()
|
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
|
// Clear search results
|
||||||
export const clearSearchResults = createAction('[Rooms] Clear Search Results');
|
export const clearSearchResults = createAction('[Rooms] Clear Search Results');
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,37 @@
|
|||||||
import { createReducer, on } from '@ngrx/store';
|
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';
|
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 {
|
export interface RoomsState {
|
||||||
currentRoom: Room | null;
|
currentRoom: Room | null;
|
||||||
savedRooms: Room[];
|
savedRooms: Room[];
|
||||||
@@ -12,6 +42,7 @@ export interface RoomsState {
|
|||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
activeChannelId: string; // currently selected text channel
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initialState: RoomsState = {
|
export const initialState: RoomsState = {
|
||||||
@@ -24,6 +55,7 @@ export const initialState: RoomsState = {
|
|||||||
isConnected: false,
|
isConnected: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
activeChannelId: 'general',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const roomsReducer = createReducer(
|
export const roomsReducer = createReducer(
|
||||||
@@ -38,7 +70,7 @@ export const roomsReducer = createReducer(
|
|||||||
|
|
||||||
on(RoomsActions.loadRoomsSuccess, (state, { rooms }) => ({
|
on(RoomsActions.loadRoomsSuccess, (state, { rooms }) => ({
|
||||||
...state,
|
...state,
|
||||||
savedRooms: rooms,
|
savedRooms: deduplicateRooms(rooms),
|
||||||
loading: false,
|
loading: false,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
@@ -74,12 +106,17 @@ export const roomsReducer = createReducer(
|
|||||||
error: null,
|
error: null,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
on(RoomsActions.createRoomSuccess, (state, { room }) => ({
|
on(RoomsActions.createRoomSuccess, (state, { room }) => {
|
||||||
...state,
|
const enriched = { ...room, channels: room.channels || defaultChannels() };
|
||||||
currentRoom: room,
|
return {
|
||||||
isConnecting: false,
|
...state,
|
||||||
isConnected: true,
|
currentRoom: enriched,
|
||||||
})),
|
savedRooms: upsertRoom(state.savedRooms, enriched),
|
||||||
|
isConnecting: false,
|
||||||
|
isConnected: true,
|
||||||
|
activeChannelId: 'general',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
on(RoomsActions.createRoomFailure, (state, { error }) => ({
|
on(RoomsActions.createRoomFailure, (state, { error }) => ({
|
||||||
...state,
|
...state,
|
||||||
@@ -94,12 +131,17 @@ export const roomsReducer = createReducer(
|
|||||||
error: null,
|
error: null,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
on(RoomsActions.joinRoomSuccess, (state, { room }) => ({
|
on(RoomsActions.joinRoomSuccess, (state, { room }) => {
|
||||||
...state,
|
const enriched = { ...room, channels: room.channels || defaultChannels() };
|
||||||
currentRoom: room,
|
return {
|
||||||
isConnecting: false,
|
...state,
|
||||||
isConnected: true,
|
currentRoom: enriched,
|
||||||
})),
|
savedRooms: upsertRoom(state.savedRooms, enriched),
|
||||||
|
isConnecting: false,
|
||||||
|
isConnected: true,
|
||||||
|
activeChannelId: 'general',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
on(RoomsActions.joinRoomFailure, (state, { error }) => ({
|
on(RoomsActions.joinRoomFailure, (state, { error }) => ({
|
||||||
...state,
|
...state,
|
||||||
@@ -128,12 +170,17 @@ export const roomsReducer = createReducer(
|
|||||||
error: null,
|
error: null,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
on(RoomsActions.viewServerSuccess, (state, { room }) => ({
|
on(RoomsActions.viewServerSuccess, (state, { room }) => {
|
||||||
...state,
|
const enriched = { ...room, channels: room.channels || defaultChannels() };
|
||||||
currentRoom: room,
|
return {
|
||||||
isConnecting: false,
|
...state,
|
||||||
isConnected: true,
|
currentRoom: enriched,
|
||||||
})),
|
savedRooms: upsertRoom(state.savedRooms, enriched),
|
||||||
|
isConnecting: false,
|
||||||
|
isConnected: true,
|
||||||
|
activeChannelId: 'general',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
// Update room settings
|
// Update room settings
|
||||||
on(RoomsActions.updateRoomSettings, (state) => ({
|
on(RoomsActions.updateRoomSettings, (state) => ({
|
||||||
@@ -225,5 +272,48 @@ export const roomsReducer = createReducer(
|
|||||||
on(RoomsActions.setConnecting, (state, { isConnecting }) => ({
|
on(RoomsActions.setConnecting, (state, { isConnecting }) => ({
|
||||||
...state,
|
...state,
|
||||||
isConnecting,
|
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,
|
selectRoomsState,
|
||||||
(state) => state.loading
|
(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,
|
selectAllUsers,
|
||||||
(users) => users.filter((u) => u.role === 'host' || u.role === 'admin' || u.role === 'moderator')
|
(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