Imrpove chat with gifs, videos, music player, redesigns and improved filesharing errors

This commit is contained in:
2026-03-06 04:47:07 +01:00
parent 2d84fbd91a
commit fe2347b54e
65 changed files with 3593 additions and 1030 deletions

2
.gitignore vendored
View File

@@ -2,6 +2,7 @@
# Compiled output
/dist
/server/dist/
/tmp
/out-tsc
/bazel-out
@@ -49,3 +50,4 @@ Thumbs.db
# Environment & certs
.env
.certs/
/server/data/variables.json

View File

@@ -4,6 +4,7 @@ import { DeleteRoomCommand } from '../../types';
export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: DataSource): Promise<void> {
const { roomId } = command.payload;
await dataSource.getRepository(RoomEntity).delete({ id: roomId });
await dataSource.getRepository(MessageEntity).delete({ roomId });
}

View File

@@ -5,7 +5,6 @@ import { SaveMessageCommand } from '../../types';
export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
const { message } = command.payload;
const entity = repo.create({
id: message.id,
roomId: message.roomId,
@@ -17,7 +16,7 @@ export async function handleSaveMessage(command: SaveMessageCommand, dataSource:
editedAt: message.editedAt ?? null,
reactions: JSON.stringify(message.reactions ?? []),
isDeleted: message.isDeleted ? 1 : 0,
replyToId: message.replyToId ?? null,
replyToId: message.replyToId ?? null
});
await repo.save(entity);

View File

@@ -34,7 +34,15 @@ if (fs.existsSync(projectRootDatabaseFilePath)) {
export const AppDataSource = new DataSource({
type: 'sqljs',
database: databaseFileBuffer,
entities: [MessageEntity, UserEntity, RoomEntity, ReactionEntity, BanEntity, AttachmentEntity, MetaEntity],
entities: [
MessageEntity,
UserEntity,
RoomEntity,
ReactionEntity,
BanEntity,
AttachmentEntity,
MetaEntity
],
migrations: [path.join(__dirname, 'migrations', '*.{ts,js}')],
synchronize: false,
logging: false,

View File

@@ -36,11 +36,16 @@ export async function initializeDatabase(): Promise<void> {
applicationDataSource = new DataSource({
type: 'sqljs',
database,
entities: [MessageEntity, UserEntity, RoomEntity, ReactionEntity, BanEntity, AttachmentEntity, MetaEntity],
migrations: [
path.join(__dirname, '..', 'migrations', '*.js'),
path.join(__dirname, '..', 'migrations', '*.ts')
entities: [
MessageEntity,
UserEntity,
RoomEntity,
ReactionEntity,
BanEntity,
AttachmentEntity,
MetaEntity
],
migrations: [path.join(__dirname, '..', 'migrations', '*.js'), path.join(__dirname, '..', 'migrations', '*.ts')],
synchronize: false,
logging: false,
autoSave: true,

View File

@@ -1,4 +1,8 @@
import { Entity, PrimaryColumn, Column } from 'typeorm';
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('meta')
export class MetaEntity {

View File

@@ -1,4 +1,8 @@
import { Entity, PrimaryColumn, Column } from 'typeorm';
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('reactions')
export class ReactionEntity {

View File

@@ -1,6 +1,7 @@
import {
app,
desktopCapturer,
dialog,
ipcMain,
shell
} from 'electron';
@@ -54,6 +55,24 @@ export function setupSystemHandlers(): void {
return true;
});
ipcMain.handle('save-file-as', async (_event, defaultFileName: string, base64Data: string) => {
const result = await dialog.showSaveDialog({
defaultPath: defaultFileName
});
if (result.canceled || !result.filePath) {
return { saved: false,
cancelled: true };
}
const buffer = Buffer.from(base64Data, 'base64');
await fsp.writeFile(result.filePath, buffer);
return { saved: true,
cancelled: false };
});
ipcMain.handle('ensure-dir', async (_event, dirPath: string) => {
await fsp.mkdir(dirPath, { recursive: true });
return true;

View File

@@ -13,6 +13,7 @@ export interface ElectronAPI {
getAppDataPath: () => Promise<string>;
readFile: (filePath: string) => Promise<string>;
writeFile: (filePath: string, data: string) => Promise<boolean>;
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
fileExists: (filePath: string) => Promise<boolean>;
ensureDir: (dirPath: string) => Promise<boolean>;
@@ -31,6 +32,7 @@ const electronAPI: ElectronAPI = {
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data),
fileExists: (filePath) => ipcRenderer.invoke('file-exists', filePath),
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),

View File

@@ -43,7 +43,7 @@ const noDashPlugin = {
module.exports = tseslint.config(
{
ignores: ['**/generated/*','dist/**', '**/migrations/**', 'release/**']
ignores: ['**/.angular/**', '**/generated/*', '**/dist/**', '**/migrations/**', 'release/**']
},
{
files: ['**/*.ts'],

View File

@@ -72,7 +72,8 @@
"sql.js": "^1.13.0",
"tslib": "^2.3.0",
"typeorm": "^0.3.28",
"uuid": "^13.0.0"
"uuid": "^13.0.0",
"wavesurfer.js": "^7.12.1"
},
"devDependencies": {
"@angular/build": "^21.0.4",

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

@@ -1,43 +0,0 @@
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

View File

@@ -1 +0,0 @@
{"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
View File

@@ -1,277 +0,0 @@
"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

File diff suppressed because one or more lines are too long

View File

@@ -1,2 +0,0 @@
export {};
// # sourceMappingURL=index.d.ts.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}

438
server/dist/index.js vendored
View File

@@ -1,438 +0,0 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = __importDefault(require("express"));
const cors_1 = __importDefault(require("cors"));
const http_1 = require("http");
const ws_1 = require("ws");
const uuid_1 = require("uuid");
const app = (0, express_1.default)();
const PORT = process.env.PORT || 3001;
app.use((0, cors_1.default)());
app.use(express_1.default.json());
const connectedUsers = new Map();
// Database
const crypto_1 = __importDefault(require("crypto"));
const db_1 = require("./db");
function hashPassword(pw) { return crypto_1.default.createHash('sha256').update(pw).digest('hex'); }
// REST API Routes
// Health check endpoint
app.get('/api/health', async (req, res) => {
const allServers = await (0, db_1.getAllPublicServers)();
res.json({
status: 'ok',
timestamp: Date.now(),
serverCount: allServers.length,
connectedUsers: connectedUsers.size,
});
});
// Time endpoint for clock synchronization
app.get('/api/time', (req, res) => {
res.json({ now: Date.now() });
});
// Image proxy to allow rendering external images within CSP (img-src 'self' data: blob:)
app.get('/api/image-proxy', async (req, res) => {
try {
const url = String(req.query.url || '');
if (!/^https?:\/\//i.test(url)) {
return res.status(400).json({ error: 'Invalid URL' });
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
const response = await fetch(url, { redirect: 'follow', signal: controller.signal });
clearTimeout(timeout);
if (!response.ok) {
return res.status(response.status).end();
}
const contentType = response.headers.get('content-type') || '';
if (!contentType.toLowerCase().startsWith('image/')) {
return res.status(415).json({ error: 'Unsupported content type' });
}
const arrayBuffer = await response.arrayBuffer();
const MAX_BYTES = 8 * 1024 * 1024; // 8MB limit
if (arrayBuffer.byteLength > MAX_BYTES) {
return res.status(413).json({ error: 'Image too large' });
}
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'public, max-age=3600');
res.send(Buffer.from(arrayBuffer));
}
catch (err) {
if (err?.name === 'AbortError') {
return res.status(504).json({ error: 'Timeout fetching image' });
}
console.error('Image proxy error:', err);
res.status(502).json({ error: 'Failed to fetch image' });
}
});
// Auth
app.post('/api/users/register', async (req, res) => {
const { username, password, displayName } = req.body;
if (!username || !password)
return res.status(400).json({ error: 'Missing username/password' });
const exists = await (0, db_1.getUserByUsername)(username);
if (exists)
return res.status(409).json({ error: 'Username taken' });
const user = { id: (0, uuid_1.v4)(), username, passwordHash: hashPassword(password), displayName: displayName || username, createdAt: Date.now() };
await (0, db_1.createUser)(user);
res.status(201).json({ id: user.id, username: user.username, displayName: user.displayName });
});
app.post('/api/users/login', async (req, res) => {
const { username, password } = req.body;
const user = await (0, db_1.getUserByUsername)(username);
if (!user || user.passwordHash !== hashPassword(password))
return res.status(401).json({ error: 'Invalid credentials' });
res.json({ id: user.id, username: user.username, displayName: user.displayName });
});
// Search servers
app.get('/api/servers', async (req, res) => {
const { q, tags, limit = 20, offset = 0 } = req.query;
let results = await (0, db_1.getAllPublicServers)();
results = results
.filter(s => {
if (q) {
const query = String(q).toLowerCase();
return s.name.toLowerCase().includes(query) ||
s.description?.toLowerCase().includes(query);
}
return true;
})
.filter(s => {
if (tags) {
const tagList = String(tags).split(',');
return tagList.some(t => s.tags.includes(t));
}
return true;
});
const total = results.length;
results = results.slice(Number(offset), Number(offset) + Number(limit));
res.json({ servers: results, total, limit: Number(limit), offset: Number(offset) });
});
// Register a server
app.post('/api/servers', async (req, res) => {
const { id: clientId, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, tags } = req.body;
if (!name || !ownerId || !ownerPublicKey) {
return res.status(400).json({ error: 'Missing required fields' });
}
const id = clientId || (0, uuid_1.v4)();
const server = {
id,
name,
description,
ownerId,
ownerPublicKey,
isPrivate: isPrivate ?? false,
maxUsers: maxUsers ?? 0,
currentUsers: 0,
tags: tags ?? [],
createdAt: Date.now(),
lastSeen: Date.now(),
};
await (0, db_1.upsertServer)(server);
res.status(201).json(server);
});
// Update server
app.put('/api/servers/:id', async (req, res) => {
const { id } = req.params;
const { ownerId, ...updates } = req.body;
const server = await (0, db_1.getServerById)(id);
if (!server) {
return res.status(404).json({ error: 'Server not found' });
}
if (server.ownerId !== ownerId) {
return res.status(403).json({ error: 'Not authorized' });
}
const updated = { ...server, ...updates, lastSeen: Date.now() };
await (0, db_1.upsertServer)(updated);
res.json(updated);
});
// Heartbeat - keep server alive
app.post('/api/servers/:id/heartbeat', async (req, res) => {
const { id } = req.params;
const { currentUsers } = req.body;
const server = await (0, db_1.getServerById)(id);
if (!server) {
return res.status(404).json({ error: 'Server not found' });
}
server.lastSeen = Date.now();
if (typeof currentUsers === 'number') {
server.currentUsers = currentUsers;
}
await (0, db_1.upsertServer)(server);
res.json({ ok: true });
});
// Remove server
app.delete('/api/servers/:id', async (req, res) => {
const { id } = req.params;
const { ownerId } = req.body;
const server = await (0, db_1.getServerById)(id);
if (!server) {
return res.status(404).json({ error: 'Server not found' });
}
if (server.ownerId !== ownerId) {
return res.status(403).json({ error: 'Not authorized' });
}
await (0, db_1.deleteServer)(id);
res.json({ ok: true });
});
// Request to join a server
app.post('/api/servers/:id/join', async (req, res) => {
const { id: serverId } = req.params;
const { userId, userPublicKey, displayName } = req.body;
const server = await (0, db_1.getServerById)(serverId);
if (!server) {
return res.status(404).json({ error: 'Server not found' });
}
const requestId = (0, uuid_1.v4)();
const request = {
id: requestId,
serverId,
userId,
userPublicKey,
displayName,
status: server.isPrivate ? 'pending' : 'approved',
createdAt: Date.now(),
};
await (0, db_1.createJoinRequest)(request);
// Notify server owner via WebSocket
if (server.isPrivate) {
notifyServerOwner(server.ownerId, {
type: 'join_request',
request,
});
}
res.status(201).json(request);
});
// Get join requests for a server
app.get('/api/servers/:id/requests', async (req, res) => {
const { id: serverId } = req.params;
const { ownerId } = req.query;
const server = await (0, db_1.getServerById)(serverId);
if (!server) {
return res.status(404).json({ error: 'Server not found' });
}
if (server.ownerId !== ownerId) {
return res.status(403).json({ error: 'Not authorized' });
}
const requests = await (0, db_1.getPendingRequestsForServer)(serverId);
res.json({ requests });
});
// Approve/reject join request
app.put('/api/requests/:id', async (req, res) => {
const { id } = req.params;
const { ownerId, status } = req.body;
const request = await (0, db_1.getJoinRequestById)(id);
if (!request) {
return res.status(404).json({ error: 'Request not found' });
}
const server = await (0, db_1.getServerById)(request.serverId);
if (!server || server.ownerId !== ownerId) {
return res.status(403).json({ error: 'Not authorized' });
}
await (0, db_1.updateJoinRequestStatus)(id, status);
const updated = { ...request, status };
// Notify the requester
notifyUser(request.userId, {
type: 'request_update',
request: updated,
});
res.json(updated);
});
// WebSocket Server for real-time signaling
const server = (0, http_1.createServer)(app);
const wss = new ws_1.WebSocketServer({ server });
wss.on('connection', (ws) => {
const connectionId = (0, uuid_1.v4)();
connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set() });
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
handleWebSocketMessage(connectionId, message);
}
catch (err) {
console.error('Invalid WebSocket message:', err);
}
});
ws.on('close', () => {
const user = connectedUsers.get(connectionId);
if (user) {
// Notify all servers the user was a member of
user.serverIds.forEach((sid) => {
broadcastToServer(sid, {
type: 'user_left',
oderId: user.oderId,
displayName: user.displayName,
serverId: sid,
}, user.oderId);
});
}
connectedUsers.delete(connectionId);
});
// Send connection acknowledgment with the connectionId (client will identify with their actual oderId)
ws.send(JSON.stringify({ type: 'connected', connectionId, serverTime: Date.now() }));
});
function handleWebSocketMessage(connectionId, message) {
const user = connectedUsers.get(connectionId);
if (!user)
return;
switch (message.type) {
case 'identify':
// User identifies themselves with their permanent ID
// Store their actual oderId for peer-to-peer routing
user.oderId = message.oderId || connectionId;
user.displayName = message.displayName || 'Anonymous';
connectedUsers.set(connectionId, user);
console.log(`User identified: ${user.displayName} (${user.oderId})`);
break;
case 'join_server': {
const sid = message.serverId;
const isNew = !user.serverIds.has(sid);
user.serverIds.add(sid);
user.viewedServerId = sid;
connectedUsers.set(connectionId, user);
console.log(`User ${user.displayName || 'Anonymous'} (${user.oderId}) joined server ${sid} (new=${isNew})`);
// Always send the current user list for this server
const usersInServer = Array.from(connectedUsers.values())
.filter(u => u.serverIds.has(sid) && u.oderId !== user.oderId && u.displayName)
.map(u => ({ oderId: u.oderId, displayName: u.displayName || 'Anonymous' }));
console.log(`Sending server_users to ${user.displayName || 'Anonymous'}:`, usersInServer);
user.ws.send(JSON.stringify({
type: 'server_users',
serverId: sid,
users: usersInServer,
}));
// Only broadcast user_joined if this is a brand-new join (not a re-view)
if (isNew) {
broadcastToServer(sid, {
type: 'user_joined',
oderId: user.oderId,
displayName: user.displayName || 'Anonymous',
serverId: sid,
}, user.oderId);
}
break;
}
case 'view_server': {
// Just switch the viewed server without joining/leaving
const viewSid = message.serverId;
user.viewedServerId = viewSid;
connectedUsers.set(connectionId, user);
console.log(`User ${user.displayName || 'Anonymous'} (${user.oderId}) viewing server ${viewSid}`);
// Send current user list for the viewed server
const viewUsers = Array.from(connectedUsers.values())
.filter(u => u.serverIds.has(viewSid) && u.oderId !== user.oderId && u.displayName)
.map(u => ({ oderId: u.oderId, displayName: u.displayName || 'Anonymous' }));
user.ws.send(JSON.stringify({
type: 'server_users',
serverId: viewSid,
users: viewUsers,
}));
break;
}
case 'leave_server': {
const leaveSid = message.serverId || user.viewedServerId;
if (leaveSid) {
user.serverIds.delete(leaveSid);
if (user.viewedServerId === leaveSid) {
user.viewedServerId = undefined;
}
connectedUsers.set(connectionId, user);
broadcastToServer(leaveSid, {
type: 'user_left',
oderId: user.oderId,
displayName: user.displayName || 'Anonymous',
serverId: leaveSid,
}, user.oderId);
}
break;
}
case 'offer':
case 'answer':
case 'ice_candidate':
// Forward signaling messages to specific peer
console.log(`Forwarding ${message.type} from ${user.oderId} to ${message.targetUserId}`);
const targetUser = findUserByUserId(message.targetUserId);
if (targetUser) {
targetUser.ws.send(JSON.stringify({
...message,
fromUserId: user.oderId,
}));
console.log(`Successfully forwarded ${message.type} to ${message.targetUserId}`);
}
else {
console.log(`Target user ${message.targetUserId} not found. Connected users:`, Array.from(connectedUsers.values()).map(u => ({ oderId: u.oderId, displayName: u.displayName })));
}
break;
case 'chat_message': {
// Broadcast chat message to all users in the server
const chatSid = message.serverId || user.viewedServerId;
if (chatSid && user.serverIds.has(chatSid)) {
broadcastToServer(chatSid, {
type: 'chat_message',
serverId: chatSid,
message: message.message,
senderId: user.oderId,
senderName: user.displayName,
timestamp: Date.now(),
});
}
break;
}
case 'typing': {
// Broadcast typing indicator
const typingSid = message.serverId || user.viewedServerId;
if (typingSid && user.serverIds.has(typingSid)) {
broadcastToServer(typingSid, {
type: 'user_typing',
serverId: typingSid,
oderId: user.oderId,
displayName: user.displayName,
}, user.oderId);
}
break;
}
default:
console.log('Unknown message type:', message.type);
}
}
function broadcastToServer(serverId, message, excludeOderId) {
console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type);
connectedUsers.forEach((user) => {
if (user.serverIds.has(serverId) && user.oderId !== excludeOderId) {
console.log(` -> Sending to ${user.displayName} (${user.oderId})`);
user.ws.send(JSON.stringify(message));
}
});
}
function notifyServerOwner(ownerId, message) {
const owner = findUserByUserId(ownerId);
if (owner) {
owner.ws.send(JSON.stringify(message));
}
}
function notifyUser(oderId, message) {
const user = findUserByUserId(oderId);
if (user) {
user.ws.send(JSON.stringify(message));
}
}
function findUserByUserId(oderId) {
return Array.from(connectedUsers.values()).find(u => u.oderId === oderId);
}
// Cleanup stale join requests periodically (older than 24 h)
setInterval(() => {
(0, db_1.deleteStaleJoinRequests)(24 * 60 * 60 * 1000).catch(err => console.error('Failed to clean up stale join requests:', err));
}, 60 * 1000);
(0, db_1.initDB)().then(() => {
server.listen(PORT, () => {
console.log(`🚀 MetoYou signaling server running on port ${PORT}`);
console.log(` REST API: http://localhost:${PORT}/api`);
console.log(` WebSocket: ws://localhost:${PORT}`);
});
}).catch((err) => {
console.error('Failed to initialize database:', err);
process.exit(1);
});
//# sourceMappingURL=index.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,72 @@
import fs from 'fs';
import path from 'path';
export interface ServerVariablesConfig {
klipyApiKey: string;
}
const DATA_DIR = path.join(process.cwd(), 'data');
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
function normalizeKlipyApiKey(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function readRawVariables(): { rawContents: string; parsed: Record<string, unknown> } {
if (!fs.existsSync(VARIABLES_FILE)) {
return { rawContents: '', parsed: {} };
}
const rawContents = fs.readFileSync(VARIABLES_FILE, 'utf8');
if (!rawContents.trim()) {
return { rawContents, parsed: {} };
}
try {
const parsed = JSON.parse(rawContents) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return { rawContents, parsed: parsed as Record<string, unknown> };
}
} catch (error) {
console.warn('[Config] Failed to parse variables.json. Recreating it with defaults.', error);
}
return { rawContents, parsed: {} };
}
export function getVariablesConfigPath(): string {
return VARIABLES_FILE;
}
export function ensureVariablesConfig(): ServerVariablesConfig {
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
const { rawContents, parsed } = readRawVariables();
const normalized = {
...parsed,
klipyApiKey: normalizeKlipyApiKey(parsed.klipyApiKey)
};
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
if (!fs.existsSync(VARIABLES_FILE) || rawContents !== nextContents) {
fs.writeFileSync(VARIABLES_FILE, nextContents, 'utf8');
}
return { klipyApiKey: normalized.klipyApiKey };
}
export function getVariablesConfig(): ServerVariablesConfig {
return ensureVariablesConfig();
}
export function getKlipyApiKey(): string {
return getVariablesConfig().klipyApiKey;
}
export function hasKlipyApiKey(): boolean {
return getKlipyApiKey().length > 0;
}

View File

@@ -32,11 +32,12 @@ export async function initDatabase(): Promise<void> {
applicationDataSource = new DataSource({
type: 'sqljs',
database,
entities: [AuthUserEntity, ServerEntity, JoinRequestEntity],
migrations: [
path.join(__dirname, '..', 'migrations', '*.js'),
path.join(__dirname, '..', 'migrations', '*.ts')
entities: [
AuthUserEntity,
ServerEntity,
JoinRequestEntity
],
migrations: [path.join(__dirname, '..', 'migrations', '*.js'), path.join(__dirname, '..', 'migrations', '*.ts')],
synchronize: false,
logging: false,
autoSave: true,

View File

@@ -11,6 +11,11 @@ dotenv.config({ path: path.resolve(__dirname, '..', '..', '.env') });
import { initDatabase } from './db';
import { deleteStaleJoinRequests } from './cqrs';
import { createApp } from './app';
import {
ensureVariablesConfig,
getVariablesConfigPath,
hasKlipyApiKey
} from './config/variables';
import { setupWebSocket } from './websocket';
const USE_SSL = (process.env.SSL ?? 'false').toLowerCase() === 'true';
@@ -38,6 +43,13 @@ function buildServer(app: ReturnType<typeof createApp>) {
}
async function bootstrap(): Promise<void> {
ensureVariablesConfig();
console.log('[Config] Variables loaded from:', getVariablesConfigPath());
if (!hasKlipyApiKey()) {
console.log('[KLIPY] API key not configured. GIF search is disabled.');
}
await initDatabase();
const app = createApp();

View File

@@ -1,5 +1,6 @@
import { Express } from 'express';
import healthRouter from './health';
import klipyRouter from './klipy';
import proxyRouter from './proxy';
import usersRouter from './users';
import serversRouter from './servers';
@@ -7,6 +8,7 @@ import joinRequestsRouter from './join-requests';
export function registerRoutes(app: Express): void {
app.use('/api', healthRouter);
app.use('/api', klipyRouter);
app.use('/api', proxyRouter);
app.use('/api/users', usersRouter);
app.use('/api/servers', serversRouter);

221
server/src/routes/klipy.ts Normal file
View File

@@ -0,0 +1,221 @@
/* eslint-disable complexity, @typescript-eslint/no-explicit-any */
import { Router } from 'express';
import { getKlipyApiKey, hasKlipyApiKey } from '../config/variables';
const router = Router();
const KLIPY_API_BASE_URL = 'https://api.klipy.com/api/v1';
const REQUEST_TIMEOUT_MS = 8000;
const DEFAULT_PAGE = 1;
const DEFAULT_PER_PAGE = 24;
const MAX_PER_PAGE = 50;
interface NormalizedMediaMeta {
url: string;
width?: number;
height?: number;
}
interface NormalizedKlipyGif {
id: string;
slug: string;
title?: string;
url: string;
previewUrl: string;
width: number;
height: number;
}
function pickFirst<T>(...values: (T | null | undefined)[]): T | undefined {
for (const value of values) {
if (value != null)
return value;
}
return undefined;
}
function sanitizeString(value: unknown): string | undefined {
if (typeof value !== 'string')
return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function toPositiveNumber(value: unknown): number | undefined {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
function clampPositiveInt(value: unknown, fallback: number, max = Number.MAX_SAFE_INTEGER): number {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 1)
return fallback;
return Math.min(Math.floor(parsed), max);
}
function normalizeMediaMeta(candidate: unknown): NormalizedMediaMeta | null {
if (!candidate)
return null;
if (typeof candidate === 'string') {
return { url: candidate };
}
if (typeof candidate === 'object' && candidate !== null) {
const url = sanitizeString((candidate as { url?: unknown }).url);
if (!url)
return null;
return {
url,
width: toPositiveNumber((candidate as { width?: unknown }).width),
height: toPositiveNumber((candidate as { height?: unknown }).height)
};
}
return null;
}
function pickGifMeta(sizeVariant: unknown): NormalizedMediaMeta | null {
const candidate = sizeVariant as {
gif?: unknown;
webp?: unknown;
} | undefined;
return normalizeMediaMeta(candidate?.gif) ?? normalizeMediaMeta(candidate?.webp);
}
function normalizeGifItem(item: any): NormalizedKlipyGif | null {
if (!item || typeof item !== 'object' || item.type === 'ad')
return null;
const lowVariant = pickFirst(item.file?.md, item.file?.sm, item.file?.xs, item.file?.hd);
const highVariant = pickFirst(item.file?.hd, item.file?.md, item.file?.sm, item.file?.xs);
const lowMeta = pickGifMeta(lowVariant);
const highMeta = pickGifMeta(highVariant);
const selectedMeta = highMeta ?? lowMeta;
const slug = sanitizeString(item.slug) ?? sanitizeString(item.id);
if (!slug || !selectedMeta?.url)
return null;
return {
id: slug,
slug,
title: sanitizeString(item.title),
url: selectedMeta.url,
previewUrl: lowMeta?.url ?? selectedMeta.url,
width: selectedMeta.width ?? lowMeta?.width ?? 0,
height: selectedMeta.height ?? lowMeta?.height ?? 0
};
}
function extractErrorMessage(payload: unknown): string | null {
if (!payload)
return null;
if (typeof payload === 'string')
return payload.slice(0, 240);
if (typeof payload === 'object' && payload !== null) {
const data = payload as { error?: unknown; message?: unknown };
if (typeof data.error === 'string')
return data.error;
if (typeof data.message === 'string')
return data.message;
}
return null;
}
router.get('/klipy/config', (_req, res) => {
res.json({ enabled: hasKlipyApiKey() });
});
router.get('/klipy/gifs', async (req, res) => {
if (!hasKlipyApiKey()) {
return res.status(503).json({ error: 'KLIPY is not configured on this server.' });
}
try {
const query = sanitizeString(req.query.q) ?? '';
const page = clampPositiveInt(req.query.page, DEFAULT_PAGE);
const perPage = clampPositiveInt(req.query.per_page, DEFAULT_PER_PAGE, MAX_PER_PAGE);
const customerId = sanitizeString(req.query.customer_id);
const locale = sanitizeString(req.query.locale);
const params = new URLSearchParams({
page: String(page),
per_page: String(perPage)
});
if (query)
params.set('q', query);
if (customerId)
params.set('customer_id', customerId);
if (locale)
params.set('locale', locale);
const endpoint = query ? 'search' : 'trending';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const response = await fetch(
`${KLIPY_API_BASE_URL}/${encodeURIComponent(getKlipyApiKey())}/gifs/${endpoint}?${params.toString()}`,
{
headers: { accept: 'application/json' },
signal: controller.signal
}
);
clearTimeout(timeout);
const text = await response.text();
let payload: unknown = null;
if (text) {
try {
payload = JSON.parse(text) as unknown;
} catch {
payload = text;
}
}
if (!response.ok) {
return res.status(response.status).json({
error: extractErrorMessage(payload) || 'Failed to fetch GIFs from KLIPY.'
});
}
const rawItems = Array.isArray((payload as any)?.data?.data)
? (payload as any).data.data
: [];
const results = rawItems
.map((item: unknown) => normalizeGifItem(item))
.filter((item: NormalizedKlipyGif | null): item is NormalizedKlipyGif => !!item);
res.json({
enabled: true,
results,
hasNext: (payload as any)?.data?.has_next === true
});
} catch (error) {
if ((error as { name?: string })?.name === 'AbortError') {
return res.status(504).json({ error: 'KLIPY request timed out.' });
}
console.error('KLIPY GIF route error:', error);
res.status(502).json({ error: 'Failed to fetch GIFs from KLIPY.' });
}
});
export default router;

View File

@@ -66,13 +66,14 @@ router.post('/', async (req, res) => {
router.put('/:id', async (req, res) => {
const { id } = req.params;
const { ownerId, ...updates } = req.body;
const { currentOwnerId, ...updates } = req.body;
const existing = await getServerById(id);
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
if (!existing)
return res.status(404).json({ error: 'Server not found' });
if (existing.ownerId !== ownerId)
if (existing.ownerId !== authenticatedOwnerId)
return res.status(403).json({ error: 'Not authorized' });
const server: ServerPayload = { ...existing, ...updates, lastSeen: Date.now() };

View File

@@ -17,7 +17,7 @@ export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
/** Key used to persist voice settings (input/output devices, volume). */
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings';
/** Key used to persist per-user volume overrides (0200%). */
/** Key used to persist per-user volume overrides (0-200%). */
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';
/** Regex that extracts a roomId from a `/room/:roomId` URL path. */

View File

@@ -331,6 +331,7 @@ export type ChatEventType =
| 'kick'
| 'ban'
| 'room-deleted'
| 'host-change'
| 'room-settings-update'
| 'voice-state'
| 'chat-inventory-request'
@@ -364,6 +365,14 @@ export interface ChatEvent {
targetUserId?: string;
/** Room ID the event pertains to. */
roomId?: string;
/** Updated room host ID after an ownership change. */
hostId?: string;
/** Updated room host `oderId` after an ownership change. */
hostOderId?: string;
/** Previous room host ID before the ownership change. */
previousHostId?: string;
/** Previous room host `oderId` before the ownership change. */
previousHostOderId?: string;
/** User who issued a kick. */
kickedBy?: string;
/** User who issued a ban. */

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, max-statements-per-line */
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, complexity, max-statements-per-line */
import {
Injectable,
inject,
@@ -14,8 +14,9 @@ import { DatabaseService } from './database.service';
/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */
const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB
/** Maximum file size (bytes) that is automatically saved to disk (Electron). */
const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
/** Maximum file size (bytes) that is automatically saved or pushed for inline previews. */
export const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
/**
* EWMA smoothing weight for the *previous* speed estimate.
* The complementary weight (1 this value) is applied to the
@@ -27,6 +28,10 @@ const EWMA_CURRENT_WEIGHT = 1 - EWMA_PREVIOUS_WEIGHT;
const DEFAULT_MIME_TYPE = 'application/octet-stream';
/** localStorage key used by the legacy attachment store (migration target). */
const LEGACY_STORAGE_KEY = 'metoyou_attachments';
/** User-facing error when no peers are available for a request. */
const NO_CONNECTED_PEERS_REQUEST_ERROR = 'No connected peers are available to provide this file right now.';
/** User-facing error when connected peers cannot provide a requested file. */
const FILE_NOT_FOUND_REQUEST_ERROR = 'The connected peers do not have this file right now.';
/**
* Metadata describing a file attachment linked to a chat message.
@@ -69,6 +74,8 @@ export interface Attachment extends AttachmentMeta {
startedAtMs?: number;
/** Epoch ms of the most recent chunk received. */
lastUpdateMs?: number;
/** User-facing request failure shown in the attachment card. */
requestError?: string;
}
/**
@@ -222,13 +229,19 @@ export class AttachmentService {
* @param attachment - Attachment to request.
*/
requestFromAnyPeer(messageId: string, attachment: Attachment): void {
const clearedRequestError = this.clearAttachmentRequestError(attachment);
const connectedPeers = this.webrtc.getConnectedPeers();
if (connectedPeers.length === 0) {
attachment.requestError = NO_CONNECTED_PEERS_REQUEST_ERROR;
this.touch();
console.warn('[Attachments] No connected peers to request file from');
return;
}
if (clearedRequestError)
this.touch();
const requestKey = this.buildRequestKey(messageId, attachment.id);
this.pendingRequests.set(requestKey, new Set());
@@ -246,8 +259,12 @@ export class AttachmentService {
const attachments = this.attachmentsByMessage.get(messageId) ?? [];
const attachment = attachments.find((entry) => entry.id === fileId);
const didSendRequest = this.sendFileRequestToNextPeer(messageId, fileId, attachment?.uploaderPeerId);
this.sendFileRequestToNextPeer(messageId, fileId, attachment?.uploaderPeerId);
if (!didSendRequest && attachment) {
attachment.requestError = FILE_NOT_FOUND_REQUEST_ERROR;
this.touch();
}
}
/**
@@ -269,8 +286,8 @@ export class AttachmentService {
*
* 1. Each file is assigned a UUID.
* 2. A `file-announce` event is broadcast to peers.
* 3. Images ≤ {@link MAX_AUTO_SAVE_SIZE_BYTES} are immediately
* streamed as chunked base-64.
* 3. Inline-preview media ≤ {@link MAX_AUTO_SAVE_SIZE_BYTES}
* are immediately streamed as chunked base-64.
*
* @param messageId - ID of the parent message.
* @param files - Array of user-selected `File` objects.
@@ -328,8 +345,8 @@ export class AttachmentService {
}
} as any);
// Auto-stream small images
if (attachment.isImage && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
// Auto-stream small inline-preview media
if (this.isMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
await this.streamFileToPeers(messageId, fileId, file);
}
}
@@ -401,6 +418,10 @@ export class AttachmentService {
const decodedBytes = this.base64ToUint8Array(data);
const assemblyKey = `${messageId}:${fileId}`;
const requestKey = this.buildRequestKey(messageId, fileId);
this.pendingRequests.delete(requestKey);
this.clearAttachmentRequestError(attachment);
// Initialise assembly buffer on first chunk
let chunkBuffer = this.chunkBuffers.get(assemblyKey);
@@ -453,7 +474,7 @@ export class AttachmentService {
attachment.available = true;
attachment.objectUrl = URL.createObjectURL(blob);
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
if (this.shouldPersistDownloadedAttachment(attachment)) {
void this.saveFileToDisk(attachment, blob);
}
@@ -652,6 +673,15 @@ export class AttachmentService {
return `${messageId}:${fileId}`;
}
/** Clear any user-facing request error stored on an attachment. */
private clearAttachmentRequestError(attachment: Attachment): boolean {
if (!attachment.requestError)
return false;
attachment.requestError = undefined;
return true;
}
/** Check whether a specific transfer has been cancelled. */
private isTransferCancelled(targetPeerId: string, messageId: string, fileId: string): boolean {
return this.cancelledTransfers.has(
@@ -659,9 +689,18 @@ export class AttachmentService {
);
}
/** Check whether a file is an image or video. */
/** Check whether a file is inline-previewable media. */
private isMedia(attachment: { mime: string }): boolean {
return attachment.mime.startsWith('image/') || attachment.mime.startsWith('video/');
return attachment.mime.startsWith('image/') ||
attachment.mime.startsWith('video/') ||
attachment.mime.startsWith('audio/');
}
/** Check whether a completed download should be cached on disk. */
private shouldPersistDownloadedAttachment(attachment: Attachment): boolean {
return attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES ||
attachment.mime.startsWith('video/') ||
attachment.mime.startsWith('audio/');
}
/**
@@ -822,9 +861,11 @@ export class AttachmentService {
const sanitisedRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room';
const subDirectory = attachment.mime.startsWith('video/')
? 'video'
: attachment.mime.startsWith('image/')
? 'image'
: 'files';
: attachment.mime.startsWith('audio/')
? 'audio'
: attachment.mime.startsWith('image/')
? 'image'
: 'files';
const directoryPath = `${appDataPath}/server/${sanitisedRoom}/${subDirectory}`;
await electronApi.ensureDir(directoryPath);

View File

@@ -21,7 +21,7 @@ interface ElectronAPI {
* structured command/query objects through the unified `cqrs:command` and
* `cqrs:query` channels exposed by the preload script.
*
* No initialisation IPC call is needed the database is initialised and
* No initialisation IPC call is needed - the database is initialised and
* migrations are run in main.ts before the renderer window is created.
*/
@Injectable({ providedIn: 'root' })

View File

@@ -5,6 +5,7 @@ export * from './electron-database.service';
export * from './database.service';
export * from './webrtc.service';
export * from './server-directory.service';
export * from './klipy.service';
export * from './voice-session.service';
export * from './voice-activity.service';
export * from './external-link.service';

View File

@@ -0,0 +1,200 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Injectable,
computed,
effect,
inject,
signal
} from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import {
Observable,
firstValueFrom,
throwError
} from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ServerDirectoryService } from './server-directory.service';
export interface KlipyGif {
id: string;
slug: string;
title?: string;
url: string;
previewUrl: string;
width: number;
height: number;
}
interface KlipyAvailabilityResponse {
enabled: boolean;
}
export interface KlipyGifSearchResponse {
enabled: boolean;
results: KlipyGif[];
hasNext: boolean;
}
const DEFAULT_PAGE_SIZE = 24;
const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id';
@Injectable({ providedIn: 'root' })
export class KlipyService {
private readonly http = inject(HttpClient);
private readonly serverDirectory = inject(ServerDirectoryService);
private readonly availabilityState = signal({
enabled: false,
loading: true
});
private lastAvailabilityKey = '';
readonly isEnabled = computed(() => this.availabilityState().enabled);
readonly isLoading = computed(() => this.availabilityState().loading);
constructor() {
effect(() => {
const activeServer = this.serverDirectory.activeServer();
const apiBaseUrl = this.serverDirectory.getApiBaseUrl();
const nextKey = `${activeServer?.id ?? 'default'}:${apiBaseUrl}`;
if (nextKey === this.lastAvailabilityKey)
return;
this.lastAvailabilityKey = nextKey;
void this.refreshAvailability();
});
}
async refreshAvailability(): Promise<void> {
this.availabilityState.set({ enabled: false,
loading: true });
try {
const response = await firstValueFrom(
this.http.get<KlipyAvailabilityResponse>(
`${this.serverDirectory.getApiBaseUrl()}/klipy/config`
)
);
this.availabilityState.set({
enabled: response.enabled === true,
loading: false
});
} catch {
this.availabilityState.set({ enabled: false,
loading: false });
}
}
searchGifs(
query: string,
page = 1,
perPage = DEFAULT_PAGE_SIZE
): Observable<KlipyGifSearchResponse> {
let params = new HttpParams()
.set('page', String(Math.max(1, Math.floor(page))))
.set('per_page', String(Math.max(1, Math.floor(perPage))))
.set('customer_id', this.getOrCreateCustomerId());
const trimmedQuery = query.trim();
if (trimmedQuery) {
params = params.set('q', trimmedQuery);
}
const locale = this.getPreferredLocale();
if (locale) {
params = params.set('locale', locale);
}
return this.http
.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl()}/klipy/gifs`, { params })
.pipe(
map((response) => ({
enabled: response.enabled !== false,
results: Array.isArray(response.results) ? response.results : [],
hasNext: response.hasNext === true
})),
catchError((error) =>
throwError(() => new Error(this.extractErrorMessage(error)))
)
);
}
normalizeMediaUrl(url: string): string {
const trimmed = url.trim();
if (!trimmed)
return '';
if (trimmed.startsWith('//'))
return `https:${trimmed}`;
return trimmed;
}
buildRenderableImageUrl(url: string): string {
const trimmed = this.normalizeMediaUrl(url);
if (!trimmed)
return '';
if (!/^https?:\/\//i.test(trimmed))
return trimmed;
return `${this.serverDirectory.getApiBaseUrl()}/image-proxy?url=${encodeURIComponent(trimmed)}`;
}
private getPreferredLocale(): string | null {
if (typeof navigator === 'undefined' || !navigator.language)
return null;
const locale = navigator.language.trim();
return locale || null;
}
private getOrCreateCustomerId(): string {
if (typeof window === 'undefined') {
return 'server';
}
try {
const existing = window.localStorage.getItem(KLIPY_CUSTOMER_ID_STORAGE_KEY);
if (existing?.trim())
return existing;
const created = window.crypto?.randomUUID?.()
?? `klipy-${Date.now().toString(36)}-${Math.random().toString(36)
.slice(2, 10)}`;
window.localStorage.setItem(KLIPY_CUSTOMER_ID_STORAGE_KEY, created);
return created;
} catch {
return `klipy-${Date.now().toString(36)}`;
}
}
private extractErrorMessage(error: unknown): string {
const httpError = error as {
error?: {
error?: unknown;
message?: unknown;
};
message?: unknown;
};
if (typeof httpError?.error?.error === 'string')
return httpError.error.error;
if (typeof httpError?.error?.message === 'string')
return httpError.error.message;
if (typeof httpError?.message === 'string')
return httpError.message;
return 'Failed to load GIFs from KLIPY.';
}
}

View File

@@ -309,10 +309,10 @@ export class ServerDirectoryService {
/** Update an existing server listing. */
updateServer(
serverId: string,
updates: Partial<ServerInfo>
updates: Partial<ServerInfo> & { currentOwnerId: string }
): Observable<ServerInfo> {
return this.http
.patch<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`, updates)
.put<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`, updates)
.pipe(
catchError((error) => {
console.error('Failed to update server:', error);

View File

@@ -236,8 +236,8 @@ export class VoiceActivityService implements OnDestroy {
// Compute RMS volume from time-domain data (values 0-255, centred at 128).
let sumSquares = 0;
for (let i = 0; i < dataArray.length; i++) {
const normalised = (dataArray[i] - 128) / 128;
for (let sampleIndex = 0; sampleIndex < dataArray.length; sampleIndex++) {
const normalised = (dataArray[sampleIndex] - 128) / 128;
sumSquares += normalised * normalised;
}

View File

@@ -128,6 +128,26 @@
<pre><code>{{ node.value }}</code></pre>
}
</ng-template>
<ng-template
[remarkTemplate]="'image'"
let-node
>
<div class="relative mt-2 inline-block overflow-hidden rounded-md border border-border/60 bg-secondary/20">
<img
[src]="getMarkdownImageSource(node.url)"
[alt]="node.alt || 'Shared image'"
class="block max-h-80 w-auto max-w-full"
loading="lazy"
/>
@if (isKlipyMediaUrl(node.url)) {
<span
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
>
KLIPY
</span>
}
</div>
</ng-template>
</remark>
</div>
@if (getAttachments(message.id).length > 0) {
@@ -206,7 +226,15 @@
<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
class="text-xs mt-0.5"
[class.text-destructive]="!!att.requestError"
[class.text-muted-foreground]="!att.requestError"
[class.opacity-70]="!att.requestError"
[class.italic]="!att.requestError"
>
{{ att.requestError || 'Waiting for image source…' }}
</div>
</div>
</div>
<button
@@ -217,6 +245,74 @@
</button>
</div>
}
} @else if (isVideoAttachment(att) || isAudioAttachment(att)) {
@if (att.available && att.objectUrl) {
@if (isVideoAttachment(att)) {
<app-chat-video-player
[src]="att.objectUrl"
[filename]="att.filename"
[sizeLabel]="formatBytes(att.size)"
(downloadRequested)="downloadAttachment(att)"
/>
} @else {
<app-chat-audio-player
[src]="att.objectUrl"
[filename]="att.filename"
[sizeLabel]="formatBytes(att.size)"
(downloadRequested)="downloadAttachment(att)"
/>
}
} @else if ((att.receivedBytes || 0) > 0) {
<div class="border border-border rounded-md p-3 bg-secondary/40 max-w-xl">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<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>
<button
class="px-2 py-1 text-xs bg-destructive text-destructive-foreground rounded"
(click)="cancelAttachment(att, message.id)"
>
Cancel
</button>
</div>
<div class="mt-2 h-1.5 rounded-full bg-muted overflow-hidden">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
[style.width.%]="((att.receivedBytes || 0) * 100) / att.size"
></div>
</div>
<div class="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span>{{ ((att.receivedBytes || 0) * 100) / att.size | number: '1.0-0' }}%</span>
@if (att.speedBps) {
<span>{{ formatSpeed(att.speedBps) }}</span>
}
</div>
</div>
} @else {
<div class="border border-dashed border-border rounded-md p-4 bg-secondary/20 max-w-xl">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<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="mt-1 text-xs leading-relaxed"
[class.text-destructive]="!!att.requestError"
[class.text-muted-foreground]="!att.requestError"
[class.opacity-80]="!att.requestError"
>
{{ getMediaAttachmentStatusText(att) }}
</div>
</div>
<button
(click)="requestAttachment(att, message.id)"
class="shrink-0 rounded-md bg-secondary px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary/80"
>
{{ getMediaAttachmentActionLabel(att) }}
</button>
</div>
</div>
}
} @else {
<div class="border border-border rounded-md p-2 bg-secondary/40">
<div class="flex items-center justify-between">
@@ -244,7 +340,7 @@
class="px-2 py-1 text-xs bg-secondary text-foreground rounded"
(click)="requestAttachment(att, message.id)"
>
Request
{{ att.requestError ? 'Retry' : 'Request' }}
</button>
} @else {
<button
@@ -267,6 +363,13 @@
}
</div>
</div>
@if (!att.available && att.requestError) {
<div
class="mt-2 w-full rounded-md border border-destructive/20 bg-destructive/5 px-2.5 py-1.5 text-xs leading-relaxed text-destructive"
>
{{ att.requestError }}
</div>
}
</div>
}
}
@@ -521,6 +624,43 @@
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
>
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2">
@if (klipy.isEnabled()) {
<button
type="button"
(click)="openKlipyGifPicker()"
class="inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-2xl border border-border/70 bg-secondary/55 px-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground"
[class.border-primary]="showKlipyGifPicker()"
[class.text-primary]="showKlipyGifPicker()"
[class.opacity-70]="!inputHovered() && !showKlipyGifPicker()"
[class.opacity-100]="inputHovered() || showKlipyGifPicker()"
[class.shadow-none]="!inputHovered() && !showKlipyGifPicker()"
aria-label="Search KLIPY GIFs"
title="Search KLIPY GIFs"
>
<ng-icon
name="lucideImage"
class="h-4 w-4"
/>
<span class="hidden sm:inline">GIF</span>
</button>
}
<button
type="button"
(click)="sendMessage()"
[disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()"
class="send-btn visible inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-1 ring-primary/20 transition-all duration-200 hover:-translate-y-0.5 hover:bg-primary/90 disabled:translate-y-0 disabled:cursor-not-allowed disabled:bg-secondary disabled:text-muted-foreground disabled:shadow-none disabled:ring-0"
aria-label="Send message"
title="Send message"
>
<ng-icon
name="lucideSend"
class="h-5 w-5"
/>
</button>
</div>
<textarea
#messageInputRef
rows="1"
@@ -534,24 +674,14 @@
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
placeholder="Type a message..."
class="chat-textarea w-full pl-3 pr-12 py-2 rounded-2xl border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
class="chat-textarea w-full rounded-[1.35rem] border border-border py-2 pl-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
[class.pr-16]="!klipy.isEnabled()"
[class.pr-40]="klipy.isEnabled()"
[class.border-primary]="dragActive()"
[class.border-dashed]="dragActive()"
[class.ctrl-resize]="ctrlHeld()"
></textarea>
<button
(click)="sendMessage()"
[disabled]="!messageContent.trim() && pendingFiles.length === 0"
class="send-btn absolute right-2 bottom-[15px] w-8 h-8 rounded-full bg-primary text-primary-foreground grid place-items-center hover:bg-primary/90 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
[class.visible]="inputHovered() || messageContent.trim().length > 0"
>
<ng-icon
name="lucideSend"
class="w-4 h-4"
/>
</button>
@if (dragActive()) {
<div
class="pointer-events-none absolute inset-0 rounded-2xl border-2 border-primary border-dashed bg-primary/5 flex items-center justify-center"
@@ -560,6 +690,39 @@
</div>
}
@if (pendingKlipyGif()) {
<div class="mt-2 flex">
<div class="group flex max-w-sm items-center gap-3 rounded-xl border border-border bg-secondary/60 px-2.5 py-2">
<div class="relative h-12 w-12 overflow-hidden rounded-lg bg-secondary/80">
<img
[src]="getPendingKlipyGifPreviewUrl()"
[alt]="pendingKlipyGif()!.title || 'KLIPY GIF'"
class="h-full w-full object-cover"
loading="lazy"
/>
<span
class="absolute bottom-1 left-1 rounded bg-black/70 px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-[0.18em] text-white/90"
>
KLIPY
</span>
</div>
<div class="min-w-0">
<div class="text-xs font-medium text-foreground">GIF ready to send</div>
<div class="max-w-[12rem] truncate text-[10px] text-muted-foreground">
{{ pendingKlipyGif()!.title || 'KLIPY GIF' }}
</div>
</div>
<button
type="button"
(click)="removePendingKlipyGif()"
class="rounded px-2 py-1 text-[10px] text-destructive transition-colors hover:bg-destructive/10"
>
Remove
</button>
</div>
</div>
}
@if (pendingFiles.length > 0) {
<div class="mt-2 flex flex-wrap gap-2">
@for (file of pendingFiles; track file.name) {
@@ -580,6 +743,13 @@
</div>
</div>
@if (showKlipyGifPicker() && klipy.isEnabled()) {
<app-klipy-gif-picker
(gifSelected)="handleKlipyGifSelected($event)"
(closed)="closeKlipyGifPicker()"
/>
}
<!-- Image Lightbox Modal -->
@if (lightboxAttachment()) {
<div

View File

@@ -15,7 +15,12 @@ import {
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AttachmentService, Attachment } from '../../../core/services/attachment.service';
import {
AttachmentService,
Attachment,
MAX_AUTO_SAVE_SIZE_BYTES
} from '../../../core/services/attachment.service';
import { KlipyGif, KlipyService } from '../../../core/services/klipy.service';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideSend,
@@ -42,8 +47,13 @@ import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/user
import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors';
import { Message } from '../../../core/models';
import { WebRTCService } from '../../../core/services/webrtc.service';
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
import { ContextMenuComponent, UserAvatarComponent } from '../../../shared';
import {
ChatAudioPlayerComponent,
ChatVideoPlayerComponent,
ContextMenuComponent,
UserAvatarComponent
} from '../../../shared';
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
import { TypingIndicatorComponent } from '../typing-indicator/typing-indicator.component';
import { RemarkModule, MermaidComponent } from 'ngx-remark';
import remarkGfm from 'remark-gfm';
@@ -70,8 +80,11 @@ const COMMON_EMOJIS = [
CommonModule,
FormsModule,
NgIcon,
ChatAudioPlayerComponent,
ChatVideoPlayerComponent,
ContextMenuComponent,
UserAvatarComponent,
KlipyGifPickerComponent,
TypingIndicatorComponent,
RemarkModule,
MermaidComponent
@@ -109,8 +122,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
@ViewChild('bottomBar') bottomBar!: ElementRef;
private store = inject(Store);
readonly klipy = inject(KlipyService);
private webrtc = inject(WebRTCService);
private serverDirectory = inject(ServerDirectoryService);
private attachmentsSvc = inject(AttachmentService);
private cdr = inject(ChangeDetectorRef);
private markdown = inject(ChatMarkdownService);
@@ -163,6 +176,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
editingMessageId = signal<string | null>(null);
replyTo = signal<Message | null>(null);
showEmojiPicker = signal<string | null>(null);
pendingKlipyGif = signal<KlipyGif | null>(null);
showKlipyGifPicker = signal(false);
readonly commonEmojis = COMMON_EMOJIS;
@@ -327,10 +342,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
sendMessage(): void {
const raw = this.messageContent.trim();
if (!raw && this.pendingFiles.length === 0)
if (!raw && this.pendingFiles.length === 0 && !this.pendingKlipyGif())
return;
const content = this.markdown.appendImageMarkdown(raw);
const content = this.buildOutgoingMessageContent(raw);
this.store.dispatch(
MessagesActions.sendMessage({
@@ -341,6 +356,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
);
this.messageContent = '';
this.pendingKlipyGif.set(null);
this.clearReply();
this.shouldScrollToBottom = true;
// Reset textarea height after sending
@@ -764,6 +780,51 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.setSelection(result.selectionStart, result.selectionEnd);
}
openKlipyGifPicker(): void {
if (!this.klipy.isEnabled())
return;
this.showKlipyGifPicker.set(true);
}
closeKlipyGifPicker(): void {
this.showKlipyGifPicker.set(false);
}
handleKlipyGifSelected(gif: KlipyGif): void {
this.pendingKlipyGif.set(gif);
this.closeKlipyGifPicker();
if (!this.messageContent.trim() && this.pendingFiles.length === 0) {
this.sendMessage();
return;
}
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
}
removePendingKlipyGif(): void {
this.pendingKlipyGif.set(null);
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
}
getPendingKlipyGifPreviewUrl(): string {
const gif = this.pendingKlipyGif();
return gif ? this.klipy.buildRenderableImageUrl(gif.previewUrl || gif.url) : '';
}
getMarkdownImageSource(url?: string): string {
return url ? this.klipy.buildRenderableImageUrl(url) : '';
}
isKlipyMediaUrl(url?: string): boolean {
if (!url)
return false;
return /^(?:https?:)?\/\/(?:[^/]+\.)?klipy\.com/i.test(url);
}
/** Handle drag-enter to activate the drop zone overlay. */
// Attachments: drag/drop and rendering
onDragEnter(evt: DragEvent): void {
@@ -945,6 +1006,47 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[i]}`;
}
/** Whether an attachment can be played inline as video. */
isVideoAttachment(att: Attachment): boolean {
return att.mime.startsWith('video/');
}
/** Whether an attachment can be played inline as audio. */
isAudioAttachment(att: Attachment): boolean {
return att.mime.startsWith('audio/');
}
/** Whether the user must explicitly accept a media download before playback. */
requiresMediaDownloadAcceptance(att: Attachment): boolean {
return (this.isVideoAttachment(att) || this.isAudioAttachment(att)) &&
att.size > MAX_AUTO_SAVE_SIZE_BYTES;
}
/** User-facing status copy for an unavailable audio/video attachment. */
getMediaAttachmentStatusText(att: Attachment): string {
if (att.requestError)
return att.requestError;
if (this.requiresMediaDownloadAcceptance(att)) {
return this.isVideoAttachment(att)
? 'Large video. Accept the download to watch it in chat.'
: 'Large audio file. Accept the download to play it in chat.';
}
return this.isVideoAttachment(att)
? 'Waiting for video source…'
: 'Waiting for audio source…';
}
/** Action label for requesting an audio/video attachment. */
getMediaAttachmentActionLabel(att: Attachment): string {
if (this.requiresMediaDownloadAcceptance(att)) {
return att.requestError ? 'Retry download' : 'Accept download';
}
return att.requestError ? 'Retry' : 'Request';
}
/** Remove a pending file from the upload queue. */
removePendingFile(file: File): void {
const idx = this.pendingFiles.findIndex((pending) => pending === file);
@@ -955,10 +1057,33 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
}
/** Download a completed attachment to the user's device. */
downloadAttachment(att: Attachment): void {
async downloadAttachment(att: Attachment): Promise<void> {
if (!att.available || !att.objectUrl)
return;
const electronApi = (window as any)?.electronAPI as {
saveFileAs?: (
defaultFileName: string,
data: string
) => Promise<{ saved: boolean; cancelled: boolean }>;
} | undefined;
if (electronApi?.saveFileAs) {
const blob = await this.getAttachmentBlob(att);
if (blob) {
try {
const result = await electronApi.saveFileAs(
att.filename,
await this.blobToBase64(blob)
);
if (result.saved || result.cancelled)
return;
} catch { /* fall back to browser download */ }
}
}
const a = document.createElement('a');
a.href = att.objectUrl;
@@ -968,6 +1093,39 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
document.body.removeChild(a);
}
private async getAttachmentBlob(att: Attachment): Promise<Blob | null> {
if (!att.objectUrl)
return null;
try {
const response = await fetch(att.objectUrl);
return await response.blob();
} catch {
return null;
}
}
private blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('Failed to encode attachment'));
return;
}
const [, base64 = ''] = reader.result.split(',', 2);
resolve(base64);
};
reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment'));
reader.readAsDataURL(blob);
});
}
/** Request a file attachment to be transferred from the uploader peer. */
requestAttachment(att: Attachment, messageId: string): void {
this.attachmentsSvc.requestFile(messageId, att);
@@ -1093,6 +1251,24 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.pendingFiles = [];
}
private buildOutgoingMessageContent(raw: string): string {
const withEmbeddedImages = this.markdown.appendImageMarkdown(raw);
const gif = this.pendingKlipyGif();
if (!gif)
return withEmbeddedImages;
const gifMarkdown = this.buildKlipyGifMarkdown(gif);
return withEmbeddedImages
? `${withEmbeddedImages}\n${gifMarkdown}`
: gifMarkdown;
}
private buildKlipyGifMarkdown(gif: KlipyGif): string {
return `![KLIPY GIF](${this.klipy.normalizeMediaUrl(gif.url)})`;
}
/** Auto-resize the textarea to fit its content up to 520px, then allow scrolling. */
autoResizeTextarea(): void {
const el = this.messageInputRef?.nativeElement;

View File

@@ -0,0 +1,144 @@
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
<div
class="fixed inset-0 z-[94] bg-black/70 backdrop-blur-sm"
(click)="close()"
(keydown.enter)="close()"
(keydown.space)="close()"
role="button"
tabindex="0"
aria-label="Close GIF picker"
></div>
<div class="fixed inset-0 z-[95] flex items-center justify-center p-4 pointer-events-none">
<div
class="pointer-events-auto flex h-[min(80vh,48rem)] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
role="dialog"
aria-modal="true"
aria-label="KLIPY GIF picker"
>
<div class="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">KLIPY</div>
<h3 class="mt-1 text-lg font-semibold text-foreground">Choose a GIF</h3>
<p class="mt-1 text-sm text-muted-foreground">
{{ searchQuery.trim() ? 'Search results from KLIPY.' : 'Trending GIFs from KLIPY.' }}
</p>
</div>
<button
type="button"
(click)="close()"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-border bg-secondary/30 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Close GIF picker"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
</div>
<div class="border-b border-border px-5 py-4">
<label class="relative block">
<ng-icon
name="lucideSearch"
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<input
#searchInput
type="text"
[ngModel]="searchQuery"
(ngModelChange)="onSearchQueryChanged($event)"
placeholder="Search KLIPY"
class="w-full rounded-xl border border-border bg-background px-10 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</label>
</div>
<div class="flex-1 overflow-y-auto px-5 py-4">
@if (errorMessage()) {
<div
class="mb-4 flex items-center justify-between gap-3 rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive"
>
<span>{{ errorMessage() }}</span>
<button
type="button"
(click)="retry()"
class="rounded-lg bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
>
Retry
</button>
</div>
}
@if (loading() && results().length === 0) {
<div class="flex h-full min-h-56 flex-col items-center justify-center gap-3 text-muted-foreground">
<span class="h-6 w-6 animate-spin rounded-full border-2 border-primary/20 border-t-primary"></span>
<p class="text-sm">Loading GIFs from KLIPY…</p>
</div>
} @else if (results().length === 0) {
<div
class="flex h-full min-h-56 flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border bg-secondary/10 px-6 text-center text-muted-foreground"
>
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<ng-icon
name="lucideImage"
class="h-5 w-5"
/>
</div>
<div>
<p class="text-sm font-medium text-foreground">No GIFs found</p>
<p class="mt-1 text-sm">Try another search term or clear the search to browse trending GIFs.</p>
</div>
</div>
} @else {
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4">
@for (gif of results(); track gif.id) {
<button
type="button"
(click)="selectGif(gif)"
class="group overflow-hidden rounded-2xl border border-border bg-secondary/10 text-left transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30"
>
<div
class="relative overflow-hidden bg-secondary/30"
[style.aspect-ratio]="gifAspectRatio(gif)"
>
<img
[src]="gifPreviewUrl(gif)"
[alt]="gif.title || 'KLIPY GIF'"
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.03]"
loading="lazy"
/>
<span
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
>
KLIPY
</span>
</div>
<div class="px-3 py-2">
<p class="truncate text-xs font-medium text-foreground">
{{ gif.title || 'KLIPY GIF' }}
</p>
<p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">Click to select</p>
</div>
</button>
}
</div>
}
</div>
<div class="flex items-center justify-between gap-4 border-t border-border px-5 py-4">
<p class="text-xs text-muted-foreground">Click a GIF to select it. Powered by KLIPY.</p>
@if (hasNext()) {
<button
type="button"
(click)="loadMore()"
[disabled]="loading()"
class="rounded-full border border-border px-4 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
{{ loading() ? 'Loading…' : 'Load more' }}
</button>
}
</div>
</div>
</div>

View File

@@ -0,0 +1,187 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
AfterViewInit,
Component,
ElementRef,
HostListener,
OnDestroy,
OnInit,
ViewChild,
inject,
output,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideImage,
lucideSearch,
lucideX
} from '@ng-icons/lucide';
import { KlipyGif, KlipyService } from '../../../core/services/klipy.service';
@Component({
selector: 'app-klipy-gif-picker',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [
provideIcons({
lucideImage,
lucideSearch,
lucideX
})
],
templateUrl: './klipy-gif-picker.component.html'
})
export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy {
readonly gifSelected = output<KlipyGif>();
readonly closed = output<undefined>();
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
private readonly klipy = inject(KlipyService);
private currentPage = 1;
private searchTimer: ReturnType<typeof setTimeout> | null = null;
private requestId = 0;
searchQuery = '';
results = signal<KlipyGif[]>([]);
loading = signal(false);
errorMessage = signal('');
hasNext = signal(false);
ngOnInit(): void {
void this.loadResults(true);
}
ngAfterViewInit(): void {
requestAnimationFrame(() => {
this.searchInput?.nativeElement.focus();
this.searchInput?.nativeElement.select();
});
}
ngOnDestroy(): void {
this.clearSearchTimer();
}
@HostListener('document:keydown.escape')
onEscape(): void {
this.close();
}
onSearchQueryChanged(query: string): void {
this.searchQuery = query;
this.clearSearchTimer();
this.searchTimer = setTimeout(() => {
void this.loadResults(true);
}, 250);
}
retry(): void {
void this.loadResults(true);
}
async loadMore(): Promise<void> {
if (this.loading() || !this.hasNext())
return;
this.currentPage += 1;
await this.loadResults(false);
}
selectGif(gif: KlipyGif): void {
this.gifSelected.emit(gif);
}
close(): void {
this.closed.emit(undefined);
}
gifAspectRatio(gif: KlipyGif): string {
if (gif.width > 0 && gif.height > 0) {
return `${gif.width} / ${gif.height}`;
}
return '1 / 1';
}
gifPreviewUrl(gif: KlipyGif): string {
return this.klipy.buildRenderableImageUrl(gif.previewUrl || gif.url);
}
private async loadResults(reset: boolean): Promise<void> {
if (reset) {
this.currentPage = 1;
}
const requestId = ++this.requestId;
this.loading.set(true);
this.errorMessage.set('');
try {
const response = await firstValueFrom(
this.klipy.searchGifs(this.searchQuery, this.currentPage)
);
if (requestId !== this.requestId)
return;
this.results.set(
reset
? response.results
: this.mergeResults(this.results(), response.results)
);
this.hasNext.set(response.hasNext);
} catch (error) {
if (requestId !== this.requestId)
return;
this.errorMessage.set(
error instanceof Error
? error.message
: 'Failed to load GIFs from KLIPY.'
);
if (reset) {
this.results.set([]);
}
this.hasNext.set(false);
} finally {
if (requestId === this.requestId) {
this.loading.set(false);
}
}
}
private mergeResults(existing: KlipyGif[], incoming: KlipyGif[]): KlipyGif[] {
const seen = new Set(existing.map((gif) => gif.id));
const merged = [...existing];
for (const gif of incoming) {
if (seen.has(gif.id))
continue;
merged.push(gif);
seen.add(gif.id);
}
return merged;
}
private clearSearchTimer(): void {
if (this.searchTimer) {
clearTimeout(this.searchTimer);
this.searchTimer = null;
}
}
}

View File

@@ -38,7 +38,11 @@ import { SettingsModalService } from '../../core/services/settings-modal.service
@Component({
selector: 'app-server-search',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon],
imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [
provideIcons({
lucideSearch,

View File

@@ -46,36 +46,21 @@
(closed)="closeMenu()"
[width]="'w-44'"
>
@if (isCurrentContextRoom()) {
<button
type="button"
(click)="leaveServer()"
class="context-menu-item"
>
Leave Server
</button>
}
<button
type="button"
(click)="openForgetConfirm()"
(click)="openLeaveConfirm()"
class="context-menu-item"
>
Forget Server
Leave Server
</button>
</app-context-menu>
}
<!-- Forget confirmation dialog -->
@if (showConfirm()) {
<app-confirm-dialog
title="Forget Server?"
confirmLabel="Forget"
(confirmed)="confirmForget()"
(cancelled)="cancelForget()"
[widthClass]="'w-[280px]'"
>
<p>
Remove <span class="font-medium text-foreground">{{ contextRoom()?.name }}</span> from your My Servers list.
</p>
</app-confirm-dialog>
@if (showLeaveConfirm() && contextRoom()) {
<app-leave-server-dialog
[room]="contextRoom()!"
[currentUser]="currentUser() ?? null"
(confirmed)="confirmLeave($event)"
(cancelled)="cancelLeave()"
/>
}

View File

@@ -12,15 +12,22 @@ import { lucidePlus } from '@ng-icons/lucide';
import { Room } from '../../core/models';
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../store/users/users.selectors';
import { VoiceSessionService } from '../../core/services/voice-session.service';
import { WebRTCService } from '../../core/services/webrtc.service';
import { RoomsActions } from '../../store/rooms/rooms.actions';
import { ContextMenuComponent, ConfirmDialogComponent } from '../../shared';
import { ContextMenuComponent, LeaveServerDialogComponent } from '../../shared';
@Component({
selector: 'app-servers-rail',
standalone: true,
imports: [CommonModule, NgIcon, ContextMenuComponent, ConfirmDialogComponent, NgOptimizedImage],
imports: [
CommonModule,
NgIcon,
ContextMenuComponent,
LeaveServerDialogComponent,
NgOptimizedImage
],
viewProviders: [provideIcons({ lucidePlus })],
templateUrl: './servers-rail.component.html'
})
@@ -40,8 +47,8 @@ export class ServersRailComponent {
menuX = signal(72); // default X: rail width (~64px) + padding
menuY = signal(100); // default Y: arbitrary initial offset
contextRoom = signal<Room | null>(null);
// Confirmation dialog state
showConfirm = signal(false);
showLeaveConfirm = signal(false);
currentUser = this.store.selectSignal(selectCurrentUser);
/** Return the first character of a server name as its icon initial. */
initial(name?: string): string {
@@ -133,38 +140,39 @@ export class ServersRailComponent {
return !!ctx && !!cur && ctx.id === cur.id;
}
/** Leave the current server and navigate to the servers list. */
leaveServer(): void {
/** Open the unified leave-server confirmation dialog. */
openLeaveConfirm(): void {
this.closeMenu();
this.store.dispatch(RoomsActions.leaveRoom());
window.dispatchEvent(new CustomEvent('navigate:servers'));
if (this.contextRoom()) {
this.showLeaveConfirm.set(true);
}
}
/** Show the forget-server confirmation dialog. */
openForgetConfirm(): void {
this.showConfirm.set(true);
this.closeMenu();
}
/** Forget (remove) a server from the saved list, leaving if it is the current room. */
confirmForget(): void {
/** Confirm the merged leave flow and remove the server locally. */
confirmLeave(result: { nextOwnerKey?: string }): void {
const ctx = this.contextRoom();
if (!ctx)
return;
if (this.currentRoom()?.id === ctx.id) {
this.store.dispatch(RoomsActions.leaveRoom());
window.dispatchEvent(new CustomEvent('navigate:servers'));
const isCurrentRoom = this.currentRoom()?.id === ctx.id;
this.store.dispatch(RoomsActions.forgetRoom({
roomId: ctx.id,
nextOwnerKey: result.nextOwnerKey
}));
if (isCurrentRoom) {
this.router.navigate(['/search']);
}
this.store.dispatch(RoomsActions.forgetRoom({ roomId: ctx.id }));
this.showConfirm.set(false);
this.showLeaveConfirm.set(false);
this.contextRoom.set(null);
}
/** Cancel the forget-server confirmation dialog. */
cancelForget(): void {
this.showConfirm.set(false);
/** Cancel the leave-server confirmation dialog. */
cancelLeave(): void {
this.showLeaveConfirm.set(false);
}
}

View File

@@ -1,3 +1,4 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
@if (isOpen()) {
<!-- Backdrop -->
<div
@@ -15,7 +16,7 @@
<!-- Modal -->
<div class="fixed inset-0 z-[91] flex items-center justify-center p-4 pointer-events-none">
<div
class="pointer-events-auto bg-card border border-border rounded-xl shadow-2xl w-full max-w-4xl h-[min(680px,85vh)] flex overflow-hidden transition-all duration-200"
class="pointer-events-auto relative bg-card border border-border rounded-xl shadow-2xl w-full max-w-4xl h-[min(680px,85vh)] flex overflow-hidden transition-all duration-200"
[class.scale-100]="animating()"
[class.opacity-100]="animating()"
[class.scale-95]="!animating()"
@@ -97,6 +98,16 @@
</div>
}
</div>
<div class="mt-auto border-t border-border px-4 py-3">
<button
type="button"
(click)="openThirdPartyLicenses()"
class="text-left text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline underline-offset-4"
>
Third-party licenses
</button>
</div>
</nav>
<!-- Content -->
@@ -174,6 +185,68 @@
}
</div>
</div>
@if (showThirdPartyLicenses()) {
<div
class="absolute inset-0 z-10 bg-background/70 backdrop-blur-sm"
(click)="closeThirdPartyLicenses()"
(keydown.enter)="closeThirdPartyLicenses()"
(keydown.space)="closeThirdPartyLicenses()"
role="button"
tabindex="0"
aria-label="Close third-party licenses"
></div>
<div class="pointer-events-none absolute inset-0 z-[11] flex items-center justify-center p-4 sm:p-6">
<div class="pointer-events-auto w-full max-w-2xl max-h-full overflow-hidden rounded-xl border border-border bg-card shadow-2xl">
<div class="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<h4 class="text-base font-semibold text-foreground">Third-party licenses</h4>
<p class="mt-1 text-sm text-muted-foreground">License notices for bundled third-party libraries used by the app.</p>
</div>
<button
type="button"
(click)="closeThirdPartyLicenses()"
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
aria-label="Close third-party licenses"
>
<ng-icon
name="lucideX"
class="w-5 h-5"
/>
</button>
</div>
<div class="max-h-[min(70vh,42rem)] overflow-y-auto px-5 py-4 space-y-4">
@for (license of thirdPartyLicenses; track license.id) {
<section class="rounded-lg border border-border bg-secondary/20 p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h5 class="text-sm font-semibold text-foreground">{{ license.name }}</h5>
<p class="text-xs text-muted-foreground">{{ license.licenseName }}</p>
</div>
<a
[href]="license.sourceUrl"
target="_blank"
rel="noopener noreferrer"
class="text-xs font-medium text-primary hover:underline underline-offset-4"
>
Source
</a>
</div>
<pre
class="mt-4 whitespace-pre-wrap break-words rounded-md bg-background/80 px-3 py-2 text-[11px] leading-5 text-muted-foreground"
>{{ license.text }}</pre
>
</section>
}
</div>
</div>
</div>
}
</div>
</div>
}

View File

@@ -33,6 +33,7 @@ import { ServerSettingsComponent } from './server-settings/server-settings.compo
import { MembersSettingsComponent } from './members-settings/members-settings.component';
import { BansSettingsComponent } from './bans-settings/bans-settings.component';
import { PermissionsSettingsComponent } from './permissions-settings/permissions-settings.component';
import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses';
@Component({
selector: 'app-settings-modal',
@@ -64,6 +65,7 @@ import { PermissionsSettingsComponent } from './permissions-settings/permissions
export class SettingsModalComponent {
readonly modal = inject(SettingsModalService);
private store = inject(Store);
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
@@ -80,7 +82,8 @@ export class SettingsModalComponent {
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
{ id: 'network',
label: 'Network',
icon: 'lucideGlobe' }, { id: 'voice',
icon: 'lucideGlobe' },
{ id: 'voice',
label: 'Voice & Audio',
icon: 'lucideAudioLines' }
];
@@ -128,6 +131,7 @@ export class SettingsModalComponent {
// Animation
animating = signal(false);
showThirdPartyLicenses = signal(false);
constructor() {
// Sync selected server when modal opens with a target
@@ -164,6 +168,11 @@ export class SettingsModalComponent {
}
@HostListener('document:keydown.escape')
onEscapeKey(): void {
if (this.showThirdPartyLicenses()) {
this.closeThirdPartyLicenses();
return;
}
if (this.isOpen()) {
this.close();
}
@@ -171,10 +180,19 @@ export class SettingsModalComponent {
// ===== MODAL CONTROLS =====
close(): void {
this.showThirdPartyLicenses.set(false);
this.animating.set(false);
setTimeout(() => this.modal.close(), 200);
}
openThirdPartyLicenses(): void {
this.showThirdPartyLicenses.set(true);
}
closeThirdPartyLicenses(): void {
this.showThirdPartyLicenses.set(false);
}
navigate(page: SettingsPage): void {
this.modal.navigate(page);
}

View File

@@ -0,0 +1,44 @@
export interface ThirdPartyLicense {
id: string;
name: string;
licenseName: string;
sourceUrl: string;
text: string;
}
export const THIRD_PARTY_LICENSES: ThirdPartyLicense[] = [
{
id: 'wavesurfer-js',
name: 'wavesurfer.js',
licenseName: 'BSD 3-Clause License',
sourceUrl: 'https://github.com/katspaugh/wavesurfer.js/blob/main/LICENSE',
text: [
'BSD 3-Clause License',
'',
'Copyright (c) 2012-2023, katspaugh and contributors',
'All rights reserved.',
'',
'Redistribution and use in source and binary forms, with or without modification, are permitted provided',
'that the following conditions are met:',
'',
'* Redistributions of source code must retain the above copyright notice, this list of conditions and',
' the following disclaimer.',
'',
'* Redistributions in binary form must reproduce the above copyright notice, this list of conditions',
' and the following disclaimer in the documentation and/or other materials provided with the',
' distribution.',
'',
'* Neither the name of the copyright holder nor the names of its contributors may be used to endorse',
' or promote products derived from this software without specific prior written permission.',
'',
'THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR',
'IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND',
'FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR',
'CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL',
'DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,',
'DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER',
'IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT',
'OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.'
].join('\n')
}
];

View File

@@ -134,3 +134,12 @@
style="-webkit-app-region: no-drag"
></div>
}
@if (showLeaveConfirm() && currentRoom()) {
<app-leave-server-dialog
[room]="currentRoom()!"
[currentUser]="currentUser() ?? null"
(confirmed)="confirmLeave($event)"
(cancelled)="cancelLeave()"
/>
}

View File

@@ -24,11 +24,16 @@ import { ServerDirectoryService } from '../../core/services/server-directory.ser
import { WebRTCService } from '../../core/services/webrtc.service';
import { PlatformService } from '../../core/services/platform.service';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
import { LeaveServerDialogComponent } from '../../shared';
@Component({
selector: 'app-title-bar',
standalone: true,
imports: [CommonModule, NgIcon],
imports: [
CommonModule,
NgIcon,
LeaveServerDialogComponent
],
viewProviders: [
provideIcons({ lucideMinus,
lucideSquare,
@@ -52,18 +57,19 @@ export class TitleBarComponent {
isElectron = computed(() => this.platform.isElectron);
showMenuState = computed(() => false);
private currentUserSig = this.store.selectSignal(selectCurrentUser);
username = computed(() => this.currentUserSig()?.displayName || 'Guest');
currentUser = this.store.selectSignal(selectCurrentUser);
username = computed(() => this.currentUser()?.displayName || 'Guest');
serverName = computed(() => this.serverDirectory.activeServer()?.name || 'No Server');
isConnected = computed(() => this.webrtc.isConnected());
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
isAuthed = computed(() => !!this.currentUserSig());
private currentRoomSig = this.store.selectSignal(selectCurrentRoom);
inRoom = computed(() => !!this.currentRoomSig());
roomName = computed(() => this.currentRoomSig()?.name || '');
roomDescription = computed(() => this.currentRoomSig()?.description || '');
isAuthed = computed(() => !!this.currentUser());
currentRoom = this.store.selectSignal(selectCurrentRoom);
inRoom = computed(() => !!this.currentRoom());
roomName = computed(() => this.currentRoom()?.name || '');
roomDescription = computed(() => this.currentRoom()?.description || '');
private _showMenu = signal(false);
showMenu = computed(() => this._showMenu());
showLeaveConfirm = signal(false);
/** Minimize the Electron window. */
minimize() {
@@ -94,11 +100,18 @@ export class TitleBarComponent {
this.router.navigate(['/login']);
}
/** Open the unified leave-server confirmation dialog. */
private openLeaveConfirm() {
this._showMenu.set(false);
if (this.currentRoom()) {
this.showLeaveConfirm.set(true);
}
}
/** Leave the current room and navigate back to the server search. */
onBack() {
// Leave room to ensure header switches to user/server view
this.store.dispatch(RoomsActions.leaveRoom());
this.router.navigate(['/search']);
this.openLeaveConfirm();
}
/** Toggle the server dropdown menu. */
@@ -108,9 +121,29 @@ export class TitleBarComponent {
/** Leave the current server and navigate to the servers list. */
leaveServer() {
this._showMenu.set(false);
this.store.dispatch(RoomsActions.leaveRoom());
window.dispatchEvent(new CustomEvent('navigate:servers'));
this.openLeaveConfirm();
}
/** Confirm the unified leave action and remove the server locally. */
confirmLeave(result: { nextOwnerKey?: string }) {
const roomId = this.currentRoom()?.id;
this.showLeaveConfirm.set(false);
if (!roomId)
return;
this.store.dispatch(RoomsActions.forgetRoom({
roomId,
nextOwnerKey: result.nextOwnerKey
}));
this.router.navigate(['/search']);
}
/** Cancel the leave-server confirmation dialog. */
cancelLeave() {
this.showLeaveConfirm.set(false);
}
/** Close the server dropdown menu. */

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, complexity */
import {
Component,
inject,

View File

@@ -0,0 +1,129 @@
<div class="audio-player-shell">
<audio
#audioEl
[src]="src()"
preload="metadata"
(ended)="onPause()"
(loadedmetadata)="onLoadedMetadata()"
(pause)="onPause()"
(play)="onPlay()"
(timeupdate)="onTimeUpdate()"
(volumechange)="onVolumeChange()"
></audio>
<div class="audio-top-bar">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-foreground">{{ filename() }}</div>
@if (sizeLabel()) {
<div class="text-xs text-muted-foreground">{{ sizeLabel() }}</div>
}
</div>
<button
type="button"
(click)="requestDownload()"
class="audio-control-btn"
title="Save to folder"
aria-label="Save audio to folder"
>
<ng-icon
name="lucideDownload"
class="w-4 h-4"
/>
</button>
</div>
<div class="audio-body">
<button
type="button"
(click)="togglePlayback()"
class="audio-play-btn"
[title]="isPlaying() ? 'Pause' : 'Play'"
[attr.aria-label]="isPlaying() ? 'Pause audio' : 'Play audio'"
>
<ng-icon
[name]="isPlaying() ? 'lucidePause' : 'lucidePlay'"
class="w-5 h-5"
/>
</button>
<div class="audio-main">
<div
class="audio-waveform-panel"
[class.expanded]="waveformExpanded()"
[attr.aria-hidden]="!waveformExpanded()"
>
<div class="audio-waveform-shell">
<div
#waveformContainer
class="audio-waveform-container"
[class.invisible]="waveformLoading() || waveformUnavailable()"
></div>
@if (waveformLoading()) {
<div class="audio-waveform-overlay text-muted-foreground">Loading waveform…</div>
} @else if (waveformUnavailable()) {
<div class="audio-waveform-overlay text-muted-foreground">
Couldnt render a waveform preview for this file, but playback still works.
</div>
}
</div>
</div>
<input
type="range"
min="0"
[max]="durationSeconds() || 0"
[value]="currentTimeSeconds()"
(input)="onSeek($event)"
class="seek-slider"
[style.background]="seekTrackBackground()"
aria-label="Seek audio"
/>
<div class="audio-controls-row">
<span class="audio-time-label"> {{ formatTime(currentTimeSeconds()) }} / {{ formatTime(durationSeconds()) }} </span>
<div class="audio-actions-group">
<div class="audio-volume-group">
<button
type="button"
(click)="toggleMute()"
class="audio-control-btn"
[title]="isMuted() ? 'Unmute' : 'Mute'"
[attr.aria-label]="isMuted() ? 'Unmute audio' : 'Mute audio'"
>
<ng-icon
[name]="isMuted() ? 'lucideVolumeX' : 'lucideVolume2'"
class="w-4 h-4"
/>
</button>
<input
type="range"
min="0"
max="100"
[value]="displayVolumePercent()"
(input)="onVolumeInput($event)"
class="volume-slider"
[style.background]="volumeTrackBackground()"
aria-label="Audio volume"
/>
</div>
<button
type="button"
(click)="toggleWaveform()"
class="audio-control-btn"
[title]="waveformExpanded() ? 'Hide waveform' : 'Show waveform'"
[attr.aria-label]="waveformExpanded() ? 'Hide waveform' : 'Show waveform'"
>
<ng-icon
[name]="waveformToggleIcon()"
class="w-4 h-4"
/>
</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,255 @@
:host {
display: block;
max-width: 40rem;
}
.audio-player-shell {
overflow: hidden;
border: 1px solid hsl(var(--border));
border-radius: calc(var(--radius) + 2px);
background:
radial-gradient(circle at top left, hsl(var(--primary) / 0.14), transparent 42%),
linear-gradient(180deg, hsl(var(--card)) 0%, hsl(var(--secondary) / 0.55) 100%);
box-shadow: 0 10px 28px rgb(0 0 0 / 18%);
}
.audio-top-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.875rem 0.875rem 0.625rem;
}
.audio-body {
display: flex;
align-items: stretch;
gap: 0.875rem;
padding: 0 0.875rem 0.875rem;
}
.audio-play-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.25rem;
height: 3.25rem;
min-width: 3.25rem;
border: 1px solid hsl(var(--primary) / 0.35);
border-radius: 9999px;
color: hsl(var(--primary-foreground));
background: linear-gradient(180deg, hsl(var(--primary)) 0%, hsl(var(--primary) / 0.76) 100%);
box-shadow: 0 10px 22px rgb(0 0 0 / 16%);
transition:
transform 0.16s ease,
filter 0.16s ease;
}
.audio-play-btn:hover {
transform: translateY(-1px);
filter: brightness(1.04);
}
.audio-main {
min-width: 0;
flex: 1;
}
.audio-waveform-panel {
max-height: 0;
opacity: 0;
overflow: hidden;
transition:
max-height 0.2s ease,
opacity 0.2s ease,
margin-bottom 0.2s ease;
}
.audio-waveform-panel.expanded {
max-height: 5.5rem;
opacity: 1;
margin-bottom: 0.75rem;
}
.audio-waveform-shell {
position: relative;
overflow: hidden;
height: 4.5rem;
padding: 0.5rem 0.625rem;
border: 1px solid hsl(var(--border) / 0.8);
border-radius: 0.95rem;
background:
linear-gradient(180deg, hsl(var(--background) / 0.72) 0%, hsl(var(--secondary) / 0.28) 100%);
}
.audio-waveform-container {
width: 100%;
height: 100%;
}
.audio-waveform-container.invisible {
opacity: 0;
}
.audio-waveform-container ::part(wrapper) {
cursor: pointer;
}
.audio-waveform-container ::part(cursor) {
display: none;
}
.audio-waveform-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0.75rem;
text-align: center;
font-size: 0.6875rem;
line-height: 1.35;
pointer-events: none;
}
.audio-controls-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-top: 0.625rem;
}
.audio-time-label {
color: hsl(var(--muted-foreground));
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.audio-actions-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.audio-volume-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.audio-control-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border: 1px solid hsl(var(--border) / 0.8);
border-radius: 9999px;
color: hsl(var(--foreground));
background: hsl(var(--card) / 0.72);
transition:
background-color 0.16s ease,
border-color 0.16s ease,
transform 0.16s ease;
}
.audio-control-btn:hover {
border-color: hsl(var(--primary) / 0.75);
background: hsl(var(--primary) / 0.14);
transform: translateY(-1px);
}
.seek-slider,
.volume-slider {
-webkit-appearance: none;
appearance: none;
outline: none;
cursor: pointer;
}
.seek-slider {
width: 100%;
height: 6px;
margin-top: 0.75rem;
border-radius: 9999px;
}
.volume-slider {
width: 5.5rem;
height: 6px;
border-radius: 9999px;
}
.seek-slider::-webkit-slider-runnable-track,
.volume-slider::-webkit-slider-runnable-track {
height: 6px;
border-radius: 9999px;
background: transparent;
}
.seek-slider::-webkit-slider-thumb,
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
margin-top: -4px;
border: 2px solid hsl(var(--card));
border-radius: 50%;
background: hsl(var(--primary));
box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
}
.seek-slider::-moz-range-track,
.volume-slider::-moz-range-track {
height: 6px;
border: none;
border-radius: 9999px;
background: hsl(var(--secondary));
}
.seek-slider::-moz-range-thumb,
.volume-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border: 2px solid hsl(var(--card));
border-radius: 50%;
background: hsl(var(--primary));
box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
}
@keyframes audio-wave-motion {
from {
transform: translateX(0);
}
to {
transform: translateX(-16px);
}
}
@media (width <= 640px) {
.audio-body {
gap: 0.75rem;
}
.audio-play-btn {
width: 3rem;
height: 3rem;
min-width: 3rem;
}
.audio-volume-group {
display: none;
}
.audio-waveform-shell {
height: 3.75rem;
}
.audio-controls-row {
margin-top: 0.5rem;
}
}

View File

@@ -0,0 +1,381 @@
import {
Component,
ElementRef,
OnDestroy,
ViewChild,
computed,
effect,
input,
output,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import type WaveSurfer from 'wavesurfer.js';
import {
lucideChevronDown,
lucideChevronUp,
lucideDownload,
lucidePause,
lucidePlay,
lucideVolume2,
lucideVolumeX
} from '@ng-icons/lucide';
const AUDIO_PLAYER_VOLUME_STORAGE_KEY = 'metoyou_audio_player_volume';
const DEFAULT_AUDIO_PLAYER_VOLUME_PERCENT = 50;
function getInitialSharedAudioPlayerVolume(): number {
if (typeof window === 'undefined')
return DEFAULT_AUDIO_PLAYER_VOLUME_PERCENT;
try {
const raw = window.localStorage.getItem(AUDIO_PLAYER_VOLUME_STORAGE_KEY);
const parsed = Number(raw);
if (Number.isFinite(parsed) && parsed > 0 && parsed <= 100)
return parsed;
} catch { /* ignore storage access issues */ }
return DEFAULT_AUDIO_PLAYER_VOLUME_PERCENT;
}
function persistSharedAudioPlayerVolume(volumePercent: number): void {
if (typeof window === 'undefined')
return;
try {
window.localStorage.setItem(AUDIO_PLAYER_VOLUME_STORAGE_KEY, String(volumePercent));
} catch { /* ignore storage access issues */ }
}
@Component({
selector: 'app-chat-audio-player',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [
provideIcons({
lucideChevronDown,
lucideChevronUp,
lucideDownload,
lucidePause,
lucidePlay,
lucideVolume2,
lucideVolumeX
})
],
templateUrl: './chat-audio-player.component.html',
styleUrl: './chat-audio-player.component.scss'
})
/* eslint-disable @typescript-eslint/member-ordering */
export class ChatAudioPlayerComponent implements OnDestroy {
src = input.required<string>();
filename = input.required<string>();
sizeLabel = input<string>('');
downloadRequested = output<undefined>();
@ViewChild('audioEl') audioRef?: ElementRef<HTMLAudioElement>;
@ViewChild('waveformContainer') waveformContainer?: ElementRef<HTMLDivElement>;
isPlaying = signal(false);
isMuted = signal(false);
waveformExpanded = signal(false);
waveformLoading = signal(false);
waveformUnavailable = signal(false);
currentTimeSeconds = signal(0);
durationSeconds = signal(0);
volumePercent = signal(getInitialSharedAudioPlayerVolume());
private lastNonZeroVolume = signal(getInitialSharedAudioPlayerVolume());
private waveSurfer: WaveSurfer | null = null;
progressPercent = computed(() => {
const duration = this.durationSeconds();
if (duration <= 0)
return 0;
return (this.currentTimeSeconds() / duration) * 100;
});
seekTrackBackground = computed(() => this.buildSliderBackground(this.progressPercent()));
waveformToggleIcon = computed(() => this.waveformExpanded() ? 'lucideChevronUp' : 'lucideChevronDown');
displayVolumePercent = computed(() => this.isMuted() ? 0 : this.volumePercent());
volumeTrackBackground = computed(() => {
const volume = Math.max(0, Math.min(100, this.displayVolumePercent()));
return this.buildSliderBackground(volume);
});
constructor() {
effect(() => {
void this.src();
const storedVolume = getInitialSharedAudioPlayerVolume();
this.destroyWaveSurfer();
this.waveformExpanded.set(false);
this.waveformLoading.set(false);
this.waveformUnavailable.set(false);
this.currentTimeSeconds.set(0);
this.durationSeconds.set(0);
this.isPlaying.set(false);
this.isMuted.set(false);
this.volumePercent.set(storedVolume);
this.lastNonZeroVolume.set(storedVolume);
});
}
ngOnDestroy(): void {
this.destroyWaveSurfer();
}
togglePlayback(): void {
const audio = this.audioRef?.nativeElement;
if (!audio)
return;
if (audio.paused || audio.ended) {
void audio.play().catch(() => {
this.isPlaying.set(false);
});
return;
}
audio.pause();
}
toggleWaveform(): void {
const nextExpanded = !this.waveformExpanded();
this.waveformExpanded.set(nextExpanded);
if (nextExpanded) {
requestAnimationFrame(() => {
void this.ensureWaveformLoaded();
});
}
}
onLoadedMetadata(): void {
const audio = this.audioRef?.nativeElement;
if (!audio)
return;
this.applyAudioVolume(this.volumePercent());
this.durationSeconds.set(Number.isFinite(audio.duration) ? audio.duration : 0);
this.currentTimeSeconds.set(audio.currentTime || 0);
}
onTimeUpdate(): void {
const audio = this.audioRef?.nativeElement;
if (!audio)
return;
this.currentTimeSeconds.set(audio.currentTime || 0);
}
onPlay(): void {
this.isPlaying.set(true);
}
onPause(): void {
this.isPlaying.set(false);
}
onSeek(event: Event): void {
const audio = this.audioRef?.nativeElement;
if (!audio)
return;
const nextTime = Number((event.target as HTMLInputElement).value);
if (!Number.isFinite(nextTime))
return;
audio.currentTime = nextTime;
this.currentTimeSeconds.set(nextTime);
}
onVolumeInput(event: Event): void {
const audio = this.audioRef?.nativeElement;
if (!audio)
return;
const nextVolume = Math.max(0, Math.min(100, Number((event.target as HTMLInputElement).value)));
if (nextVolume <= 0) {
audio.volume = 0;
audio.muted = true;
this.isMuted.set(true);
return;
}
audio.volume = nextVolume / 100;
audio.muted = false;
this.volumePercent.set(nextVolume);
this.lastNonZeroVolume.set(nextVolume);
this.isMuted.set(false);
this.setSharedVolume(nextVolume);
}
onVolumeChange(): void {
const audio = this.audioRef?.nativeElement;
if (!audio)
return;
this.isMuted.set(audio.muted || audio.volume === 0);
if (!audio.muted && audio.volume > 0) {
const volume = Math.round(audio.volume * 100);
this.volumePercent.set(volume);
this.lastNonZeroVolume.set(volume);
this.setSharedVolume(volume);
}
}
toggleMute(): void {
const audio = this.audioRef?.nativeElement;
if (!audio)
return;
if (audio.muted || audio.volume === 0) {
const restoredVolume = Math.max(this.lastNonZeroVolume(), 1);
audio.muted = false;
audio.volume = restoredVolume / 100;
this.volumePercent.set(restoredVolume);
this.isMuted.set(false);
this.setSharedVolume(restoredVolume);
return;
}
audio.muted = true;
this.isMuted.set(true);
}
requestDownload(): void {
this.downloadRequested.emit(undefined);
}
private async ensureWaveformLoaded(): Promise<void> {
if (this.waveformLoading() || this.waveSurfer || this.waveformUnavailable())
return;
const source = this.src();
const audio = this.audioRef?.nativeElement;
const waveformContainer = this.waveformContainer?.nativeElement;
if (!source || !audio || !waveformContainer)
return;
this.waveformLoading.set(true);
try {
const { default: WaveSurfer } = await import('wavesurfer.js');
this.waveSurfer = WaveSurfer.create({
barGap: 2,
barRadius: 999,
barWidth: 3,
container: waveformContainer,
cursorWidth: 0,
dragToSeek: true,
height: 56,
hideScrollbar: true,
interact: true,
media: audio,
normalize: true,
progressColor: this.resolveThemeColor('--primary', 'hsl(262 83% 58%)', 0.9),
waveColor: this.resolveThemeColor('--foreground', 'hsl(215 16% 47%)', 0.22)
});
this.waveSurfer.on('error', () => {
this.waveformLoading.set(false);
this.waveformUnavailable.set(true);
this.destroyWaveSurfer();
});
this.waveSurfer.on('ready', () => {
this.waveformLoading.set(false);
this.waveformUnavailable.set(false);
});
} catch {
this.destroyWaveSurfer();
this.waveformUnavailable.set(true);
} finally {
if (this.waveformUnavailable()) {
this.waveformLoading.set(false);
}
}
}
private destroyWaveSurfer(): void {
if (!this.waveSurfer)
return;
this.waveSurfer.destroy();
this.waveSurfer = null;
}
private resolveThemeColor(cssVarName: string, fallback: string, alpha: number): string {
if (typeof window === 'undefined')
return fallback;
const rawValue = window.getComputedStyle(document.documentElement)
.getPropertyValue(cssVarName)
.trim();
if (!rawValue)
return fallback;
return `hsl(${rawValue} / ${alpha})`;
}
private applyAudioVolume(volumePercent: number): void {
const audio = this.audioRef?.nativeElement;
if (!audio)
return;
audio.volume = this.isMuted() ? 0 : volumePercent / 100;
audio.muted = this.isMuted();
}
private setSharedVolume(volumePercent: number): void {
persistSharedAudioPlayerVolume(volumePercent);
}
private buildSliderBackground(fillPercent: number): string {
return [
'linear-gradient(90deg, ',
'hsl(var(--primary)) 0%, ',
`hsl(var(--primary)) ${fillPercent}%, `,
`hsl(var(--secondary)) ${fillPercent}%, `,
'hsl(var(--secondary)) 100%)'
].join('');
}
formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0)
return '0:00';
const totalSeconds = Math.floor(seconds);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const remainingSeconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
}
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
}

View File

@@ -0,0 +1,160 @@
<div
#playerRoot
class="video-player-shell"
[class.fullscreen]="isFullscreen()"
[class.controls-hidden]="isFullscreen() && !controlsVisible()"
(mousemove)="onPlayerMouseMove()"
>
@if (!isFullscreen()) {
<div class="video-top-bar">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-foreground">{{ filename() }}</div>
@if (sizeLabel()) {
<div class="text-xs text-muted-foreground">{{ sizeLabel() }}</div>
}
</div>
<button
type="button"
(click)="requestDownload()"
class="video-control-btn"
title="Save to folder"
aria-label="Save video to folder"
>
<ng-icon
name="lucideDownload"
class="w-4 h-4"
/>
</button>
</div>
}
<div
class="video-stage"
(click)="onVideoClick()"
(dblclick)="onVideoDoubleClick($event)"
(keydown.enter)="onVideoClick()"
(keydown.space)="onVideoClick(); $event.preventDefault()"
role="button"
tabindex="0"
aria-label="Toggle video playback"
>
<video
#videoEl
[src]="src()"
playsinline
preload="metadata"
class="chat-video-element"
(ended)="onPause()"
(loadedmetadata)="onLoadedMetadata()"
(pause)="onPause()"
(play)="onPlay()"
(timeupdate)="onTimeUpdate()"
(volumechange)="onVolumeChange()"
></video>
@if (!isPlaying()) {
<button
type="button"
(click)="onOverlayPlayClick($event)"
class="video-play-overlay"
title="Play video"
aria-label="Play video"
>
<ng-icon
name="lucidePlay"
class="w-8 h-8"
/>
</button>
}
</div>
<div
class="video-bottom-bar"
[class.fullscreen-overlay]="isFullscreen()"
[class.hidden-overlay]="isFullscreen() && !controlsVisible()"
>
<input
type="range"
min="0"
[max]="durationSeconds() || 0"
[value]="currentTimeSeconds()"
(input)="onSeek($event)"
class="seek-slider"
[style.background]="seekTrackBackground()"
aria-label="Seek video"
/>
<div class="video-controls-row">
<div class="flex items-center gap-2 min-w-0 flex-1">
<button
type="button"
(click)="togglePlayback()"
class="video-control-btn"
[title]="isPlaying() ? 'Pause' : 'Play'"
[attr.aria-label]="isPlaying() ? 'Pause video' : 'Play video'"
>
<ng-icon
[name]="isPlaying() ? 'lucidePause' : 'lucidePlay'"
class="w-4 h-4"
/>
</button>
<span class="video-time-label"> {{ formatTime(currentTimeSeconds()) }} / {{ formatTime(durationSeconds()) }} </span>
</div>
<div class="video-volume-group">
<button
type="button"
(click)="toggleMute()"
class="video-control-btn"
[title]="isMuted() ? 'Unmute' : 'Mute'"
[attr.aria-label]="isMuted() ? 'Unmute video' : 'Mute video'"
>
<ng-icon
[name]="isMuted() ? 'lucideVolumeX' : 'lucideVolume2'"
class="w-4 h-4"
/>
</button>
<input
type="range"
min="0"
max="100"
[value]="isMuted() ? 0 : volumePercent()"
(input)="onVolumeInput($event)"
class="volume-slider"
[style.background]="volumeTrackBackground()"
aria-label="Video volume"
/>
</div>
@if (isFullscreen()) {
<button
type="button"
(click)="requestDownload()"
class="video-control-btn"
title="Save to folder"
aria-label="Save video to folder"
>
<ng-icon
name="lucideDownload"
class="w-4 h-4"
/>
</button>
}
<button
type="button"
(click)="toggleFullscreen()"
class="video-control-btn"
[title]="isFullscreen() ? 'Exit fullscreen' : 'Fullscreen'"
[attr.aria-label]="isFullscreen() ? 'Exit fullscreen' : 'Enter fullscreen'"
>
<ng-icon
[name]="isFullscreen() ? 'lucideMinimize' : 'lucideMaximize'"
class="w-4 h-4"
/>
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,243 @@
:host {
display: block;
max-width: 40rem;
}
.video-player-shell {
position: relative;
overflow: hidden;
border: 1px solid hsl(var(--border));
border-radius: calc(var(--radius) + 2px);
background:
radial-gradient(circle at top, hsl(var(--primary) / 0.16), transparent 38%),
linear-gradient(180deg, hsl(var(--card)) 0%, hsl(222deg 47% 8%) 100%);
box-shadow: 0 10px 30px rgb(0 0 0 / 25%);
}
.video-player-shell.fullscreen {
width: 100vw;
height: 100vh;
max-width: none;
border: none;
border-radius: 0;
background: rgb(0 0 0);
}
.video-top-bar,
.video-bottom-bar {
position: relative;
z-index: 2;
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.video-top-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.875rem 0.875rem 0.625rem;
background: linear-gradient(180deg, rgb(6 10 18 / 82%) 0%, rgb(6 10 18 / 30%) 100%);
}
.video-stage {
position: relative;
background: rgb(0 0 0 / 40%);
}
.video-player-shell.fullscreen .video-stage {
height: 100vh;
background: rgb(0 0 0);
}
.video-player-shell.fullscreen.controls-hidden .video-stage,
.video-player-shell.fullscreen.controls-hidden .chat-video-element {
cursor: none;
}
.chat-video-element {
display: block;
width: 100%;
max-height: min(28rem, 70vh);
background: rgb(0 0 0 / 85%);
cursor: pointer;
object-fit: contain;
}
.video-player-shell.fullscreen .chat-video-element {
width: 100vw;
height: 100vh;
max-height: 100vh;
}
.video-play-overlay {
position: absolute;
left: 50%;
top: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
width: 4rem;
height: 4rem;
transform: translate(-50%, -50%);
border: 1px solid hsl(var(--border) / 0.8);
border-radius: 9999px;
color: hsl(var(--primary-foreground));
background: rgb(8 14 24 / 78%);
box-shadow: 0 12px 24px rgb(0 0 0 / 35%);
transition:
transform 0.16s ease,
background-color 0.16s ease;
}
.video-play-overlay:hover {
transform: translate(-50%, -50%) scale(1.05);
background: rgb(12 18 30 / 88%);
}
.video-bottom-bar {
padding: 0.75rem 0.875rem 0.875rem;
background: linear-gradient(180deg, rgb(6 10 18 / 38%) 0%, rgb(6 10 18 / 86%) 100%);
}
.video-bottom-bar.fullscreen-overlay {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 3;
padding-bottom: max(0.875rem, env(safe-area-inset-bottom));
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.video-bottom-bar.hidden-overlay {
opacity: 0;
transform: translateY(1rem);
pointer-events: none;
}
.video-controls-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.625rem;
}
.video-control-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border: 1px solid hsl(var(--border) / 0.8);
border-radius: 9999px;
color: hsl(var(--foreground));
background: hsl(var(--card) / 0.72);
transition:
background-color 0.16s ease,
border-color 0.16s ease,
transform 0.16s ease;
}
.video-control-btn:hover {
border-color: hsl(var(--primary) / 0.75);
background: hsl(var(--primary) / 0.14);
transform: translateY(-1px);
}
.video-time-label {
color: hsl(var(--muted-foreground));
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.video-volume-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.seek-slider,
.volume-slider {
-webkit-appearance: none;
appearance: none;
outline: none;
cursor: pointer;
}
.seek-slider {
width: 100%;
height: 6px;
border-radius: 9999px;
background: linear-gradient(90deg, hsl(var(--primary)) 0%, hsl(var(--primary)) var(--value, 0%), hsl(var(--secondary)) var(--value, 0%), hsl(var(--secondary)) 100%);
}
.volume-slider {
width: 5.5rem;
height: 6px;
border-radius: 9999px;
background: hsl(var(--secondary));
}
.seek-slider::-webkit-slider-runnable-track,
.volume-slider::-webkit-slider-runnable-track {
height: 6px;
border-radius: 9999px;
background: transparent;
}
.seek-slider::-webkit-slider-thumb,
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
margin-top: -4px;
border: 2px solid hsl(var(--card));
border-radius: 50%;
background: hsl(var(--primary));
box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
}
.seek-slider::-moz-range-track,
.volume-slider::-moz-range-track {
height: 6px;
border: none;
border-radius: 9999px;
background: hsl(var(--secondary));
}
.seek-slider::-moz-range-thumb,
.volume-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border: 2px solid hsl(var(--card));
border-radius: 50%;
background: hsl(var(--primary));
box-shadow: 0 1px 3px rgb(0 0 0 / 30%);
}
@media (width <= 640px) {
.video-top-bar {
padding-inline: 0.75rem;
}
.video-bottom-bar {
padding-inline: 0.75rem;
}
.video-controls-row {
gap: 0.5rem;
}
.video-volume-group {
display: none;
}
.video-time-label {
font-size: 0.6875rem;
}
}

View File

@@ -0,0 +1,327 @@
import {
Component,
ElementRef,
HostListener,
OnDestroy,
ViewChild,
computed,
input,
output,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideDownload,
lucideMaximize,
lucideMinimize,
lucidePause,
lucidePlay,
lucideVolume2,
lucideVolumeX
} from '@ng-icons/lucide';
@Component({
selector: 'app-chat-video-player',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [
provideIcons({
lucideDownload,
lucideMaximize,
lucideMinimize,
lucidePause,
lucidePlay,
lucideVolume2,
lucideVolumeX
})
],
templateUrl: './chat-video-player.component.html',
styleUrl: './chat-video-player.component.scss'
})
/* eslint-disable @typescript-eslint/member-ordering */
export class ChatVideoPlayerComponent implements OnDestroy {
src = input.required<string>();
filename = input.required<string>();
sizeLabel = input<string>('');
downloadRequested = output<undefined>();
private readonly SINGLE_CLICK_DELAY_MS = 300;
private readonly FULLSCREEN_IDLE_MS = 2200;
@ViewChild('playerRoot') playerRoot?: ElementRef<HTMLDivElement>;
@ViewChild('videoEl') videoRef?: ElementRef<HTMLVideoElement>;
isPlaying = signal(false);
isMuted = signal(false);
isFullscreen = signal(false);
controlsVisible = signal(true);
currentTimeSeconds = signal(0);
durationSeconds = signal(0);
volumePercent = signal(100);
private lastNonZeroVolume = signal(100);
private singleClickTimer: ReturnType<typeof setTimeout> | null = null;
private controlsHideTimer: ReturnType<typeof setTimeout> | null = null;
progressPercent = computed(() => {
const duration = this.durationSeconds();
if (duration <= 0)
return 0;
return (this.currentTimeSeconds() / duration) * 100;
});
seekTrackBackground = computed(() => {
const progress = Math.max(0, Math.min(100, this.progressPercent()));
return this.buildSliderBackground(progress);
});
volumeTrackBackground = computed(() => {
const volume = Math.max(0, Math.min(100, this.isMuted() ? 0 : this.volumePercent()));
return this.buildSliderBackground(volume);
});
@HostListener('document:fullscreenchange')
onFullscreenChange(): void {
const player = this.playerRoot?.nativeElement;
const isFullscreen = !!player && document.fullscreenElement === player;
this.isFullscreen.set(isFullscreen);
if (isFullscreen) {
this.revealControlsTemporarily();
return;
}
this.controlsVisible.set(true);
this.clearControlsHideTimer();
}
ngOnDestroy(): void {
this.clearControlsHideTimer();
this.clearSingleClickTimer();
}
onPlayerMouseMove(): void {
if (!this.isFullscreen())
return;
this.revealControlsTemporarily();
}
onVideoClick(): void {
this.clearSingleClickTimer();
this.revealControlsTemporarily();
this.singleClickTimer = setTimeout(() => {
this.singleClickTimer = null;
this.togglePlayback();
}, this.SINGLE_CLICK_DELAY_MS);
}
onVideoDoubleClick(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
this.clearSingleClickTimer();
void this.toggleFullscreen();
}
onOverlayPlayClick(event: MouseEvent): void {
event.stopPropagation();
this.togglePlayback();
}
togglePlayback(): void {
const video = this.videoRef?.nativeElement;
if (!video)
return;
if (video.paused || video.ended) {
void video.play().catch(() => {
this.isPlaying.set(false);
});
return;
}
video.pause();
this.revealControlsTemporarily();
}
onLoadedMetadata(): void {
const video = this.videoRef?.nativeElement;
if (!video)
return;
this.durationSeconds.set(Number.isFinite(video.duration) ? video.duration : 0);
this.currentTimeSeconds.set(video.currentTime || 0);
}
onTimeUpdate(): void {
const video = this.videoRef?.nativeElement;
if (!video)
return;
this.currentTimeSeconds.set(video.currentTime || 0);
}
onPlay(): void {
this.isPlaying.set(true);
this.revealControlsTemporarily();
}
onPause(): void {
this.isPlaying.set(false);
this.revealControlsTemporarily();
}
onSeek(event: Event): void {
const video = this.videoRef?.nativeElement;
if (!video)
return;
const nextTime = Number((event.target as HTMLInputElement).value);
if (!Number.isFinite(nextTime))
return;
video.currentTime = nextTime;
this.currentTimeSeconds.set(nextTime);
this.revealControlsTemporarily();
}
onVolumeInput(event: Event): void {
const video = this.videoRef?.nativeElement;
if (!video)
return;
const nextVolume = Math.max(0, Math.min(100, Number((event.target as HTMLInputElement).value)));
video.volume = nextVolume / 100;
video.muted = nextVolume === 0;
if (nextVolume > 0)
this.lastNonZeroVolume.set(nextVolume);
this.volumePercent.set(nextVolume);
this.isMuted.set(video.muted);
this.revealControlsTemporarily();
}
onVolumeChange(): void {
const video = this.videoRef?.nativeElement;
if (!video)
return;
this.isMuted.set(video.muted || video.volume === 0);
if (!video.muted && video.volume > 0) {
const volume = Math.round(video.volume * 100);
this.volumePercent.set(volume);
this.lastNonZeroVolume.set(volume);
}
this.revealControlsTemporarily();
}
toggleMute(): void {
const video = this.videoRef?.nativeElement;
if (!video)
return;
if (video.muted || video.volume === 0) {
const restoredVolume = Math.max(this.lastNonZeroVolume(), 1);
video.muted = false;
video.volume = restoredVolume / 100;
this.volumePercent.set(restoredVolume);
this.isMuted.set(false);
this.revealControlsTemporarily();
return;
}
video.muted = true;
this.isMuted.set(true);
this.revealControlsTemporarily();
}
async toggleFullscreen(): Promise<void> {
const player = this.playerRoot?.nativeElement;
if (!player)
return;
if (document.fullscreenElement === player) {
await document.exitFullscreen().catch(() => {});
return;
}
await player.requestFullscreen?.().catch(() => {});
}
requestDownload(): void {
this.downloadRequested.emit(undefined);
this.revealControlsTemporarily();
}
private buildSliderBackground(fillPercent: number): string {
return [
'linear-gradient(90deg, ',
'hsl(var(--primary)) 0%, ',
`hsl(var(--primary)) ${fillPercent}%, `,
`hsl(var(--secondary)) ${fillPercent}%, `,
'hsl(var(--secondary)) 100%)'
].join('');
}
private revealControlsTemporarily(): void {
if (!this.isFullscreen()) {
this.controlsVisible.set(true);
return;
}
this.controlsVisible.set(true);
this.clearControlsHideTimer();
this.controlsHideTimer = setTimeout(() => {
this.controlsVisible.set(false);
}, this.FULLSCREEN_IDLE_MS);
}
private clearControlsHideTimer(): void {
if (this.controlsHideTimer) {
clearTimeout(this.controlsHideTimer);
this.controlsHideTimer = null;
}
}
private clearSingleClickTimer(): void {
if (this.singleClickTimer) {
clearTimeout(this.singleClickTimer);
this.singleClickTimer = null;
}
}
formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0)
return '0:00';
const totalSeconds = Math.floor(seconds);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const remainingSeconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
}
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
}

View File

@@ -0,0 +1,45 @@
<!-- Backdrop -->
<div
class="fixed inset-0 z-40 bg-black/30"
(click)="cancelled.emit(undefined)"
(keydown.enter)="cancelled.emit(undefined)"
(keydown.space)="cancelled.emit(undefined)"
role="button"
tabindex="0"
aria-label="Close dialog"
></div>
<!-- Dialog -->
<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"
[class]="widthClass()"
>
<div class="p-4">
<h4 class="font-semibold text-foreground mb-2">{{ title() }}</h4>
<div class="text-sm text-muted-foreground">
<ng-content />
</div>
</div>
<div class="flex gap-2 p-3 border-t border-border">
<button
(click)="cancelled.emit(undefined)"
type="button"
class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm"
>
{{ cancelLabel() }}
</button>
<button
(click)="confirmed.emit(undefined)"
type="button"
class="flex-1 px-3 py-2 rounded-lg transition-colors text-sm"
[class.bg-primary]="variant() === 'primary'"
[class.text-primary-foreground]="variant() === 'primary'"
[class.hover:bg-primary/90]="variant() === 'primary'"
[class.bg-destructive]="variant() === 'danger'"
[class.text-destructive-foreground]="variant() === 'danger'"
[class.hover:bg-destructive/90]="variant() === 'danger'"
>
{{ confirmLabel() }}
</button>
</div>
</div>

View File

@@ -26,51 +26,10 @@ import {
@Component({
selector: 'app-confirm-dialog',
standalone: true,
template: `
<!-- Backdrop -->
<div
class="fixed inset-0 z-40 bg-black/30"
(click)="cancelled.emit(undefined)"
(keydown.enter)="cancelled.emit(undefined)"
(keydown.space)="cancelled.emit(undefined)"
role="button"
tabindex="0"
aria-label="Close dialog"
></div>
<!-- Dialog -->
<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"
[class]="widthClass()">
<div class="p-4">
<h4 class="font-semibold text-foreground mb-2">{{ title() }}</h4>
<div class="text-sm text-muted-foreground">
<ng-content />
</div>
</div>
<div class="flex gap-2 p-3 border-t border-border">
<button
(click)="cancelled.emit(undefined)"
type="button"
class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm"
>
{{ cancelLabel() }}
</button>
<button
(click)="confirmed.emit(undefined)"
type="button"
class="flex-1 px-3 py-2 rounded-lg transition-colors text-sm"
[class.bg-primary]="variant() === 'primary'"
[class.text-primary-foreground]="variant() === 'primary'"
[class.hover:bg-primary/90]="variant() === 'primary'"
[class.bg-destructive]="variant() === 'danger'"
[class.text-destructive-foreground]="variant() === 'danger'"
[class.hover:bg-destructive/90]="variant() === 'danger'"
>
{{ confirmLabel() }}
</button>
</div>
</div>
`,
styles: [':host { display: contents; }']
templateUrl: './confirm-dialog.component.html',
host: {
style: 'display: contents;'
}
})
export class ConfirmDialogComponent {
/** Dialog title. */

View File

@@ -0,0 +1,65 @@
<div
class="fixed inset-0 z-40 bg-black/30"
(click)="cancelled.emit(undefined)"
(keydown.enter)="cancelled.emit(undefined)"
(keydown.space)="cancelled.emit(undefined)"
role="button"
tabindex="0"
aria-label="Close dialog"
></div>
<div
class="fixed left-1/2 top-1/2 z-50 w-[360px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-card shadow-lg"
>
<div class="space-y-3 p-4">
<h4 class="font-semibold text-foreground">Leave Server?</h4>
<div class="space-y-3 text-sm text-muted-foreground">
<p>
Leaving will remove
<span class="font-medium text-foreground">{{ room().name }}</span>
from your My Servers list.
</p>
@if (isOwner()) {
<div class="space-y-2 rounded-md border border-border/80 bg-secondary/20 p-3">
<p class="text-foreground">You are the current owner of this server.</p>
<p>You can optionally promote another member before leaving. If you skip this step, the server will continue without an owner.</p>
@if (ownerCandidates().length > 0) {
<label class="block space-y-1">
<span class="text-xs font-medium uppercase tracking-wide text-muted-foreground"> New owner </span>
<select
class="w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
[ngModel]="selectedOwnerKey()"
(ngModelChange)="selectedOwnerKey.set($event || '')"
>
<option value="">Skip owner transfer</option>
@for (member of ownerCandidates(); track roomMemberKey(member)) {
<option [value]="roomMemberKey(member)">{{ member.displayName }} - {{ roleLabel(member) }}</option>
}
</select>
</label>
} @else {
<p>No other known members are available to promote right now.</p>
}
</div>
}
</div>
</div>
<div class="flex gap-2 border-t border-border p-3">
<button
(click)="cancelled.emit(undefined)"
type="button"
class="flex-1 rounded-lg bg-secondary px-3 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
>
Cancel
</button>
<button
(click)="confirmLeave()"
type="button"
class="flex-1 rounded-lg bg-destructive px-3 py-2 text-sm text-destructive-foreground transition-colors hover:bg-destructive/90"
>
Leave Server
</button>
</div>
</div>

View File

@@ -0,0 +1,91 @@
import {
Component,
HostListener,
computed,
effect,
input,
output,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
Room,
RoomMember,
User
} from '../../../core/models';
export interface LeaveServerDialogResult {
nextOwnerKey?: string;
}
@Component({
selector: 'app-leave-server-dialog',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './leave-server-dialog.component.html'
})
export class LeaveServerDialogComponent {
room = input.required<Room>();
currentUser = input<User | null>(null);
confirmed = output<LeaveServerDialogResult>();
cancelled = output<undefined>();
selectedOwnerKey = signal('');
isOwner = computed(() => {
const room = this.room();
const user = this.currentUser();
if (!room || !user)
return false;
return room.hostId === user.id || room.hostId === user.oderId;
});
ownerCandidates = computed(() => {
const room = this.room();
const user = this.currentUser();
const userIds = new Set([user?.id, user?.oderId].filter((value): value is string => !!value));
return (room.members ?? []).filter((member) => !userIds.has(member.id) && !userIds.has(member.oderId || ''));
});
constructor() {
effect(() => {
this.room();
this.currentUser();
this.selectedOwnerKey.set('');
});
}
@HostListener('document:keydown.escape')
onEscape(): void {
this.cancelled.emit(undefined);
}
confirmLeave(): void {
this.confirmed.emit(
this.selectedOwnerKey()
? { nextOwnerKey: this.selectedOwnerKey() }
: {}
);
}
roomMemberKey(member: RoomMember): string {
return member.oderId || member.id;
}
roleLabel(member: RoomMember): string {
switch (member.role) {
case 'host':
return 'Owner';
case 'admin':
return 'Admin';
case 'moderator':
return 'Moderator';
default:
return 'Member';
}
}
}

View File

@@ -0,0 +1,17 @@
@if (avatarUrl()) {
<img
[ngSrc]="avatarUrl()!"
[width]="sizePx()"
[height]="sizePx()"
alt=""
class="rounded-full object-cover"
[class]="sizeClasses() + ' ' + ringClass()"
/>
} @else {
<div
class="rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
[class]="sizeClasses() + ' ' + textClass() + ' ' + ringClass()"
>
{{ initial() }}
</div>
}

View File

@@ -1,3 +1,4 @@
import { NgOptimizedImage } from '@angular/common';
import { Component, input } from '@angular/core';
/**
@@ -17,24 +18,11 @@ import { Component, input } from '@angular/core';
@Component({
selector: 'app-user-avatar',
standalone: true,
template: `
@if (avatarUrl()) {
<img
[src]="avatarUrl()"
alt=""
class="rounded-full object-cover"
[class]="sizeClasses() + ' ' + ringClass()"
/>
} @else {
<div
class="rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
[class]="sizeClasses() + ' ' + textClass() + ' ' + ringClass()"
>
{{ initial() }}
</div>
}
`,
styles: [':host { display: contents; }']
imports: [NgOptimizedImage],
templateUrl: './user-avatar.component.html',
host: {
style: 'display: contents;'
}
})
export class UserAvatarComponent {
/** Display name - first character is used as fallback initial. */
@@ -62,6 +50,16 @@ export class UserAvatarComponent {
}
}
/** Map size token to explicit pixel dimensions for image optimisation. */
sizePx(): number {
switch (this.size()) {
case 'xs': return 28;
case 'sm': return 32;
case 'md': return 40;
case 'lg': return 48;
}
}
/** Map size token to text size for initials. */
textClass(): string {
switch (this.size()) {

View File

@@ -4,4 +4,7 @@
export { ContextMenuComponent } from './components/context-menu/context-menu.component';
export { UserAvatarComponent } from './components/user-avatar/user-avatar.component';
export { ConfirmDialogComponent } from './components/confirm-dialog/confirm-dialog.component';
export { LeaveServerDialogComponent } from './components/leave-server-dialog/leave-server-dialog.component';
export { ChatAudioPlayerComponent } from './components/chat-audio-player/chat-audio-player.component';
export { ChatVideoPlayerComponent } from './components/chat-video-player/chat-video-player.component';
export { UserVolumeMenuComponent } from './components/user-volume-menu/user-volume-menu.component';

View File

@@ -22,17 +22,16 @@ import { WebRTCService } from '../../core/services/webrtc.service';
import { UsersActions } from '../users/users.actions';
import { selectCurrentUser } from '../users/users.selectors';
import { RoomsActions } from './rooms.actions';
import {
selectCurrentRoom,
selectSavedRooms
} from './rooms.selectors';
import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors';
import {
areRoomMembersEqual,
findRoomMember,
mergeRoomMembers,
pruneRoomMembers,
removeRoomMember,
roomMemberFromUser,
touchRoomMemberLastSeen,
transferRoomOwnership,
updateRoomMemberRole,
upsertRoomMember
} from './room-members.helpers';
@@ -79,7 +78,10 @@ export class RoomMembersSyncEffects {
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom)
),
mergeMap(([, currentUser, currentRoom]) => {
mergeMap(([
, currentUser,
currentRoom
]) => {
if (!currentUser || !currentRoom)
return EMPTY;
@@ -119,7 +121,12 @@ export class RoomMembersSyncEffects {
this.store.select(selectSavedRooms),
this.store.select(selectCurrentUser)
),
mergeMap(([message, currentRoom, savedRooms, currentUser]) => {
mergeMap(([
message,
currentRoom,
savedRooms,
currentUser
]) => {
const signalingMessage = message as any;
const roomId = typeof signalingMessage.serverId === 'string' ? signalingMessage.serverId : undefined;
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
@@ -136,7 +143,7 @@ export class RoomMembersSyncEffects {
let members = room.members ?? [];
for (const user of signalingMessage.users as Array<{ oderId: string; displayName: string }>) {
for (const user of signalingMessage.users as { oderId: string; displayName: string }[]) {
if (!user?.oderId || user.oderId === myId)
continue;
@@ -229,7 +236,12 @@ export class RoomMembersSyncEffects {
this.store.select(selectSavedRooms),
this.store.select(selectCurrentUser)
),
mergeMap(([event, currentRoom, savedRooms, currentUser]) => {
mergeMap(([
event,
currentRoom,
savedRooms,
currentUser
]) => {
switch (event.type) {
case 'member-roster-request': {
const actions = this.handleMemberRosterRequest(event, currentRoom, savedRooms, currentUser ?? null);
@@ -249,6 +261,12 @@ export class RoomMembersSyncEffects {
return actions.length > 0 ? actions : EMPTY;
}
case 'host-change': {
const actions = this.handleIncomingHostChange(event, currentRoom, savedRooms, currentUser ?? null);
return actions.length > 0 ? actions : EMPTY;
}
case 'role-change': {
const actions = this.handleIncomingRoleChange(event, currentRoom, savedRooms);
@@ -326,6 +344,7 @@ export class RoomMembersSyncEffects {
return [];
const isCurrentRoom = currentRoom?.id === room.id;
let members = room.members ?? [];
if (currentUser) {
@@ -392,6 +411,78 @@ export class RoomMembersSyncEffects {
return actions;
}
private handleIncomingHostChange(
event: any,
currentRoom: Room | null,
savedRooms: Room[],
currentUser: User | null
): Action[] {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
if (!room)
return [];
const members = Array.isArray(event.members)
? pruneRoomMembers(event.members)
: transferRoomOwnership(
room.members ?? [],
event.hostId || event.hostOderId
? {
id: event.hostId,
oderId: event.hostOderId
}
: null,
{
id: event.previousHostId,
oderId: event.previousHostOderId
}
);
const hostId = typeof event.hostId === 'string' ? event.hostId : '';
const actions: Action[] = [
RoomsActions.updateRoom({
roomId: room.id,
changes: {
hostId,
members
}
})
];
for (const previousHostKey of new Set([event.previousHostId, event.previousHostOderId].filter((value): value is string => !!value))) {
actions.push(
UsersActions.updateUserRole({
userId: previousHostKey,
role: 'member'
})
);
}
for (const nextHostKey of new Set([event.hostId, event.hostOderId].filter((value): value is string => !!value))) {
actions.push(
UsersActions.updateUserRole({
userId: nextHostKey,
role: 'host'
})
);
}
if (currentUser) {
const isCurrentUserNextHost = event.hostId === currentUser.id || event.hostOderId === currentUser.oderId;
const isCurrentUserPreviousHost = event.previousHostId === currentUser.id || event.previousHostOderId === currentUser.oderId;
if (isCurrentUserPreviousHost && !isCurrentUserNextHost) {
actions.push(UsersActions.updateCurrentUser({ updates: { role: 'member' } }));
}
if (isCurrentUserNextHost) {
actions.push(UsersActions.updateCurrentUser({ updates: { role: 'host' } }));
}
}
return actions;
}
private handleIncomingRoleChange(
event: any,
currentRoom: Room | null,

View File

@@ -1,7 +1,4 @@
import {
RoomMember,
User
} from '../../core/models';
import { RoomMember, User } from '../../core/models';
/** Remove members that have not been seen for roughly two months. */
export const ROOM_MEMBER_STALE_MS = 1000 * 60 * 60 * 24 * 60;
@@ -234,7 +231,7 @@ export function touchRoomMemberLastSeen(
/** Remove a member from a room roster by either ID flavor. */
export function removeRoomMember(
members: RoomMember[] = [],
...identifiers: Array<string | undefined>
...identifiers: (string | undefined)[]
): RoomMember[] {
const ids = new Set(identifiers.filter((identifier): identifier is string => !!identifier));
@@ -246,6 +243,43 @@ export function removeRoomMember(
);
}
/** Reassign ownership within a room roster, optionally leaving the room ownerless. */
export function transferRoomOwnership(
members: RoomMember[] = [],
nextOwner: Partial<RoomMember> | null,
previousOwner?: Pick<RoomMember, 'id' | 'oderId'>,
now = Date.now()
): RoomMember[] {
const nextMembers = pruneRoomMembers(members, now).map((member) => {
const isPreviousOwner =
member.role === 'host'
|| (!!previousOwner?.id && member.id === previousOwner.id)
|| (!!previousOwner?.oderId && member.oderId === previousOwner.oderId);
return isPreviousOwner
? { ...member,
role: 'member' as const }
: member;
});
if (!nextOwner || !(nextOwner.id || nextOwner.oderId))
return pruneRoomMembers(nextMembers, now);
const existingNextOwner = findRoomMember(nextMembers, nextOwner.id || nextOwner.oderId);
const nextOwnerMember: RoomMember = {
id: existingNextOwner?.id || nextOwner.id || nextOwner.oderId || '',
oderId: existingNextOwner?.oderId || nextOwner.oderId || undefined,
username: existingNextOwner?.username || nextOwner.username || '',
displayName: existingNextOwner?.displayName || nextOwner.displayName || 'User',
avatarUrl: existingNextOwner?.avatarUrl || nextOwner.avatarUrl || undefined,
role: 'host',
joinedAt: existingNextOwner?.joinedAt || nextOwner.joinedAt || now,
lastSeenAt: existingNextOwner?.lastSeenAt || nextOwner.lastSeenAt || now
};
return upsertRoomMember(nextMembers, nextOwnerMember, now);
}
/** Update a persisted member role without touching presence timestamps. */
export function updateRoomMemberRole(
members: RoomMember[] = [],

View File

@@ -42,7 +42,7 @@ export const RoomsActions = createActionGroup({
'Delete Room': props<{ roomId: string }>(),
'Delete Room Success': props<{ roomId: string }>(),
'Forget Room': props<{ roomId: string }>(),
'Forget Room': props<{ roomId: string; nextOwnerKey?: string }>(),
'Forget Room Success': props<{ roomId: string }>(),
'Update Room Settings': props<{ settings: Partial<RoomSettings> }>(),

View File

@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/member-ordering */
/* eslint-disable id-length */
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-explicit-any, complexity */
import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router';
import {
@@ -29,7 +29,7 @@ import { RoomsActions } from './rooms.actions';
import { UsersActions } from '../users/users.actions';
import { MessagesActions } from '../messages/messages.actions';
import { selectCurrentUser, selectAllUsers } from '../users/users.selectors';
import { selectCurrentRoom } from './rooms.selectors';
import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors';
import { DatabaseService } from '../../core/services/database.service';
import { WebRTCService } from '../../core/services/webrtc.service';
import { ServerDirectoryService } from '../../core/services/server-directory.service';
@@ -40,7 +40,11 @@ import {
VoiceState
} from '../../core/models';
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
import { findRoomMember } from './room-members.helpers';
import {
findRoomMember,
removeRoomMember,
transferRoomOwnership
} from './room-members.helpers';
/** Build a minimal User object from signaling payload. */
function buildSignalingUser(
@@ -337,12 +341,64 @@ export class RoomsEffects {
)
);
/** Forgets a room locally: removes from DB and leaves the signaling server for that room. */
/** Leaves a server, optionally transfers ownership, and removes it locally. */
forgetRoom$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.forgetRoom),
withLatestFrom(this.store.select(selectCurrentUser)),
switchMap(([{ roomId }, currentUser]) => {
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms)
),
switchMap(([
{ roomId, nextOwnerKey },
currentUser,
currentRoom,
savedRooms
]) => {
const room = currentRoom?.id === roomId
? currentRoom
: (savedRooms.find((savedRoom) => savedRoom.id === roomId) ?? null);
const isRoomOwner = !!currentUser && !!room && (room.hostId === currentUser.id || room.hostId === currentUser.oderId);
if (currentUser && room && isRoomOwner) {
const nextOwner = nextOwnerKey
? (findRoomMember(room.members ?? [], nextOwnerKey) ?? null)
: null;
const updatedMembers = removeRoomMember(
transferRoomOwnership(
room.members ?? [],
nextOwner,
{
id: room.hostId,
oderId: currentUser.oderId
}
),
currentUser.id,
currentUser.oderId
);
const nextHostId = nextOwner?.id || nextOwner?.oderId || '';
const nextHostOderId = nextOwner?.oderId || '';
this.webrtc.broadcastMessage({
type: 'host-change',
roomId,
hostId: nextHostId,
hostOderId: nextHostOderId,
previousHostId: room.hostId,
previousHostOderId: currentUser.oderId,
members: updatedMembers
});
this.serverDirectory.updateServer(roomId, {
currentOwnerId: currentUser.id,
ownerId: nextHostId,
ownerPublicKey: nextHostOderId
}).subscribe({
error: () => {}
});
}
if (currentUser) {
this.webrtc.broadcastMessage({
type: 'member-leave',
@@ -359,7 +415,9 @@ export class RoomsEffects {
// Leave this specific server (doesn't affect other servers)
this.webrtc.leaveRoom(roomId);
return of(RoomsActions.forgetRoomSuccess({ roomId }));
return currentRoom?.id === roomId
? [RoomsActions.leaveRoomSuccess(), RoomsActions.forgetRoomSuccess({ roomId })]
: of(RoomsActions.forgetRoomSuccess({ roomId }));
})
)
);
@@ -539,12 +597,8 @@ export class RoomsEffects {
onJoinRoomSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.joinRoomSuccess),
mergeMap(({ room }) => [
MessagesActions.loadMessages({ roomId: room.id }),
// Don't load users from database - they come from signaling server
// UsersActions.loadRoomUsers({ roomId: room.id }),
UsersActions.loadBans()
])
// Don't load users from database - they come from signaling server.
mergeMap(({ room }) => [MessagesActions.loadMessages({ roomId: room.id }), UsersActions.loadBans()])
)
);

View File

@@ -32,10 +32,7 @@ import {
import { selectCurrentRoom } from '../rooms/rooms.selectors';
import { DatabaseService } from '../../core/services/database.service';
import { WebRTCService } from '../../core/services/webrtc.service';
import {
BanEntry,
User
} from '../../core/models';
import { BanEntry, User } from '../../core/models';
@Injectable()
export class UsersEffects {

View File

@@ -1,50 +1,72 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>MeToYou</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; connect-src 'self' ws: wss: http: https:; media-src 'self' blob:; img-src 'self' data: blob:;">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<script>
// Polyfills for Node.js modules used in browser
if (typeof global === 'undefined') {
window.global = window;
}
if (typeof process === 'undefined') {
window.process = { env: {}, browser: true, version: '', versions: {} };
}
// Add nextTick polyfill for simple-peer/streams
if (typeof process.nextTick === 'undefined') {
window.process.nextTick = function(fn) {
setTimeout(fn, 0);
};
}
if (typeof Buffer === 'undefined') {
window.Buffer = {
isBuffer: function() { return false; },
from: function() { return []; },
alloc: function() { return []; }
};
}
// Polyfill for util module (used by simple-peer/debug)
if (typeof window.util === 'undefined') {
window.util = {
debuglog: function() { return function() {}; },
inspect: function(obj) { return JSON.stringify(obj); },
format: function() { return Array.prototype.slice.call(arguments).join(' '); },
inherits: function(ctor, superCtor) {
ctor.super_ = superCtor;
ctor.prototype = Object.create(superCtor.prototype, {
constructor: { value: ctor, enumerable: false, writable: true, configurable: true }
});
}
};
}
</script>
</head>
<body>
<app-root></app-root>
</body>
<head>
<meta charset="utf-8" />
<title>MeToYou</title>
<base href="/" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; connect-src 'self' blob: ws: wss: http: https:; media-src 'self' blob:; img-src 'self' data: blob: http: https:;"
/>
<link
rel="icon"
type="image/x-icon"
href="favicon.ico"
/>
<script>
// Polyfills for Node.js modules used in browser
if (typeof global === 'undefined') {
window.global = window;
}
if (typeof process === 'undefined') {
window.process = { env: {}, browser: true, version: '', versions: {} };
}
// Add nextTick polyfill for simple-peer/streams
if (typeof process.nextTick === 'undefined') {
window.process.nextTick = function (fn) {
setTimeout(fn, 0);
};
}
if (typeof Buffer === 'undefined') {
window.Buffer = {
isBuffer: function () {
return false;
},
from: function () {
return [];
},
alloc: function () {
return [];
}
};
}
// Polyfill for util module (used by simple-peer/debug)
if (typeof window.util === 'undefined') {
window.util = {
debuglog: function () {
return function () {};
},
inspect: function (obj) {
return JSON.stringify(obj);
},
format: function () {
return Array.prototype.slice.call(arguments).join(' ');
},
inherits: function (ctor, superCtor) {
ctor.super_ = superCtor;
ctor.prototype = Object.create(superCtor.prototype, {
constructor: { value: ctor, enumerable: false, writable: true, configurable: true }
});
}
};
}
</script>
</head>
<body>
<app-root></app-root>
</body>
</html>