Imrpove chat with gifs, videos, music player, redesigns and improved filesharing errors
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Entity, PrimaryColumn, Column } from 'typeorm';
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('meta')
|
||||
export class MetaEntity {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Entity, PrimaryColumn, Column } from 'typeorm';
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('reactions')
|
||||
export class ReactionEntity {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ const noDashPlugin = {
|
||||
|
||||
module.exports = tseslint.config(
|
||||
{
|
||||
ignores: ['**/generated/*','dist/**', '**/migrations/**', 'release/**']
|
||||
ignores: ['**/.angular/**', '**/generated/*', '**/dist/**', '**/migrations/**', 'release/**']
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
|
||||
@@ -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
43
server/dist/db.d.ts
vendored
@@ -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
|
||||
1
server/dist/db.d.ts.map
vendored
1
server/dist/db.d.ts.map
vendored
@@ -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
277
server/dist/db.js
vendored
@@ -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
|
||||
1
server/dist/db.js.map
vendored
1
server/dist/db.js.map
vendored
File diff suppressed because one or more lines are too long
2
server/dist/index.d.ts
vendored
2
server/dist/index.d.ts
vendored
@@ -1,2 +0,0 @@
|
||||
export {};
|
||||
// # sourceMappingURL=index.d.ts.map
|
||||
1
server/dist/index.d.ts.map
vendored
1
server/dist/index.d.ts.map
vendored
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
|
||||
438
server/dist/index.js
vendored
438
server/dist/index.js
vendored
@@ -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
|
||||
1
server/dist/index.js.map
vendored
1
server/dist/index.js.map
vendored
File diff suppressed because one or more lines are too long
72
server/src/config/variables.ts
Normal file
72
server/src/config/variables.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
221
server/src/routes/klipy.ts
Normal 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;
|
||||
@@ -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() };
|
||||
|
||||
@@ -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 (0–200%). */
|
||||
/** 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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,6 +861,8 @@ export class AttachmentService {
|
||||
const sanitisedRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room';
|
||||
const subDirectory = attachment.mime.startsWith('video/')
|
||||
? 'video'
|
||||
: attachment.mime.startsWith('audio/')
|
||||
? 'audio'
|
||||
: attachment.mime.startsWith('image/')
|
||||
? 'image'
|
||||
: 'files';
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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';
|
||||
|
||||
200
src/app/core/services/klipy.service.ts
Normal file
200
src/app/core/services/klipy.service.ts
Normal 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.';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `})`;
|
||||
}
|
||||
|
||||
/** Auto-resize the textarea to fit its content up to 520px, then allow scrolling. */
|
||||
autoResizeTextarea(): void {
|
||||
const el = this.messageInputRef?.nativeElement;
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -46,36 +46,21 @@
|
||||
(closed)="closeMenu()"
|
||||
[width]="'w-44'"
|
||||
>
|
||||
@if (isCurrentContextRoom()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="leaveServer()"
|
||||
(click)="openLeaveConfirm()"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Leave Server
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
(click)="openForgetConfirm()"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Forget 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()"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
];
|
||||
@@ -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()"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
Couldn’t 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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')}`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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')}`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
imports: [NgOptimizedImage],
|
||||
templateUrl: './user-avatar.component.html',
|
||||
host: {
|
||||
style: 'display: contents;'
|
||||
}
|
||||
`,
|
||||
styles: [':host { 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()) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] = [],
|
||||
|
||||
@@ -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> }>(),
|
||||
|
||||
@@ -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()])
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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">
|
||||
<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') {
|
||||
@@ -17,24 +27,36 @@
|
||||
}
|
||||
// Add nextTick polyfill for simple-peer/streams
|
||||
if (typeof process.nextTick === 'undefined') {
|
||||
window.process.nextTick = function(fn) {
|
||||
window.process.nextTick = function (fn) {
|
||||
setTimeout(fn, 0);
|
||||
};
|
||||
}
|
||||
if (typeof Buffer === 'undefined') {
|
||||
window.Buffer = {
|
||||
isBuffer: function() { return false; },
|
||||
from: function() { return []; },
|
||||
alloc: function() { return []; }
|
||||
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) {
|
||||
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 }
|
||||
@@ -43,8 +65,8 @@
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user