Refactor and code designing
This commit is contained in:
@@ -65,8 +65,8 @@
|
|||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "500kB",
|
"maximumWarning": "1MB",
|
||||||
"maximumError": "1MB"
|
"maximumError": "2MB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
|
|||||||
@@ -321,13 +321,11 @@ function rowToBan(r) {
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
function registerDatabaseIpc() {
|
function registerDatabaseIpc() {
|
||||||
// ── Lifecycle ──────────────────────────────────────────────────────
|
|
||||||
ipcMain.handle('db:initialize', async () => {
|
ipcMain.handle('db:initialize', async () => {
|
||||||
await initDatabase();
|
await initDatabase();
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Messages ───────────────────────────────────────────────────────
|
|
||||||
ipcMain.handle('db:saveMessage', (_e, message) => {
|
ipcMain.handle('db:saveMessage', (_e, message) => {
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT OR REPLACE INTO messages
|
`INSERT OR REPLACE INTO messages
|
||||||
@@ -390,7 +388,6 @@ function registerDatabaseIpc() {
|
|||||||
persist();
|
persist();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Reactions ──────────────────────────────────────────────────────
|
|
||||||
ipcMain.handle('db:saveReaction', (_e, reaction) => {
|
ipcMain.handle('db:saveReaction', (_e, reaction) => {
|
||||||
const check = db.exec(
|
const check = db.exec(
|
||||||
'SELECT 1 FROM reactions WHERE messageId = ? AND userId = ? AND emoji = ?',
|
'SELECT 1 FROM reactions WHERE messageId = ? AND userId = ? AND emoji = ?',
|
||||||
@@ -416,7 +413,6 @@ function registerDatabaseIpc() {
|
|||||||
return rows.map(rowToReaction);
|
return rows.map(rowToReaction);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Users ──────────────────────────────────────────────────────────
|
|
||||||
ipcMain.handle('db:saveUser', (_e, user) => {
|
ipcMain.handle('db:saveUser', (_e, user) => {
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT OR REPLACE INTO users
|
`INSERT OR REPLACE INTO users
|
||||||
@@ -486,7 +482,6 @@ function registerDatabaseIpc() {
|
|||||||
persist();
|
persist();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Rooms ──────────────────────────────────────────────────────────
|
|
||||||
ipcMain.handle('db:saveRoom', (_e, room) => {
|
ipcMain.handle('db:saveRoom', (_e, room) => {
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT OR REPLACE INTO rooms
|
`INSERT OR REPLACE INTO rooms
|
||||||
@@ -542,7 +537,6 @@ function registerDatabaseIpc() {
|
|||||||
persist();
|
persist();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Bans ───────────────────────────────────────────────────────────
|
|
||||||
ipcMain.handle('db:saveBan', (_e, ban) => {
|
ipcMain.handle('db:saveBan', (_e, ban) => {
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT OR REPLACE INTO bans
|
`INSERT OR REPLACE INTO bans
|
||||||
@@ -579,7 +573,6 @@ function registerDatabaseIpc() {
|
|||||||
return rows.some((r) => String(r.oderId) === userId);
|
return rows.some((r) => String(r.oderId) === userId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Attachments ─────────────────────────────────────────────────────
|
|
||||||
ipcMain.handle('db:saveAttachment', (_e, attachment) => {
|
ipcMain.handle('db:saveAttachment', (_e, attachment) => {
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT OR REPLACE INTO attachments
|
`INSERT OR REPLACE INTO attachments
|
||||||
@@ -610,7 +603,6 @@ function registerDatabaseIpc() {
|
|||||||
persist();
|
persist();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Utilities ──────────────────────────────────────────────────────
|
|
||||||
ipcMain.handle('db:clearAllData', () => {
|
ipcMain.handle('db:clearAllData', () => {
|
||||||
db.run('DELETE FROM messages');
|
db.run('DELETE FROM messages');
|
||||||
db.run('DELETE FROM users');
|
db.run('DELETE FROM users');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { app, BrowserWindow, ipcMain, desktopCapturer } = require('electron');
|
const { app, BrowserWindow, ipcMain, desktopCapturer, shell } = require('electron');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const fsp = fs.promises;
|
const fsp = fs.promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
@@ -50,11 +50,36 @@ function createWindow() {
|
|||||||
mainWindow.on('closed', () => {
|
mainWindow.on('closed', () => {
|
||||||
mainWindow = null;
|
mainWindow = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Force all external links to open in the system default browser
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
shell.openExternal(url);
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.webContents.on('will-navigate', (event, url) => {
|
||||||
|
// Allow navigation to the app itself (dev server or file://)
|
||||||
|
const currentUrl = mainWindow.webContents.getURL();
|
||||||
|
const isSameOrigin = new URL(url).origin === new URL(currentUrl).origin;
|
||||||
|
if (!isSameOrigin) {
|
||||||
|
event.preventDefault();
|
||||||
|
shell.openExternal(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register database IPC handlers before app is ready
|
// Register database IPC handlers before app is ready
|
||||||
registerDatabaseIpc();
|
registerDatabaseIpc();
|
||||||
|
|
||||||
|
// IPC handler for opening URLs in the system default browser
|
||||||
|
ipcMain.handle('open-external', async (_event, url) => {
|
||||||
|
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
|
||||||
|
await shell.openExternal(url);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
app.whenReady().then(createWindow);
|
app.whenReady().then(createWindow);
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
maximizeWindow: () => ipcRenderer.send('window-maximize'),
|
maximizeWindow: () => ipcRenderer.send('window-maximize'),
|
||||||
closeWindow: () => ipcRenderer.send('window-close'),
|
closeWindow: () => ipcRenderer.send('window-close'),
|
||||||
|
|
||||||
|
// Open URL in system default browser
|
||||||
|
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
||||||
|
|
||||||
// Desktop capturer for screen sharing
|
// Desktop capturer for screen sharing
|
||||||
getSources: () => ipcRenderer.invoke('get-sources'),
|
getSources: () => ipcRenderer.invoke('get-sources'),
|
||||||
|
|
||||||
@@ -18,7 +21,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
fileExists: (filePath) => ipcRenderer.invoke('file-exists', filePath),
|
fileExists: (filePath) => ipcRenderer.invoke('file-exists', filePath),
|
||||||
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
|
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
|
||||||
|
|
||||||
// ── Database operations (all SQL lives in main process) ───────────
|
|
||||||
db: {
|
db: {
|
||||||
initialize: () => ipcRenderer.invoke('db:initialize'),
|
initialize: () => ipcRenderer.invoke('db:initialize'),
|
||||||
|
|
||||||
|
|||||||
19
package.json
19
package.json
@@ -59,14 +59,16 @@
|
|||||||
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"mermaid": "^11.12.3",
|
||||||
|
"ngx-remark": "^0.2.2",
|
||||||
|
"remark": "^15.0.1",
|
||||||
|
"remark-breaks": "^4.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"simple-peer": "^9.11.1",
|
"simple-peer": "^9.11.1",
|
||||||
"sql.js": "^1.13.0",
|
"sql.js": "^1.13.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0"
|
||||||
"marked": "^12.0.2",
|
|
||||||
"dompurify": "^3.0.6",
|
|
||||||
"highlight.js": "^11.9.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "^21.0.4",
|
"@angular/build": "^21.0.4",
|
||||||
@@ -115,10 +117,15 @@
|
|||||||
"allowToChangeInstallationDirectory": true
|
"allowToChangeInstallationDirectory": true
|
||||||
},
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"target": ["AppImage", "deb"],
|
"target": [
|
||||||
|
"AppImage",
|
||||||
|
"deb"
|
||||||
|
],
|
||||||
"category": "Network;Chat",
|
"category": "Network;Chat",
|
||||||
"executableName": "metoyou",
|
"executableName": "metoyou",
|
||||||
"executableArgs": ["--no-sandbox"]
|
"executableArgs": [
|
||||||
|
"--no-sandbox"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ import { messagesReducer } from './store/messages/messages.reducer';
|
|||||||
import { usersReducer } from './store/users/users.reducer';
|
import { usersReducer } from './store/users/users.reducer';
|
||||||
import { roomsReducer } from './store/rooms/rooms.reducer';
|
import { roomsReducer } from './store/rooms/rooms.reducer';
|
||||||
import { MessagesEffects } from './store/messages/messages.effects';
|
import { MessagesEffects } from './store/messages/messages.effects';
|
||||||
|
import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
|
||||||
import { UsersEffects } from './store/users/users.effects';
|
import { UsersEffects } from './store/users/users.effects';
|
||||||
import { RoomsEffects } from './store/rooms/rooms.effects';
|
import { RoomsEffects } from './store/rooms/rooms.effects';
|
||||||
|
import { STORE_DEVTOOLS_MAX_AGE } from './core/constants';
|
||||||
|
|
||||||
|
/** Root application configuration providing routing, HTTP, NgRx store, and devtools. */
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
@@ -23,9 +26,9 @@ export const appConfig: ApplicationConfig = {
|
|||||||
users: usersReducer,
|
users: usersReducer,
|
||||||
rooms: roomsReducer,
|
rooms: roomsReducer,
|
||||||
}),
|
}),
|
||||||
provideEffects([MessagesEffects, UsersEffects, RoomsEffects]),
|
provideEffects([MessagesEffects, MessagesSyncEffects, UsersEffects, RoomsEffects]),
|
||||||
provideStoreDevtools({
|
provideStoreDevtools({
|
||||||
maxAge: 25,
|
maxAge: STORE_DEVTOOLS_MAX_AGE,
|
||||||
logOnly: !isDevMode(),
|
logOnly: !isDevMode(),
|
||||||
autoPause: true,
|
autoPause: true,
|
||||||
trace: false,
|
trace: false,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
/** Application route configuration with lazy-loaded feature components. */
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
@@ -9,28 +10,28 @@ export const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'login',
|
path: 'login',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/auth/login/login.component').then((m) => m.LoginComponent),
|
import('./features/auth/login/login.component').then((module) => module.LoginComponent),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'register',
|
path: 'register',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/auth/register/register.component').then((m) => m.RegisterComponent),
|
import('./features/auth/register/register.component').then((module) => module.RegisterComponent),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'search',
|
path: 'search',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/server-search/server-search.component').then(
|
import('./features/server-search/server-search.component').then(
|
||||||
(m) => m.ServerSearchComponent
|
(module) => module.ServerSearchComponent
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'room/:roomId',
|
path: 'room/:roomId',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/room/chat-room/chat-room.component').then((m) => m.ChatRoomComponent),
|
import('./features/room/chat-room/chat-room.component').then((module) => module.ChatRoomComponent),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/settings/settings.component').then((m) => m.SettingsComponent),
|
import('./features/settings/settings.component').then((module) => module.SettingsComponent),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, inject, HostListener } from '@angular/core';
|
||||||
import { Router, RouterOutlet, NavigationEnd } from '@angular/router';
|
import { Router, RouterOutlet, NavigationEnd } from '@angular/router';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
@@ -7,13 +7,21 @@ import { DatabaseService } from './core/services/database.service';
|
|||||||
import { ServerDirectoryService } from './core/services/server-directory.service';
|
import { ServerDirectoryService } from './core/services/server-directory.service';
|
||||||
import { TimeSyncService } from './core/services/time-sync.service';
|
import { TimeSyncService } from './core/services/time-sync.service';
|
||||||
import { VoiceSessionService } from './core/services/voice-session.service';
|
import { VoiceSessionService } from './core/services/voice-session.service';
|
||||||
|
import { ExternalLinkService } from './core/services/external-link.service';
|
||||||
import { ServersRailComponent } from './features/servers/servers-rail.component';
|
import { ServersRailComponent } from './features/servers/servers-rail.component';
|
||||||
import { TitleBarComponent } from './features/shell/title-bar.component';
|
import { TitleBarComponent } from './features/shell/title-bar.component';
|
||||||
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
|
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
|
||||||
import * as UsersActions from './store/users/users.actions';
|
import { UsersActions } from './store/users/users.actions';
|
||||||
import * as RoomsActions from './store/rooms/rooms.actions';
|
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||||
|
import { ROOM_URL_PATTERN, STORAGE_KEY_CURRENT_USER_ID, STORAGE_KEY_LAST_VISITED_ROUTE } from './core/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root application component.
|
||||||
|
*
|
||||||
|
* Initialises the database, loads persisted user and room data,
|
||||||
|
* handles route restoration, and tracks voice session navigation.
|
||||||
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [CommonModule, RouterOutlet, ServersRailComponent, TitleBarComponent, FloatingVoiceControlsComponent],
|
imports: [CommonModule, RouterOutlet, ServersRailComponent, TitleBarComponent, FloatingVoiceControlsComponent],
|
||||||
@@ -27,9 +35,16 @@ export class App implements OnInit {
|
|||||||
private servers = inject(ServerDirectoryService);
|
private servers = inject(ServerDirectoryService);
|
||||||
private timeSync = inject(TimeSyncService);
|
private timeSync = inject(TimeSyncService);
|
||||||
private voiceSession = inject(VoiceSessionService);
|
private voiceSession = inject(VoiceSessionService);
|
||||||
|
private externalLinks = inject(ExternalLinkService);
|
||||||
|
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
|
/** Intercept all <a> clicks and open them externally. */
|
||||||
|
@HostListener('document:click', ['$event'])
|
||||||
|
onGlobalLinkClick(evt: MouseEvent): void {
|
||||||
|
this.externalLinks.handleClick(evt);
|
||||||
|
}
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
// Initialize database
|
// Initialize database
|
||||||
await this.databaseService.initialize();
|
await this.databaseService.initialize();
|
||||||
@@ -47,13 +62,13 @@ export class App implements OnInit {
|
|||||||
this.store.dispatch(RoomsActions.loadRooms());
|
this.store.dispatch(RoomsActions.loadRooms());
|
||||||
|
|
||||||
// If not authenticated, redirect to login; else restore last route
|
// If not authenticated, redirect to login; else restore last route
|
||||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||||
if (!currentUserId) {
|
if (!currentUserId) {
|
||||||
if (this.router.url !== '/login' && this.router.url !== '/register') {
|
if (this.router.url !== '/login' && this.router.url !== '/register') {
|
||||||
this.router.navigate(['/login']).catch(() => {});
|
this.router.navigate(['/login']).catch(() => {});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const last = localStorage.getItem('metoyou_lastVisitedRoute');
|
const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE);
|
||||||
if (last && typeof last === 'string') {
|
if (last && typeof last === 'string') {
|
||||||
const current = this.router.url;
|
const current = this.router.url;
|
||||||
if (current === '/' || current === '/search') {
|
if (current === '/' || current === '/search') {
|
||||||
@@ -67,11 +82,11 @@ export class App implements OnInit {
|
|||||||
if (evt instanceof NavigationEnd) {
|
if (evt instanceof NavigationEnd) {
|
||||||
const url = evt.urlAfterRedirects || evt.url;
|
const url = evt.urlAfterRedirects || evt.url;
|
||||||
// Store room route or search
|
// Store room route or search
|
||||||
localStorage.setItem('metoyou_lastVisitedRoute', url);
|
localStorage.setItem(STORAGE_KEY_LAST_VISITED_ROUTE, url);
|
||||||
|
|
||||||
// Check if user navigated away from voice-connected server
|
// Check if user navigated away from voice-connected server
|
||||||
// Extract roomId from URL if on a room route
|
// Extract roomId from URL if on a room route
|
||||||
const roomMatch = url.match(/\/room\/([^/]+)/);
|
const roomMatch = url.match(ROOM_URL_PATTERN);
|
||||||
const currentRoomId = roomMatch ? roomMatch[1] : null;
|
const currentRoomId = roomMatch ? roomMatch[1] : null;
|
||||||
|
|
||||||
// Update voice session service with current server context
|
// Update voice session service with current server context
|
||||||
|
|||||||
36
src/app/core/constants.ts
Normal file
36
src/app/core/constants.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Application-wide constants shared across multiple components and services.
|
||||||
|
*
|
||||||
|
* Centralises localStorage keys, common defaults, and UI thresholds
|
||||||
|
* so that magic strings and numbers are defined in one place.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Key used to persist the current user's ID in localStorage. */
|
||||||
|
export const STORAGE_KEY_CURRENT_USER_ID = 'metoyou_currentUserId';
|
||||||
|
|
||||||
|
/** Key used to persist the last visited route for session restore. */
|
||||||
|
export const STORAGE_KEY_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute';
|
||||||
|
|
||||||
|
/** Key used to persist signaling / API connection settings. */
|
||||||
|
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';
|
||||||
|
|
||||||
|
/** Regex that extracts a roomId from a `/room/:roomId` URL path. */
|
||||||
|
export const ROOM_URL_PATTERN = /\/room\/([^/]+)/;
|
||||||
|
|
||||||
|
/** Maximum number of actions retained by NgRx Store devtools. */
|
||||||
|
export const STORE_DEVTOOLS_MAX_AGE = 25;
|
||||||
|
|
||||||
|
/** Default maximum number of users allowed in a new room. */
|
||||||
|
export const DEFAULT_MAX_USERS = 50;
|
||||||
|
|
||||||
|
/** Default audio bitrate in kbps for voice chat. */
|
||||||
|
export const DEFAULT_AUDIO_BITRATE_KBPS = 96;
|
||||||
|
|
||||||
|
/** Default volume level (0–100). */
|
||||||
|
export const DEFAULT_VOLUME = 100;
|
||||||
|
|
||||||
|
/** Default search debounce time in milliseconds. */
|
||||||
|
export const SEARCH_DEBOUNCE_MS = 300;
|
||||||
@@ -1,192 +1,420 @@
|
|||||||
// Models for the P2P Chat Application
|
/**
|
||||||
|
* Core domain models for the MetoYou P2P chat application.
|
||||||
|
*
|
||||||
|
* These interfaces define the data structures shared across
|
||||||
|
* services, store, and components.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Possible online-presence statuses for a user. */
|
||||||
|
export type UserStatus = 'online' | 'away' | 'busy' | 'offline';
|
||||||
|
|
||||||
|
/** Role hierarchy within a room/server. */
|
||||||
|
export type UserRole = 'host' | 'admin' | 'moderator' | 'member';
|
||||||
|
|
||||||
|
/** Channel type within a server. */
|
||||||
|
export type ChannelType = 'text' | 'voice';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an authenticated user in the system.
|
||||||
|
* Users are identified by both a local `id` and a network-wide `oderId`.
|
||||||
|
*/
|
||||||
export interface User {
|
export interface User {
|
||||||
|
/** Local database identifier. */
|
||||||
id: string;
|
id: string;
|
||||||
oderId: string; // Unique order ID for peer identification
|
/** Network-wide unique identifier used for peer identification. */
|
||||||
|
oderId: string;
|
||||||
|
/** Login username (unique per auth server). */
|
||||||
username: string;
|
username: string;
|
||||||
|
/** Human-readable display name shown in the UI. */
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
/** Optional URL to the user's avatar image. */
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
status: 'online' | 'away' | 'busy' | 'offline';
|
/** Current online-presence status. */
|
||||||
role: 'host' | 'admin' | 'moderator' | 'member';
|
status: UserStatus;
|
||||||
|
/** Role within the current room/server. */
|
||||||
|
role: UserRole;
|
||||||
|
/** Epoch timestamp (ms) when the user first joined. */
|
||||||
joinedAt: number;
|
joinedAt: number;
|
||||||
|
/** WebRTC peer identifier (transient, set when connected). */
|
||||||
peerId?: string;
|
peerId?: string;
|
||||||
|
/** Whether the user is currently connected. */
|
||||||
isOnline?: boolean;
|
isOnline?: boolean;
|
||||||
|
/** Whether the user holds admin-level privileges. */
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
|
/** Whether the user is the owner of the current room. */
|
||||||
isRoomOwner?: boolean;
|
isRoomOwner?: boolean;
|
||||||
|
/** Real-time voice connection state. */
|
||||||
voiceState?: VoiceState;
|
voiceState?: VoiceState;
|
||||||
|
/** Real-time screen-sharing state. */
|
||||||
screenShareState?: ScreenShareState;
|
screenShareState?: ScreenShareState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A communication channel within a server (either text or voice).
|
||||||
|
*/
|
||||||
export interface Channel {
|
export interface Channel {
|
||||||
|
/** Unique channel identifier. */
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Display name of the channel. */
|
||||||
name: string;
|
name: string;
|
||||||
type: 'text' | 'voice';
|
/** Whether this is a text chat or voice channel. */
|
||||||
position: number; // ordering within its type group
|
type: ChannelType;
|
||||||
|
/** Sort order within its type group (lower value = higher priority). */
|
||||||
|
position: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single chat message in a room's text channel.
|
||||||
|
*/
|
||||||
export interface Message {
|
export interface Message {
|
||||||
|
/** Unique message identifier. */
|
||||||
id: string;
|
id: string;
|
||||||
|
/** The room this message belongs to. */
|
||||||
roomId: string;
|
roomId: string;
|
||||||
channelId?: string; // which text channel the message belongs to (default: 'general')
|
/** The text channel within the room (defaults to 'general'). */
|
||||||
|
channelId?: string;
|
||||||
|
/** Identifier of the user who sent the message. */
|
||||||
senderId: string;
|
senderId: string;
|
||||||
|
/** Display name of the sender at the time of sending. */
|
||||||
senderName: string;
|
senderName: string;
|
||||||
|
/** Markdown-formatted message body. */
|
||||||
content: string;
|
content: string;
|
||||||
|
/** Epoch timestamp (ms) when the message was created. */
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
/** Epoch timestamp (ms) of the last edit, if any. */
|
||||||
editedAt?: number;
|
editedAt?: number;
|
||||||
|
/** Emoji reactions attached to this message. */
|
||||||
reactions: Reaction[];
|
reactions: Reaction[];
|
||||||
|
/** Whether this message has been soft-deleted. */
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
|
/** If this is a reply, the ID of the parent message. */
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An emoji reaction on a message.
|
||||||
|
*/
|
||||||
export interface Reaction {
|
export interface Reaction {
|
||||||
|
/** Unique reaction identifier. */
|
||||||
id: string;
|
id: string;
|
||||||
|
/** The message this reaction is attached to. */
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
/** Network-wide user ID of the reactor. */
|
||||||
oderId: string;
|
oderId: string;
|
||||||
userId: string; // Alias for backward compatibility
|
/** Alias for `oderId` (kept for backward compatibility). */
|
||||||
|
userId: string;
|
||||||
|
/** The emoji character(s) used. */
|
||||||
emoji: string;
|
emoji: string;
|
||||||
|
/** Epoch timestamp (ms) when the reaction was added. */
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A chat room (server) that users can join to communicate.
|
||||||
|
*/
|
||||||
export interface Room {
|
export interface Room {
|
||||||
|
/** Unique room identifier. */
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Display name of the room. */
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Optional long-form description. */
|
||||||
description?: string;
|
description?: string;
|
||||||
|
/** Short topic/status line shown in the header. */
|
||||||
topic?: string;
|
topic?: string;
|
||||||
|
/** User ID of the room's creator/owner. */
|
||||||
hostId: string;
|
hostId: string;
|
||||||
|
/** Password required to join (if private). */
|
||||||
password?: string;
|
password?: string;
|
||||||
|
/** Whether the room requires a password to join. */
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
|
/** Epoch timestamp (ms) when the room was created. */
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
/** Current number of connected users. */
|
||||||
userCount: number;
|
userCount: number;
|
||||||
|
/** Maximum allowed concurrent users. */
|
||||||
maxUsers?: number;
|
maxUsers?: number;
|
||||||
// Optional server icon synced P2P
|
/** Server icon as a data-URL or remote URL. */
|
||||||
icon?: string; // data URL (e.g., base64 PNG) or remote URL
|
icon?: string;
|
||||||
iconUpdatedAt?: number; // last update timestamp for conflict resolution
|
/** Epoch timestamp (ms) of the last icon update (for conflict resolution). */
|
||||||
// Role-based management permissions
|
iconUpdatedAt?: number;
|
||||||
|
/** Role-based management permission overrides. */
|
||||||
permissions?: RoomPermissions;
|
permissions?: RoomPermissions;
|
||||||
// Channels within the server
|
/** Text and voice channels within the server. */
|
||||||
channels?: Channel[];
|
channels?: Channel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editable subset of room properties exposed in the settings UI.
|
||||||
|
*/
|
||||||
export interface RoomSettings {
|
export interface RoomSettings {
|
||||||
|
/** Room display name. */
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Optional long-form description. */
|
||||||
description?: string;
|
description?: string;
|
||||||
|
/** Short topic/status line. */
|
||||||
topic?: string;
|
topic?: string;
|
||||||
|
/** Whether a password is required to join. */
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
|
/** Password for private rooms. */
|
||||||
password?: string;
|
password?: string;
|
||||||
|
/** Maximum allowed concurrent users. */
|
||||||
maxUsers?: number;
|
maxUsers?: number;
|
||||||
|
/** Optional list of room rules. */
|
||||||
rules?: string[];
|
rules?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fine-grained permission toggles for a room.
|
||||||
|
* Controls which roles can perform management actions.
|
||||||
|
*/
|
||||||
export interface RoomPermissions {
|
export interface RoomPermissions {
|
||||||
// Whether admins can manage chat/voice rooms creation and modifications
|
/** Whether admins can create/modify rooms. */
|
||||||
adminsManageRooms?: boolean;
|
adminsManageRooms?: boolean;
|
||||||
|
/** Whether moderators can create/modify rooms. */
|
||||||
moderatorsManageRooms?: boolean;
|
moderatorsManageRooms?: boolean;
|
||||||
// Whether admins/moderators can change server icon
|
/** Whether admins can change the server icon. */
|
||||||
adminsManageIcon?: boolean;
|
adminsManageIcon?: boolean;
|
||||||
|
/** Whether moderators can change the server icon. */
|
||||||
moderatorsManageIcon?: boolean;
|
moderatorsManageIcon?: boolean;
|
||||||
// Existing capability toggles
|
/** Whether voice channels are enabled. */
|
||||||
allowVoice?: boolean;
|
allowVoice?: boolean;
|
||||||
|
/** Whether screen sharing is enabled. */
|
||||||
allowScreenShare?: boolean;
|
allowScreenShare?: boolean;
|
||||||
|
/** Whether file uploads are enabled. */
|
||||||
allowFileUploads?: boolean;
|
allowFileUploads?: boolean;
|
||||||
|
/** Minimum delay (seconds) between messages (0 = disabled). */
|
||||||
slowModeInterval?: number;
|
slowModeInterval?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A record of a user being banned from a room.
|
||||||
|
*/
|
||||||
export interface BanEntry {
|
export interface BanEntry {
|
||||||
|
/** Unique ban identifier (also used as the banned user's oderId). */
|
||||||
oderId: string;
|
oderId: string;
|
||||||
|
/** The banned user's local ID. */
|
||||||
userId: string;
|
userId: string;
|
||||||
|
/** The room the ban applies to. */
|
||||||
roomId: string;
|
roomId: string;
|
||||||
|
/** User ID of the admin who issued the ban. */
|
||||||
bannedBy: string;
|
bannedBy: string;
|
||||||
|
/** Display name of the banned user at the time of banning. */
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
/** Human-readable reason for the ban. */
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
/** Epoch timestamp (ms) when the ban expires (undefined = permanent). */
|
||||||
expiresAt?: number;
|
expiresAt?: number;
|
||||||
|
/** Epoch timestamp (ms) when the ban was issued. */
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks the state of a WebRTC peer connection.
|
||||||
|
*/
|
||||||
export interface PeerConnection {
|
export interface PeerConnection {
|
||||||
|
/** Remote peer identifier. */
|
||||||
peerId: string;
|
peerId: string;
|
||||||
|
/** Local user identifier. */
|
||||||
userId: string;
|
userId: string;
|
||||||
|
/** Current connection lifecycle state. */
|
||||||
status: 'connecting' | 'connected' | 'disconnected' | 'failed';
|
status: 'connecting' | 'connected' | 'disconnected' | 'failed';
|
||||||
|
/** The RTCDataChannel used for P2P messaging. */
|
||||||
dataChannel?: RTCDataChannel;
|
dataChannel?: RTCDataChannel;
|
||||||
|
/** The underlying RTCPeerConnection. */
|
||||||
connection?: RTCPeerConnection;
|
connection?: RTCPeerConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real-time voice connection state for a user in a voice channel.
|
||||||
|
*/
|
||||||
export interface VoiceState {
|
export interface VoiceState {
|
||||||
|
/** Whether the user is connected to a voice channel. */
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
|
/** Whether the user's microphone is muted (self or by admin). */
|
||||||
isMuted: boolean;
|
isMuted: boolean;
|
||||||
|
/** Whether the user has deafened themselves. */
|
||||||
isDeafened: boolean;
|
isDeafened: boolean;
|
||||||
|
/** Whether the user is currently speaking (voice activity detection). */
|
||||||
isSpeaking: boolean;
|
isSpeaking: boolean;
|
||||||
|
/** Whether the user was server-muted by an admin. */
|
||||||
isMutedByAdmin?: boolean;
|
isMutedByAdmin?: boolean;
|
||||||
|
/** User's output volume level (0-1). */
|
||||||
volume?: number;
|
volume?: number;
|
||||||
/** The voice channel/room ID within a server (e.g., 'general', 'afk') */
|
/** The voice channel ID within the server (e.g. 'vc-general'). */
|
||||||
roomId?: string;
|
roomId?: string;
|
||||||
/** The server ID the user is connected to voice in */
|
/** The server ID the user is connected to voice in. */
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real-time screen-sharing state for a user.
|
||||||
|
*/
|
||||||
export interface ScreenShareState {
|
export interface ScreenShareState {
|
||||||
|
/** Whether the user is actively sharing their screen. */
|
||||||
isSharing: boolean;
|
isSharing: boolean;
|
||||||
|
/** MediaStream ID of the screen capture. */
|
||||||
streamId?: string;
|
streamId?: string;
|
||||||
|
/** Desktop capturer source ID (Electron only). */
|
||||||
sourceId?: string;
|
sourceId?: string;
|
||||||
|
/** Human-readable name of the captured source. */
|
||||||
sourceName?: string;
|
sourceName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** All signaling message types exchanged via the WebSocket relay. */
|
||||||
|
export type SignalingMessageType =
|
||||||
|
| 'offer'
|
||||||
|
| 'answer'
|
||||||
|
| 'ice-candidate'
|
||||||
|
| 'join'
|
||||||
|
| 'leave'
|
||||||
|
| 'chat'
|
||||||
|
| 'state-sync'
|
||||||
|
| 'kick'
|
||||||
|
| 'ban'
|
||||||
|
| 'host-change'
|
||||||
|
| 'room-update';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A message exchanged via the signaling WebSocket server.
|
||||||
|
*/
|
||||||
export interface SignalingMessage {
|
export interface SignalingMessage {
|
||||||
type: 'offer' | 'answer' | 'ice-candidate' | 'join' | 'leave' | 'chat' | 'state-sync' | 'kick' | 'ban' | 'host-change' | 'room-update';
|
/** The type of signaling event. */
|
||||||
|
type: SignalingMessageType;
|
||||||
|
/** Sender's peer ID. */
|
||||||
from: string;
|
from: string;
|
||||||
|
/** Optional target peer ID (for directed messages). */
|
||||||
to?: string;
|
to?: string;
|
||||||
|
/** Arbitrary payload specific to the message type. */
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
|
/** Epoch timestamp (ms) when the message was sent. */
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** All P2P chat event types exchanged via RTCDataChannel. */
|
||||||
|
export type ChatEventType =
|
||||||
|
| 'message'
|
||||||
|
| 'chat-message'
|
||||||
|
| 'edit'
|
||||||
|
| 'message-edited'
|
||||||
|
| 'delete'
|
||||||
|
| 'message-deleted'
|
||||||
|
| 'reaction'
|
||||||
|
| 'reaction-added'
|
||||||
|
| 'reaction-removed'
|
||||||
|
| 'kick'
|
||||||
|
| 'ban'
|
||||||
|
| 'room-deleted'
|
||||||
|
| 'room-settings-update'
|
||||||
|
| 'voice-state'
|
||||||
|
| 'voice-state-request'
|
||||||
|
| 'state-request'
|
||||||
|
| 'screen-state'
|
||||||
|
| 'role-change'
|
||||||
|
| 'channels-update';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A P2P event exchanged between peers via RTCDataChannel.
|
||||||
|
* The `type` field determines which optional fields are populated.
|
||||||
|
*/
|
||||||
export interface ChatEvent {
|
export interface ChatEvent {
|
||||||
type: 'message' | 'chat-message' | 'edit' | 'message-edited' | 'delete' | 'message-deleted' | 'reaction' | 'reaction-added' | 'reaction-removed' | 'kick' | 'ban' | 'room-deleted' | 'room-settings-update' | 'voice-state' | 'voice-state-request' | 'state-request' | 'screen-state' | 'role-change' | 'channels-update';
|
/** The type of P2P event. */
|
||||||
|
type: ChatEventType;
|
||||||
|
/** Relevant message ID (for edits, deletes, reactions). */
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
|
/** Full message payload (for new messages). */
|
||||||
message?: Message;
|
message?: Message;
|
||||||
|
/** Reaction payload (for reaction events). */
|
||||||
reaction?: Reaction;
|
reaction?: Reaction;
|
||||||
|
/** Partial message updates (for edits). */
|
||||||
data?: Partial<Message>;
|
data?: Partial<Message>;
|
||||||
|
/** Event timestamp. */
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
|
/** Target user ID (for kick/ban). */
|
||||||
targetUserId?: string;
|
targetUserId?: string;
|
||||||
|
/** Room ID the event pertains to. */
|
||||||
roomId?: string;
|
roomId?: string;
|
||||||
|
/** User who issued a kick. */
|
||||||
kickedBy?: string;
|
kickedBy?: string;
|
||||||
|
/** User who issued a ban. */
|
||||||
bannedBy?: string;
|
bannedBy?: string;
|
||||||
|
/** Text content (for messages/edits). */
|
||||||
content?: string;
|
content?: string;
|
||||||
|
/** Edit timestamp. */
|
||||||
editedAt?: number;
|
editedAt?: number;
|
||||||
|
/** User who performed a delete. */
|
||||||
deletedBy?: string;
|
deletedBy?: string;
|
||||||
|
/** Network-wide user identifier. */
|
||||||
oderId?: string;
|
oderId?: string;
|
||||||
|
/** Display name of the event sender. */
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
/** Emoji character (for reactions). */
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
|
/** Ban/kick reason. */
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
/** Updated room settings. */
|
||||||
settings?: RoomSettings;
|
settings?: RoomSettings;
|
||||||
|
/** Partial voice state update. */
|
||||||
voiceState?: Partial<VoiceState>;
|
voiceState?: Partial<VoiceState>;
|
||||||
|
/** Screen-sharing flag. */
|
||||||
isScreenSharing?: boolean;
|
isScreenSharing?: boolean;
|
||||||
role?: 'host' | 'admin' | 'moderator' | 'member';
|
/** New role assignment. */
|
||||||
|
role?: UserRole;
|
||||||
|
/** Updated channel list. */
|
||||||
channels?: Channel[];
|
channels?: Channel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server listing as returned by the directory API.
|
||||||
|
*/
|
||||||
export interface ServerInfo {
|
export interface ServerInfo {
|
||||||
|
/** Unique server identifier. */
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Display name. */
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Optional description. */
|
||||||
description?: string;
|
description?: string;
|
||||||
|
/** Optional topic. */
|
||||||
topic?: string;
|
topic?: string;
|
||||||
|
/** Display name of the host. */
|
||||||
hostName: string;
|
hostName: string;
|
||||||
|
/** Owner's user ID. */
|
||||||
ownerId?: string;
|
ownerId?: string;
|
||||||
|
/** Owner's public key / oderId. */
|
||||||
ownerPublicKey?: string;
|
ownerPublicKey?: string;
|
||||||
|
/** Current number of connected users. */
|
||||||
userCount: number;
|
userCount: number;
|
||||||
|
/** Maximum allowed users. */
|
||||||
maxUsers: number;
|
maxUsers: number;
|
||||||
|
/** Whether a password is required. */
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
|
/** Searchable tags. */
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
/** Epoch timestamp (ms) when the server was created. */
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request payload for joining a server.
|
||||||
|
*/
|
||||||
export interface JoinRequest {
|
export interface JoinRequest {
|
||||||
|
/** Target room/server ID. */
|
||||||
roomId: string;
|
roomId: string;
|
||||||
|
/** Requesting user's ID. */
|
||||||
userId: string;
|
userId: string;
|
||||||
|
/** Requesting user's username. */
|
||||||
username: string;
|
username: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-level application state snapshot (used for diagnostics).
|
||||||
|
*/
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
|
/** The currently authenticated user, or null if logged out. */
|
||||||
currentUser: User | null;
|
currentUser: User | null;
|
||||||
|
/** The room the user is currently viewing, or null. */
|
||||||
currentRoom: Room | null;
|
currentRoom: Room | null;
|
||||||
|
/** Whether a connection attempt is in progress. */
|
||||||
isConnecting: boolean;
|
isConnecting: boolean;
|
||||||
|
/** Last error message, or null. */
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,43 +1,98 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
|
||||||
import { ServerDirectoryService, ServerEndpoint } from './server-directory.service';
|
import { ServerDirectoryService, ServerEndpoint } from './server-directory.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response returned by the authentication endpoints (login / register).
|
||||||
|
*/
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
|
/** Unique user identifier assigned by the server. */
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Login username. */
|
||||||
username: string;
|
username: string;
|
||||||
|
/** Human-readable display name. */
|
||||||
displayName: string;
|
displayName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fallback API base URL used when no server endpoint is configured. */
|
||||||
|
const DEFAULT_API_BASE = 'http://localhost:3001/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles user authentication (login and registration) against a
|
||||||
|
* configurable back-end server.
|
||||||
|
*
|
||||||
|
* The target server is resolved via {@link ServerDirectoryService}: the
|
||||||
|
* caller may pass an explicit `serverId`, otherwise the currently active
|
||||||
|
* server endpoint is used.
|
||||||
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
private serverDirectory = inject(ServerDirectoryService);
|
private readonly serverDirectory = inject(ServerDirectoryService);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the API base URL for the given server.
|
||||||
|
*
|
||||||
|
* @param serverId - Optional server ID to look up. When omitted the
|
||||||
|
* currently active endpoint is used.
|
||||||
|
* @returns Fully-qualified API base URL (e.g. `http://host:3001/api`).
|
||||||
|
*/
|
||||||
private endpointFor(serverId?: string): string {
|
private endpointFor(serverId?: string): string {
|
||||||
let base: ServerEndpoint | undefined;
|
let endpoint: ServerEndpoint | undefined;
|
||||||
|
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
base = this.serverDirectory.servers().find((s) => s.id === serverId);
|
endpoint = this.serverDirectory.servers().find(
|
||||||
|
(server) => server.id === serverId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const active = base || this.serverDirectory.activeServer();
|
|
||||||
return active ? `${active.url}/api` : 'http://localhost:3001/api';
|
const activeEndpoint = endpoint ?? this.serverDirectory.activeServer();
|
||||||
|
return activeEndpoint ? `${activeEndpoint.url}/api` : DEFAULT_API_BASE;
|
||||||
}
|
}
|
||||||
|
|
||||||
register(params: { username: string; password: string; displayName?: string; serverId?: string }): Observable<LoginResponse> {
|
/**
|
||||||
|
* Register a new user account on the target server.
|
||||||
|
*
|
||||||
|
* @param params - Registration parameters.
|
||||||
|
* @param params.username - Desired login username.
|
||||||
|
* @param params.password - Account password.
|
||||||
|
* @param params.displayName - Optional display name (defaults to username on the server).
|
||||||
|
* @param params.serverId - Optional server ID to register against.
|
||||||
|
* @returns Observable emitting the {@link LoginResponse} on success.
|
||||||
|
*/
|
||||||
|
register(params: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
displayName?: string;
|
||||||
|
serverId?: string;
|
||||||
|
}): Observable<LoginResponse> {
|
||||||
const url = `${this.endpointFor(params.serverId)}/users/register`;
|
const url = `${this.endpointFor(params.serverId)}/users/register`;
|
||||||
return this.http.post<LoginResponse>(url, {
|
return this.http.post<LoginResponse>(url, {
|
||||||
username: params.username,
|
username: params.username,
|
||||||
password: params.password,
|
password: params.password,
|
||||||
displayName: params.displayName,
|
displayName: params.displayName,
|
||||||
}).pipe(map((resp) => resp));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
login(params: { username: string; password: string; serverId?: string }): Observable<LoginResponse> {
|
/**
|
||||||
|
* Log in to an existing user account on the target server.
|
||||||
|
*
|
||||||
|
* @param params - Login parameters.
|
||||||
|
* @param params.username - Login username.
|
||||||
|
* @param params.password - Account password.
|
||||||
|
* @param params.serverId - Optional server ID to authenticate against.
|
||||||
|
* @returns Observable emitting the {@link LoginResponse} on success.
|
||||||
|
*/
|
||||||
|
login(params: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
serverId?: string;
|
||||||
|
}): Observable<LoginResponse> {
|
||||||
const url = `${this.endpointFor(params.serverId)}/users/login`;
|
const url = `${this.endpointFor(params.serverId)}/users/login`;
|
||||||
return this.http.post<LoginResponse>(url, {
|
return this.http.post<LoginResponse>(url, {
|
||||||
username: params.username,
|
username: params.username,
|
||||||
password: params.password,
|
password: params.password,
|
||||||
}).pipe(map((resp) => resp));
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,233 +1,319 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Message, User, Room, Reaction, BanEntry } from '../models';
|
import { Message, User, Room, Reaction, BanEntry } from '../models';
|
||||||
|
|
||||||
const DB_NAME = 'metoyou';
|
/** IndexedDB database name for the MetoYou application. */
|
||||||
const DB_VERSION = 2;
|
const DATABASE_NAME = 'metoyou';
|
||||||
|
|
||||||
|
/** IndexedDB schema version — bump when adding/changing object stores. */
|
||||||
|
const DATABASE_VERSION = 2;
|
||||||
|
|
||||||
|
/** Names of every object store used by the application. */
|
||||||
|
const STORE_MESSAGES = 'messages';
|
||||||
|
const STORE_USERS = 'users';
|
||||||
|
const STORE_ROOMS = 'rooms';
|
||||||
|
const STORE_REACTIONS = 'reactions';
|
||||||
|
const STORE_BANS = 'bans';
|
||||||
|
const STORE_META = 'meta';
|
||||||
|
const STORE_ATTACHMENTS = 'attachments';
|
||||||
|
|
||||||
|
/** All object store names, used when clearing the entire database. */
|
||||||
|
const ALL_STORE_NAMES: string[] = [
|
||||||
|
STORE_MESSAGES,
|
||||||
|
STORE_USERS,
|
||||||
|
STORE_ROOMS,
|
||||||
|
STORE_REACTIONS,
|
||||||
|
STORE_BANS,
|
||||||
|
STORE_ATTACHMENTS,
|
||||||
|
STORE_META,
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IndexedDB-backed database service used when the app runs in a
|
* IndexedDB-backed database service used when the app runs in a
|
||||||
* plain browser (i.e. without Electron).
|
* plain browser (i.e. without Electron).
|
||||||
*
|
*
|
||||||
* Every public method mirrors the DatabaseService API so the
|
* Every public method mirrors the {@link DatabaseService} API so the
|
||||||
* facade can delegate transparently.
|
* facade can delegate transparently.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class BrowserDatabaseService {
|
export class BrowserDatabaseService {
|
||||||
private db: IDBDatabase | null = null;
|
/** Handle to the opened IndexedDB database, or `null` before {@link initialize}. */
|
||||||
|
private database: IDBDatabase | null = null;
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Lifecycle */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
|
/** Open (or create) the IndexedDB database. Safe to call multiple times. */
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
if (this.db) return;
|
if (this.database) return;
|
||||||
this.db = await this.openDatabase();
|
this.database = await this.openDatabase();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/** Persist a single message. */
|
||||||
/* Messages */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
async saveMessage(message: Message): Promise<void> {
|
async saveMessage(message: Message): Promise<void> {
|
||||||
await this.put('messages', message);
|
await this.put(STORE_MESSAGES, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve messages for a room, sorted oldest-first.
|
||||||
|
*
|
||||||
|
* @param roomId - Target room.
|
||||||
|
* @param limit - Maximum number of messages to return.
|
||||||
|
* @param offset - Number of messages to skip (for pagination).
|
||||||
|
*/
|
||||||
async getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> {
|
async getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> {
|
||||||
const all = await this.getAllFromIndex<Message>('messages', 'roomId', roomId);
|
const allRoomMessages = await this.getAllFromIndex<Message>(
|
||||||
return all
|
STORE_MESSAGES, 'roomId', roomId,
|
||||||
.sort((a, b) => a.timestamp - b.timestamp)
|
);
|
||||||
|
return allRoomMessages
|
||||||
|
.sort((first, second) => first.timestamp - second.timestamp)
|
||||||
.slice(offset, offset + limit);
|
.slice(offset, offset + limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete a message by its ID. */
|
||||||
async deleteMessage(messageId: string): Promise<void> {
|
async deleteMessage(messageId: string): Promise<void> {
|
||||||
await this.delete('messages', messageId);
|
await this.deleteRecord(STORE_MESSAGES, messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Apply partial updates to an existing message. */
|
||||||
async updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
|
async updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
|
||||||
const msg = await this.get<Message>('messages', messageId);
|
const existing = await this.get<Message>(STORE_MESSAGES, messageId);
|
||||||
if (msg) await this.put('messages', { ...msg, ...updates });
|
if (existing) {
|
||||||
|
await this.put(STORE_MESSAGES, { ...existing, ...updates });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Retrieve a single message by ID, or `null` if not found. */
|
||||||
async getMessageById(messageId: string): Promise<Message | null> {
|
async getMessageById(messageId: string): Promise<Message | null> {
|
||||||
return (await this.get<Message>('messages', messageId)) ?? null;
|
return (await this.get<Message>(STORE_MESSAGES, messageId)) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove every message belonging to a room. */
|
||||||
async clearRoomMessages(roomId: string): Promise<void> {
|
async clearRoomMessages(roomId: string): Promise<void> {
|
||||||
const msgs = await this.getAllFromIndex<Message>('messages', 'roomId', roomId);
|
const messages = await this.getAllFromIndex<Message>(
|
||||||
const tx = this.transaction('messages', 'readwrite');
|
STORE_MESSAGES, 'roomId', roomId,
|
||||||
for (const m of msgs) tx.objectStore('messages').delete(m.id);
|
|
||||||
await this.complete(tx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Reactions */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
async saveReaction(reaction: Reaction): Promise<void> {
|
|
||||||
const existing = await this.getAllFromIndex<Reaction>('reactions', 'messageId', reaction.messageId);
|
|
||||||
const dup = existing.some(
|
|
||||||
(r) => r.userId === reaction.userId && r.emoji === reaction.emoji,
|
|
||||||
);
|
);
|
||||||
if (!dup) await this.put('reactions', reaction);
|
const transaction = this.createTransaction(STORE_MESSAGES, 'readwrite');
|
||||||
|
for (const message of messages) {
|
||||||
|
transaction.objectStore(STORE_MESSAGES).delete(message.id);
|
||||||
|
}
|
||||||
|
await this.awaitTransaction(transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a reaction, ignoring duplicates (same user + same emoji on
|
||||||
|
* the same message).
|
||||||
|
*/
|
||||||
|
async saveReaction(reaction: Reaction): Promise<void> {
|
||||||
|
const existing = await this.getAllFromIndex<Reaction>(
|
||||||
|
STORE_REACTIONS, 'messageId', reaction.messageId,
|
||||||
|
);
|
||||||
|
const isDuplicate = existing.some(
|
||||||
|
(entry) => entry.userId === reaction.userId && entry.emoji === reaction.emoji,
|
||||||
|
);
|
||||||
|
if (!isDuplicate) {
|
||||||
|
await this.put(STORE_REACTIONS, reaction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a specific reaction (identified by user + emoji + message). */
|
||||||
async removeReaction(messageId: string, userId: string, emoji: string): Promise<void> {
|
async removeReaction(messageId: string, userId: string, emoji: string): Promise<void> {
|
||||||
const all = await this.getAllFromIndex<Reaction>('reactions', 'messageId', messageId);
|
const reactions = await this.getAllFromIndex<Reaction>(
|
||||||
const target = all.find((r) => r.userId === userId && r.emoji === emoji);
|
STORE_REACTIONS, 'messageId', messageId,
|
||||||
if (target) await this.delete('reactions', target.id);
|
);
|
||||||
|
const target = reactions.find(
|
||||||
|
(entry) => entry.userId === userId && entry.emoji === emoji,
|
||||||
|
);
|
||||||
|
if (target) {
|
||||||
|
await this.deleteRecord(STORE_REACTIONS, target.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return all reactions for a given message. */
|
||||||
async getReactionsForMessage(messageId: string): Promise<Reaction[]> {
|
async getReactionsForMessage(messageId: string): Promise<Reaction[]> {
|
||||||
return this.getAllFromIndex<Reaction>('reactions', 'messageId', messageId);
|
return this.getAllFromIndex<Reaction>(STORE_REACTIONS, 'messageId', messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/** Persist a user record. */
|
||||||
/* Users */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
async saveUser(user: User): Promise<void> {
|
async saveUser(user: User): Promise<void> {
|
||||||
await this.put('users', user);
|
await this.put(STORE_USERS, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Retrieve a user by ID, or `null` if not found. */
|
||||||
async getUser(userId: string): Promise<User | null> {
|
async getUser(userId: string): Promise<User | null> {
|
||||||
return (await this.get<User>('users', userId)) ?? null;
|
return (await this.get<User>(STORE_USERS, userId)) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Retrieve the last-authenticated ("current") user, or `null`. */
|
||||||
async getCurrentUser(): Promise<User | null> {
|
async getCurrentUser(): Promise<User | null> {
|
||||||
const meta = await this.get<{ id: string; value: string }>('meta', 'currentUserId');
|
const meta = await this.get<{ id: string; value: string }>(
|
||||||
|
STORE_META, 'currentUserId',
|
||||||
|
);
|
||||||
if (!meta) return null;
|
if (!meta) return null;
|
||||||
return this.getUser(meta.value);
|
return this.getUser(meta.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Store which user ID is considered "current" (logged-in). */
|
||||||
async setCurrentUserId(userId: string): Promise<void> {
|
async setCurrentUserId(userId: string): Promise<void> {
|
||||||
await this.put('meta', { id: 'currentUserId', value: userId });
|
await this.put(STORE_META, { id: 'currentUserId', value: userId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve all known users.
|
||||||
|
* @param _roomId - Accepted for API parity but currently unused.
|
||||||
|
*/
|
||||||
async getUsersByRoom(_roomId: string): Promise<User[]> {
|
async getUsersByRoom(_roomId: string): Promise<User[]> {
|
||||||
return this.getAll<User>('users');
|
return this.getAll<User>(STORE_USERS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Apply partial updates to an existing user. */
|
||||||
async updateUser(userId: string, updates: Partial<User>): Promise<void> {
|
async updateUser(userId: string, updates: Partial<User>): Promise<void> {
|
||||||
const user = await this.get<User>('users', userId);
|
const existing = await this.get<User>(STORE_USERS, userId);
|
||||||
if (user) await this.put('users', { ...user, ...updates });
|
if (existing) {
|
||||||
|
await this.put(STORE_USERS, { ...existing, ...updates });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/** Persist a room record. */
|
||||||
/* Rooms */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
async saveRoom(room: Room): Promise<void> {
|
async saveRoom(room: Room): Promise<void> {
|
||||||
await this.put('rooms', room);
|
await this.put(STORE_ROOMS, room);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Retrieve a room by ID, or `null` if not found. */
|
||||||
async getRoom(roomId: string): Promise<Room | null> {
|
async getRoom(roomId: string): Promise<Room | null> {
|
||||||
return (await this.get<Room>('rooms', roomId)) ?? null;
|
return (await this.get<Room>(STORE_ROOMS, roomId)) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return every persisted room. */
|
||||||
async getAllRooms(): Promise<Room[]> {
|
async getAllRooms(): Promise<Room[]> {
|
||||||
return this.getAll<Room>('rooms');
|
return this.getAll<Room>(STORE_ROOMS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete a room and all of its messages. */
|
||||||
async deleteRoom(roomId: string): Promise<void> {
|
async deleteRoom(roomId: string): Promise<void> {
|
||||||
await this.delete('rooms', roomId);
|
await this.deleteRecord(STORE_ROOMS, roomId);
|
||||||
await this.clearRoomMessages(roomId);
|
await this.clearRoomMessages(roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Apply partial updates to an existing room. */
|
||||||
async updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
|
async updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
|
||||||
const room = await this.get<Room>('rooms', roomId);
|
const existing = await this.get<Room>(STORE_ROOMS, roomId);
|
||||||
if (room) await this.put('rooms', { ...room, ...updates });
|
if (existing) {
|
||||||
|
await this.put(STORE_ROOMS, { ...existing, ...updates });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/** Persist a ban entry. */
|
||||||
/* Bans */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
async saveBan(ban: BanEntry): Promise<void> {
|
async saveBan(ban: BanEntry): Promise<void> {
|
||||||
await this.put('bans', ban);
|
await this.put(STORE_BANS, ban);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove a ban by the banned user's `oderId`. */
|
||||||
async removeBan(oderId: string): Promise<void> {
|
async removeBan(oderId: string): Promise<void> {
|
||||||
const all = await this.getAll<BanEntry>('bans');
|
const allBans = await this.getAll<BanEntry>(STORE_BANS);
|
||||||
const match = all.find((b) => b.oderId === oderId);
|
const match = allBans.find((ban) => ban.oderId === oderId);
|
||||||
if (match) await this.delete('bans', (match as any).id ?? match.oderId);
|
if (match) {
|
||||||
|
await this.deleteRecord(
|
||||||
|
STORE_BANS,
|
||||||
|
(match as any).id ?? match.oderId,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return active (non-expired) bans for a room.
|
||||||
|
*
|
||||||
|
* @param roomId - Room to query.
|
||||||
|
*/
|
||||||
async getBansForRoom(roomId: string): Promise<BanEntry[]> {
|
async getBansForRoom(roomId: string): Promise<BanEntry[]> {
|
||||||
const all = await this.getAllFromIndex<BanEntry>('bans', 'roomId', roomId);
|
const allBans = await this.getAllFromIndex<BanEntry>(
|
||||||
|
STORE_BANS, 'roomId', roomId,
|
||||||
|
);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return all.filter((b) => !b.expiresAt || b.expiresAt > now);
|
return allBans.filter(
|
||||||
|
(ban) => !ban.expiresAt || ban.expiresAt > now,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Check whether a specific user is currently banned from a room. */
|
||||||
async isUserBanned(userId: string, roomId: string): Promise<boolean> {
|
async isUserBanned(userId: string, roomId: string): Promise<boolean> {
|
||||||
const bans = await this.getBansForRoom(roomId);
|
const activeBans = await this.getBansForRoom(roomId);
|
||||||
return bans.some((b) => b.oderId === userId);
|
return activeBans.some((ban) => ban.oderId === userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/** Persist an attachment metadata record. */
|
||||||
/* Attachments */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
async saveAttachment(attachment: any): Promise<void> {
|
async saveAttachment(attachment: any): Promise<void> {
|
||||||
await this.put('attachments', attachment);
|
await this.put(STORE_ATTACHMENTS, attachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return all attachment records for a message. */
|
||||||
async getAttachmentsForMessage(messageId: string): Promise<any[]> {
|
async getAttachmentsForMessage(messageId: string): Promise<any[]> {
|
||||||
return this.getAllFromIndex<any>('attachments', 'messageId', messageId);
|
return this.getAllFromIndex<any>(STORE_ATTACHMENTS, 'messageId', messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return every persisted attachment record. */
|
||||||
async getAllAttachments(): Promise<any[]> {
|
async getAllAttachments(): Promise<any[]> {
|
||||||
return this.getAll<any>('attachments');
|
return this.getAll<any>(STORE_ATTACHMENTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete all attachment records for a message. */
|
||||||
async deleteAttachmentsForMessage(messageId: string): Promise<void> {
|
async deleteAttachmentsForMessage(messageId: string): Promise<void> {
|
||||||
const atts = await this.getAllFromIndex<any>('attachments', 'messageId', messageId);
|
const attachments = await this.getAllFromIndex<any>(
|
||||||
if (atts.length === 0) return;
|
STORE_ATTACHMENTS, 'messageId', messageId,
|
||||||
const tx = this.transaction('attachments', 'readwrite');
|
);
|
||||||
for (const a of atts) tx.objectStore('attachments').delete(a.id);
|
if (attachments.length === 0) return;
|
||||||
await this.complete(tx);
|
|
||||||
|
const transaction = this.createTransaction(STORE_ATTACHMENTS, 'readwrite');
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
transaction.objectStore(STORE_ATTACHMENTS).delete(attachment.id);
|
||||||
|
}
|
||||||
|
await this.awaitTransaction(transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wipe every object store, removing all persisted data. */
|
||||||
async clearAllData(): Promise<void> {
|
async clearAllData(): Promise<void> {
|
||||||
const storeNames: string[] = ['messages', 'users', 'rooms', 'reactions', 'bans', 'attachments', 'meta'];
|
const transaction = this.createTransaction(ALL_STORE_NAMES, 'readwrite');
|
||||||
const tx = this.transaction(storeNames, 'readwrite');
|
for (const storeName of ALL_STORE_NAMES) {
|
||||||
for (const name of storeNames) tx.objectStore(name).clear();
|
transaction.objectStore(storeName).clear();
|
||||||
await this.complete(tx);
|
}
|
||||||
|
await this.awaitTransaction(transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================================================================== */
|
// ══════════════════════════════════════════════════════════════════
|
||||||
/* Private helpers – thin wrappers around IndexedDB */
|
// Private helpers — thin wrappers around IndexedDB
|
||||||
/* ================================================================== */
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open (or upgrade) the IndexedDB database and create any missing
|
||||||
|
* object stores.
|
||||||
|
*/
|
||||||
private openDatabase(): Promise<IDBDatabase> {
|
private openDatabase(): Promise<IDBDatabase> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
|
||||||
|
|
||||||
request.onupgradeneeded = () => {
|
request.onupgradeneeded = () => {
|
||||||
const db = request.result;
|
const database = request.result;
|
||||||
if (!db.objectStoreNames.contains('messages')) {
|
|
||||||
const msgs = db.createObjectStore('messages', { keyPath: 'id' });
|
if (!database.objectStoreNames.contains(STORE_MESSAGES)) {
|
||||||
msgs.createIndex('roomId', 'roomId', { unique: false });
|
const messagesStore = database.createObjectStore(STORE_MESSAGES, { keyPath: 'id' });
|
||||||
|
messagesStore.createIndex('roomId', 'roomId', { unique: false });
|
||||||
}
|
}
|
||||||
if (!db.objectStoreNames.contains('users')) {
|
if (!database.objectStoreNames.contains(STORE_USERS)) {
|
||||||
db.createObjectStore('users', { keyPath: 'id' });
|
database.createObjectStore(STORE_USERS, { keyPath: 'id' });
|
||||||
}
|
}
|
||||||
if (!db.objectStoreNames.contains('rooms')) {
|
if (!database.objectStoreNames.contains(STORE_ROOMS)) {
|
||||||
db.createObjectStore('rooms', { keyPath: 'id' });
|
database.createObjectStore(STORE_ROOMS, { keyPath: 'id' });
|
||||||
}
|
}
|
||||||
if (!db.objectStoreNames.contains('reactions')) {
|
if (!database.objectStoreNames.contains(STORE_REACTIONS)) {
|
||||||
const rxns = db.createObjectStore('reactions', { keyPath: 'id' });
|
const reactionsStore = database.createObjectStore(STORE_REACTIONS, { keyPath: 'id' });
|
||||||
rxns.createIndex('messageId', 'messageId', { unique: false });
|
reactionsStore.createIndex('messageId', 'messageId', { unique: false });
|
||||||
}
|
}
|
||||||
if (!db.objectStoreNames.contains('bans')) {
|
if (!database.objectStoreNames.contains(STORE_BANS)) {
|
||||||
const bans = db.createObjectStore('bans', { keyPath: 'oderId' });
|
const bansStore = database.createObjectStore(STORE_BANS, { keyPath: 'oderId' });
|
||||||
bans.createIndex('roomId', 'roomId', { unique: false });
|
bansStore.createIndex('roomId', 'roomId', { unique: false });
|
||||||
}
|
}
|
||||||
if (!db.objectStoreNames.contains('meta')) {
|
if (!database.objectStoreNames.contains(STORE_META)) {
|
||||||
db.createObjectStore('meta', { keyPath: 'id' });
|
database.createObjectStore(STORE_META, { keyPath: 'id' });
|
||||||
}
|
}
|
||||||
if (!db.objectStoreNames.contains('attachments')) {
|
if (!database.objectStoreNames.contains(STORE_ATTACHMENTS)) {
|
||||||
const atts = db.createObjectStore('attachments', { keyPath: 'id' });
|
const attachmentsStore = database.createObjectStore(STORE_ATTACHMENTS, { keyPath: 'id' });
|
||||||
atts.createIndex('messageId', 'messageId', { unique: false });
|
attachmentsStore.createIndex('messageId', 'messageId', { unique: false });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -236,67 +322,74 @@ export class BrowserDatabaseService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private transaction(
|
/** Create an IndexedDB transaction on one or more stores. */
|
||||||
|
private createTransaction(
|
||||||
stores: string | string[],
|
stores: string | string[],
|
||||||
mode: IDBTransactionMode = 'readonly',
|
mode: IDBTransactionMode = 'readonly',
|
||||||
): IDBTransaction {
|
): IDBTransaction {
|
||||||
return this.db!.transaction(stores, mode);
|
return this.database!.transaction(stores, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private complete(tx: IDBTransaction): Promise<void> {
|
/** Wrap a transaction's completion event as a Promise. */
|
||||||
|
private awaitTransaction(transaction: IDBTransaction): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
tx.oncomplete = () => resolve();
|
transaction.oncomplete = () => resolve();
|
||||||
tx.onerror = () => reject(tx.error);
|
transaction.onerror = () => reject(transaction.error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private get<T>(store: string, key: IDBValidKey): Promise<T | undefined> {
|
/** Retrieve a single record by primary key. */
|
||||||
|
private get<T>(storeName: string, key: IDBValidKey): Promise<T | undefined> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tx = this.transaction(store);
|
const transaction = this.createTransaction(storeName);
|
||||||
const req = tx.objectStore(store).get(key);
|
const request = transaction.objectStore(storeName).get(key);
|
||||||
req.onsuccess = () => resolve(req.result as T | undefined);
|
request.onsuccess = () => resolve(request.result as T | undefined);
|
||||||
req.onerror = () => reject(req.error);
|
request.onerror = () => reject(request.error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getAll<T>(store: string): Promise<T[]> {
|
/** Retrieve every record from an object store. */
|
||||||
|
private getAll<T>(storeName: string): Promise<T[]> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tx = this.transaction(store);
|
const transaction = this.createTransaction(storeName);
|
||||||
const req = tx.objectStore(store).getAll();
|
const request = transaction.objectStore(storeName).getAll();
|
||||||
req.onsuccess = () => resolve(req.result as T[]);
|
request.onsuccess = () => resolve(request.result as T[]);
|
||||||
req.onerror = () => reject(req.error);
|
request.onerror = () => reject(request.error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Retrieve all records from an index that match a key. */
|
||||||
private getAllFromIndex<T>(
|
private getAllFromIndex<T>(
|
||||||
store: string,
|
storeName: string,
|
||||||
indexName: string,
|
indexName: string,
|
||||||
key: IDBValidKey,
|
key: IDBValidKey,
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tx = this.transaction(store);
|
const transaction = this.createTransaction(storeName);
|
||||||
const idx = tx.objectStore(store).index(indexName);
|
const index = transaction.objectStore(storeName).index(indexName);
|
||||||
const req = idx.getAll(key);
|
const request = index.getAll(key);
|
||||||
req.onsuccess = () => resolve(req.result as T[]);
|
request.onsuccess = () => resolve(request.result as T[]);
|
||||||
req.onerror = () => reject(req.error);
|
request.onerror = () => reject(request.error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private put(store: string, value: any): Promise<void> {
|
/** Insert or update a record in the given object store. */
|
||||||
|
private put(storeName: string, value: any): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tx = this.transaction(store, 'readwrite');
|
const transaction = this.createTransaction(storeName, 'readwrite');
|
||||||
tx.objectStore(store).put(value);
|
transaction.objectStore(storeName).put(value);
|
||||||
tx.oncomplete = () => resolve();
|
transaction.oncomplete = () => resolve();
|
||||||
tx.onerror = () => reject(tx.error);
|
transaction.onerror = () => reject(transaction.error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private delete(store: string, key: IDBValidKey): Promise<void> {
|
/** Delete a record by primary key. */
|
||||||
|
private deleteRecord(storeName: string, key: IDBValidKey): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tx = this.transaction(store, 'readwrite');
|
const transaction = this.createTransaction(storeName, 'readwrite');
|
||||||
tx.objectStore(store).delete(key);
|
transaction.objectStore(storeName).delete(key);
|
||||||
tx.oncomplete = () => resolve();
|
transaction.oncomplete = () => resolve();
|
||||||
tx.onerror = () => reject(tx.error);
|
transaction.onerror = () => reject(transaction.error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,101 +5,119 @@ import { BrowserDatabaseService } from './browser-database.service';
|
|||||||
import { ElectronDatabaseService } from './electron-database.service';
|
import { ElectronDatabaseService } from './electron-database.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Facade database service.
|
* Facade database service that transparently delegates to the correct
|
||||||
|
* storage backend based on the runtime platform.
|
||||||
*
|
*
|
||||||
* - **Electron** → delegates to {@link ElectronDatabaseService} which
|
* - **Electron** → SQLite via {@link ElectronDatabaseService} (IPC to main process).
|
||||||
* persists data in a local SQLite file (via sql.js + Electron IPC).
|
* - **Browser** → IndexedDB via {@link BrowserDatabaseService}.
|
||||||
* - **Browser** → delegates to {@link BrowserDatabaseService} which
|
|
||||||
* persists data in IndexedDB.
|
|
||||||
*
|
*
|
||||||
* All consumers keep injecting `DatabaseService` – the underlying storage
|
* All consumers inject `DatabaseService` — the underlying storage engine
|
||||||
* engine is selected automatically at startup.
|
* is selected automatically.
|
||||||
*/
|
*/
|
||||||
@Injectable({
|
@Injectable({ providedIn: 'root' })
|
||||||
providedIn: 'root',
|
|
||||||
})
|
|
||||||
export class DatabaseService {
|
export class DatabaseService {
|
||||||
private readonly platform = inject(PlatformService);
|
private readonly platform = inject(PlatformService);
|
||||||
private readonly browserDb = inject(BrowserDatabaseService);
|
private readonly browserDb = inject(BrowserDatabaseService);
|
||||||
private readonly electronDb = inject(ElectronDatabaseService);
|
private readonly electronDb = inject(ElectronDatabaseService);
|
||||||
|
|
||||||
|
/** Reactive flag: `true` once {@link initialize} has completed. */
|
||||||
isReady = signal(false);
|
isReady = signal(false);
|
||||||
|
|
||||||
/** The active backend for the current platform. */
|
/** The active storage backend for the current platform. */
|
||||||
private get backend() {
|
private get backend() {
|
||||||
return this.platform.isBrowser ? this.browserDb : this.electronDb;
|
return this.platform.isBrowser ? this.browserDb : this.electronDb;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/** Initialise the platform-specific database. */
|
||||||
/* Lifecycle */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
await this.backend.initialize();
|
await this.backend.initialize();
|
||||||
this.isReady.set(true);
|
this.isReady.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/** Persist a single chat message. */
|
||||||
/* Messages */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
saveMessage(message: Message) { return this.backend.saveMessage(message); }
|
saveMessage(message: Message) { return this.backend.saveMessage(message); }
|
||||||
|
|
||||||
|
/** Retrieve messages for a room with optional pagination. */
|
||||||
getMessages(roomId: string, limit = 100, offset = 0) { return this.backend.getMessages(roomId, limit, offset); }
|
getMessages(roomId: string, limit = 100, offset = 0) { return this.backend.getMessages(roomId, limit, offset); }
|
||||||
|
|
||||||
|
/** Permanently delete a message by ID. */
|
||||||
deleteMessage(messageId: string) { return this.backend.deleteMessage(messageId); }
|
deleteMessage(messageId: string) { return this.backend.deleteMessage(messageId); }
|
||||||
|
|
||||||
|
/** Apply partial updates to an existing message. */
|
||||||
updateMessage(messageId: string, updates: Partial<Message>) { return this.backend.updateMessage(messageId, updates); }
|
updateMessage(messageId: string, updates: Partial<Message>) { return this.backend.updateMessage(messageId, updates); }
|
||||||
|
|
||||||
|
/** Retrieve a single message by ID. */
|
||||||
getMessageById(messageId: string) { return this.backend.getMessageById(messageId); }
|
getMessageById(messageId: string) { return this.backend.getMessageById(messageId); }
|
||||||
|
|
||||||
|
/** Remove every message belonging to a room. */
|
||||||
clearRoomMessages(roomId: string) { return this.backend.clearRoomMessages(roomId); }
|
clearRoomMessages(roomId: string) { return this.backend.clearRoomMessages(roomId); }
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/** Persist a reaction. */
|
||||||
/* Reactions */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
saveReaction(reaction: Reaction) { return this.backend.saveReaction(reaction); }
|
saveReaction(reaction: Reaction) { return this.backend.saveReaction(reaction); }
|
||||||
|
|
||||||
|
/** Remove a specific reaction (user + emoji + message). */
|
||||||
removeReaction(messageId: string, userId: string, emoji: string) { return this.backend.removeReaction(messageId, userId, emoji); }
|
removeReaction(messageId: string, userId: string, emoji: string) { return this.backend.removeReaction(messageId, userId, emoji); }
|
||||||
|
|
||||||
|
/** Return all reactions for a given message. */
|
||||||
getReactionsForMessage(messageId: string) { return this.backend.getReactionsForMessage(messageId); }
|
getReactionsForMessage(messageId: string) { return this.backend.getReactionsForMessage(messageId); }
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/** Persist a user record. */
|
||||||
/* Users */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
saveUser(user: User) { return this.backend.saveUser(user); }
|
saveUser(user: User) { return this.backend.saveUser(user); }
|
||||||
|
|
||||||
|
/** Retrieve a user by ID. */
|
||||||
getUser(userId: string) { return this.backend.getUser(userId); }
|
getUser(userId: string) { return this.backend.getUser(userId); }
|
||||||
|
|
||||||
|
/** Retrieve the current (logged-in) user. */
|
||||||
getCurrentUser() { return this.backend.getCurrentUser(); }
|
getCurrentUser() { return this.backend.getCurrentUser(); }
|
||||||
|
|
||||||
|
/** Store the current user ID. */
|
||||||
setCurrentUserId(userId: string) { return this.backend.setCurrentUserId(userId); }
|
setCurrentUserId(userId: string) { return this.backend.setCurrentUserId(userId); }
|
||||||
|
|
||||||
|
/** Retrieve users in a room. */
|
||||||
getUsersByRoom(roomId: string) { return this.backend.getUsersByRoom(roomId); }
|
getUsersByRoom(roomId: string) { return this.backend.getUsersByRoom(roomId); }
|
||||||
|
|
||||||
|
/** Apply partial updates to an existing user. */
|
||||||
updateUser(userId: string, updates: Partial<User>) { return this.backend.updateUser(userId, updates); }
|
updateUser(userId: string, updates: Partial<User>) { return this.backend.updateUser(userId, updates); }
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/** Persist a room record. */
|
||||||
/* Rooms */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
saveRoom(room: Room) { return this.backend.saveRoom(room); }
|
saveRoom(room: Room) { return this.backend.saveRoom(room); }
|
||||||
|
|
||||||
|
/** Retrieve a room by ID. */
|
||||||
getRoom(roomId: string) { return this.backend.getRoom(roomId); }
|
getRoom(roomId: string) { return this.backend.getRoom(roomId); }
|
||||||
|
|
||||||
|
/** Return every persisted room. */
|
||||||
getAllRooms() { return this.backend.getAllRooms(); }
|
getAllRooms() { return this.backend.getAllRooms(); }
|
||||||
|
|
||||||
|
/** Delete a room and its associated messages. */
|
||||||
deleteRoom(roomId: string) { return this.backend.deleteRoom(roomId); }
|
deleteRoom(roomId: string) { return this.backend.deleteRoom(roomId); }
|
||||||
|
|
||||||
|
/** Apply partial updates to an existing room. */
|
||||||
updateRoom(roomId: string, updates: Partial<Room>) { return this.backend.updateRoom(roomId, updates); }
|
updateRoom(roomId: string, updates: Partial<Room>) { return this.backend.updateRoom(roomId, updates); }
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/** Persist a ban entry. */
|
||||||
/* Bans */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
saveBan(ban: BanEntry) { return this.backend.saveBan(ban); }
|
saveBan(ban: BanEntry) { return this.backend.saveBan(ban); }
|
||||||
|
|
||||||
|
/** Remove a ban by oderId. */
|
||||||
removeBan(oderId: string) { return this.backend.removeBan(oderId); }
|
removeBan(oderId: string) { return this.backend.removeBan(oderId); }
|
||||||
|
|
||||||
|
/** Return active bans for a room. */
|
||||||
getBansForRoom(roomId: string) { return this.backend.getBansForRoom(roomId); }
|
getBansForRoom(roomId: string) { return this.backend.getBansForRoom(roomId); }
|
||||||
|
|
||||||
|
/** Check whether a user is currently banned from a room. */
|
||||||
isUserBanned(userId: string, roomId: string) { return this.backend.isUserBanned(userId, roomId); }
|
isUserBanned(userId: string, roomId: string) { return this.backend.isUserBanned(userId, roomId); }
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/** Persist attachment metadata. */
|
||||||
/* Attachments */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
saveAttachment(attachment: any) { return this.backend.saveAttachment(attachment); }
|
saveAttachment(attachment: any) { return this.backend.saveAttachment(attachment); }
|
||||||
|
|
||||||
|
/** Return all attachment records for a message. */
|
||||||
getAttachmentsForMessage(messageId: string) { return this.backend.getAttachmentsForMessage(messageId); }
|
getAttachmentsForMessage(messageId: string) { return this.backend.getAttachmentsForMessage(messageId); }
|
||||||
|
|
||||||
|
/** Return every persisted attachment record. */
|
||||||
getAllAttachments() { return this.backend.getAllAttachments(); }
|
getAllAttachments() { return this.backend.getAllAttachments(); }
|
||||||
|
|
||||||
|
/** Delete all attachment records for a message. */
|
||||||
deleteAttachmentsForMessage(messageId: string) { return this.backend.deleteAttachmentsForMessage(messageId); }
|
deleteAttachmentsForMessage(messageId: string) { return this.backend.deleteAttachmentsForMessage(messageId); }
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/** Wipe all persisted data. */
|
||||||
/* Utilities */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
clearAllData() { return this.backend.clearAllData(); }
|
clearAllData() { return this.backend.clearAllData(); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,163 +10,168 @@ import { Message, User, Room, Reaction, BanEntry } from '../models';
|
|||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ElectronDatabaseService {
|
export class ElectronDatabaseService {
|
||||||
private initialized = false;
|
/** Whether {@link initialize} has already been called successfully. */
|
||||||
|
private isInitialised = false;
|
||||||
|
|
||||||
/** Shorthand for the preload-exposed database API. */
|
/** Shorthand accessor for the preload-exposed database API. */
|
||||||
private get api() {
|
private get api() {
|
||||||
return (window as any).electronAPI.db;
|
return (window as any).electronAPI.db;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/** Initialise the SQLite database via the main-process IPC bridge. */
|
||||||
/* Lifecycle */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
if (this.initialized) return;
|
if (this.isInitialised) return;
|
||||||
await this.api.initialize();
|
await this.api.initialize();
|
||||||
this.initialized = true;
|
this.isInitialised = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/** Persist a single chat message. */
|
||||||
/* Messages */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
saveMessage(message: Message): Promise<void> {
|
saveMessage(message: Message): Promise<void> {
|
||||||
return this.api.saveMessage(message);
|
return this.api.saveMessage(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve messages for a room, sorted oldest-first.
|
||||||
|
*
|
||||||
|
* @param roomId - Target room.
|
||||||
|
* @param limit - Maximum number of messages to return.
|
||||||
|
* @param offset - Number of messages to skip (for pagination).
|
||||||
|
*/
|
||||||
getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> {
|
getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> {
|
||||||
return this.api.getMessages(roomId, limit, offset);
|
return this.api.getMessages(roomId, limit, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Permanently delete a message by ID. */
|
||||||
deleteMessage(messageId: string): Promise<void> {
|
deleteMessage(messageId: string): Promise<void> {
|
||||||
return this.api.deleteMessage(messageId);
|
return this.api.deleteMessage(messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Apply partial updates to an existing message. */
|
||||||
updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
|
updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
|
||||||
return this.api.updateMessage(messageId, updates);
|
return this.api.updateMessage(messageId, updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Retrieve a single message by ID, or `null` if not found. */
|
||||||
getMessageById(messageId: string): Promise<Message | null> {
|
getMessageById(messageId: string): Promise<Message | null> {
|
||||||
return this.api.getMessageById(messageId);
|
return this.api.getMessageById(messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove every message belonging to a room. */
|
||||||
clearRoomMessages(roomId: string): Promise<void> {
|
clearRoomMessages(roomId: string): Promise<void> {
|
||||||
return this.api.clearRoomMessages(roomId);
|
return this.api.clearRoomMessages(roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/** Persist a reaction (deduplication is handled server-side). */
|
||||||
/* Reactions */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
saveReaction(reaction: Reaction): Promise<void> {
|
saveReaction(reaction: Reaction): Promise<void> {
|
||||||
return this.api.saveReaction(reaction);
|
return this.api.saveReaction(reaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove a specific reaction (user + emoji + message). */
|
||||||
removeReaction(messageId: string, userId: string, emoji: string): Promise<void> {
|
removeReaction(messageId: string, userId: string, emoji: string): Promise<void> {
|
||||||
return this.api.removeReaction(messageId, userId, emoji);
|
return this.api.removeReaction(messageId, userId, emoji);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return all reactions for a given message. */
|
||||||
getReactionsForMessage(messageId: string): Promise<Reaction[]> {
|
getReactionsForMessage(messageId: string): Promise<Reaction[]> {
|
||||||
return this.api.getReactionsForMessage(messageId);
|
return this.api.getReactionsForMessage(messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/** Persist a user record. */
|
||||||
/* Users */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
saveUser(user: User): Promise<void> {
|
saveUser(user: User): Promise<void> {
|
||||||
return this.api.saveUser(user);
|
return this.api.saveUser(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Retrieve a user by ID, or `null` if not found. */
|
||||||
getUser(userId: string): Promise<User | null> {
|
getUser(userId: string): Promise<User | null> {
|
||||||
return this.api.getUser(userId);
|
return this.api.getUser(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Retrieve the last-authenticated ("current") user, or `null`. */
|
||||||
getCurrentUser(): Promise<User | null> {
|
getCurrentUser(): Promise<User | null> {
|
||||||
return this.api.getCurrentUser();
|
return this.api.getCurrentUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Store which user ID is considered "current" (logged-in). */
|
||||||
setCurrentUserId(userId: string): Promise<void> {
|
setCurrentUserId(userId: string): Promise<void> {
|
||||||
return this.api.setCurrentUserId(userId);
|
return this.api.setCurrentUserId(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Retrieve users associated with a room. */
|
||||||
getUsersByRoom(roomId: string): Promise<User[]> {
|
getUsersByRoom(roomId: string): Promise<User[]> {
|
||||||
return this.api.getUsersByRoom(roomId);
|
return this.api.getUsersByRoom(roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Apply partial updates to an existing user. */
|
||||||
updateUser(userId: string, updates: Partial<User>): Promise<void> {
|
updateUser(userId: string, updates: Partial<User>): Promise<void> {
|
||||||
return this.api.updateUser(userId, updates);
|
return this.api.updateUser(userId, updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/** Persist a room record. */
|
||||||
/* Rooms */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
saveRoom(room: Room): Promise<void> {
|
saveRoom(room: Room): Promise<void> {
|
||||||
return this.api.saveRoom(room);
|
return this.api.saveRoom(room);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Retrieve a room by ID, or `null` if not found. */
|
||||||
getRoom(roomId: string): Promise<Room | null> {
|
getRoom(roomId: string): Promise<Room | null> {
|
||||||
return this.api.getRoom(roomId);
|
return this.api.getRoom(roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return every persisted room. */
|
||||||
getAllRooms(): Promise<Room[]> {
|
getAllRooms(): Promise<Room[]> {
|
||||||
return this.api.getAllRooms();
|
return this.api.getAllRooms();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete a room by ID. */
|
||||||
deleteRoom(roomId: string): Promise<void> {
|
deleteRoom(roomId: string): Promise<void> {
|
||||||
return this.api.deleteRoom(roomId);
|
return this.api.deleteRoom(roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Apply partial updates to an existing room. */
|
||||||
updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
|
updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
|
||||||
return this.api.updateRoom(roomId, updates);
|
return this.api.updateRoom(roomId, updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/** Persist a ban entry. */
|
||||||
/* Bans */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
saveBan(ban: BanEntry): Promise<void> {
|
saveBan(ban: BanEntry): Promise<void> {
|
||||||
return this.api.saveBan(ban);
|
return this.api.saveBan(ban);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove a ban by the banned user's `oderId`. */
|
||||||
removeBan(oderId: string): Promise<void> {
|
removeBan(oderId: string): Promise<void> {
|
||||||
return this.api.removeBan(oderId);
|
return this.api.removeBan(oderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return active bans for a room. */
|
||||||
getBansForRoom(roomId: string): Promise<BanEntry[]> {
|
getBansForRoom(roomId: string): Promise<BanEntry[]> {
|
||||||
return this.api.getBansForRoom(roomId);
|
return this.api.getBansForRoom(roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Check whether a user is currently banned from a room. */
|
||||||
isUserBanned(userId: string, roomId: string): Promise<boolean> {
|
isUserBanned(userId: string, roomId: string): Promise<boolean> {
|
||||||
return this.api.isUserBanned(userId, roomId);
|
return this.api.isUserBanned(userId, roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/** Persist attachment metadata. */
|
||||||
/* Attachments */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
saveAttachment(attachment: any): Promise<void> {
|
saveAttachment(attachment: any): Promise<void> {
|
||||||
return this.api.saveAttachment(attachment);
|
return this.api.saveAttachment(attachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return all attachment records for a message. */
|
||||||
getAttachmentsForMessage(messageId: string): Promise<any[]> {
|
getAttachmentsForMessage(messageId: string): Promise<any[]> {
|
||||||
return this.api.getAttachmentsForMessage(messageId);
|
return this.api.getAttachmentsForMessage(messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return every persisted attachment record. */
|
||||||
getAllAttachments(): Promise<any[]> {
|
getAllAttachments(): Promise<any[]> {
|
||||||
return this.api.getAllAttachments();
|
return this.api.getAllAttachments();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete all attachment records for a message. */
|
||||||
deleteAttachmentsForMessage(messageId: string): Promise<void> {
|
deleteAttachmentsForMessage(messageId: string): Promise<void> {
|
||||||
return this.api.deleteAttachmentsForMessage(messageId);
|
return this.api.deleteAttachmentsForMessage(messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/** Wipe every table, removing all persisted data. */
|
||||||
/* Utilities */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
clearAllData(): Promise<void> {
|
clearAllData(): Promise<void> {
|
||||||
return this.api.clearAllData();
|
return this.api.clearAllData();
|
||||||
}
|
}
|
||||||
|
|||||||
51
src/app/core/services/external-link.service.ts
Normal file
51
src/app/core/services/external-link.service.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { PlatformService } from './platform.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens URLs in the system default browser (Electron) or a new tab (browser).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* inject(ExternalLinkService).open('https://example.com');
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ExternalLinkService {
|
||||||
|
private platform = inject(PlatformService);
|
||||||
|
|
||||||
|
/** Open a URL externally. Only http/https URLs are allowed. */
|
||||||
|
open(url: string): void {
|
||||||
|
if (!url || !(url.startsWith('http://') || url.startsWith('https://'))) return;
|
||||||
|
|
||||||
|
if (this.platform.isElectron) {
|
||||||
|
(window as any).electronAPI?.openExternal(url);
|
||||||
|
} else {
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click handler for anchor elements. Call from a (click) binding or HostListener.
|
||||||
|
* Returns true if the click was handled (link opened externally), false otherwise.
|
||||||
|
*/
|
||||||
|
handleClick(evt: MouseEvent): boolean {
|
||||||
|
const target = (evt.target as HTMLElement)?.closest('a') as HTMLAnchorElement | null;
|
||||||
|
if (!target) return false;
|
||||||
|
|
||||||
|
const href = target.href; // resolved full URL
|
||||||
|
if (!href) return false;
|
||||||
|
|
||||||
|
// Skip non-navigable URLs
|
||||||
|
if (href.startsWith('javascript:') || href.startsWith('blob:') || href.startsWith('data:')) return false;
|
||||||
|
|
||||||
|
// Skip same-page anchors
|
||||||
|
const rawAttr = target.getAttribute('href');
|
||||||
|
if (rawAttr?.startsWith('#')) return false;
|
||||||
|
|
||||||
|
// Skip Angular router links
|
||||||
|
if (target.hasAttribute('routerlink') || target.hasAttribute('ng-reflect-router-link')) return false;
|
||||||
|
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
this.open(href);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,3 +5,4 @@ export * from './database.service';
|
|||||||
export * from './webrtc.service';
|
export * from './webrtc.service';
|
||||||
export * from './server-directory.service';
|
export * from './server-directory.service';
|
||||||
export * from './voice-session.service';
|
export * from './voice-session.service';
|
||||||
|
export * from './external-link.service';
|
||||||
|
|||||||
@@ -5,423 +5,530 @@ import { catchError, map } from 'rxjs/operators';
|
|||||||
import { ServerInfo, JoinRequest, User } from '../models';
|
import { ServerInfo, JoinRequest, User } from '../models';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A configured server endpoint that the user can connect to.
|
||||||
|
*/
|
||||||
export interface ServerEndpoint {
|
export interface ServerEndpoint {
|
||||||
|
/** Unique endpoint identifier. */
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Human-readable label shown in the UI. */
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Base URL (e.g. `http://localhost:3001`). */
|
||||||
url: string;
|
url: string;
|
||||||
|
/** Whether this is the currently selected endpoint. */
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
/** Whether this is the built-in default endpoint. */
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
|
/** Most recent health-check result. */
|
||||||
status: 'online' | 'offline' | 'checking' | 'unknown';
|
status: 'online' | 'offline' | 'checking' | 'unknown';
|
||||||
|
/** Last measured round-trip latency (ms). */
|
||||||
latency?: number;
|
latency?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'metoyou_server_endpoints';
|
/** localStorage key that persists the user's configured endpoints. */
|
||||||
|
const ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
|
||||||
|
|
||||||
/** Derive default server URL from current page protocol (handles SSL toggle). */
|
/** Timeout (ms) for server health-check and alternative-endpoint pings. */
|
||||||
function getDefaultServerUrl(): string {
|
const HEALTH_CHECK_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the default server URL from the current page protocol so that
|
||||||
|
* SSL/TLS is matched automatically.
|
||||||
|
*/
|
||||||
|
function buildDefaultServerUrl(): string {
|
||||||
if (typeof window !== 'undefined' && window.location) {
|
if (typeof window !== 'undefined' && window.location) {
|
||||||
const proto = window.location.protocol === 'https:' ? 'https' : 'http';
|
const protocol = window.location.protocol === 'https:' ? 'https' : 'http';
|
||||||
return `${proto}://localhost:3001`;
|
return `${protocol}://localhost:3001`;
|
||||||
}
|
}
|
||||||
return 'http://localhost:3001';
|
return 'http://localhost:3001';
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SERVER: Omit<ServerEndpoint, 'id'> = {
|
/** Blueprint for the built-in default endpoint. */
|
||||||
|
const DEFAULT_ENDPOINT: Omit<ServerEndpoint, 'id'> = {
|
||||||
name: 'Local Server',
|
name: 'Local Server',
|
||||||
url: getDefaultServerUrl(),
|
url: buildDefaultServerUrl(),
|
||||||
isActive: true,
|
isActive: true,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
status: 'unknown',
|
status: 'unknown',
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable({
|
/**
|
||||||
providedIn: 'root',
|
* Manages the user's list of configured server endpoints and
|
||||||
})
|
* provides an HTTP client for server-directory API calls
|
||||||
|
* (search, register, join/leave, heartbeat, etc.).
|
||||||
|
*
|
||||||
|
* Endpoints are persisted in `localStorage` and exposed as
|
||||||
|
* Angular signals for reactive consumption.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ServerDirectoryService {
|
export class ServerDirectoryService {
|
||||||
private readonly _servers = signal<ServerEndpoint[]>([]);
|
private readonly _servers = signal<ServerEndpoint[]>([]);
|
||||||
private _searchAllServers = false;
|
|
||||||
|
|
||||||
|
/** Whether search queries should be fanned out to all non-offline endpoints. */
|
||||||
|
private shouldSearchAllServers = false;
|
||||||
|
|
||||||
|
/** Reactive list of all configured endpoints. */
|
||||||
readonly servers = computed(() => this._servers());
|
readonly servers = computed(() => this._servers());
|
||||||
readonly activeServer = computed(() => this._servers().find((s) => s.isActive) || this._servers()[0]);
|
|
||||||
|
|
||||||
constructor(private http: HttpClient) {
|
/** The currently active endpoint, falling back to the first in the list. */
|
||||||
this.loadServers();
|
readonly activeServer = computed(
|
||||||
|
() => this._servers().find((endpoint) => endpoint.isActive) ?? this._servers()[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
constructor(private readonly http: HttpClient) {
|
||||||
|
this.loadEndpoints();
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadServers(): void {
|
/**
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
* Add a new server endpoint (inactive by default).
|
||||||
if (stored) {
|
*
|
||||||
try {
|
* @param server - Name and URL of the endpoint to add.
|
||||||
let servers = JSON.parse(stored) as ServerEndpoint[];
|
*/
|
||||||
// Ensure at least one is active
|
addServer(server: { name: string; url: string }): void {
|
||||||
if (!servers.some((s) => s.isActive) && servers.length > 0) {
|
const sanitisedUrl = this.sanitiseUrl(server.url);
|
||||||
servers[0].isActive = true;
|
const newEndpoint: ServerEndpoint = {
|
||||||
}
|
id: uuidv4(),
|
||||||
// Migrate default localhost entries to match current protocol
|
name: server.name,
|
||||||
const expectedProto = (typeof window !== 'undefined' && window.location?.protocol === 'https:') ? 'https' : 'http';
|
url: sanitisedUrl,
|
||||||
servers = servers.map((s) => {
|
isActive: false,
|
||||||
if (s.isDefault && /^https?:\/\/localhost:\d+$/.test(s.url)) {
|
isDefault: false,
|
||||||
return { ...s, url: s.url.replace(/^https?/, expectedProto) };
|
status: 'unknown',
|
||||||
}
|
};
|
||||||
return s;
|
this._servers.update((endpoints) => [...endpoints, newEndpoint]);
|
||||||
});
|
this.saveEndpoints();
|
||||||
this._servers.set(servers);
|
}
|
||||||
this.saveServers();
|
|
||||||
} catch {
|
/**
|
||||||
this.initializeDefaultServer();
|
* Remove an endpoint by ID.
|
||||||
|
* The built-in default endpoint cannot be removed. If the removed
|
||||||
|
* endpoint was active, the first remaining endpoint is activated.
|
||||||
|
*/
|
||||||
|
removeServer(endpointId: string): void {
|
||||||
|
const endpoints = this._servers();
|
||||||
|
const target = endpoints.find((endpoint) => endpoint.id === endpointId);
|
||||||
|
if (target?.isDefault) return;
|
||||||
|
|
||||||
|
const wasActive = target?.isActive;
|
||||||
|
this._servers.update((list) => list.filter((endpoint) => endpoint.id !== endpointId));
|
||||||
|
|
||||||
|
if (wasActive) {
|
||||||
|
this._servers.update((list) => {
|
||||||
|
if (list.length > 0) list[0].isActive = true;
|
||||||
|
return [...list];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.saveEndpoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Activate a specific endpoint and deactivate all others. */
|
||||||
|
setActiveServer(endpointId: string): void {
|
||||||
|
this._servers.update((endpoints) =>
|
||||||
|
endpoints.map((endpoint) => ({
|
||||||
|
...endpoint,
|
||||||
|
isActive: endpoint.id === endpointId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
this.saveEndpoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the health status and optional latency of an endpoint. */
|
||||||
|
updateServerStatus(
|
||||||
|
endpointId: string,
|
||||||
|
status: ServerEndpoint['status'],
|
||||||
|
latency?: number,
|
||||||
|
): void {
|
||||||
|
this._servers.update((endpoints) =>
|
||||||
|
endpoints.map((endpoint) =>
|
||||||
|
endpoint.id === endpointId ? { ...endpoint, status, latency } : endpoint,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
this.saveEndpoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enable or disable fan-out search across all endpoints. */
|
||||||
|
setSearchAllServers(enabled: boolean): void {
|
||||||
|
this.shouldSearchAllServers = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe a single endpoint's health and update its status.
|
||||||
|
*
|
||||||
|
* @param endpointId - ID of the endpoint to test.
|
||||||
|
* @returns `true` if the server responded successfully.
|
||||||
|
*/
|
||||||
|
async testServer(endpointId: string): Promise<boolean> {
|
||||||
|
const endpoint = this._servers().find((entry) => entry.id === endpointId);
|
||||||
|
if (!endpoint) return false;
|
||||||
|
|
||||||
|
this.updateServerStatus(endpointId, 'checking');
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${endpoint.url}/api/health`, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
const latency = Date.now() - startTime;
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
this.updateServerStatus(endpointId, 'online', latency);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
} else {
|
this.updateServerStatus(endpointId, 'offline');
|
||||||
this.initializeDefaultServer();
|
return false;
|
||||||
|
} catch {
|
||||||
|
// Fall back to the /servers endpoint
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${endpoint.url}/api/servers`, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
const latency = Date.now() - startTime;
|
||||||
|
if (response.ok) {
|
||||||
|
this.updateServerStatus(endpointId, 'online', latency);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch { /* both checks failed */ }
|
||||||
|
this.updateServerStatus(endpointId, 'offline');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private initializeDefaultServer(): void {
|
/** Probe all configured endpoints in parallel. */
|
||||||
const defaultServer: ServerEndpoint = {
|
async testAllServers(): Promise<void> {
|
||||||
...DEFAULT_SERVER,
|
const endpoints = this._servers();
|
||||||
id: uuidv4(),
|
await Promise.all(endpoints.map((endpoint) => this.testServer(endpoint.id)));
|
||||||
};
|
|
||||||
this._servers.set([defaultServer]);
|
|
||||||
this.saveServers();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private saveServers(): void {
|
/** Expose the API base URL for external consumers. */
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this._servers()));
|
getApiBaseUrl(): string {
|
||||||
|
return this.buildApiBaseUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
private get baseUrl(): string {
|
/** Get the WebSocket URL derived from the active endpoint. */
|
||||||
|
getWebSocketUrl(): string {
|
||||||
const active = this.activeServer();
|
const active = this.activeServer();
|
||||||
const raw = active ? active.url : getDefaultServerUrl();
|
if (!active) {
|
||||||
// Strip trailing slashes and any accidental '/api'
|
const protocol = (typeof window !== 'undefined' && window.location?.protocol === 'https:') ? 'wss' : 'ws';
|
||||||
let base = raw.replace(/\/+$/,'');
|
return `${protocol}://localhost:3001`;
|
||||||
|
}
|
||||||
|
return active.url.replace(/^http/, 'ws');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for public servers matching a query string.
|
||||||
|
* When {@link shouldSearchAllServers} is `true`, the search is
|
||||||
|
* fanned out to every non-offline endpoint.
|
||||||
|
*/
|
||||||
|
searchServers(query: string): Observable<ServerInfo[]> {
|
||||||
|
if (this.shouldSearchAllServers) {
|
||||||
|
return this.searchAllEndpoints(query);
|
||||||
|
}
|
||||||
|
return this.searchSingleEndpoint(query, this.buildApiBaseUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retrieve the full list of public servers. */
|
||||||
|
getServers(): Observable<ServerInfo[]> {
|
||||||
|
if (this.shouldSearchAllServers) {
|
||||||
|
return this.getAllServersFromAllEndpoints();
|
||||||
|
}
|
||||||
|
return this.http
|
||||||
|
.get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`)
|
||||||
|
.pipe(
|
||||||
|
map((response) => this.unwrapServersResponse(response)),
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Failed to get servers:', error);
|
||||||
|
return of([]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch details for a single server. */
|
||||||
|
getServer(serverId: string): Observable<ServerInfo | null> {
|
||||||
|
return this.http
|
||||||
|
.get<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`)
|
||||||
|
.pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Failed to get server:', error);
|
||||||
|
return of(null);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a new server listing in the directory. */
|
||||||
|
registerServer(
|
||||||
|
server: Omit<ServerInfo, 'createdAt'> & { id?: string },
|
||||||
|
): Observable<ServerInfo> {
|
||||||
|
return this.http
|
||||||
|
.post<ServerInfo>(`${this.buildApiBaseUrl()}/servers`, server)
|
||||||
|
.pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Failed to register server:', error);
|
||||||
|
return throwError(() => error);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update an existing server listing. */
|
||||||
|
updateServer(
|
||||||
|
serverId: string,
|
||||||
|
updates: Partial<ServerInfo>,
|
||||||
|
): Observable<ServerInfo> {
|
||||||
|
return this.http
|
||||||
|
.patch<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`, updates)
|
||||||
|
.pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Failed to update server:', error);
|
||||||
|
return throwError(() => error);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a server listing from the directory. */
|
||||||
|
unregisterServer(serverId: string): Observable<void> {
|
||||||
|
return this.http
|
||||||
|
.delete<void>(`${this.buildApiBaseUrl()}/servers/${serverId}`)
|
||||||
|
.pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Failed to unregister server:', error);
|
||||||
|
return throwError(() => error);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retrieve users currently connected to a server. */
|
||||||
|
getServerUsers(serverId: string): Observable<User[]> {
|
||||||
|
return this.http
|
||||||
|
.get<User[]>(`${this.buildApiBaseUrl()}/servers/${serverId}/users`)
|
||||||
|
.pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Failed to get server users:', error);
|
||||||
|
return of([]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a join request for a server and receive the signaling URL. */
|
||||||
|
requestJoin(
|
||||||
|
request: JoinRequest,
|
||||||
|
): Observable<{ success: boolean; signalingUrl?: string }> {
|
||||||
|
return this.http
|
||||||
|
.post<{ success: boolean; signalingUrl?: string }>(
|
||||||
|
`${this.buildApiBaseUrl()}/servers/${request.roomId}/join`,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Failed to send join request:', error);
|
||||||
|
return throwError(() => error);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notify the directory that a user has left a server. */
|
||||||
|
notifyLeave(serverId: string, userId: string): Observable<void> {
|
||||||
|
return this.http
|
||||||
|
.post<void>(`${this.buildApiBaseUrl()}/servers/${serverId}/leave`, { userId })
|
||||||
|
.pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Failed to notify leave:', error);
|
||||||
|
return of(undefined);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the live user count for a server listing. */
|
||||||
|
updateUserCount(serverId: string, count: number): Observable<void> {
|
||||||
|
return this.http
|
||||||
|
.patch<void>(`${this.buildApiBaseUrl()}/servers/${serverId}/user-count`, { count })
|
||||||
|
.pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Failed to update user count:', error);
|
||||||
|
return of(undefined);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a heartbeat to keep the server listing active. */
|
||||||
|
sendHeartbeat(serverId: string): Observable<void> {
|
||||||
|
return this.http
|
||||||
|
.post<void>(`${this.buildApiBaseUrl()}/servers/${serverId}/heartbeat`, {})
|
||||||
|
.pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Failed to send heartbeat:', error);
|
||||||
|
return of(undefined);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the active endpoint's API base URL, stripping trailing
|
||||||
|
* slashes and accidental `/api` suffixes.
|
||||||
|
*/
|
||||||
|
private buildApiBaseUrl(): string {
|
||||||
|
const active = this.activeServer();
|
||||||
|
const rawUrl = active ? active.url : buildDefaultServerUrl();
|
||||||
|
let base = rawUrl.replace(/\/+$/, '');
|
||||||
if (base.toLowerCase().endsWith('/api')) {
|
if (base.toLowerCase().endsWith('/api')) {
|
||||||
base = base.slice(0, -4);
|
base = base.slice(0, -4);
|
||||||
}
|
}
|
||||||
return `${base}/api`;
|
return `${base}/api`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose API base URL for consumers that need to call server endpoints
|
/** Strip trailing slashes and `/api` suffix from a URL. */
|
||||||
getApiBaseUrl(): string {
|
private sanitiseUrl(rawUrl: string): string {
|
||||||
return this.baseUrl;
|
let cleaned = rawUrl.trim().replace(/\/+$/, '');
|
||||||
}
|
if (cleaned.toLowerCase().endsWith('/api')) {
|
||||||
|
cleaned = cleaned.slice(0, -4);
|
||||||
// Server management methods
|
|
||||||
addServer(server: { name: string; url: string }): void {
|
|
||||||
const newServer: ServerEndpoint = {
|
|
||||||
id: uuidv4(),
|
|
||||||
name: server.name,
|
|
||||||
// Sanitize: remove trailing slashes and any '/api'
|
|
||||||
url: (() => {
|
|
||||||
let u = server.url.trim();
|
|
||||||
u = u.replace(/\/+$/,'');
|
|
||||||
if (u.toLowerCase().endsWith('/api')) u = u.slice(0, -4);
|
|
||||||
return u;
|
|
||||||
})(),
|
|
||||||
isActive: false,
|
|
||||||
isDefault: false,
|
|
||||||
status: 'unknown',
|
|
||||||
};
|
|
||||||
this._servers.update((servers) => [...servers, newServer]);
|
|
||||||
this.saveServers();
|
|
||||||
}
|
|
||||||
|
|
||||||
removeServer(id: string): void {
|
|
||||||
const servers = this._servers();
|
|
||||||
const server = servers.find((s) => s.id === id);
|
|
||||||
if (server?.isDefault) return; // Can't remove default server
|
|
||||||
|
|
||||||
const wasActive = server?.isActive;
|
|
||||||
this._servers.update((servers) => servers.filter((s) => s.id !== id));
|
|
||||||
|
|
||||||
// If removed server was active, activate the first server
|
|
||||||
if (wasActive) {
|
|
||||||
this._servers.update((servers) => {
|
|
||||||
if (servers.length > 0) {
|
|
||||||
servers[0].isActive = true;
|
|
||||||
}
|
|
||||||
return [...servers];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
this.saveServers();
|
return cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveServer(id: string): void {
|
/**
|
||||||
this._servers.update((servers) =>
|
* Handle both `{ servers: [...] }` and direct `ServerInfo[]`
|
||||||
servers.map((s) => ({
|
* response shapes from the directory API.
|
||||||
...s,
|
*/
|
||||||
isActive: s.id === id,
|
private unwrapServersResponse(
|
||||||
}))
|
response: { servers: ServerInfo[]; total: number } | ServerInfo[],
|
||||||
);
|
): ServerInfo[] {
|
||||||
this.saveServers();
|
if (Array.isArray(response)) return response;
|
||||||
|
return response.servers ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
updateServerStatus(id: string, status: ServerEndpoint['status'], latency?: number): void {
|
/** Search a single endpoint for servers matching a query. */
|
||||||
this._servers.update((servers) =>
|
private searchSingleEndpoint(
|
||||||
servers.map((s) => (s.id === id ? { ...s, status, latency } : s))
|
query: string,
|
||||||
);
|
apiBaseUrl: string,
|
||||||
this.saveServers();
|
): Observable<ServerInfo[]> {
|
||||||
}
|
|
||||||
|
|
||||||
setSearchAllServers(value: boolean): void {
|
|
||||||
this._searchAllServers = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
async testServer(id: string): Promise<boolean> {
|
|
||||||
const server = this._servers().find((s) => s.id === id);
|
|
||||||
if (!server) return false;
|
|
||||||
|
|
||||||
this.updateServerStatus(id, 'checking');
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${server.url}/api/health`, {
|
|
||||||
method: 'GET',
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
});
|
|
||||||
const latency = Date.now() - startTime;
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
this.updateServerStatus(id, 'online', latency);
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
this.updateServerStatus(id, 'offline');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Try alternative endpoint
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${server.url}/api/servers`, {
|
|
||||||
method: 'GET',
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
});
|
|
||||||
const latency = Date.now() - startTime;
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
this.updateServerStatus(id, 'online', latency);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Server is offline
|
|
||||||
}
|
|
||||||
this.updateServerStatus(id, 'offline');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async testAllServers(): Promise<void> {
|
|
||||||
const servers = this._servers();
|
|
||||||
await Promise.all(servers.map((s) => this.testServer(s.id)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search for servers - optionally across all configured endpoints
|
|
||||||
searchServers(query: string): Observable<ServerInfo[]> {
|
|
||||||
if (this._searchAllServers) {
|
|
||||||
return this.searchAllServerEndpoints(query);
|
|
||||||
}
|
|
||||||
return this.searchSingleServer(query, this.baseUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
private searchSingleServer(query: string, baseUrl: string): Observable<ServerInfo[]> {
|
|
||||||
const params = new HttpParams().set('q', query);
|
const params = new HttpParams().set('q', query);
|
||||||
|
return this.http
|
||||||
return this.http.get<{ servers: ServerInfo[]; total: number }>(`${baseUrl}/servers`, { params }).pipe(
|
.get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params })
|
||||||
map((response) => {
|
.pipe(
|
||||||
// Handle both wrapped response { servers: [...] } and direct array
|
map((response) => this.unwrapServersResponse(response)),
|
||||||
if (Array.isArray(response)) {
|
catchError((error) => {
|
||||||
return response;
|
console.error('Failed to search servers:', error);
|
||||||
}
|
return of([]);
|
||||||
return response.servers || [];
|
}),
|
||||||
}),
|
);
|
||||||
catchError((error) => {
|
|
||||||
console.error('Failed to search servers:', error);
|
|
||||||
return of([]);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private searchAllServerEndpoints(query: string): Observable<ServerInfo[]> {
|
/** Fan-out search across all non-offline endpoints, deduplicating results. */
|
||||||
const servers = this._servers().filter((s) => s.status !== 'offline');
|
private searchAllEndpoints(query: string): Observable<ServerInfo[]> {
|
||||||
|
const onlineEndpoints = this._servers().filter(
|
||||||
|
(endpoint) => endpoint.status !== 'offline',
|
||||||
|
);
|
||||||
|
|
||||||
if (servers.length === 0) {
|
if (onlineEndpoints.length === 0) {
|
||||||
return this.searchSingleServer(query, this.baseUrl);
|
return this.searchSingleEndpoint(query, this.buildApiBaseUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
const requests = servers.map((server) =>
|
const requests = onlineEndpoints.map((endpoint) =>
|
||||||
this.searchSingleServer(query, `${server.url}/api`).pipe(
|
this.searchSingleEndpoint(query, `${endpoint.url}/api`).pipe(
|
||||||
map((results) =>
|
map((results) =>
|
||||||
results.map((r) => ({
|
results.map((server) => ({
|
||||||
...r,
|
...server,
|
||||||
sourceId: server.id,
|
sourceId: endpoint.id,
|
||||||
sourceName: server.name,
|
sourceName: endpoint.name,
|
||||||
}))
|
})),
|
||||||
)
|
),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return forkJoin(requests).pipe(
|
return forkJoin(requests).pipe(
|
||||||
map((results) => results.flat()),
|
map((resultArrays) => resultArrays.flat()),
|
||||||
// Remove duplicates based on server ID
|
map((servers) => this.deduplicateById(servers)),
|
||||||
map((servers) => {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return servers.filter((s) => {
|
|
||||||
if (seen.has(s.id)) return false;
|
|
||||||
seen.add(s.id);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all available servers
|
|
||||||
getServers(): Observable<ServerInfo[]> {
|
|
||||||
if (this._searchAllServers) {
|
|
||||||
return this.getAllServersFromAllEndpoints();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.http.get<{ servers: ServerInfo[]; total: number }>(`${this.baseUrl}/servers`).pipe(
|
|
||||||
map((response) => {
|
|
||||||
if (Array.isArray(response)) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
return response.servers || [];
|
|
||||||
}),
|
|
||||||
catchError((error) => {
|
|
||||||
console.error('Failed to get servers:', error);
|
|
||||||
return of([]);
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Retrieve all servers from all non-offline endpoints. */
|
||||||
private getAllServersFromAllEndpoints(): Observable<ServerInfo[]> {
|
private getAllServersFromAllEndpoints(): Observable<ServerInfo[]> {
|
||||||
const servers = this._servers().filter((s) => s.status !== 'offline');
|
const onlineEndpoints = this._servers().filter(
|
||||||
|
(endpoint) => endpoint.status !== 'offline',
|
||||||
|
);
|
||||||
|
|
||||||
if (servers.length === 0) {
|
if (onlineEndpoints.length === 0) {
|
||||||
return this.http.get<{ servers: ServerInfo[]; total: number }>(`${this.baseUrl}/servers`).pipe(
|
return this.http
|
||||||
map((response) => (Array.isArray(response) ? response : response.servers || [])),
|
.get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`)
|
||||||
catchError(() => of([]))
|
.pipe(
|
||||||
);
|
map((response) => this.unwrapServersResponse(response)),
|
||||||
|
catchError(() => of([])),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const requests = servers.map((server) =>
|
const requests = onlineEndpoints.map((endpoint) =>
|
||||||
this.http.get<{ servers: ServerInfo[]; total: number }>(`${server.url}/api/servers`).pipe(
|
this.http
|
||||||
map((response) => {
|
.get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`)
|
||||||
const results = Array.isArray(response) ? response : response.servers || [];
|
.pipe(
|
||||||
return results.map((r) => ({
|
map((response) => {
|
||||||
...r,
|
const results = this.unwrapServersResponse(response);
|
||||||
sourceId: server.id,
|
return results.map((server) => ({
|
||||||
sourceName: server.name,
|
...server,
|
||||||
}));
|
sourceId: endpoint.id,
|
||||||
}),
|
sourceName: endpoint.name,
|
||||||
catchError(() => of([]))
|
}));
|
||||||
)
|
}),
|
||||||
|
catchError(() => of([] as ServerInfo[])),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return forkJoin(requests).pipe(map((results) => results.flat()));
|
return forkJoin(requests).pipe(map((resultArrays) => resultArrays.flat()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get server details
|
/** Remove duplicate servers (by `id`), keeping the first occurrence. */
|
||||||
getServer(serverId: string): Observable<ServerInfo | null> {
|
private deduplicateById<T extends { id: string }>(items: T[]): T[] {
|
||||||
return this.http.get<ServerInfo>(`${this.baseUrl}/servers/${serverId}`).pipe(
|
const seen = new Set<string>();
|
||||||
catchError((error) => {
|
return items.filter((item) => {
|
||||||
console.error('Failed to get server:', error);
|
if (seen.has(item.id)) return false;
|
||||||
return of(null);
|
seen.add(item.id);
|
||||||
})
|
return true;
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register a new server (with optional pre-generated ID)
|
/** Load endpoints from localStorage, migrating protocol if needed. */
|
||||||
registerServer(server: Omit<ServerInfo, 'createdAt'> & { id?: string }): Observable<ServerInfo> {
|
private loadEndpoints(): void {
|
||||||
return this.http.post<ServerInfo>(`${this.baseUrl}/servers`, server).pipe(
|
const stored = localStorage.getItem(ENDPOINTS_STORAGE_KEY);
|
||||||
catchError((error) => {
|
if (!stored) {
|
||||||
console.error('Failed to register server:', error);
|
this.initialiseDefaultEndpoint();
|
||||||
return throwError(() => error);
|
return;
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update server info
|
|
||||||
updateServer(serverId: string, updates: Partial<ServerInfo>): Observable<ServerInfo> {
|
|
||||||
return this.http.patch<ServerInfo>(`${this.baseUrl}/servers/${serverId}`, updates).pipe(
|
|
||||||
catchError((error) => {
|
|
||||||
console.error('Failed to update server:', error);
|
|
||||||
return throwError(() => error);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove server from directory
|
|
||||||
unregisterServer(serverId: string): Observable<void> {
|
|
||||||
return this.http.delete<void>(`${this.baseUrl}/servers/${serverId}`).pipe(
|
|
||||||
catchError((error) => {
|
|
||||||
console.error('Failed to unregister server:', error);
|
|
||||||
return throwError(() => error);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get users in a server
|
|
||||||
getServerUsers(serverId: string): Observable<User[]> {
|
|
||||||
return this.http.get<User[]>(`${this.baseUrl}/servers/${serverId}/users`).pipe(
|
|
||||||
catchError((error) => {
|
|
||||||
console.error('Failed to get server users:', error);
|
|
||||||
return of([]);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send join request
|
|
||||||
requestJoin(request: JoinRequest): Observable<{ success: boolean; signalingUrl?: string }> {
|
|
||||||
return this.http
|
|
||||||
.post<{ success: boolean; signalingUrl?: string }>(
|
|
||||||
`${this.baseUrl}/servers/${request.roomId}/join`,
|
|
||||||
request
|
|
||||||
)
|
|
||||||
.pipe(
|
|
||||||
catchError((error) => {
|
|
||||||
console.error('Failed to send join request:', error);
|
|
||||||
return throwError(() => error);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify server of user leaving
|
|
||||||
notifyLeave(serverId: string, userId: string): Observable<void> {
|
|
||||||
return this.http.post<void>(`${this.baseUrl}/servers/${serverId}/leave`, { userId }).pipe(
|
|
||||||
catchError((error) => {
|
|
||||||
console.error('Failed to notify leave:', error);
|
|
||||||
return of(undefined);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user count for a server
|
|
||||||
updateUserCount(serverId: string, count: number): Observable<void> {
|
|
||||||
return this.http.patch<void>(`${this.baseUrl}/servers/${serverId}/user-count`, { count }).pipe(
|
|
||||||
catchError((error) => {
|
|
||||||
console.error('Failed to update user count:', error);
|
|
||||||
return of(undefined);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Heartbeat to keep server active in directory
|
|
||||||
sendHeartbeat(serverId: string): Observable<void> {
|
|
||||||
return this.http.post<void>(`${this.baseUrl}/servers/${serverId}/heartbeat`, {}).pipe(
|
|
||||||
catchError((error) => {
|
|
||||||
console.error('Failed to send heartbeat:', error);
|
|
||||||
return of(undefined);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the WebSocket URL for the active server
|
|
||||||
getWebSocketUrl(): string {
|
|
||||||
const active = this.activeServer();
|
|
||||||
if (!active) {
|
|
||||||
const proto = (typeof window !== 'undefined' && window.location?.protocol === 'https:') ? 'wss' : 'ws';
|
|
||||||
return `${proto}://localhost:3001`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert http(s) to ws(s)
|
try {
|
||||||
return active.url.replace(/^http/, 'ws');
|
let endpoints = JSON.parse(stored) as ServerEndpoint[];
|
||||||
|
|
||||||
|
// Ensure at least one endpoint is active
|
||||||
|
if (endpoints.length > 0 && !endpoints.some((ep) => ep.isActive)) {
|
||||||
|
endpoints[0].isActive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate localhost entries to match the current page protocol
|
||||||
|
const expectedProtocol =
|
||||||
|
typeof window !== 'undefined' && window.location?.protocol === 'https:'
|
||||||
|
? 'https'
|
||||||
|
: 'http';
|
||||||
|
|
||||||
|
endpoints = endpoints.map((endpoint) => {
|
||||||
|
if (endpoint.isDefault && /^https?:\/\/localhost:\d+$/.test(endpoint.url)) {
|
||||||
|
return { ...endpoint, url: endpoint.url.replace(/^https?/, expectedProtocol) };
|
||||||
|
}
|
||||||
|
return endpoint;
|
||||||
|
});
|
||||||
|
|
||||||
|
this._servers.set(endpoints);
|
||||||
|
this.saveEndpoints();
|
||||||
|
} catch {
|
||||||
|
this.initialiseDefaultEndpoint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create and persist the built-in default endpoint. */
|
||||||
|
private initialiseDefaultEndpoint(): void {
|
||||||
|
const defaultEndpoint: ServerEndpoint = { ...DEFAULT_ENDPOINT, id: uuidv4() };
|
||||||
|
this._servers.set([defaultEndpoint]);
|
||||||
|
this.saveEndpoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the current endpoint list to localStorage. */
|
||||||
|
private saveEndpoints(): void {
|
||||||
|
localStorage.setItem(ENDPOINTS_STORAGE_KEY, JSON.stringify(this._servers()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,94 @@
|
|||||||
import { Injectable, signal, computed } from '@angular/core';
|
import { Injectable, signal, computed } from '@angular/core';
|
||||||
|
|
||||||
|
/** Default timeout (ms) for the NTP-style HTTP sync request. */
|
||||||
|
const DEFAULT_SYNC_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maintains a clock-offset between the local system time and the
|
||||||
|
* remote signaling server.
|
||||||
|
*
|
||||||
|
* The offset is estimated using a simple NTP-style round-trip
|
||||||
|
* measurement and is stored as a reactive Angular signal so that
|
||||||
|
* any dependent computed value auto-updates when a new sync occurs.
|
||||||
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class TimeSyncService {
|
export class TimeSyncService {
|
||||||
// serverTime - clientTime offset (milliseconds)
|
/**
|
||||||
|
* Internal offset signal:
|
||||||
|
* `serverTime = Date.now() + offset`.
|
||||||
|
*/
|
||||||
private readonly _offset = signal<number>(0);
|
private readonly _offset = signal<number>(0);
|
||||||
private _lastSyncAt = 0;
|
|
||||||
|
|
||||||
|
/** Epoch timestamp of the most recent successful sync. */
|
||||||
|
private lastSyncTimestamp = 0;
|
||||||
|
|
||||||
|
/** Reactive read-only offset (milliseconds). */
|
||||||
readonly offset = computed(() => this._offset());
|
readonly offset = computed(() => this._offset());
|
||||||
|
|
||||||
// Returns a server-adjusted now() using the current offset
|
/**
|
||||||
|
* Return a server-adjusted "now" timestamp.
|
||||||
|
*
|
||||||
|
* @returns Epoch milliseconds aligned to the server clock.
|
||||||
|
*/
|
||||||
now(): number {
|
now(): number {
|
||||||
return Date.now() + this._offset();
|
return Date.now() + this._offset();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set offset based on a serverTime observed at approximately receiveAt
|
/**
|
||||||
setFromServerTime(serverTime: number, receiveAt?: number): void {
|
* Set the offset from a known server timestamp.
|
||||||
const observedAt = receiveAt ?? Date.now();
|
*
|
||||||
const offset = serverTime - observedAt;
|
* @param serverTime - Epoch timestamp reported by the server.
|
||||||
this._offset.set(offset);
|
* @param receiveTimestamp - Local epoch timestamp when the server time was
|
||||||
this._lastSyncAt = Date.now();
|
* observed. Defaults to `Date.now()` if omitted.
|
||||||
|
*/
|
||||||
|
setFromServerTime(serverTime: number, receiveTimestamp?: number): void {
|
||||||
|
const observedAt = receiveTimestamp ?? Date.now();
|
||||||
|
this._offset.set(serverTime - observedAt);
|
||||||
|
this.lastSyncTimestamp = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform an HTTP-based sync using a simple NTP-style roundtrip
|
/**
|
||||||
async syncWithEndpoint(baseApiUrl: string, timeoutMs = 5000): Promise<void> {
|
* Perform an HTTP-based clock synchronisation using a simple
|
||||||
|
* NTP-style round-trip.
|
||||||
|
*
|
||||||
|
* 1. Record `clientSendTime` (`t0`).
|
||||||
|
* 2. Fetch `GET {baseApiUrl}/time`.
|
||||||
|
* 3. Record `clientReceiveTime` (`t2`).
|
||||||
|
* 4. Estimate one-way latency as `(t2 − t0) / 2`.
|
||||||
|
* 5. Compute offset: `serverNow − midpoint(t0, t2)`.
|
||||||
|
*
|
||||||
|
* Any network or parsing error is silently ignored so that the
|
||||||
|
* last known offset (or zero) is retained.
|
||||||
|
*
|
||||||
|
* @param baseApiUrl - API base URL (e.g. `http://host:3001/api`).
|
||||||
|
* @param timeoutMs - Maximum time to wait for the response.
|
||||||
|
*/
|
||||||
|
async syncWithEndpoint(
|
||||||
|
baseApiUrl: string,
|
||||||
|
timeoutMs: number = DEFAULT_SYNC_TIMEOUT_MS,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const t0 = Date.now();
|
const clientSendTime = Date.now();
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
const resp = await fetch(`${baseApiUrl}/time`, { signal: controller.signal });
|
|
||||||
const t2 = Date.now();
|
const response = await fetch(`${baseApiUrl}/time`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const clientReceiveTime = Date.now();
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
if (!resp.ok) return;
|
|
||||||
const data = await resp.json();
|
if (!response.ok) return;
|
||||||
// Estimate one-way latency and offset
|
|
||||||
|
const data = await response.json();
|
||||||
const serverNow = Number(data?.now) || Date.now();
|
const serverNow = Number(data?.now) || Date.now();
|
||||||
const midpoint = (t0 + t2) / 2;
|
const midpoint = (clientSendTime + clientReceiveTime) / 2;
|
||||||
const offset = serverNow - midpoint;
|
|
||||||
this._offset.set(offset);
|
this._offset.set(serverNow - midpoint);
|
||||||
this._lastSyncAt = Date.now();
|
this.lastSyncTimestamp = Date.now();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore sync failures; retain last offset
|
// Sync failure is non-fatal; retain the previous offset.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,78 @@
|
|||||||
import { Injectable, signal, computed, inject } from '@angular/core';
|
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import * as RoomsActions from '../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot of an active voice session, retained so that floating
|
||||||
|
* voice controls can display the connection details when the user
|
||||||
|
* navigates away from the server view.
|
||||||
|
*/
|
||||||
export interface VoiceSessionInfo {
|
export interface VoiceSessionInfo {
|
||||||
|
/** Unique server identifier. */
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
/** Display name of the server. */
|
||||||
serverName: string;
|
serverName: string;
|
||||||
|
/** Room/channel ID within the server. */
|
||||||
roomId: string;
|
roomId: string;
|
||||||
|
/** Display name of the room/channel. */
|
||||||
roomName: string;
|
roomName: string;
|
||||||
|
/** Optional server icon (data-URL or remote URL). */
|
||||||
serverIcon?: string;
|
serverIcon?: string;
|
||||||
|
/** Optional server description. */
|
||||||
serverDescription?: string;
|
serverDescription?: string;
|
||||||
/** The route path to navigate back to the server */
|
/** Angular route path to navigate back to the server. */
|
||||||
serverRoute: string;
|
serverRoute: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service to track the current voice session across navigation.
|
* Tracks the user's current voice session across client-side
|
||||||
* When a user is connected to voice in a server and navigates away,
|
* navigation so that floating voice controls remain visible when
|
||||||
* this service maintains the session info for the floating controls.
|
* the user is browsing a different server or view.
|
||||||
|
*
|
||||||
|
* This service is purely a UI-state tracker — actual WebRTC
|
||||||
|
* voice management lives in {@link WebRTCService} and its managers.
|
||||||
*/
|
*/
|
||||||
@Injectable({
|
@Injectable({ providedIn: 'root' })
|
||||||
providedIn: 'root',
|
|
||||||
})
|
|
||||||
export class VoiceSessionService {
|
export class VoiceSessionService {
|
||||||
private router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
// The voice session info when connected
|
/** Current voice session metadata, or `null` when disconnected. */
|
||||||
private readonly _voiceSession = signal<VoiceSessionInfo | null>(null);
|
private readonly _voiceSession = signal<VoiceSessionInfo | null>(null);
|
||||||
|
|
||||||
// Whether the user is currently viewing the voice-connected server
|
/** Whether the user is currently viewing the voice-connected server. */
|
||||||
private readonly _isViewingVoiceServer = signal(true);
|
private readonly _isViewingVoiceServer = signal<boolean>(true);
|
||||||
|
|
||||||
// Public computed signals
|
/** Reactive read-only voice session. */
|
||||||
readonly voiceSession = computed(() => this._voiceSession());
|
readonly voiceSession = computed(() => this._voiceSession());
|
||||||
|
|
||||||
|
/** Reactive flag: is the user's current view the voice server? */
|
||||||
readonly isViewingVoiceServer = computed(() => this._isViewingVoiceServer());
|
readonly isViewingVoiceServer = computed(() => this._isViewingVoiceServer());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether to show floating voice controls:
|
* Whether the floating voice-controls overlay should be visible.
|
||||||
* True when connected to voice AND not viewing the voice-connected server
|
* `true` when a voice session is active AND the user is viewing
|
||||||
|
* a different server.
|
||||||
*/
|
*/
|
||||||
readonly showFloatingControls = computed(() => {
|
readonly showFloatingControls = computed(
|
||||||
return this._voiceSession() !== null && !this._isViewingVoiceServer();
|
() => this._voiceSession() !== null && !this._isViewingVoiceServer(),
|
||||||
});
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a voice session - called when user joins voice in a server
|
* Begin tracking a voice session.
|
||||||
|
* Called when the user joins a voice channel.
|
||||||
|
*
|
||||||
|
* @param sessionInfo - Metadata describing the voice-connected server/channel.
|
||||||
*/
|
*/
|
||||||
startSession(info: VoiceSessionInfo): void {
|
startSession(sessionInfo: VoiceSessionInfo): void {
|
||||||
this._voiceSession.set(info);
|
this._voiceSession.set(sessionInfo);
|
||||||
this._isViewingVoiceServer.set(true);
|
this._isViewingVoiceServer.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* End the voice session - called when user disconnects from voice
|
* Stop tracking the voice session.
|
||||||
|
* Called when the user disconnects from voice.
|
||||||
*/
|
*/
|
||||||
endSession(): void {
|
endSession(): void {
|
||||||
this._voiceSession.set(null);
|
this._voiceSession.set(null);
|
||||||
@@ -61,14 +80,20 @@ export class VoiceSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update whether user is viewing the voice-connected server
|
* Manually flag whether the user is currently viewing the
|
||||||
|
* voice-connected server.
|
||||||
|
*
|
||||||
|
* @param isViewing - `true` if the user's current view is the voice server.
|
||||||
*/
|
*/
|
||||||
setViewingVoiceServer(isViewing: boolean): void {
|
setViewingVoiceServer(isViewing: boolean): void {
|
||||||
this._isViewingVoiceServer.set(isViewing);
|
this._isViewingVoiceServer.set(isViewing);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the current route matches the voice session server
|
* Compare the given server ID to the voice session's server and
|
||||||
|
* update the {@link isViewingVoiceServer} flag accordingly.
|
||||||
|
*
|
||||||
|
* @param currentServerId - ID of the server the user is currently viewing.
|
||||||
*/
|
*/
|
||||||
checkCurrentRoute(currentServerId: string | null): void {
|
checkCurrentRoute(currentServerId: string | null): void {
|
||||||
const session = this._voiceSession();
|
const session = this._voiceSession();
|
||||||
@@ -80,13 +105,15 @@ export class VoiceSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate back to the voice-connected server
|
* Navigate the user back to the voice-connected server by
|
||||||
|
* dispatching a `viewServer` action.
|
||||||
*/
|
*/
|
||||||
navigateToVoiceServer(): void {
|
navigateToVoiceServer(): void {
|
||||||
const session = this._voiceSession();
|
const session = this._voiceSession();
|
||||||
if (session) {
|
if (!session) return;
|
||||||
// Use viewServer to switch view without leaving current server
|
|
||||||
this.store.dispatch(RoomsActions.viewServer({
|
this.store.dispatch(
|
||||||
|
RoomsActions.viewServer({
|
||||||
room: {
|
room: {
|
||||||
id: session.serverId,
|
id: session.serverId,
|
||||||
name: session.serverName,
|
name: session.serverName,
|
||||||
@@ -98,13 +125,14 @@ export class VoiceSessionService {
|
|||||||
maxUsers: 50,
|
maxUsers: 50,
|
||||||
icon: session.serverIcon,
|
icon: session.serverIcon,
|
||||||
} as any,
|
} as any,
|
||||||
}));
|
}),
|
||||||
this._isViewingVoiceServer.set(true);
|
);
|
||||||
}
|
this._isViewingVoiceServer.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current server ID from the voice session
|
* Return the server ID of the active voice session, or `null`
|
||||||
|
* if the user is not in a voice channel.
|
||||||
*/
|
*/
|
||||||
getVoiceServerId(): string | null {
|
getVoiceServerId(): string | null {
|
||||||
return this._voiceSession()?.serverId ?? null;
|
return this._voiceSession()?.serverId ?? null;
|
||||||
|
|||||||
@@ -52,17 +52,14 @@ import {
|
|||||||
export class WebRTCService implements OnDestroy {
|
export class WebRTCService implements OnDestroy {
|
||||||
private readonly timeSync = inject(TimeSyncService);
|
private readonly timeSync = inject(TimeSyncService);
|
||||||
|
|
||||||
// ─── Logger ────────────────────────────────────────────────────────
|
|
||||||
private readonly logger = new WebRTCLogger(/* debugEnabled */ true);
|
private readonly logger = new WebRTCLogger(/* debugEnabled */ true);
|
||||||
|
|
||||||
// ─── Identity & server membership ──────────────────────────────────
|
|
||||||
private lastIdentifyCredentials: IdentifyCredentials | null = null;
|
private lastIdentifyCredentials: IdentifyCredentials | null = null;
|
||||||
private lastJoinedServer: JoinedServerInfo | null = null;
|
private lastJoinedServer: JoinedServerInfo | null = null;
|
||||||
private readonly memberServerIds = new Set<string>();
|
private readonly memberServerIds = new Set<string>();
|
||||||
private activeServerId: string | null = null;
|
private activeServerId: string | null = null;
|
||||||
private readonly serviceDestroyed$ = new Subject<void>();
|
private readonly serviceDestroyed$ = new Subject<void>();
|
||||||
|
|
||||||
// ─── Angular signals (reactive state) ──────────────────────────────
|
|
||||||
private readonly _localPeerId = signal<string>(uuidv4());
|
private readonly _localPeerId = signal<string>(uuidv4());
|
||||||
private readonly _isSignalingConnected = signal(false);
|
private readonly _isSignalingConnected = signal(false);
|
||||||
private readonly _isVoiceConnected = signal(false);
|
private readonly _isVoiceConnected = signal(false);
|
||||||
@@ -73,10 +70,12 @@ export class WebRTCService implements OnDestroy {
|
|||||||
private readonly _screenStreamSignal = signal<MediaStream | null>(null);
|
private readonly _screenStreamSignal = signal<MediaStream | null>(null);
|
||||||
private readonly _hasConnectionError = signal(false);
|
private readonly _hasConnectionError = signal(false);
|
||||||
private readonly _connectionErrorMessage = signal<string | null>(null);
|
private readonly _connectionErrorMessage = signal<string | null>(null);
|
||||||
|
private readonly _hasEverConnected = signal(false);
|
||||||
|
|
||||||
// Public computed signals (unchanged external API)
|
// Public computed signals (unchanged external API)
|
||||||
readonly peerId = computed(() => this._localPeerId());
|
readonly peerId = computed(() => this._localPeerId());
|
||||||
readonly isConnected = computed(() => this._isSignalingConnected());
|
readonly isConnected = computed(() => this._isSignalingConnected());
|
||||||
|
readonly hasEverConnected = computed(() => this._hasEverConnected());
|
||||||
readonly isVoiceConnected = computed(() => this._isVoiceConnected());
|
readonly isVoiceConnected = computed(() => this._isVoiceConnected());
|
||||||
readonly connectedPeers = computed(() => this._connectedPeers());
|
readonly connectedPeers = computed(() => this._connectedPeers());
|
||||||
readonly isMuted = computed(() => this._isMuted());
|
readonly isMuted = computed(() => this._isMuted());
|
||||||
@@ -91,7 +90,6 @@ export class WebRTCService implements OnDestroy {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Public observables (unchanged external API) ───────────────────
|
|
||||||
private readonly signalingMessage$ = new Subject<SignalingMessage>();
|
private readonly signalingMessage$ = new Subject<SignalingMessage>();
|
||||||
readonly onSignalingMessage = this.signalingMessage$.asObservable();
|
readonly onSignalingMessage = this.signalingMessage$.asObservable();
|
||||||
|
|
||||||
@@ -102,8 +100,6 @@ export class WebRTCService implements OnDestroy {
|
|||||||
get onRemoteStream(): Observable<{ peerId: string; stream: MediaStream }> { return this.peerManager.remoteStream$.asObservable(); }
|
get onRemoteStream(): Observable<{ peerId: string; stream: MediaStream }> { return this.peerManager.remoteStream$.asObservable(); }
|
||||||
get onVoiceConnected(): Observable<void> { return this.mediaManager.voiceConnected$.asObservable(); }
|
get onVoiceConnected(): Observable<void> { return this.mediaManager.voiceConnected$.asObservable(); }
|
||||||
|
|
||||||
// ─── Sub-managers ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private readonly signalingManager: SignalingManager;
|
private readonly signalingManager: SignalingManager;
|
||||||
private readonly peerManager: PeerConnectionManager;
|
private readonly peerManager: PeerConnectionManager;
|
||||||
private readonly mediaManager: MediaManager;
|
private readonly mediaManager: MediaManager;
|
||||||
@@ -162,12 +158,11 @@ export class WebRTCService implements OnDestroy {
|
|||||||
this.wireManagerEvents();
|
this.wireManagerEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Event wiring ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private wireManagerEvents(): void {
|
private wireManagerEvents(): void {
|
||||||
// Signaling → connection status
|
// Signaling → connection status
|
||||||
this.signalingManager.connectionStatus$.subscribe(({ connected, errorMessage }) => {
|
this.signalingManager.connectionStatus$.subscribe(({ connected, errorMessage }) => {
|
||||||
this._isSignalingConnected.set(connected);
|
this._isSignalingConnected.set(connected);
|
||||||
|
if (connected) this._hasEverConnected.set(true);
|
||||||
this._hasConnectionError.set(!connected);
|
this._hasConnectionError.set(!connected);
|
||||||
this._connectionErrorMessage.set(connected ? null : (errorMessage ?? null));
|
this._connectionErrorMessage.set(connected ? null : (errorMessage ?? null));
|
||||||
});
|
});
|
||||||
@@ -187,8 +182,6 @@ export class WebRTCService implements OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Signaling message routing ─────────────────────────────────────
|
|
||||||
|
|
||||||
private handleSignalingMessage(message: any): void {
|
private handleSignalingMessage(message: any): void {
|
||||||
this.signalingMessage$.next(message);
|
this.signalingMessage$.next(message);
|
||||||
this.logger.info('Signaling message', { type: message.type });
|
this.logger.info('Signaling message', { type: message.type });
|
||||||
@@ -242,8 +235,6 @@ export class WebRTCService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Voice state snapshot ──────────────────────────────────────────
|
|
||||||
|
|
||||||
private getCurrentVoiceState(): VoiceStateSnapshot {
|
private getCurrentVoiceState(): VoiceStateSnapshot {
|
||||||
return {
|
return {
|
||||||
isConnected: this._isVoiceConnected(),
|
isConnected: this._isVoiceConnected(),
|
||||||
@@ -259,41 +250,85 @@ export class WebRTCService implements OnDestroy {
|
|||||||
// PUBLIC API – matches the old monolithic service's interface
|
// PUBLIC API – matches the old monolithic service's interface
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ─── Signaling ─────────────────────────────────────────────────────
|
/**
|
||||||
|
* Connect to a signaling server via WebSocket.
|
||||||
|
*
|
||||||
|
* @param serverUrl - The WebSocket URL of the signaling server.
|
||||||
|
* @returns An observable that emits `true` once connected.
|
||||||
|
*/
|
||||||
connectToSignalingServer(serverUrl: string): Observable<boolean> {
|
connectToSignalingServer(serverUrl: string): Observable<boolean> {
|
||||||
return this.signalingManager.connect(serverUrl);
|
return this.signalingManager.connect(serverUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the signaling WebSocket is connected, reconnecting if needed.
|
||||||
|
*
|
||||||
|
* @param timeoutMs - Maximum time (ms) to wait for the connection.
|
||||||
|
* @returns `true` if connected within the timeout.
|
||||||
|
*/
|
||||||
async ensureSignalingConnected(timeoutMs?: number): Promise<boolean> {
|
async ensureSignalingConnected(timeoutMs?: number): Promise<boolean> {
|
||||||
return this.signalingManager.ensureConnected(timeoutMs);
|
return this.signalingManager.ensureConnected(timeoutMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a signaling-level message (with `from` and `timestamp` auto-populated).
|
||||||
|
*
|
||||||
|
* @param message - The signaling message payload (excluding `from` / `timestamp`).
|
||||||
|
*/
|
||||||
sendSignalingMessage(message: Omit<SignalingMessage, 'from' | 'timestamp'>): void {
|
sendSignalingMessage(message: Omit<SignalingMessage, 'from' | 'timestamp'>): void {
|
||||||
this.signalingManager.sendSignalingMessage(message, this._localPeerId());
|
this.signalingManager.sendSignalingMessage(message, this._localPeerId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a raw JSON payload through the signaling WebSocket.
|
||||||
|
*
|
||||||
|
* @param message - Arbitrary JSON message.
|
||||||
|
*/
|
||||||
sendRawMessage(message: Record<string, unknown>): void {
|
sendRawMessage(message: Record<string, unknown>): void {
|
||||||
this.signalingManager.sendRawMessage(message);
|
this.signalingManager.sendRawMessage(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Server membership ─────────────────────────────────────────────
|
/**
|
||||||
|
* Track the currently-active server ID (for server-scoped operations).
|
||||||
|
*
|
||||||
|
* @param serverId - The server to mark as active.
|
||||||
|
*/
|
||||||
setCurrentServer(serverId: string): void {
|
setCurrentServer(serverId: string): void {
|
||||||
this.activeServerId = serverId;
|
this.activeServerId = serverId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an identify message to the signaling server.
|
||||||
|
*
|
||||||
|
* The credentials are cached so they can be replayed after a reconnect.
|
||||||
|
*
|
||||||
|
* @param oderId - The user's unique order/peer ID.
|
||||||
|
* @param displayName - The user's display name.
|
||||||
|
*/
|
||||||
identify(oderId: string, displayName: string): void {
|
identify(oderId: string, displayName: string): void {
|
||||||
this.lastIdentifyCredentials = { oderId, displayName };
|
this.lastIdentifyCredentials = { oderId, displayName };
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, oderId, displayName });
|
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, oderId, displayName });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join a server (room) on the signaling server.
|
||||||
|
*
|
||||||
|
* @param roomId - The server / room ID to join.
|
||||||
|
* @param userId - The local user ID.
|
||||||
|
*/
|
||||||
joinRoom(roomId: string, userId: string): void {
|
joinRoom(roomId: string, userId: string): void {
|
||||||
this.lastJoinedServer = { serverId: roomId, userId };
|
this.lastJoinedServer = { serverId: roomId, userId };
|
||||||
this.memberServerIds.add(roomId);
|
this.memberServerIds.add(roomId);
|
||||||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId: roomId });
|
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId: roomId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to a different server. If already a member, sends a view event;
|
||||||
|
* otherwise joins the server.
|
||||||
|
*
|
||||||
|
* @param serverId - The target server ID.
|
||||||
|
* @param userId - The local user ID.
|
||||||
|
*/
|
||||||
switchServer(serverId: string, userId: string): void {
|
switchServer(serverId: string, userId: string): void {
|
||||||
this.lastJoinedServer = { serverId, userId };
|
this.lastJoinedServer = { serverId, userId };
|
||||||
|
|
||||||
@@ -307,6 +342,14 @@ export class WebRTCService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leave one or all servers.
|
||||||
|
*
|
||||||
|
* If `serverId` is provided, leaves only that server.
|
||||||
|
* Otherwise leaves every joined server and performs a full cleanup.
|
||||||
|
*
|
||||||
|
* @param serverId - Optional server to leave; omit to leave all.
|
||||||
|
*/
|
||||||
leaveRoom(serverId?: string): void {
|
leaveRoom(serverId?: string): void {
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
this.memberServerIds.delete(serverId);
|
this.memberServerIds.delete(serverId);
|
||||||
@@ -323,86 +366,159 @@ export class WebRTCService implements OnDestroy {
|
|||||||
this.fullCleanup();
|
this.fullCleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the local client has joined a given server.
|
||||||
|
*
|
||||||
|
* @param serverId - The server to check.
|
||||||
|
*/
|
||||||
hasJoinedServer(serverId: string): boolean {
|
hasJoinedServer(serverId: string): boolean {
|
||||||
return this.memberServerIds.has(serverId);
|
return this.memberServerIds.has(serverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns a read-only set of all currently-joined server IDs. */
|
||||||
getJoinedServerIds(): ReadonlySet<string> {
|
getJoinedServerIds(): ReadonlySet<string> {
|
||||||
return this.memberServerIds;
|
return this.memberServerIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Peer messaging ────────────────────────────────────────────────
|
/**
|
||||||
|
* Broadcast a {@link ChatEvent} to every connected peer.
|
||||||
|
*
|
||||||
|
* @param event - The chat event to send.
|
||||||
|
*/
|
||||||
broadcastMessage(event: ChatEvent): void {
|
broadcastMessage(event: ChatEvent): void {
|
||||||
this.peerManager.broadcastMessage(event);
|
this.peerManager.broadcastMessage(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a {@link ChatEvent} to a specific peer.
|
||||||
|
*
|
||||||
|
* @param peerId - The target peer ID.
|
||||||
|
* @param event - The chat event to send.
|
||||||
|
*/
|
||||||
sendToPeer(peerId: string, event: ChatEvent): void {
|
sendToPeer(peerId: string, event: ChatEvent): void {
|
||||||
this.peerManager.sendToPeer(peerId, event);
|
this.peerManager.sendToPeer(peerId, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a {@link ChatEvent} to a peer with back-pressure awareness.
|
||||||
|
*
|
||||||
|
* @param peerId - The target peer ID.
|
||||||
|
* @param event - The chat event to send.
|
||||||
|
*/
|
||||||
async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise<void> {
|
async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise<void> {
|
||||||
return this.peerManager.sendToPeerBuffered(peerId, event);
|
return this.peerManager.sendToPeerBuffered(peerId, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns an array of currently-connected peer IDs. */
|
||||||
getConnectedPeers(): string[] {
|
getConnectedPeers(): string[] {
|
||||||
return this.peerManager.getConnectedPeerIds();
|
return this.peerManager.getConnectedPeerIds();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the composite remote {@link MediaStream} for a connected peer.
|
||||||
|
*
|
||||||
|
* @param peerId - The remote peer whose stream to retrieve.
|
||||||
|
* @returns The stream, or `null` if the peer has no active stream.
|
||||||
|
*/
|
||||||
getRemoteStream(peerId: string): MediaStream | null {
|
getRemoteStream(peerId: string): MediaStream | null {
|
||||||
return this.peerManager.remotePeerStreams.get(peerId) ?? null;
|
return this.peerManager.remotePeerStreams.get(peerId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Voice / Media ─────────────────────────────────────────────────
|
/**
|
||||||
|
* Request microphone access and start sending audio to all peers.
|
||||||
|
*
|
||||||
|
* @returns The captured local {@link MediaStream}.
|
||||||
|
*/
|
||||||
async enableVoice(): Promise<MediaStream> {
|
async enableVoice(): Promise<MediaStream> {
|
||||||
const stream = await this.mediaManager.enableVoice();
|
const stream = await this.mediaManager.enableVoice();
|
||||||
this.syncMediaSignals();
|
this.syncMediaSignals();
|
||||||
return stream;
|
return stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stop local voice capture and remove audio senders from peers. */
|
||||||
disableVoice(): void {
|
disableVoice(): void {
|
||||||
this.mediaManager.disableVoice();
|
this.mediaManager.disableVoice();
|
||||||
this._isVoiceConnected.set(false);
|
this._isVoiceConnected.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject an externally-obtained media stream as the local voice source.
|
||||||
|
*
|
||||||
|
* @param stream - The media stream to use.
|
||||||
|
*/
|
||||||
setLocalStream(stream: MediaStream): void {
|
setLocalStream(stream: MediaStream): void {
|
||||||
this.mediaManager.setLocalStream(stream);
|
this.mediaManager.setLocalStream(stream);
|
||||||
this.syncMediaSignals();
|
this.syncMediaSignals();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the local microphone mute state.
|
||||||
|
*
|
||||||
|
* @param muted - Explicit state; if omitted, the current state is toggled.
|
||||||
|
*/
|
||||||
toggleMute(muted?: boolean): void {
|
toggleMute(muted?: boolean): void {
|
||||||
this.mediaManager.toggleMute(muted);
|
this.mediaManager.toggleMute(muted);
|
||||||
this._isMuted.set(this.mediaManager.getIsMicMuted());
|
this._isMuted.set(this.mediaManager.getIsMicMuted());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle self-deafen (suppress incoming audio playback).
|
||||||
|
*
|
||||||
|
* @param deafened - Explicit state; if omitted, the current state is toggled.
|
||||||
|
*/
|
||||||
toggleDeafen(deafened?: boolean): void {
|
toggleDeafen(deafened?: boolean): void {
|
||||||
this.mediaManager.toggleDeafen(deafened);
|
this.mediaManager.toggleDeafen(deafened);
|
||||||
this._isDeafened.set(this.mediaManager.getIsSelfDeafened());
|
this._isDeafened.set(this.mediaManager.getIsSelfDeafened());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the output volume for remote audio playback.
|
||||||
|
*
|
||||||
|
* @param volume - Normalised volume (0–1).
|
||||||
|
*/
|
||||||
setOutputVolume(volume: number): void {
|
setOutputVolume(volume: number): void {
|
||||||
this.mediaManager.setOutputVolume(volume);
|
this.mediaManager.setOutputVolume(volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the maximum audio bitrate for all peer connections.
|
||||||
|
*
|
||||||
|
* @param kbps - Target bitrate in kilobits per second.
|
||||||
|
*/
|
||||||
async setAudioBitrate(kbps: number): Promise<void> {
|
async setAudioBitrate(kbps: number): Promise<void> {
|
||||||
return this.mediaManager.setAudioBitrate(kbps);
|
return this.mediaManager.setAudioBitrate(kbps);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a predefined latency profile that maps to a specific bitrate.
|
||||||
|
*
|
||||||
|
* @param profile - One of `'low'`, `'balanced'`, or `'high'`.
|
||||||
|
*/
|
||||||
async setLatencyProfile(profile: LatencyProfile): Promise<void> {
|
async setLatencyProfile(profile: LatencyProfile): Promise<void> {
|
||||||
return this.mediaManager.setLatencyProfile(profile);
|
return this.mediaManager.setLatencyProfile(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start broadcasting voice-presence heartbeats to all peers.
|
||||||
|
*
|
||||||
|
* @param roomId - The voice channel room ID.
|
||||||
|
* @param serverId - The voice channel server ID.
|
||||||
|
*/
|
||||||
startVoiceHeartbeat(roomId?: string, serverId?: string): void {
|
startVoiceHeartbeat(roomId?: string, serverId?: string): void {
|
||||||
this.mediaManager.startVoiceHeartbeat(roomId, serverId);
|
this.mediaManager.startVoiceHeartbeat(roomId, serverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stop the voice-presence heartbeat. */
|
||||||
stopVoiceHeartbeat(): void {
|
stopVoiceHeartbeat(): void {
|
||||||
this.mediaManager.stopVoiceHeartbeat();
|
this.mediaManager.stopVoiceHeartbeat();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Screen share ──────────────────────────────────────────────────
|
/**
|
||||||
|
* Start sharing the screen (or a window) with all connected peers.
|
||||||
|
*
|
||||||
|
* @param includeAudio - Whether to capture and mix system audio.
|
||||||
|
* @returns The screen-capture {@link MediaStream}.
|
||||||
|
*/
|
||||||
async startScreenShare(includeAudio: boolean = false): Promise<MediaStream> {
|
async startScreenShare(includeAudio: boolean = false): Promise<MediaStream> {
|
||||||
const stream = await this.screenShareManager.startScreenShare(includeAudio);
|
const stream = await this.screenShareManager.startScreenShare(includeAudio);
|
||||||
this._isScreenSharing.set(true);
|
this._isScreenSharing.set(true);
|
||||||
@@ -410,24 +526,26 @@ export class WebRTCService implements OnDestroy {
|
|||||||
return stream;
|
return stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stop screen sharing and restore microphone audio on all peers. */
|
||||||
stopScreenShare(): void {
|
stopScreenShare(): void {
|
||||||
this.screenShareManager.stopScreenShare();
|
this.screenShareManager.stopScreenShare();
|
||||||
this._isScreenSharing.set(false);
|
this._isScreenSharing.set(false);
|
||||||
this._screenStreamSignal.set(null);
|
this._screenStreamSignal.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Disconnect / cleanup ─────────────────────────────────────────
|
/** Disconnect from the signaling server and clean up all state. */
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
this.leaveRoom();
|
this.leaveRoom();
|
||||||
this.mediaManager.stopVoiceHeartbeat();
|
this.mediaManager.stopVoiceHeartbeat();
|
||||||
this.signalingManager.close();
|
this.signalingManager.close();
|
||||||
this._isSignalingConnected.set(false);
|
this._isSignalingConnected.set(false);
|
||||||
|
this._hasEverConnected.set(false);
|
||||||
this._hasConnectionError.set(false);
|
this._hasConnectionError.set(false);
|
||||||
this._connectionErrorMessage.set(null);
|
this._connectionErrorMessage.set(null);
|
||||||
this.serviceDestroyed$.next();
|
this.serviceDestroyed$.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Alias for {@link disconnect}. */
|
||||||
disconnectAll(): void {
|
disconnectAll(): void {
|
||||||
this.disconnect();
|
this.disconnect();
|
||||||
}
|
}
|
||||||
@@ -442,8 +560,6 @@ export class WebRTCService implements OnDestroy {
|
|||||||
this._screenStreamSignal.set(null);
|
this._screenStreamSignal.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Synchronise Angular signals from the MediaManager's internal state. */
|
/** Synchronise Angular signals from the MediaManager's internal state. */
|
||||||
private syncMediaSignals(): void {
|
private syncMediaSignals(): void {
|
||||||
this._isVoiceConnected.set(this.mediaManager.getIsVoiceActive());
|
this._isVoiceConnected.set(this.mediaManager.getIsVoiceActive());
|
||||||
@@ -451,8 +567,6 @@ export class WebRTCService implements OnDestroy {
|
|||||||
this._isDeafened.set(this.mediaManager.getIsSelfDeafened());
|
this._isDeafened.set(this.mediaManager.getIsSelfDeafened());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Lifecycle ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.disconnect();
|
this.disconnect();
|
||||||
this.serviceDestroyed$.complete();
|
this.serviceDestroyed$.complete();
|
||||||
|
|||||||
@@ -66,22 +66,40 @@ export class MediaManager {
|
|||||||
private callbacks: MediaManagerCallbacks,
|
private callbacks: MediaManagerCallbacks,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the callback set at runtime.
|
||||||
|
* Needed because of circular initialisation between managers.
|
||||||
|
*
|
||||||
|
* @param cb - The new callback interface to wire into this manager.
|
||||||
|
*/
|
||||||
setCallbacks(cb: MediaManagerCallbacks): void {
|
setCallbacks(cb: MediaManagerCallbacks): void {
|
||||||
this.callbacks = cb;
|
this.callbacks = cb;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Accessors ─────────────────────────────────────────────────────
|
/** Returns the current local media stream, or `null` if voice is disabled. */
|
||||||
|
|
||||||
getLocalStream(): MediaStream | null { return this.localMediaStream; }
|
getLocalStream(): MediaStream | null { return this.localMediaStream; }
|
||||||
|
/** Whether voice is currently active (mic captured). */
|
||||||
getIsVoiceActive(): boolean { return this.isVoiceActive; }
|
getIsVoiceActive(): boolean { return this.isVoiceActive; }
|
||||||
|
/** Whether the local microphone is muted. */
|
||||||
getIsMicMuted(): boolean { return this.isMicMuted; }
|
getIsMicMuted(): boolean { return this.isMicMuted; }
|
||||||
|
/** Whether the user has self-deafened. */
|
||||||
getIsSelfDeafened(): boolean { return this.isSelfDeafened; }
|
getIsSelfDeafened(): boolean { return this.isSelfDeafened; }
|
||||||
|
/** Current remote audio output volume (normalised 0–1). */
|
||||||
getRemoteAudioVolume(): number { return this.remoteAudioVolume; }
|
getRemoteAudioVolume(): number { return this.remoteAudioVolume; }
|
||||||
|
/** The voice channel room ID, if currently in voice. */
|
||||||
getCurrentVoiceRoomId(): string | undefined { return this.currentVoiceRoomId; }
|
getCurrentVoiceRoomId(): string | undefined { return this.currentVoiceRoomId; }
|
||||||
|
/** The voice channel server ID, if currently in voice. */
|
||||||
getCurrentVoiceServerId(): string | undefined { return this.currentVoiceServerId; }
|
getCurrentVoiceServerId(): string | undefined { return this.currentVoiceServerId; }
|
||||||
|
|
||||||
// ─── Enable / Disable voice ────────────────────────────────────────
|
/**
|
||||||
|
* Request microphone access via `getUserMedia` and bind the resulting
|
||||||
|
* audio track to every active peer connection.
|
||||||
|
*
|
||||||
|
* If a local stream already exists it is stopped first.
|
||||||
|
*
|
||||||
|
* @returns The captured {@link MediaStream}.
|
||||||
|
* @throws If `getUserMedia` is unavailable (non-secure context) or the user denies access.
|
||||||
|
*/
|
||||||
async enableVoice(): Promise<MediaStream> {
|
async enableVoice(): Promise<MediaStream> {
|
||||||
try {
|
try {
|
||||||
// Stop any existing stream first
|
// Stop any existing stream first
|
||||||
@@ -125,6 +143,10 @@ export class MediaManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop all local media tracks and remove audio senders from peers.
|
||||||
|
* The peer connections themselves are kept alive.
|
||||||
|
*/
|
||||||
disableVoice(): void {
|
disableVoice(): void {
|
||||||
if (this.localMediaStream) {
|
if (this.localMediaStream) {
|
||||||
this.localMediaStream.getTracks().forEach((track) => track.stop());
|
this.localMediaStream.getTracks().forEach((track) => track.stop());
|
||||||
@@ -154,8 +176,11 @@ export class MediaManager {
|
|||||||
this.voiceConnected$.next();
|
this.voiceConnected$.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Mute / Deafen ────────────────────────────────────────────────
|
/**
|
||||||
|
* Toggle the local microphone mute state.
|
||||||
|
*
|
||||||
|
* @param muted - Explicit state; if omitted, the current state is toggled.
|
||||||
|
*/
|
||||||
toggleMute(muted?: boolean): void {
|
toggleMute(muted?: boolean): void {
|
||||||
if (this.localMediaStream) {
|
if (this.localMediaStream) {
|
||||||
const audioTracks = this.localMediaStream.getAudioTracks();
|
const audioTracks = this.localMediaStream.getAudioTracks();
|
||||||
@@ -165,18 +190,32 @@ export class MediaManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle self-deafen (suppress all incoming audio playback).
|
||||||
|
*
|
||||||
|
* @param deafened - Explicit state; if omitted, the current state is toggled.
|
||||||
|
*/
|
||||||
toggleDeafen(deafened?: boolean): void {
|
toggleDeafen(deafened?: boolean): void {
|
||||||
this.isSelfDeafened = deafened !== undefined ? deafened : !this.isSelfDeafened;
|
this.isSelfDeafened = deafened !== undefined ? deafened : !this.isSelfDeafened;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Volume ────────────────────────────────────────────────────────
|
/**
|
||||||
|
* Set the output volume for remote audio.
|
||||||
|
*
|
||||||
|
* @param volume - A value between {@link VOLUME_MIN} (0) and {@link VOLUME_MAX} (1).
|
||||||
|
*/
|
||||||
setOutputVolume(volume: number): void {
|
setOutputVolume(volume: number): void {
|
||||||
this.remoteAudioVolume = Math.max(VOLUME_MIN, Math.min(VOLUME_MAX, volume));
|
this.remoteAudioVolume = Math.max(VOLUME_MIN, Math.min(VOLUME_MAX, volume));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Audio bitrate ────────────────────────────────────────────────
|
/**
|
||||||
|
* Set the maximum audio bitrate on every active peer's audio sender.
|
||||||
|
*
|
||||||
|
* The value is clamped between {@link AUDIO_BITRATE_MIN_BPS} and
|
||||||
|
* {@link AUDIO_BITRATE_MAX_BPS}.
|
||||||
|
*
|
||||||
|
* @param kbps - Target bitrate in kilobits per second.
|
||||||
|
*/
|
||||||
async setAudioBitrate(kbps: number): Promise<void> {
|
async setAudioBitrate(kbps: number): Promise<void> {
|
||||||
const targetBps = Math.max(AUDIO_BITRATE_MIN_BPS, Math.min(AUDIO_BITRATE_MAX_BPS, Math.floor(kbps * KBPS_TO_BPS)));
|
const targetBps = Math.max(AUDIO_BITRATE_MIN_BPS, Math.min(AUDIO_BITRATE_MAX_BPS, Math.floor(kbps * KBPS_TO_BPS)));
|
||||||
|
|
||||||
@@ -186,25 +225,36 @@ export class MediaManager {
|
|||||||
if (peerData.connection.signalingState !== 'stable') return;
|
if (peerData.connection.signalingState !== 'stable') return;
|
||||||
|
|
||||||
let params: RTCRtpSendParameters;
|
let params: RTCRtpSendParameters;
|
||||||
try { params = sender.getParameters(); } catch (e) { console.warn('getParameters failed; skipping bitrate apply', e); return; }
|
try { params = sender.getParameters(); } catch (error) { this.logger.warn('getParameters failed; skipping bitrate apply', error as any); return; }
|
||||||
params.encodings = params.encodings || [{}];
|
params.encodings = params.encodings || [{}];
|
||||||
params.encodings[0].maxBitrate = targetBps;
|
params.encodings[0].maxBitrate = targetBps;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sender.setParameters(params);
|
await sender.setParameters(params);
|
||||||
console.log('Applied audio bitrate:', targetBps);
|
this.logger.info('Applied audio bitrate', { targetBps });
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
console.warn('Failed to set audio bitrate', e);
|
this.logger.warn('Failed to set audio bitrate', error as any);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a named latency profile that maps to a predefined bitrate.
|
||||||
|
*
|
||||||
|
* @param profile - One of `'low'`, `'balanced'`, or `'high'`.
|
||||||
|
*/
|
||||||
async setLatencyProfile(profile: LatencyProfile): Promise<void> {
|
async setLatencyProfile(profile: LatencyProfile): Promise<void> {
|
||||||
await this.setAudioBitrate(LATENCY_PROFILE_BITRATES[profile]);
|
await this.setAudioBitrate(LATENCY_PROFILE_BITRATES[profile]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Voice-presence heartbeat ─────────────────────────────────────
|
/**
|
||||||
|
* Start periodically broadcasting voice presence to all peers.
|
||||||
|
*
|
||||||
|
* Optionally records the voice room/server so heartbeats include them.
|
||||||
|
*
|
||||||
|
* @param roomId - The voice channel room ID.
|
||||||
|
* @param serverId - The voice channel server ID.
|
||||||
|
*/
|
||||||
startVoiceHeartbeat(roomId?: string, serverId?: string): void {
|
startVoiceHeartbeat(roomId?: string, serverId?: string): void {
|
||||||
this.stopVoiceHeartbeat();
|
this.stopVoiceHeartbeat();
|
||||||
|
|
||||||
@@ -224,6 +274,7 @@ export class MediaManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stop the voice-presence heartbeat timer. */
|
||||||
stopVoiceHeartbeat(): void {
|
stopVoiceHeartbeat(): void {
|
||||||
if (this.voicePresenceTimer) {
|
if (this.voicePresenceTimer) {
|
||||||
clearInterval(this.voicePresenceTimer);
|
clearInterval(this.voicePresenceTimer);
|
||||||
@@ -231,8 +282,6 @@ export class MediaManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Internal helpers ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bind local audio/video tracks to all existing peer transceivers.
|
* Bind local audio/video tracks to all existing peer transceivers.
|
||||||
* Restores transceiver direction to sendrecv if previously set to recvonly
|
* Restores transceiver direction to sendrecv if previously set to recvonly
|
||||||
@@ -285,6 +334,7 @@ export class MediaManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Broadcast a voice-presence state event to all connected peers. */
|
||||||
private broadcastVoicePresence(): void {
|
private broadcastVoicePresence(): void {
|
||||||
const oderId = this.callbacks.getIdentifyOderId();
|
const oderId = this.callbacks.getIdentifyOderId();
|
||||||
const displayName = this.callbacks.getIdentifyDisplayName();
|
const displayName = this.callbacks.getIdentifyDisplayName();
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export class PeerConnectionManager {
|
|||||||
private disconnectedPeerTracker = new Map<string, DisconnectedPeerEntry>();
|
private disconnectedPeerTracker = new Map<string, DisconnectedPeerEntry>();
|
||||||
private peerReconnectTimers = new Map<string, ReturnType<typeof setInterval>>();
|
private peerReconnectTimers = new Map<string, ReturnType<typeof setInterval>>();
|
||||||
|
|
||||||
// ─── Public event subjects ─────────────────────────────────────────
|
|
||||||
readonly peerConnected$ = new Subject<string>();
|
readonly peerConnected$ = new Subject<string>();
|
||||||
readonly peerDisconnected$ = new Subject<string>();
|
readonly peerDisconnected$ = new Subject<string>();
|
||||||
readonly remoteStream$ = new Subject<{ peerId: string; stream: MediaStream }>();
|
readonly remoteStream$ = new Subject<{ peerId: string; stream: MediaStream }>();
|
||||||
@@ -77,13 +76,27 @@ export class PeerConnectionManager {
|
|||||||
private callbacks: PeerConnectionCallbacks,
|
private callbacks: PeerConnectionCallbacks,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Allow hot-swapping callbacks (e.g. after service wiring). */
|
/**
|
||||||
|
* Replace the callback set at runtime.
|
||||||
|
* Needed because of circular initialisation between managers.
|
||||||
|
*
|
||||||
|
* @param cb - The new callback interface to wire into this manager.
|
||||||
|
*/
|
||||||
setCallbacks(cb: PeerConnectionCallbacks): void {
|
setCallbacks(cb: PeerConnectionCallbacks): void {
|
||||||
this.callbacks = cb;
|
this.callbacks = cb;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Peer connection lifecycle ─────────────────────────────────────
|
/**
|
||||||
|
* Create a new RTCPeerConnection to a remote peer.
|
||||||
|
*
|
||||||
|
* Sets up ICE candidate forwarding, connection-state monitoring,
|
||||||
|
* data-channel creation (initiator) or listening (answerer),
|
||||||
|
* transceiver pre-creation, and local-track attachment.
|
||||||
|
*
|
||||||
|
* @param remotePeerId - Unique identifier of the remote peer.
|
||||||
|
* @param isInitiator - `true` if this side should create the data channel and send the offer.
|
||||||
|
* @returns The newly-created {@link PeerData} record.
|
||||||
|
*/
|
||||||
createPeerConnection(remotePeerId: string, isInitiator: boolean): PeerData {
|
createPeerConnection(remotePeerId: string, isInitiator: boolean): PeerData {
|
||||||
this.logger.info('Creating peer connection', { remotePeerId, isInitiator });
|
this.logger.info('Creating peer connection', { remotePeerId, isInitiator });
|
||||||
|
|
||||||
@@ -201,8 +214,11 @@ export class PeerConnectionManager {
|
|||||||
return peerData;
|
return peerData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Offer / Answer / ICE ──────────────────────────────────────────
|
/**
|
||||||
|
* Create an SDP offer and send it to the remote peer via the signaling server.
|
||||||
|
*
|
||||||
|
* @param remotePeerId - The peer to send the offer to.
|
||||||
|
*/
|
||||||
async createAndSendOffer(remotePeerId: string): Promise<void> {
|
async createAndSendOffer(remotePeerId: string): Promise<void> {
|
||||||
const peerData = this.activePeerConnections.get(remotePeerId);
|
const peerData = this.activePeerConnections.get(remotePeerId);
|
||||||
if (!peerData) return;
|
if (!peerData) return;
|
||||||
@@ -221,6 +237,16 @@ export class PeerConnectionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming SDP offer from a remote peer.
|
||||||
|
*
|
||||||
|
* Creates the peer connection if it doesn't exist, sets the remote
|
||||||
|
* description, discovers browser-created transceivers, attaches local
|
||||||
|
* tracks, flushes queued ICE candidates, and sends back an answer.
|
||||||
|
*
|
||||||
|
* @param fromUserId - The peer ID that sent the offer.
|
||||||
|
* @param sdp - The remote session description.
|
||||||
|
*/
|
||||||
async handleOffer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise<void> {
|
async handleOffer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise<void> {
|
||||||
this.logger.info('Handling offer', { fromUserId });
|
this.logger.info('Handling offer', { fromUserId });
|
||||||
|
|
||||||
@@ -277,6 +303,15 @@ export class PeerConnectionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming SDP answer from a remote peer.
|
||||||
|
*
|
||||||
|
* Sets the remote description and flushes any queued ICE candidates.
|
||||||
|
* Ignored if the connection is not in the `have-local-offer` state.
|
||||||
|
*
|
||||||
|
* @param fromUserId - The peer ID that sent the answer.
|
||||||
|
* @param sdp - The remote session description.
|
||||||
|
*/
|
||||||
async handleAnswer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise<void> {
|
async handleAnswer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise<void> {
|
||||||
this.logger.info('Handling answer', { fromUserId });
|
this.logger.info('Handling answer', { fromUserId });
|
||||||
const peerData = this.activePeerConnections.get(fromUserId);
|
const peerData = this.activePeerConnections.get(fromUserId);
|
||||||
@@ -300,6 +335,15 @@ export class PeerConnectionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process an incoming ICE candidate from a remote peer.
|
||||||
|
*
|
||||||
|
* If the remote description has already been set the candidate is added
|
||||||
|
* immediately; otherwise it is queued until the description arrives.
|
||||||
|
*
|
||||||
|
* @param fromUserId - The peer ID that sent the candidate.
|
||||||
|
* @param candidate - The ICE candidate to add.
|
||||||
|
*/
|
||||||
async handleIceCandidate(fromUserId: string, candidate: RTCIceCandidateInit): Promise<void> {
|
async handleIceCandidate(fromUserId: string, candidate: RTCIceCandidateInit): Promise<void> {
|
||||||
let peerData = this.activePeerConnections.get(fromUserId);
|
let peerData = this.activePeerConnections.get(fromUserId);
|
||||||
if (!peerData) {
|
if (!peerData) {
|
||||||
@@ -338,21 +382,28 @@ export class PeerConnectionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Data channel ──────────────────────────────────────────────────
|
/**
|
||||||
|
* Wire open/close/error/message handlers onto a data channel.
|
||||||
|
*
|
||||||
|
* On open, current voice and screen states are sent to the remote peer
|
||||||
|
* and a state-request is emitted so the remote peer does the same.
|
||||||
|
*
|
||||||
|
* @param channel - The RTCDataChannel to configure.
|
||||||
|
* @param remotePeerId - The remote peer this channel belongs to.
|
||||||
|
*/
|
||||||
private setupDataChannel(channel: RTCDataChannel, remotePeerId: string): void {
|
private setupDataChannel(channel: RTCDataChannel, remotePeerId: string): void {
|
||||||
channel.onopen = () => {
|
channel.onopen = () => {
|
||||||
console.log(`Data channel open with ${remotePeerId}`);
|
this.logger.info('Data channel open', { remotePeerId });
|
||||||
this.sendCurrentStatesToChannel(channel, remotePeerId);
|
this.sendCurrentStatesToChannel(channel, remotePeerId);
|
||||||
try { channel.send(JSON.stringify({ type: P2P_TYPE_STATE_REQUEST })); } catch { /* ignore */ }
|
try { channel.send(JSON.stringify({ type: P2P_TYPE_STATE_REQUEST })); } catch { /* ignore */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
channel.onclose = () => {
|
channel.onclose = () => {
|
||||||
console.log(`Data channel closed with ${remotePeerId}`);
|
this.logger.info('Data channel closed', { remotePeerId });
|
||||||
};
|
};
|
||||||
|
|
||||||
channel.onerror = (error) => {
|
channel.onerror = (error) => {
|
||||||
console.error(`Data channel error with ${remotePeerId}:`, error);
|
this.logger.error('Data channel error', error, { remotePeerId });
|
||||||
};
|
};
|
||||||
|
|
||||||
channel.onmessage = (event) => {
|
channel.onmessage = (event) => {
|
||||||
@@ -365,8 +416,18 @@ export class PeerConnectionManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route an incoming peer-to-peer message.
|
||||||
|
*
|
||||||
|
* State-request messages trigger an immediate state broadcast back.
|
||||||
|
* All other messages are enriched with `fromPeerId` and forwarded
|
||||||
|
* to the {@link messageReceived$} subject.
|
||||||
|
*
|
||||||
|
* @param peerId - The remote peer that sent the message.
|
||||||
|
* @param message - The parsed JSON payload.
|
||||||
|
*/
|
||||||
private handlePeerMessage(peerId: string, message: any): void {
|
private handlePeerMessage(peerId: string, message: any): void {
|
||||||
console.log('Received P2P message from', peerId, ':', message);
|
this.logger.info('Received P2P message', { peerId, type: message?.type });
|
||||||
|
|
||||||
if (message.type === P2P_TYPE_STATE_REQUEST || message.type === P2P_TYPE_VOICE_STATE_REQUEST) {
|
if (message.type === P2P_TYPE_STATE_REQUEST || message.type === P2P_TYPE_VOICE_STATE_REQUEST) {
|
||||||
this.sendCurrentStatesToPeer(peerId);
|
this.sendCurrentStatesToPeer(peerId);
|
||||||
@@ -377,8 +438,6 @@ export class PeerConnectionManager {
|
|||||||
this.messageReceived$.next(enriched);
|
this.messageReceived$.next(enriched);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Messaging helpers ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Broadcast a ChatEvent to every peer with an open data channel. */
|
/** Broadcast a ChatEvent to every peer with an open data channel. */
|
||||||
broadcastMessage(event: ChatEvent): void {
|
broadcastMessage(event: ChatEvent): void {
|
||||||
const data = JSON.stringify(event);
|
const data = JSON.stringify(event);
|
||||||
@@ -386,33 +445,48 @@ export class PeerConnectionManager {
|
|||||||
try {
|
try {
|
||||||
if (peerData.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) {
|
if (peerData.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) {
|
||||||
peerData.dataChannel.send(data);
|
peerData.dataChannel.send(data);
|
||||||
console.log('Sent message via P2P to:', peerId);
|
this.logger.info('Sent message via P2P', { peerId });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to send to peer ${peerId}:`, error);
|
this.logger.error('Failed to send to peer', error, { peerId });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Send a ChatEvent to a single peer. */
|
/**
|
||||||
|
* Send a {@link ChatEvent} to a specific peer's data channel.
|
||||||
|
*
|
||||||
|
* Silently returns if the peer is not connected or the channel is not open.
|
||||||
|
*
|
||||||
|
* @param peerId - The target peer.
|
||||||
|
* @param event - The chat event to send.
|
||||||
|
*/
|
||||||
sendToPeer(peerId: string, event: ChatEvent): void {
|
sendToPeer(peerId: string, event: ChatEvent): void {
|
||||||
const peerData = this.activePeerConnections.get(peerId);
|
const peerData = this.activePeerConnections.get(peerId);
|
||||||
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
||||||
console.error(`Peer ${peerId} not connected`);
|
this.logger.warn('Peer not connected – cannot send', { peerId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
peerData.dataChannel.send(JSON.stringify(event));
|
peerData.dataChannel.send(JSON.stringify(event));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to send to peer ${peerId}:`, error);
|
this.logger.error('Failed to send to peer', error, { peerId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Send with back-pressure awareness (for large payloads). */
|
/**
|
||||||
|
* Send a {@link ChatEvent} with back-pressure awareness.
|
||||||
|
*
|
||||||
|
* If the data channel's buffer exceeds {@link DATA_CHANNEL_HIGH_WATER_BYTES}
|
||||||
|
* the call awaits until the buffer drains below {@link DATA_CHANNEL_LOW_WATER_BYTES}.
|
||||||
|
*
|
||||||
|
* @param peerId - The target peer.
|
||||||
|
* @param event - The chat event to send.
|
||||||
|
*/
|
||||||
async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise<void> {
|
async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise<void> {
|
||||||
const peerData = this.activePeerConnections.get(peerId);
|
const peerData = this.activePeerConnections.get(peerId);
|
||||||
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
||||||
console.error(`Peer ${peerId} not connected`);
|
this.logger.warn('Peer not connected – cannot send buffered', { peerId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,11 +509,14 @@ export class PeerConnectionManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try { channel.send(data); } catch (error) { console.error(`Failed to send to peer ${peerId}:`, error); }
|
try { channel.send(data); } catch (error) { this.logger.error('Failed to send buffered message', error, { peerId }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── State broadcasts ─────────────────────────────────────────────
|
/**
|
||||||
|
* Send the current voice and screen-share states to a single peer.
|
||||||
|
*
|
||||||
|
* @param peerId - The peer to notify.
|
||||||
|
*/
|
||||||
sendCurrentStatesToPeer(peerId: string): void {
|
sendCurrentStatesToPeer(peerId: string): void {
|
||||||
const credentials = this.callbacks.getIdentifyCredentials();
|
const credentials = this.callbacks.getIdentifyCredentials();
|
||||||
const oderId = credentials?.oderId || this.callbacks.getLocalPeerId();
|
const oderId = credentials?.oderId || this.callbacks.getLocalPeerId();
|
||||||
@@ -469,6 +546,7 @@ export class PeerConnectionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Broadcast the current voice and screen-share states to all connected peers. */
|
||||||
broadcastCurrentStates(): void {
|
broadcastCurrentStates(): void {
|
||||||
const credentials = this.callbacks.getIdentifyCredentials();
|
const credentials = this.callbacks.getIdentifyCredentials();
|
||||||
const oderId = credentials?.oderId || this.callbacks.getLocalPeerId();
|
const oderId = credentials?.oderId || this.callbacks.getLocalPeerId();
|
||||||
@@ -479,8 +557,6 @@ export class PeerConnectionManager {
|
|||||||
this.broadcastMessage({ type: P2P_TYPE_SCREEN_STATE, oderId, displayName, isScreenSharing: this.callbacks.isScreenSharingActive() } as any);
|
this.broadcastMessage({ type: P2P_TYPE_SCREEN_STATE, oderId, displayName, isScreenSharing: this.callbacks.isScreenSharingActive() } as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Remote tracks ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private handleRemoteTrack(event: RTCTrackEvent, remotePeerId: string): void {
|
private handleRemoteTrack(event: RTCTrackEvent, remotePeerId: string): void {
|
||||||
const track = event.track;
|
const track = event.track;
|
||||||
const settings = typeof track.getSettings === 'function' ? track.getSettings() : {} as MediaTrackSettings;
|
const settings = typeof track.getSettings === 'function' ? track.getSettings() : {} as MediaTrackSettings;
|
||||||
@@ -495,7 +571,7 @@ export class PeerConnectionManager {
|
|||||||
|
|
||||||
// Merge into composite stream per peer
|
// Merge into composite stream per peer
|
||||||
let compositeStream = this.remotePeerStreams.get(remotePeerId) || new MediaStream();
|
let compositeStream = this.remotePeerStreams.get(remotePeerId) || new MediaStream();
|
||||||
const trackAlreadyAdded = compositeStream.getTracks().some(t => t.id === track.id);
|
const trackAlreadyAdded = compositeStream.getTracks().some(existingTrack => existingTrack.id === track.id);
|
||||||
if (!trackAlreadyAdded) {
|
if (!trackAlreadyAdded) {
|
||||||
try { compositeStream.addTrack(track); } catch (e) { this.logger.warn('Failed to add track to composite stream', e as any); }
|
try { compositeStream.addTrack(track); } catch (e) { this.logger.warn('Failed to add track to composite stream', e as any); }
|
||||||
}
|
}
|
||||||
@@ -503,8 +579,11 @@ export class PeerConnectionManager {
|
|||||||
this.remoteStream$.next({ peerId: remotePeerId, stream: compositeStream });
|
this.remoteStream$.next({ peerId: remotePeerId, stream: compositeStream });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Peer removal / cleanup ────────────────────────────────────────
|
/**
|
||||||
|
* Close and remove a peer connection, data channel, and emit a disconnect event.
|
||||||
|
*
|
||||||
|
* @param peerId - The peer to remove.
|
||||||
|
*/
|
||||||
removePeer(peerId: string): void {
|
removePeer(peerId: string): void {
|
||||||
const peerData = this.activePeerConnections.get(peerId);
|
const peerData = this.activePeerConnections.get(peerId);
|
||||||
if (peerData) {
|
if (peerData) {
|
||||||
@@ -516,6 +595,7 @@ export class PeerConnectionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Close every active peer connection and clear internal state. */
|
||||||
closeAllPeers(): void {
|
closeAllPeers(): void {
|
||||||
this.clearAllPeerReconnectTimers();
|
this.clearAllPeerReconnectTimers();
|
||||||
this.activePeerConnections.forEach((peerData) => {
|
this.activePeerConnections.forEach((peerData) => {
|
||||||
@@ -526,8 +606,6 @@ export class PeerConnectionManager {
|
|||||||
this.connectedPeersChanged$.next([]);
|
this.connectedPeersChanged$.next([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── P2P reconnection ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
private trackDisconnectedPeer(peerId: string): void {
|
private trackDisconnectedPeer(peerId: string): void {
|
||||||
this.disconnectedPeerTracker.set(peerId, { lastSeenTimestamp: Date.now(), reconnectAttempts: 0 });
|
this.disconnectedPeerTracker.set(peerId, { lastSeenTimestamp: Date.now(), reconnectAttempts: 0 });
|
||||||
}
|
}
|
||||||
@@ -537,6 +615,7 @@ export class PeerConnectionManager {
|
|||||||
if (timer) { clearInterval(timer); this.peerReconnectTimers.delete(peerId); }
|
if (timer) { clearInterval(timer); this.peerReconnectTimers.delete(peerId); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cancel all pending peer reconnect timers and clear the tracker. */
|
||||||
clearAllPeerReconnectTimers(): void {
|
clearAllPeerReconnectTimers(): void {
|
||||||
this.peerReconnectTimers.forEach((timer) => clearInterval(timer));
|
this.peerReconnectTimers.forEach((timer) => clearInterval(timer));
|
||||||
this.peerReconnectTimers.clear();
|
this.peerReconnectTimers.clear();
|
||||||
@@ -586,10 +665,9 @@ export class PeerConnectionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Connected-peer helpers ────────────────────────────────────────
|
|
||||||
|
|
||||||
private connectedPeersList: string[] = [];
|
private connectedPeersList: string[] = [];
|
||||||
|
|
||||||
|
/** Return a snapshot copy of the currently-connected peer IDs. */
|
||||||
getConnectedPeerIds(): string[] {
|
getConnectedPeerIds(): string[] {
|
||||||
return [...this.connectedPeersList];
|
return [...this.connectedPeersList];
|
||||||
}
|
}
|
||||||
@@ -601,11 +679,17 @@ export class PeerConnectionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a peer from the connected list and notify subscribers.
|
||||||
|
*
|
||||||
|
* @param peerId - The peer to remove.
|
||||||
|
*/
|
||||||
private removeFromConnectedPeers(peerId: string): void {
|
private removeFromConnectedPeers(peerId: string): void {
|
||||||
this.connectedPeersList = this.connectedPeersList.filter(p => p !== peerId);
|
this.connectedPeersList = this.connectedPeersList.filter(connectedId => connectedId !== peerId);
|
||||||
this.connectedPeersChanged$.next(this.connectedPeersList);
|
this.connectedPeersChanged$.next(this.connectedPeersList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reset the connected peers list to empty and notify subscribers. */
|
||||||
resetConnectedPeers(): void {
|
resetConnectedPeers(): void {
|
||||||
this.connectedPeersList = [];
|
this.connectedPeersList = [];
|
||||||
this.connectedPeersChanged$.next([]);
|
this.connectedPeersChanged$.next([]);
|
||||||
|
|||||||
@@ -43,17 +43,32 @@ export class ScreenShareManager {
|
|||||||
private callbacks: ScreenShareCallbacks,
|
private callbacks: ScreenShareCallbacks,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the callback set at runtime.
|
||||||
|
* Needed because of circular initialisation between managers.
|
||||||
|
*
|
||||||
|
* @param cb - The new callback interface to wire into this manager.
|
||||||
|
*/
|
||||||
setCallbacks(cb: ScreenShareCallbacks): void {
|
setCallbacks(cb: ScreenShareCallbacks): void {
|
||||||
this.callbacks = cb;
|
this.callbacks = cb;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Accessors ─────────────────────────────────────────────────────
|
/** Returns the current screen-capture stream, or `null` if inactive. */
|
||||||
|
|
||||||
getScreenStream(): MediaStream | null { return this.activeScreenStream; }
|
getScreenStream(): MediaStream | null { return this.activeScreenStream; }
|
||||||
|
/** Whether screen sharing is currently active. */
|
||||||
getIsScreenActive(): boolean { return this.isScreenActive; }
|
getIsScreenActive(): boolean { return this.isScreenActive; }
|
||||||
|
|
||||||
// ─── Start / Stop ──────────────────────────────────────────────────
|
/**
|
||||||
|
* Begin screen sharing.
|
||||||
|
*
|
||||||
|
* Tries the Electron desktop capturer API first (for Electron builds),
|
||||||
|
* then falls back to standard `getDisplayMedia`.
|
||||||
|
* Optionally includes system audio mixed with the microphone.
|
||||||
|
*
|
||||||
|
* @param includeSystemAudio - Whether to capture system / tab audio alongside the video.
|
||||||
|
* @returns The captured screen {@link MediaStream}.
|
||||||
|
* @throws If both Electron and browser screen capture fail.
|
||||||
|
*/
|
||||||
async startScreenShare(includeSystemAudio: boolean = false): Promise<MediaStream> {
|
async startScreenShare(includeSystemAudio: boolean = false): Promise<MediaStream> {
|
||||||
try {
|
try {
|
||||||
this.logger.info('startScreenShare invoked', { includeSystemAudio });
|
this.logger.info('startScreenShare invoked', { includeSystemAudio });
|
||||||
@@ -125,6 +140,12 @@ export class ScreenShareManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop screen sharing and restore the microphone audio track on all peers.
|
||||||
|
*
|
||||||
|
* Stops all screen-capture tracks, tears down mixed audio,
|
||||||
|
* resets video transceivers to receive-only, and triggers renegotiation.
|
||||||
|
*/
|
||||||
stopScreenShare(): void {
|
stopScreenShare(): void {
|
||||||
if (this.activeScreenStream) {
|
if (this.activeScreenStream) {
|
||||||
this.activeScreenStream.getTracks().forEach((track) => track.stop());
|
this.activeScreenStream.getTracks().forEach((track) => track.stop());
|
||||||
@@ -135,14 +156,14 @@ export class ScreenShareManager {
|
|||||||
|
|
||||||
// Clean up mixed audio
|
// Clean up mixed audio
|
||||||
if (this.combinedAudioStream) {
|
if (this.combinedAudioStream) {
|
||||||
try { this.combinedAudioStream.getTracks().forEach(t => t.stop()); } catch { /* ignore */ }
|
try { this.combinedAudioStream.getTracks().forEach(track => track.stop()); } catch { /* ignore */ }
|
||||||
this.combinedAudioStream = null;
|
this.combinedAudioStream = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove video track and restore mic on all peers
|
// Remove video track and restore mic on all peers
|
||||||
this.callbacks.getActivePeers().forEach((peerData, peerId) => {
|
this.callbacks.getActivePeers().forEach((peerData, peerId) => {
|
||||||
const transceivers = peerData.connection.getTransceivers();
|
const transceivers = peerData.connection.getTransceivers();
|
||||||
const videoTransceiver = transceivers.find(t => t.sender === peerData.videoSender || t.sender === peerData.screenVideoSender);
|
const videoTransceiver = transceivers.find(transceiver => transceiver.sender === peerData.videoSender || transceiver.sender === peerData.screenVideoSender);
|
||||||
if (videoTransceiver) {
|
if (videoTransceiver) {
|
||||||
videoTransceiver.sender.replaceTrack(null).catch(() => {});
|
videoTransceiver.sender.replaceTrack(null).catch(() => {});
|
||||||
if (videoTransceiver.direction === TRANSCEIVER_SEND_RECV) {
|
if (videoTransceiver.direction === TRANSCEIVER_SEND_RECV) {
|
||||||
@@ -161,15 +182,20 @@ export class ScreenShareManager {
|
|||||||
audioSender = transceiver.sender;
|
audioSender = transceiver.sender;
|
||||||
}
|
}
|
||||||
peerData.audioSender = audioSender;
|
peerData.audioSender = audioSender;
|
||||||
audioSender.replaceTrack(micTrack).catch((e) => console.error('restore mic replaceTrack failed:', e));
|
audioSender.replaceTrack(micTrack).catch((error) => this.logger.error('Restore mic replaceTrack failed', error));
|
||||||
}
|
}
|
||||||
this.callbacks.renegotiate(peerId);
|
this.callbacks.renegotiate(peerId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Internal helpers ──────────────────────────────────────────────
|
/**
|
||||||
|
* Create a mixed audio stream from screen audio + microphone audio
|
||||||
/** Create a mixed audio stream from screen audio + mic audio. */
|
* using the Web Audio API ({@link AudioContext}).
|
||||||
|
*
|
||||||
|
* Falls back to screen-audio-only if mixing fails.
|
||||||
|
*
|
||||||
|
* @param includeSystemAudio - Whether system audio should be mixed in.
|
||||||
|
*/
|
||||||
private prepareMixedAudio(includeSystemAudio: boolean): void {
|
private prepareMixedAudio(includeSystemAudio: boolean): void {
|
||||||
const screenAudioTrack = includeSystemAudio ? (this.activeScreenStream?.getAudioTracks()[0] || null) : null;
|
const screenAudioTrack = includeSystemAudio ? (this.activeScreenStream?.getAudioTracks()[0] || null) : null;
|
||||||
const micAudioTrack = this.callbacks.getLocalMediaStream()?.getAudioTracks()[0] || null;
|
const micAudioTrack = this.callbacks.getLocalMediaStream()?.getAudioTracks()[0] || null;
|
||||||
@@ -204,7 +230,12 @@ export class ScreenShareManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Attach screen video + audio tracks to all active peers. */
|
/**
|
||||||
|
* Attach screen video and (optionally) combined audio tracks to all
|
||||||
|
* active peer connections, then trigger SDP renegotiation.
|
||||||
|
*
|
||||||
|
* @param includeSystemAudio - Whether the combined audio track should replace the mic sender.
|
||||||
|
*/
|
||||||
private attachScreenTracksToPeers(includeSystemAudio: boolean): void {
|
private attachScreenTracksToPeers(includeSystemAudio: boolean): void {
|
||||||
this.callbacks.getActivePeers().forEach((peerData, peerId) => {
|
this.callbacks.getActivePeers().forEach((peerData, peerId) => {
|
||||||
if (!this.activeScreenStream) return;
|
if (!this.activeScreenStream) return;
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ export class SignalingManager {
|
|||||||
private readonly getMemberServerIds: () => ReadonlySet<string>,
|
private readonly getMemberServerIds: () => ReadonlySet<string>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ─── Public API ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Open (or re-open) a WebSocket to the signaling server. */
|
/** Open (or re-open) a WebSocket to the signaling server. */
|
||||||
connect(serverUrl: string): Observable<boolean> {
|
connect(serverUrl: string): Observable<boolean> {
|
||||||
this.lastSignalingUrl = serverUrl;
|
this.lastSignalingUrl = serverUrl;
|
||||||
@@ -146,8 +144,6 @@ export class SignalingManager {
|
|||||||
return this.lastSignalingUrl;
|
return this.lastSignalingUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Internals ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Re-identify and rejoin servers after a reconnect. */
|
/** Re-identify and rejoin servers after a reconnect. */
|
||||||
private reIdentifyAndRejoin(): void {
|
private reIdentifyAndRejoin(): void {
|
||||||
const credentials = this.getLastIdentify();
|
const credentials = this.getLastIdentify();
|
||||||
@@ -172,6 +168,12 @@ export class SignalingManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a reconnect attempt using exponential backoff.
|
||||||
|
*
|
||||||
|
* The delay doubles with each attempt up to {@link SIGNALING_RECONNECT_MAX_DELAY_MS}.
|
||||||
|
* No-ops if a timer is already pending or no URL is stored.
|
||||||
|
*/
|
||||||
private scheduleReconnect(): void {
|
private scheduleReconnect(): void {
|
||||||
if (this.signalingReconnectTimer || !this.lastSignalingUrl) return;
|
if (this.signalingReconnectTimer || !this.lastSignalingUrl) return;
|
||||||
const delay = Math.min(
|
const delay = Math.min(
|
||||||
@@ -189,6 +191,7 @@ export class SignalingManager {
|
|||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cancel any pending reconnect timer and reset the attempt counter. */
|
||||||
private clearReconnect(): void {
|
private clearReconnect(): void {
|
||||||
if (this.signalingReconnectTimer) {
|
if (this.signalingReconnectTimer) {
|
||||||
clearTimeout(this.signalingReconnectTimer);
|
clearTimeout(this.signalingReconnectTimer);
|
||||||
@@ -197,11 +200,13 @@ export class SignalingManager {
|
|||||||
this.signalingReconnectAttempts = 0;
|
this.signalingReconnectAttempts = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Start the heartbeat interval that drives periodic state broadcasts. */
|
||||||
private startHeartbeat(): void {
|
private startHeartbeat(): void {
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
this.stateHeartbeatTimer = setInterval(() => this.heartbeatTick$.next(), STATE_HEARTBEAT_INTERVAL_MS);
|
this.stateHeartbeatTimer = setInterval(() => this.heartbeatTick$.next(), STATE_HEARTBEAT_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stop the heartbeat interval. */
|
||||||
private stopHeartbeat(): void {
|
private stopHeartbeat(): void {
|
||||||
if (this.stateHeartbeatTimer) {
|
if (this.stateHeartbeatTimer) {
|
||||||
clearInterval(this.stateHeartbeatTimer);
|
clearInterval(this.stateHeartbeatTimer);
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ export class WebRTCLogger {
|
|||||||
try { console.error(`[WebRTC] ${prefix}`, payload); } catch { /* swallow */ }
|
try { console.error(`[WebRTC] ${prefix}`, payload); } catch { /* swallow */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Track / Stream diagnostics ──────────────────────────────────
|
|
||||||
|
|
||||||
/** Attach lifecycle event listeners to a track for debugging. */
|
/** Attach lifecycle event listeners to a track for debugging. */
|
||||||
attachTrackDiagnostics(track: MediaStreamTrack, label: string): void {
|
attachTrackDiagnostics(track: MediaStreamTrack, label: string): void {
|
||||||
const settings = typeof track.getSettings === 'function' ? track.getSettings() : {} as MediaTrackSettings;
|
const settings = typeof track.getSettings === 'function' ? track.getSettings() : {} as MediaTrackSettings;
|
||||||
@@ -58,9 +56,9 @@ export class WebRTCLogger {
|
|||||||
id: (stream as any).id,
|
id: (stream as any).id,
|
||||||
audioTrackCount: audioTracks.length,
|
audioTrackCount: audioTracks.length,
|
||||||
videoTrackCount: videoTracks.length,
|
videoTrackCount: videoTracks.length,
|
||||||
allTrackIds: stream.getTracks().map(t => ({ id: t.id, kind: t.kind })),
|
allTrackIds: stream.getTracks().map(streamTrack => ({ id: streamTrack.id, kind: streamTrack.kind })),
|
||||||
});
|
});
|
||||||
audioTracks.forEach((t, i) => this.attachTrackDiagnostics(t, `${label}:audio#${i}`));
|
audioTracks.forEach((audioTrack, index) => this.attachTrackDiagnostics(audioTrack, `${label}:audio#${index}`));
|
||||||
videoTracks.forEach((t, i) => this.attachTrackDiagnostics(t, `${label}:video#${i}`));
|
videoTracks.forEach((videoTrack, index) => this.attachTrackDiagnostics(videoTrack, `${label}:video#${index}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* Centralised here so nothing is hard-coded inline.
|
* Centralised here so nothing is hard-coded inline.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ─── ICE / STUN ──────────────────────────────────────────────────────
|
|
||||||
export const ICE_SERVERS: RTCIceServer[] = [
|
export const ICE_SERVERS: RTCIceServer[] = [
|
||||||
{ urls: 'stun:stun.l.google.com:19302' },
|
{ urls: 'stun:stun.l.google.com:19302' },
|
||||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||||
@@ -12,7 +11,6 @@ export const ICE_SERVERS: RTCIceServer[] = [
|
|||||||
{ urls: 'stun:stun4.l.google.com:19302' },
|
{ urls: 'stun:stun4.l.google.com:19302' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Signaling reconnection ──────────────────────────────────────────
|
|
||||||
/** Base delay (ms) for exponential backoff on signaling reconnect */
|
/** Base delay (ms) for exponential backoff on signaling reconnect */
|
||||||
export const SIGNALING_RECONNECT_BASE_DELAY_MS = 1_000;
|
export const SIGNALING_RECONNECT_BASE_DELAY_MS = 1_000;
|
||||||
/** Maximum delay (ms) between signaling reconnect attempts */
|
/** Maximum delay (ms) between signaling reconnect attempts */
|
||||||
@@ -20,19 +18,16 @@ export const SIGNALING_RECONNECT_MAX_DELAY_MS = 30_000;
|
|||||||
/** Default timeout (ms) for `ensureSignalingConnected` */
|
/** Default timeout (ms) for `ensureSignalingConnected` */
|
||||||
export const SIGNALING_CONNECT_TIMEOUT_MS = 5_000;
|
export const SIGNALING_CONNECT_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
// ─── Peer-to-peer reconnection ──────────────────────────────────────
|
|
||||||
/** Maximum P2P reconnect attempts before giving up */
|
/** Maximum P2P reconnect attempts before giving up */
|
||||||
export const PEER_RECONNECT_MAX_ATTEMPTS = 12;
|
export const PEER_RECONNECT_MAX_ATTEMPTS = 12;
|
||||||
/** Interval (ms) between P2P reconnect attempts */
|
/** Interval (ms) between P2P reconnect attempts */
|
||||||
export const PEER_RECONNECT_INTERVAL_MS = 5_000;
|
export const PEER_RECONNECT_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
// ─── Heartbeat / presence ────────────────────────────────────────────
|
|
||||||
/** Interval (ms) for broadcasting state heartbeats */
|
/** Interval (ms) for broadcasting state heartbeats */
|
||||||
export const STATE_HEARTBEAT_INTERVAL_MS = 5_000;
|
export const STATE_HEARTBEAT_INTERVAL_MS = 5_000;
|
||||||
/** Interval (ms) for broadcasting voice presence */
|
/** Interval (ms) for broadcasting voice presence */
|
||||||
export const VOICE_HEARTBEAT_INTERVAL_MS = 5_000;
|
export const VOICE_HEARTBEAT_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
// ─── Data-channel back-pressure ──────────────────────────────────────
|
|
||||||
/** Data channel name used for P2P chat */
|
/** Data channel name used for P2P chat */
|
||||||
export const DATA_CHANNEL_LABEL = 'chat';
|
export const DATA_CHANNEL_LABEL = 'chat';
|
||||||
/** High-water mark (bytes) – pause sending when buffered amount exceeds this */
|
/** High-water mark (bytes) – pause sending when buffered amount exceeds this */
|
||||||
@@ -40,14 +35,12 @@ export const DATA_CHANNEL_HIGH_WATER_BYTES = 4 * 1024 * 1024; // 4 MB
|
|||||||
/** Low-water mark (bytes) – resume sending once buffered amount drops below this */
|
/** Low-water mark (bytes) – resume sending once buffered amount drops below this */
|
||||||
export const DATA_CHANNEL_LOW_WATER_BYTES = 1 * 1024 * 1024; // 1 MB
|
export const DATA_CHANNEL_LOW_WATER_BYTES = 1 * 1024 * 1024; // 1 MB
|
||||||
|
|
||||||
// ─── Screen share defaults ───────────────────────────────────────────
|
|
||||||
export const SCREEN_SHARE_IDEAL_WIDTH = 1920;
|
export const SCREEN_SHARE_IDEAL_WIDTH = 1920;
|
||||||
export const SCREEN_SHARE_IDEAL_HEIGHT = 1080;
|
export const SCREEN_SHARE_IDEAL_HEIGHT = 1080;
|
||||||
export const SCREEN_SHARE_IDEAL_FRAME_RATE = 30;
|
export const SCREEN_SHARE_IDEAL_FRAME_RATE = 30;
|
||||||
/** Electron source name to prefer for whole-screen capture */
|
/** Electron source name to prefer for whole-screen capture */
|
||||||
export const ELECTRON_ENTIRE_SCREEN_SOURCE_NAME = 'Entire Screen';
|
export const ELECTRON_ENTIRE_SCREEN_SOURCE_NAME = 'Entire Screen';
|
||||||
|
|
||||||
// ─── Audio bitrate ───────────────────────────────────────────────────
|
|
||||||
/** Minimum audio bitrate (bps) */
|
/** Minimum audio bitrate (bps) */
|
||||||
export const AUDIO_BITRATE_MIN_BPS = 16_000;
|
export const AUDIO_BITRATE_MIN_BPS = 16_000;
|
||||||
/** Maximum audio bitrate (bps) */
|
/** Maximum audio bitrate (bps) */
|
||||||
@@ -62,23 +55,19 @@ export const LATENCY_PROFILE_BITRATES = {
|
|||||||
} as const;
|
} as const;
|
||||||
export type LatencyProfile = keyof typeof LATENCY_PROFILE_BITRATES;
|
export type LatencyProfile = keyof typeof LATENCY_PROFILE_BITRATES;
|
||||||
|
|
||||||
// ─── RTC transceiver directions ──────────────────────────────────────
|
|
||||||
export const TRANSCEIVER_SEND_RECV: RTCRtpTransceiverDirection = 'sendrecv';
|
export const TRANSCEIVER_SEND_RECV: RTCRtpTransceiverDirection = 'sendrecv';
|
||||||
export const TRANSCEIVER_RECV_ONLY: RTCRtpTransceiverDirection = 'recvonly';
|
export const TRANSCEIVER_RECV_ONLY: RTCRtpTransceiverDirection = 'recvonly';
|
||||||
export const TRANSCEIVER_INACTIVE: RTCRtpTransceiverDirection = 'inactive';
|
export const TRANSCEIVER_INACTIVE: RTCRtpTransceiverDirection = 'inactive';
|
||||||
|
|
||||||
// ─── Connection / data-channel states (for readability) ──────────────
|
|
||||||
export const CONNECTION_STATE_CONNECTED = 'connected';
|
export const CONNECTION_STATE_CONNECTED = 'connected';
|
||||||
export const CONNECTION_STATE_DISCONNECTED = 'disconnected';
|
export const CONNECTION_STATE_DISCONNECTED = 'disconnected';
|
||||||
export const CONNECTION_STATE_FAILED = 'failed';
|
export const CONNECTION_STATE_FAILED = 'failed';
|
||||||
export const CONNECTION_STATE_CLOSED = 'closed';
|
export const CONNECTION_STATE_CLOSED = 'closed';
|
||||||
export const DATA_CHANNEL_STATE_OPEN = 'open';
|
export const DATA_CHANNEL_STATE_OPEN = 'open';
|
||||||
|
|
||||||
// ─── Track kinds ─────────────────────────────────────────────────────
|
|
||||||
export const TRACK_KIND_AUDIO = 'audio';
|
export const TRACK_KIND_AUDIO = 'audio';
|
||||||
export const TRACK_KIND_VIDEO = 'video';
|
export const TRACK_KIND_VIDEO = 'video';
|
||||||
|
|
||||||
// ─── Signaling message types ─────────────────────────────────────────
|
|
||||||
export const SIGNALING_TYPE_IDENTIFY = 'identify';
|
export const SIGNALING_TYPE_IDENTIFY = 'identify';
|
||||||
export const SIGNALING_TYPE_JOIN_SERVER = 'join_server';
|
export const SIGNALING_TYPE_JOIN_SERVER = 'join_server';
|
||||||
export const SIGNALING_TYPE_VIEW_SERVER = 'view_server';
|
export const SIGNALING_TYPE_VIEW_SERVER = 'view_server';
|
||||||
@@ -91,13 +80,11 @@ export const SIGNALING_TYPE_SERVER_USERS = 'server_users';
|
|||||||
export const SIGNALING_TYPE_USER_JOINED = 'user_joined';
|
export const SIGNALING_TYPE_USER_JOINED = 'user_joined';
|
||||||
export const SIGNALING_TYPE_USER_LEFT = 'user_left';
|
export const SIGNALING_TYPE_USER_LEFT = 'user_left';
|
||||||
|
|
||||||
// ─── P2P message types ──────────────────────────────────────────────
|
|
||||||
export const P2P_TYPE_STATE_REQUEST = 'state-request';
|
export const P2P_TYPE_STATE_REQUEST = 'state-request';
|
||||||
export const P2P_TYPE_VOICE_STATE_REQUEST = 'voice-state-request';
|
export const P2P_TYPE_VOICE_STATE_REQUEST = 'voice-state-request';
|
||||||
export const P2P_TYPE_VOICE_STATE = 'voice-state';
|
export const P2P_TYPE_VOICE_STATE = 'voice-state';
|
||||||
export const P2P_TYPE_SCREEN_STATE = 'screen-state';
|
export const P2P_TYPE_SCREEN_STATE = 'screen-state';
|
||||||
|
|
||||||
// ─── Misc ────────────────────────────────────────────────────────────
|
|
||||||
/** Default display name fallback */
|
/** Default display name fallback */
|
||||||
export const DEFAULT_DISPLAY_NAME = 'User';
|
export const DEFAULT_DISPLAY_NAME = 'User';
|
||||||
/** Minimum volume (normalised 0-1) */
|
/** Minimum volume (normalised 0-1) */
|
||||||
|
|||||||
@@ -4,40 +4,60 @@
|
|||||||
|
|
||||||
/** Tracks a single peer's connection, data channel, and RTP senders. */
|
/** Tracks a single peer's connection, data channel, and RTP senders. */
|
||||||
export interface PeerData {
|
export interface PeerData {
|
||||||
|
/** The underlying RTCPeerConnection instance. */
|
||||||
connection: RTCPeerConnection;
|
connection: RTCPeerConnection;
|
||||||
|
/** The negotiated data channel, or `null` before the channel is established. */
|
||||||
dataChannel: RTCDataChannel | null;
|
dataChannel: RTCDataChannel | null;
|
||||||
|
/** `true` when this side created the offer (and data channel). */
|
||||||
isInitiator: boolean;
|
isInitiator: boolean;
|
||||||
|
/** ICE candidates received before the remote description was set. */
|
||||||
pendingIceCandidates: RTCIceCandidateInit[];
|
pendingIceCandidates: RTCIceCandidateInit[];
|
||||||
|
/** The RTP sender carrying the local audio track. */
|
||||||
audioSender?: RTCRtpSender;
|
audioSender?: RTCRtpSender;
|
||||||
|
/** The RTP sender carrying the local video (camera) track. */
|
||||||
videoSender?: RTCRtpSender;
|
videoSender?: RTCRtpSender;
|
||||||
|
/** The RTP sender carrying the screen-share video track. */
|
||||||
screenVideoSender?: RTCRtpSender;
|
screenVideoSender?: RTCRtpSender;
|
||||||
|
/** The RTP sender carrying the screen-share audio track. */
|
||||||
screenAudioSender?: RTCRtpSender;
|
screenAudioSender?: RTCRtpSender;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Credentials cached for automatic re-identification after reconnect. */
|
/** Credentials cached for automatic re-identification after reconnect. */
|
||||||
export interface IdentifyCredentials {
|
export interface IdentifyCredentials {
|
||||||
|
/** The user's unique order / peer identifier. */
|
||||||
oderId: string;
|
oderId: string;
|
||||||
|
/** The user's display name shown to other peers. */
|
||||||
displayName: string;
|
displayName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Last-joined server info, used for reconnection. */
|
/** Last-joined server info, used for reconnection. */
|
||||||
export interface JoinedServerInfo {
|
export interface JoinedServerInfo {
|
||||||
|
/** The server (room) that was last joined. */
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
/** The local user ID at the time of joining. */
|
||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Entry in the disconnected-peer tracker for P2P reconnect scheduling. */
|
/** Entry in the disconnected-peer tracker for P2P reconnect scheduling. */
|
||||||
export interface DisconnectedPeerEntry {
|
export interface DisconnectedPeerEntry {
|
||||||
|
/** Timestamp (ms since epoch) when the peer was last seen connected. */
|
||||||
lastSeenTimestamp: number;
|
lastSeenTimestamp: number;
|
||||||
|
/** Number of reconnect attempts made so far. */
|
||||||
reconnectAttempts: number;
|
reconnectAttempts: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Snapshot of current voice / screen state (broadcast to peers). */
|
/** Snapshot of current voice / screen state (broadcast to peers). */
|
||||||
export interface VoiceStateSnapshot {
|
export interface VoiceStateSnapshot {
|
||||||
|
/** Whether the user's voice is currently active. */
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
|
/** Whether the user's microphone is muted. */
|
||||||
isMuted: boolean;
|
isMuted: boolean;
|
||||||
|
/** Whether the user has self-deafened. */
|
||||||
isDeafened: boolean;
|
isDeafened: boolean;
|
||||||
|
/** Whether the user is sharing their screen. */
|
||||||
isScreenSharing: boolean;
|
isScreenSharing: boolean;
|
||||||
|
/** The voice channel room ID, if applicable. */
|
||||||
roomId?: string;
|
roomId?: string;
|
||||||
|
/** The voice channel server ID, if applicable. */
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,9 +147,7 @@
|
|||||||
} @else {
|
} @else {
|
||||||
@for (user of membersFiltered(); track user.id) {
|
@for (user of membersFiltered(); track user.id) {
|
||||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
||||||
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold text-sm">
|
<app-user-avatar [name]="user.displayName || '?'" size="sm" />
|
||||||
{{ user.displayName ? user.displayName.charAt(0).toUpperCase() : '?' }}
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<p class="text-sm font-medium text-foreground truncate">{{ user.displayName }}</p>
|
<p class="text-sm font-medium text-foreground truncate">{{ user.displayName }}</p>
|
||||||
@@ -302,7 +300,11 @@
|
|||||||
<p class="text-sm font-medium text-foreground">Admins Can Manage Rooms</p>
|
<p class="text-sm font-medium text-foreground">Admins Can Manage Rooms</p>
|
||||||
<p class="text-xs text-muted-foreground">Allow admins to create/modify chat & voice rooms</p>
|
<p class="text-xs text-muted-foreground">Allow admins to create/modify chat & voice rooms</p>
|
||||||
</div>
|
</div>
|
||||||
<input type="checkbox" [(ngModel)]="adminsManageRooms" class="w-4 h-4 accent-primary" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[(ngModel)]="adminsManageRooms"
|
||||||
|
class="w-4 h-4 accent-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||||
@@ -310,7 +312,11 @@
|
|||||||
<p class="text-sm font-medium text-foreground">Moderators Can Manage Rooms</p>
|
<p class="text-sm font-medium text-foreground">Moderators Can Manage Rooms</p>
|
||||||
<p class="text-xs text-muted-foreground">Allow moderators to create/modify chat & voice rooms</p>
|
<p class="text-xs text-muted-foreground">Allow moderators to create/modify chat & voice rooms</p>
|
||||||
</div>
|
</div>
|
||||||
<input type="checkbox" [(ngModel)]="moderatorsManageRooms" class="w-4 h-4 accent-primary" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[(ngModel)]="moderatorsManageRooms"
|
||||||
|
class="w-4 h-4 accent-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||||
@@ -318,7 +324,11 @@
|
|||||||
<p class="text-sm font-medium text-foreground">Admins Can Change Server Icon</p>
|
<p class="text-sm font-medium text-foreground">Admins Can Change Server Icon</p>
|
||||||
<p class="text-xs text-muted-foreground">Grant icon management to admins</p>
|
<p class="text-xs text-muted-foreground">Grant icon management to admins</p>
|
||||||
</div>
|
</div>
|
||||||
<input type="checkbox" [(ngModel)]="adminsManageIcon" class="w-4 h-4 accent-primary" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[(ngModel)]="adminsManageIcon"
|
||||||
|
class="w-4 h-4 accent-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||||
@@ -326,7 +336,11 @@
|
|||||||
<p class="text-sm font-medium text-foreground">Moderators Can Change Server Icon</p>
|
<p class="text-sm font-medium text-foreground">Moderators Can Change Server Icon</p>
|
||||||
<p class="text-xs text-muted-foreground">Grant icon management to moderators</p>
|
<p class="text-xs text-muted-foreground">Grant icon management to moderators</p>
|
||||||
</div>
|
</div>
|
||||||
<input type="checkbox" [(ngModel)]="moderatorsManageIcon" class="w-4 h-4 accent-primary" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[(ngModel)]="moderatorsManageIcon"
|
||||||
|
class="w-4 h-4 accent-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -346,28 +360,16 @@
|
|||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
<!-- Delete Confirmation Modal -->
|
||||||
@if (showDeleteConfirm()) {
|
@if (showDeleteConfirm()) {
|
||||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="showDeleteConfirm.set(false)">
|
<app-confirm-dialog
|
||||||
<div class="bg-card border border-border rounded-lg p-6 w-96 max-w-[90vw]" (click)="$event.stopPropagation()">
|
title="Delete Room"
|
||||||
<h3 class="text-lg font-semibold text-foreground mb-2">Delete Room</h3>
|
confirmLabel="Delete Room"
|
||||||
<p class="text-sm text-muted-foreground mb-4">
|
variant="danger"
|
||||||
Are you sure you want to delete this room? This action cannot be undone.
|
[widthClass]="'w-96 max-w-[90vw]'"
|
||||||
</p>
|
(confirmed)="deleteRoom()"
|
||||||
<div class="flex gap-2 justify-end">
|
(cancelled)="showDeleteConfirm.set(false)"
|
||||||
<button
|
>
|
||||||
(click)="showDeleteConfirm.set(false)"
|
<p>Are you sure you want to delete this room? This action cannot be undone.</p>
|
||||||
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
</app-confirm-dialog>
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="deleteRoom()"
|
|
||||||
class="px-4 py-2 bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
|
|
||||||
>
|
|
||||||
Delete Room
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<div class="h-full flex items-center justify-center text-muted-foreground">
|
<div class="h-full flex items-center justify-center text-muted-foreground">
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
lucideUnlock,
|
lucideUnlock,
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import * as UsersActions from '../../../store/users/users.actions';
|
import { UsersActions } from '../../../store/users/users.actions';
|
||||||
import * as RoomsActions from '../../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||||
import {
|
import {
|
||||||
selectBannedUsers,
|
selectBannedUsers,
|
||||||
@@ -27,13 +27,14 @@ import {
|
|||||||
} from '../../../store/users/users.selectors';
|
} from '../../../store/users/users.selectors';
|
||||||
import { BanEntry, Room, User } from '../../../core/models';
|
import { BanEntry, Room, User } from '../../../core/models';
|
||||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||||
|
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||||
|
|
||||||
type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
|
type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-panel',
|
selector: 'app-admin-panel',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, NgIcon],
|
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent, ConfirmDialogComponent],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucideShield,
|
lucideShield,
|
||||||
@@ -50,6 +51,10 @@ type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
|
|||||||
],
|
],
|
||||||
templateUrl: './admin-panel.component.html',
|
templateUrl: './admin-panel.component.html',
|
||||||
})
|
})
|
||||||
|
/**
|
||||||
|
* Admin panel for managing room settings, members, bans, and permissions.
|
||||||
|
* Only accessible to users with admin privileges.
|
||||||
|
*/
|
||||||
export class AdminPanelComponent {
|
export class AdminPanelComponent {
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private webrtc = inject(WebRTCService);
|
private webrtc = inject(WebRTCService);
|
||||||
@@ -99,10 +104,12 @@ export class AdminPanelComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggle the room's private visibility setting. */
|
||||||
togglePrivate(): void {
|
togglePrivate(): void {
|
||||||
this.isPrivate.update((v) => !v);
|
this.isPrivate.update((current) => !current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Save the current room name, description, privacy, and max-user settings. */
|
||||||
saveSettings(): void {
|
saveSettings(): void {
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
@@ -120,6 +127,7 @@ export class AdminPanelComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persist updated room permissions (voice, screen-share, uploads, slow-mode, role grants). */
|
||||||
savePermissions(): void {
|
savePermissions(): void {
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
@@ -141,14 +149,17 @@ export class AdminPanelComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove a user's ban entry. */
|
||||||
unbanUser(ban: BanEntry): void {
|
unbanUser(ban: BanEntry): void {
|
||||||
this.store.dispatch(UsersActions.unbanUser({ oderId: ban.oderId }));
|
this.store.dispatch(UsersActions.unbanUser({ oderId: ban.oderId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Show the delete-room confirmation dialog. */
|
||||||
confirmDeleteRoom(): void {
|
confirmDeleteRoom(): void {
|
||||||
this.showDeleteConfirm.set(true);
|
this.showDeleteConfirm.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete the current room after confirmation. */
|
||||||
deleteRoom(): void {
|
deleteRoom(): void {
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
@@ -157,17 +168,20 @@ export class AdminPanelComponent {
|
|||||||
this.showDeleteConfirm.set(false);
|
this.showDeleteConfirm.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Format a ban expiry timestamp into a human-readable date/time string. */
|
||||||
formatExpiry(timestamp: number): string {
|
formatExpiry(timestamp: number): string {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Members tab: get all users except self
|
// Members tab: get all users except self
|
||||||
|
/** Return online users excluding the current user (for the members list). */
|
||||||
membersFiltered(): User[] {
|
membersFiltered(): User[] {
|
||||||
const me = this.currentUser();
|
const me = this.currentUser();
|
||||||
return this.onlineUsers().filter(u => u.id !== me?.id && u.oderId !== me?.oderId);
|
return this.onlineUsers().filter(user => user.id !== me?.id && user.oderId !== me?.oderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Change a member's role and broadcast the update to all peers. */
|
||||||
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
|
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
|
||||||
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
|
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
|
||||||
this.webrtc.broadcastMessage({
|
this.webrtc.broadcastMessage({
|
||||||
@@ -177,6 +191,7 @@ export class AdminPanelComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Kick a member from the server and broadcast the action to peers. */
|
||||||
kickMember(user: User): void {
|
kickMember(user: User): void {
|
||||||
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
||||||
this.webrtc.broadcastMessage({
|
this.webrtc.broadcastMessage({
|
||||||
@@ -186,6 +201,7 @@ export class AdminPanelComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ban a member from the server and broadcast the action to peers. */
|
||||||
banMember(user: User): void {
|
banMember(user: User): void {
|
||||||
this.store.dispatch(UsersActions.banUser({ userId: user.id }));
|
this.store.dispatch(UsersActions.banUser({ userId: user.id }));
|
||||||
this.webrtc.broadcastMessage({
|
this.webrtc.broadcastMessage({
|
||||||
|
|||||||
@@ -8,20 +8,40 @@
|
|||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-muted-foreground mb-1">Username</label>
|
<label class="block text-xs text-muted-foreground mb-1">Username</label>
|
||||||
<input [(ngModel)]="username" type="text" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
|
<input
|
||||||
|
[(ngModel)]="username"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-muted-foreground mb-1">Password</label>
|
<label class="block text-xs text-muted-foreground mb-1">Password</label>
|
||||||
<input [(ngModel)]="password" type="password" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
|
<input
|
||||||
|
[(ngModel)]="password"
|
||||||
|
type="password"
|
||||||
|
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-muted-foreground mb-1">Server App</label>
|
<label class="block text-xs text-muted-foreground mb-1">Server App</label>
|
||||||
<select [(ngModel)]="serverId" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground">
|
<select
|
||||||
<option *ngFor="let s of servers(); trackBy: trackById" [value]="s.id">{{ s.name }}</option>
|
[(ngModel)]="serverId"
|
||||||
|
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||||
|
>
|
||||||
|
@for (s of servers(); track s.id) {
|
||||||
|
<option [value]="s.id">{{ s.name }}</option>
|
||||||
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<p *ngIf="error()" class="text-xs text-destructive">{{ error() }}</p>
|
@if (error()) {
|
||||||
<button (click)="submit()" class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90">Login</button>
|
<p class="text-xs text-destructive">{{ error() }}</p>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
(click)="submit()"
|
||||||
|
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
<div class="text-xs text-muted-foreground text-center mt-2">
|
<div class="text-xs text-muted-foreground text-center mt-2">
|
||||||
No account? <a class="text-primary hover:underline" (click)="goRegister()">Register</a>
|
No account? <a class="text-primary hover:underline" (click)="goRegister()">Register</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import { lucideLogIn } from '@ng-icons/lucide';
|
|||||||
|
|
||||||
import { AuthService } from '../../../core/services/auth.service';
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
||||||
import * as UsersActions from '../../../store/users/users.actions';
|
import { UsersActions } from '../../../store/users/users.actions';
|
||||||
import { User } from '../../../core/models';
|
import { User } from '../../../core/models';
|
||||||
|
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-login',
|
selector: 'app-login',
|
||||||
@@ -18,6 +19,9 @@ import { User } from '../../../core/models';
|
|||||||
viewProviders: [provideIcons({ lucideLogIn })],
|
viewProviders: [provideIcons({ lucideLogIn })],
|
||||||
templateUrl: './login.component.html',
|
templateUrl: './login.component.html',
|
||||||
})
|
})
|
||||||
|
/**
|
||||||
|
* Login form allowing existing users to authenticate against a selected server.
|
||||||
|
*/
|
||||||
export class LoginComponent {
|
export class LoginComponent {
|
||||||
private auth = inject(AuthService);
|
private auth = inject(AuthService);
|
||||||
private serversSvc = inject(ServerDirectoryService);
|
private serversSvc = inject(ServerDirectoryService);
|
||||||
@@ -30,8 +34,10 @@ export class LoginComponent {
|
|||||||
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
|
/** TrackBy function for server list rendering. */
|
||||||
trackById(_index: number, item: { id: string }) { return item.id; }
|
trackById(_index: number, item: { id: string }) { return item.id; }
|
||||||
|
|
||||||
|
/** Validate and submit the login form, then navigate to search on success. */
|
||||||
submit() {
|
submit() {
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
||||||
@@ -47,7 +53,7 @@ export class LoginComponent {
|
|||||||
role: 'member',
|
role: 'member',
|
||||||
joinedAt: Date.now(),
|
joinedAt: Date.now(),
|
||||||
};
|
};
|
||||||
try { localStorage.setItem('metoyou_currentUserId', resp.id); } catch {}
|
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
||||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
||||||
this.router.navigate(['/search']);
|
this.router.navigate(['/search']);
|
||||||
},
|
},
|
||||||
@@ -57,6 +63,7 @@ export class LoginComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Navigate to the registration page. */
|
||||||
goRegister() {
|
goRegister() {
|
||||||
this.router.navigate(['/register']);
|
this.router.navigate(['/register']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,24 +8,48 @@
|
|||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-muted-foreground mb-1">Username</label>
|
<label class="block text-xs text-muted-foreground mb-1">Username</label>
|
||||||
<input [(ngModel)]="username" type="text" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
|
<input
|
||||||
|
[(ngModel)]="username"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-muted-foreground mb-1">Display Name</label>
|
<label class="block text-xs text-muted-foreground mb-1">Display Name</label>
|
||||||
<input [(ngModel)]="displayName" type="text" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
|
<input
|
||||||
|
[(ngModel)]="displayName"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-muted-foreground mb-1">Password</label>
|
<label class="block text-xs text-muted-foreground mb-1">Password</label>
|
||||||
<input [(ngModel)]="password" type="password" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
|
<input
|
||||||
|
[(ngModel)]="password"
|
||||||
|
type="password"
|
||||||
|
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-muted-foreground mb-1">Server App</label>
|
<label class="block text-xs text-muted-foreground mb-1">Server App</label>
|
||||||
<select [(ngModel)]="serverId" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground">
|
<select
|
||||||
<option *ngFor="let s of servers(); trackBy: trackById" [value]="s.id">{{ s.name }}</option>
|
[(ngModel)]="serverId"
|
||||||
|
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||||
|
>
|
||||||
|
@for (s of servers(); track s.id) {
|
||||||
|
<option [value]="s.id">{{ s.name }}</option>
|
||||||
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<p *ngIf="error()" class="text-xs text-destructive">{{ error() }}</p>
|
@if (error()) {
|
||||||
<button (click)="submit()" class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90">Create Account</button>
|
<p class="text-xs text-destructive">{{ error() }}</p>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
(click)="submit()"
|
||||||
|
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Create Account
|
||||||
|
</button>
|
||||||
<div class="text-xs text-muted-foreground text-center mt-2">
|
<div class="text-xs text-muted-foreground text-center mt-2">
|
||||||
Have an account? <a class="text-primary hover:underline" (click)="goLogin()">Login</a>
|
Have an account? <a class="text-primary hover:underline" (click)="goLogin()">Login</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import { lucideUserPlus } from '@ng-icons/lucide';
|
|||||||
|
|
||||||
import { AuthService } from '../../../core/services/auth.service';
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
||||||
import * as UsersActions from '../../../store/users/users.actions';
|
import { UsersActions } from '../../../store/users/users.actions';
|
||||||
import { User } from '../../../core/models';
|
import { User } from '../../../core/models';
|
||||||
|
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-register',
|
selector: 'app-register',
|
||||||
@@ -18,6 +19,9 @@ import { User } from '../../../core/models';
|
|||||||
viewProviders: [provideIcons({ lucideUserPlus })],
|
viewProviders: [provideIcons({ lucideUserPlus })],
|
||||||
templateUrl: './register.component.html',
|
templateUrl: './register.component.html',
|
||||||
})
|
})
|
||||||
|
/**
|
||||||
|
* Registration form allowing new users to create an account on a selected server.
|
||||||
|
*/
|
||||||
export class RegisterComponent {
|
export class RegisterComponent {
|
||||||
private auth = inject(AuthService);
|
private auth = inject(AuthService);
|
||||||
private serversSvc = inject(ServerDirectoryService);
|
private serversSvc = inject(ServerDirectoryService);
|
||||||
@@ -31,8 +35,10 @@ export class RegisterComponent {
|
|||||||
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
|
/** TrackBy function for server list rendering. */
|
||||||
trackById(_index: number, item: { id: string }) { return item.id; }
|
trackById(_index: number, item: { id: string }) { return item.id; }
|
||||||
|
|
||||||
|
/** Validate and submit the registration form, then navigate to search on success. */
|
||||||
submit() {
|
submit() {
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
||||||
@@ -48,7 +54,7 @@ export class RegisterComponent {
|
|||||||
role: 'member',
|
role: 'member',
|
||||||
joinedAt: Date.now(),
|
joinedAt: Date.now(),
|
||||||
};
|
};
|
||||||
try { localStorage.setItem('metoyou_currentUserId', resp.id); } catch {}
|
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
||||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
||||||
this.router.navigate(['/search']);
|
this.router.navigate(['/search']);
|
||||||
},
|
},
|
||||||
@@ -58,6 +64,7 @@ export class RegisterComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Navigate to the login page. */
|
||||||
goLogin() {
|
goLogin() {
|
||||||
this.router.navigate(['/login']);
|
this.router.navigate(['/login']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ import { selectCurrentUser } from '../../../store/users/users.selectors';
|
|||||||
viewProviders: [provideIcons({ lucideUser, lucideLogIn, lucideUserPlus })],
|
viewProviders: [provideIcons({ lucideUser, lucideLogIn, lucideUserPlus })],
|
||||||
templateUrl: './user-bar.component.html',
|
templateUrl: './user-bar.component.html',
|
||||||
})
|
})
|
||||||
|
/**
|
||||||
|
* Compact user status bar showing the current user with login/register navigation links.
|
||||||
|
*/
|
||||||
export class UserBarComponent {
|
export class UserBarComponent {
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
user = this.store.selectSignal(selectCurrentUser);
|
user = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
|
/** Navigate to the specified authentication page. */
|
||||||
goto(path: 'login' | 'register') {
|
goto(path: 'login' | 'register') {
|
||||||
this.router.navigate([`/${path}`]);
|
this.router.navigate([`/${path}`]);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
462
src/app/features/chat/chat-messages/chat-messages.component.html
Normal file
462
src/app/features/chat/chat-messages/chat-messages.component.html
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
<div class="chat-layout relative h-full">
|
||||||
|
<!-- Messages List -->
|
||||||
|
<div #messagesContainer class="chat-messages-scroll absolute inset-0 overflow-y-auto p-4 space-y-4" (scroll)="onScroll()">
|
||||||
|
<!-- Syncing indicator -->
|
||||||
|
@if (syncing() && !loading()) {
|
||||||
|
<div class="flex items-center justify-center gap-2 py-1.5 text-xs text-muted-foreground">
|
||||||
|
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-primary"></div>
|
||||||
|
<span>Syncing messages…</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="flex items-center justify-center py-8">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
} @else if (messages().length === 0) {
|
||||||
|
<div class="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||||
|
<p class="text-lg">No messages yet</p>
|
||||||
|
<p class="text-sm">Be the first to say something!</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<!-- Infinite scroll: load-more sentinel at top -->
|
||||||
|
@if (hasMoreMessages()) {
|
||||||
|
<div class="flex items-center justify-center py-3">
|
||||||
|
@if (loadingMore()) {
|
||||||
|
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-primary"></div>
|
||||||
|
} @else {
|
||||||
|
<button (click)="loadMore()" class="text-xs text-muted-foreground hover:text-foreground transition-colors px-3 py-1 rounded-md hover:bg-secondary">
|
||||||
|
Load older messages
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@for (message of messages(); track message.id) {
|
||||||
|
<div
|
||||||
|
[attr.data-message-id]="message.id"
|
||||||
|
class="group relative flex gap-3 p-2 rounded-lg hover:bg-secondary/30 transition-colors"
|
||||||
|
[class.opacity-50]="message.isDeleted"
|
||||||
|
>
|
||||||
|
<!-- Avatar -->
|
||||||
|
<app-user-avatar [name]="message.senderName" size="md" class="flex-shrink-0" />
|
||||||
|
|
||||||
|
<!-- Message Content -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<!-- Reply indicator -->
|
||||||
|
@if (message.replyToId) {
|
||||||
|
@let repliedMsg = getRepliedMessage(message.replyToId);
|
||||||
|
<div class="flex items-center gap-1.5 mb-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground transition-colors" (click)="scrollToMessage(message.replyToId)">
|
||||||
|
<div class="w-4 h-3 border-l-2 border-t-2 border-muted-foreground/50 rounded-tl-md"></div>
|
||||||
|
<ng-icon name="lucideReply" class="w-3 h-3" />
|
||||||
|
@if (repliedMsg) {
|
||||||
|
<span class="font-medium">{{ repliedMsg.senderName }}</span>
|
||||||
|
<span class="truncate max-w-[200px]">{{ repliedMsg.content }}</span>
|
||||||
|
} @else {
|
||||||
|
<span class="italic">Original message not found</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="flex items-baseline gap-2">
|
||||||
|
<span class="font-semibold text-foreground">{{ message.senderName }}</span>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
{{ formatTimestamp(message.timestamp) }}
|
||||||
|
</span>
|
||||||
|
@if (message.editedAt) {
|
||||||
|
<span class="text-xs text-muted-foreground">(edited)</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (editingMessageId() === message.id) {
|
||||||
|
<!-- Edit Mode -->
|
||||||
|
<div class="mt-1 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="editContent"
|
||||||
|
(keydown.enter)="saveEdit(message.id)"
|
||||||
|
(keydown.escape)="cancelEdit()"
|
||||||
|
class="flex-1 px-3 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
(click)="saveEdit(message.id)"
|
||||||
|
class="p-1 text-primary hover:bg-primary/10 rounded"
|
||||||
|
>
|
||||||
|
<ng-icon name="lucideCheck" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="cancelEdit()"
|
||||||
|
class="p-1 text-muted-foreground hover:bg-secondary rounded"
|
||||||
|
>
|
||||||
|
<ng-icon name="lucideX" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="chat-markdown mt-1 break-words">
|
||||||
|
<remark [markdown]="message.content" [processor]="remarkProcessor">
|
||||||
|
<ng-template [remarkTemplate]="'code'" let-node>
|
||||||
|
@if (node.lang === 'mermaid') {
|
||||||
|
<remark-mermaid [code]="node.value"/>
|
||||||
|
} @else {
|
||||||
|
<pre><code>{{ node.value }}</code></pre>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
</remark>
|
||||||
|
</div>
|
||||||
|
@if (getAttachments(message.id).length > 0) {
|
||||||
|
<div class="mt-2 space-y-2">
|
||||||
|
@for (att of getAttachments(message.id); track att.id) {
|
||||||
|
@if (att.isImage) {
|
||||||
|
@if (att.available && att.objectUrl) {
|
||||||
|
<!-- Available image with hover overlay -->
|
||||||
|
<div class="relative group/img inline-block" (contextmenu)="openImageContextMenu($event, att)">
|
||||||
|
<img
|
||||||
|
[src]="att.objectUrl"
|
||||||
|
[alt]="att.filename"
|
||||||
|
class="rounded-md max-h-80 w-auto cursor-pointer"
|
||||||
|
(click)="openLightbox(att)"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-0 bg-black/0 group-hover/img:bg-black/20 transition-colors rounded-md pointer-events-none"></div>
|
||||||
|
<div class="absolute top-2 right-2 opacity-0 group-hover/img:opacity-100 transition-opacity flex gap-1">
|
||||||
|
<button
|
||||||
|
(click)="openLightbox(att); $event.stopPropagation()"
|
||||||
|
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md backdrop-blur-sm transition-colors"
|
||||||
|
title="View full size"
|
||||||
|
>
|
||||||
|
<ng-icon name="lucideExpand" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="downloadAttachment(att); $event.stopPropagation()"
|
||||||
|
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md backdrop-blur-sm transition-colors"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<ng-icon name="lucideDownload" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else if ((att.receivedBytes || 0) > 0) {
|
||||||
|
<!-- Downloading in progress -->
|
||||||
|
<div class="border border-border rounded-md p-3 bg-secondary/40 max-w-xs">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex-shrink-0 w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center">
|
||||||
|
<ng-icon name="lucideImage" class="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium truncate">{{ att.filename }}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs font-medium text-primary">{{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div class="h-full rounded-full bg-primary transition-all duration-300" [style.width.%]="(att.receivedBytes || 0) * 100 / att.size"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<!-- Unavailable — waiting for source -->
|
||||||
|
<div class="border border-dashed border-border rounded-md p-4 bg-secondary/20 max-w-xs">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex-shrink-0 w-10 h-10 rounded-md bg-muted flex items-center justify-center">
|
||||||
|
<ng-icon name="lucideImage" class="w-5 h-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium truncate text-foreground">{{ att.filename }}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
|
||||||
|
<div class="text-xs text-muted-foreground/70 mt-0.5 italic">Waiting for image source…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
(click)="retryImageRequest(att, message.id)"
|
||||||
|
class="mt-2 w-full px-3 py-1.5 text-xs bg-secondary hover:bg-secondary/80 text-foreground rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<div class="border border-border rounded-md p-2 bg-secondary/40">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm font-medium truncate">{{ att.filename }}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@if (!isUploader(att)) {
|
||||||
|
@if (!att.available) {
|
||||||
|
<div class="w-24 h-1.5 rounded bg-muted">
|
||||||
|
<div class="h-1.5 rounded bg-primary" [style.width.%]="(att.receivedBytes || 0) * 100 / att.size"></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground flex items-center gap-2">
|
||||||
|
<span>{{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}%</span>
|
||||||
|
@if (att.speedBps) {
|
||||||
|
<span>• {{ formatSpeed(att.speedBps) }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (!(att.receivedBytes || 0)) {
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 text-xs bg-secondary text-foreground rounded"
|
||||||
|
(click)="requestAttachment(att, message.id)"
|
||||||
|
>Request</button>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 text-xs bg-destructive text-destructive-foreground rounded"
|
||||||
|
(click)="cancelAttachment(att, message.id)"
|
||||||
|
>Cancel</button>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 text-xs bg-primary text-primary-foreground rounded"
|
||||||
|
(click)="downloadAttachment(att)"
|
||||||
|
>Download</button>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<div class="text-xs text-muted-foreground">Shared from your device</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Reactions -->
|
||||||
|
@if (message.reactions.length > 0) {
|
||||||
|
<div class="flex flex-wrap gap-1 mt-2">
|
||||||
|
@for (reaction of getGroupedReactions(message); track reaction.emoji) {
|
||||||
|
<button
|
||||||
|
(click)="toggleReaction(message.id, reaction.emoji)"
|
||||||
|
class="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-secondary hover:bg-secondary/80 transition-colors"
|
||||||
|
[class.ring-1]="reaction.hasCurrentUser"
|
||||||
|
[class.ring-primary]="reaction.hasCurrentUser"
|
||||||
|
>
|
||||||
|
<span>{{ reaction.emoji }}</span>
|
||||||
|
<span class="text-muted-foreground">{{ reaction.count }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message Actions (visible on hover) -->
|
||||||
|
@if (!message.isDeleted) {
|
||||||
|
<div class="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 bg-card border border-border rounded-lg shadow-lg">
|
||||||
|
<!-- Emoji Picker Toggle -->
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
(click)="toggleEmojiPicker(message.id)"
|
||||||
|
class="p-1.5 hover:bg-secondary rounded-l-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ng-icon name="lucideSmile" class="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (showEmojiPicker() === message.id) {
|
||||||
|
<div class="absolute bottom-full right-0 mb-2 p-2 bg-card border border-border rounded-lg shadow-lg flex gap-1 z-10">
|
||||||
|
@for (emoji of commonEmojis; track emoji) {
|
||||||
|
<button
|
||||||
|
(click)="addReaction(message.id, emoji)"
|
||||||
|
class="p-1 hover:bg-secondary rounded transition-colors text-lg"
|
||||||
|
>
|
||||||
|
{{ emoji }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reply -->
|
||||||
|
<button
|
||||||
|
(click)="setReplyTo(message)"
|
||||||
|
class="p-1.5 hover:bg-secondary transition-colors"
|
||||||
|
>
|
||||||
|
<ng-icon name="lucideReply" class="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Edit (own messages only) -->
|
||||||
|
@if (isOwnMessage(message)) {
|
||||||
|
<button
|
||||||
|
(click)="startEdit(message)"
|
||||||
|
class="p-1.5 hover:bg-secondary transition-colors"
|
||||||
|
>
|
||||||
|
<ng-icon name="lucideEdit" class="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Delete (own messages or admin) -->
|
||||||
|
@if (isOwnMessage(message) || isAdmin()) {
|
||||||
|
<button
|
||||||
|
(click)="deleteMessage(message)"
|
||||||
|
class="p-1.5 hover:bg-destructive/10 rounded-r-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ng-icon name="lucideTrash2" class="w-4 h-4 text-destructive" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<!-- New messages snackbar (center bottom inside container) -->
|
||||||
|
@if (showNewMessagesBar()) {
|
||||||
|
<div class="sticky bottom-4 flex justify-center pointer-events-none">
|
||||||
|
<div class="px-3 py-2 bg-card border border-border rounded-lg shadow flex items-center gap-3 pointer-events-auto">
|
||||||
|
<span class="text-sm text-muted-foreground">New messages</span>
|
||||||
|
<button (click)="readLatest()" class="px-2 py-1 bg-primary text-primary-foreground rounded hover:bg-primary/90 text-sm">Read latest</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom bar: floats over messages -->
|
||||||
|
<div #bottomBar class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
|
||||||
|
|
||||||
|
<!-- Reply Preview -->
|
||||||
|
@if (replyTo()) {
|
||||||
|
<div class="px-4 py-2 bg-secondary/50 flex items-center gap-2 pointer-events-auto">
|
||||||
|
<ng-icon name="lucideReply" class="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span class="text-sm text-muted-foreground flex-1">
|
||||||
|
Replying to <span class="font-semibold">{{ replyTo()?.senderName }}</span>
|
||||||
|
</span>
|
||||||
|
<button (click)="clearReply()" class="p-1 hover:bg-secondary rounded">
|
||||||
|
<ng-icon name="lucideX" class="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Typing Indicator -->
|
||||||
|
<app-typing-indicator />
|
||||||
|
|
||||||
|
<!-- Markdown Toolbar -->
|
||||||
|
@if (toolbarVisible()) {
|
||||||
|
<div class="pointer-events-auto" (mousedown)="$event.preventDefault()" (mouseenter)="onToolbarMouseEnter()" (mouseleave)="onToolbarMouseLeave()">
|
||||||
|
<div class="mx-4 -mb-2 flex flex-wrap gap-2 justify-start items-center bg-card/70 backdrop-blur border border-border rounded-lg px-2 py-1 shadow-sm">
|
||||||
|
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline('**')"><b>B</b></button>
|
||||||
|
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline('*')"><i>I</i></button>
|
||||||
|
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline('~~')"><s>S</s></button>
|
||||||
|
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline(inlineCodeToken)">`</button>
|
||||||
|
<span class="mx-1 text-muted-foreground">|</span>
|
||||||
|
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHeading(1)">H1</button>
|
||||||
|
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHeading(2)">H2</button>
|
||||||
|
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHeading(3)">H3</button>
|
||||||
|
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyPrefix('> ')">Quote</button>
|
||||||
|
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyPrefix('- ')">• List</button>
|
||||||
|
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyOrderedList()">1. List</button>
|
||||||
|
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyCodeBlock()">Code</button>
|
||||||
|
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyLink()">Link</button>
|
||||||
|
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyImage()">Image</button>
|
||||||
|
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHorizontalRule()">HR</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Message Input -->
|
||||||
|
<div class="p-4 border-border">
|
||||||
|
<div
|
||||||
|
class="chat-input-wrapper relative"
|
||||||
|
(mouseenter)="inputHovered.set(true)"
|
||||||
|
(mouseleave)="inputHovered.set(false)"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
#messageInputRef
|
||||||
|
rows="1"
|
||||||
|
[(ngModel)]="messageContent"
|
||||||
|
(focus)="onInputFocus()"
|
||||||
|
(blur)="onInputBlur()"
|
||||||
|
(keydown.enter)="onEnter($event)"
|
||||||
|
(input)="onInputChange(); autoResizeTextarea()"
|
||||||
|
(dragenter)="onDragEnter($event)"
|
||||||
|
(dragover)="onDragOver($event)"
|
||||||
|
(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.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">
|
||||||
|
<div class="text-sm text-muted-foreground">Drop files to attach</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (pendingFiles.length > 0) {
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
@for (file of pendingFiles; track file.name) {
|
||||||
|
<div class="group flex items-center gap-2 px-2 py-1 rounded bg-secondary/60 border border-border">
|
||||||
|
<div class="text-xs font-medium truncate max-w-[14rem]">{{ file.name }}</div>
|
||||||
|
<div class="text-[10px] text-muted-foreground">{{ formatBytes(file.size) }}</div>
|
||||||
|
<button (click)="removePendingFile(file)" class="opacity-70 group-hover:opacity-100 text-[10px] bg-destructive/20 text-destructive rounded px-1 py-0.5">Remove</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image Lightbox Modal -->
|
||||||
|
@if (lightboxAttachment()) {
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||||
|
(click)="closeLightbox()"
|
||||||
|
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!)"
|
||||||
|
(keydown.escape)="closeLightbox()"
|
||||||
|
tabindex="0"
|
||||||
|
#lightboxBackdrop
|
||||||
|
>
|
||||||
|
<div class="relative max-w-[90vw] max-h-[90vh]" (click)="$event.stopPropagation()">
|
||||||
|
<img
|
||||||
|
[src]="lightboxAttachment()!.objectUrl"
|
||||||
|
[alt]="lightboxAttachment()!.filename"
|
||||||
|
class="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
|
||||||
|
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!); $event.stopPropagation()"
|
||||||
|
/>
|
||||||
|
<!-- Top-right action bar -->
|
||||||
|
<div class="absolute top-3 right-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
(click)="downloadAttachment(lightboxAttachment()!)"
|
||||||
|
class="p-2 bg-black/60 hover:bg-black/80 text-white rounded-lg backdrop-blur-sm transition-colors"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<ng-icon name="lucideDownload" class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="closeLightbox()"
|
||||||
|
class="p-2 bg-black/60 hover:bg-black/80 text-white rounded-lg backdrop-blur-sm transition-colors"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<ng-icon name="lucideX" class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Bottom info bar -->
|
||||||
|
<div class="absolute bottom-3 left-3 right-3 flex items-center justify-between">
|
||||||
|
<div class="px-3 py-1.5 bg-black/60 backdrop-blur-sm rounded-lg">
|
||||||
|
<span class="text-white text-sm">{{ lightboxAttachment()!.filename }}</span>
|
||||||
|
<span class="text-white/60 text-xs ml-2">{{ formatBytes(lightboxAttachment()!.size) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Image Context Menu -->
|
||||||
|
@if (imageContextMenu()) {
|
||||||
|
<app-context-menu [x]="imageContextMenu()!.x" [y]="imageContextMenu()!.y" (closed)="closeImageContextMenu()">
|
||||||
|
<button (click)="copyImageToClipboard(imageContextMenu()!.attachment)" class="context-menu-item-icon">
|
||||||
|
<ng-icon name="lucideCopy" class="w-4 h-4 text-muted-foreground" />
|
||||||
|
Copy Image
|
||||||
|
</button>
|
||||||
|
<button (click)="downloadAttachment(imageContextMenu()!.attachment); closeImageContextMenu()" class="context-menu-item-icon">
|
||||||
|
<ng-icon name="lucideDownload" class="w-4 h-4 text-muted-foreground" />
|
||||||
|
Save Image
|
||||||
|
</button>
|
||||||
|
</app-context-menu>
|
||||||
|
}
|
||||||
223
src/app/features/chat/chat-messages/chat-messages.component.scss
Normal file
223
src/app/features/chat/chat-messages/chat-messages.component.scss
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
/* ── Chat layout: messages scroll behind input ──── */
|
||||||
|
|
||||||
|
.chat-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages-scroll {
|
||||||
|
/* Fallback; dynamically overridden by updateScrollPadding() */
|
||||||
|
padding-bottom: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bottom-bar {
|
||||||
|
pointer-events: auto;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
background: hsl(var(--background) / 0.85);
|
||||||
|
/* Inset from right so the scrollbar track stays visible */
|
||||||
|
right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient fade-in above the bottom bar — blur only, no darkening */
|
||||||
|
.chat-bottom-fade {
|
||||||
|
height: 20px;
|
||||||
|
background: transparent;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
mask-image: linear-gradient(to bottom, transparent 0%, black 100%);
|
||||||
|
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chat textarea redesign ────────────────────── */
|
||||||
|
|
||||||
|
.chat-textarea {
|
||||||
|
--textarea-bg: hsl(40deg 3.7% 15.9% / 87%);
|
||||||
|
background: var(--textarea-bg);
|
||||||
|
|
||||||
|
/* Auto-resize: start at 62px, grow upward */
|
||||||
|
height: 62px;
|
||||||
|
min-height: 62px;
|
||||||
|
max-height: 520px;
|
||||||
|
overflow-y: hidden;
|
||||||
|
resize: none;
|
||||||
|
transition: height 0.12s ease;
|
||||||
|
|
||||||
|
/* Show manual resize handle only while Ctrl is held */
|
||||||
|
&.ctrl-resize {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Send button: hidden by default, fades in on wrapper hover */
|
||||||
|
.send-btn {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: scale(0.85);
|
||||||
|
transition:
|
||||||
|
opacity 0.2s ease,
|
||||||
|
transform 0.2s ease;
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ngx-remark markdown styles ──────────────── */
|
||||||
|
|
||||||
|
.chat-markdown {
|
||||||
|
max-width: 100%;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
|
||||||
|
::ng-deep {
|
||||||
|
remark {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.25em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 700;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
em {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
del {
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0.5em 0 0.25em;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.5em; }
|
||||||
|
h2 { font-size: 1.3em; }
|
||||||
|
h3 { font-size: 1.15em; }
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
margin: 0.25em 0;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
}
|
||||||
|
ul { list-style-type: disc; }
|
||||||
|
ol { list-style-type: decimal; }
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin: 0.125em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 3px solid hsl(var(--primary) / 0.5);
|
||||||
|
margin: 0.5em 0;
|
||||||
|
padding: 0.25em 0.75em;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
background: hsl(var(--secondary) / 0.3);
|
||||||
|
border-radius: 0 var(--radius) var(--radius) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.875em;
|
||||||
|
background: hsl(var(--secondary));
|
||||||
|
padding: 0.15em 0.35em;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
overflow-x: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
padding: 0.75em 1em;
|
||||||
|
background: hsl(var(--secondary));
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
white-space: pre;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
|
margin: 0.75em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
font-size: 0.875em;
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
padding: 0.35em 0.75em;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: hsl(var(--secondary));
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 320px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure consecutive paragraphs have minimal spacing for chat feel
|
||||||
|
p + p {
|
||||||
|
margin-top: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mermaid diagrams: prevent SVG from blocking clicks on the rest of the app
|
||||||
|
remark-mermaid {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
pointer-events: none;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
996
src/app/features/chat/chat-messages/chat-messages.component.ts
Normal file
996
src/app/features/chat/chat-messages/chat-messages.component.ts
Normal file
@@ -0,0 +1,996 @@
|
|||||||
|
import { Component, inject, signal, computed, effect, ElementRef, ViewChild, AfterViewChecked, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
import { AttachmentService, Attachment } from '../../../core/services/attachment.service';
|
||||||
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
|
import {
|
||||||
|
lucideSend,
|
||||||
|
lucideSmile,
|
||||||
|
lucideEdit,
|
||||||
|
lucideTrash2,
|
||||||
|
lucideReply,
|
||||||
|
lucideMoreVertical,
|
||||||
|
lucideCheck,
|
||||||
|
lucideX,
|
||||||
|
lucideDownload,
|
||||||
|
lucideExpand,
|
||||||
|
lucideImage,
|
||||||
|
lucideCopy,
|
||||||
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
|
import { MessagesActions } from '../../../store/messages/messages.actions';
|
||||||
|
import { selectAllMessages, selectMessagesLoading, selectMessagesSyncing } from '../../../store/messages/messages.selectors';
|
||||||
|
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||||
|
import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors';
|
||||||
|
import { Message } from '../../../core/models';
|
||||||
|
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
||||||
|
import { ContextMenuComponent, UserAvatarComponent } from '../../../shared';
|
||||||
|
import { TypingIndicatorComponent } from '../typing-indicator/typing-indicator.component';
|
||||||
|
import { RemarkModule, MermaidComponent } from 'ngx-remark';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import remarkBreaks from 'remark-breaks';
|
||||||
|
import remarkParse from 'remark-parse';
|
||||||
|
import { unified } from 'unified';
|
||||||
|
|
||||||
|
const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥', '👀'];
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-chat-messages',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, NgIcon, ContextMenuComponent, UserAvatarComponent, TypingIndicatorComponent, RemarkModule, MermaidComponent],
|
||||||
|
viewProviders: [
|
||||||
|
provideIcons({
|
||||||
|
lucideSend,
|
||||||
|
lucideSmile,
|
||||||
|
lucideEdit,
|
||||||
|
lucideTrash2,
|
||||||
|
lucideReply,
|
||||||
|
lucideMoreVertical,
|
||||||
|
lucideCheck,
|
||||||
|
lucideX,
|
||||||
|
lucideDownload,
|
||||||
|
lucideExpand,
|
||||||
|
lucideImage,
|
||||||
|
lucideCopy,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
templateUrl: './chat-messages.component.html',
|
||||||
|
styleUrls: ['./chat-messages.component.scss'],
|
||||||
|
// eslint-disable-next-line @angular-eslint/no-host-metadata-property
|
||||||
|
host: {
|
||||||
|
'(document:keydown)': 'onDocKeydown($event)',
|
||||||
|
'(document:keyup)': 'onDocKeyup($event)',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Real-time chat messages view with infinite scroll, markdown rendering,
|
||||||
|
* emoji reactions, file attachments, and image lightbox support.
|
||||||
|
*/
|
||||||
|
export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestroy {
|
||||||
|
@ViewChild('messagesContainer') messagesContainer!: ElementRef;
|
||||||
|
@ViewChild('messageInputRef') messageInputRef!: ElementRef<HTMLTextAreaElement>;
|
||||||
|
@ViewChild('bottomBar') bottomBar!: ElementRef;
|
||||||
|
|
||||||
|
private store = inject(Store);
|
||||||
|
private webrtc = inject(WebRTCService);
|
||||||
|
private serverDirectory = inject(ServerDirectoryService);
|
||||||
|
private attachmentsSvc = inject(AttachmentService);
|
||||||
|
private cdr = inject(ChangeDetectorRef);
|
||||||
|
|
||||||
|
/** Remark processor with GFM (tables, strikethrough, etc.) and line-break support */
|
||||||
|
remarkProcessor = unified()
|
||||||
|
.use(remarkParse)
|
||||||
|
.use(remarkGfm)
|
||||||
|
.use(remarkBreaks) as any;
|
||||||
|
|
||||||
|
private allMessages = this.store.selectSignal(selectAllMessages);
|
||||||
|
private activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||||
|
|
||||||
|
// --- Infinite scroll (upwards) pagination ---
|
||||||
|
private readonly PAGE_SIZE = 50;
|
||||||
|
displayLimit = signal(this.PAGE_SIZE);
|
||||||
|
loadingMore = signal(false);
|
||||||
|
|
||||||
|
/** All messages for the current channel (full list, unsliced) */
|
||||||
|
private allChannelMessages = computed(() => {
|
||||||
|
const channelId = this.activeChannelId();
|
||||||
|
const roomId = this.currentRoom()?.id;
|
||||||
|
return this.allMessages().filter(message =>
|
||||||
|
message.roomId === roomId && (message.channelId || 'general') === channelId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Paginated view — only the most recent `displayLimit` messages */
|
||||||
|
messages = computed(() => {
|
||||||
|
const all = this.allChannelMessages();
|
||||||
|
const limit = this.displayLimit();
|
||||||
|
if (all.length <= limit) return all;
|
||||||
|
return all.slice(all.length - limit);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Whether there are older messages that can be loaded */
|
||||||
|
hasMoreMessages = computed(() => this.allChannelMessages().length > this.displayLimit());
|
||||||
|
loading = this.store.selectSignal(selectMessagesLoading);
|
||||||
|
syncing = this.store.selectSignal(selectMessagesSyncing);
|
||||||
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||||
|
private currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
|
messageContent = '';
|
||||||
|
editContent = '';
|
||||||
|
editingMessageId = signal<string | null>(null);
|
||||||
|
replyTo = signal<Message | null>(null);
|
||||||
|
showEmojiPicker = signal<string | null>(null);
|
||||||
|
|
||||||
|
readonly commonEmojis = COMMON_EMOJIS;
|
||||||
|
|
||||||
|
private shouldScrollToBottom = true;
|
||||||
|
/** Keeps us pinned to bottom while images/attachments load after initial open */
|
||||||
|
private initialScrollObserver: MutationObserver | null = null;
|
||||||
|
private initialScrollTimer: any = null;
|
||||||
|
private boundOnImageLoad: (() => void) | null = null;
|
||||||
|
/** True while a programmatic scroll-to-bottom is in progress (suppresses onScroll). */
|
||||||
|
private isAutoScrolling = false;
|
||||||
|
private lastTypingSentAt = 0;
|
||||||
|
private lastMessageCount = 0;
|
||||||
|
private initialScrollPending = true;
|
||||||
|
pendingFiles: File[] = [];
|
||||||
|
// New messages snackbar state
|
||||||
|
showNewMessagesBar = signal(false);
|
||||||
|
// Plain (non-reactive) reference time used only by formatTimestamp.
|
||||||
|
// Updated periodically but NOT a signal, so it won't re-render every message.
|
||||||
|
private nowRef = Date.now();
|
||||||
|
private nowTimer: any;
|
||||||
|
toolbarVisible = signal(false);
|
||||||
|
private toolbarHovering = false;
|
||||||
|
inlineCodeToken = '`';
|
||||||
|
dragActive = signal(false);
|
||||||
|
inputHovered = signal(false);
|
||||||
|
ctrlHeld = signal(false);
|
||||||
|
private boundCtrlDown: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
private boundCtrlUp: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
|
||||||
|
// Image lightbox modal state
|
||||||
|
lightboxAttachment = signal<Attachment | null>(null);
|
||||||
|
// Image right-click context menu state
|
||||||
|
imageContextMenu = signal<{ x: number; y: number; attachment: Attachment } | null>(null);
|
||||||
|
private boundOnKeydown: ((event: KeyboardEvent) => void) | null = null;
|
||||||
|
|
||||||
|
// Reset scroll state when room/server changes (handles reuse of component on navigation)
|
||||||
|
private onRoomChanged = effect(() => {
|
||||||
|
void this.currentRoom(); // track room signal
|
||||||
|
this.initialScrollPending = true;
|
||||||
|
this.stopInitialScrollWatch();
|
||||||
|
this.showNewMessagesBar.set(false);
|
||||||
|
this.lastMessageCount = 0;
|
||||||
|
this.displayLimit.set(this.PAGE_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset pagination when switching channels within the same room
|
||||||
|
private onChannelChanged = effect(() => {
|
||||||
|
void this.activeChannelId(); // track channel signal
|
||||||
|
this.displayLimit.set(this.PAGE_SIZE);
|
||||||
|
this.initialScrollPending = true;
|
||||||
|
this.showNewMessagesBar.set(false);
|
||||||
|
this.lastMessageCount = 0;
|
||||||
|
});
|
||||||
|
// Re-render when attachments update (e.g. download progress from WebRTC callbacks)
|
||||||
|
private attachmentsUpdatedEffect = effect(() => {
|
||||||
|
void this.attachmentsSvc.updated();
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track total channel messages (not paginated) for new-message detection
|
||||||
|
private totalChannelMessagesLength = computed(() => this.allChannelMessages().length);
|
||||||
|
messagesLength = computed(() => this.messages().length);
|
||||||
|
private onMessagesChanged = effect(() => {
|
||||||
|
const currentCount = this.totalChannelMessagesLength();
|
||||||
|
const el = this.messagesContainer?.nativeElement;
|
||||||
|
if (!el) {
|
||||||
|
this.lastMessageCount = currentCount;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip during initial scroll setup
|
||||||
|
if (this.initialScrollPending) {
|
||||||
|
this.lastMessageCount = currentCount;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
|
const newMessages = currentCount > this.lastMessageCount;
|
||||||
|
if (newMessages) {
|
||||||
|
if (distanceFromBottom <= 300) {
|
||||||
|
// Smooth auto-scroll only when near bottom; schedule after render
|
||||||
|
this.scheduleScrollToBottomSmooth();
|
||||||
|
this.showNewMessagesBar.set(false);
|
||||||
|
} else {
|
||||||
|
// Schedule snackbar update to avoid blocking change detection
|
||||||
|
queueMicrotask(() => this.showNewMessagesBar.set(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.lastMessageCount = currentCount;
|
||||||
|
});
|
||||||
|
|
||||||
|
ngAfterViewChecked(): void {
|
||||||
|
const el = this.messagesContainer?.nativeElement;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
// First render after connect: scroll to bottom instantly (no animation)
|
||||||
|
// Only proceed once messages are actually rendered in the DOM
|
||||||
|
if (this.initialScrollPending) {
|
||||||
|
if (this.messages().length > 0) {
|
||||||
|
this.initialScrollPending = false;
|
||||||
|
// Snap to bottom immediately, then keep watching for late layout changes
|
||||||
|
this.isAutoScrolling = true;
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
requestAnimationFrame(() => { this.isAutoScrolling = false; });
|
||||||
|
this.startInitialScrollWatch();
|
||||||
|
this.showNewMessagesBar.set(false);
|
||||||
|
this.lastMessageCount = this.messages().length;
|
||||||
|
} else if (!this.loading()) {
|
||||||
|
// Room has no messages and loading is done
|
||||||
|
this.initialScrollPending = false;
|
||||||
|
this.lastMessageCount = 0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.updateScrollPadding();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
// Initialize message count for snackbar trigger
|
||||||
|
this.lastMessageCount = this.messages().length;
|
||||||
|
|
||||||
|
// Update reference time silently (non-reactive) so formatTimestamp
|
||||||
|
// uses a reasonably fresh "now" without re-rendering every message.
|
||||||
|
this.nowTimer = setInterval(() => {
|
||||||
|
this.nowRef = Date.now();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
// Global Escape key listener for lightbox & context menu
|
||||||
|
this.boundOnKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
if (this.imageContextMenu()) { this.closeImageContextMenu(); return; }
|
||||||
|
if (this.lightboxAttachment()) { this.closeLightbox(); return; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', this.boundOnKeydown);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.stopInitialScrollWatch();
|
||||||
|
if (this.nowTimer) {
|
||||||
|
clearInterval(this.nowTimer);
|
||||||
|
this.nowTimer = null;
|
||||||
|
}
|
||||||
|
if (this.boundOnKeydown) {
|
||||||
|
document.removeEventListener('keydown', this.boundOnKeydown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send the current message content (with optional attachments) and reset the input. */
|
||||||
|
sendMessage(): void {
|
||||||
|
const raw = this.messageContent.trim();
|
||||||
|
if (!raw && this.pendingFiles.length === 0) return;
|
||||||
|
|
||||||
|
const content = this.appendImageMarkdown(raw);
|
||||||
|
|
||||||
|
this.store.dispatch(
|
||||||
|
MessagesActions.sendMessage({
|
||||||
|
content,
|
||||||
|
replyToId: this.replyTo()?.id,
|
||||||
|
channelId: this.activeChannelId(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.messageContent = '';
|
||||||
|
this.clearReply();
|
||||||
|
this.shouldScrollToBottom = true;
|
||||||
|
// Reset textarea height after sending
|
||||||
|
requestAnimationFrame(() => this.autoResizeTextarea());
|
||||||
|
this.showNewMessagesBar.set(false);
|
||||||
|
|
||||||
|
if (this.pendingFiles.length > 0) {
|
||||||
|
// Wait briefly for the message to appear in the list, then attach
|
||||||
|
setTimeout(() => this.attachFilesToLastOwnMessage(content), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throttle and broadcast a typing indicator when the user types. */
|
||||||
|
onInputChange(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastTypingSentAt > 1000) { // throttle typing events
|
||||||
|
try {
|
||||||
|
this.webrtc.sendRawMessage({ type: 'typing' });
|
||||||
|
this.lastTypingSentAt = now;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Begin editing an existing message, populating the edit input. */
|
||||||
|
startEdit(message: Message): void {
|
||||||
|
this.editingMessageId.set(message.id);
|
||||||
|
this.editContent = message.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save the edited message content and exit edit mode. */
|
||||||
|
saveEdit(messageId: string): void {
|
||||||
|
if (!this.editContent.trim()) return;
|
||||||
|
|
||||||
|
this.store.dispatch(
|
||||||
|
MessagesActions.editMessage({
|
||||||
|
messageId,
|
||||||
|
content: this.editContent.trim(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.cancelEdit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel the current edit and clear the edit state. */
|
||||||
|
cancelEdit(): void {
|
||||||
|
this.editingMessageId.set(null);
|
||||||
|
this.editContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a message (own or admin-delete if the user has admin privileges). */
|
||||||
|
deleteMessage(message: Message): void {
|
||||||
|
if (this.isOwnMessage(message)) {
|
||||||
|
this.store.dispatch(MessagesActions.deleteMessage({ messageId: message.id }));
|
||||||
|
} else if (this.isAdmin()) {
|
||||||
|
this.store.dispatch(MessagesActions.adminDeleteMessage({ messageId: message.id }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set the message to reply to. */
|
||||||
|
setReplyTo(message: Message): void {
|
||||||
|
this.replyTo.set(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the current reply-to reference. */
|
||||||
|
clearReply(): void {
|
||||||
|
this.replyTo.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the original message that a reply references. */
|
||||||
|
getRepliedMessage(messageId: string): Message | undefined {
|
||||||
|
return this.allMessages().find(message => message.id === messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Smooth-scroll to a specific message element and briefly highlight it. */
|
||||||
|
scrollToMessage(messageId: string): void {
|
||||||
|
const container = this.messagesContainer?.nativeElement;
|
||||||
|
if (!container) return;
|
||||||
|
const el = container.querySelector(`[data-message-id="${messageId}"]`);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
el.classList.add('bg-primary/10');
|
||||||
|
setTimeout(() => el.classList.remove('bg-primary/10'), 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle the emoji picker for a message. */
|
||||||
|
toggleEmojiPicker(messageId: string): void {
|
||||||
|
this.showEmojiPicker.update((current) =>
|
||||||
|
current === messageId ? null : messageId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a reaction emoji to a message. */
|
||||||
|
addReaction(messageId: string, emoji: string): void {
|
||||||
|
this.store.dispatch(MessagesActions.addReaction({ messageId, emoji }));
|
||||||
|
this.showEmojiPicker.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle the reaction for the current user on a message. */
|
||||||
|
toggleReaction(messageId: string, emoji: string): void {
|
||||||
|
const message = this.messages().find((msg) => msg.id === messageId);
|
||||||
|
const currentUserId = this.currentUser()?.id;
|
||||||
|
|
||||||
|
if (!message || !currentUserId) return;
|
||||||
|
|
||||||
|
const hasReacted = message.reactions.some(
|
||||||
|
(reaction) => reaction.emoji === emoji && reaction.userId === currentUserId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasReacted) {
|
||||||
|
this.store.dispatch(MessagesActions.removeReaction({ messageId, emoji }));
|
||||||
|
} else {
|
||||||
|
this.store.dispatch(MessagesActions.addReaction({ messageId, emoji }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether a message was sent by the current user. */
|
||||||
|
isOwnMessage(message: Message): boolean {
|
||||||
|
return message.senderId === this.currentUser()?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aggregate reactions by emoji, returning counts and whether the current user reacted. */
|
||||||
|
getGroupedReactions(message: Message): { emoji: string; count: number; hasCurrentUser: boolean }[] {
|
||||||
|
const groups = new Map<string, { count: number; hasCurrentUser: boolean }>();
|
||||||
|
const currentUserId = this.currentUser()?.id;
|
||||||
|
|
||||||
|
message.reactions.forEach((reaction) => {
|
||||||
|
const existing = groups.get(reaction.emoji) || { count: 0, hasCurrentUser: false };
|
||||||
|
groups.set(reaction.emoji, {
|
||||||
|
count: existing.count + 1,
|
||||||
|
hasCurrentUser: existing.hasCurrentUser || reaction.userId === currentUserId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(groups.entries()).map(([emoji, data]) => ({
|
||||||
|
emoji,
|
||||||
|
...data,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a timestamp as a relative or absolute time string. */
|
||||||
|
formatTimestamp(timestamp: number): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date(this.nowRef);
|
||||||
|
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
// Compare calendar days (midnight-aligned) to avoid NG0100 flicker
|
||||||
|
const toDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||||
|
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (dayDiff === 0) {
|
||||||
|
return time;
|
||||||
|
} else if (dayDiff === 1) {
|
||||||
|
return 'Yesterday ' + time;
|
||||||
|
} else if (dayDiff < 7) {
|
||||||
|
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scrollToBottom(): void {
|
||||||
|
if (this.messagesContainer) {
|
||||||
|
const el = this.messagesContainer.nativeElement;
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
this.shouldScrollToBottom = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start observing the messages container for DOM mutations
|
||||||
|
* and image load events. Every time the container's content
|
||||||
|
* changes size (new nodes, images finishing load) we instantly
|
||||||
|
* snap to the bottom. Automatically stops after a timeout or
|
||||||
|
* when the user scrolls up.
|
||||||
|
*/
|
||||||
|
private startInitialScrollWatch(): void {
|
||||||
|
this.stopInitialScrollWatch(); // clean up any prior watcher
|
||||||
|
|
||||||
|
const el = this.messagesContainer?.nativeElement;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const snap = () => {
|
||||||
|
if (this.messagesContainer) {
|
||||||
|
const e = this.messagesContainer.nativeElement;
|
||||||
|
this.isAutoScrolling = true;
|
||||||
|
e.scrollTop = e.scrollHeight;
|
||||||
|
// Clear flag after browser fires the synchronous scroll event
|
||||||
|
requestAnimationFrame(() => { this.isAutoScrolling = false; });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. MutationObserver catches new DOM nodes (attachments rendered, etc.)
|
||||||
|
this.initialScrollObserver = new MutationObserver(() => {
|
||||||
|
requestAnimationFrame(snap);
|
||||||
|
});
|
||||||
|
this.initialScrollObserver.observe(el, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['src'], // img src swaps
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Capture-phase 'load' listener catches images finishing load
|
||||||
|
this.boundOnImageLoad = () => requestAnimationFrame(snap);
|
||||||
|
el.addEventListener('load', this.boundOnImageLoad, true);
|
||||||
|
|
||||||
|
// 3. Auto-stop after 5s so we don't fight user scrolling
|
||||||
|
this.initialScrollTimer = setTimeout(() => this.stopInitialScrollWatch(), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopInitialScrollWatch(): void {
|
||||||
|
if (this.initialScrollObserver) {
|
||||||
|
this.initialScrollObserver.disconnect();
|
||||||
|
this.initialScrollObserver = null;
|
||||||
|
}
|
||||||
|
if (this.boundOnImageLoad && this.messagesContainer) {
|
||||||
|
this.messagesContainer.nativeElement.removeEventListener('load', this.boundOnImageLoad, true);
|
||||||
|
this.boundOnImageLoad = null;
|
||||||
|
}
|
||||||
|
if (this.initialScrollTimer) {
|
||||||
|
clearTimeout(this.initialScrollTimer);
|
||||||
|
this.initialScrollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scrollToBottomSmooth(): void {
|
||||||
|
if (this.messagesContainer) {
|
||||||
|
const el = this.messagesContainer.nativeElement;
|
||||||
|
try {
|
||||||
|
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||||
|
} catch {
|
||||||
|
// Fallback if smooth not supported
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
this.shouldScrollToBottom = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleScrollToBottomSmooth(): void {
|
||||||
|
// Use double rAF to ensure DOM updated and layout computed before scrolling
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => this.scrollToBottomSmooth());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle scroll events: toggle auto-scroll, dismiss snackbar, and trigger infinite scroll. */
|
||||||
|
onScroll(): void {
|
||||||
|
if (!this.messagesContainer) return;
|
||||||
|
// Ignore scroll events caused by programmatic snap-to-bottom
|
||||||
|
if (this.isAutoScrolling) return;
|
||||||
|
|
||||||
|
const el = this.messagesContainer.nativeElement;
|
||||||
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
|
this.shouldScrollToBottom = distanceFromBottom <= 300;
|
||||||
|
if (this.shouldScrollToBottom) {
|
||||||
|
this.showNewMessagesBar.set(false);
|
||||||
|
}
|
||||||
|
// Any user-initiated scroll during the initial load period
|
||||||
|
// immediately hands control back to the user
|
||||||
|
if (this.initialScrollObserver) {
|
||||||
|
this.stopInitialScrollWatch();
|
||||||
|
}
|
||||||
|
// Infinite scroll upwards — load older messages when near the top
|
||||||
|
if (el.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
|
||||||
|
this.loadMore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load older messages by expanding the display window, preserving scroll position */
|
||||||
|
loadMore(): void {
|
||||||
|
if (this.loadingMore() || !this.hasMoreMessages()) return;
|
||||||
|
this.loadingMore.set(true);
|
||||||
|
|
||||||
|
const el = this.messagesContainer?.nativeElement;
|
||||||
|
const prevScrollHeight = el?.scrollHeight ?? 0;
|
||||||
|
|
||||||
|
this.displayLimit.update(limit => limit + this.PAGE_SIZE);
|
||||||
|
|
||||||
|
// After Angular renders the new messages, restore scroll position
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (el) {
|
||||||
|
const newScrollHeight = el.scrollHeight;
|
||||||
|
el.scrollTop += newScrollHeight - prevScrollHeight;
|
||||||
|
}
|
||||||
|
this.loadingMore.set(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Markdown toolbar actions
|
||||||
|
/** Handle keyboard events in the message input (Enter to send, Shift+Enter for newline). */
|
||||||
|
onEnter(evt: Event): void {
|
||||||
|
const keyEvent = evt as KeyboardEvent;
|
||||||
|
if (keyEvent.shiftKey) {
|
||||||
|
// allow newline
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
keyEvent.preventDefault();
|
||||||
|
this.sendMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSelection(): { start: number; end: number } {
|
||||||
|
const el = this.messageInputRef?.nativeElement;
|
||||||
|
return { start: el?.selectionStart ?? this.messageContent.length, end: el?.selectionEnd ?? this.messageContent.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
private setSelection(start: number, end: number): void {
|
||||||
|
const el = this.messageInputRef?.nativeElement;
|
||||||
|
if (el) {
|
||||||
|
el.selectionStart = start;
|
||||||
|
el.selectionEnd = end;
|
||||||
|
el.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap selected text in an inline markdown token (bold, italic, etc.). */
|
||||||
|
applyInline(token: string): void {
|
||||||
|
const { start, end } = this.getSelection();
|
||||||
|
const before = this.messageContent.slice(0, start);
|
||||||
|
const selected = this.messageContent.slice(start, end) || 'text';
|
||||||
|
const after = this.messageContent.slice(end);
|
||||||
|
const newText = `${before}${token}${selected}${token}${after}`;
|
||||||
|
this.messageContent = newText;
|
||||||
|
const cursor = before.length + token.length + selected.length + token.length;
|
||||||
|
this.setSelection(cursor, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prepend each selected line with a markdown prefix (e.g. `- ` for lists). */
|
||||||
|
applyPrefix(prefix: string): void {
|
||||||
|
const { start, end } = this.getSelection();
|
||||||
|
const before = this.messageContent.slice(0, start);
|
||||||
|
const selected = this.messageContent.slice(start, end) || 'text';
|
||||||
|
const after = this.messageContent.slice(end);
|
||||||
|
const lines = selected.split('\n').map(line => `${prefix}${line}`);
|
||||||
|
const newSelected = lines.join('\n');
|
||||||
|
const newText = `${before}${newSelected}${after}`;
|
||||||
|
this.messageContent = newText;
|
||||||
|
const cursor = before.length + newSelected.length;
|
||||||
|
this.setSelection(cursor, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a markdown heading at the given level around the current selection. */
|
||||||
|
applyHeading(level: number): void {
|
||||||
|
const hashes = '#'.repeat(Math.max(1, Math.min(6, level)));
|
||||||
|
const { start, end } = this.getSelection();
|
||||||
|
const before = this.messageContent.slice(0, start);
|
||||||
|
const selected = this.messageContent.slice(start, end) || 'Heading';
|
||||||
|
const after = this.messageContent.slice(end);
|
||||||
|
const needsLeadingNewline = before.length > 0 && !before.endsWith('\n');
|
||||||
|
const needsTrailingNewline = after.length > 0 && !after.startsWith('\n');
|
||||||
|
const block = `${needsLeadingNewline ? '\n' : ''}${hashes} ${selected}${needsTrailingNewline ? '\n' : ''}`;
|
||||||
|
const newText = `${before}${block}${after}`;
|
||||||
|
this.messageContent = newText;
|
||||||
|
const cursor = before.length + block.length;
|
||||||
|
this.setSelection(cursor, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert selected lines into a numbered markdown list. */
|
||||||
|
applyOrderedList(): void {
|
||||||
|
const { start, end } = this.getSelection();
|
||||||
|
const before = this.messageContent.slice(0, start);
|
||||||
|
const selected = this.messageContent.slice(start, end) || 'item\nitem';
|
||||||
|
const after = this.messageContent.slice(end);
|
||||||
|
const lines = selected.split('\n').map((line, index) => `${index + 1}. ${line}`);
|
||||||
|
const newSelected = lines.join('\n');
|
||||||
|
const newText = `${before}${newSelected}${after}`;
|
||||||
|
this.messageContent = newText;
|
||||||
|
const cursor = before.length + newSelected.length;
|
||||||
|
this.setSelection(cursor, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap the selection in a fenced markdown code block. */
|
||||||
|
applyCodeBlock(): void {
|
||||||
|
const { start, end } = this.getSelection();
|
||||||
|
const before = this.messageContent.slice(0, start);
|
||||||
|
const selected = this.messageContent.slice(start, end) || 'code';
|
||||||
|
const after = this.messageContent.slice(end);
|
||||||
|
const fenced = `\n\n\`\`\`\n${selected}\n\`\`\`\n\n`;
|
||||||
|
const newText = `${before}${fenced}${after}`;
|
||||||
|
this.messageContent = newText;
|
||||||
|
const cursor = before.length + fenced.length;
|
||||||
|
this.setSelection(cursor, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a markdown link around the current selection. */
|
||||||
|
applyLink(): void {
|
||||||
|
const { start, end } = this.getSelection();
|
||||||
|
const before = this.messageContent.slice(0, start);
|
||||||
|
const selected = this.messageContent.slice(start, end) || 'link';
|
||||||
|
const after = this.messageContent.slice(end);
|
||||||
|
const link = `[${selected}](https://)`;
|
||||||
|
const newText = `${before}${link}${after}`;
|
||||||
|
this.messageContent = newText;
|
||||||
|
const cursorStart = before.length + link.length - 1; // position inside url
|
||||||
|
this.setSelection(cursorStart - 8, cursorStart - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a markdown image embed around the current selection. */
|
||||||
|
applyImage(): void {
|
||||||
|
const { start, end } = this.getSelection();
|
||||||
|
const before = this.messageContent.slice(0, start);
|
||||||
|
const selected = this.messageContent.slice(start, end) || 'alt';
|
||||||
|
const after = this.messageContent.slice(end);
|
||||||
|
const img = ``;
|
||||||
|
const newText = `${before}${img}${after}`;
|
||||||
|
this.messageContent = newText;
|
||||||
|
const cursorStart = before.length + img.length - 1;
|
||||||
|
this.setSelection(cursorStart - 8, cursorStart - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a horizontal rule at the cursor position. */
|
||||||
|
applyHorizontalRule(): void {
|
||||||
|
const { start, end } = this.getSelection();
|
||||||
|
const before = this.messageContent.slice(0, start);
|
||||||
|
const after = this.messageContent.slice(end);
|
||||||
|
const hr = `\n\n---\n\n`;
|
||||||
|
const newText = `${before}${hr}${after}`;
|
||||||
|
this.messageContent = newText;
|
||||||
|
const cursor = before.length + hr.length;
|
||||||
|
this.setSelection(cursor, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle drag-enter to activate the drop zone overlay. */
|
||||||
|
// Attachments: drag/drop and rendering
|
||||||
|
onDragEnter(evt: DragEvent): void {
|
||||||
|
evt.preventDefault();
|
||||||
|
this.dragActive.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the drop zone active while dragging over. */
|
||||||
|
onDragOver(evt: DragEvent): void {
|
||||||
|
evt.preventDefault();
|
||||||
|
this.dragActive.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deactivate the drop zone when dragging leaves. */
|
||||||
|
onDragLeave(evt: DragEvent): void {
|
||||||
|
evt.preventDefault();
|
||||||
|
this.dragActive.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle dropped files, adding them to the pending upload queue. */
|
||||||
|
onDrop(evt: DragEvent): void {
|
||||||
|
evt.preventDefault();
|
||||||
|
const files: File[] = [];
|
||||||
|
const items = evt.dataTransfer?.items;
|
||||||
|
if (items && items.length) {
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
if (item.kind === 'file') {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) files.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (evt.dataTransfer?.files?.length) {
|
||||||
|
for (let i = 0; i < evt.dataTransfer.files.length; i++) {
|
||||||
|
files.push(evt.dataTransfer.files[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files.forEach((file) => this.pendingFiles.push(file));
|
||||||
|
// Keep toolbar visible so user sees options
|
||||||
|
this.toolbarVisible.set(true);
|
||||||
|
this.dragActive.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return all file attachments associated with a message. */
|
||||||
|
getAttachments(messageId: string): Attachment[] {
|
||||||
|
return this.attachmentsSvc.getForMessage(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a byte count into a human-readable size string (B, KB, MB, GB). */
|
||||||
|
formatBytes(bytes: number): string {
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let size = bytes;
|
||||||
|
let i = 0;
|
||||||
|
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
||||||
|
return `${size.toFixed(1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a transfer speed in bytes/second to a human-readable string. */
|
||||||
|
formatSpeed(bps?: number): string {
|
||||||
|
if (!bps || bps <= 0) return '0 B/s';
|
||||||
|
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
|
||||||
|
let speed = bps;
|
||||||
|
let i = 0;
|
||||||
|
while (speed >= 1024 && i < units.length - 1) { speed /= 1024; i++; }
|
||||||
|
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a pending file from the upload queue. */
|
||||||
|
removePendingFile(file: File): void {
|
||||||
|
const idx = this.pendingFiles.findIndex((pending) => pending === file);
|
||||||
|
if (idx >= 0) {
|
||||||
|
this.pendingFiles.splice(idx, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download a completed attachment to the user's device. */
|
||||||
|
downloadAttachment(att: Attachment): void {
|
||||||
|
if (!att.available || !att.objectUrl) return;
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = att.objectUrl;
|
||||||
|
a.download = att.filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Request a file attachment to be transferred from the uploader peer. */
|
||||||
|
requestAttachment(att: Attachment, messageId: string): void {
|
||||||
|
this.attachmentsSvc.requestFile(messageId, att);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel an in-progress attachment transfer request. */
|
||||||
|
cancelAttachment(att: Attachment, messageId: string): void {
|
||||||
|
this.attachmentsSvc.cancelRequest(messageId, att);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether the current user is the original uploader of an attachment. */
|
||||||
|
isUploader(att: Attachment): boolean {
|
||||||
|
const myUserId = this.currentUser()?.id;
|
||||||
|
return !!att.uploaderPeerId && !!myUserId && att.uploaderPeerId === myUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the image lightbox for a completed image attachment. */
|
||||||
|
// ---- Image lightbox ----
|
||||||
|
openLightbox(att: Attachment): void {
|
||||||
|
if (att.available && att.objectUrl) {
|
||||||
|
this.lightboxAttachment.set(att);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the image lightbox. */
|
||||||
|
closeLightbox(): void {
|
||||||
|
this.lightboxAttachment.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a context menu on right-click of an image attachment. */
|
||||||
|
// ---- Image context menu ----
|
||||||
|
openImageContextMenu(event: MouseEvent, att: Attachment): void {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.imageContextMenu.set({ x: event.clientX, y: event.clientY, attachment: att });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the image context menu. */
|
||||||
|
closeImageContextMenu(): void {
|
||||||
|
this.imageContextMenu.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copy an image attachment to the system clipboard as PNG. */
|
||||||
|
async copyImageToClipboard(att: Attachment): Promise<void> {
|
||||||
|
this.closeImageContextMenu();
|
||||||
|
if (!att.objectUrl) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(att.objectUrl);
|
||||||
|
const blob = await resp.blob();
|
||||||
|
// Convert to PNG for clipboard compatibility
|
||||||
|
const pngBlob = await this.convertToPng(blob);
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new ClipboardItem({ 'image/png': pngBlob }),
|
||||||
|
]);
|
||||||
|
} catch (_error) {
|
||||||
|
// Failed to copy image to clipboard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertToPng(blob: Blob): Promise<Blob> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (blob.type === 'image/png') {
|
||||||
|
resolve(blob);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const img = new Image();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = img.naturalWidth;
|
||||||
|
canvas.height = img.naturalHeight;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) { reject(new Error('Canvas not supported')); return; }
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
canvas.toBlob((pngBlob) => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
if (pngBlob) resolve(pngBlob);
|
||||||
|
else reject(new Error('PNG conversion failed'));
|
||||||
|
}, 'image/png');
|
||||||
|
};
|
||||||
|
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Image load failed')); };
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retry fetching an image from any available peer. */
|
||||||
|
retryImageRequest(att: Attachment, messageId: string): void {
|
||||||
|
this.attachmentsSvc.requestImageFromAnyPeer(messageId, att);
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachFilesToLastOwnMessage(content: string): void {
|
||||||
|
const me = this.currentUser()?.id;
|
||||||
|
if (!me) return;
|
||||||
|
const msg = [...this.messages()].reverse().find((message) => message.senderId === me && message.content === content && !message.isDeleted);
|
||||||
|
if (!msg) {
|
||||||
|
// Retry shortly until message appears
|
||||||
|
setTimeout(() => this.attachFilesToLastOwnMessage(content), 150);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uploaderPeerId = this.currentUser()?.id || undefined;
|
||||||
|
this.attachmentsSvc.publishAttachments(msg.id, this.pendingFiles, uploaderPeerId);
|
||||||
|
this.pendingFiles = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect image URLs and append Markdown embeds at the end
|
||||||
|
private appendImageMarkdown(content: string): string {
|
||||||
|
const imageUrlRegex = /(https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg|bmp|tiff)(?:\?[^\s)]*)?)/ig;
|
||||||
|
const urls = new Set<string>();
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
const text = content;
|
||||||
|
while ((match = imageUrlRegex.exec(text)) !== null) {
|
||||||
|
urls.add(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urls.size === 0) return content;
|
||||||
|
|
||||||
|
let append = '';
|
||||||
|
for (const url of urls) {
|
||||||
|
// Skip if already embedded as a Markdown image
|
||||||
|
const alreadyEmbedded = new RegExp(`!\\[[^\\]]*\\\\]\\(\s*${this.escapeRegex(url)}\s*\\)`, 'i').test(text);
|
||||||
|
if (!alreadyEmbedded) {
|
||||||
|
append += `\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return append ? content + append : content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private escapeRegex(str: string): string {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auto-resize the textarea to fit its content up to 520px, then allow scrolling. */
|
||||||
|
autoResizeTextarea(): void {
|
||||||
|
const el = this.messageInputRef?.nativeElement;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 520) + 'px';
|
||||||
|
el.style.overflowY = el.scrollHeight > 520 ? 'auto' : 'hidden';
|
||||||
|
this.updateScrollPadding();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep scroll container bottom-padding in sync with the floating bottom bar height. */
|
||||||
|
private updateScrollPadding(): void {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const bar = this.bottomBar?.nativeElement;
|
||||||
|
const scroll = this.messagesContainer?.nativeElement;
|
||||||
|
if (!bar || !scroll) return;
|
||||||
|
scroll.style.paddingBottom = bar.offsetHeight + 20 + 'px';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show the markdown toolbar when the input gains focus. */
|
||||||
|
onInputFocus(): void {
|
||||||
|
this.toolbarVisible.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hide the markdown toolbar after a brief delay when the input loses focus. */
|
||||||
|
onInputBlur(): void {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.toolbarHovering) {
|
||||||
|
this.toolbarVisible.set(false);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Track mouse entry on the toolbar to prevent premature hiding. */
|
||||||
|
onToolbarMouseEnter(): void {
|
||||||
|
this.toolbarHovering = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Track mouse leave on the toolbar; hide if input is not focused. */
|
||||||
|
onToolbarMouseLeave(): void {
|
||||||
|
this.toolbarHovering = false;
|
||||||
|
if (document.activeElement !== this.messageInputRef?.nativeElement) {
|
||||||
|
this.toolbarVisible.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle Ctrl key down for enabling manual resize. */
|
||||||
|
onDocKeydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Control') this.ctrlHeld.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle Ctrl key up for disabling manual resize. */
|
||||||
|
onDocKeyup(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Control') this.ctrlHeld.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scroll to the newest message and dismiss the new-messages snackbar. */
|
||||||
|
readLatest(): void {
|
||||||
|
this.shouldScrollToBottom = true;
|
||||||
|
this.scrollToBottomSmooth();
|
||||||
|
this.showNewMessagesBar.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
@if (typingDisplay().length > 0) {
|
||||||
|
<div class="px-4 py-2 backdrop-blur-sm bg-background/60">
|
||||||
|
<span class="inline-block px-3 py-1 rounded-full text-sm text-muted-foreground">
|
||||||
|
{{ typingDisplay().join(', ') }}
|
||||||
|
@if (typingOthersCount() > 0) {
|
||||||
|
and {{ typingOthersCount() }} others are typing...
|
||||||
|
} @else {
|
||||||
|
{{ typingDisplay().length === 1 ? 'is' : 'are' }} typing...
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Component, inject, signal, DestroyRef } from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||||
|
import { merge, interval, filter, map, tap } from 'rxjs';
|
||||||
|
|
||||||
|
const TYPING_TTL = 3_000;
|
||||||
|
const PURGE_INTERVAL = 1_000;
|
||||||
|
const MAX_SHOWN = 4;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-typing-indicator',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './typing-indicator.component.html',
|
||||||
|
host: {
|
||||||
|
'class': 'block',
|
||||||
|
'style': 'background: linear-gradient(to bottom, transparent, hsl(var(--background)));',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class TypingIndicatorComponent {
|
||||||
|
private readonly typingMap = new Map<string, { name: string; expiresAt: number }>();
|
||||||
|
|
||||||
|
typingDisplay = signal<string[]>([]);
|
||||||
|
typingOthersCount = signal<number>(0);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const webrtc = inject(WebRTCService);
|
||||||
|
const destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
const typing$ = webrtc.onSignalingMessage.pipe(
|
||||||
|
filter((msg: any) => msg?.type === 'user_typing' && msg.displayName && msg.oderId),
|
||||||
|
tap((msg: any) => {
|
||||||
|
const now = Date.now();
|
||||||
|
this.typingMap.set(String(msg.oderId), {
|
||||||
|
name: String(msg.displayName),
|
||||||
|
expiresAt: now + TYPING_TTL,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const purge$ = interval(PURGE_INTERVAL).pipe(
|
||||||
|
map(() => Date.now()),
|
||||||
|
filter((now) => {
|
||||||
|
let changed = false;
|
||||||
|
for (const [key, entry] of this.typingMap) {
|
||||||
|
if (entry.expiresAt <= now) {
|
||||||
|
this.typingMap.delete(key);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
merge(typing$, purge$)
|
||||||
|
.pipe(takeUntilDestroyed(destroyRef))
|
||||||
|
.subscribe(() => this.recomputeDisplay());
|
||||||
|
}
|
||||||
|
|
||||||
|
private recomputeDisplay(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const names = Array.from(this.typingMap.values())
|
||||||
|
.filter((e) => e.expiresAt > now)
|
||||||
|
.map((e) => e.name);
|
||||||
|
this.typingDisplay.set(names.slice(0, MAX_SHOWN));
|
||||||
|
this.typingOthersCount.set(Math.max(0, names.length - MAX_SHOWN));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
import { Component, inject, signal, computed } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { Store } from '@ngrx/store';
|
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
|
||||||
import {
|
|
||||||
lucideMic,
|
|
||||||
lucideMicOff,
|
|
||||||
lucideMonitor,
|
|
||||||
lucideShield,
|
|
||||||
lucideCrown,
|
|
||||||
lucideMoreVertical,
|
|
||||||
lucideBan,
|
|
||||||
lucideUserX,
|
|
||||||
lucideVolume2,
|
|
||||||
lucideVolumeX,
|
|
||||||
} from '@ng-icons/lucide';
|
|
||||||
|
|
||||||
import * as UsersActions from '../../store/users/users.actions';
|
|
||||||
import {
|
|
||||||
selectOnlineUsers,
|
|
||||||
selectCurrentUser,
|
|
||||||
selectIsCurrentUserAdmin,
|
|
||||||
} from '../../store/users/users.selectors';
|
|
||||||
import { User } from '../../core/models';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-user-list',
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule, FormsModule, NgIcon],
|
|
||||||
viewProviders: [
|
|
||||||
provideIcons({
|
|
||||||
lucideMic,
|
|
||||||
lucideMicOff,
|
|
||||||
lucideMonitor,
|
|
||||||
lucideShield,
|
|
||||||
lucideCrown,
|
|
||||||
lucideMoreVertical,
|
|
||||||
lucideBan,
|
|
||||||
lucideUserX,
|
|
||||||
lucideVolume2,
|
|
||||||
lucideVolumeX,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
template: `
|
|
||||||
<div class="h-full flex flex-col bg-card border-l border-border">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="p-4 border-b border-border">
|
|
||||||
<h3 class="font-semibold text-foreground">Members</h3>
|
|
||||||
<p class="text-xs text-muted-foreground">{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice</p>
|
|
||||||
@if (voiceUsers().length > 0) {
|
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
|
||||||
@for (v of voiceUsers(); track v.id) {
|
|
||||||
<span class="px-2 py-1 text-xs rounded bg-secondary text-foreground flex items-center gap-1">
|
|
||||||
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
|
||||||
{{ v.displayName }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- User List -->
|
|
||||||
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
|
||||||
@for (user of onlineUsers(); track user.id) {
|
|
||||||
<div
|
|
||||||
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
|
|
||||||
(click)="toggleUserMenu(user.id)"
|
|
||||||
>
|
|
||||||
<!-- Avatar with online indicator -->
|
|
||||||
<div class="relative">
|
|
||||||
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold text-sm">
|
|
||||||
{{ user.displayName.charAt(0).toUpperCase() }}
|
|
||||||
</div>
|
|
||||||
<span class="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-card"
|
|
||||||
[class.bg-green-500]="user.isOnline !== false && user.status !== 'offline'"
|
|
||||||
[class.bg-gray-500]="user.isOnline === false || user.status === 'offline'"
|
|
||||||
></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- User Info -->
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<span class="font-medium text-sm text-foreground truncate">
|
|
||||||
{{ user.displayName }}
|
|
||||||
</span>
|
|
||||||
@if (user.isAdmin) {
|
|
||||||
<ng-icon name="lucideShield" class="w-3 h-3 text-primary" />
|
|
||||||
}
|
|
||||||
@if (user.isRoomOwner) {
|
|
||||||
<ng-icon name="lucideCrown" class="w-3 h-3 text-yellow-500" />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Voice/Screen Status -->
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
@if (user.voiceState?.isSpeaking) {
|
|
||||||
<ng-icon name="lucideMic" class="w-4 h-4 text-green-500 animate-pulse" />
|
|
||||||
} @else if (user.voiceState?.isMuted) {
|
|
||||||
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
|
|
||||||
} @else if (user.voiceState?.isConnected) {
|
|
||||||
<ng-icon name="lucideMic" class="w-4 h-4 text-muted-foreground" />
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (user.screenShareState?.isSharing) {
|
|
||||||
<ng-icon name="lucideMonitor" class="w-4 h-4 text-primary" />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- User Menu -->
|
|
||||||
@if (showUserMenu() === user.id && isAdmin() && !isCurrentUser(user)) {
|
|
||||||
<div
|
|
||||||
class="absolute right-0 top-full mt-1 z-10 w-48 bg-card border border-border rounded-lg shadow-lg py-1"
|
|
||||||
(click)="$event.stopPropagation()"
|
|
||||||
>
|
|
||||||
@if (user.voiceState?.isConnected) {
|
|
||||||
<button
|
|
||||||
(click)="muteUser(user)"
|
|
||||||
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2"
|
|
||||||
>
|
|
||||||
@if (user.voiceState?.isMutedByAdmin) {
|
|
||||||
<ng-icon name="lucideVolume2" class="w-4 h-4" />
|
|
||||||
<span>Unmute</span>
|
|
||||||
} @else {
|
|
||||||
<ng-icon name="lucideVolumeX" class="w-4 h-4" />
|
|
||||||
<span>Mute</span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<button
|
|
||||||
(click)="kickUser(user)"
|
|
||||||
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2 text-yellow-500"
|
|
||||||
>
|
|
||||||
<ng-icon name="lucideUserX" class="w-4 h-4" />
|
|
||||||
<span>Kick</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="banUser(user)"
|
|
||||||
class="w-full px-4 py-2 text-left text-sm hover:bg-destructive/10 flex items-center gap-2 text-destructive"
|
|
||||||
>
|
|
||||||
<ng-icon name="lucideBan" class="w-4 h-4" />
|
|
||||||
<span>Ban</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (onlineUsers().length === 0) {
|
|
||||||
<div class="text-center py-8 text-muted-foreground text-sm">
|
|
||||||
No users online
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ban Dialog -->
|
|
||||||
@if (showBanDialog()) {
|
|
||||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="closeBanDialog()">
|
|
||||||
<div class="bg-card border border-border rounded-lg p-6 w-96 max-w-[90vw]" (click)="$event.stopPropagation()">
|
|
||||||
<h3 class="text-lg font-semibold text-foreground mb-4">Ban User</h3>
|
|
||||||
<p class="text-sm text-muted-foreground mb-4">
|
|
||||||
Are you sure you want to ban <span class="font-semibold text-foreground">{{ userToBan()?.displayName }}</span>?
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-foreground mb-1">Reason (optional)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
[(ngModel)]="banReason"
|
|
||||||
placeholder="Enter ban reason..."
|
|
||||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-foreground mb-1">Duration</label>
|
|
||||||
<select
|
|
||||||
[(ngModel)]="banDuration"
|
|
||||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
>
|
|
||||||
<option value="3600000">1 hour</option>
|
|
||||||
<option value="86400000">1 day</option>
|
|
||||||
<option value="604800000">1 week</option>
|
|
||||||
<option value="2592000000">30 days</option>
|
|
||||||
<option value="0">Permanent</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2 justify-end">
|
|
||||||
<button
|
|
||||||
(click)="closeBanDialog()"
|
|
||||||
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
(click)="confirmBan()"
|
|
||||||
class="px-4 py-2 bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
|
|
||||||
>
|
|
||||||
Ban User
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class UserListComponent {
|
|
||||||
private store = inject(Store);
|
|
||||||
|
|
||||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
|
||||||
voiceUsers = computed(() => this.onlineUsers().filter(u => !!u.voiceState?.isConnected));
|
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
|
||||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
|
||||||
|
|
||||||
showUserMenu = signal<string | null>(null);
|
|
||||||
showBanDialog = signal(false);
|
|
||||||
userToBan = signal<User | null>(null);
|
|
||||||
banReason = '';
|
|
||||||
banDuration = '86400000'; // Default 1 day
|
|
||||||
|
|
||||||
toggleUserMenu(userId: string): void {
|
|
||||||
this.showUserMenu.update((current) => (current === userId ? null : userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
isCurrentUser(user: User): boolean {
|
|
||||||
return user.id === this.currentUser()?.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
muteUser(user: User): void {
|
|
||||||
if (user.voiceState?.isMutedByAdmin) {
|
|
||||||
this.store.dispatch(UsersActions.adminUnmuteUser({ userId: user.id }));
|
|
||||||
} else {
|
|
||||||
this.store.dispatch(UsersActions.adminMuteUser({ userId: user.id }));
|
|
||||||
}
|
|
||||||
this.showUserMenu.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
kickUser(user: User): void {
|
|
||||||
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
|
||||||
this.showUserMenu.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
banUser(user: User): void {
|
|
||||||
this.userToBan.set(user);
|
|
||||||
this.showBanDialog.set(true);
|
|
||||||
this.showUserMenu.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeBanDialog(): void {
|
|
||||||
this.showBanDialog.set(false);
|
|
||||||
this.userToBan.set(null);
|
|
||||||
this.banReason = '';
|
|
||||||
this.banDuration = '86400000';
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmBan(): void {
|
|
||||||
const user = this.userToBan();
|
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
const duration = parseInt(this.banDuration, 10);
|
|
||||||
const expiresAt = duration === 0 ? undefined : Date.now() + duration;
|
|
||||||
|
|
||||||
this.store.dispatch(
|
|
||||||
UsersActions.banUser({
|
|
||||||
userId: user.id,
|
|
||||||
reason: this.banReason || undefined,
|
|
||||||
expiresAt,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
this.closeBanDialog();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
147
src/app/features/chat/user-list/user-list.component.html
Normal file
147
src/app/features/chat/user-list/user-list.component.html
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<!-- Header -->
|
||||||
|
<div class="p-4 border-b border-border">
|
||||||
|
<h3 class="font-semibold text-foreground">Members</h3>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice</p>
|
||||||
|
@if (voiceUsers().length > 0) {
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
@for (v of voiceUsers(); track v.id) {
|
||||||
|
<span class="px-2 py-1 text-xs rounded bg-secondary text-foreground flex items-center gap-1">
|
||||||
|
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
||||||
|
{{ v.displayName }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User List -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
||||||
|
@for (user of onlineUsers(); track user.id) {
|
||||||
|
<div
|
||||||
|
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
|
||||||
|
(click)="toggleUserMenu(user.id)"
|
||||||
|
>
|
||||||
|
<!-- Avatar with online indicator -->
|
||||||
|
<div class="relative">
|
||||||
|
<app-user-avatar [name]="user.displayName" size="sm" />
|
||||||
|
<span class="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-card"
|
||||||
|
[class.bg-green-500]="user.isOnline !== false && user.status !== 'offline'"
|
||||||
|
[class.bg-gray-500]="user.isOnline === false || user.status === 'offline'"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Info -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="font-medium text-sm text-foreground truncate">
|
||||||
|
{{ user.displayName }}
|
||||||
|
</span>
|
||||||
|
@if (user.isAdmin) {
|
||||||
|
<ng-icon name="lucideShield" class="w-3 h-3 text-primary" />
|
||||||
|
}
|
||||||
|
@if (user.isRoomOwner) {
|
||||||
|
<ng-icon name="lucideCrown" class="w-3 h-3 text-yellow-500" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Voice/Screen Status -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
@if (user.voiceState?.isSpeaking) {
|
||||||
|
<ng-icon name="lucideMic" class="w-4 h-4 text-green-500 animate-pulse" />
|
||||||
|
} @else if (user.voiceState?.isMuted) {
|
||||||
|
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
|
||||||
|
} @else if (user.voiceState?.isConnected) {
|
||||||
|
<ng-icon name="lucideMic" class="w-4 h-4 text-muted-foreground" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (user.screenShareState?.isSharing) {
|
||||||
|
<ng-icon name="lucideMonitor" class="w-4 h-4 text-primary" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Menu -->
|
||||||
|
@if (showUserMenu() === user.id && isAdmin() && !isCurrentUser(user)) {
|
||||||
|
<div
|
||||||
|
class="absolute right-0 top-full mt-1 z-10 w-48 bg-card border border-border rounded-lg shadow-lg py-1"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
@if (user.voiceState?.isConnected) {
|
||||||
|
<button
|
||||||
|
(click)="muteUser(user)"
|
||||||
|
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
@if (user.voiceState?.isMutedByAdmin) {
|
||||||
|
<ng-icon name="lucideVolume2" class="w-4 h-4" />
|
||||||
|
<span>Unmute</span>
|
||||||
|
} @else {
|
||||||
|
<ng-icon name="lucideVolumeX" class="w-4 h-4" />
|
||||||
|
<span>Mute</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
(click)="kickUser(user)"
|
||||||
|
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2 text-yellow-500"
|
||||||
|
>
|
||||||
|
<ng-icon name="lucideUserX" class="w-4 h-4" />
|
||||||
|
<span>Kick</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="banUser(user)"
|
||||||
|
class="w-full px-4 py-2 text-left text-sm hover:bg-destructive/10 flex items-center gap-2 text-destructive"
|
||||||
|
>
|
||||||
|
<ng-icon name="lucideBan" class="w-4 h-4" />
|
||||||
|
<span>Ban</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (onlineUsers().length === 0) {
|
||||||
|
<div class="text-center py-8 text-muted-foreground text-sm">
|
||||||
|
No users online
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ban Dialog -->
|
||||||
|
@if (showBanDialog()) {
|
||||||
|
<app-confirm-dialog
|
||||||
|
title="Ban User"
|
||||||
|
confirmLabel="Ban User"
|
||||||
|
variant="danger"
|
||||||
|
[widthClass]="'w-96 max-w-[90vw]'"
|
||||||
|
(confirmed)="confirmBan()"
|
||||||
|
(cancelled)="closeBanDialog()"
|
||||||
|
>
|
||||||
|
<p class="mb-4">
|
||||||
|
Are you sure you want to ban <span class="font-semibold text-foreground">{{ userToBan()?.displayName }}</span>?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-foreground mb-1">Reason (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="banReason"
|
||||||
|
placeholder="Enter ban reason..."
|
||||||
|
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-foreground mb-1">Duration</label>
|
||||||
|
<select
|
||||||
|
[(ngModel)]="banDuration"
|
||||||
|
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="3600000">1 hour</option>
|
||||||
|
<option value="86400000">1 day</option>
|
||||||
|
<option value="604800000">1 week</option>
|
||||||
|
<option value="2592000000">30 days</option>
|
||||||
|
<option value="0">Permanent</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</app-confirm-dialog>
|
||||||
|
}
|
||||||
124
src/app/features/chat/user-list/user-list.component.ts
Normal file
124
src/app/features/chat/user-list/user-list.component.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { Component, inject, signal, computed } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
|
import {
|
||||||
|
lucideMic,
|
||||||
|
lucideMicOff,
|
||||||
|
lucideMonitor,
|
||||||
|
lucideShield,
|
||||||
|
lucideCrown,
|
||||||
|
lucideMoreVertical,
|
||||||
|
lucideBan,
|
||||||
|
lucideUserX,
|
||||||
|
lucideVolume2,
|
||||||
|
lucideVolumeX,
|
||||||
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
|
import { UsersActions } from '../../../store/users/users.actions';
|
||||||
|
import {
|
||||||
|
selectOnlineUsers,
|
||||||
|
selectCurrentUser,
|
||||||
|
selectIsCurrentUserAdmin,
|
||||||
|
} from '../../../store/users/users.selectors';
|
||||||
|
import { User } from '../../../core/models';
|
||||||
|
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-user-list',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent, ConfirmDialogComponent],
|
||||||
|
viewProviders: [
|
||||||
|
provideIcons({
|
||||||
|
lucideMic,
|
||||||
|
lucideMicOff,
|
||||||
|
lucideMonitor,
|
||||||
|
lucideShield,
|
||||||
|
lucideCrown,
|
||||||
|
lucideMoreVertical,
|
||||||
|
lucideBan,
|
||||||
|
lucideUserX,
|
||||||
|
lucideVolume2,
|
||||||
|
lucideVolumeX,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
templateUrl: './user-list.component.html',
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Displays the list of online users with voice state indicators and admin actions.
|
||||||
|
*/
|
||||||
|
export class UserListComponent {
|
||||||
|
private store = inject(Store);
|
||||||
|
|
||||||
|
onlineUsers = this.store.selectSignal(selectOnlineUsers) as import('@angular/core').Signal<User[]>;
|
||||||
|
voiceUsers = computed(() => this.onlineUsers().filter((user: User) => !!user.voiceState?.isConnected));
|
||||||
|
currentUser = this.store.selectSignal(selectCurrentUser) as import('@angular/core').Signal<User | undefined | null>;
|
||||||
|
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||||
|
|
||||||
|
showUserMenu = signal<string | null>(null);
|
||||||
|
showBanDialog = signal(false);
|
||||||
|
userToBan = signal<User | null>(null);
|
||||||
|
banReason = '';
|
||||||
|
banDuration = '86400000'; // Default 1 day
|
||||||
|
|
||||||
|
/** Toggle the context menu for a specific user. */
|
||||||
|
toggleUserMenu(userId: string): void {
|
||||||
|
this.showUserMenu.update((current) => (current === userId ? null : userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether the given user is the currently authenticated user. */
|
||||||
|
isCurrentUser(user: User): boolean {
|
||||||
|
return user.id === this.currentUser()?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle server-side mute on a user (admin action). */
|
||||||
|
muteUser(user: User): void {
|
||||||
|
if (user.voiceState?.isMutedByAdmin) {
|
||||||
|
this.store.dispatch(UsersActions.adminUnmuteUser({ userId: user.id }));
|
||||||
|
} else {
|
||||||
|
this.store.dispatch(UsersActions.adminMuteUser({ userId: user.id }));
|
||||||
|
}
|
||||||
|
this.showUserMenu.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kick a user from the server (admin action). */
|
||||||
|
kickUser(user: User): void {
|
||||||
|
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
||||||
|
this.showUserMenu.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the ban confirmation dialog for a user (admin action). */
|
||||||
|
banUser(user: User): void {
|
||||||
|
this.userToBan.set(user);
|
||||||
|
this.showBanDialog.set(true);
|
||||||
|
this.showUserMenu.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the ban dialog and reset its form fields. */
|
||||||
|
closeBanDialog(): void {
|
||||||
|
this.showBanDialog.set(false);
|
||||||
|
this.userToBan.set(null);
|
||||||
|
this.banReason = '';
|
||||||
|
this.banDuration = '86400000';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirm the ban, dispatch the action with duration, and close the dialog. */
|
||||||
|
confirmBan(): void {
|
||||||
|
const user = this.userToBan();
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const duration = parseInt(this.banDuration, 10);
|
||||||
|
const expiresAt = duration === 0 ? undefined : Date.now() + duration;
|
||||||
|
|
||||||
|
this.store.dispatch(
|
||||||
|
UsersActions.banUser({
|
||||||
|
userId: user.id,
|
||||||
|
reason: this.banReason || undefined,
|
||||||
|
expiresAt,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.closeBanDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
lucideChevronLeft,
|
lucideChevronLeft,
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { ChatMessagesComponent } from '../../chat/chat-messages.component';
|
import { ChatMessagesComponent } from '../../chat/chat-messages/chat-messages.component';
|
||||||
import { UserListComponent } from '../../chat/user-list.component';
|
import { UserListComponent } from '../../chat/user-list/user-list.component';
|
||||||
import { ScreenShareViewerComponent } from '../../voice/screen-share-viewer/screen-share-viewer.component';
|
import { ScreenShareViewerComponent } from '../../voice/screen-share-viewer/screen-share-viewer.component';
|
||||||
import { AdminPanelComponent } from '../../admin/admin-panel/admin-panel.component';
|
import { AdminPanelComponent } from '../../admin/admin-panel/admin-panel.component';
|
||||||
import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.component';
|
import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.component';
|
||||||
@@ -46,6 +46,9 @@ type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
|
|||||||
],
|
],
|
||||||
templateUrl: './chat-room.component.html',
|
templateUrl: './chat-room.component.html',
|
||||||
})
|
})
|
||||||
|
/**
|
||||||
|
* Main chat room view combining the messages panel, side panels, and admin controls.
|
||||||
|
*/
|
||||||
export class ChatRoomComponent {
|
export class ChatRoomComponent {
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
@@ -57,13 +60,15 @@ export class ChatRoomComponent {
|
|||||||
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||||
textChannels = this.store.selectSignal(selectTextChannels);
|
textChannels = this.store.selectSignal(selectTextChannels);
|
||||||
|
|
||||||
|
/** Returns the display name of the currently active text channel. */
|
||||||
get activeChannelName(): string {
|
get activeChannelName(): string {
|
||||||
const id = this.activeChannelId();
|
const id = this.activeChannelId();
|
||||||
const ch = this.textChannels().find(c => c.id === id);
|
const activeChannel = this.textChannels().find(channel => channel.id === id);
|
||||||
return ch ? ch.name : id;
|
return activeChannel ? activeChannel.name : id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggle the admin panel sidebar visibility. */
|
||||||
toggleAdminPanel() {
|
toggleAdminPanel() {
|
||||||
this.showAdminPanel.update(v => !v);
|
this.showAdminPanel.update((current) => !current);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,25 +126,12 @@
|
|||||||
<div class="ml-5 mt-1 space-y-1">
|
<div class="ml-5 mt-1 space-y-1">
|
||||||
@for (u of voiceUsersInRoom(ch.id); track u.id) {
|
@for (u of voiceUsersInRoom(ch.id); track u.id) {
|
||||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
|
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
|
||||||
@if (u.avatarUrl) {
|
<app-user-avatar
|
||||||
<img
|
[name]="u.displayName"
|
||||||
[src]="u.avatarUrl"
|
[avatarUrl]="u.avatarUrl"
|
||||||
alt=""
|
size="xs"
|
||||||
class="w-7 h-7 rounded-full ring-2 object-cover"
|
[ringClass]="u.voiceState?.isDeafened ? 'ring-2 ring-red-500' : u.voiceState?.isMuted ? 'ring-2 ring-yellow-500' : 'ring-2 ring-green-500'"
|
||||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
/>
|
||||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
|
||||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
|
||||||
/>
|
|
||||||
} @else {
|
|
||||||
<div
|
|
||||||
class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-medium ring-2"
|
|
||||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
|
||||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
|
||||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
|
||||||
>
|
|
||||||
{{ u.displayName.charAt(0).toUpperCase() }}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||||
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
|
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
|
||||||
<button
|
<button
|
||||||
@@ -177,13 +164,7 @@
|
|||||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">You</h4>
|
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">You</h4>
|
||||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded bg-secondary/30">
|
<div class="flex items-center gap-2 px-2 py-1.5 rounded bg-secondary/30">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@if (currentUser()?.avatarUrl) {
|
<app-user-avatar [name]="currentUser()?.displayName || '?'" [avatarUrl]="currentUser()?.avatarUrl" size="sm" />
|
||||||
<img [src]="currentUser()?.avatarUrl" alt="" class="w-8 h-8 rounded-full object-cover" />
|
|
||||||
} @else {
|
|
||||||
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary text-sm font-medium">
|
|
||||||
{{ currentUser()?.displayName?.charAt(0)?.toUpperCase() || '?' }}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
|
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
@@ -220,13 +201,7 @@
|
|||||||
(contextmenu)="openUserContextMenu($event, user)"
|
(contextmenu)="openUserContextMenu($event, user)"
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@if (user.avatarUrl) {
|
<app-user-avatar [name]="user.displayName" [avatarUrl]="user.avatarUrl" size="sm" />
|
||||||
<img [src]="user.avatarUrl" alt="" class="w-8 h-8 rounded-full object-cover" />
|
|
||||||
} @else {
|
|
||||||
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary text-sm font-medium">
|
|
||||||
{{ user.displayName.charAt(0).toUpperCase() }}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
|
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
@@ -283,77 +258,53 @@
|
|||||||
|
|
||||||
<!-- Channel context menu -->
|
<!-- Channel context menu -->
|
||||||
@if (showChannelMenu()) {
|
@if (showChannelMenu()) {
|
||||||
<div class="fixed inset-0 z-40" (click)="closeChannelMenu()"></div>
|
<app-context-menu [x]="channelMenuX()" [y]="channelMenuY()" (closed)="closeChannelMenu()" [width]="'w-44'">
|
||||||
<div class="fixed z-50 bg-card border border-border rounded-lg shadow-lg w-44 py-1" [style.left.px]="channelMenuX()" [style.top.px]="channelMenuY()">
|
<button (click)="resyncMessages()" class="context-menu-item">Resync Messages</button>
|
||||||
<button (click)="resyncMessages()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
|
|
||||||
Resync Messages
|
|
||||||
</button>
|
|
||||||
@if (canManageChannels()) {
|
@if (canManageChannels()) {
|
||||||
<div class="border-t border-border my-1"></div>
|
<div class="context-menu-divider"></div>
|
||||||
<button (click)="startRename()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
|
<button (click)="startRename()" class="context-menu-item">Rename Channel</button>
|
||||||
Rename Channel
|
<button (click)="deleteChannel()" class="context-menu-item-danger">Delete Channel</button>
|
||||||
</button>
|
|
||||||
<button (click)="deleteChannel()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-destructive">
|
|
||||||
Delete Channel
|
|
||||||
</button>
|
|
||||||
}
|
}
|
||||||
</div>
|
</app-context-menu>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- User context menu (kick / role management) -->
|
<!-- User context menu (kick / role management) -->
|
||||||
@if (showUserMenu()) {
|
@if (showUserMenu()) {
|
||||||
<div class="fixed inset-0 z-40" (click)="closeUserMenu()"></div>
|
<app-context-menu [x]="userMenuX()" [y]="userMenuY()" (closed)="closeUserMenu()">
|
||||||
<div class="fixed z-50 bg-card border border-border rounded-lg shadow-lg w-48 py-1" [style.left.px]="userMenuX()" [style.top.px]="userMenuY()">
|
|
||||||
@if (isAdmin()) {
|
@if (isAdmin()) {
|
||||||
<!-- Role management -->
|
|
||||||
@if (contextMenuUser()?.role === 'member') {
|
@if (contextMenuUser()?.role === 'member') {
|
||||||
<button (click)="changeUserRole('moderator')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
|
<button (click)="changeUserRole('moderator')" class="context-menu-item">Promote to Moderator</button>
|
||||||
Promote to Moderator
|
<button (click)="changeUserRole('admin')" class="context-menu-item">Promote to Admin</button>
|
||||||
</button>
|
|
||||||
<button (click)="changeUserRole('admin')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
|
|
||||||
Promote to Admin
|
|
||||||
</button>
|
|
||||||
}
|
}
|
||||||
@if (contextMenuUser()?.role === 'moderator') {
|
@if (contextMenuUser()?.role === 'moderator') {
|
||||||
<button (click)="changeUserRole('admin')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
|
<button (click)="changeUserRole('admin')" class="context-menu-item">Promote to Admin</button>
|
||||||
Promote to Admin
|
<button (click)="changeUserRole('member')" class="context-menu-item">Demote to Member</button>
|
||||||
</button>
|
|
||||||
<button (click)="changeUserRole('member')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
|
|
||||||
Demote to Member
|
|
||||||
</button>
|
|
||||||
}
|
}
|
||||||
@if (contextMenuUser()?.role === 'admin') {
|
@if (contextMenuUser()?.role === 'admin') {
|
||||||
<button (click)="changeUserRole('member')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
|
<button (click)="changeUserRole('member')" class="context-menu-item">Demote to Member</button>
|
||||||
Demote to Member
|
|
||||||
</button>
|
|
||||||
}
|
}
|
||||||
<div class="border-t border-border my-1"></div>
|
<div class="context-menu-divider"></div>
|
||||||
<button (click)="kickUserAction()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-destructive">
|
<button (click)="kickUserAction()" class="context-menu-item-danger">Kick User</button>
|
||||||
Kick User
|
|
||||||
</button>
|
|
||||||
} @else {
|
} @else {
|
||||||
<div class="px-3 py-1.5 text-sm text-muted-foreground">No actions available</div>
|
<div class="context-menu-empty">No actions available</div>
|
||||||
}
|
}
|
||||||
</div>
|
</app-context-menu>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Create channel dialog -->
|
<!-- Create channel dialog -->
|
||||||
@if (showCreateChannelDialog()) {
|
@if (showCreateChannelDialog()) {
|
||||||
<div class="fixed inset-0 z-40 bg-black/30" (click)="cancelCreateChannel()"></div>
|
<app-confirm-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 w-[320px]">
|
[title]="'Create ' + (createChannelType() === 'text' ? 'Text' : 'Voice') + ' Channel'"
|
||||||
<div class="p-4">
|
confirmLabel="Create"
|
||||||
<h4 class="font-semibold text-foreground mb-3">Create {{ createChannelType() === 'text' ? 'Text' : 'Voice' }} Channel</h4>
|
(confirmed)="confirmCreateChannel()"
|
||||||
<input
|
(cancelled)="cancelCreateChannel()"
|
||||||
type="text"
|
>
|
||||||
[(ngModel)]="newChannelName"
|
<input
|
||||||
placeholder="Channel name"
|
type="text"
|
||||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary text-sm"
|
[(ngModel)]="newChannelName"
|
||||||
(keydown.enter)="confirmCreateChannel()"
|
placeholder="Channel name"
|
||||||
/>
|
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary text-sm"
|
||||||
</div>
|
(keydown.enter)="confirmCreateChannel()"
|
||||||
<div class="flex gap-2 p-3 border-t border-border">
|
/>
|
||||||
<button (click)="cancelCreateChannel()" class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm">Cancel</button>
|
</app-confirm-dialog>
|
||||||
<button (click)="confirmCreateChannel()" class="flex-1 px-3 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors text-sm">Create</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
|
|||||||
import { lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers, lucidePlus } from '@ng-icons/lucide';
|
import { lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers, lucidePlus } from '@ng-icons/lucide';
|
||||||
import { selectOnlineUsers, selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
import { selectOnlineUsers, selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||||
import { selectCurrentRoom, selectActiveChannelId, selectTextChannels, selectVoiceChannels } from '../../../store/rooms/rooms.selectors';
|
import { selectCurrentRoom, selectActiveChannelId, selectTextChannels, selectVoiceChannels } from '../../../store/rooms/rooms.selectors';
|
||||||
import * as UsersActions from '../../../store/users/users.actions';
|
import { UsersActions } from '../../../store/users/users.actions';
|
||||||
import * as RoomsActions from '../../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||||
import * as MessagesActions from '../../../store/messages/messages.actions';
|
import { MessagesActions } from '../../../store/messages/messages.actions';
|
||||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||||
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
||||||
import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component';
|
import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component';
|
||||||
|
import { ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||||
import { Channel, User } from '../../../core/models';
|
import { Channel, User } from '../../../core/models';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
@@ -20,12 +21,15 @@ type TabView = 'channels' | 'users';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-rooms-side-panel',
|
selector: 'app-rooms-side-panel',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, NgIcon, VoiceControlsComponent],
|
imports: [CommonModule, FormsModule, NgIcon, VoiceControlsComponent, ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers, lucidePlus })
|
provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers, lucidePlus })
|
||||||
],
|
],
|
||||||
templateUrl: './rooms-side-panel.component.html',
|
templateUrl: './rooms-side-panel.component.html',
|
||||||
})
|
})
|
||||||
|
/**
|
||||||
|
* Side panel listing text and voice channels, online users, and channel management actions.
|
||||||
|
*/
|
||||||
export class RoomsSidePanelComponent {
|
export class RoomsSidePanelComponent {
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private webrtc = inject(WebRTCService);
|
private webrtc = inject(WebRTCService);
|
||||||
@@ -61,14 +65,16 @@ export class RoomsSidePanelComponent {
|
|||||||
userMenuY = signal(0);
|
userMenuY = signal(0);
|
||||||
contextMenuUser = signal<User | null>(null);
|
contextMenuUser = signal<User | null>(null);
|
||||||
|
|
||||||
|
/** Return online users excluding the current user. */
|
||||||
// Filter out current user from online users list
|
// Filter out current user from online users list
|
||||||
onlineUsersFiltered() {
|
onlineUsersFiltered() {
|
||||||
const current = this.currentUser();
|
const current = this.currentUser();
|
||||||
const currentId = current?.id;
|
const currentId = current?.id;
|
||||||
const currentOderId = current?.oderId;
|
const currentOderId = current?.oderId;
|
||||||
return this.onlineUsers().filter(u => u.id !== currentId && u.oderId !== currentOderId);
|
return this.onlineUsers().filter(user => user.id !== currentId && user.oderId !== currentOderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Check whether the current user has permission to manage channels. */
|
||||||
canManageChannels(): boolean {
|
canManageChannels(): boolean {
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
@@ -81,12 +87,14 @@ export class RoomsSidePanelComponent {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Select a text channel (no-op if currently renaming). */
|
||||||
// ---- Text channel selection ----
|
// ---- Text channel selection ----
|
||||||
selectTextChannel(channelId: string) {
|
selectTextChannel(channelId: string) {
|
||||||
if (this.renamingChannelId()) return; // don't switch while renaming
|
if (this.renamingChannelId()) return; // don't switch while renaming
|
||||||
this.store.dispatch(RoomsActions.selectChannel({ channelId }));
|
this.store.dispatch(RoomsActions.selectChannel({ channelId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Open the context menu for a channel at the cursor position. */
|
||||||
// ---- Channel context menu ----
|
// ---- Channel context menu ----
|
||||||
openChannelContextMenu(evt: MouseEvent, channel: Channel) {
|
openChannelContextMenu(evt: MouseEvent, channel: Channel) {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
@@ -96,10 +104,12 @@ export class RoomsSidePanelComponent {
|
|||||||
this.showChannelMenu.set(true);
|
this.showChannelMenu.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Close the channel context menu. */
|
||||||
closeChannelMenu() {
|
closeChannelMenu() {
|
||||||
this.showChannelMenu.set(false);
|
this.showChannelMenu.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Begin inline renaming of the context-menu channel. */
|
||||||
startRename() {
|
startRename() {
|
||||||
const ch = this.contextChannel();
|
const ch = this.contextChannel();
|
||||||
this.closeChannelMenu();
|
this.closeChannelMenu();
|
||||||
@@ -108,6 +118,7 @@ export class RoomsSidePanelComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Commit the channel rename from the inline input value. */
|
||||||
confirmRename(event: Event) {
|
confirmRename(event: Event) {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const name = input.value.trim();
|
const name = input.value.trim();
|
||||||
@@ -118,10 +129,12 @@ export class RoomsSidePanelComponent {
|
|||||||
this.renamingChannelId.set(null);
|
this.renamingChannelId.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cancel the inline rename operation. */
|
||||||
cancelRename() {
|
cancelRename() {
|
||||||
this.renamingChannelId.set(null);
|
this.renamingChannelId.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete the context-menu channel. */
|
||||||
deleteChannel() {
|
deleteChannel() {
|
||||||
const ch = this.contextChannel();
|
const ch = this.contextChannel();
|
||||||
this.closeChannelMenu();
|
this.closeChannelMenu();
|
||||||
@@ -130,11 +143,11 @@ export class RoomsSidePanelComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Trigger a message inventory re-sync from all connected peers. */
|
||||||
resyncMessages() {
|
resyncMessages() {
|
||||||
this.closeChannelMenu();
|
this.closeChannelMenu();
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
if (!room) {
|
if (!room) {
|
||||||
console.warn('[Resync] No current room');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,19 +156,19 @@ export class RoomsSidePanelComponent {
|
|||||||
|
|
||||||
// Request inventory from all connected peers
|
// Request inventory from all connected peers
|
||||||
const peers = this.webrtc.getConnectedPeers();
|
const peers = this.webrtc.getConnectedPeers();
|
||||||
console.log(`[Resync] Requesting inventory from ${peers.length} peer(s) for room ${room.id}`);
|
|
||||||
if (peers.length === 0) {
|
if (peers.length === 0) {
|
||||||
console.warn('[Resync] No connected peers — sync will time out');
|
// No connected peers — sync will time out
|
||||||
}
|
}
|
||||||
peers.forEach((pid) => {
|
peers.forEach((pid) => {
|
||||||
try {
|
try {
|
||||||
this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: room.id } as any);
|
this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: room.id } as any);
|
||||||
} catch (e) {
|
} catch (_error) {
|
||||||
console.error(`[Resync] Failed to send to peer ${pid}:`, e);
|
// Failed to send inventory request to this peer
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Open the create-channel dialog for the given channel type. */
|
||||||
// ---- Create channel ----
|
// ---- Create channel ----
|
||||||
createChannel(type: 'text' | 'voice') {
|
createChannel(type: 'text' | 'voice') {
|
||||||
this.createChannelType.set(type);
|
this.createChannelType.set(type);
|
||||||
@@ -163,6 +176,7 @@ export class RoomsSidePanelComponent {
|
|||||||
this.showCreateChannelDialog.set(true);
|
this.showCreateChannelDialog.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Confirm channel creation and dispatch the add-channel action. */
|
||||||
confirmCreateChannel() {
|
confirmCreateChannel() {
|
||||||
const name = this.newChannelName.trim();
|
const name = this.newChannelName.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
@@ -178,10 +192,12 @@ export class RoomsSidePanelComponent {
|
|||||||
this.showCreateChannelDialog.set(false);
|
this.showCreateChannelDialog.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cancel channel creation and close the dialog. */
|
||||||
cancelCreateChannel() {
|
cancelCreateChannel() {
|
||||||
this.showCreateChannelDialog.set(false);
|
this.showCreateChannelDialog.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Open the user context menu for admin actions (kick/role change). */
|
||||||
// ---- User context menu (kick/role) ----
|
// ---- User context menu (kick/role) ----
|
||||||
openUserContextMenu(evt: MouseEvent, user: User) {
|
openUserContextMenu(evt: MouseEvent, user: User) {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
@@ -192,10 +208,12 @@ export class RoomsSidePanelComponent {
|
|||||||
this.showUserMenu.set(true);
|
this.showUserMenu.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Close the user context menu. */
|
||||||
closeUserMenu() {
|
closeUserMenu() {
|
||||||
this.showUserMenu.set(false);
|
this.showUserMenu.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Change a user's role and broadcast the update to connected peers. */
|
||||||
changeUserRole(role: 'admin' | 'moderator' | 'member') {
|
changeUserRole(role: 'admin' | 'moderator' | 'member') {
|
||||||
const user = this.contextMenuUser();
|
const user = this.contextMenuUser();
|
||||||
this.closeUserMenu();
|
this.closeUserMenu();
|
||||||
@@ -210,6 +228,7 @@ export class RoomsSidePanelComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Kick a user and broadcast the action to peers. */
|
||||||
kickUserAction() {
|
kickUserAction() {
|
||||||
const user = this.contextMenuUser();
|
const user = this.contextMenuUser();
|
||||||
this.closeUserMenu();
|
this.closeUserMenu();
|
||||||
@@ -224,12 +243,13 @@ export class RoomsSidePanelComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Join a voice channel, managing permissions and existing voice connections. */
|
||||||
// ---- Voice ----
|
// ---- Voice ----
|
||||||
joinVoice(roomId: string) {
|
joinVoice(roomId: string) {
|
||||||
// Gate by room permissions
|
// Gate by room permissions
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
if (room && room.permissions && room.permissions.allowVoice === false) {
|
if (room && room.permissions && room.permissions.allowVoice === false) {
|
||||||
console.warn('Voice is disabled by room permissions');
|
// Voice is disabled by room permissions
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +268,7 @@ export class RoomsSidePanelComponent {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('Already connected to voice in another server. Disconnect first before joining.');
|
// Already connected to voice in another server; must disconnect first
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,8 +300,8 @@ export class RoomsSidePanelComponent {
|
|||||||
// Update voice session for floating controls
|
// Update voice session for floating controls
|
||||||
if (room) {
|
if (room) {
|
||||||
// Find label from channel list
|
// Find label from channel list
|
||||||
const vc = this.voiceChannels().find(c => c.id === roomId);
|
const voiceChannel = this.voiceChannels().find(channel => channel.id === roomId);
|
||||||
const voiceRoomName = vc ? `🔊 ${vc.name}` : roomId;
|
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
|
||||||
this.voiceSessionService.startSession({
|
this.voiceSessionService.startSession({
|
||||||
serverId: room.id,
|
serverId: room.id,
|
||||||
serverName: room.name,
|
serverName: room.name,
|
||||||
@@ -292,9 +312,12 @@ export class RoomsSidePanelComponent {
|
|||||||
serverRoute: `/room/${room.id}`,
|
serverRoute: `/room/${room.id}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}).catch((e) => console.error('Failed to join voice room', roomId, e));
|
}).catch((_error) => {
|
||||||
|
// Failed to join voice room
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Leave a voice channel and broadcast the disconnect state. */
|
||||||
leaveVoice(roomId: string) {
|
leaveVoice(roomId: string) {
|
||||||
const current = this.currentUser();
|
const current = this.currentUser();
|
||||||
// Only leave if currently in this room
|
// Only leave if currently in this room
|
||||||
@@ -326,32 +349,36 @@ export class RoomsSidePanelComponent {
|
|||||||
this.voiceSessionService.endSession();
|
this.voiceSessionService.endSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Count the number of users connected to a voice channel in the current room. */
|
||||||
voiceOccupancy(roomId: string): number {
|
voiceOccupancy(roomId: string): number {
|
||||||
const users = this.onlineUsers();
|
const users = this.onlineUsers();
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
return users.filter(u =>
|
return users.filter(user =>
|
||||||
!!u.voiceState?.isConnected &&
|
!!user.voiceState?.isConnected &&
|
||||||
u.voiceState?.roomId === roomId &&
|
user.voiceState?.roomId === roomId &&
|
||||||
u.voiceState?.serverId === room?.id
|
user.voiceState?.serverId === room?.id
|
||||||
).length;
|
).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dispatch a viewer:focus event to display a remote user's screen share. */
|
||||||
viewShare(userId: string) {
|
viewShare(userId: string) {
|
||||||
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
||||||
window.dispatchEvent(evt);
|
window.dispatchEvent(evt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dispatch a viewer:focus event to display a remote user's stream. */
|
||||||
viewStream(userId: string) {
|
viewStream(userId: string) {
|
||||||
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
||||||
window.dispatchEvent(evt);
|
window.dispatchEvent(evt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Check whether a user is currently sharing their screen. */
|
||||||
isUserSharing(userId: string): boolean {
|
isUserSharing(userId: string): boolean {
|
||||||
const me = this.currentUser();
|
const me = this.currentUser();
|
||||||
if (me?.id === userId) {
|
if (me?.id === userId) {
|
||||||
return this.webrtc.isScreenSharing();
|
return this.webrtc.isScreenSharing();
|
||||||
}
|
}
|
||||||
const user = this.onlineUsers().find(u => u.id === userId || u.oderId === userId);
|
const user = this.onlineUsers().find(onlineUser => onlineUser.id === userId || onlineUser.oderId === userId);
|
||||||
if (user?.screenShareState?.isSharing === false) {
|
if (user?.screenShareState?.isSharing === false) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -359,15 +386,17 @@ export class RoomsSidePanelComponent {
|
|||||||
return !!stream && stream.getVideoTracks().length > 0;
|
return !!stream && stream.getVideoTracks().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return all users currently connected to a specific voice channel. */
|
||||||
voiceUsersInRoom(roomId: string) {
|
voiceUsersInRoom(roomId: string) {
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
return this.onlineUsers().filter(u =>
|
return this.onlineUsers().filter(user =>
|
||||||
!!u.voiceState?.isConnected &&
|
!!user.voiceState?.isConnected &&
|
||||||
u.voiceState?.roomId === roomId &&
|
user.voiceState?.roomId === roomId &&
|
||||||
u.voiceState?.serverId === room?.id
|
user.voiceState?.serverId === room?.id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Check whether the current user is connected to the specified voice channel. */
|
||||||
isCurrentRoom(roomId: string): boolean {
|
isCurrentRoom(roomId: string): boolean {
|
||||||
const me = this.currentUser();
|
const me = this.currentUser();
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
@@ -378,6 +407,7 @@ export class RoomsSidePanelComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Check whether voice is enabled by the current room's permissions. */
|
||||||
voiceEnabled(): boolean {
|
voiceEnabled(): boolean {
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
return room?.permissions?.allowVoice !== false;
|
return room?.permissions?.allowVoice !== false;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
lucideSettings,
|
lucideSettings,
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import * as RoomsActions from '../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||||
import {
|
import {
|
||||||
selectSearchResults,
|
selectSearchResults,
|
||||||
selectIsSearching,
|
selectIsSearching,
|
||||||
@@ -33,6 +33,10 @@ import { ServerInfo } from '../../core/models';
|
|||||||
],
|
],
|
||||||
templateUrl: './server-search.component.html',
|
templateUrl: './server-search.component.html',
|
||||||
})
|
})
|
||||||
|
/**
|
||||||
|
* Server search and discovery view with server creation dialog.
|
||||||
|
* Allows users to search for, join, and create new servers.
|
||||||
|
*/
|
||||||
export class ServerSearchComponent implements OnInit {
|
export class ServerSearchComponent implements OnInit {
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
@@ -52,6 +56,7 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
newServerPrivate = signal(false);
|
newServerPrivate = signal(false);
|
||||||
newServerPassword = signal('');
|
newServerPassword = signal('');
|
||||||
|
|
||||||
|
/** Initialize server search, load saved rooms, and set up debounced search. */
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// Initial load
|
// Initial load
|
||||||
this.store.dispatch(RoomsActions.searchServers({ query: '' }));
|
this.store.dispatch(RoomsActions.searchServers({ query: '' }));
|
||||||
@@ -65,10 +70,12 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Emit a search query to the debounced search subject. */
|
||||||
onSearchChange(query: string): void {
|
onSearchChange(query: string): void {
|
||||||
this.searchSubject.next(query);
|
this.searchSubject.next(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Join a server from the search results. Redirects to login if unauthenticated. */
|
||||||
joinServer(server: ServerInfo): void {
|
joinServer(server: ServerInfo): void {
|
||||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||||
if (!currentUserId) {
|
if (!currentUserId) {
|
||||||
@@ -85,15 +92,18 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Open the create-server dialog. */
|
||||||
openCreateDialog(): void {
|
openCreateDialog(): void {
|
||||||
this.showCreateDialog.set(true);
|
this.showCreateDialog.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Close the create-server dialog and reset the form. */
|
||||||
closeCreateDialog(): void {
|
closeCreateDialog(): void {
|
||||||
this.showCreateDialog.set(false);
|
this.showCreateDialog.set(false);
|
||||||
this.resetCreateForm();
|
this.resetCreateForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Submit the new server creation form and dispatch the create action. */
|
||||||
createServer(): void {
|
createServer(): void {
|
||||||
if (!this.newServerName()) return;
|
if (!this.newServerName()) return;
|
||||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||||
@@ -115,10 +125,12 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
this.closeCreateDialog();
|
this.closeCreateDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Navigate to the application settings page. */
|
||||||
openSettings(): void {
|
openSettings(): void {
|
||||||
this.router.navigate(['/settings']);
|
this.router.navigate(['/settings']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Join a previously saved room by converting it to a ServerInfo payload. */
|
||||||
joinSavedRoom(room: Room): void {
|
joinSavedRoom(room: Room): void {
|
||||||
this.joinServer({
|
this.joinServer({
|
||||||
id: room.id,
|
id: room.id,
|
||||||
|
|||||||
@@ -10,48 +10,44 @@
|
|||||||
|
|
||||||
<!-- Saved servers icons -->
|
<!-- Saved servers icons -->
|
||||||
<div class="flex-1 w-full overflow-y-auto flex flex-col items-center gap-2 mt-2">
|
<div class="flex-1 w-full overflow-y-auto flex flex-col items-center gap-2 mt-2">
|
||||||
<ng-container *ngFor="let room of savedRooms(); trackBy: trackRoomId">
|
@for (room of savedRooms(); track room.id) {
|
||||||
<button
|
<button
|
||||||
class="w-10 h-10 flex-shrink-0 rounded-2xl overflow-hidden border border-border hover:border-primary/60 hover:shadow-sm transition-all"
|
class="w-10 h-10 flex-shrink-0 rounded-2xl overflow-hidden border border-border hover:border-primary/60 hover:shadow-sm transition-all"
|
||||||
[title]="room.name"
|
[title]="room.name"
|
||||||
(click)="joinSavedRoom(room)"
|
(click)="joinSavedRoom(room)"
|
||||||
(contextmenu)="openContextMenu($event, room)"
|
(contextmenu)="openContextMenu($event, room)"
|
||||||
>
|
>
|
||||||
<ng-container *ngIf="room.icon; else noIcon">
|
@if (room.icon) {
|
||||||
<img [src]="room.icon" [alt]="room.name" class="w-full h-full object-cover" />
|
<img [src]="room.icon" [alt]="room.name" class="w-full h-full object-cover" />
|
||||||
</ng-container>
|
} @else {
|
||||||
<ng-template #noIcon>
|
|
||||||
<div class="w-full h-full flex items-center justify-center bg-secondary">
|
<div class="w-full h-full flex items-center justify-center bg-secondary">
|
||||||
<span class="text-sm font-semibold text-muted-foreground">{{ initial(room.name) }}</span>
|
<span class="text-sm font-semibold text-muted-foreground">{{ initial(room.name) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
}
|
||||||
</button>
|
</button>
|
||||||
</ng-container>
|
}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Context menu -->
|
<!-- Context menu -->
|
||||||
<div *ngIf="showMenu()" class="">
|
@if (showMenu()) {
|
||||||
<div class="fixed inset-0 z-40" (click)="closeMenu()"></div>
|
<app-context-menu [x]="menuX()" [y]="menuY()" (closed)="closeMenu()" [width]="'w-44'">
|
||||||
<div class="fixed z-50 bg-card border border-border rounded-lg shadow-md w-44" [style.left.px]="menuX()" [style.top.px]="menuY()">
|
@if (isCurrentContextRoom()) {
|
||||||
<button *ngIf="isCurrentContextRoom()" (click)="leaveServer()" class="w-full text-left px-3 py-2 hover:bg-secondary transition-colors text-foreground">Leave Server</button>
|
<button (click)="leaveServer()" class="context-menu-item">Leave Server</button>
|
||||||
<button (click)="openForgetConfirm()" class="w-full text-left px-3 py-2 hover:bg-secondary transition-colors text-foreground">Forget Server</button>
|
}
|
||||||
</div>
|
<button (click)="openForgetConfirm()" class="context-menu-item">Forget Server</button>
|
||||||
</div>
|
</app-context-menu>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Forget confirmation dialog -->
|
<!-- Forget confirmation dialog -->
|
||||||
<div *ngIf="showConfirm()">
|
@if (showConfirm()) {
|
||||||
<div class="fixed inset-0 z-40 bg-black/30" (click)="cancelForget()"></div>
|
<app-confirm-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 w-[280px]">
|
title="Forget Server?"
|
||||||
<div class="p-4">
|
confirmLabel="Forget"
|
||||||
<h4 class="font-semibold text-foreground mb-2">Forget Server?</h4>
|
(confirmed)="confirmForget()"
|
||||||
<p class="text-sm text-muted-foreground">
|
(cancelled)="cancelForget()"
|
||||||
Remove <span class="font-medium text-foreground">{{ contextRoom()?.name }}</span> from your My Servers list.
|
[widthClass]="'w-[280px]'"
|
||||||
</p>
|
>
|
||||||
</div>
|
<p>Remove <span class="font-medium text-foreground">{{ contextRoom()?.name }}</span> from your My Servers list.</p>
|
||||||
<div class="flex gap-2 p-3 border-t border-border">
|
</app-confirm-dialog>
|
||||||
<button (click)="cancelForget()" class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors">Cancel</button>
|
}
|
||||||
<button (click)="confirmForget()" class="flex-1 px-3 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors">Forget</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -9,15 +9,19 @@ import { Room } from '../../core/models';
|
|||||||
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||||
import { VoiceSessionService } from '../../core/services/voice-session.service';
|
import { VoiceSessionService } from '../../core/services/voice-session.service';
|
||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
import * as RoomsActions from '../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||||
|
import { ContextMenuComponent, ConfirmDialogComponent } from '../../shared';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-servers-rail',
|
selector: 'app-servers-rail',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, NgIcon],
|
imports: [CommonModule, NgIcon, ContextMenuComponent, ConfirmDialogComponent],
|
||||||
viewProviders: [provideIcons({ lucidePlus })],
|
viewProviders: [provideIcons({ lucidePlus })],
|
||||||
templateUrl: './servers-rail.component.html',
|
templateUrl: './servers-rail.component.html',
|
||||||
})
|
})
|
||||||
|
/**
|
||||||
|
* Vertical rail of saved server icons with context-menu actions for leaving/forgetting.
|
||||||
|
*/
|
||||||
export class ServersRailComponent {
|
export class ServersRailComponent {
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
@@ -28,12 +32,13 @@ export class ServersRailComponent {
|
|||||||
|
|
||||||
// Context menu state
|
// Context menu state
|
||||||
showMenu = signal(false);
|
showMenu = signal(false);
|
||||||
menuX = signal(72); // rail width (~64px) + padding, position menu to the right
|
menuX = signal(72); // default X: rail width (~64px) + padding
|
||||||
menuY = signal(100);
|
menuY = signal(100); // default Y: arbitrary initial offset
|
||||||
contextRoom = signal<Room | null>(null);
|
contextRoom = signal<Room | null>(null);
|
||||||
// Confirmation dialog state
|
// Confirmation dialog state
|
||||||
showConfirm = signal(false);
|
showConfirm = signal(false);
|
||||||
|
|
||||||
|
/** Return the first character of a server name as its icon initial. */
|
||||||
initial(name?: string): string {
|
initial(name?: string): string {
|
||||||
if (!name) return '?';
|
if (!name) return '?';
|
||||||
const ch = name.trim()[0]?.toUpperCase();
|
const ch = name.trim()[0]?.toUpperCase();
|
||||||
@@ -42,6 +47,7 @@ export class ServersRailComponent {
|
|||||||
|
|
||||||
trackRoomId = (index: number, room: Room) => room.id;
|
trackRoomId = (index: number, room: Room) => room.id;
|
||||||
|
|
||||||
|
/** Navigate to the server search view. Updates voice session state if applicable. */
|
||||||
createServer(): void {
|
createServer(): void {
|
||||||
// Navigate to server list (has create button)
|
// Navigate to server list (has create button)
|
||||||
// Update voice session state if connected to voice
|
// Update voice session state if connected to voice
|
||||||
@@ -52,6 +58,7 @@ export class ServersRailComponent {
|
|||||||
this.router.navigate(['/search']);
|
this.router.navigate(['/search']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Join or switch to a saved room. Manages voice session and authentication state. */
|
||||||
joinSavedRoom(room: Room): void {
|
joinSavedRoom(room: Room): void {
|
||||||
// Require auth: if no current user, go to login
|
// Require auth: if no current user, go to login
|
||||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||||
@@ -88,37 +95,43 @@ export class ServersRailComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Open the context menu positioned near the cursor for a given room. */
|
||||||
openContextMenu(evt: MouseEvent, room: Room): void {
|
openContextMenu(evt: MouseEvent, room: Room): void {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
this.contextRoom.set(room);
|
this.contextRoom.set(room);
|
||||||
// Position menu slightly to the right of cursor to avoid overlapping the rail
|
// Offset 8px right to avoid overlapping the rail; floor at rail width (72px)
|
||||||
this.menuX.set(Math.max((evt.clientX + 8), 72));
|
this.menuX.set(Math.max((evt.clientX + 8), 72));
|
||||||
this.menuY.set(evt.clientY);
|
this.menuY.set(evt.clientY);
|
||||||
this.showMenu.set(true);
|
this.showMenu.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Close the context menu (keeps contextRoom for potential confirmation). */
|
||||||
closeMenu(): void {
|
closeMenu(): void {
|
||||||
this.showMenu.set(false);
|
this.showMenu.set(false);
|
||||||
// keep contextRoom for potential confirmation dialog
|
// keep contextRoom for potential confirmation dialog
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Check whether the context-menu room is the currently active room. */
|
||||||
isCurrentContextRoom(): boolean {
|
isCurrentContextRoom(): boolean {
|
||||||
const ctx = this.contextRoom();
|
const ctx = this.contextRoom();
|
||||||
const cur = this.currentRoom();
|
const cur = this.currentRoom();
|
||||||
return !!ctx && !!cur && ctx.id === cur.id;
|
return !!ctx && !!cur && ctx.id === cur.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Leave the current server and navigate to the servers list. */
|
||||||
leaveServer(): void {
|
leaveServer(): void {
|
||||||
this.closeMenu();
|
this.closeMenu();
|
||||||
this.store.dispatch(RoomsActions.leaveRoom());
|
this.store.dispatch(RoomsActions.leaveRoom());
|
||||||
window.dispatchEvent(new CustomEvent('navigate:servers'));
|
window.dispatchEvent(new CustomEvent('navigate:servers'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Show the forget-server confirmation dialog. */
|
||||||
openForgetConfirm(): void {
|
openForgetConfirm(): void {
|
||||||
this.showConfirm.set(true);
|
this.showConfirm.set(true);
|
||||||
this.closeMenu();
|
this.closeMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Forget (remove) a server from the saved list, leaving if it is the current room. */
|
||||||
confirmForget(): void {
|
confirmForget(): void {
|
||||||
const ctx = this.contextRoom();
|
const ctx = this.contextRoom();
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
@@ -131,6 +144,7 @@ export class ServersRailComponent {
|
|||||||
this.contextRoom.set(null);
|
this.contextRoom.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cancel the forget-server confirmation dialog. */
|
||||||
cancelForget(): void {
|
cancelForget(): void {
|
||||||
this.showConfirm.set(false);
|
this.showConfirm.set(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||||
|
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../core/constants';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-settings',
|
selector: 'app-settings',
|
||||||
@@ -36,6 +37,9 @@ import { ServerDirectoryService } from '../../core/services/server-directory.ser
|
|||||||
],
|
],
|
||||||
templateUrl: './settings.component.html',
|
templateUrl: './settings.component.html',
|
||||||
})
|
})
|
||||||
|
/**
|
||||||
|
* Settings page for managing signaling servers and connection preferences.
|
||||||
|
*/
|
||||||
export class SettingsComponent implements OnInit {
|
export class SettingsComponent implements OnInit {
|
||||||
private serverDirectory = inject(ServerDirectoryService);
|
private serverDirectory = inject(ServerDirectoryService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
@@ -49,10 +53,12 @@ export class SettingsComponent implements OnInit {
|
|||||||
autoReconnect = true;
|
autoReconnect = true;
|
||||||
searchAllServers = true;
|
searchAllServers = true;
|
||||||
|
|
||||||
|
/** Load persisted connection settings on component init. */
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadConnectionSettings();
|
this.loadConnectionSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Add a new signaling server after URL validation and duplicate checking. */
|
||||||
addServer(): void {
|
addServer(): void {
|
||||||
this.addError.set(null);
|
this.addError.set(null);
|
||||||
|
|
||||||
@@ -65,7 +71,7 @@ export class SettingsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicates
|
// Check for duplicates
|
||||||
if (this.servers().some((s) => s.url === this.newServerUrl)) {
|
if (this.servers().some((server) => server.url === this.newServerUrl)) {
|
||||||
this.addError.set('This server URL already exists');
|
this.addError.set('This server URL already exists');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -87,22 +93,26 @@ export class SettingsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove a signaling server by its ID. */
|
||||||
removeServer(id: string): void {
|
removeServer(id: string): void {
|
||||||
this.serverDirectory.removeServer(id);
|
this.serverDirectory.removeServer(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Set the active signaling server used for connections. */
|
||||||
setActiveServer(id: string): void {
|
setActiveServer(id: string): void {
|
||||||
this.serverDirectory.setActiveServer(id);
|
this.serverDirectory.setActiveServer(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Test connectivity to all configured servers. */
|
||||||
async testAllServers(): Promise<void> {
|
async testAllServers(): Promise<void> {
|
||||||
this.isTesting.set(true);
|
this.isTesting.set(true);
|
||||||
await this.serverDirectory.testAllServers();
|
await this.serverDirectory.testAllServers();
|
||||||
this.isTesting.set(false);
|
this.isTesting.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Load connection settings (auto-reconnect, search scope) from localStorage. */
|
||||||
loadConnectionSettings(): void {
|
loadConnectionSettings(): void {
|
||||||
const settings = localStorage.getItem('metoyou_connection_settings');
|
const settings = localStorage.getItem(STORAGE_KEY_CONNECTION_SETTINGS);
|
||||||
if (settings) {
|
if (settings) {
|
||||||
const parsed = JSON.parse(settings);
|
const parsed = JSON.parse(settings);
|
||||||
this.autoReconnect = parsed.autoReconnect ?? true;
|
this.autoReconnect = parsed.autoReconnect ?? true;
|
||||||
@@ -111,9 +121,10 @@ export class SettingsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persist current connection settings to localStorage. */
|
||||||
saveConnectionSettings(): void {
|
saveConnectionSettings(): void {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
'metoyou_connection_settings',
|
STORAGE_KEY_CONNECTION_SETTINGS,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
autoReconnect: this.autoReconnect,
|
autoReconnect: this.autoReconnect,
|
||||||
searchAllServers: this.searchAllServers,
|
searchAllServers: this.searchAllServers,
|
||||||
@@ -122,6 +133,7 @@ export class SettingsComponent implements OnInit {
|
|||||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Navigate back to the main page. */
|
||||||
goBack(): void {
|
goBack(): void {
|
||||||
this.router.navigate(['/']);
|
this.router.navigate(['/']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,75 @@
|
|||||||
<div class="fixed top-0 left-16 right-0 h-10 bg-card border-b border-border flex items-center justify-between px-4 z-50 select-none" style="-webkit-app-region: drag;">
|
<div
|
||||||
<div class="flex items-center gap-2 min-w-0 relative" style="-webkit-app-region: no-drag;">
|
class="fixed top-0 left-16 right-0 h-10 bg-card border-b border-border flex items-center justify-between px-4 z-50 select-none"
|
||||||
<button *ngIf="inRoom()" (click)="onBack()" class="p-2 hover:bg-secondary rounded" title="Back">
|
style="-webkit-app-region: drag;"
|
||||||
<ng-icon name="lucideChevronLeft" class="w-5 h-5 text-muted-foreground" />
|
>
|
||||||
</button>
|
<div class="flex items-center gap-2 min-w-0 relative" style="-webkit-app-region: no-drag;">
|
||||||
<ng-container *ngIf="inRoom(); else userServer">
|
@if (inRoom()) {
|
||||||
<ng-icon name="lucideHash" class="w-5 h-5 text-muted-foreground" />
|
<button (click)="onBack()" class="p-2 hover:bg-secondary rounded" title="Back">
|
||||||
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
|
<ng-icon name="lucideChevronLeft" class="w-5 h-5 text-muted-foreground" />
|
||||||
<span *ngIf="roomDescription()" class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">{{ roomDescription() }}</span>
|
</button>
|
||||||
<button (click)="toggleMenu()" class="ml-2 p-2 hover:bg-secondary rounded" title="Menu">
|
}
|
||||||
<ng-icon name="lucideMenu" class="w-5 h-5 text-muted-foreground" />
|
@if (inRoom()) {
|
||||||
</button>
|
<ng-icon name="lucideHash" class="w-5 h-5 text-muted-foreground" />
|
||||||
<!-- Anchored dropdown under the menu button -->
|
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
|
||||||
<div *ngIf="showMenu()" class="absolute right-0 top-full mt-1 z-50 bg-card border border-border rounded-lg shadow-lg w-48">
|
@if (roomDescription()) {
|
||||||
<button (click)="leaveServer()" class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground">Leave Server</button>
|
<span class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">
|
||||||
<div class="border-t border-border"></div>
|
{{ roomDescription() }}
|
||||||
<button (click)="logout()" class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground">Logout</button>
|
</span>
|
||||||
</div>
|
}
|
||||||
</ng-container>
|
<button (click)="toggleMenu()" class="ml-2 p-2 hover:bg-secondary rounded" title="Menu">
|
||||||
<ng-template #userServer>
|
<ng-icon name="lucideMenu" class="w-5 h-5 text-muted-foreground" />
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
</button>
|
||||||
<span class="text-sm text-muted-foreground truncate">{{ username() }} | {{ serverName() }}</span>
|
<!-- Anchored dropdown under the menu button -->
|
||||||
<span *ngIf="!isConnected()" class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive">Reconnecting…</span>
|
@if (showMenu()) {
|
||||||
</div>
|
<div class="absolute right-0 top-full mt-1 z-50 bg-card border border-border rounded-lg shadow-lg w-48">
|
||||||
</ng-template>
|
<button
|
||||||
</div>
|
(click)="leaveServer()"
|
||||||
<div class="flex items-center gap-2" style="-webkit-app-region: no-drag;">
|
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground"
|
||||||
<button *ngIf="!isAuthed()" class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground" (click)="goLogin()" title="Login">Login</button>
|
>
|
||||||
<button class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Minimize" (click)="minimize()">
|
Leave Server
|
||||||
<ng-icon name="lucideMinus" class="w-4 h-4" />
|
</button>
|
||||||
</button>
|
<div class="border-t border-border"></div>
|
||||||
<button class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Maximize" (click)="maximize()">
|
<button
|
||||||
<ng-icon name="lucideSquare" class="w-4 h-4" />
|
(click)="logout()"
|
||||||
</button>
|
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground"
|
||||||
<button class="w-8 h-8 grid place-items-center hover:bg-destructive/10 rounded" title="Close" (click)="close()">
|
>
|
||||||
<ng-icon name="lucideX" class="w-4 h-4 text-destructive" />
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<span class="text-sm text-muted-foreground truncate">{{ username() }} | {{ serverName() }}</span>
|
||||||
|
@if (isReconnecting()) {
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive">Reconnecting…</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2" style="-webkit-app-region: no-drag;">
|
||||||
|
@if (!isAuthed()) {
|
||||||
|
<button
|
||||||
|
class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground"
|
||||||
|
(click)="goLogin()"
|
||||||
|
title="Login"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (isElectron()) {
|
||||||
|
<button class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Minimize" (click)="minimize()">
|
||||||
|
<ng-icon name="lucideMinus" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Maximize" (click)="maximize()">
|
||||||
|
<ng-icon name="lucideSquare" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button class="w-8 h-8 grid place-items-center hover:bg-destructive/10 rounded" title="Close" (click)="close()">
|
||||||
|
<ng-icon name="lucideX" class="w-4 h-4 text-destructive" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Click-away overlay to close dropdown -->
|
<!-- Click-away overlay to close dropdown -->
|
||||||
<div *ngIf="showMenu()" class="fixed inset-0 z-40" (click)="closeMenu()" style="-webkit-app-region: no-drag;"></div>
|
@if (showMenu()) {
|
||||||
|
<div class="fixed inset-0 z-40" (click)="closeMenu()" style="-webkit-app-region: no-drag;"></div>
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
|
|||||||
import { lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu } from '@ng-icons/lucide';
|
import { lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu } from '@ng-icons/lucide';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||||
import * as RoomsActions from '../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
|
import { PlatformService } from '../../core/services/platform.service';
|
||||||
|
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-title-bar',
|
selector: 'app-title-bar',
|
||||||
@@ -17,17 +19,24 @@ import { WebRTCService } from '../../core/services/webrtc.service';
|
|||||||
viewProviders: [provideIcons({ lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu })],
|
viewProviders: [provideIcons({ lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu })],
|
||||||
templateUrl: './title-bar.component.html',
|
templateUrl: './title-bar.component.html',
|
||||||
})
|
})
|
||||||
|
/**
|
||||||
|
* Electron-style title bar with window controls, navigation, and server menu.
|
||||||
|
*/
|
||||||
export class TitleBarComponent {
|
export class TitleBarComponent {
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private serverDirectory = inject(ServerDirectoryService);
|
private serverDirectory = inject(ServerDirectoryService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private webrtc = inject(WebRTCService);
|
private webrtc = inject(WebRTCService);
|
||||||
|
private platform = inject(PlatformService);
|
||||||
|
|
||||||
|
isElectron = computed(() => this.platform.isElectron);
|
||||||
showMenuState = computed(() => false);
|
showMenuState = computed(() => false);
|
||||||
|
|
||||||
private currentUserSig = this.store.selectSignal(selectCurrentUser);
|
private currentUserSig = this.store.selectSignal(selectCurrentUser);
|
||||||
username = computed(() => this.currentUserSig()?.displayName || 'Guest');
|
username = computed(() => this.currentUserSig()?.displayName || 'Guest');
|
||||||
serverName = computed(() => this.serverDirectory.activeServer()?.name || 'No Server');
|
serverName = computed(() => this.serverDirectory.activeServer()?.name || 'No Server');
|
||||||
isConnected = computed(() => this.webrtc.isConnected());
|
isConnected = computed(() => this.webrtc.isConnected());
|
||||||
|
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
|
||||||
isAuthed = computed(() => !!this.currentUserSig());
|
isAuthed = computed(() => !!this.currentUserSig());
|
||||||
private currentRoomSig = this.store.selectSignal(selectCurrentRoom);
|
private currentRoomSig = this.store.selectSignal(selectCurrentRoom);
|
||||||
inRoom = computed(() => !!this.currentRoomSig());
|
inRoom = computed(() => !!this.currentRoomSig());
|
||||||
@@ -36,52 +45,61 @@ export class TitleBarComponent {
|
|||||||
private _showMenu = signal(false);
|
private _showMenu = signal(false);
|
||||||
showMenu = computed(() => this._showMenu());
|
showMenu = computed(() => this._showMenu());
|
||||||
|
|
||||||
|
/** Minimize the Electron window. */
|
||||||
minimize() {
|
minimize() {
|
||||||
const api = (window as any).electronAPI;
|
const api = (window as any).electronAPI;
|
||||||
if (api?.minimizeWindow) api.minimizeWindow();
|
if (api?.minimizeWindow) api.minimizeWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Maximize or restore the Electron window. */
|
||||||
maximize() {
|
maximize() {
|
||||||
const api = (window as any).electronAPI;
|
const api = (window as any).electronAPI;
|
||||||
if (api?.maximizeWindow) api.maximizeWindow();
|
if (api?.maximizeWindow) api.maximizeWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Close the Electron window. */
|
||||||
close() {
|
close() {
|
||||||
const api = (window as any).electronAPI;
|
const api = (window as any).electronAPI;
|
||||||
if (api?.closeWindow) api.closeWindow();
|
if (api?.closeWindow) api.closeWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Navigate to the login page. */
|
||||||
goLogin() {
|
goLogin() {
|
||||||
this.router.navigate(['/login']);
|
this.router.navigate(['/login']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Leave the current room and navigate back to the server search. */
|
||||||
onBack() {
|
onBack() {
|
||||||
// Leave room to ensure header switches to user/server view
|
// Leave room to ensure header switches to user/server view
|
||||||
this.store.dispatch(RoomsActions.leaveRoom());
|
this.store.dispatch(RoomsActions.leaveRoom());
|
||||||
this.router.navigate(['/search']);
|
this.router.navigate(['/search']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggle the server dropdown menu. */
|
||||||
toggleMenu() {
|
toggleMenu() {
|
||||||
this._showMenu.set(!this._showMenu());
|
this._showMenu.set(!this._showMenu());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Leave the current server and navigate to the servers list. */
|
||||||
leaveServer() {
|
leaveServer() {
|
||||||
this._showMenu.set(false);
|
this._showMenu.set(false);
|
||||||
this.store.dispatch(RoomsActions.leaveRoom());
|
this.store.dispatch(RoomsActions.leaveRoom());
|
||||||
window.dispatchEvent(new CustomEvent('navigate:servers'));
|
window.dispatchEvent(new CustomEvent('navigate:servers'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Close the server dropdown menu. */
|
||||||
closeMenu() {
|
closeMenu() {
|
||||||
this._showMenu.set(false);
|
this._showMenu.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
||||||
logout() {
|
logout() {
|
||||||
this._showMenu.set(false);
|
this._showMenu.set(false);
|
||||||
// Disconnect from signaling server – this broadcasts "user_left" to all
|
// Disconnect from signaling server – this broadcasts "user_left" to all
|
||||||
// servers the user was a member of, so other users see them go offline.
|
// servers the user was a member of, so other users see them go offline.
|
||||||
this.webrtc.disconnect();
|
this.webrtc.disconnect();
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem('metoyou_currentUserId');
|
localStorage.removeItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||||
} catch {}
|
} catch {}
|
||||||
this.router.navigate(['/login']);
|
this.router.navigate(['/login']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
|
|
||||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||||
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
||||||
import * as UsersActions from '../../../store/users/users.actions';
|
import { UsersActions } from '../../../store/users/users.actions';
|
||||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -34,6 +34,10 @@ import { selectCurrentUser } from '../../../store/users/users.selectors';
|
|||||||
],
|
],
|
||||||
templateUrl: './floating-voice-controls.component.html'
|
templateUrl: './floating-voice-controls.component.html'
|
||||||
})
|
})
|
||||||
|
/**
|
||||||
|
* Floating voice controls displayed when the user navigates away from the voice-connected server.
|
||||||
|
* Provides mute, deafen, screen-share, and disconnect actions in a compact overlay.
|
||||||
|
*/
|
||||||
export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||||
private webrtcService = inject(WebRTCService);
|
private webrtcService = inject(WebRTCService);
|
||||||
private voiceSessionService = inject(VoiceSessionService);
|
private voiceSessionService = inject(VoiceSessionService);
|
||||||
@@ -52,6 +56,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
private stateSubscription: Subscription | null = null;
|
private stateSubscription: Subscription | null = null;
|
||||||
|
|
||||||
|
/** Sync local mute/deafen/screen-share state from the WebRTC service on init. */
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// Sync mute/deafen state from webrtc service
|
// Sync mute/deafen state from webrtc service
|
||||||
this.isMuted.set(this.webrtcService.isMuted());
|
this.isMuted.set(this.webrtcService.isMuted());
|
||||||
@@ -63,12 +68,14 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
this.stateSubscription?.unsubscribe();
|
this.stateSubscription?.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Navigate back to the voice-connected server. */
|
||||||
navigateToServer(): void {
|
navigateToServer(): void {
|
||||||
this.voiceSessionService.navigateToVoiceServer();
|
this.voiceSessionService.navigateToVoiceServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggle microphone mute and broadcast the updated voice state. */
|
||||||
toggleMute(): void {
|
toggleMute(): void {
|
||||||
this.isMuted.update(v => !v);
|
this.isMuted.update((current) => !current);
|
||||||
this.webrtcService.toggleMute(this.isMuted());
|
this.webrtcService.toggleMute(this.isMuted());
|
||||||
|
|
||||||
// Broadcast mute state change
|
// Broadcast mute state change
|
||||||
@@ -84,8 +91,9 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggle deafen state (muting audio output) and broadcast the updated voice state. */
|
||||||
toggleDeafen(): void {
|
toggleDeafen(): void {
|
||||||
this.isDeafened.update(v => !v);
|
this.isDeafened.update((current) => !current);
|
||||||
this.webrtcService.toggleDeafen(this.isDeafened());
|
this.webrtcService.toggleDeafen(this.isDeafened());
|
||||||
|
|
||||||
// When deafening, also mute
|
// When deafening, also mute
|
||||||
@@ -107,6 +115,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggle screen sharing on or off. */
|
||||||
async toggleScreenShare(): Promise<void> {
|
async toggleScreenShare(): Promise<void> {
|
||||||
if (this.isScreenSharing()) {
|
if (this.isScreenSharing()) {
|
||||||
this.webrtcService.stopScreenShare();
|
this.webrtcService.stopScreenShare();
|
||||||
@@ -115,12 +124,13 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
try {
|
try {
|
||||||
await this.webrtcService.startScreenShare(false);
|
await this.webrtcService.startScreenShare(false);
|
||||||
this.isScreenSharing.set(true);
|
this.isScreenSharing.set(true);
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.error('Failed to start screen share:', error);
|
// Screen share request was denied or failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Disconnect from the voice session entirely, cleaning up all voice state. */
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
// Stop voice heartbeat
|
// Stop voice heartbeat
|
||||||
this.webrtcService.stopVoiceHeartbeat();
|
this.webrtcService.stopVoiceHeartbeat();
|
||||||
@@ -163,6 +173,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
this.isDeafened.set(false);
|
this.isDeafened.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return the CSS classes for the compact control button based on active state. */
|
||||||
getCompactButtonClass(isActive: boolean): string {
|
getCompactButtonClass(isActive: boolean): string {
|
||||||
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
|
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
@@ -171,6 +182,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return the CSS classes for the compact screen-share button. */
|
||||||
getCompactScreenShareClass(): string {
|
getCompactScreenShareClass(): string {
|
||||||
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
|
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
|
||||||
if (this.isScreenSharing()) {
|
if (this.isScreenSharing()) {
|
||||||
@@ -179,6 +191,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return the CSS classes for the mute toggle button. */
|
||||||
getMuteButtonClass(): string {
|
getMuteButtonClass(): string {
|
||||||
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
||||||
if (this.isMuted()) {
|
if (this.isMuted()) {
|
||||||
@@ -187,6 +200,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return the CSS classes for the deafen toggle button. */
|
||||||
getDeafenButtonClass(): string {
|
getDeafenButtonClass(): string {
|
||||||
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
||||||
if (this.isDeafened()) {
|
if (this.isDeafened()) {
|
||||||
@@ -195,6 +209,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return the CSS classes for the screen-share toggle button. */
|
||||||
getScreenShareButtonClass(): string {
|
getScreenShareButtonClass(): string {
|
||||||
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
||||||
if (this.isScreenSharing()) {
|
if (this.isScreenSharing()) {
|
||||||
|
|||||||
@@ -19,12 +19,13 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2 text-white">
|
<div class="flex items-center gap-2 text-white">
|
||||||
<ng-icon name="lucideMonitor" class="w-4 h-4" />
|
<ng-icon name="lucideMonitor" class="w-4 h-4" />
|
||||||
<span class="text-sm font-medium" *ngIf="activeScreenSharer(); else sharingUnknown">
|
@if (activeScreenSharer()) {
|
||||||
{{ activeScreenSharer()?.displayName }} is sharing their screen
|
<span class="text-sm font-medium">
|
||||||
</span>
|
{{ activeScreenSharer()?.displayName }} is sharing their screen
|
||||||
<ng-template #sharingUnknown>
|
</span>
|
||||||
|
} @else {
|
||||||
<span class="text-sm font-medium">Someone is sharing their screen</span>
|
<span class="text-sm font-medium">Someone is sharing their screen</span>
|
||||||
</ng-template>
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<!-- Viewer volume -->
|
<!-- Viewer volume -->
|
||||||
@@ -71,10 +72,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- No Stream Placeholder -->
|
<!-- No Stream Placeholder -->
|
||||||
<div *ngIf="!hasStream()" class="absolute inset-0 flex items-center justify-center bg-secondary">
|
@if (!hasStream()) {
|
||||||
<div class="text-center text-muted-foreground">
|
<div class="absolute inset-0 flex items-center justify-center bg-secondary">
|
||||||
<ng-icon name="lucideMonitor" class="w-12 h-12 mx-auto mb-2 opacity-50" />
|
<div class="text-center text-muted-foreground">
|
||||||
<p>Waiting for screen share...</p>
|
<ng-icon name="lucideMonitor" class="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>Waiting for screen share...</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||||
import { selectOnlineUsers } from '../../../store/users/users.selectors';
|
import { selectOnlineUsers } from '../../../store/users/users.selectors';
|
||||||
import { User } from '../../../core/models';
|
import { User } from '../../../core/models';
|
||||||
|
import { DEFAULT_VOLUME } from '../../../core/constants';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-screen-share-viewer',
|
selector: 'app-screen-share-viewer',
|
||||||
@@ -28,6 +29,10 @@ import { User } from '../../../core/models';
|
|||||||
],
|
],
|
||||||
templateUrl: './screen-share-viewer.component.html',
|
templateUrl: './screen-share-viewer.component.html',
|
||||||
})
|
})
|
||||||
|
/**
|
||||||
|
* Displays a local or remote screen-share stream in a video player.
|
||||||
|
* Supports fullscreen toggling, volume control, and viewer focus events.
|
||||||
|
*/
|
||||||
export class ScreenShareViewerComponent implements OnDestroy {
|
export class ScreenShareViewerComponent implements OnDestroy {
|
||||||
@ViewChild('screenVideo') videoRef!: ElementRef<HTMLVideoElement>;
|
@ViewChild('screenVideo') videoRef!: ElementRef<HTMLVideoElement>;
|
||||||
|
|
||||||
@@ -43,7 +48,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
|||||||
isFullscreen = signal(false);
|
isFullscreen = signal(false);
|
||||||
hasStream = signal(false);
|
hasStream = signal(false);
|
||||||
isLocalShare = signal(false);
|
isLocalShare = signal(false);
|
||||||
screenVolume = signal(100);
|
screenVolume = signal(DEFAULT_VOLUME);
|
||||||
|
|
||||||
private streamSubscription: (() => void) | null = null;
|
private streamSubscription: (() => void) | null = null;
|
||||||
private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => {
|
private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => {
|
||||||
@@ -51,7 +56,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
|||||||
const userId = evt.detail?.userId;
|
const userId = evt.detail?.userId;
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
const stream = this.webrtcService.getRemoteStream(userId);
|
const stream = this.webrtcService.getRemoteStream(userId);
|
||||||
const user = this.onlineUsers().find((u) => u.id === userId || u.oderId === userId) || null;
|
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) || null;
|
||||||
if (stream && stream.getVideoTracks().length > 0) {
|
if (stream && stream.getVideoTracks().length > 0) {
|
||||||
if (user) {
|
if (user) {
|
||||||
this.setRemoteStream(stream, user);
|
this.setRemoteStream(stream, user);
|
||||||
@@ -63,8 +68,8 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
|||||||
this.isLocalShare.set(false);
|
this.isLocalShare.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (_error) {
|
||||||
console.error('Failed to focus viewer on user stream:', e);
|
// Failed to focus viewer on user stream
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,7 +100,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
|||||||
if (!watchingId || !isWatchingRemote) return;
|
if (!watchingId || !isWatchingRemote) return;
|
||||||
|
|
||||||
const users = this.onlineUsers();
|
const users = this.onlineUsers();
|
||||||
const watchedUser = users.find(u => u.id === watchingId || u.oderId === watchingId);
|
const watchedUser = users.find(user => user.id === watchingId || user.oderId === watchingId);
|
||||||
|
|
||||||
// If the user is no longer sharing (screenShareState.isSharing is false), stop watching
|
// If the user is no longer sharing (screenShareState.isSharing is false), stop watching
|
||||||
if (watchedUser && watchedUser.screenShareState?.isSharing === false) {
|
if (watchedUser && watchedUser.screenShareState?.isSharing === false) {
|
||||||
@@ -105,7 +110,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
|||||||
|
|
||||||
// Also check if the stream's video tracks are still available
|
// Also check if the stream's video tracks are still available
|
||||||
const stream = this.webrtcService.getRemoteStream(watchingId);
|
const stream = this.webrtcService.getRemoteStream(watchingId);
|
||||||
const hasActiveVideo = stream?.getVideoTracks().some(t => t.readyState === 'live');
|
const hasActiveVideo = stream?.getVideoTracks().some(track => track.readyState === 'live');
|
||||||
if (!hasActiveVideo) {
|
if (!hasActiveVideo) {
|
||||||
// Stream or video tracks are gone - stop watching
|
// Stream or video tracks are gone - stop watching
|
||||||
this.stopWatching();
|
this.stopWatching();
|
||||||
@@ -136,6 +141,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
|||||||
window.removeEventListener('viewer:focus', this.viewerFocusHandler as EventListener);
|
window.removeEventListener('viewer:focus', this.viewerFocusHandler as EventListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggle between fullscreen and windowed display. */
|
||||||
toggleFullscreen(): void {
|
toggleFullscreen(): void {
|
||||||
if (this.isFullscreen()) {
|
if (this.isFullscreen()) {
|
||||||
this.exitFullscreen();
|
this.exitFullscreen();
|
||||||
@@ -144,6 +150,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Enter fullscreen mode, requesting browser fullscreen if available. */
|
||||||
enterFullscreen(): void {
|
enterFullscreen(): void {
|
||||||
this.isFullscreen.set(true);
|
this.isFullscreen.set(true);
|
||||||
// Request browser fullscreen if available
|
// Request browser fullscreen if available
|
||||||
@@ -154,6 +161,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Exit fullscreen mode. */
|
||||||
exitFullscreen(): void {
|
exitFullscreen(): void {
|
||||||
this.isFullscreen.set(false);
|
this.isFullscreen.set(false);
|
||||||
if (document.fullscreenElement) {
|
if (document.fullscreenElement) {
|
||||||
@@ -161,6 +169,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stop the local screen share and reset viewer state. */
|
||||||
stopSharing(): void {
|
stopSharing(): void {
|
||||||
this.webrtcService.stopScreenShare();
|
this.webrtcService.stopScreenShare();
|
||||||
this.activeScreenSharer.set(null);
|
this.activeScreenSharer.set(null);
|
||||||
@@ -168,6 +177,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
|||||||
this.isLocalShare.set(false);
|
this.isLocalShare.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stop watching a remote stream and reset the viewer. */
|
||||||
// Stop watching a remote stream (for viewers)
|
// Stop watching a remote stream (for viewers)
|
||||||
stopWatching(): void {
|
stopWatching(): void {
|
||||||
if (this.videoRef) {
|
if (this.videoRef) {
|
||||||
@@ -182,6 +192,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Attach and play a remote peer's screen-share stream. */
|
||||||
// Called by parent when a remote peer starts sharing
|
// Called by parent when a remote peer starts sharing
|
||||||
setRemoteStream(stream: MediaStream, user: User): void {
|
setRemoteStream(stream: MediaStream, user: User): void {
|
||||||
this.activeScreenSharer.set(user);
|
this.activeScreenSharer.set(user);
|
||||||
@@ -212,6 +223,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Attach and play the local user's screen-share stream (always muted). */
|
||||||
// Called when local user starts sharing
|
// Called when local user starts sharing
|
||||||
setLocalStream(stream: MediaStream, user: User): void {
|
setLocalStream(stream: MediaStream, user: User): void {
|
||||||
this.activeScreenSharer.set(user);
|
this.activeScreenSharer.set(user);
|
||||||
@@ -225,6 +237,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Handle volume slider changes, applying only to remote streams. */
|
||||||
onScreenVolumeChange(event: Event): void {
|
onScreenVolumeChange(event: Event): void {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const val = Math.max(0, Math.min(100, parseInt(input.value, 10)));
|
const val = Math.max(0, Math.min(100, parseInt(input.value, 10)));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="bg-card border-t border-border p-4">
|
<div class="bg-card border-border p-4">
|
||||||
<!-- Connection Error Banner -->
|
<!-- Connection Error Banner -->
|
||||||
@if (showConnectionError()) {
|
@if (showConnectionError()) {
|
||||||
<div class="mb-3 p-2 bg-destructive/20 border border-destructive/30 rounded-lg flex items-center gap-2">
|
<div class="mb-3 p-2 bg-destructive/20 border border-destructive/30 rounded-lg flex items-center gap-2">
|
||||||
@@ -10,9 +10,7 @@
|
|||||||
|
|
||||||
<!-- User Info -->
|
<!-- User Info -->
|
||||||
<div class="flex items-center gap-3 mb-4">
|
<div class="flex items-center gap-3 mb-4">
|
||||||
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold text-sm">
|
<app-user-avatar [name]="currentUser()?.displayName || '?'" size="sm" />
|
||||||
{{ currentUser()?.displayName?.charAt(0)?.toUpperCase() || '?' }}
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="font-medium text-sm text-foreground truncate">
|
<p class="font-medium text-sm text-foreground truncate">
|
||||||
{{ currentUser()?.displayName || 'Unknown' }}
|
{{ currentUser()?.displayName || 'Unknown' }}
|
||||||
@@ -145,7 +143,10 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-foreground mb-1">Latency</label>
|
<label class="block text-sm font-medium text-foreground mb-1">Latency</label>
|
||||||
<select (change)="onLatencyProfileChange($event)" class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm">
|
<select
|
||||||
|
(change)="onLatencyProfileChange($event)"
|
||||||
|
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm"
|
||||||
|
>
|
||||||
<option value="low">Low (fast)</option>
|
<option value="low">Low (fast)</option>
|
||||||
<option value="balanced" selected>Balanced</option>
|
<option value="balanced" selected>Balanced</option>
|
||||||
<option value="high">High (quality)</option>
|
<option value="high">High (quality)</option>
|
||||||
@@ -154,7 +155,12 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-foreground mb-1">Include system audio when sharing screen</label>
|
<label class="block text-sm font-medium text-foreground mb-1">Include system audio when sharing screen</label>
|
||||||
<input type="checkbox" [checked]="includeSystemAudio()" (change)="onIncludeSystemAudioChange($event)" class="accent-primary" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[checked]="includeSystemAudio()"
|
||||||
|
(change)="onIncludeSystemAudioChange($event)"
|
||||||
|
class="accent-primary"
|
||||||
|
/>
|
||||||
<p class="text-xs text-muted-foreground">Off by default; viewers will still hear your mic.</p>
|
<p class="text-xs text-muted-foreground">Off by default; viewers will still hear your mic.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -162,7 +168,15 @@
|
|||||||
<label class="block text-sm font-medium text-foreground mb-1">
|
<label class="block text-sm font-medium text-foreground mb-1">
|
||||||
Audio Bitrate: {{ audioBitrate() }} kbps
|
Audio Bitrate: {{ audioBitrate() }} kbps
|
||||||
</label>
|
</label>
|
||||||
<input type="range" [value]="audioBitrate()" (input)="onAudioBitrateChange($event)" min="32" max="256" step="8" class="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" />
|
<input
|
||||||
|
type="range"
|
||||||
|
[value]="audioBitrate()"
|
||||||
|
(input)="onAudioBitrateChange($event)"
|
||||||
|
min="32"
|
||||||
|
max="256"
|
||||||
|
step="8"
|
||||||
|
class="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ import {
|
|||||||
|
|
||||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||||
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
||||||
import * as UsersActions from '../../../store/users/users.actions';
|
import { UsersActions } from '../../../store/users/users.actions';
|
||||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||||
|
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../core/constants';
|
||||||
|
import { UserAvatarComponent } from '../../../shared';
|
||||||
|
|
||||||
interface AudioDevice {
|
interface AudioDevice {
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
@@ -29,7 +31,7 @@ interface AudioDevice {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-voice-controls',
|
selector: 'app-voice-controls',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, NgIcon],
|
imports: [CommonModule, NgIcon, UserAvatarComponent],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucideMic,
|
lucideMic,
|
||||||
@@ -74,7 +76,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
latencyProfile = signal<'low'|'balanced'|'high'>('balanced');
|
latencyProfile = signal<'low'|'balanced'|'high'>('balanced');
|
||||||
includeSystemAudio = signal(false);
|
includeSystemAudio = signal(false);
|
||||||
|
|
||||||
private SETTINGS_KEY = 'metoyou_voice_settings';
|
|
||||||
private voiceConnectedSubscription: Subscription | null = null;
|
private voiceConnectedSubscription: Subscription | null = null;
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
@@ -87,14 +88,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
// Subscribe to remote streams to play audio from peers
|
// Subscribe to remote streams to play audio from peers
|
||||||
this.remoteStreamSubscription = this.webrtcService.onRemoteStream.subscribe(
|
this.remoteStreamSubscription = this.webrtcService.onRemoteStream.subscribe(
|
||||||
({ peerId, stream }) => {
|
({ peerId, stream }) => {
|
||||||
console.log('Received remote stream from:', peerId, 'tracks:', stream.getTracks().map(t => t.kind));
|
|
||||||
this.playRemoteAudio(peerId, stream);
|
this.playRemoteAudio(peerId, stream);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Subscribe to voice connected event to play pending streams and ensure all remote audio is set up
|
// Subscribe to voice connected event to play pending streams and ensure all remote audio is set up
|
||||||
this.voiceConnectedSubscription = this.webrtcService.onVoiceConnected.subscribe(() => {
|
this.voiceConnectedSubscription = this.webrtcService.onVoiceConnected.subscribe(() => {
|
||||||
console.log('Voice connected, playing pending streams:', this.pendingRemoteStreams.size);
|
|
||||||
this.playPendingStreams();
|
this.playPendingStreams();
|
||||||
// Also ensure all remote streams from connected peers are playing
|
// Also ensure all remote streams from connected peers are playing
|
||||||
// This handles the case where streams were received while voice was "connected"
|
// This handles the case where streams were received while voice was "connected"
|
||||||
@@ -130,7 +129,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
private playPendingStreams(): void {
|
private playPendingStreams(): void {
|
||||||
this.pendingRemoteStreams.forEach((stream, peerId) => {
|
this.pendingRemoteStreams.forEach((stream, peerId) => {
|
||||||
console.log('Playing pending stream from:', peerId, 'tracks:', stream.getTracks().map(t => t.kind));
|
|
||||||
this.playRemoteAudio(peerId, stream);
|
this.playRemoteAudio(peerId, stream);
|
||||||
});
|
});
|
||||||
this.pendingRemoteStreams.clear();
|
this.pendingRemoteStreams.clear();
|
||||||
@@ -143,7 +141,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
private ensureAllRemoteStreamsPlaying(): void {
|
private ensureAllRemoteStreamsPlaying(): void {
|
||||||
const connectedPeers = this.webrtcService.getConnectedPeers();
|
const connectedPeers = this.webrtcService.getConnectedPeers();
|
||||||
console.log('Ensuring audio for connected peers:', connectedPeers.length);
|
|
||||||
|
|
||||||
for (const peerId of connectedPeers) {
|
for (const peerId of connectedPeers) {
|
||||||
const stream = this.webrtcService.getRemoteStream(peerId);
|
const stream = this.webrtcService.getRemoteStream(peerId);
|
||||||
@@ -151,7 +148,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
// Check if we already have an active audio element for this peer
|
// Check if we already have an active audio element for this peer
|
||||||
const existingAudio = this.remoteAudioElements.get(peerId);
|
const existingAudio = this.remoteAudioElements.get(peerId);
|
||||||
if (!existingAudio || existingAudio.srcObject !== stream) {
|
if (!existingAudio || existingAudio.srcObject !== stream) {
|
||||||
console.log('Setting up remote audio for peer:', peerId);
|
|
||||||
this.playRemoteAudio(peerId, stream);
|
this.playRemoteAudio(peerId, stream);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,14 +164,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
audio.srcObject = null;
|
audio.srcObject = null;
|
||||||
audio.remove();
|
audio.remove();
|
||||||
this.remoteAudioElements.delete(peerId);
|
this.remoteAudioElements.delete(peerId);
|
||||||
console.log('Removed remote audio for:', peerId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private playRemoteAudio(peerId: string, stream: MediaStream): void {
|
private playRemoteAudio(peerId: string, stream: MediaStream): void {
|
||||||
// Only play remote audio if we have joined voice
|
// Only play remote audio if we have joined voice
|
||||||
if (!this.isConnected()) {
|
if (!this.isConnected()) {
|
||||||
console.log('Not connected to voice, storing pending stream from:', peerId);
|
|
||||||
// Store the stream to play later when we connect
|
// Store the stream to play later when we connect
|
||||||
this.pendingRemoteStreams.set(peerId, stream);
|
this.pendingRemoteStreams.set(peerId, stream);
|
||||||
return;
|
return;
|
||||||
@@ -184,21 +178,18 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
// Check if stream has audio tracks
|
// Check if stream has audio tracks
|
||||||
const audioTracks = stream.getAudioTracks();
|
const audioTracks = stream.getAudioTracks();
|
||||||
if (audioTracks.length === 0) {
|
if (audioTracks.length === 0) {
|
||||||
console.log('No audio tracks in stream from:', peerId);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if audio track is live
|
// Check if audio track is live
|
||||||
const audioTrack = audioTracks[0];
|
const audioTrack = audioTracks[0];
|
||||||
if (audioTrack.readyState !== 'live') {
|
if (audioTrack.readyState !== 'live') {
|
||||||
console.warn('Audio track not live from:', peerId, 'state:', audioTrack.readyState);
|
|
||||||
// Still try to play it - it might become live later
|
// Still try to play it - it might become live later
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove existing audio element for this peer if any
|
// Remove existing audio element for this peer if any
|
||||||
const existingAudio = this.remoteAudioElements.get(peerId);
|
const existingAudio = this.remoteAudioElements.get(peerId);
|
||||||
if (existingAudio) {
|
if (existingAudio) {
|
||||||
console.log('Removing existing audio element for:', peerId);
|
|
||||||
existingAudio.srcObject = null;
|
existingAudio.srcObject = null;
|
||||||
existingAudio.remove();
|
existingAudio.remove();
|
||||||
}
|
}
|
||||||
@@ -216,9 +207,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// Play the audio
|
// Play the audio
|
||||||
audio.play().then(() => {
|
audio.play().then(() => {
|
||||||
console.log('Playing remote audio from:', peerId, 'track state:', audioTrack.readyState, 'enabled:', audioTrack.enabled);
|
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error('Failed to play remote audio from:', peerId, error);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.remoteAudioElements.set(peerId, audio);
|
this.remoteAudioElements.set(peerId, audio);
|
||||||
@@ -227,22 +216,20 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
async loadAudioDevices(): Promise<void> {
|
async loadAudioDevices(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (!navigator.mediaDevices?.enumerateDevices) {
|
if (!navigator.mediaDevices?.enumerateDevices) {
|
||||||
console.warn('navigator.mediaDevices not available (requires HTTPS or localhost)');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
this.inputDevices.set(
|
this.inputDevices.set(
|
||||||
devices
|
devices
|
||||||
.filter((d) => d.kind === 'audioinput')
|
.filter((device) => device.kind === 'audioinput')
|
||||||
.map((d) => ({ deviceId: d.deviceId, label: d.label }))
|
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||||
);
|
);
|
||||||
this.outputDevices.set(
|
this.outputDevices.set(
|
||||||
devices
|
devices
|
||||||
.filter((d) => d.kind === 'audiooutput')
|
.filter((device) => device.kind === 'audiooutput')
|
||||||
.map((d) => ({ deviceId: d.deviceId, label: d.label }))
|
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to enumerate devices:', error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,12 +238,10 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
// Require signaling connectivity first
|
// Require signaling connectivity first
|
||||||
const ok = await this.webrtcService.ensureSignalingConnected();
|
const ok = await this.webrtcService.ensureSignalingConnected();
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
console.error('Cannot join call: signaling server unreachable');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!navigator.mediaDevices?.getUserMedia) {
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
console.error('Cannot join call: navigator.mediaDevices not available (requires HTTPS or localhost)');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +274,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// Play any pending remote streams now that we're connected
|
// Play any pending remote streams now that we're connected
|
||||||
this.pendingRemoteStreams.forEach((pendingStream, peerId) => {
|
this.pendingRemoteStreams.forEach((pendingStream, peerId) => {
|
||||||
console.log('Playing pending stream from:', peerId);
|
|
||||||
this.playRemoteAudio(peerId, pendingStream);
|
this.playRemoteAudio(peerId, pendingStream);
|
||||||
});
|
});
|
||||||
this.pendingRemoteStreams.clear();
|
this.pendingRemoteStreams.clear();
|
||||||
@@ -297,7 +281,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
// Persist settings after successful connection
|
// Persist settings after successful connection
|
||||||
this.saveSettings();
|
this.saveSettings();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get user media:', error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,8 +288,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
async retryConnection(): Promise<void> {
|
async retryConnection(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.webrtcService.ensureSignalingConnected(10000);
|
await this.webrtcService.ensureSignalingConnected(10000);
|
||||||
} catch (e) {
|
} catch (_error) {
|
||||||
console.error('Retry connection failed:', e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,7 +341,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleMute(): void {
|
toggleMute(): void {
|
||||||
this.isMuted.update((v) => !v);
|
this.isMuted.update((current) => !current);
|
||||||
this.webrtcService.toggleMute(this.isMuted());
|
this.webrtcService.toggleMute(this.isMuted());
|
||||||
|
|
||||||
// Broadcast mute state change
|
// Broadcast mute state change
|
||||||
@@ -376,7 +358,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleDeafen(): void {
|
toggleDeafen(): void {
|
||||||
this.isDeafened.update((v) => !v);
|
this.isDeafened.update((current) => !current);
|
||||||
this.webrtcService.toggleDeafen(this.isDeafened());
|
this.webrtcService.toggleDeafen(this.isDeafened());
|
||||||
|
|
||||||
// Mute/unmute all remote audio elements
|
// Mute/unmute all remote audio elements
|
||||||
@@ -412,13 +394,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
await this.webrtcService.startScreenShare(this.includeSystemAudio());
|
await this.webrtcService.startScreenShare(this.includeSystemAudio());
|
||||||
this.isScreenSharing.set(true);
|
this.isScreenSharing.set(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to start screen share:', error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSettings(): void {
|
toggleSettings(): void {
|
||||||
this.showSettings.update((v) => !v);
|
this.showSettings.update((current) => !current);
|
||||||
}
|
}
|
||||||
|
|
||||||
closeSettings(): void {
|
closeSettings(): void {
|
||||||
@@ -485,9 +466,9 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
private loadSettings(): void {
|
private loadSettings(): void {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(this.SETTINGS_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
const s = JSON.parse(raw) as {
|
const settings = JSON.parse(raw) as {
|
||||||
inputDevice?: string;
|
inputDevice?: string;
|
||||||
outputDevice?: string;
|
outputDevice?: string;
|
||||||
inputVolume?: number;
|
inputVolume?: number;
|
||||||
@@ -496,19 +477,19 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
latencyProfile?: 'low'|'balanced'|'high';
|
latencyProfile?: 'low'|'balanced'|'high';
|
||||||
includeSystemAudio?: boolean;
|
includeSystemAudio?: boolean;
|
||||||
};
|
};
|
||||||
if (s.inputDevice) this.selectedInputDevice.set(s.inputDevice);
|
if (settings.inputDevice) this.selectedInputDevice.set(settings.inputDevice);
|
||||||
if (s.outputDevice) this.selectedOutputDevice.set(s.outputDevice);
|
if (settings.outputDevice) this.selectedOutputDevice.set(settings.outputDevice);
|
||||||
if (typeof s.inputVolume === 'number') this.inputVolume.set(s.inputVolume);
|
if (typeof settings.inputVolume === 'number') this.inputVolume.set(settings.inputVolume);
|
||||||
if (typeof s.outputVolume === 'number') this.outputVolume.set(s.outputVolume);
|
if (typeof settings.outputVolume === 'number') this.outputVolume.set(settings.outputVolume);
|
||||||
if (typeof s.audioBitrate === 'number') this.audioBitrate.set(s.audioBitrate);
|
if (typeof settings.audioBitrate === 'number') this.audioBitrate.set(settings.audioBitrate);
|
||||||
if (s.latencyProfile) this.latencyProfile.set(s.latencyProfile);
|
if (settings.latencyProfile) this.latencyProfile.set(settings.latencyProfile);
|
||||||
if (typeof s.includeSystemAudio === 'boolean') this.includeSystemAudio.set(s.includeSystemAudio);
|
if (typeof settings.includeSystemAudio === 'boolean') this.includeSystemAudio.set(settings.includeSystemAudio);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
private saveSettings(): void {
|
private saveSettings(): void {
|
||||||
try {
|
try {
|
||||||
const s = {
|
const voiceSettings = {
|
||||||
inputDevice: this.selectedInputDevice(),
|
inputDevice: this.selectedInputDevice(),
|
||||||
outputDevice: this.selectedOutputDevice(),
|
outputDevice: this.selectedOutputDevice(),
|
||||||
inputVolume: this.inputVolume(),
|
inputVolume: this.inputVolume(),
|
||||||
@@ -517,7 +498,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
latencyProfile: this.latencyProfile(),
|
latencyProfile: this.latencyProfile(),
|
||||||
includeSystemAudio: this.includeSystemAudio(),
|
includeSystemAudio: this.includeSystemAudio(),
|
||||||
};
|
};
|
||||||
localStorage.setItem(this.SETTINGS_KEY, JSON.stringify(s));
|
localStorage.setItem(STORAGE_KEY_VOICE_SETTINGS, JSON.stringify(voiceSettings));
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,7 +517,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
this.remoteAudioElements.forEach((audio) => {
|
this.remoteAudioElements.forEach((audio) => {
|
||||||
const anyAudio = audio as any;
|
const anyAudio = audio as any;
|
||||||
if (typeof anyAudio.setSinkId === 'function') {
|
if (typeof anyAudio.setSinkId === 'function') {
|
||||||
anyAudio.setSinkId(deviceId).catch((e: any) => console.warn('Failed to setSinkId', e));
|
anyAudio.setSinkId(deviceId).catch(() => {});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Component, input, output, HostListener } from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable confirmation dialog modal.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```html
|
||||||
|
* @if (showConfirm()) {
|
||||||
|
* <app-confirm-dialog
|
||||||
|
* title="Delete Room?"
|
||||||
|
* confirmLabel="Delete"
|
||||||
|
* variant="danger"
|
||||||
|
* (confirmed)="onDelete()"
|
||||||
|
* (cancelled)="showConfirm.set(false)"
|
||||||
|
* >
|
||||||
|
* <p>This will permanently delete the room.</p>
|
||||||
|
* </app-confirm-dialog>
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-confirm-dialog',
|
||||||
|
standalone: true,
|
||||||
|
template: `
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div class="fixed inset-0 z-40 bg-black/30" (click)="cancelled.emit()"></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()"
|
||||||
|
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()"
|
||||||
|
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; }`],
|
||||||
|
})
|
||||||
|
export class ConfirmDialogComponent {
|
||||||
|
/** Dialog title. */
|
||||||
|
title = input.required<string>();
|
||||||
|
/** Label for the confirm button. */
|
||||||
|
confirmLabel = input<string>('Confirm');
|
||||||
|
/** Label for the cancel button. */
|
||||||
|
cancelLabel = input<string>('Cancel');
|
||||||
|
/** Visual style of the confirm button. */
|
||||||
|
variant = input<'primary' | 'danger'>('primary');
|
||||||
|
/** Tailwind width class for the dialog. */
|
||||||
|
widthClass = input<string>('w-[320px]');
|
||||||
|
/** Emitted when the user confirms. */
|
||||||
|
confirmed = output<void>();
|
||||||
|
/** Emitted when the user cancels (backdrop click, Cancel button, or Escape). */
|
||||||
|
cancelled = output<void>();
|
||||||
|
|
||||||
|
@HostListener('document:keydown.escape')
|
||||||
|
onEscape(): void {
|
||||||
|
this.cancelled.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Component, input, output, HostListener } from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic positioned context-menu overlay.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```html
|
||||||
|
* @if (showMenu()) {
|
||||||
|
* <app-context-menu [x]="menuX()" [y]="menuY()" (closed)="closeMenu()" [width]="'w-48'">
|
||||||
|
* <button (click)="doSomething()" class="context-menu-item">Action</button>
|
||||||
|
* </app-context-menu>
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Built-in item classes are available via the host styles:
|
||||||
|
* - `.context-menu-item` — normal item
|
||||||
|
* - `.context-menu-item-danger` — destructive (red) item
|
||||||
|
* - `.context-menu-divider` — horizontal separator
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-context-menu',
|
||||||
|
standalone: true,
|
||||||
|
template: `
|
||||||
|
<!-- Invisible backdrop that captures clicks outside -->
|
||||||
|
<div class="fixed inset-0 z-40" (click)="closed.emit()"></div>
|
||||||
|
<!-- Positioned menu panel -->
|
||||||
|
<div
|
||||||
|
class="fixed z-50 bg-card border border-border rounded-lg shadow-lg py-1"
|
||||||
|
[class]="width()"
|
||||||
|
[style.left.px]="x()"
|
||||||
|
[style.top.px]="y()"
|
||||||
|
>
|
||||||
|
<ng-content />
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
:host {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
/* Convenience classes consumers can use on projected buttons */
|
||||||
|
:host ::ng-deep .context-menu-item {
|
||||||
|
@apply w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground;
|
||||||
|
}
|
||||||
|
:host ::ng-deep .context-menu-item-danger {
|
||||||
|
@apply w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-destructive;
|
||||||
|
}
|
||||||
|
:host ::ng-deep .context-menu-item-icon {
|
||||||
|
@apply w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground flex items-center gap-2;
|
||||||
|
}
|
||||||
|
:host ::ng-deep .context-menu-item-icon-danger {
|
||||||
|
@apply w-full text-left px-3 py-2 text-sm hover:bg-destructive/10 transition-colors text-destructive flex items-center gap-2;
|
||||||
|
}
|
||||||
|
:host ::ng-deep .context-menu-divider {
|
||||||
|
@apply border-t border-border my-1;
|
||||||
|
}
|
||||||
|
:host ::ng-deep .context-menu-empty {
|
||||||
|
@apply px-3 py-1.5 text-sm text-muted-foreground;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ContextMenuComponent {
|
||||||
|
/** Horizontal position (px from left). */
|
||||||
|
x = input.required<number>();
|
||||||
|
/** Vertical position (px from top). */
|
||||||
|
y = input.required<number>();
|
||||||
|
/** Tailwind width class for the panel (default `w-48`). */
|
||||||
|
width = input<string>('w-48');
|
||||||
|
/** Emitted when the menu should close (backdrop click or Escape). */
|
||||||
|
closed = output<void>();
|
||||||
|
|
||||||
|
@HostListener('document:keydown.escape')
|
||||||
|
onEscape(): void {
|
||||||
|
this.closed.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Component, input } from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable user avatar circle.
|
||||||
|
*
|
||||||
|
* Displays the user's image when `avatarUrl` is provided, otherwise
|
||||||
|
* falls back to a colored circle with the first letter of `name`.
|
||||||
|
*
|
||||||
|
* Optional rings (e.g. voice-state colours) can be applied via `ringClass`.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```html
|
||||||
|
* <app-user-avatar [name]="user.displayName" [avatarUrl]="user.avatarUrl" size="md" />
|
||||||
|
* <app-user-avatar [name]="user.displayName" size="sm" ringClass="ring-2 ring-green-500" />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-user-avatar',
|
||||||
|
standalone: true,
|
||||||
|
template: `
|
||||||
|
@if (avatarUrl()) {
|
||||||
|
<img
|
||||||
|
[src]="avatarUrl()"
|
||||||
|
alt=""
|
||||||
|
class="rounded-full object-cover"
|
||||||
|
[class]="sizeClasses() + ' ' + ringClass()"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<div
|
||||||
|
class="rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
|
||||||
|
[class]="sizeClasses() + ' ' + textClass() + ' ' + ringClass()"
|
||||||
|
>
|
||||||
|
{{ initial() }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: [`:host { display: contents; }`],
|
||||||
|
})
|
||||||
|
export class UserAvatarComponent {
|
||||||
|
/** Display name — first character is used as fallback initial. */
|
||||||
|
name = input.required<string>();
|
||||||
|
/** Optional avatar image URL. */
|
||||||
|
avatarUrl = input<string | undefined | null>();
|
||||||
|
/** Predefined size: `xs` (28px), `sm` (32px), `md` (40px), `lg` (48px). */
|
||||||
|
size = input<'xs' | 'sm' | 'md' | 'lg'>('sm');
|
||||||
|
/** Extra ring classes, e.g. `'ring-2 ring-green-500'`. */
|
||||||
|
ringClass = input<string>('');
|
||||||
|
|
||||||
|
/** Compute the first-letter initial. */
|
||||||
|
initial(): string {
|
||||||
|
return this.name()?.charAt(0)?.toUpperCase() ?? '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map size token to Tailwind dimension classes. */
|
||||||
|
sizeClasses(): string {
|
||||||
|
switch (this.size()) {
|
||||||
|
case 'xs': return 'w-7 h-7';
|
||||||
|
case 'sm': return 'w-8 h-8';
|
||||||
|
case 'md': return 'w-10 h-10';
|
||||||
|
case 'lg': return 'w-12 h-12';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map size token to text size for initials. */
|
||||||
|
textClass(): string {
|
||||||
|
switch (this.size()) {
|
||||||
|
case 'xs': return 'text-xs';
|
||||||
|
case 'sm': return 'text-sm';
|
||||||
|
case 'md': return 'text-base font-semibold';
|
||||||
|
case 'lg': return 'text-lg font-semibold';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/app/shared/index.ts
Normal file
6
src/app/shared/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Shared reusable UI components barrel.
|
||||||
|
*/
|
||||||
|
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';
|
||||||
@@ -1,27 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Root state definition and barrel exports for the NgRx store.
|
||||||
|
*
|
||||||
|
* Three feature slices:
|
||||||
|
* - **messages** – chat messages, reactions, sync state
|
||||||
|
* - **users** – online users, bans, roles, voice state
|
||||||
|
* - **rooms** – servers / rooms, channels, search results
|
||||||
|
*/
|
||||||
import { isDevMode } from '@angular/core';
|
import { isDevMode } from '@angular/core';
|
||||||
import { ActionReducerMap, MetaReducer } from '@ngrx/store';
|
import { ActionReducerMap, MetaReducer } from '@ngrx/store';
|
||||||
import { messagesReducer, MessagesState } from './messages/messages.reducer';
|
import { messagesReducer, MessagesState } from './messages/messages.reducer';
|
||||||
import { usersReducer, UsersState } from './users/users.reducer';
|
import { usersReducer, UsersState } from './users/users.reducer';
|
||||||
import { roomsReducer, RoomsState } from './rooms/rooms.reducer';
|
import { roomsReducer, RoomsState } from './rooms/rooms.reducer';
|
||||||
|
|
||||||
|
/** Combined root state of the application. */
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
|
/** Chat messages feature slice. */
|
||||||
messages: MessagesState;
|
messages: MessagesState;
|
||||||
|
/** Users / presence feature slice. */
|
||||||
users: UsersState;
|
users: UsersState;
|
||||||
|
/** Rooms / servers feature slice. */
|
||||||
rooms: RoomsState;
|
rooms: RoomsState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Top-level reducer map registered with `StoreModule.forRoot()`. */
|
||||||
export const reducers: ActionReducerMap<AppState> = {
|
export const reducers: ActionReducerMap<AppState> = {
|
||||||
messages: messagesReducer,
|
messages: messagesReducer,
|
||||||
users: usersReducer,
|
users: usersReducer,
|
||||||
rooms: roomsReducer,
|
rooms: roomsReducer,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Meta-reducers (e.g. logging) enabled only in development builds. */
|
||||||
export const metaReducers: MetaReducer<AppState>[] = isDevMode() ? [] : [];
|
export const metaReducers: MetaReducer<AppState>[] = isDevMode() ? [] : [];
|
||||||
|
|
||||||
// Re-export actions
|
// Re-export actions
|
||||||
export * as MessagesActions from './messages/messages.actions';
|
export { MessagesActions } from './messages/messages.actions';
|
||||||
export * as UsersActions from './users/users.actions';
|
export { UsersActions } from './users/users.actions';
|
||||||
export * as RoomsActions from './rooms/rooms.actions';
|
export { RoomsActions } from './rooms/rooms.actions';
|
||||||
|
|
||||||
// Re-export selectors explicitly to avoid conflicts
|
// Re-export selectors explicitly to avoid conflicts
|
||||||
export {
|
export {
|
||||||
@@ -57,6 +71,7 @@ export {
|
|||||||
|
|
||||||
// Re-export effects
|
// Re-export effects
|
||||||
export { MessagesEffects } from './messages/messages.effects';
|
export { MessagesEffects } from './messages/messages.effects';
|
||||||
|
export { MessagesSyncEffects } from './messages/messages-sync.effects';
|
||||||
export { UsersEffects } from './users/users.effects';
|
export { UsersEffects } from './users/users.effects';
|
||||||
export { RoomsEffects } from './rooms/rooms.effects';
|
export { RoomsEffects } from './rooms/rooms.effects';
|
||||||
|
|
||||||
|
|||||||
445
src/app/store/messages/messages-incoming.handlers.ts
Normal file
445
src/app/store/messages/messages-incoming.handlers.ts
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
/**
|
||||||
|
* Handler functions for incoming P2P messages dispatched via WebRTC.
|
||||||
|
*
|
||||||
|
* Each handler is a pure function that receives an event and a context
|
||||||
|
* object containing the required services. Handlers return an
|
||||||
|
* `Observable<Action>` or `EMPTY` when no store action needs dispatching.
|
||||||
|
*
|
||||||
|
* The handler registry at the bottom maps event `type` strings to their
|
||||||
|
* handlers, and `dispatchIncomingMessage()` is the single entry point
|
||||||
|
* consumed by the `incomingMessages$` effect.
|
||||||
|
*/
|
||||||
|
import { Observable, of, from, EMPTY } from 'rxjs';
|
||||||
|
import { mergeMap } from 'rxjs/operators';
|
||||||
|
import { Action } from '@ngrx/store';
|
||||||
|
import { Message } from '../../core/models';
|
||||||
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
|
import { AttachmentService } from '../../core/services/attachment.service';
|
||||||
|
import { MessagesActions } from './messages.actions';
|
||||||
|
import {
|
||||||
|
INVENTORY_LIMIT,
|
||||||
|
CHUNK_SIZE,
|
||||||
|
FULL_SYNC_LIMIT,
|
||||||
|
chunkArray,
|
||||||
|
buildInventoryItem,
|
||||||
|
buildLocalInventoryMap,
|
||||||
|
findMissingIds,
|
||||||
|
hydrateMessage,
|
||||||
|
mergeIncomingMessage,
|
||||||
|
} from './messages.helpers';
|
||||||
|
|
||||||
|
/** Shared context injected into each handler function. */
|
||||||
|
export interface IncomingMessageContext {
|
||||||
|
db: DatabaseService;
|
||||||
|
webrtc: WebRTCService;
|
||||||
|
attachments: AttachmentService;
|
||||||
|
currentUser: any;
|
||||||
|
currentRoom: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signature for an incoming-message handler function. */
|
||||||
|
type MessageHandler = (
|
||||||
|
event: any,
|
||||||
|
ctx: IncomingMessageContext,
|
||||||
|
) => Observable<Action>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Responds to a peer's inventory request by building and sending
|
||||||
|
* our local message inventory in chunks.
|
||||||
|
*/
|
||||||
|
function handleInventoryRequest(
|
||||||
|
event: any,
|
||||||
|
{ db, webrtc }: IncomingMessageContext,
|
||||||
|
): Observable<Action> {
|
||||||
|
const { roomId, fromPeerId } = event;
|
||||||
|
if (!roomId || !fromPeerId) return EMPTY;
|
||||||
|
|
||||||
|
return from(
|
||||||
|
(async () => {
|
||||||
|
const messages = await db.getMessages(roomId, INVENTORY_LIMIT, 0);
|
||||||
|
const items = await Promise.all(
|
||||||
|
messages.map((msg) => buildInventoryItem(msg, db)),
|
||||||
|
);
|
||||||
|
items.sort((a, b) => a.ts - b.ts);
|
||||||
|
|
||||||
|
for (const chunk of chunkArray(items, CHUNK_SIZE)) {
|
||||||
|
webrtc.sendToPeer(fromPeerId, {
|
||||||
|
type: 'chat-inventory',
|
||||||
|
roomId,
|
||||||
|
items: chunk,
|
||||||
|
total: items.length,
|
||||||
|
index: 0,
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
).pipe(mergeMap(() => EMPTY));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares a peer's inventory against local state
|
||||||
|
* and requests any missing or stale messages.
|
||||||
|
*/
|
||||||
|
function handleInventory(
|
||||||
|
event: any,
|
||||||
|
{ db, webrtc }: IncomingMessageContext,
|
||||||
|
): Observable<Action> {
|
||||||
|
const { roomId, fromPeerId, items } = event;
|
||||||
|
if (!roomId || !Array.isArray(items) || !fromPeerId) return EMPTY;
|
||||||
|
|
||||||
|
return from(
|
||||||
|
(async () => {
|
||||||
|
const local = await db.getMessages(roomId, INVENTORY_LIMIT, 0);
|
||||||
|
const localMap = await buildLocalInventoryMap(local, db);
|
||||||
|
const missing = findMissingIds(items, localMap);
|
||||||
|
|
||||||
|
for (const chunk of chunkArray(missing, CHUNK_SIZE)) {
|
||||||
|
webrtc.sendToPeer(fromPeerId, {
|
||||||
|
type: 'chat-sync-request-ids',
|
||||||
|
roomId,
|
||||||
|
ids: chunk,
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
).pipe(mergeMap(() => EMPTY));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Responds to a peer's request for specific message IDs by sending
|
||||||
|
* hydrated messages along with their attachment metadata.
|
||||||
|
*/
|
||||||
|
function handleSyncRequestIds(
|
||||||
|
event: any,
|
||||||
|
{ db, webrtc, attachments }: IncomingMessageContext,
|
||||||
|
): Observable<Action> {
|
||||||
|
const { roomId, ids, fromPeerId } = event;
|
||||||
|
if (!Array.isArray(ids) || !fromPeerId) return EMPTY;
|
||||||
|
|
||||||
|
return from(
|
||||||
|
(async () => {
|
||||||
|
const maybeMessages = await Promise.all(
|
||||||
|
(ids as string[]).map((id) => db.getMessageById(id)),
|
||||||
|
);
|
||||||
|
const messages = maybeMessages.filter(
|
||||||
|
(msg): msg is Message => !!msg,
|
||||||
|
);
|
||||||
|
const hydrated = await Promise.all(
|
||||||
|
messages.map((msg) => hydrateMessage(msg, db)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const msgIds = hydrated.map((msg) => msg.id);
|
||||||
|
const attachmentMetas =
|
||||||
|
attachments.getAttachmentMetasForMessages(msgIds);
|
||||||
|
|
||||||
|
for (const chunk of chunkArray(hydrated, CHUNK_SIZE)) {
|
||||||
|
const chunkAttachments: Record<string, any> = {};
|
||||||
|
for (const m of chunk) {
|
||||||
|
if (attachmentMetas[m.id])
|
||||||
|
chunkAttachments[m.id] = attachmentMetas[m.id];
|
||||||
|
}
|
||||||
|
webrtc.sendToPeer(fromPeerId, {
|
||||||
|
type: 'chat-sync-batch',
|
||||||
|
roomId: roomId || '',
|
||||||
|
messages: chunk,
|
||||||
|
attachments:
|
||||||
|
Object.keys(chunkAttachments).length > 0
|
||||||
|
? chunkAttachments
|
||||||
|
: undefined,
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
).pipe(mergeMap(() => EMPTY));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a batch of synced messages from a peer: merges each into
|
||||||
|
* the local DB, registers attachment metadata, and auto-requests any
|
||||||
|
* missing image attachments.
|
||||||
|
*/
|
||||||
|
function handleSyncBatch(
|
||||||
|
event: any,
|
||||||
|
{ db, attachments }: IncomingMessageContext,
|
||||||
|
): Observable<Action> {
|
||||||
|
if (!Array.isArray(event.messages)) return EMPTY;
|
||||||
|
|
||||||
|
if (event.attachments && typeof event.attachments === 'object') {
|
||||||
|
attachments.registerSyncedAttachments(event.attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
return from(processSyncBatch(event, db, attachments)).pipe(
|
||||||
|
mergeMap((toUpsert) =>
|
||||||
|
toUpsert.length > 0
|
||||||
|
? of(MessagesActions.syncMessages({ messages: toUpsert }))
|
||||||
|
: EMPTY,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merges each incoming message and collects those that changed. */
|
||||||
|
async function processSyncBatch(
|
||||||
|
event: any,
|
||||||
|
db: DatabaseService,
|
||||||
|
attachments: AttachmentService,
|
||||||
|
): Promise<Message[]> {
|
||||||
|
const toUpsert: Message[] = [];
|
||||||
|
|
||||||
|
for (const incoming of event.messages as Message[]) {
|
||||||
|
const { message, changed } = await mergeIncomingMessage(incoming, db);
|
||||||
|
if (changed) toUpsert.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.attachments && event.fromPeerId) {
|
||||||
|
requestMissingImages(event.attachments, attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
return toUpsert;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auto-requests any unavailable image attachments from any connected peer. */
|
||||||
|
function requestMissingImages(
|
||||||
|
attachmentMap: Record<string, any[]>,
|
||||||
|
attachments: AttachmentService,
|
||||||
|
): void {
|
||||||
|
for (const [msgId, metas] of Object.entries(attachmentMap)) {
|
||||||
|
for (const meta of metas) {
|
||||||
|
if (!meta.isImage) continue;
|
||||||
|
const atts = attachments.getForMessage(msgId);
|
||||||
|
const att = atts.find((a: any) => a.id === meta.id);
|
||||||
|
if (
|
||||||
|
att &&
|
||||||
|
!att.available &&
|
||||||
|
!(att.receivedBytes && att.receivedBytes > 0)
|
||||||
|
) {
|
||||||
|
attachments.requestImageFromAnyPeer(msgId, att);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saves an incoming chat message to DB and dispatches receiveMessage. */
|
||||||
|
function handleChatMessage(
|
||||||
|
event: any,
|
||||||
|
{ db, currentUser }: IncomingMessageContext,
|
||||||
|
): Observable<Action> {
|
||||||
|
const msg = event.message;
|
||||||
|
if (!msg) return EMPTY;
|
||||||
|
|
||||||
|
// Skip our own messages (reflected via server relay)
|
||||||
|
const isOwnMessage =
|
||||||
|
msg.senderId === currentUser?.id ||
|
||||||
|
msg.senderId === currentUser?.oderId;
|
||||||
|
if (isOwnMessage) return EMPTY;
|
||||||
|
|
||||||
|
db.saveMessage(msg);
|
||||||
|
return of(MessagesActions.receiveMessage({ message: msg }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies a remote message edit to the local DB and store. */
|
||||||
|
function handleMessageEdited(
|
||||||
|
event: any,
|
||||||
|
{ db }: IncomingMessageContext,
|
||||||
|
): Observable<Action> {
|
||||||
|
if (!event.messageId || !event.content) return EMPTY;
|
||||||
|
|
||||||
|
db.updateMessage(event.messageId, {
|
||||||
|
content: event.content,
|
||||||
|
editedAt: event.editedAt,
|
||||||
|
});
|
||||||
|
return of(
|
||||||
|
MessagesActions.editMessageSuccess({
|
||||||
|
messageId: event.messageId,
|
||||||
|
content: event.content,
|
||||||
|
editedAt: event.editedAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies a remote message deletion to the local DB and store. */
|
||||||
|
function handleMessageDeleted(
|
||||||
|
event: any,
|
||||||
|
{ db }: IncomingMessageContext,
|
||||||
|
): Observable<Action> {
|
||||||
|
if (!event.messageId) return EMPTY;
|
||||||
|
|
||||||
|
db.deleteMessage(event.messageId);
|
||||||
|
return of(
|
||||||
|
MessagesActions.deleteMessageSuccess({ messageId: event.messageId }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saves an incoming reaction to DB and updates the store. */
|
||||||
|
function handleReactionAdded(
|
||||||
|
event: any,
|
||||||
|
{ db }: IncomingMessageContext,
|
||||||
|
): Observable<Action> {
|
||||||
|
if (!event.messageId || !event.reaction) return EMPTY;
|
||||||
|
|
||||||
|
db.saveReaction(event.reaction);
|
||||||
|
return of(MessagesActions.addReactionSuccess({ reaction: event.reaction }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes a reaction from DB and updates the store. */
|
||||||
|
function handleReactionRemoved(
|
||||||
|
event: any,
|
||||||
|
{ db }: IncomingMessageContext,
|
||||||
|
): Observable<Action> {
|
||||||
|
if (!event.messageId || !event.oderId || !event.emoji) return EMPTY;
|
||||||
|
|
||||||
|
db.removeReaction(event.messageId, event.oderId, event.emoji);
|
||||||
|
return of(
|
||||||
|
MessagesActions.removeReactionSuccess({
|
||||||
|
messageId: event.messageId,
|
||||||
|
oderId: event.oderId,
|
||||||
|
emoji: event.emoji,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileAnnounce(
|
||||||
|
event: any,
|
||||||
|
{ attachments }: IncomingMessageContext,
|
||||||
|
): Observable<Action> {
|
||||||
|
attachments.handleFileAnnounce(event);
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileChunk(
|
||||||
|
event: any,
|
||||||
|
{ attachments }: IncomingMessageContext,
|
||||||
|
): Observable<Action> {
|
||||||
|
attachments.handleFileChunk(event);
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileRequest(
|
||||||
|
event: any,
|
||||||
|
{ attachments }: IncomingMessageContext,
|
||||||
|
): Observable<Action> {
|
||||||
|
attachments.handleFileRequest(event);
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileCancel(
|
||||||
|
event: any,
|
||||||
|
{ attachments }: IncomingMessageContext,
|
||||||
|
): Observable<Action> {
|
||||||
|
attachments.handleFileCancel(event);
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileNotFound(
|
||||||
|
event: any,
|
||||||
|
{ attachments }: IncomingMessageContext,
|
||||||
|
): Observable<Action> {
|
||||||
|
attachments.handleFileNotFound(event);
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares a peer's dataset summary and requests full sync
|
||||||
|
* if the peer has newer or more data.
|
||||||
|
*/
|
||||||
|
function handleSyncSummary(
|
||||||
|
event: any,
|
||||||
|
{ db, webrtc, currentRoom }: IncomingMessageContext,
|
||||||
|
): Observable<Action> {
|
||||||
|
if (!currentRoom) return EMPTY;
|
||||||
|
|
||||||
|
return from(
|
||||||
|
(async () => {
|
||||||
|
const local = await db.getMessages(currentRoom.id, FULL_SYNC_LIMIT, 0);
|
||||||
|
const localCount = local.length;
|
||||||
|
const localLastUpdated = local.reduce(
|
||||||
|
(max, m) => Math.max(max, m.editedAt || m.timestamp || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const remoteLastUpdated = event.lastUpdated || 0;
|
||||||
|
const remoteCount = event.count || 0;
|
||||||
|
|
||||||
|
const identical =
|
||||||
|
localLastUpdated === remoteLastUpdated && localCount === remoteCount;
|
||||||
|
const needsSync =
|
||||||
|
remoteLastUpdated > localLastUpdated ||
|
||||||
|
(remoteLastUpdated === localLastUpdated && remoteCount > localCount);
|
||||||
|
|
||||||
|
if (!identical && needsSync && event.fromPeerId) {
|
||||||
|
webrtc.sendToPeer(event.fromPeerId, {
|
||||||
|
type: 'chat-sync-request',
|
||||||
|
roomId: currentRoom.id,
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
).pipe(mergeMap(() => EMPTY));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Responds to a peer's full sync request by sending all local messages. */
|
||||||
|
function handleSyncRequest(
|
||||||
|
event: any,
|
||||||
|
{ db, webrtc, currentRoom }: IncomingMessageContext,
|
||||||
|
): Observable<Action> {
|
||||||
|
if (!currentRoom || !event.fromPeerId) return EMPTY;
|
||||||
|
|
||||||
|
return from(
|
||||||
|
(async () => {
|
||||||
|
const all = await db.getMessages(currentRoom.id, FULL_SYNC_LIMIT, 0);
|
||||||
|
webrtc.sendToPeer(event.fromPeerId, {
|
||||||
|
type: 'chat-sync-full',
|
||||||
|
roomId: currentRoom.id,
|
||||||
|
messages: all,
|
||||||
|
} as any);
|
||||||
|
})(),
|
||||||
|
).pipe(mergeMap(() => EMPTY));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merges a full message dump from a peer into the local DB and store. */
|
||||||
|
function handleSyncFull(
|
||||||
|
event: any,
|
||||||
|
{ db }: IncomingMessageContext,
|
||||||
|
): Observable<Action> {
|
||||||
|
if (!event.messages || !Array.isArray(event.messages)) return EMPTY;
|
||||||
|
|
||||||
|
event.messages.forEach((msg: Message) => db.saveMessage(msg));
|
||||||
|
return of(MessagesActions.syncMessages({ messages: event.messages }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map of event types to their handler functions. */
|
||||||
|
const HANDLER_MAP: Readonly<Record<string, MessageHandler>> = {
|
||||||
|
// Inventory-based sync protocol
|
||||||
|
'chat-inventory-request': handleInventoryRequest,
|
||||||
|
'chat-inventory': handleInventory,
|
||||||
|
'chat-sync-request-ids': handleSyncRequestIds,
|
||||||
|
'chat-sync-batch': handleSyncBatch,
|
||||||
|
|
||||||
|
// Chat messages
|
||||||
|
'chat-message': handleChatMessage,
|
||||||
|
'message-edited': handleMessageEdited,
|
||||||
|
'message-deleted': handleMessageDeleted,
|
||||||
|
|
||||||
|
// Reactions
|
||||||
|
'reaction-added': handleReactionAdded,
|
||||||
|
'reaction-removed': handleReactionRemoved,
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
'file-announce': handleFileAnnounce,
|
||||||
|
'file-chunk': handleFileChunk,
|
||||||
|
'file-request': handleFileRequest,
|
||||||
|
'file-cancel': handleFileCancel,
|
||||||
|
'file-not-found': handleFileNotFound,
|
||||||
|
|
||||||
|
// Legacy sync handshake
|
||||||
|
'chat-sync-summary': handleSyncSummary,
|
||||||
|
'chat-sync-request': handleSyncRequest,
|
||||||
|
'chat-sync-full': handleSyncFull,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes an incoming P2P message to the appropriate handler.
|
||||||
|
* Returns `EMPTY` if the event type is unknown or has no relevant handler.
|
||||||
|
*/
|
||||||
|
export function dispatchIncomingMessage(
|
||||||
|
event: any,
|
||||||
|
ctx: IncomingMessageContext,
|
||||||
|
): Observable<Action> {
|
||||||
|
const handler = HANDLER_MAP[event.type];
|
||||||
|
return handler ? handler(event, ctx) : EMPTY;
|
||||||
|
}
|
||||||
211
src/app/store/messages/messages-sync.effects.ts
Normal file
211
src/app/store/messages/messages-sync.effects.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* Sync-lifecycle effects for the messages store slice.
|
||||||
|
*
|
||||||
|
* These effects manage the periodic sync polling, peer-connect
|
||||||
|
* handshakes, and join-room kickoff that keep message databases
|
||||||
|
* in sync across peers.
|
||||||
|
*
|
||||||
|
* Extracted from the monolithic MessagesEffects to keep each
|
||||||
|
* class focused on a single concern.
|
||||||
|
*/
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
import { of, from, timer, Subject, EMPTY } from 'rxjs';
|
||||||
|
import {
|
||||||
|
map,
|
||||||
|
mergeMap,
|
||||||
|
catchError,
|
||||||
|
withLatestFrom,
|
||||||
|
tap,
|
||||||
|
filter,
|
||||||
|
exhaustMap,
|
||||||
|
switchMap,
|
||||||
|
repeat,
|
||||||
|
takeUntil,
|
||||||
|
} from 'rxjs/operators';
|
||||||
|
import { MessagesActions } from './messages.actions';
|
||||||
|
import { RoomsActions } from '../rooms/rooms.actions';
|
||||||
|
import { selectMessagesSyncing } from './messages.selectors';
|
||||||
|
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
||||||
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
|
import {
|
||||||
|
INVENTORY_LIMIT,
|
||||||
|
FULL_SYNC_LIMIT,
|
||||||
|
SYNC_POLL_FAST_MS,
|
||||||
|
SYNC_POLL_SLOW_MS,
|
||||||
|
SYNC_TIMEOUT_MS,
|
||||||
|
getLatestTimestamp,
|
||||||
|
} from './messages.helpers';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MessagesSyncEffects {
|
||||||
|
private readonly actions$ = inject(Actions);
|
||||||
|
private readonly store = inject(Store);
|
||||||
|
private readonly db = inject(DatabaseService);
|
||||||
|
private readonly webrtc = inject(WebRTCService);
|
||||||
|
|
||||||
|
/** Tracks whether the last sync cycle found no new messages. */
|
||||||
|
private lastSyncClean = false;
|
||||||
|
|
||||||
|
/** Subject to reset the periodic sync timer. */
|
||||||
|
private readonly syncReset$ = new Subject<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a new peer connects, sends our dataset summary and an
|
||||||
|
* inventory request so both sides can reconcile.
|
||||||
|
*/
|
||||||
|
peerConnectedSync$ = createEffect(
|
||||||
|
() =>
|
||||||
|
this.webrtc.onPeerConnected.pipe(
|
||||||
|
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||||
|
mergeMap(([peerId, room]) => {
|
||||||
|
if (!room) return EMPTY;
|
||||||
|
return from(
|
||||||
|
this.db.getMessages(room.id, FULL_SYNC_LIMIT, 0),
|
||||||
|
).pipe(
|
||||||
|
tap((messages) => {
|
||||||
|
const count = messages.length;
|
||||||
|
const lastUpdated = getLatestTimestamp(messages);
|
||||||
|
this.webrtc.sendToPeer(peerId, {
|
||||||
|
type: 'chat-sync-summary',
|
||||||
|
roomId: room.id,
|
||||||
|
count,
|
||||||
|
lastUpdated,
|
||||||
|
} as any);
|
||||||
|
this.webrtc.sendToPeer(peerId, {
|
||||||
|
type: 'chat-inventory-request',
|
||||||
|
roomId: room.id,
|
||||||
|
} as any);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
{ dispatch: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the user joins a room, sends a summary and inventory
|
||||||
|
* request to every already-connected peer.
|
||||||
|
*/
|
||||||
|
joinRoomSyncKickoff$ = createEffect(
|
||||||
|
() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(RoomsActions.joinRoomSuccess),
|
||||||
|
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||||
|
mergeMap(([{ room }, currentRoom]) => {
|
||||||
|
const activeRoom = currentRoom || room;
|
||||||
|
if (!activeRoom) return EMPTY;
|
||||||
|
|
||||||
|
return from(
|
||||||
|
this.db.getMessages(activeRoom.id, FULL_SYNC_LIMIT, 0),
|
||||||
|
).pipe(
|
||||||
|
tap((messages) => {
|
||||||
|
const count = messages.length;
|
||||||
|
const lastUpdated = getLatestTimestamp(messages);
|
||||||
|
for (const pid of this.webrtc.getConnectedPeers()) {
|
||||||
|
try {
|
||||||
|
this.webrtc.sendToPeer(pid, {
|
||||||
|
type: 'chat-sync-summary',
|
||||||
|
roomId: activeRoom.id,
|
||||||
|
count,
|
||||||
|
lastUpdated,
|
||||||
|
} as any);
|
||||||
|
this.webrtc.sendToPeer(pid, {
|
||||||
|
type: 'chat-inventory-request',
|
||||||
|
roomId: activeRoom.id,
|
||||||
|
} as any);
|
||||||
|
} catch {
|
||||||
|
/* peer may have disconnected */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
{ dispatch: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alternates between fast (10 s) and slow (15 min) sync intervals.
|
||||||
|
* Sends inventory requests to all connected peers.
|
||||||
|
*/
|
||||||
|
periodicSyncPoll$ = createEffect(() =>
|
||||||
|
timer(SYNC_POLL_FAST_MS).pipe(
|
||||||
|
repeat({
|
||||||
|
delay: () =>
|
||||||
|
timer(
|
||||||
|
this.lastSyncClean ? SYNC_POLL_SLOW_MS : SYNC_POLL_FAST_MS,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
takeUntil(this.syncReset$),
|
||||||
|
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||||
|
filter(
|
||||||
|
([, room]) =>
|
||||||
|
!!room && this.webrtc.getConnectedPeers().length > 0,
|
||||||
|
),
|
||||||
|
exhaustMap(([, room]) => {
|
||||||
|
const peers = this.webrtc.getConnectedPeers();
|
||||||
|
if (!room || peers.length === 0) {
|
||||||
|
return of(MessagesActions.syncComplete());
|
||||||
|
}
|
||||||
|
|
||||||
|
return from(
|
||||||
|
this.db.getMessages(room.id, INVENTORY_LIMIT, 0),
|
||||||
|
).pipe(
|
||||||
|
map(() => {
|
||||||
|
for (const pid of peers) {
|
||||||
|
try {
|
||||||
|
this.webrtc.sendToPeer(pid, {
|
||||||
|
type: 'chat-inventory-request',
|
||||||
|
roomId: room.id,
|
||||||
|
} as any);
|
||||||
|
} catch {
|
||||||
|
/* peer may have disconnected */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MessagesActions.startSync();
|
||||||
|
}),
|
||||||
|
catchError(() => {
|
||||||
|
this.lastSyncClean = false;
|
||||||
|
return of(MessagesActions.syncComplete());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-completes a sync cycle after a timeout if no messages arrive.
|
||||||
|
* Switches to slow polling when the cycle is clean.
|
||||||
|
*/
|
||||||
|
syncTimeout$ = createEffect(() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(MessagesActions.startSync),
|
||||||
|
switchMap(() => from(
|
||||||
|
new Promise<void>((resolve) => setTimeout(resolve, SYNC_TIMEOUT_MS)),
|
||||||
|
)),
|
||||||
|
withLatestFrom(this.store.select(selectMessagesSyncing)),
|
||||||
|
filter(([, syncing]) => syncing),
|
||||||
|
map(() => {
|
||||||
|
this.lastSyncClean = true;
|
||||||
|
return MessagesActions.syncComplete();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a peer (re)connects, revert to aggressive polling in case
|
||||||
|
* we missed messages while disconnected.
|
||||||
|
*/
|
||||||
|
syncReceivedMessages$ = createEffect(
|
||||||
|
() =>
|
||||||
|
this.webrtc.onPeerConnected.pipe(
|
||||||
|
tap(() => {
|
||||||
|
this.lastSyncClean = false;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
{ dispatch: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,112 +1,51 @@
|
|||||||
import { createAction, props } from '@ngrx/store';
|
/**
|
||||||
|
* Messages store actions using `createActionGroup` for concise definitions.
|
||||||
|
*
|
||||||
|
* Action type strings follow the `[Messages] Event Name` convention and are
|
||||||
|
* generated automatically by NgRx from the `source` and event key.
|
||||||
|
*/
|
||||||
|
import { createActionGroup, emptyProps, props } from '@ngrx/store';
|
||||||
import { Message, Reaction } from '../../core/models';
|
import { Message, Reaction } from '../../core/models';
|
||||||
|
|
||||||
// Load messages
|
export const MessagesActions = createActionGroup({
|
||||||
export const loadMessages = createAction(
|
source: 'Messages',
|
||||||
'[Messages] Load Messages',
|
events: {
|
||||||
props<{ roomId: string }>()
|
/** Triggers loading messages for the given room from the local database. */
|
||||||
);
|
'Load Messages': props<{ roomId: string }>(),
|
||||||
|
'Load Messages Success': props<{ messages: Message[] }>(),
|
||||||
|
'Load Messages Failure': props<{ error: string }>(),
|
||||||
|
|
||||||
export const loadMessagesSuccess = createAction(
|
/** Sends a new chat message to the current room and broadcasts to peers. */
|
||||||
'[Messages] Load Messages Success',
|
'Send Message': props<{ content: string; replyToId?: string; channelId?: string }>(),
|
||||||
props<{ messages: Message[] }>()
|
'Send Message Success': props<{ message: Message }>(),
|
||||||
);
|
'Send Message Failure': props<{ error: string }>(),
|
||||||
|
|
||||||
export const loadMessagesFailure = createAction(
|
/** Applies a message received from a remote peer to the local store. */
|
||||||
'[Messages] Load Messages Failure',
|
'Receive Message': props<{ message: Message }>(),
|
||||||
props<{ error: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Send message
|
'Edit Message': props<{ messageId: string; content: string }>(),
|
||||||
export const sendMessage = createAction(
|
'Edit Message Success': props<{ messageId: string; content: string; editedAt: number }>(),
|
||||||
'[Messages] Send Message',
|
'Edit Message Failure': props<{ error: string }>(),
|
||||||
props<{ content: string; replyToId?: string; channelId?: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const sendMessageSuccess = createAction(
|
'Delete Message': props<{ messageId: string }>(),
|
||||||
'[Messages] Send Message Success',
|
'Delete Message Success': props<{ messageId: string }>(),
|
||||||
props<{ message: Message }>()
|
'Delete Message Failure': props<{ error: string }>(),
|
||||||
);
|
/** Soft-deletes a message by an admin (can delete any message). */
|
||||||
|
'Admin Delete Message': props<{ messageId: string }>(),
|
||||||
|
|
||||||
export const sendMessageFailure = createAction(
|
'Add Reaction': props<{ messageId: string; emoji: string }>(),
|
||||||
'[Messages] Send Message Failure',
|
'Add Reaction Success': props<{ reaction: Reaction }>(),
|
||||||
props<{ error: string }>()
|
'Remove Reaction': props<{ messageId: string; emoji: string }>(),
|
||||||
);
|
'Remove Reaction Success': props<{ messageId: string; emoji: string; oderId: string }>(),
|
||||||
|
|
||||||
// Receive message from peer
|
/** Merges a batch of messages received from a peer into the local store. */
|
||||||
export const receiveMessage = createAction(
|
'Sync Messages': props<{ messages: Message[] }>(),
|
||||||
'[Messages] Receive Message',
|
/** Marks the start of a message sync cycle. */
|
||||||
props<{ message: Message }>()
|
'Start Sync': emptyProps(),
|
||||||
);
|
/** Marks the end of a message sync cycle. */
|
||||||
|
'Sync Complete': emptyProps(),
|
||||||
|
|
||||||
// Edit message
|
/** Removes all messages from the store (e.g. when leaving a room). */
|
||||||
export const editMessage = createAction(
|
'Clear Messages': emptyProps(),
|
||||||
'[Messages] Edit Message',
|
},
|
||||||
props<{ messageId: string; content: string }>()
|
});
|
||||||
);
|
|
||||||
|
|
||||||
export const editMessageSuccess = createAction(
|
|
||||||
'[Messages] Edit Message Success',
|
|
||||||
props<{ messageId: string; content: string; editedAt: number }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const editMessageFailure = createAction(
|
|
||||||
'[Messages] Edit Message Failure',
|
|
||||||
props<{ error: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Delete message
|
|
||||||
export const deleteMessage = createAction(
|
|
||||||
'[Messages] Delete Message',
|
|
||||||
props<{ messageId: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const deleteMessageSuccess = createAction(
|
|
||||||
'[Messages] Delete Message Success',
|
|
||||||
props<{ messageId: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const deleteMessageFailure = createAction(
|
|
||||||
'[Messages] Delete Message Failure',
|
|
||||||
props<{ error: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Admin delete message (can delete any message)
|
|
||||||
export const adminDeleteMessage = createAction(
|
|
||||||
'[Messages] Admin Delete Message',
|
|
||||||
props<{ messageId: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Reactions
|
|
||||||
export const addReaction = createAction(
|
|
||||||
'[Messages] Add Reaction',
|
|
||||||
props<{ messageId: string; emoji: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const addReactionSuccess = createAction(
|
|
||||||
'[Messages] Add Reaction Success',
|
|
||||||
props<{ reaction: Reaction }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const removeReaction = createAction(
|
|
||||||
'[Messages] Remove Reaction',
|
|
||||||
props<{ messageId: string; emoji: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const removeReactionSuccess = createAction(
|
|
||||||
'[Messages] Remove Reaction Success',
|
|
||||||
props<{ messageId: string; emoji: string; oderId: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sync messages from peer
|
|
||||||
export const syncMessages = createAction(
|
|
||||||
'[Messages] Sync Messages',
|
|
||||||
props<{ messages: Message[] }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sync lifecycle
|
|
||||||
export const startSync = createAction('[Messages] Start Sync');
|
|
||||||
export const syncComplete = createAction('[Messages] Sync Complete');
|
|
||||||
|
|
||||||
// Clear messages
|
|
||||||
export const clearMessages = createAction('[Messages] Clear Messages');
|
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Core message CRUD effects (load, send, edit, delete, react)
|
||||||
|
* and the central incoming-message dispatcher.
|
||||||
|
*
|
||||||
|
* Sync-lifecycle effects (polling, peer-connect handshakes) live in
|
||||||
|
* `messages-sync.effects.ts` to keep this file focused.
|
||||||
|
*
|
||||||
|
* The giant `incomingMessages$` switch-case has been replaced by a
|
||||||
|
* handler registry in `messages-incoming.handlers.ts`.
|
||||||
|
*/
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { of, from, timer, Subject } from 'rxjs';
|
import { of, from, EMPTY } from 'rxjs';
|
||||||
import { map, mergeMap, catchError, withLatestFrom, tap, switchMap, filter, exhaustMap, repeat, takeUntil } from 'rxjs/operators';
|
import { mergeMap, catchError, withLatestFrom, switchMap } from 'rxjs/operators';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import * as MessagesActions from './messages.actions';
|
import { MessagesActions } from './messages.actions';
|
||||||
import { selectMessagesSyncing } from './messages.selectors';
|
|
||||||
import { selectCurrentUser } from '../users/users.selectors';
|
import { selectCurrentUser } from '../users/users.selectors';
|
||||||
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
||||||
import { DatabaseService } from '../../core/services/database.service';
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
@@ -13,55 +22,46 @@ import { WebRTCService } from '../../core/services/webrtc.service';
|
|||||||
import { TimeSyncService } from '../../core/services/time-sync.service';
|
import { TimeSyncService } from '../../core/services/time-sync.service';
|
||||||
import { AttachmentService } from '../../core/services/attachment.service';
|
import { AttachmentService } from '../../core/services/attachment.service';
|
||||||
import { Message, Reaction } from '../../core/models';
|
import { Message, Reaction } from '../../core/models';
|
||||||
import * as UsersActions from '../users/users.actions';
|
import { hydrateMessages } from './messages.helpers';
|
||||||
import * as RoomsActions from '../rooms/rooms.actions';
|
import {
|
||||||
|
dispatchIncomingMessage,
|
||||||
|
IncomingMessageContext,
|
||||||
|
} from './messages-incoming.handlers';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MessagesEffects {
|
export class MessagesEffects {
|
||||||
private actions$ = inject(Actions);
|
private readonly actions$ = inject(Actions);
|
||||||
private store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
private db = inject(DatabaseService);
|
private readonly db = inject(DatabaseService);
|
||||||
private webrtc = inject(WebRTCService);
|
private readonly webrtc = inject(WebRTCService);
|
||||||
private timeSync = inject(TimeSyncService);
|
private readonly timeSync = inject(TimeSyncService);
|
||||||
private attachments = inject(AttachmentService);
|
private readonly attachments = inject(AttachmentService);
|
||||||
|
|
||||||
private readonly INVENTORY_LIMIT = 1000; // number of recent messages to consider
|
/** Loads messages for a room from the local database, hydrating reactions. */
|
||||||
private readonly CHUNK_SIZE = 200; // chunk size for inventory/batch transfers
|
|
||||||
private readonly SYNC_POLL_FAST_MS = 10_000; // 10s — aggressive poll
|
|
||||||
private readonly SYNC_POLL_SLOW_MS = 900_000; // 15min — idle poll after clean sync
|
|
||||||
private lastSyncClean = false; // true after a sync cycle with no new messages
|
|
||||||
|
|
||||||
// Load messages from local database (hydrate reactions from separate table)
|
|
||||||
loadMessages$ = createEffect(() =>
|
loadMessages$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(MessagesActions.loadMessages),
|
ofType(MessagesActions.loadMessages),
|
||||||
switchMap(({ roomId }) =>
|
switchMap(({ roomId }) =>
|
||||||
from(this.db.getMessages(roomId)).pipe(
|
from(this.db.getMessages(roomId)).pipe(
|
||||||
mergeMap(async (messages) => {
|
mergeMap(async (messages) => {
|
||||||
// Hydrate each message with its reactions from the reactions table
|
const hydrated = await hydrateMessages(messages, this.db);
|
||||||
const hydrated = await Promise.all(
|
|
||||||
messages.map(async (m) => {
|
|
||||||
const reactions = await this.db.getReactionsForMessage(m.id);
|
|
||||||
return reactions.length > 0 ? { ...m, reactions } : m;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return MessagesActions.loadMessagesSuccess({ messages: hydrated });
|
return MessagesActions.loadMessagesSuccess({ messages: hydrated });
|
||||||
}),
|
}),
|
||||||
catchError((error) =>
|
catchError((error) =>
|
||||||
of(MessagesActions.loadMessagesFailure({ error: error.message }))
|
of(MessagesActions.loadMessagesFailure({ error: error.message })),
|
||||||
)
|
),
|
||||||
)
|
),
|
||||||
)
|
),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send message
|
/** Constructs a new message, persists it locally, and broadcasts to all peers. */
|
||||||
sendMessage$ = createEffect(() =>
|
sendMessage$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(MessagesActions.sendMessage),
|
ofType(MessagesActions.sendMessage),
|
||||||
withLatestFrom(
|
withLatestFrom(
|
||||||
this.store.select(selectCurrentUser),
|
this.store.select(selectCurrentUser),
|
||||||
this.store.select(selectCurrentRoom)
|
this.store.select(selectCurrentRoom),
|
||||||
),
|
),
|
||||||
mergeMap(([{ content, replyToId, channelId }, currentUser, currentRoom]) => {
|
mergeMap(([{ content, replyToId, channelId }, currentUser, currentRoom]) => {
|
||||||
if (!currentUser || !currentRoom) {
|
if (!currentUser || !currentRoom) {
|
||||||
@@ -81,24 +81,18 @@ export class MessagesEffects {
|
|||||||
replyToId,
|
replyToId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save to local DB
|
|
||||||
this.db.saveMessage(message);
|
this.db.saveMessage(message);
|
||||||
|
this.webrtc.broadcastMessage({ type: 'chat-message', message });
|
||||||
// Broadcast to all peers
|
|
||||||
this.webrtc.broadcastMessage({
|
|
||||||
type: 'chat-message',
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
|
|
||||||
return of(MessagesActions.sendMessageSuccess({ message }));
|
return of(MessagesActions.sendMessageSuccess({ message }));
|
||||||
}),
|
}),
|
||||||
catchError((error) =>
|
catchError((error) =>
|
||||||
of(MessagesActions.sendMessageFailure({ error: error.message }))
|
of(MessagesActions.sendMessageFailure({ error: error.message })),
|
||||||
)
|
),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Edit message
|
/** Edits an existing message (author-only), updates DB, and broadcasts the change. */
|
||||||
editMessage$ = createEffect(() =>
|
editMessage$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(MessagesActions.editMessage),
|
ofType(MessagesActions.editMessage),
|
||||||
@@ -109,40 +103,29 @@ export class MessagesEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return from(this.db.getMessageById(messageId)).pipe(
|
return from(this.db.getMessageById(messageId)).pipe(
|
||||||
mergeMap((existingMessage) => {
|
mergeMap((existing) => {
|
||||||
if (!existingMessage) {
|
if (!existing) {
|
||||||
return of(MessagesActions.editMessageFailure({ error: 'Message not found' }));
|
return of(MessagesActions.editMessageFailure({ error: 'Message not found' }));
|
||||||
}
|
}
|
||||||
|
if (existing.senderId !== currentUser.id) {
|
||||||
// Check if user owns the message
|
|
||||||
if (existingMessage.senderId !== currentUser.id) {
|
|
||||||
return of(MessagesActions.editMessageFailure({ error: 'Cannot edit others messages' }));
|
return of(MessagesActions.editMessageFailure({ error: 'Cannot edit others messages' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const editedAt = this.timeSync.now();
|
const editedAt = this.timeSync.now();
|
||||||
|
|
||||||
// Update in DB
|
|
||||||
this.db.updateMessage(messageId, { content, editedAt });
|
this.db.updateMessage(messageId, { content, editedAt });
|
||||||
|
this.webrtc.broadcastMessage({ type: 'message-edited', messageId, content, editedAt });
|
||||||
// Broadcast to peers
|
|
||||||
this.webrtc.broadcastMessage({
|
|
||||||
type: 'message-edited',
|
|
||||||
messageId,
|
|
||||||
content,
|
|
||||||
editedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
return of(MessagesActions.editMessageSuccess({ messageId, content, editedAt }));
|
return of(MessagesActions.editMessageSuccess({ messageId, content, editedAt }));
|
||||||
}),
|
}),
|
||||||
catchError((error) =>
|
catchError((error) =>
|
||||||
of(MessagesActions.editMessageFailure({ error: error.message }))
|
of(MessagesActions.editMessageFailure({ error: error.message })),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete message (user's own)
|
/** Soft-deletes a message (author-only), marks it deleted in DB, and broadcasts. */
|
||||||
deleteMessage$ = createEffect(() =>
|
deleteMessage$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(MessagesActions.deleteMessage),
|
ofType(MessagesActions.deleteMessage),
|
||||||
@@ -153,36 +136,28 @@ export class MessagesEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return from(this.db.getMessageById(messageId)).pipe(
|
return from(this.db.getMessageById(messageId)).pipe(
|
||||||
mergeMap((existingMessage) => {
|
mergeMap((existing) => {
|
||||||
if (!existingMessage) {
|
if (!existing) {
|
||||||
return of(MessagesActions.deleteMessageFailure({ error: 'Message not found' }));
|
return of(MessagesActions.deleteMessageFailure({ error: 'Message not found' }));
|
||||||
}
|
}
|
||||||
|
if (existing.senderId !== currentUser.id) {
|
||||||
// Check if user owns the message
|
|
||||||
if (existingMessage.senderId !== currentUser.id) {
|
|
||||||
return of(MessagesActions.deleteMessageFailure({ error: 'Cannot delete others messages' }));
|
return of(MessagesActions.deleteMessageFailure({ error: 'Cannot delete others messages' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Soft delete - mark as deleted
|
|
||||||
this.db.updateMessage(messageId, { isDeleted: true });
|
this.db.updateMessage(messageId, { isDeleted: true });
|
||||||
|
this.webrtc.broadcastMessage({ type: 'message-deleted', messageId });
|
||||||
// Broadcast to peers
|
|
||||||
this.webrtc.broadcastMessage({
|
|
||||||
type: 'message-deleted',
|
|
||||||
messageId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return of(MessagesActions.deleteMessageSuccess({ messageId }));
|
return of(MessagesActions.deleteMessageSuccess({ messageId }));
|
||||||
}),
|
}),
|
||||||
catchError((error) =>
|
catchError((error) =>
|
||||||
of(MessagesActions.deleteMessageFailure({ error: error.message }))
|
of(MessagesActions.deleteMessageFailure({ error: error.message })),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Admin delete message
|
/** Soft-deletes any message (admin+ only). */
|
||||||
adminDeleteMessage$ = createEffect(() =>
|
adminDeleteMessage$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(MessagesActions.adminDeleteMessage),
|
ofType(MessagesActions.adminDeleteMessage),
|
||||||
@@ -192,38 +167,33 @@ export class MessagesEffects {
|
|||||||
return of(MessagesActions.deleteMessageFailure({ error: 'Not logged in' }));
|
return of(MessagesActions.deleteMessageFailure({ error: 'Not logged in' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check admin permission
|
const hasPermission =
|
||||||
if (currentUser.role !== 'host' && currentUser.role !== 'admin' && currentUser.role !== 'moderator') {
|
currentUser.role === 'host' ||
|
||||||
|
currentUser.role === 'admin' ||
|
||||||
|
currentUser.role === 'moderator';
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
return of(MessagesActions.deleteMessageFailure({ error: 'Permission denied' }));
|
return of(MessagesActions.deleteMessageFailure({ error: 'Permission denied' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Soft delete
|
|
||||||
this.db.updateMessage(messageId, { isDeleted: true });
|
this.db.updateMessage(messageId, { isDeleted: true });
|
||||||
|
this.webrtc.broadcastMessage({ type: 'message-deleted', messageId, deletedBy: currentUser.id });
|
||||||
// Broadcast to peers
|
|
||||||
this.webrtc.broadcastMessage({
|
|
||||||
type: 'message-deleted',
|
|
||||||
messageId,
|
|
||||||
deletedBy: currentUser.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return of(MessagesActions.deleteMessageSuccess({ messageId }));
|
return of(MessagesActions.deleteMessageSuccess({ messageId }));
|
||||||
}),
|
}),
|
||||||
catchError((error) =>
|
catchError((error) =>
|
||||||
of(MessagesActions.deleteMessageFailure({ error: error.message }))
|
of(MessagesActions.deleteMessageFailure({ error: error.message })),
|
||||||
)
|
),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add reaction
|
/** Adds an emoji reaction to a message, persists it, and broadcasts to peers. */
|
||||||
addReaction$ = createEffect(() =>
|
addReaction$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(MessagesActions.addReaction),
|
ofType(MessagesActions.addReaction),
|
||||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||||
mergeMap(([{ messageId, emoji }, currentUser]) => {
|
mergeMap(([{ messageId, emoji }, currentUser]) => {
|
||||||
if (!currentUser) {
|
if (!currentUser) return EMPTY;
|
||||||
return of({ type: 'NO_OP' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const reaction: Reaction = {
|
const reaction: Reaction = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
@@ -234,35 +204,23 @@ export class MessagesEffects {
|
|||||||
timestamp: this.timeSync.now(),
|
timestamp: this.timeSync.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save to DB
|
|
||||||
this.db.saveReaction(reaction);
|
this.db.saveReaction(reaction);
|
||||||
|
this.webrtc.broadcastMessage({ type: 'reaction-added', messageId, reaction });
|
||||||
// Broadcast to peers
|
|
||||||
this.webrtc.broadcastMessage({
|
|
||||||
type: 'reaction-added',
|
|
||||||
messageId,
|
|
||||||
reaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
return of(MessagesActions.addReactionSuccess({ reaction }));
|
return of(MessagesActions.addReactionSuccess({ reaction }));
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove reaction
|
/** Removes the current user's reaction from a message, deletes from DB, and broadcasts. */
|
||||||
removeReaction$ = createEffect(() =>
|
removeReaction$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(MessagesActions.removeReaction),
|
ofType(MessagesActions.removeReaction),
|
||||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||||
mergeMap(([{ messageId, emoji }, currentUser]) => {
|
mergeMap(([{ messageId, emoji }, currentUser]) => {
|
||||||
if (!currentUser) {
|
if (!currentUser) return EMPTY;
|
||||||
return of({ type: 'NO_OP' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from DB
|
|
||||||
this.db.removeReaction(messageId, currentUser.id, emoji);
|
this.db.removeReaction(messageId, currentUser.id, emoji);
|
||||||
|
|
||||||
// Broadcast to peers
|
|
||||||
this.webrtc.broadcastMessage({
|
this.webrtc.broadcastMessage({
|
||||||
type: 'reaction-removed',
|
type: 'reaction-removed',
|
||||||
messageId,
|
messageId,
|
||||||
@@ -270,412 +228,37 @@ export class MessagesEffects {
|
|||||||
emoji,
|
emoji,
|
||||||
});
|
});
|
||||||
|
|
||||||
return of(MessagesActions.removeReactionSuccess({ messageId, oderId: currentUser.id, emoji }));
|
return of(
|
||||||
})
|
MessagesActions.removeReactionSuccess({
|
||||||
)
|
messageId,
|
||||||
|
oderId: currentUser.id,
|
||||||
|
emoji,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Listen to incoming messages from WebRTC peers
|
/**
|
||||||
|
* Central dispatcher for all incoming P2P messages.
|
||||||
|
* Delegates to handler functions in `messages-incoming.handlers.ts`.
|
||||||
|
*/
|
||||||
incomingMessages$ = createEffect(() =>
|
incomingMessages$ = createEffect(() =>
|
||||||
this.webrtc.onMessageReceived.pipe(
|
this.webrtc.onMessageReceived.pipe(
|
||||||
withLatestFrom(
|
withLatestFrom(
|
||||||
this.store.select(selectCurrentUser),
|
this.store.select(selectCurrentUser),
|
||||||
this.store.select(selectCurrentRoom)
|
this.store.select(selectCurrentRoom),
|
||||||
),
|
),
|
||||||
mergeMap(([event, currentUser, currentRoom]: [any, any, any]) => {
|
mergeMap(([event, currentUser, currentRoom]: [any, any, any]) => {
|
||||||
console.log('Received peer message:', event.type, event);
|
const ctx: IncomingMessageContext = {
|
||||||
|
db: this.db,
|
||||||
switch (event.type) {
|
webrtc: this.webrtc,
|
||||||
// Precise sync via ID inventory and targeted requests
|
attachments: this.attachments,
|
||||||
case 'chat-inventory-request': {
|
currentUser,
|
||||||
const reqRoomId = event.roomId;
|
currentRoom,
|
||||||
if (!reqRoomId || !event.fromPeerId) return of({ type: 'NO_OP' });
|
};
|
||||||
return from(this.db.getMessages(reqRoomId, this.INVENTORY_LIMIT, 0)).pipe(
|
return dispatchIncomingMessage(event, ctx);
|
||||||
mergeMap(async (messages) => {
|
|
||||||
const items = await Promise.all(
|
|
||||||
messages.map(async (m) => {
|
|
||||||
const reactions = await this.db.getReactionsForMessage(m.id);
|
|
||||||
return { id: m.id, ts: m.editedAt || m.timestamp || 0, rc: reactions.length };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
items.sort((a, b) => a.ts - b.ts);
|
|
||||||
console.log(`[Sync] Sending inventory of ${items.length} items for room ${reqRoomId}`);
|
|
||||||
for (let i = 0; i < items.length; i += this.CHUNK_SIZE) {
|
|
||||||
const chunk = items.slice(i, i + this.CHUNK_SIZE);
|
|
||||||
this.webrtc.sendToPeer(event.fromPeerId, {
|
|
||||||
type: 'chat-inventory',
|
|
||||||
roomId: reqRoomId,
|
|
||||||
items: chunk,
|
|
||||||
total: items.length,
|
|
||||||
index: i,
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
map(() => ({ type: 'NO_OP' }))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'chat-inventory': {
|
|
||||||
const invRoomId = event.roomId;
|
|
||||||
if (!invRoomId || !Array.isArray(event.items) || !event.fromPeerId) return of({ type: 'NO_OP' });
|
|
||||||
// Determine which IDs we are missing or have older versions of
|
|
||||||
return from(this.db.getMessages(invRoomId, this.INVENTORY_LIMIT, 0)).pipe(
|
|
||||||
mergeMap(async (local) => {
|
|
||||||
// Build local map with timestamps and reaction counts
|
|
||||||
const localMap = new Map<string, { ts: number; rc: number }>();
|
|
||||||
await Promise.all(
|
|
||||||
local.map(async (m) => {
|
|
||||||
const reactions = await this.db.getReactionsForMessage(m.id);
|
|
||||||
localMap.set(m.id, { ts: m.editedAt || m.timestamp || 0, rc: reactions.length });
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const missing: string[] = [];
|
|
||||||
for (const item of event.items as Array<{ id: string; ts: number; rc?: number }>) {
|
|
||||||
const localEntry = localMap.get(item.id);
|
|
||||||
if (!localEntry) {
|
|
||||||
missing.push(item.id);
|
|
||||||
} else if (item.ts > localEntry.ts) {
|
|
||||||
missing.push(item.id);
|
|
||||||
} else if (item.rc !== undefined && item.rc !== localEntry.rc) {
|
|
||||||
missing.push(item.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`[Sync] Inventory received: ${event.items.length} remote, ${missing.length} missing/stale`);
|
|
||||||
// Request in chunks from the sender
|
|
||||||
for (let i = 0; i < missing.length; i += this.CHUNK_SIZE) {
|
|
||||||
const chunk = missing.slice(i, i + this.CHUNK_SIZE);
|
|
||||||
this.webrtc.sendToPeer(event.fromPeerId, {
|
|
||||||
type: 'chat-sync-request-ids',
|
|
||||||
roomId: invRoomId,
|
|
||||||
ids: chunk,
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
return { type: 'NO_OP' } as any;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'chat-sync-request-ids': {
|
|
||||||
const syncReqRoomId = event.roomId;
|
|
||||||
if (!Array.isArray(event.ids) || !event.fromPeerId) return of({ type: 'NO_OP' });
|
|
||||||
const ids: string[] = event.ids;
|
|
||||||
return from(Promise.all(ids.map((id: string) => this.db.getMessageById(id)))).pipe(
|
|
||||||
mergeMap(async (maybeMessages) => {
|
|
||||||
const messages = maybeMessages.filter((m): m is Message => !!m);
|
|
||||||
// Hydrate reactions from the separate reactions table
|
|
||||||
const hydrated = await Promise.all(
|
|
||||||
messages.map(async (m) => {
|
|
||||||
const reactions = await this.db.getReactionsForMessage(m.id);
|
|
||||||
return { ...m, reactions };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
// Collect attachment metadata for synced messages
|
|
||||||
const msgIds = hydrated.map(m => m.id);
|
|
||||||
const attachmentMetas = this.attachments.getAttachmentMetasForMessages(msgIds);
|
|
||||||
console.log(`[Sync] Sending ${hydrated.length} messages for ${ids.length} requested IDs`);
|
|
||||||
// Send in chunks to avoid large payloads
|
|
||||||
for (let i = 0; i < hydrated.length; i += this.CHUNK_SIZE) {
|
|
||||||
const chunk = hydrated.slice(i, i + this.CHUNK_SIZE);
|
|
||||||
// Include only attachments for this chunk
|
|
||||||
const chunkAttachments: Record<string, any> = {};
|
|
||||||
for (const m of chunk) {
|
|
||||||
if (attachmentMetas[m.id]) chunkAttachments[m.id] = attachmentMetas[m.id];
|
|
||||||
}
|
|
||||||
this.webrtc.sendToPeer(event.fromPeerId, {
|
|
||||||
type: 'chat-sync-batch',
|
|
||||||
roomId: syncReqRoomId || '',
|
|
||||||
messages: chunk,
|
|
||||||
attachments: Object.keys(chunkAttachments).length > 0 ? chunkAttachments : undefined,
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
map(() => ({ type: 'NO_OP' }))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'chat-sync-batch': {
|
|
||||||
if (!Array.isArray(event.messages)) return of({ type: 'NO_OP' });
|
|
||||||
// Register synced attachment metadata so the UI knows about them
|
|
||||||
if (event.attachments && typeof event.attachments === 'object') {
|
|
||||||
this.attachments.registerSyncedAttachments(event.attachments);
|
|
||||||
}
|
|
||||||
return from((async () => {
|
|
||||||
const toUpsert: Message[] = [];
|
|
||||||
for (const m of event.messages as Message[]) {
|
|
||||||
const existing = await this.db.getMessageById(m.id);
|
|
||||||
const ets = existing ? (existing.editedAt || existing.timestamp || 0) : -1;
|
|
||||||
const its = m.editedAt || m.timestamp || 0;
|
|
||||||
const isNewer = !existing || its > ets;
|
|
||||||
|
|
||||||
if (isNewer) {
|
|
||||||
await this.db.saveMessage(m);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist incoming reactions to the reactions table (deduped)
|
|
||||||
const incomingReactions = m.reactions ?? [];
|
|
||||||
for (const r of incomingReactions) {
|
|
||||||
await this.db.saveReaction(r);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hydrate merged reactions from DB and upsert if anything changed
|
|
||||||
if (isNewer || incomingReactions.length > 0) {
|
|
||||||
const reactions = await this.db.getReactionsForMessage(m.id);
|
|
||||||
toUpsert.push({ ...(isNewer ? m : existing!), reactions });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-request unavailable images from the sender
|
|
||||||
if (event.attachments && event.fromPeerId) {
|
|
||||||
for (const [msgId, metas] of Object.entries(event.attachments) as [string, any[]][]) {
|
|
||||||
for (const meta of metas) {
|
|
||||||
if (meta.isImage) {
|
|
||||||
const atts = this.attachments.getForMessage(msgId);
|
|
||||||
const att = atts.find((a: any) => a.id === meta.id);
|
|
||||||
if (att && !att.available && !(att.receivedBytes && att.receivedBytes > 0)) {
|
|
||||||
this.attachments.requestImageFromAnyPeer(msgId, att);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return toUpsert;
|
|
||||||
})()).pipe(
|
|
||||||
mergeMap((toUpsert) => toUpsert.length ? of(MessagesActions.syncMessages({ messages: toUpsert })) : of({ type: 'NO_OP' }))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case 'voice-state':
|
|
||||||
// Update voice state for the sender
|
|
||||||
if (event.oderId && event.voiceState) {
|
|
||||||
const userId = event.oderId;
|
|
||||||
return of(UsersActions.updateVoiceState({ userId, voiceState: event.voiceState }));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'chat-message':
|
|
||||||
// Save to local DB and dispatch receive action
|
|
||||||
// Skip if this is our own message (sent via server relay)
|
|
||||||
if (event.message && event.message.senderId !== currentUser?.id && event.message.senderId !== currentUser?.oderId) {
|
|
||||||
this.db.saveMessage(event.message);
|
|
||||||
return of(MessagesActions.receiveMessage({ message: event.message }));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'file-announce':
|
|
||||||
this.attachments.handleFileAnnounce(event);
|
|
||||||
return of({ type: 'NO_OP' });
|
|
||||||
|
|
||||||
case 'file-chunk':
|
|
||||||
this.attachments.handleFileChunk(event);
|
|
||||||
return of({ type: 'NO_OP' });
|
|
||||||
|
|
||||||
case 'file-request':
|
|
||||||
// Uploader can fulfill request directly via AttachmentService
|
|
||||||
this.attachments.handleFileRequest(event);
|
|
||||||
return of({ type: 'NO_OP' });
|
|
||||||
|
|
||||||
case 'file-cancel':
|
|
||||||
// Stop any in-progress upload to the requester
|
|
||||||
this.attachments.handleFileCancel(event);
|
|
||||||
return of({ type: 'NO_OP' });
|
|
||||||
|
|
||||||
case 'file-not-found':
|
|
||||||
// Peer couldn't serve the file – try another peer automatically
|
|
||||||
this.attachments.handleFileNotFound(event);
|
|
||||||
return of({ type: 'NO_OP' });
|
|
||||||
|
|
||||||
case 'message-edited':
|
|
||||||
if (event.messageId && event.content) {
|
|
||||||
this.db.updateMessage(event.messageId, { content: event.content, editedAt: event.editedAt });
|
|
||||||
return of(MessagesActions.editMessageSuccess({
|
|
||||||
messageId: event.messageId,
|
|
||||||
content: event.content,
|
|
||||||
editedAt: event.editedAt,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'message-deleted':
|
|
||||||
if (event.messageId) {
|
|
||||||
this.db.deleteMessage(event.messageId);
|
|
||||||
return of(MessagesActions.deleteMessageSuccess({ messageId: event.messageId }));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'reaction-added':
|
|
||||||
if (event.messageId && event.reaction) {
|
|
||||||
this.db.saveReaction(event.reaction);
|
|
||||||
return of(MessagesActions.addReactionSuccess({
|
|
||||||
reaction: event.reaction,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'reaction-removed':
|
|
||||||
if (event.messageId && event.oderId && event.emoji) {
|
|
||||||
this.db.removeReaction(event.messageId, event.oderId, event.emoji);
|
|
||||||
return of(MessagesActions.removeReactionSuccess({
|
|
||||||
messageId: event.messageId,
|
|
||||||
oderId: event.oderId,
|
|
||||||
emoji: event.emoji,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Chat sync handshake: summary -> request -> full
|
|
||||||
case 'chat-sync-summary':
|
|
||||||
// Compare summaries and request sync if the peer has newer data
|
|
||||||
if (!currentRoom) return of({ type: 'NO_OP' });
|
|
||||||
return from(this.db.getMessages(currentRoom.id, 10000, 0)).pipe(
|
|
||||||
tap((local) => {
|
|
||||||
const localCount = local.length;
|
|
||||||
const localLastUpdated = local.reduce((max, m) => Math.max(max, m.editedAt || m.timestamp || 0), 0);
|
|
||||||
const remoteLastUpdated = event.lastUpdated || 0;
|
|
||||||
const remoteCount = event.count || 0;
|
|
||||||
|
|
||||||
const identical = localLastUpdated === remoteLastUpdated && localCount === remoteCount;
|
|
||||||
const needsSync = remoteLastUpdated > localLastUpdated || (remoteLastUpdated === localLastUpdated && remoteCount > localCount);
|
|
||||||
|
|
||||||
if (!identical && needsSync && event.fromPeerId) {
|
|
||||||
this.webrtc.sendToPeer(event.fromPeerId, { type: 'chat-sync-request', roomId: currentRoom.id } as any);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
map(() => ({ type: 'NO_OP' }))
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'chat-sync-request':
|
|
||||||
if (!currentRoom || !event.fromPeerId) return of({ type: 'NO_OP' });
|
|
||||||
return from(this.db.getMessages(currentRoom.id, 10000, 0)).pipe(
|
|
||||||
tap((all) => {
|
|
||||||
this.webrtc.sendToPeer(event.fromPeerId, { type: 'chat-sync-full', roomId: currentRoom.id, messages: all } as any);
|
|
||||||
}),
|
|
||||||
map(() => ({ type: 'NO_OP' }))
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'chat-sync-full':
|
|
||||||
if (event.messages && Array.isArray(event.messages)) {
|
|
||||||
// Merge into local DB and update store
|
|
||||||
event.messages.forEach((m: Message) => this.db.saveMessage(m));
|
|
||||||
return of(MessagesActions.syncMessages({ messages: event.messages }));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return of({ type: 'NO_OP' });
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// On peer connect, broadcast local dataset summary
|
|
||||||
peerConnectedSync$ = createEffect(
|
|
||||||
() =>
|
|
||||||
this.webrtc.onPeerConnected.pipe(
|
|
||||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
|
||||||
mergeMap(([peerId, room]) => {
|
|
||||||
if (!room) return of({ type: 'NO_OP' });
|
|
||||||
return from(this.db.getMessages(room.id, 10000, 0)).pipe(
|
|
||||||
map((messages) => {
|
|
||||||
const count = messages.length;
|
|
||||||
const lastUpdated = messages.reduce((max, m) => Math.max(max, m.editedAt || m.timestamp || 0), 0);
|
|
||||||
// Send summary specifically to the newly connected peer
|
|
||||||
this.webrtc.sendToPeer(peerId, { type: 'chat-sync-summary', roomId: room.id, count, lastUpdated } as any);
|
|
||||||
// Also request their inventory for precise reconciliation
|
|
||||||
this.webrtc.sendToPeer(peerId, { type: 'chat-inventory-request', roomId: room.id } as any);
|
|
||||||
return { type: 'NO_OP' };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
})
|
|
||||||
),
|
|
||||||
{ dispatch: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Kick off sync to all currently connected peers shortly after joining a room
|
|
||||||
joinRoomSyncKickoff$ = createEffect(
|
|
||||||
() =>
|
|
||||||
this.actions$.pipe(
|
|
||||||
ofType(RoomsActions.joinRoomSuccess),
|
|
||||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
|
||||||
mergeMap(([{ room }, currentRoom]) => {
|
|
||||||
const activeRoom = currentRoom || room;
|
|
||||||
if (!activeRoom) return of({ type: 'NO_OP' });
|
|
||||||
return from(this.db.getMessages(activeRoom.id, 10000, 0)).pipe(
|
|
||||||
tap((messages) => {
|
|
||||||
const count = messages.length;
|
|
||||||
const lastUpdated = messages.reduce((max, m) => Math.max(max, m.editedAt || m.timestamp || 0), 0);
|
|
||||||
const peers = this.webrtc.getConnectedPeers();
|
|
||||||
peers.forEach((pid) => {
|
|
||||||
try {
|
|
||||||
this.webrtc.sendToPeer(pid, { type: 'chat-sync-summary', roomId: activeRoom.id, count, lastUpdated } as any);
|
|
||||||
this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: activeRoom.id } as any);
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
map(() => ({ type: 'NO_OP' }))
|
|
||||||
);
|
|
||||||
})
|
|
||||||
),
|
|
||||||
{ dispatch: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Periodic sync poll – 10s when catching up, 15min after a clean sync
|
|
||||||
private syncReset$ = new Subject<void>();
|
|
||||||
|
|
||||||
periodicSyncPoll$ = createEffect(() =>
|
|
||||||
timer(this.SYNC_POLL_FAST_MS).pipe(
|
|
||||||
// After each emission, decide the next delay based on last result
|
|
||||||
repeat({ delay: () => timer(this.lastSyncClean ? this.SYNC_POLL_SLOW_MS : this.SYNC_POLL_FAST_MS) }),
|
|
||||||
takeUntil(this.syncReset$), // restart via syncReset$ is handled externally if needed
|
|
||||||
withLatestFrom(
|
|
||||||
this.store.select(selectCurrentRoom)
|
|
||||||
),
|
|
||||||
filter(([, room]) => !!room && this.webrtc.getConnectedPeers().length > 0),
|
|
||||||
exhaustMap(([, room]) => {
|
|
||||||
const peers = this.webrtc.getConnectedPeers();
|
|
||||||
if (!room || peers.length === 0) return of(MessagesActions.syncComplete());
|
|
||||||
|
|
||||||
return from(this.db.getMessages(room.id, this.INVENTORY_LIMIT, 0)).pipe(
|
|
||||||
map((messages) => {
|
|
||||||
peers.forEach((pid) => {
|
|
||||||
try {
|
|
||||||
this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: room.id } as any);
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
return MessagesActions.startSync();
|
|
||||||
}),
|
|
||||||
catchError(() => {
|
|
||||||
this.lastSyncClean = false;
|
|
||||||
return of(MessagesActions.syncComplete());
|
|
||||||
})
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Auto-complete sync after a timeout if no sync messages arrive
|
|
||||||
syncTimeout$ = createEffect(() =>
|
|
||||||
this.actions$.pipe(
|
|
||||||
ofType(MessagesActions.startSync),
|
|
||||||
switchMap(() => {
|
|
||||||
// If no syncMessages or syncComplete within 5s, auto-complete
|
|
||||||
return new Promise<void>((resolve) => setTimeout(resolve, 5000));
|
|
||||||
}),
|
}),
|
||||||
withLatestFrom(this.store.select(selectMessagesSyncing)),
|
),
|
||||||
filter(([, syncing]) => syncing),
|
|
||||||
map(() => {
|
|
||||||
// No new messages arrived during this cycle → clean sync, slow down
|
|
||||||
this.lastSyncClean = true;
|
|
||||||
return MessagesActions.syncComplete();
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// When new messages actually arrive via sync, switch back to fast polling
|
|
||||||
syncReceivedMessages$ = createEffect(
|
|
||||||
() =>
|
|
||||||
this.webrtc.onPeerConnected.pipe(
|
|
||||||
// A peer (re)connecting means we may have been offline — revert to aggressive polling
|
|
||||||
tap(() => { this.lastSyncClean = false; })
|
|
||||||
),
|
|
||||||
{ dispatch: false }
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
156
src/app/store/messages/messages.helpers.ts
Normal file
156
src/app/store/messages/messages.helpers.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* Pure utility functions and constants for the messages store slice.
|
||||||
|
*
|
||||||
|
* Extracted from messages.effects.ts to improve readability, testability,
|
||||||
|
* and reuse across effects and handler files.
|
||||||
|
*/
|
||||||
|
import { Message } from '../../core/models';
|
||||||
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
|
|
||||||
|
/** Maximum number of recent messages to include in sync inventories. */
|
||||||
|
export const INVENTORY_LIMIT = 1000;
|
||||||
|
|
||||||
|
/** Number of messages per chunk for inventory / batch transfers. */
|
||||||
|
export const CHUNK_SIZE = 200;
|
||||||
|
|
||||||
|
/** Aggressive sync poll interval (10 seconds). */
|
||||||
|
export const SYNC_POLL_FAST_MS = 10_000;
|
||||||
|
|
||||||
|
/** Idle sync poll interval after a clean (no-new-messages) cycle (15 minutes). */
|
||||||
|
export const SYNC_POLL_SLOW_MS = 900_000;
|
||||||
|
|
||||||
|
/** Sync timeout duration before auto-completing a cycle (5 seconds). */
|
||||||
|
export const SYNC_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
|
/** Large limit used for legacy full-sync operations. */
|
||||||
|
export const FULL_SYNC_LIMIT = 10_000;
|
||||||
|
|
||||||
|
/** Extracts the effective timestamp from a message (editedAt takes priority). */
|
||||||
|
export function getMessageTimestamp(msg: Message): number {
|
||||||
|
return msg.editedAt || msg.timestamp || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Computes the most recent timestamp across a batch of messages. */
|
||||||
|
export function getLatestTimestamp(messages: Message[]): number {
|
||||||
|
return messages.reduce(
|
||||||
|
(max, msg) => Math.max(max, getMessageTimestamp(msg)),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits an array into chunks of the given size. */
|
||||||
|
export function chunkArray<T>(items: T[], size: number): T[][] {
|
||||||
|
const chunks: T[][] = [];
|
||||||
|
for (let i = 0; i < items.length; i += size) {
|
||||||
|
chunks.push(items.slice(i, i + size));
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hydrates a single message with its reactions from the database. */
|
||||||
|
export async function hydrateMessage(
|
||||||
|
msg: Message,
|
||||||
|
db: DatabaseService,
|
||||||
|
): Promise<Message> {
|
||||||
|
const reactions = await db.getReactionsForMessage(msg.id);
|
||||||
|
return reactions.length > 0 ? { ...msg, reactions } : msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hydrates an array of messages with their reactions. */
|
||||||
|
export async function hydrateMessages(
|
||||||
|
messages: Message[],
|
||||||
|
db: DatabaseService,
|
||||||
|
): Promise<Message[]> {
|
||||||
|
return Promise.all(messages.map((msg) => hydrateMessage(msg, db)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inventory item representing a message's sync state. */
|
||||||
|
export interface InventoryItem {
|
||||||
|
id: string;
|
||||||
|
ts: number;
|
||||||
|
rc: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a sync inventory item from a message and its reaction count. */
|
||||||
|
export async function buildInventoryItem(
|
||||||
|
msg: Message,
|
||||||
|
db: DatabaseService,
|
||||||
|
): Promise<InventoryItem> {
|
||||||
|
const reactions = await db.getReactionsForMessage(msg.id);
|
||||||
|
return { id: msg.id, ts: getMessageTimestamp(msg), rc: reactions.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a local map of `{timestamp, reactionCount}` keyed by message ID. */
|
||||||
|
export async function buildLocalInventoryMap(
|
||||||
|
messages: Message[],
|
||||||
|
db: DatabaseService,
|
||||||
|
): Promise<Map<string, { ts: number; rc: number }>> {
|
||||||
|
const map = new Map<string, { ts: number; rc: number }>();
|
||||||
|
await Promise.all(
|
||||||
|
messages.map(async (msg) => {
|
||||||
|
const reactions = await db.getReactionsForMessage(msg.id);
|
||||||
|
map.set(msg.id, { ts: getMessageTimestamp(msg), rc: reactions.length });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Identifies missing or stale message IDs by comparing remote items against a local map. */
|
||||||
|
export function findMissingIds(
|
||||||
|
remoteItems: ReadonlyArray<{ id: string; ts: number; rc?: number }>,
|
||||||
|
localMap: ReadonlyMap<string, { ts: number; rc: number }>,
|
||||||
|
): string[] {
|
||||||
|
const missing: string[] = [];
|
||||||
|
for (const item of remoteItems) {
|
||||||
|
const local = localMap.get(item.id);
|
||||||
|
if (
|
||||||
|
!local ||
|
||||||
|
item.ts > local.ts ||
|
||||||
|
(item.rc !== undefined && item.rc !== local.rc)
|
||||||
|
) {
|
||||||
|
missing.push(item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of merging an incoming message into the local database. */
|
||||||
|
export interface MergeResult {
|
||||||
|
message: Message;
|
||||||
|
changed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges an incoming message into the local database.
|
||||||
|
* Handles message upsert and reaction deduplication, then returns
|
||||||
|
* the fully hydrated message alongside a `changed` flag.
|
||||||
|
*/
|
||||||
|
export async function mergeIncomingMessage(
|
||||||
|
incoming: Message,
|
||||||
|
db: DatabaseService,
|
||||||
|
): Promise<MergeResult> {
|
||||||
|
const existing = await db.getMessageById(incoming.id);
|
||||||
|
const existingTs = existing ? getMessageTimestamp(existing) : -1;
|
||||||
|
const incomingTs = getMessageTimestamp(incoming);
|
||||||
|
const isNewer = !existing || incomingTs > existingTs;
|
||||||
|
|
||||||
|
if (isNewer) {
|
||||||
|
await db.saveMessage(incoming);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist incoming reactions (deduped by the DB layer)
|
||||||
|
const incomingReactions = incoming.reactions ?? [];
|
||||||
|
for (const reaction of incomingReactions) {
|
||||||
|
await db.saveReaction(reaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
const changed = isNewer || incomingReactions.length > 0;
|
||||||
|
if (changed) {
|
||||||
|
const reactions = await db.getReactionsForMessage(incoming.id);
|
||||||
|
return {
|
||||||
|
message: { ...(isNewer ? incoming : existing!), reactions },
|
||||||
|
changed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { message: existing!, changed: false };
|
||||||
|
}
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
import { createReducer, on } from '@ngrx/store';
|
import { createReducer, on } from '@ngrx/store';
|
||||||
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
|
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
|
||||||
import { Message } from '../../core/models';
|
import { Message } from '../../core/models';
|
||||||
import * as MessagesActions from './messages.actions';
|
import { MessagesActions } from './messages.actions';
|
||||||
|
|
||||||
|
/** State shape for the messages feature slice, extending NgRx EntityState. */
|
||||||
export interface MessagesState extends EntityState<Message> {
|
export interface MessagesState extends EntityState<Message> {
|
||||||
|
/** Whether messages are being loaded from the database. */
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
/** Whether a peer-to-peer sync cycle is in progress. */
|
||||||
syncing: boolean;
|
syncing: boolean;
|
||||||
|
/** Most recent error message from message operations. */
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
/** ID of the room whose messages are currently loaded. */
|
||||||
currentRoomId: string | null;
|
currentRoomId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +113,7 @@ export const messagesReducer = createReducer(
|
|||||||
if (!message) return state;
|
if (!message) return state;
|
||||||
|
|
||||||
const existingReaction = message.reactions.find(
|
const existingReaction = message.reactions.find(
|
||||||
(r) => r.emoji === reaction.emoji && r.userId === reaction.userId
|
(existing) => existing.emoji === reaction.emoji && existing.userId === reaction.userId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingReaction) return state;
|
if (existingReaction) return state;
|
||||||
@@ -134,7 +139,7 @@ export const messagesReducer = createReducer(
|
|||||||
id: messageId,
|
id: messageId,
|
||||||
changes: {
|
changes: {
|
||||||
reactions: message.reactions.filter(
|
reactions: message.reactions.filter(
|
||||||
(r) => !(r.emoji === emoji && r.userId === oderId)
|
(existingReaction) => !(existingReaction.emoji === emoji && existingReaction.userId === oderId)
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -155,18 +160,18 @@ export const messagesReducer = createReducer(
|
|||||||
|
|
||||||
// Sync messages from peer (merge reactions to avoid losing local-only reactions)
|
// Sync messages from peer (merge reactions to avoid losing local-only reactions)
|
||||||
on(MessagesActions.syncMessages, (state, { messages }) => {
|
on(MessagesActions.syncMessages, (state, { messages }) => {
|
||||||
const merged = messages.map(m => {
|
const merged = messages.map(message => {
|
||||||
const existing = state.entities[m.id];
|
const existing = state.entities[message.id];
|
||||||
if (existing?.reactions?.length) {
|
if (existing?.reactions?.length) {
|
||||||
const combined = [...(m.reactions ?? [])];
|
const combined = [...(message.reactions ?? [])];
|
||||||
for (const r of existing.reactions) {
|
for (const existingReaction of existing.reactions) {
|
||||||
if (!combined.some(c => c.userId === r.userId && c.emoji === r.emoji && c.messageId === r.messageId)) {
|
if (!combined.some(combinedReaction => combinedReaction.userId === existingReaction.userId && combinedReaction.emoji === existingReaction.emoji && combinedReaction.messageId === existingReaction.messageId)) {
|
||||||
combined.push(r);
|
combined.push(existingReaction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { ...m, reactions: combined };
|
return { ...message, reactions: combined };
|
||||||
}
|
}
|
||||||
return m;
|
return message;
|
||||||
});
|
});
|
||||||
return messagesAdapter.upsertMany(merged, {
|
return messagesAdapter.upsertMany(merged, {
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -1,45 +1,55 @@
|
|||||||
import { createFeatureSelector, createSelector } from '@ngrx/store';
|
import { createFeatureSelector, createSelector } from '@ngrx/store';
|
||||||
import { MessagesState, messagesAdapter } from './messages.reducer';
|
import { MessagesState, messagesAdapter } from './messages.reducer';
|
||||||
|
|
||||||
|
/** Selects the top-level messages feature state. */
|
||||||
export const selectMessagesState = createFeatureSelector<MessagesState>('messages');
|
export const selectMessagesState = createFeatureSelector<MessagesState>('messages');
|
||||||
|
|
||||||
const { selectIds, selectEntities, selectAll, selectTotal } = messagesAdapter.getSelectors();
|
const { selectIds, selectEntities, selectAll, selectTotal } = messagesAdapter.getSelectors();
|
||||||
|
|
||||||
|
/** Selects all message entities as a flat array. */
|
||||||
export const selectAllMessages = createSelector(selectMessagesState, selectAll);
|
export const selectAllMessages = createSelector(selectMessagesState, selectAll);
|
||||||
|
|
||||||
|
/** Selects the message entity dictionary keyed by ID. */
|
||||||
export const selectMessagesEntities = createSelector(selectMessagesState, selectEntities);
|
export const selectMessagesEntities = createSelector(selectMessagesState, selectEntities);
|
||||||
|
|
||||||
|
/** Selects all message IDs. */
|
||||||
export const selectMessagesIds = createSelector(selectMessagesState, selectIds);
|
export const selectMessagesIds = createSelector(selectMessagesState, selectIds);
|
||||||
|
|
||||||
|
/** Selects the total count of messages. */
|
||||||
export const selectMessagesTotal = createSelector(selectMessagesState, selectTotal);
|
export const selectMessagesTotal = createSelector(selectMessagesState, selectTotal);
|
||||||
|
|
||||||
|
/** Whether messages are currently being loaded from the database. */
|
||||||
export const selectMessagesLoading = createSelector(
|
export const selectMessagesLoading = createSelector(
|
||||||
selectMessagesState,
|
selectMessagesState,
|
||||||
(state) => state.loading
|
(state) => state.loading
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects the most recent messages-related error message. */
|
||||||
export const selectMessagesError = createSelector(
|
export const selectMessagesError = createSelector(
|
||||||
selectMessagesState,
|
selectMessagesState,
|
||||||
(state) => state.error
|
(state) => state.error
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Whether a peer-to-peer message sync cycle is in progress. */
|
||||||
export const selectMessagesSyncing = createSelector(
|
export const selectMessagesSyncing = createSelector(
|
||||||
selectMessagesState,
|
selectMessagesState,
|
||||||
(state) => state.syncing
|
(state) => state.syncing
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects the ID of the room whose messages are currently loaded. */
|
||||||
export const selectCurrentRoomId = createSelector(
|
export const selectCurrentRoomId = createSelector(
|
||||||
selectMessagesState,
|
selectMessagesState,
|
||||||
(state) => state.currentRoomId
|
(state) => state.currentRoomId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects all messages belonging to the currently active room. */
|
||||||
export const selectCurrentRoomMessages = createSelector(
|
export const selectCurrentRoomMessages = createSelector(
|
||||||
selectAllMessages,
|
selectAllMessages,
|
||||||
selectCurrentRoomId,
|
selectCurrentRoomId,
|
||||||
(messages, roomId) => roomId ? messages.filter((m) => m.roomId === roomId) : []
|
(messages, roomId) => roomId ? messages.filter((message) => message.roomId === roomId) : []
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Select messages for the currently active text channel */
|
/** Creates a selector that returns messages for a specific text channel within the current room. */
|
||||||
export const selectChannelMessages = (channelId: string) =>
|
export const selectChannelMessages = (channelId: string) =>
|
||||||
createSelector(
|
createSelector(
|
||||||
selectAllMessages,
|
selectAllMessages,
|
||||||
@@ -47,25 +57,29 @@ export const selectChannelMessages = (channelId: string) =>
|
|||||||
(messages, roomId) => {
|
(messages, roomId) => {
|
||||||
if (!roomId) return [];
|
if (!roomId) return [];
|
||||||
return messages.filter(
|
return messages.filter(
|
||||||
(m) => m.roomId === roomId && (m.channelId || 'general') === channelId
|
(message) => message.roomId === roomId && (message.channelId || 'general') === channelId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Creates a selector that returns a single message by its ID. */
|
||||||
export const selectMessageById = (id: string) =>
|
export const selectMessageById = (id: string) =>
|
||||||
createSelector(selectMessagesEntities, (entities) => entities[id]);
|
createSelector(selectMessagesEntities, (entities) => entities[id]);
|
||||||
|
|
||||||
|
/** Creates a selector that returns all messages for a specific room. */
|
||||||
export const selectMessagesByRoomId = (roomId: string) =>
|
export const selectMessagesByRoomId = (roomId: string) =>
|
||||||
createSelector(selectAllMessages, (messages) =>
|
createSelector(selectAllMessages, (messages) =>
|
||||||
messages.filter((m) => m.roomId === roomId)
|
messages.filter((message) => message.roomId === roomId)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Creates a selector that returns the N most recent messages. */
|
||||||
export const selectRecentMessages = (limit: number) =>
|
export const selectRecentMessages = (limit: number) =>
|
||||||
createSelector(selectAllMessages, (messages) =>
|
createSelector(selectAllMessages, (messages) =>
|
||||||
messages.slice(-limit)
|
messages.slice(-limit)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects only messages that have at least one reaction. */
|
||||||
export const selectMessagesWithReactions = createSelector(
|
export const selectMessagesWithReactions = createSelector(
|
||||||
selectAllMessages,
|
selectAllMessages,
|
||||||
(messages) => messages.filter((m) => m.reactions.length > 0)
|
(messages) => messages.filter((message) => message.reactions.length > 0)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,190 +1,62 @@
|
|||||||
import { createAction, props } from '@ngrx/store';
|
/**
|
||||||
|
* Rooms store actions using `createActionGroup`.
|
||||||
|
*/
|
||||||
|
import { createActionGroup, emptyProps, props } from '@ngrx/store';
|
||||||
import { Room, RoomSettings, ServerInfo, RoomPermissions, Channel } from '../../core/models';
|
import { Room, RoomSettings, ServerInfo, RoomPermissions, Channel } from '../../core/models';
|
||||||
|
|
||||||
// Load rooms from storage
|
export const RoomsActions = createActionGroup({
|
||||||
export const loadRooms = createAction('[Rooms] Load Rooms');
|
source: 'Rooms',
|
||||||
|
events: {
|
||||||
|
'Load Rooms': emptyProps(),
|
||||||
|
'Load Rooms Success': props<{ rooms: Room[] }>(),
|
||||||
|
'Load Rooms Failure': props<{ error: string }>(),
|
||||||
|
|
||||||
export const loadRoomsSuccess = createAction(
|
'Search Servers': props<{ query: string }>(),
|
||||||
'[Rooms] Load Rooms Success',
|
'Search Servers Success': props<{ servers: ServerInfo[] }>(),
|
||||||
props<{ rooms: Room[] }>()
|
'Search Servers Failure': props<{ error: string }>(),
|
||||||
);
|
|
||||||
|
|
||||||
export const loadRoomsFailure = createAction(
|
'Create Room': props<{ name: string; description?: string; topic?: string; isPrivate?: boolean; password?: string }>(),
|
||||||
'[Rooms] Load Rooms Failure',
|
'Create Room Success': props<{ room: Room }>(),
|
||||||
props<{ error: string }>()
|
'Create Room Failure': props<{ error: string }>(),
|
||||||
);
|
|
||||||
|
|
||||||
// Search servers
|
'Join Room': props<{ roomId: string; password?: string; serverInfo?: { name: string; description?: string; hostName?: string } }>(),
|
||||||
export const searchServers = createAction(
|
'Join Room Success': props<{ room: Room }>(),
|
||||||
'[Rooms] Search Servers',
|
'Join Room Failure': props<{ error: string }>(),
|
||||||
props<{ query: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const searchServersSuccess = createAction(
|
'Leave Room': emptyProps(),
|
||||||
'[Rooms] Search Servers Success',
|
'Leave Room Success': emptyProps(),
|
||||||
props<{ servers: ServerInfo[] }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const searchServersFailure = createAction(
|
'View Server': props<{ room: Room }>(),
|
||||||
'[Rooms] Search Servers Failure',
|
'View Server Success': props<{ room: Room }>(),
|
||||||
props<{ error: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create room
|
'Delete Room': props<{ roomId: string }>(),
|
||||||
export const createRoom = createAction(
|
'Delete Room Success': props<{ roomId: string }>(),
|
||||||
'[Rooms] Create Room',
|
|
||||||
props<{ name: string; description?: string; topic?: string; isPrivate?: boolean; password?: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const createRoomSuccess = createAction(
|
'Forget Room': props<{ roomId: string }>(),
|
||||||
'[Rooms] Create Room Success',
|
'Forget Room Success': props<{ roomId: string }>(),
|
||||||
props<{ room: Room }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const createRoomFailure = createAction(
|
'Update Room Settings': props<{ settings: Partial<RoomSettings> }>(),
|
||||||
'[Rooms] Create Room Failure',
|
'Update Room Settings Success': props<{ settings: RoomSettings }>(),
|
||||||
props<{ error: string }>()
|
'Update Room Settings Failure': props<{ error: string }>(),
|
||||||
);
|
|
||||||
|
|
||||||
// Join room
|
'Update Room Permissions': props<{ roomId: string; permissions: Partial<RoomPermissions> }>(),
|
||||||
export const joinRoom = createAction(
|
|
||||||
'[Rooms] Join Room',
|
|
||||||
props<{ roomId: string; password?: string; serverInfo?: { name: string; description?: string; hostName?: string } }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const joinRoomSuccess = createAction(
|
'Update Server Icon': props<{ roomId: string; icon: string }>(),
|
||||||
'[Rooms] Join Room Success',
|
'Update Server Icon Success': props<{ roomId: string; icon: string; iconUpdatedAt: number }>(),
|
||||||
props<{ room: Room }>()
|
'Update Server Icon Failure': props<{ error: string }>(),
|
||||||
);
|
|
||||||
|
|
||||||
export const joinRoomFailure = createAction(
|
'Set Current Room': props<{ room: Room }>(),
|
||||||
'[Rooms] Join Room Failure',
|
'Clear Current Room': emptyProps(),
|
||||||
props<{ error: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Leave room
|
'Update Room': props<{ roomId: string; changes: Partial<Room> }>(),
|
||||||
export const leaveRoom = createAction('[Rooms] Leave Room');
|
'Receive Room Update': props<{ room: Partial<Room> }>(),
|
||||||
|
|
||||||
export const leaveRoomSuccess = createAction('[Rooms] Leave Room Success');
|
'Select Channel': props<{ channelId: string }>(),
|
||||||
|
'Add Channel': props<{ channel: Channel }>(),
|
||||||
|
'Remove Channel': props<{ channelId: string }>(),
|
||||||
|
'Rename Channel': props<{ channelId: string; name: string }>(),
|
||||||
|
|
||||||
// View server (switch view without disconnecting)
|
'Clear Search Results': emptyProps(),
|
||||||
export const viewServer = createAction(
|
'Set Connecting': props<{ isConnecting: boolean }>(),
|
||||||
'[Rooms] View Server',
|
},
|
||||||
props<{ room: Room }>()
|
});
|
||||||
);
|
|
||||||
|
|
||||||
export const viewServerSuccess = createAction(
|
|
||||||
'[Rooms] View Server Success',
|
|
||||||
props<{ room: Room }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Delete room
|
|
||||||
export const deleteRoom = createAction(
|
|
||||||
'[Rooms] Delete Room',
|
|
||||||
props<{ roomId: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const deleteRoomSuccess = createAction(
|
|
||||||
'[Rooms] Delete Room Success',
|
|
||||||
props<{ roomId: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Forget room locally
|
|
||||||
export const forgetRoom = createAction(
|
|
||||||
'[Rooms] Forget Room',
|
|
||||||
props<{ roomId: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const forgetRoomSuccess = createAction(
|
|
||||||
'[Rooms] Forget Room Success',
|
|
||||||
props<{ roomId: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update room settings
|
|
||||||
export const updateRoomSettings = createAction(
|
|
||||||
'[Rooms] Update Room Settings',
|
|
||||||
props<{ settings: Partial<RoomSettings> }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const updateRoomSettingsSuccess = createAction(
|
|
||||||
'[Rooms] Update Room Settings Success',
|
|
||||||
props<{ settings: RoomSettings }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const updateRoomSettingsFailure = createAction(
|
|
||||||
'[Rooms] Update Room Settings Failure',
|
|
||||||
props<{ error: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update room permissions
|
|
||||||
export const updateRoomPermissions = createAction(
|
|
||||||
'[Rooms] Update Room Permissions',
|
|
||||||
props<{ roomId: string; permissions: Partial<RoomPermissions> }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update server icon (permission enforced)
|
|
||||||
export const updateServerIcon = createAction(
|
|
||||||
'[Rooms] Update Server Icon',
|
|
||||||
props<{ roomId: string; icon: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const updateServerIconSuccess = createAction(
|
|
||||||
'[Rooms] Update Server Icon Success',
|
|
||||||
props<{ roomId: string; icon: string; iconUpdatedAt: number }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const updateServerIconFailure = createAction(
|
|
||||||
'[Rooms] Update Server Icon Failure',
|
|
||||||
props<{ error: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set current room
|
|
||||||
export const setCurrentRoom = createAction(
|
|
||||||
'[Rooms] Set Current Room',
|
|
||||||
props<{ room: Room }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clear current room
|
|
||||||
export const clearCurrentRoom = createAction('[Rooms] Clear Current Room');
|
|
||||||
|
|
||||||
// Update room
|
|
||||||
export const updateRoom = createAction(
|
|
||||||
'[Rooms] Update Room',
|
|
||||||
props<{ roomId: string; changes: Partial<Room> }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Receive room update from peer
|
|
||||||
export const receiveRoomUpdate = createAction(
|
|
||||||
'[Rooms] Receive Room Update',
|
|
||||||
props<{ room: Partial<Room> }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Channel management
|
|
||||||
export const selectChannel = createAction(
|
|
||||||
'[Rooms] Select Channel',
|
|
||||||
props<{ channelId: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const addChannel = createAction(
|
|
||||||
'[Rooms] Add Channel',
|
|
||||||
props<{ channel: Channel }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const removeChannel = createAction(
|
|
||||||
'[Rooms] Remove Channel',
|
|
||||||
props<{ channelId: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const renameChannel = createAction(
|
|
||||||
'[Rooms] Rename Channel',
|
|
||||||
props<{ channelId: string; name: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clear search results
|
|
||||||
export const clearSearchResults = createAction('[Rooms] Clear Search Results');
|
|
||||||
|
|
||||||
// Set connection status
|
|
||||||
export const setConnecting = createAction(
|
|
||||||
'[Rooms] Set Connecting',
|
|
||||||
props<{ isConnecting: boolean }>()
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable, inject } from '@angular/core';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { of, from } from 'rxjs';
|
import { of, from, EMPTY } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
map,
|
map,
|
||||||
mergeMap,
|
mergeMap,
|
||||||
@@ -11,18 +11,41 @@ import {
|
|||||||
tap,
|
tap,
|
||||||
debounceTime,
|
debounceTime,
|
||||||
switchMap,
|
switchMap,
|
||||||
|
filter,
|
||||||
} from 'rxjs/operators';
|
} from 'rxjs/operators';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import * as RoomsActions from './rooms.actions';
|
import { RoomsActions } from './rooms.actions';
|
||||||
import * as UsersActions from '../users/users.actions';
|
import { UsersActions } from '../users/users.actions';
|
||||||
import * as MessagesActions from '../messages/messages.actions';
|
import { MessagesActions } from '../messages/messages.actions';
|
||||||
import { selectCurrentUser } from '../users/users.selectors';
|
import { selectCurrentUser, selectAllUsers } from '../users/users.selectors';
|
||||||
import { selectCurrentRoom } from './rooms.selectors';
|
import { selectCurrentRoom } from './rooms.selectors';
|
||||||
import { DatabaseService } from '../../core/services/database.service';
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||||
import { Room, RoomSettings, RoomPermissions } from '../../core/models';
|
import { Room, RoomSettings, RoomPermissions, VoiceState } from '../../core/models';
|
||||||
import { selectAllUsers } from '../users/users.selectors';
|
|
||||||
|
/** Build a minimal User object from signaling payload. */
|
||||||
|
function buildSignalingUser(
|
||||||
|
data: { oderId: string; displayName: string },
|
||||||
|
extras: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
oderId: data.oderId,
|
||||||
|
id: data.oderId,
|
||||||
|
username: data.displayName.toLowerCase().replace(/\s+/g, '_'),
|
||||||
|
displayName: data.displayName,
|
||||||
|
status: 'online' as const,
|
||||||
|
isOnline: true,
|
||||||
|
role: 'member' as const,
|
||||||
|
joinedAt: Date.now(),
|
||||||
|
...extras,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true when the message's server ID does not match the viewed server. */
|
||||||
|
function isWrongServer(msgServerId: string | undefined, viewedServerId: string | undefined): boolean {
|
||||||
|
return !!(msgServerId && viewedServerId && msgServerId !== viewedServerId);
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RoomsEffects {
|
export class RoomsEffects {
|
||||||
@@ -33,7 +56,7 @@ export class RoomsEffects {
|
|||||||
private webrtc = inject(WebRTCService);
|
private webrtc = inject(WebRTCService);
|
||||||
private serverDirectory = inject(ServerDirectoryService);
|
private serverDirectory = inject(ServerDirectoryService);
|
||||||
|
|
||||||
// Load rooms from database
|
/** Loads all saved rooms from the local database. */
|
||||||
loadRooms$ = createEffect(() =>
|
loadRooms$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.loadRooms),
|
ofType(RoomsActions.loadRooms),
|
||||||
@@ -48,7 +71,7 @@ export class RoomsEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Search servers with debounce
|
/** Searches the server directory with debounced input. */
|
||||||
searchServers$ = createEffect(() =>
|
searchServers$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.searchServers),
|
ofType(RoomsActions.searchServers),
|
||||||
@@ -64,7 +87,7 @@ export class RoomsEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create room
|
/** Creates a new room, saves it locally, and registers it with the server directory. */
|
||||||
createRoom$ = createEffect(() =>
|
createRoom$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.createRoom),
|
ofType(RoomsActions.createRoom),
|
||||||
@@ -104,10 +127,7 @@ export class RoomsEffects {
|
|||||||
maxUsers: room.maxUsers || 50,
|
maxUsers: room.maxUsers || 50,
|
||||||
tags: [],
|
tags: [],
|
||||||
})
|
})
|
||||||
.subscribe({
|
.subscribe();
|
||||||
next: () => console.log('Room registered with directory, ID:', room.id),
|
|
||||||
error: (err) => console.warn('Failed to register room:', err),
|
|
||||||
});
|
|
||||||
|
|
||||||
return of(RoomsActions.createRoomSuccess({ room }));
|
return of(RoomsActions.createRoomSuccess({ room }));
|
||||||
}),
|
}),
|
||||||
@@ -117,7 +137,7 @@ export class RoomsEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Join room
|
/** Joins an existing room by ID, resolving room data from local DB or server directory. */
|
||||||
joinRoom$ = createEffect(() =>
|
joinRoom$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.joinRoom),
|
ofType(RoomsActions.joinRoom),
|
||||||
@@ -183,7 +203,7 @@ export class RoomsEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Navigate to room after successful create or join
|
/** Navigates to the room view and establishes or reuses a signaling connection. */
|
||||||
navigateToRoom$ = createEffect(
|
navigateToRoom$ = createEffect(
|
||||||
() =>
|
() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
@@ -196,23 +216,18 @@ export class RoomsEffects {
|
|||||||
|
|
||||||
// Check if already connected to signaling server
|
// Check if already connected to signaling server
|
||||||
if (this.webrtc.isConnected()) {
|
if (this.webrtc.isConnected()) {
|
||||||
// Already connected - join the new server (additive, multi-server)
|
|
||||||
console.log('Already connected to signaling, joining room:', room.id);
|
|
||||||
this.webrtc.setCurrentServer(room.id);
|
this.webrtc.setCurrentServer(room.id);
|
||||||
this.webrtc.switchServer(room.id, oderId);
|
this.webrtc.switchServer(room.id, oderId);
|
||||||
} else {
|
} else {
|
||||||
// Not connected - establish new connection
|
|
||||||
console.log('Connecting to signaling server:', wsUrl);
|
|
||||||
this.webrtc.connectToSignalingServer(wsUrl).subscribe({
|
this.webrtc.connectToSignalingServer(wsUrl).subscribe({
|
||||||
next: (connected) => {
|
next: (connected) => {
|
||||||
if (connected) {
|
if (connected) {
|
||||||
console.log('Connected to signaling, identifying user and joining room');
|
|
||||||
this.webrtc.setCurrentServer(room.id);
|
this.webrtc.setCurrentServer(room.id);
|
||||||
this.webrtc.identify(oderId, displayName);
|
this.webrtc.identify(oderId, displayName);
|
||||||
this.webrtc.joinRoom(room.id, oderId);
|
this.webrtc.joinRoom(room.id, oderId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (err) => console.error('Failed to connect to signaling server:', err),
|
error: () => {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +237,7 @@ export class RoomsEffects {
|
|||||||
{ dispatch: false }
|
{ dispatch: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// View server – switch view to an already-joined server without leaving others
|
/** Switches the UI view to an already-joined server without leaving others. */
|
||||||
viewServer$ = createEffect(() =>
|
viewServer$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.viewServer),
|
ofType(RoomsActions.viewServer),
|
||||||
@@ -241,7 +256,7 @@ export class RoomsEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// When viewing a different server, reload messages and users for that server
|
/** Reloads messages and users when the viewed server changes. */
|
||||||
onViewServerSuccess$ = createEffect(() =>
|
onViewServerSuccess$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.viewServerSuccess),
|
ofType(RoomsActions.viewServerSuccess),
|
||||||
@@ -253,20 +268,15 @@ export class RoomsEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Leave room
|
/** Handles leave-room dispatches (navigation only, peers stay connected). */
|
||||||
leaveRoom$ = createEffect(() =>
|
leaveRoom$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.leaveRoom),
|
ofType(RoomsActions.leaveRoom),
|
||||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
map(() => RoomsActions.leaveRoomSuccess()),
|
||||||
mergeMap(([, currentRoom]) => {
|
),
|
||||||
// Do not disconnect peers or voice on simple room exit
|
|
||||||
// Navigation away from room should not kill voice or P2P.
|
|
||||||
return of(RoomsActions.leaveRoomSuccess());
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete room
|
/** Deletes a room (host-only): removes from DB, notifies peers, and disconnects. */
|
||||||
deleteRoom$ = createEffect(() =>
|
deleteRoom$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.deleteRoom),
|
ofType(RoomsActions.deleteRoom),
|
||||||
@@ -274,34 +284,19 @@ export class RoomsEffects {
|
|||||||
this.store.select(selectCurrentUser),
|
this.store.select(selectCurrentUser),
|
||||||
this.store.select(selectCurrentRoom)
|
this.store.select(selectCurrentRoom)
|
||||||
),
|
),
|
||||||
switchMap(([{ roomId }, currentUser, currentRoom]) => {
|
filter(([, currentUser, currentRoom]) =>
|
||||||
if (!currentUser) {
|
!!currentUser && currentRoom?.hostId === currentUser.id,
|
||||||
return of({ type: 'NO_OP' });
|
),
|
||||||
}
|
switchMap(([{ roomId }]) => {
|
||||||
|
|
||||||
// Only host can delete the room
|
|
||||||
if (currentRoom?.hostId !== currentUser.id) {
|
|
||||||
return of({ type: 'NO_OP' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete from local DB
|
|
||||||
this.db.deleteRoom(roomId);
|
this.db.deleteRoom(roomId);
|
||||||
|
this.webrtc.broadcastMessage({ type: 'room-deleted', roomId });
|
||||||
// Notify all connected peers
|
|
||||||
this.webrtc.broadcastMessage({
|
|
||||||
type: 'room-deleted',
|
|
||||||
roomId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Disconnect everyone
|
|
||||||
this.webrtc.disconnectAll();
|
this.webrtc.disconnectAll();
|
||||||
|
|
||||||
return of(RoomsActions.deleteRoomSuccess({ roomId }));
|
return of(RoomsActions.deleteRoomSuccess({ roomId }));
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Forget room locally (remove from savedRooms and local DB)
|
/** Forgets a room locally: removes from DB and leaves the signaling server for that room. */
|
||||||
forgetRoom$ = createEffect(() =>
|
forgetRoom$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.forgetRoom),
|
ofType(RoomsActions.forgetRoom),
|
||||||
@@ -318,7 +313,7 @@ export class RoomsEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update room settings
|
/** Updates room settings (host/admin-only) and broadcasts changes to all peers. */
|
||||||
updateRoomSettings$ = createEffect(() =>
|
updateRoomSettings$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.updateRoomSettings),
|
ofType(RoomsActions.updateRoomSettings),
|
||||||
@@ -373,7 +368,7 @@ export class RoomsEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update room
|
/** Persists room field changes to the local database. */
|
||||||
updateRoom$ = createEffect(
|
updateRoom$ = createEffect(
|
||||||
() =>
|
() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
@@ -388,21 +383,17 @@ export class RoomsEffects {
|
|||||||
{ dispatch: false }
|
{ dispatch: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update room permissions (host only)
|
/** Updates room permission grants (host-only) and broadcasts to peers. */
|
||||||
updateRoomPermissions$ = createEffect(() =>
|
updateRoomPermissions$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.updateRoomPermissions),
|
ofType(RoomsActions.updateRoomPermissions),
|
||||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
||||||
mergeMap(([{ roomId, permissions }, currentUser, currentRoom]) => {
|
filter(([{ roomId }, currentUser, currentRoom]) =>
|
||||||
if (!currentUser || !currentRoom || currentRoom.id !== roomId) {
|
!!currentUser && !!currentRoom && currentRoom.id === roomId && currentRoom.hostId === currentUser.id,
|
||||||
return of({ type: 'NO_OP' });
|
),
|
||||||
}
|
mergeMap(([{ roomId, permissions }, , currentRoom]) => {
|
||||||
// Only host can change permission grant settings
|
|
||||||
if (currentRoom.hostId !== currentUser.id) {
|
|
||||||
return of({ type: 'NO_OP' });
|
|
||||||
}
|
|
||||||
const updated: Partial<Room> = {
|
const updated: Partial<Room> = {
|
||||||
permissions: { ...(currentRoom.permissions || {}), ...permissions } as RoomPermissions,
|
permissions: { ...(currentRoom!.permissions || {}), ...permissions } as RoomPermissions,
|
||||||
};
|
};
|
||||||
this.db.updateRoom(roomId, updated);
|
this.db.updateRoom(roomId, updated);
|
||||||
// Broadcast to peers
|
// Broadcast to peers
|
||||||
@@ -412,7 +403,7 @@ export class RoomsEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update server icon (host or permitted roles)
|
/** Updates the server icon (permission-enforced) and broadcasts to peers. */
|
||||||
updateServerIcon$ = createEffect(() =>
|
updateServerIcon$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.updateServerIcon),
|
ofType(RoomsActions.updateServerIcon),
|
||||||
@@ -440,7 +431,7 @@ export class RoomsEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Persist room creation to database
|
/** Persists newly created room to the local database. */
|
||||||
persistRoomCreation$ = createEffect(
|
persistRoomCreation$ = createEffect(
|
||||||
() =>
|
() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
@@ -452,7 +443,7 @@ export class RoomsEffects {
|
|||||||
{ dispatch: false }
|
{ dispatch: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// When joining a room, also load messages and users
|
/** Loads messages and bans when joining a room. */
|
||||||
onJoinRoomSuccess$ = createEffect(() =>
|
onJoinRoomSuccess$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.joinRoomSuccess),
|
ofType(RoomsActions.joinRoomSuccess),
|
||||||
@@ -465,7 +456,7 @@ export class RoomsEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// When leaving a room, clear messages and users
|
/** Clears messages and users from the store when leaving a room. */
|
||||||
onLeaveRoom$ = createEffect(() =>
|
onLeaveRoom$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.leaveRoomSuccess),
|
ofType(RoomsActions.leaveRoomSuccess),
|
||||||
@@ -476,7 +467,7 @@ export class RoomsEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Listen to WebRTC signaling messages for user presence
|
/** Handles WebRTC signaling events for user presence (join, leave, server_users). */
|
||||||
signalingMessages$ = createEffect(() =>
|
signalingMessages$ = createEffect(() =>
|
||||||
this.webrtc.onSignalingMessage.pipe(
|
this.webrtc.onSignalingMessage.pipe(
|
||||||
withLatestFrom(
|
withLatestFrom(
|
||||||
@@ -484,223 +475,174 @@ export class RoomsEffects {
|
|||||||
this.store.select(selectCurrentRoom),
|
this.store.select(selectCurrentRoom),
|
||||||
),
|
),
|
||||||
mergeMap(([message, currentUser, currentRoom]: [any, any, any]) => {
|
mergeMap(([message, currentUser, currentRoom]: [any, any, any]) => {
|
||||||
const actions: any[] = [];
|
|
||||||
const myId = currentUser?.oderId || currentUser?.id;
|
const myId = currentUser?.oderId || currentUser?.id;
|
||||||
const viewedServerId = currentRoom?.id;
|
const viewedServerId = currentRoom?.id;
|
||||||
|
|
||||||
if (message.type === 'server_users' && message.users) {
|
switch (message.type) {
|
||||||
// Only populate for the currently viewed server
|
case 'server_users': {
|
||||||
const msgServerId = message.serverId;
|
if (!message.users || isWrongServer(message.serverId, viewedServerId)) return EMPTY;
|
||||||
if (msgServerId && viewedServerId && msgServerId !== viewedServerId) {
|
const joinActions = (message.users as { oderId: string; displayName: string }[])
|
||||||
return [{ type: 'NO_OP' }];
|
.filter((u) => u.oderId !== myId)
|
||||||
|
.map((u) => UsersActions.userJoined({ user: buildSignalingUser(u) }));
|
||||||
|
return [UsersActions.clearUsers(), ...joinActions];
|
||||||
}
|
}
|
||||||
// Clear existing users first, then add the new set
|
case 'user_joined': {
|
||||||
actions.push(UsersActions.clearUsers());
|
if (isWrongServer(message.serverId, viewedServerId) || message.oderId === myId) return EMPTY;
|
||||||
// Add all existing users to the store (excluding ourselves)
|
return [UsersActions.userJoined({ user: buildSignalingUser(message) })];
|
||||||
message.users.forEach((user: { oderId: string; displayName: string }) => {
|
|
||||||
// Don't add ourselves to the list
|
|
||||||
if (user.oderId !== myId) {
|
|
||||||
actions.push(
|
|
||||||
UsersActions.userJoined({
|
|
||||||
user: {
|
|
||||||
oderId: user.oderId,
|
|
||||||
id: user.oderId,
|
|
||||||
username: user.displayName.toLowerCase().replace(/\s+/g, '_'),
|
|
||||||
displayName: user.displayName,
|
|
||||||
status: 'online',
|
|
||||||
isOnline: true,
|
|
||||||
role: 'member',
|
|
||||||
joinedAt: Date.now(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (message.type === 'user_joined') {
|
|
||||||
// Only add to user list if this event is for the currently viewed server
|
|
||||||
const msgServerId = message.serverId;
|
|
||||||
if (msgServerId && viewedServerId && msgServerId !== viewedServerId) {
|
|
||||||
return [{ type: 'NO_OP' }];
|
|
||||||
}
|
}
|
||||||
// Don't add ourselves to the list
|
case 'user_left': {
|
||||||
if (message.oderId !== myId) {
|
if (isWrongServer(message.serverId, viewedServerId)) return EMPTY;
|
||||||
actions.push(
|
return [UsersActions.userLeft({ userId: message.oderId })];
|
||||||
UsersActions.userJoined({
|
|
||||||
user: {
|
|
||||||
oderId: message.oderId,
|
|
||||||
id: message.oderId,
|
|
||||||
username: message.displayName.toLowerCase().replace(/\s+/g, '_'),
|
|
||||||
displayName: message.displayName,
|
|
||||||
status: 'online',
|
|
||||||
isOnline: true,
|
|
||||||
role: 'member',
|
|
||||||
joinedAt: Date.now(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else if (message.type === 'user_left') {
|
default:
|
||||||
// Only remove from user list if this event is for the currently viewed server
|
return EMPTY;
|
||||||
const msgServerId = message.serverId;
|
|
||||||
if (msgServerId && viewedServerId && msgServerId !== viewedServerId) {
|
|
||||||
return [{ type: 'NO_OP' }];
|
|
||||||
}
|
|
||||||
actions.push(UsersActions.userLeft({ userId: message.oderId }));
|
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
return actions.length > 0 ? actions : [{ type: 'NO_OP' }];
|
),
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Incoming P2P room/icon events
|
/** Processes incoming P2P room and icon-sync events. */
|
||||||
incomingRoomEvents$ = createEffect(() =>
|
incomingRoomEvents$ = createEffect(() =>
|
||||||
this.webrtc.onMessageReceived.pipe(
|
this.webrtc.onMessageReceived.pipe(
|
||||||
withLatestFrom(
|
withLatestFrom(
|
||||||
this.store.select(selectCurrentRoom),
|
this.store.select(selectCurrentRoom),
|
||||||
this.store.select(selectAllUsers)
|
this.store.select(selectAllUsers),
|
||||||
),
|
),
|
||||||
mergeMap(([event, currentRoom, allUsers]: [any, Room | null, any[]]) => {
|
filter(([, room]) => !!room),
|
||||||
if (!currentRoom) return of({ type: 'NO_OP' });
|
mergeMap(([event, currentRoom, allUsers]: [any, any, any[]]) => {
|
||||||
|
const room = currentRoom as Room;
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'voice-state': {
|
case 'voice-state':
|
||||||
const userId = (event.fromPeerId as string) || (event.oderId as string);
|
return this.handleVoiceOrScreenState(event, allUsers, 'voice');
|
||||||
const vs = event.voiceState as Partial<import('../../core/models').VoiceState> | undefined;
|
case 'screen-state':
|
||||||
if (!userId || !vs) return of({ type: 'NO_OP' });
|
return this.handleVoiceOrScreenState(event, allUsers, 'screen');
|
||||||
|
case 'room-settings-update':
|
||||||
// Check if user exists in the store
|
return this.handleRoomSettingsUpdate(event, room);
|
||||||
const userExists = allUsers.some(u => u.id === userId || u.oderId === userId);
|
case 'server-icon-summary':
|
||||||
|
return this.handleIconSummary(event, room);
|
||||||
if (!userExists) {
|
case 'server-icon-request':
|
||||||
// User doesn't exist yet - create them with the voice state
|
return this.handleIconRequest(event, room);
|
||||||
// This handles the race condition where voice-state arrives before server_users
|
|
||||||
const displayName = event.displayName || 'User';
|
|
||||||
return of(UsersActions.userJoined({
|
|
||||||
user: {
|
|
||||||
oderId: userId,
|
|
||||||
id: userId,
|
|
||||||
username: displayName.toLowerCase().replace(/\s+/g, '_'),
|
|
||||||
displayName: displayName,
|
|
||||||
status: 'online',
|
|
||||||
isOnline: true,
|
|
||||||
role: 'member',
|
|
||||||
joinedAt: Date.now(),
|
|
||||||
voiceState: {
|
|
||||||
isConnected: vs.isConnected ?? false,
|
|
||||||
isMuted: vs.isMuted ?? false,
|
|
||||||
isDeafened: vs.isDeafened ?? false,
|
|
||||||
isSpeaking: vs.isSpeaking ?? false,
|
|
||||||
isMutedByAdmin: vs.isMutedByAdmin,
|
|
||||||
volume: vs.volume,
|
|
||||||
roomId: vs.roomId,
|
|
||||||
serverId: vs.serverId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return of(UsersActions.updateVoiceState({ userId, voiceState: vs }));
|
|
||||||
}
|
|
||||||
case 'screen-state': {
|
|
||||||
const userId = (event.fromPeerId as string) || (event.oderId as string);
|
|
||||||
const isSharing = event.isScreenSharing as boolean | undefined;
|
|
||||||
if (!userId || isSharing === undefined) return of({ type: 'NO_OP' });
|
|
||||||
|
|
||||||
// Check if user exists in the store
|
|
||||||
const userExists = allUsers.some(u => u.id === userId || u.oderId === userId);
|
|
||||||
|
|
||||||
if (!userExists) {
|
|
||||||
// User doesn't exist yet - create them with the screen share state
|
|
||||||
const displayName = event.displayName || 'User';
|
|
||||||
return of(UsersActions.userJoined({
|
|
||||||
user: {
|
|
||||||
oderId: userId,
|
|
||||||
id: userId,
|
|
||||||
username: displayName.toLowerCase().replace(/\s+/g, '_'),
|
|
||||||
displayName: displayName,
|
|
||||||
status: 'online',
|
|
||||||
isOnline: true,
|
|
||||||
role: 'member',
|
|
||||||
joinedAt: Date.now(),
|
|
||||||
screenShareState: {
|
|
||||||
isSharing,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return of(UsersActions.updateScreenShareState({
|
|
||||||
userId,
|
|
||||||
screenShareState: { isSharing },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
case 'room-settings-update': {
|
|
||||||
const settings: RoomSettings | undefined = event.settings;
|
|
||||||
if (!settings) return of({ type: 'NO_OP' });
|
|
||||||
this.db.updateRoom(currentRoom.id, settings);
|
|
||||||
return of(
|
|
||||||
RoomsActions.receiveRoomUpdate({ room: { ...settings } as Partial<Room> })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server icon sync handshake
|
|
||||||
case 'server-icon-summary': {
|
|
||||||
const remoteUpdated = event.iconUpdatedAt || 0;
|
|
||||||
const localUpdated = currentRoom.iconUpdatedAt || 0;
|
|
||||||
const needsSync = remoteUpdated > localUpdated;
|
|
||||||
if (needsSync && event.fromPeerId) {
|
|
||||||
this.webrtc.sendToPeer(event.fromPeerId, { type: 'server-icon-request', roomId: currentRoom.id } as any);
|
|
||||||
}
|
|
||||||
return of({ type: 'NO_OP' });
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'server-icon-request': {
|
|
||||||
if (event.fromPeerId) {
|
|
||||||
this.webrtc.sendToPeer(event.fromPeerId, {
|
|
||||||
type: 'server-icon-full',
|
|
||||||
roomId: currentRoom.id,
|
|
||||||
icon: currentRoom.icon,
|
|
||||||
iconUpdatedAt: currentRoom.iconUpdatedAt || 0,
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
return of({ type: 'NO_OP' });
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'server-icon-full':
|
case 'server-icon-full':
|
||||||
case 'server-icon-update': {
|
case 'server-icon-update':
|
||||||
if (typeof event.icon !== 'string') return of({ type: 'NO_OP' });
|
return this.handleIconData(event, room);
|
||||||
// Enforce that only owner or permitted roles can update
|
default:
|
||||||
const senderId = event.fromPeerId as string | undefined;
|
return EMPTY;
|
||||||
if (!senderId) return of({ type: 'NO_OP' });
|
|
||||||
return this.store.select(selectAllUsers).pipe(
|
|
||||||
map((users) => users.find((u) => u.id === senderId)),
|
|
||||||
mergeMap((sender) => {
|
|
||||||
if (!sender) return of({ type: 'NO_OP' });
|
|
||||||
const role = sender.role;
|
|
||||||
const perms = currentRoom.permissions || {};
|
|
||||||
const isOwner = currentRoom.hostId === sender.id;
|
|
||||||
const canByRole = (role === 'admin' && perms.adminsManageIcon) || (role === 'moderator' && perms.moderatorsManageIcon);
|
|
||||||
if (!isOwner && !canByRole) {
|
|
||||||
return of({ type: 'NO_OP' });
|
|
||||||
}
|
|
||||||
const updates: Partial<Room> = {
|
|
||||||
icon: event.icon,
|
|
||||||
iconUpdatedAt: event.iconUpdatedAt || Date.now(),
|
|
||||||
};
|
|
||||||
this.db.updateRoom(currentRoom.id, updates);
|
|
||||||
return of(RoomsActions.updateRoom({ roomId: currentRoom.id, changes: updates }));
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
return of({ type: 'NO_OP' });
|
),
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// On peer connect, broadcast local server icon summary (sync upon join/connect)
|
private handleVoiceOrScreenState(
|
||||||
|
event: any,
|
||||||
|
allUsers: any[],
|
||||||
|
kind: 'voice' | 'screen',
|
||||||
|
) {
|
||||||
|
const userId: string | undefined = event.fromPeerId ?? event.oderId;
|
||||||
|
if (!userId) return EMPTY;
|
||||||
|
|
||||||
|
const userExists = allUsers.some((u) => u.id === userId || u.oderId === userId);
|
||||||
|
|
||||||
|
if (kind === 'voice') {
|
||||||
|
const vs = event.voiceState as Partial<VoiceState> | undefined;
|
||||||
|
if (!vs) return EMPTY;
|
||||||
|
|
||||||
|
if (!userExists) {
|
||||||
|
return of(UsersActions.userJoined({
|
||||||
|
user: buildSignalingUser(
|
||||||
|
{ oderId: userId, displayName: event.displayName || 'User' },
|
||||||
|
{
|
||||||
|
voiceState: {
|
||||||
|
isConnected: vs.isConnected ?? false,
|
||||||
|
isMuted: vs.isMuted ?? false,
|
||||||
|
isDeafened: vs.isDeafened ?? false,
|
||||||
|
isSpeaking: vs.isSpeaking ?? false,
|
||||||
|
isMutedByAdmin: vs.isMutedByAdmin,
|
||||||
|
volume: vs.volume,
|
||||||
|
roomId: vs.roomId,
|
||||||
|
serverId: vs.serverId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return of(UsersActions.updateVoiceState({ userId, voiceState: vs }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// screen-state
|
||||||
|
const isSharing = event.isScreenSharing as boolean | undefined;
|
||||||
|
if (isSharing === undefined) return EMPTY;
|
||||||
|
|
||||||
|
if (!userExists) {
|
||||||
|
return of(UsersActions.userJoined({
|
||||||
|
user: buildSignalingUser(
|
||||||
|
{ oderId: userId, displayName: event.displayName || 'User' },
|
||||||
|
{ screenShareState: { isSharing } },
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return of(UsersActions.updateScreenShareState({
|
||||||
|
userId,
|
||||||
|
screenShareState: { isSharing },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRoomSettingsUpdate(event: any, room: Room) {
|
||||||
|
const settings: RoomSettings | undefined = event.settings;
|
||||||
|
if (!settings) return EMPTY;
|
||||||
|
this.db.updateRoom(room.id, settings);
|
||||||
|
return of(RoomsActions.receiveRoomUpdate({ room: { ...settings } as Partial<Room> }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleIconSummary(event: any, room: Room) {
|
||||||
|
const remoteUpdated = event.iconUpdatedAt || 0;
|
||||||
|
const localUpdated = room.iconUpdatedAt || 0;
|
||||||
|
if (remoteUpdated > localUpdated && event.fromPeerId) {
|
||||||
|
this.webrtc.sendToPeer(event.fromPeerId, {
|
||||||
|
type: 'server-icon-request',
|
||||||
|
roomId: room.id,
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleIconRequest(event: any, room: Room) {
|
||||||
|
if (event.fromPeerId) {
|
||||||
|
this.webrtc.sendToPeer(event.fromPeerId, {
|
||||||
|
type: 'server-icon-full',
|
||||||
|
roomId: room.id,
|
||||||
|
icon: room.icon,
|
||||||
|
iconUpdatedAt: room.iconUpdatedAt || 0,
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleIconData(event: any, room: Room) {
|
||||||
|
const senderId = event.fromPeerId as string | undefined;
|
||||||
|
if (typeof event.icon !== 'string' || !senderId) return EMPTY;
|
||||||
|
|
||||||
|
return this.store.select(selectAllUsers).pipe(
|
||||||
|
map((users) => users.find((u) => u.id === senderId)),
|
||||||
|
mergeMap((sender) => {
|
||||||
|
if (!sender) return EMPTY;
|
||||||
|
const perms = room.permissions || {};
|
||||||
|
const isOwner = room.hostId === sender.id;
|
||||||
|
const canByRole =
|
||||||
|
(sender.role === 'admin' && perms.adminsManageIcon) ||
|
||||||
|
(sender.role === 'moderator' && perms.moderatorsManageIcon);
|
||||||
|
if (!isOwner && !canByRole) return EMPTY;
|
||||||
|
|
||||||
|
const updates: Partial<Room> = {
|
||||||
|
icon: event.icon,
|
||||||
|
iconUpdatedAt: event.iconUpdatedAt || Date.now(),
|
||||||
|
};
|
||||||
|
this.db.updateRoom(room.id, updates);
|
||||||
|
return of(RoomsActions.updateRoom({ roomId: room.id, changes: updates }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broadcasts the local server icon summary to peers when a new peer connects. */
|
||||||
peerConnectedIconSync$ = createEffect(
|
peerConnectedIconSync$ = createEffect(
|
||||||
() =>
|
() =>
|
||||||
this.webrtc.onPeerConnected.pipe(
|
this.webrtc.onPeerConnected.pipe(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createReducer, on } from '@ngrx/store';
|
import { createReducer, on } from '@ngrx/store';
|
||||||
import { Room, ServerInfo, RoomSettings, Channel } from '../../core/models';
|
import { Room, ServerInfo, RoomSettings, Channel } from '../../core/models';
|
||||||
import * as RoomsActions from './rooms.actions';
|
import { RoomsActions } from './rooms.actions';
|
||||||
|
|
||||||
/** Default channels for a new server */
|
/** Default channels for a new server */
|
||||||
export function defaultChannels(): Channel[] {
|
export function defaultChannels(): Channel[] {
|
||||||
@@ -15,15 +15,15 @@ export function defaultChannels(): Channel[] {
|
|||||||
/** Deduplicate rooms by id, keeping the last occurrence */
|
/** Deduplicate rooms by id, keeping the last occurrence */
|
||||||
function deduplicateRooms(rooms: Room[]): Room[] {
|
function deduplicateRooms(rooms: Room[]): Room[] {
|
||||||
const seen = new Map<string, Room>();
|
const seen = new Map<string, Room>();
|
||||||
for (const r of rooms) {
|
for (const room of rooms) {
|
||||||
seen.set(r.id, r);
|
seen.set(room.id, room);
|
||||||
}
|
}
|
||||||
return Array.from(seen.values());
|
return Array.from(seen.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Upsert a room into a saved-rooms list (add or replace by id) */
|
/** Upsert a room into a saved-rooms list (add or replace by id) */
|
||||||
function upsertRoom(savedRooms: Room[], room: Room): Room[] {
|
function upsertRoom(savedRooms: Room[], room: Room): Room[] {
|
||||||
const idx = savedRooms.findIndex(r => r.id === room.id);
|
const idx = savedRooms.findIndex(existingRoom => existingRoom.id === room.id);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
const updated = [...savedRooms];
|
const updated = [...savedRooms];
|
||||||
updated[idx] = room;
|
updated[idx] = room;
|
||||||
@@ -32,17 +32,28 @@ function upsertRoom(savedRooms: Room[], room: Room): Room[] {
|
|||||||
return [...savedRooms, room];
|
return [...savedRooms, room];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** State shape for the rooms feature slice. */
|
||||||
export interface RoomsState {
|
export interface RoomsState {
|
||||||
|
/** The room the user is currently viewing. */
|
||||||
currentRoom: Room | null;
|
currentRoom: Room | null;
|
||||||
|
/** All rooms persisted locally (joined or created). */
|
||||||
savedRooms: Room[];
|
savedRooms: Room[];
|
||||||
|
/** Editable settings for the current room. */
|
||||||
roomSettings: RoomSettings | null;
|
roomSettings: RoomSettings | null;
|
||||||
|
/** Results returned from the server directory search. */
|
||||||
searchResults: ServerInfo[];
|
searchResults: ServerInfo[];
|
||||||
|
/** Whether a server directory search is in progress. */
|
||||||
isSearching: boolean;
|
isSearching: boolean;
|
||||||
|
/** Whether a connection to a room is being established. */
|
||||||
isConnecting: boolean;
|
isConnecting: boolean;
|
||||||
|
/** Whether the user is connected to a room. */
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
|
/** Whether rooms are being loaded from local storage. */
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
/** Most recent error message, if any. */
|
||||||
error: string | null;
|
error: string | null;
|
||||||
activeChannelId: string; // currently selected text channel
|
/** ID of the currently selected text channel. */
|
||||||
|
activeChannelId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initialState: RoomsState = {
|
export const initialState: RoomsState = {
|
||||||
@@ -212,14 +223,14 @@ export const roomsReducer = createReducer(
|
|||||||
// Delete room
|
// Delete room
|
||||||
on(RoomsActions.deleteRoomSuccess, (state, { roomId }) => ({
|
on(RoomsActions.deleteRoomSuccess, (state, { roomId }) => ({
|
||||||
...state,
|
...state,
|
||||||
savedRooms: state.savedRooms.filter((r) => r.id !== roomId),
|
savedRooms: state.savedRooms.filter((room) => room.id !== roomId),
|
||||||
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom,
|
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
// Forget room (local only)
|
// Forget room (local only)
|
||||||
on(RoomsActions.forgetRoomSuccess, (state, { roomId }) => ({
|
on(RoomsActions.forgetRoomSuccess, (state, { roomId }) => ({
|
||||||
...state,
|
...state,
|
||||||
savedRooms: state.savedRooms.filter((r) => r.id !== roomId),
|
savedRooms: state.savedRooms.filter((room) => room.id !== roomId),
|
||||||
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom,
|
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
@@ -295,7 +306,7 @@ export const roomsReducer = createReducer(
|
|||||||
on(RoomsActions.removeChannel, (state, { channelId }) => {
|
on(RoomsActions.removeChannel, (state, { channelId }) => {
|
||||||
if (!state.currentRoom) return state;
|
if (!state.currentRoom) return state;
|
||||||
const existing = state.currentRoom.channels || defaultChannels();
|
const existing = state.currentRoom.channels || defaultChannels();
|
||||||
const updatedChannels = existing.filter(c => c.id !== channelId);
|
const updatedChannels = existing.filter(channel => channel.id !== channelId);
|
||||||
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
|
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -308,7 +319,7 @@ export const roomsReducer = createReducer(
|
|||||||
on(RoomsActions.renameChannel, (state, { channelId, name }) => {
|
on(RoomsActions.renameChannel, (state, { channelId, name }) => {
|
||||||
if (!state.currentRoom) return state;
|
if (!state.currentRoom) return state;
|
||||||
const existing = state.currentRoom.channels || defaultChannels();
|
const existing = state.currentRoom.channels || defaultChannels();
|
||||||
const updatedChannels = existing.map(c => c.id === channelId ? { ...c, name } : c);
|
const updatedChannels = existing.map(channel => channel.id === channelId ? { ...channel, name } : channel);
|
||||||
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
|
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -1,84 +1,101 @@
|
|||||||
import { createFeatureSelector, createSelector } from '@ngrx/store';
|
import { createFeatureSelector, createSelector } from '@ngrx/store';
|
||||||
import { RoomsState } from './rooms.reducer';
|
import { RoomsState } from './rooms.reducer';
|
||||||
|
|
||||||
|
/** Selects the top-level rooms feature state. */
|
||||||
export const selectRoomsState = createFeatureSelector<RoomsState>('rooms');
|
export const selectRoomsState = createFeatureSelector<RoomsState>('rooms');
|
||||||
|
|
||||||
|
/** Selects the room the user is currently viewing. */
|
||||||
export const selectCurrentRoom = createSelector(
|
export const selectCurrentRoom = createSelector(
|
||||||
selectRoomsState,
|
selectRoomsState,
|
||||||
(state) => state.currentRoom
|
(state) => state.currentRoom
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects the current room's settings (name, topic, privacy, etc.). */
|
||||||
export const selectRoomSettings = createSelector(
|
export const selectRoomSettings = createSelector(
|
||||||
selectRoomsState,
|
selectRoomsState,
|
||||||
(state) => state.roomSettings
|
(state) => state.roomSettings
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects server search results from the directory. */
|
||||||
export const selectSearchResults = createSelector(
|
export const selectSearchResults = createSelector(
|
||||||
selectRoomsState,
|
selectRoomsState,
|
||||||
(state) => state.searchResults
|
(state) => state.searchResults
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Whether a server directory search is currently in progress. */
|
||||||
export const selectIsSearching = createSelector(
|
export const selectIsSearching = createSelector(
|
||||||
selectRoomsState,
|
selectRoomsState,
|
||||||
(state) => state.isSearching
|
(state) => state.isSearching
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Whether a room connection is being established. */
|
||||||
export const selectIsConnecting = createSelector(
|
export const selectIsConnecting = createSelector(
|
||||||
selectRoomsState,
|
selectRoomsState,
|
||||||
(state) => state.isConnecting
|
(state) => state.isConnecting
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Whether the user is currently connected to a room. */
|
||||||
export const selectIsConnected = createSelector(
|
export const selectIsConnected = createSelector(
|
||||||
selectRoomsState,
|
selectRoomsState,
|
||||||
(state) => state.isConnected
|
(state) => state.isConnected
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects the most recent rooms-related error message. */
|
||||||
export const selectRoomsError = createSelector(
|
export const selectRoomsError = createSelector(
|
||||||
selectRoomsState,
|
selectRoomsState,
|
||||||
(state) => state.error
|
(state) => state.error
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects the ID of the current room, or null. */
|
||||||
export const selectCurrentRoomId = createSelector(
|
export const selectCurrentRoomId = createSelector(
|
||||||
selectCurrentRoom,
|
selectCurrentRoom,
|
||||||
(room) => room?.id ?? null
|
(room) => room?.id ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects the display name of the current room. */
|
||||||
export const selectCurrentRoomName = createSelector(
|
export const selectCurrentRoomName = createSelector(
|
||||||
selectCurrentRoom,
|
selectCurrentRoom,
|
||||||
(room) => room?.name ?? ''
|
(room) => room?.name ?? ''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects the host ID of the current room (for ownership checks). */
|
||||||
export const selectIsCurrentUserHost = createSelector(
|
export const selectIsCurrentUserHost = createSelector(
|
||||||
selectCurrentRoom,
|
selectCurrentRoom,
|
||||||
(room) => room?.hostId // Will be compared with current user ID in component
|
(room) => room?.hostId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects all locally-saved rooms. */
|
||||||
export const selectSavedRooms = createSelector(
|
export const selectSavedRooms = createSelector(
|
||||||
selectRoomsState,
|
selectRoomsState,
|
||||||
(state) => state.savedRooms
|
(state) => state.savedRooms
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Whether rooms are currently being loaded from local storage. */
|
||||||
export const selectRoomsLoading = createSelector(
|
export const selectRoomsLoading = createSelector(
|
||||||
selectRoomsState,
|
selectRoomsState,
|
||||||
(state) => state.loading
|
(state) => state.loading
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects the ID of the currently active text channel. */
|
||||||
export const selectActiveChannelId = createSelector(
|
export const selectActiveChannelId = createSelector(
|
||||||
selectRoomsState,
|
selectRoomsState,
|
||||||
(state) => state.activeChannelId
|
(state) => state.activeChannelId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects all channels defined on the current room. */
|
||||||
export const selectCurrentRoomChannels = createSelector(
|
export const selectCurrentRoomChannels = createSelector(
|
||||||
selectCurrentRoom,
|
selectCurrentRoom,
|
||||||
(room) => room?.channels ?? []
|
(room) => room?.channels ?? []
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects only text channels, sorted by position. */
|
||||||
export const selectTextChannels = createSelector(
|
export const selectTextChannels = createSelector(
|
||||||
selectCurrentRoomChannels,
|
selectCurrentRoomChannels,
|
||||||
(channels) => channels.filter(c => c.type === 'text').sort((a, b) => a.position - b.position)
|
(channels) => channels.filter(channel => channel.type === 'text').sort((a, b) => a.position - b.position)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects only voice channels, sorted by position. */
|
||||||
export const selectVoiceChannels = createSelector(
|
export const selectVoiceChannels = createSelector(
|
||||||
selectCurrentRoomChannels,
|
selectCurrentRoomChannels,
|
||||||
(channels) => channels.filter(c => c.type === 'voice').sort((a, b) => a.position - b.position)
|
(channels) => channels.filter(channel => channel.type === 'voice').sort((a, b) => a.position - b.position)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,146 +1,48 @@
|
|||||||
import { createAction, props } from '@ngrx/store';
|
/**
|
||||||
|
* Users store actions using `createActionGroup`.
|
||||||
|
*/
|
||||||
|
import { createActionGroup, emptyProps, props } from '@ngrx/store';
|
||||||
import { User, BanEntry, VoiceState, ScreenShareState } from '../../core/models';
|
import { User, BanEntry, VoiceState, ScreenShareState } from '../../core/models';
|
||||||
|
|
||||||
// Load current user from storage
|
export const UsersActions = createActionGroup({
|
||||||
export const loadCurrentUser = createAction('[Users] Load Current User');
|
source: 'Users',
|
||||||
|
events: {
|
||||||
|
'Load Current User': emptyProps(),
|
||||||
|
'Load Current User Success': props<{ user: User }>(),
|
||||||
|
'Load Current User Failure': props<{ error: string }>(),
|
||||||
|
|
||||||
export const loadCurrentUserSuccess = createAction(
|
'Set Current User': props<{ user: User }>(),
|
||||||
'[Users] Load Current User Success',
|
'Update Current User': props<{ updates: Partial<User> }>(),
|
||||||
props<{ user: User }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const loadCurrentUserFailure = createAction(
|
'Load Room Users': props<{ roomId: string }>(),
|
||||||
'[Users] Load Current User Failure',
|
'Load Room Users Success': props<{ users: User[] }>(),
|
||||||
props<{ error: string }>()
|
'Load Room Users Failure': props<{ error: string }>(),
|
||||||
);
|
|
||||||
|
|
||||||
// Set current user
|
'User Joined': props<{ user: User }>(),
|
||||||
export const setCurrentUser = createAction(
|
'User Left': props<{ userId: string }>(),
|
||||||
'[Users] Set Current User',
|
|
||||||
props<{ user: User }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update current user
|
'Update User': props<{ userId: string; updates: Partial<User> }>(),
|
||||||
export const updateCurrentUser = createAction(
|
'Update User Role': props<{ userId: string; role: User['role'] }>(),
|
||||||
'[Users] Update Current User',
|
|
||||||
props<{ updates: Partial<User> }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Load users in room
|
'Kick User': props<{ userId: string }>(),
|
||||||
export const loadRoomUsers = createAction(
|
'Kick User Success': props<{ userId: string }>(),
|
||||||
'[Users] Load Room Users',
|
|
||||||
props<{ roomId: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const loadRoomUsersSuccess = createAction(
|
'Ban User': props<{ userId: string; reason?: string; expiresAt?: number }>(),
|
||||||
'[Users] Load Room Users Success',
|
'Ban User Success': props<{ userId: string; ban: BanEntry }>(),
|
||||||
props<{ users: User[] }>()
|
'Unban User': props<{ oderId: string }>(),
|
||||||
);
|
'Unban User Success': props<{ oderId: string }>(),
|
||||||
|
|
||||||
export const loadRoomUsersFailure = createAction(
|
'Load Bans': emptyProps(),
|
||||||
'[Users] Load Room Users Failure',
|
'Load Bans Success': props<{ bans: BanEntry[] }>(),
|
||||||
props<{ error: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// User joined
|
'Admin Mute User': props<{ userId: string }>(),
|
||||||
export const userJoined = createAction(
|
'Admin Unmute User': props<{ userId: string }>(),
|
||||||
'[Users] User Joined',
|
|
||||||
props<{ user: User }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// User left
|
'Sync Users': props<{ users: User[] }>(),
|
||||||
export const userLeft = createAction(
|
'Clear Users': emptyProps(),
|
||||||
'[Users] User Left',
|
'Update Host': props<{ userId: string }>(),
|
||||||
props<{ userId: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update user
|
'Update Voice State': props<{ userId: string; voiceState: Partial<VoiceState> }>(),
|
||||||
export const updateUser = createAction(
|
'Update Screen Share State': props<{ userId: string; screenShareState: Partial<ScreenShareState> }>(),
|
||||||
'[Users] Update User',
|
},
|
||||||
props<{ userId: string; updates: Partial<User> }>()
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// Update user role
|
|
||||||
export const updateUserRole = createAction(
|
|
||||||
'[Users] Update User Role',
|
|
||||||
props<{ userId: string; role: User['role'] }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Kick user
|
|
||||||
export const kickUser = createAction(
|
|
||||||
'[Users] Kick User',
|
|
||||||
props<{ userId: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const kickUserSuccess = createAction(
|
|
||||||
'[Users] Kick User Success',
|
|
||||||
props<{ userId: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ban user
|
|
||||||
export const banUser = createAction(
|
|
||||||
'[Users] Ban User',
|
|
||||||
props<{ userId: string; reason?: string; expiresAt?: number }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const banUserSuccess = createAction(
|
|
||||||
'[Users] Ban User Success',
|
|
||||||
props<{ userId: string; ban: BanEntry }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Unban user
|
|
||||||
export const unbanUser = createAction(
|
|
||||||
'[Users] Unban User',
|
|
||||||
props<{ oderId: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const unbanUserSuccess = createAction(
|
|
||||||
'[Users] Unban User Success',
|
|
||||||
props<{ oderId: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Load bans
|
|
||||||
export const loadBans = createAction('[Users] Load Bans');
|
|
||||||
|
|
||||||
export const loadBansSuccess = createAction(
|
|
||||||
'[Users] Load Bans Success',
|
|
||||||
props<{ bans: BanEntry[] }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Admin mute/unmute
|
|
||||||
export const adminMuteUser = createAction(
|
|
||||||
'[Users] Admin Mute User',
|
|
||||||
props<{ userId: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const adminUnmuteUser = createAction(
|
|
||||||
'[Users] Admin Unmute User',
|
|
||||||
props<{ userId: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sync users from peer
|
|
||||||
export const syncUsers = createAction(
|
|
||||||
'[Users] Sync Users',
|
|
||||||
props<{ users: User[] }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clear users
|
|
||||||
export const clearUsers = createAction('[Users] Clear Users');
|
|
||||||
|
|
||||||
// Update host
|
|
||||||
export const updateHost = createAction(
|
|
||||||
'[Users] Update Host',
|
|
||||||
props<{ userId: string }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update voice state for a user
|
|
||||||
export const updateVoiceState = createAction(
|
|
||||||
'[Users] Update Voice State',
|
|
||||||
props<{ userId: string; voiceState: Partial<VoiceState> }>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update screen share state for a user
|
|
||||||
export const updateScreenShareState = createAction(
|
|
||||||
'[Users] Update Screen Share State',
|
|
||||||
props<{ userId: string; screenShareState: Partial<ScreenShareState> }>()
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Users store effects (load, kick, ban, host election, profile persistence).
|
||||||
|
*/
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { of, from } from 'rxjs';
|
import { of, from, EMPTY } from 'rxjs';
|
||||||
import { map, mergeMap, catchError, withLatestFrom, tap, switchMap } from 'rxjs/operators';
|
import { map, mergeMap, catchError, withLatestFrom, tap, switchMap } from 'rxjs/operators';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import * as UsersActions from './users.actions';
|
import { UsersActions } from './users.actions';
|
||||||
import { selectCurrentUser, selectCurrentUserId, selectHostId } from './users.selectors';
|
import { selectCurrentUser, selectCurrentUserId, selectHostId } from './users.selectors';
|
||||||
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
||||||
import { DatabaseService } from '../../core/services/database.service';
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
import { User, BanEntry } from '../../core/models';
|
import { BanEntry } from '../../core/models';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersEffects {
|
export class UsersEffects {
|
||||||
@@ -19,6 +22,7 @@ export class UsersEffects {
|
|||||||
private webrtc = inject(WebRTCService);
|
private webrtc = inject(WebRTCService);
|
||||||
|
|
||||||
// Load current user from storage
|
// Load current user from storage
|
||||||
|
/** Loads the persisted current user from the local database on startup. */
|
||||||
loadCurrentUser$ = createEffect(() =>
|
loadCurrentUser$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(UsersActions.loadCurrentUser),
|
ofType(UsersActions.loadCurrentUser),
|
||||||
@@ -38,7 +42,7 @@ export class UsersEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load room users from database
|
/** Loads all users associated with a specific room from the local database. */
|
||||||
loadRoomUsers$ = createEffect(() =>
|
loadRoomUsers$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(UsersActions.loadRoomUsers),
|
ofType(UsersActions.loadRoomUsers),
|
||||||
@@ -53,25 +57,23 @@ export class UsersEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Kick user
|
/** Kicks a user from the room (requires moderator+ role). Broadcasts a kick signal. */
|
||||||
kickUser$ = createEffect(() =>
|
kickUser$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(UsersActions.kickUser),
|
ofType(UsersActions.kickUser),
|
||||||
withLatestFrom(
|
withLatestFrom(
|
||||||
this.store.select(selectCurrentUser),
|
this.store.select(selectCurrentUser),
|
||||||
this.store.select(selectCurrentRoom)
|
this.store.select(selectCurrentRoom),
|
||||||
),
|
),
|
||||||
mergeMap(([{ userId }, currentUser, currentRoom]) => {
|
mergeMap(([{ userId }, currentUser, currentRoom]) => {
|
||||||
if (!currentUser || !currentRoom) {
|
if (!currentUser || !currentRoom) return EMPTY;
|
||||||
return of({ type: 'NO_OP' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if current user has permission to kick
|
const canKick =
|
||||||
if (currentUser.role !== 'host' && currentUser.role !== 'admin' && currentUser.role !== 'moderator') {
|
currentUser.role === 'host' ||
|
||||||
return of({ type: 'NO_OP' });
|
currentUser.role === 'admin' ||
|
||||||
}
|
currentUser.role === 'moderator';
|
||||||
|
if (!canKick) return EMPTY;
|
||||||
|
|
||||||
// Send kick signal to the target user
|
|
||||||
this.webrtc.broadcastMessage({
|
this.webrtc.broadcastMessage({
|
||||||
type: 'kick',
|
type: 'kick',
|
||||||
targetUserId: userId,
|
targetUserId: userId,
|
||||||
@@ -80,32 +82,26 @@ export class UsersEffects {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return of(UsersActions.kickUserSuccess({ userId }));
|
return of(UsersActions.kickUserSuccess({ userId }));
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ban user
|
/** Bans a user, persists the ban locally, and broadcasts a ban signal to peers. */
|
||||||
banUser$ = createEffect(() =>
|
banUser$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(UsersActions.banUser),
|
ofType(UsersActions.banUser),
|
||||||
withLatestFrom(
|
withLatestFrom(
|
||||||
this.store.select(selectCurrentUser),
|
this.store.select(selectCurrentUser),
|
||||||
this.store.select(selectCurrentRoom)
|
this.store.select(selectCurrentRoom),
|
||||||
),
|
),
|
||||||
mergeMap(([{ userId, reason, expiresAt }, currentUser, currentRoom]) => {
|
mergeMap(([{ userId, reason, expiresAt }, currentUser, currentRoom]) => {
|
||||||
if (!currentUser || !currentRoom) {
|
if (!currentUser || !currentRoom) return EMPTY;
|
||||||
return of({ type: 'NO_OP' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check permission
|
const canBan = currentUser.role === 'host' || currentUser.role === 'admin';
|
||||||
if (currentUser.role !== 'host' && currentUser.role !== 'admin') {
|
if (!canBan) return EMPTY;
|
||||||
return of({ type: 'NO_OP' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to ban list
|
|
||||||
const banId = uuidv4();
|
|
||||||
const ban: BanEntry = {
|
const ban: BanEntry = {
|
||||||
oderId: banId,
|
oderId: uuidv4(),
|
||||||
userId,
|
userId,
|
||||||
roomId: currentRoom.id,
|
roomId: currentRoom.id,
|
||||||
bannedBy: currentUser.id,
|
bannedBy: currentUser.id,
|
||||||
@@ -115,8 +111,6 @@ export class UsersEffects {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.db.saveBan(ban);
|
this.db.saveBan(ban);
|
||||||
|
|
||||||
// Send ban signal
|
|
||||||
this.webrtc.broadcastMessage({
|
this.webrtc.broadcastMessage({
|
||||||
type: 'ban',
|
type: 'ban',
|
||||||
targetUserId: userId,
|
targetUserId: userId,
|
||||||
@@ -126,24 +120,24 @@ export class UsersEffects {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return of(UsersActions.banUserSuccess({ userId, ban }));
|
return of(UsersActions.banUserSuccess({ userId, ban }));
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Unban user
|
/** Removes a ban entry from the local database. */
|
||||||
unbanUser$ = createEffect(() =>
|
unbanUser$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(UsersActions.unbanUser),
|
ofType(UsersActions.unbanUser),
|
||||||
switchMap(({ oderId }) =>
|
switchMap(({ oderId }) =>
|
||||||
from(this.db.removeBan(oderId)).pipe(
|
from(this.db.removeBan(oderId)).pipe(
|
||||||
map(() => UsersActions.unbanUserSuccess({ oderId })),
|
map(() => UsersActions.unbanUserSuccess({ oderId })),
|
||||||
catchError(() => of({ type: 'NO_OP' }))
|
catchError(() => EMPTY)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load bans
|
/** Loads all active bans for the current room from the local database. */
|
||||||
loadBans$ = createEffect(() =>
|
loadBans$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(UsersActions.loadBans),
|
ofType(UsersActions.loadBans),
|
||||||
@@ -160,7 +154,7 @@ export class UsersEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle host reassignment when host leaves
|
/** Elects the current user as host if the previous host leaves. */
|
||||||
handleHostLeave$ = createEffect(() =>
|
handleHostLeave$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(UsersActions.userLeft),
|
ofType(UsersActions.userLeft),
|
||||||
@@ -168,17 +162,15 @@ export class UsersEffects {
|
|||||||
this.store.select(selectHostId),
|
this.store.select(selectHostId),
|
||||||
this.store.select(selectCurrentUserId)
|
this.store.select(selectCurrentUserId)
|
||||||
),
|
),
|
||||||
mergeMap(([{ userId }, hostId, currentUserId]) => {
|
mergeMap(([{ userId }, hostId, currentUserId]) =>
|
||||||
// If the leaving user is the host, elect a new host
|
userId === hostId && currentUserId
|
||||||
if (userId === hostId && currentUserId) {
|
? of(UsersActions.updateHost({ userId: currentUserId }))
|
||||||
return of(UsersActions.updateHost({ userId: currentUserId }));
|
: EMPTY,
|
||||||
}
|
),
|
||||||
return of({ type: 'NO_OP' });
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Persist user changes to database
|
/** Persists user profile changes to the local database whenever the current user is updated. */
|
||||||
persistUser$ = createEffect(
|
persistUser$ = createEffect(
|
||||||
() =>
|
() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { createReducer, on } from '@ngrx/store';
|
import { createReducer, on } from '@ngrx/store';
|
||||||
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
|
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
|
||||||
import { User, BanEntry } from '../../core/models';
|
import { User, BanEntry } from '../../core/models';
|
||||||
import * as UsersActions from './users.actions';
|
import { UsersActions } from './users.actions';
|
||||||
|
|
||||||
|
/** State shape for the users feature slice, extending NgRx EntityState. */
|
||||||
export interface UsersState extends EntityState<User> {
|
export interface UsersState extends EntityState<User> {
|
||||||
|
/** ID of the locally authenticated user. */
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
|
/** ID of the room host (owner). */
|
||||||
hostId: string | null;
|
hostId: string | null;
|
||||||
|
/** Whether a user-loading operation is in progress. */
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
/** Most recent error message from user operations. */
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
/** List of active bans for the current room. */
|
||||||
bans: BanEntry[];
|
bans: BanEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +143,7 @@ export const usersReducer = createReducer(
|
|||||||
// Unban user
|
// Unban user
|
||||||
on(UsersActions.unbanUserSuccess, (state, { oderId }) => ({
|
on(UsersActions.unbanUserSuccess, (state, { oderId }) => ({
|
||||||
...state,
|
...state,
|
||||||
bans: state.bans.filter((b) => b.oderId !== oderId),
|
bans: state.bans.filter((ban) => ban.oderId !== oderId),
|
||||||
})),
|
})),
|
||||||
|
|
||||||
// Load bans
|
// Load bans
|
||||||
|
|||||||
@@ -1,84 +1,103 @@
|
|||||||
import { createFeatureSelector, createSelector } from '@ngrx/store';
|
import { createFeatureSelector, createSelector } from '@ngrx/store';
|
||||||
import { UsersState, usersAdapter } from './users.reducer';
|
import { UsersState, usersAdapter } from './users.reducer';
|
||||||
|
|
||||||
|
/** Selects the top-level users feature state. */
|
||||||
export const selectUsersState = createFeatureSelector<UsersState>('users');
|
export const selectUsersState = createFeatureSelector<UsersState>('users');
|
||||||
|
|
||||||
const { selectIds, selectEntities, selectAll, selectTotal } = usersAdapter.getSelectors();
|
const { selectIds, selectEntities, selectAll, selectTotal } = usersAdapter.getSelectors();
|
||||||
|
|
||||||
|
/** Selects all user entities as a flat array. */
|
||||||
export const selectAllUsers = createSelector(selectUsersState, selectAll);
|
export const selectAllUsers = createSelector(selectUsersState, selectAll);
|
||||||
|
|
||||||
|
/** Selects the user entity dictionary keyed by ID. */
|
||||||
export const selectUsersEntities = createSelector(selectUsersState, selectEntities);
|
export const selectUsersEntities = createSelector(selectUsersState, selectEntities);
|
||||||
|
|
||||||
|
/** Selects all user IDs. */
|
||||||
export const selectUsersIds = createSelector(selectUsersState, selectIds);
|
export const selectUsersIds = createSelector(selectUsersState, selectIds);
|
||||||
|
|
||||||
|
/** Selects the total count of users. */
|
||||||
export const selectUsersTotal = createSelector(selectUsersState, selectTotal);
|
export const selectUsersTotal = createSelector(selectUsersState, selectTotal);
|
||||||
|
|
||||||
|
/** Whether a user-loading operation is in progress. */
|
||||||
export const selectUsersLoading = createSelector(
|
export const selectUsersLoading = createSelector(
|
||||||
selectUsersState,
|
selectUsersState,
|
||||||
(state) => state.loading
|
(state) => state.loading
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects the most recent users-related error message. */
|
||||||
export const selectUsersError = createSelector(
|
export const selectUsersError = createSelector(
|
||||||
selectUsersState,
|
selectUsersState,
|
||||||
(state) => state.error
|
(state) => state.error
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects the current (local) user's ID, or null. */
|
||||||
export const selectCurrentUserId = createSelector(
|
export const selectCurrentUserId = createSelector(
|
||||||
selectUsersState,
|
selectUsersState,
|
||||||
(state) => state.currentUserId
|
(state) => state.currentUserId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects the host's user ID. */
|
||||||
export const selectHostId = createSelector(
|
export const selectHostId = createSelector(
|
||||||
selectUsersState,
|
selectUsersState,
|
||||||
(state) => state.hostId
|
(state) => state.hostId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects all active ban entries for the current room. */
|
||||||
export const selectBannedUsers = createSelector(
|
export const selectBannedUsers = createSelector(
|
||||||
selectUsersState,
|
selectUsersState,
|
||||||
(state) => state.bans
|
(state) => state.bans
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects the full User entity for the current (local) user. */
|
||||||
export const selectCurrentUser = createSelector(
|
export const selectCurrentUser = createSelector(
|
||||||
selectUsersEntities,
|
selectUsersEntities,
|
||||||
selectCurrentUserId,
|
selectCurrentUserId,
|
||||||
(entities, currentUserId) => (currentUserId ? entities[currentUserId] : null)
|
(entities, currentUserId) => (currentUserId ? entities[currentUserId] : null)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects the full User entity for the room host. */
|
||||||
export const selectHost = createSelector(
|
export const selectHost = createSelector(
|
||||||
selectUsersEntities,
|
selectUsersEntities,
|
||||||
selectHostId,
|
selectHostId,
|
||||||
(entities, hostId) => (hostId ? entities[hostId] : null)
|
(entities, hostId) => (hostId ? entities[hostId] : null)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Creates a selector that returns a single user by their ID. */
|
||||||
export const selectUserById = (id: string) =>
|
export const selectUserById = (id: string) =>
|
||||||
createSelector(selectUsersEntities, (entities) => entities[id]);
|
createSelector(selectUsersEntities, (entities) => entities[id]);
|
||||||
|
|
||||||
|
/** Whether the current user is the room host. */
|
||||||
export const selectIsCurrentUserHost = createSelector(
|
export const selectIsCurrentUserHost = createSelector(
|
||||||
selectCurrentUserId,
|
selectCurrentUserId,
|
||||||
selectHostId,
|
selectHostId,
|
||||||
(currentUserId, hostId) => currentUserId === hostId
|
(currentUserId, hostId) => currentUserId === hostId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Whether the current user holds an elevated role (host, admin, or moderator). */
|
||||||
export const selectIsCurrentUserAdmin = createSelector(
|
export const selectIsCurrentUserAdmin = createSelector(
|
||||||
selectCurrentUser,
|
selectCurrentUser,
|
||||||
(user) => user?.role === 'host' || user?.role === 'admin' || user?.role === 'moderator'
|
(user) => user?.role === 'host' || user?.role === 'admin' || user?.role === 'moderator'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects users who are currently online (not offline). */
|
||||||
export const selectOnlineUsers = createSelector(
|
export const selectOnlineUsers = createSelector(
|
||||||
selectAllUsers,
|
selectAllUsers,
|
||||||
(users) => users.filter((u) => u.status !== 'offline' || u.isOnline === true)
|
(users) => users.filter((user) => user.status !== 'offline' || user.isOnline === true)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Creates a selector that returns users with a specific role. */
|
||||||
export const selectUsersByRole = (role: string) =>
|
export const selectUsersByRole = (role: string) =>
|
||||||
createSelector(selectAllUsers, (users) =>
|
createSelector(selectAllUsers, (users) =>
|
||||||
users.filter((u) => u.role === role)
|
users.filter((user) => user.role === role)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Selects all users with an elevated role (host, admin, or moderator). */
|
||||||
export const selectAdmins = createSelector(
|
export const selectAdmins = createSelector(
|
||||||
selectAllUsers,
|
selectAllUsers,
|
||||||
(users) => users.filter((u) => u.role === 'host' || u.role === 'admin' || u.role === 'moderator')
|
(users) => users.filter((user) => user.role === 'host' || user.role === 'admin' || user.role === 'moderator')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Whether the current user is the room owner (host role). */
|
||||||
export const selectIsCurrentUserOwner = createSelector(
|
export const selectIsCurrentUserOwner = createSelector(
|
||||||
selectCurrentUser,
|
selectCurrentUser,
|
||||||
(user) => user?.role === 'host'
|
(user) => user?.role === 'host'
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { bootstrapApplication } from '@angular/platform-browser';
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
import { appConfig } from './app/app.config';
|
import { appConfig } from './app/app.config';
|
||||||
import { App } from './app/app';
|
import { App } from './app/app';
|
||||||
|
import mermaid from 'mermaid';
|
||||||
|
|
||||||
|
// Expose mermaid globally for ngx-remark's MermaidComponent
|
||||||
|
(window as any)['mermaid'] = mermaid;
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: false,
|
||||||
|
securityLevel: 'loose',
|
||||||
|
theme: 'dark',
|
||||||
|
});
|
||||||
|
|
||||||
bootstrapApplication(App, appConfig)
|
bootstrapApplication(App, appConfig)
|
||||||
.catch((err) => console.error(err));
|
.catch((err) => console.error(err));
|
||||||
|
|||||||
@@ -64,27 +64,3 @@
|
|||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Markdown & code block tweaks */
|
|
||||||
.prose pre {
|
|
||||||
overflow-x: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
.prose code {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
.prose {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose img {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
max-height: 320px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Highlight.js theme */
|
|
||||||
@import 'highlight.js/styles/github-dark.css';
|
|
||||||
|
|||||||
Reference in New Issue
Block a user