Refactor and code designing
This commit is contained in:
@@ -65,8 +65,8 @@
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
"maximumWarning": "1MB",
|
||||
"maximumError": "2MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
||||
@@ -321,13 +321,11 @@ function rowToBan(r) {
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function registerDatabaseIpc() {
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────
|
||||
ipcMain.handle('db:initialize', async () => {
|
||||
await initDatabase();
|
||||
return true;
|
||||
});
|
||||
|
||||
// ── Messages ───────────────────────────────────────────────────────
|
||||
ipcMain.handle('db:saveMessage', (_e, message) => {
|
||||
db.run(
|
||||
`INSERT OR REPLACE INTO messages
|
||||
@@ -390,7 +388,6 @@ function registerDatabaseIpc() {
|
||||
persist();
|
||||
});
|
||||
|
||||
// ── Reactions ──────────────────────────────────────────────────────
|
||||
ipcMain.handle('db:saveReaction', (_e, reaction) => {
|
||||
const check = db.exec(
|
||||
'SELECT 1 FROM reactions WHERE messageId = ? AND userId = ? AND emoji = ?',
|
||||
@@ -416,7 +413,6 @@ function registerDatabaseIpc() {
|
||||
return rows.map(rowToReaction);
|
||||
});
|
||||
|
||||
// ── Users ──────────────────────────────────────────────────────────
|
||||
ipcMain.handle('db:saveUser', (_e, user) => {
|
||||
db.run(
|
||||
`INSERT OR REPLACE INTO users
|
||||
@@ -486,7 +482,6 @@ function registerDatabaseIpc() {
|
||||
persist();
|
||||
});
|
||||
|
||||
// ── Rooms ──────────────────────────────────────────────────────────
|
||||
ipcMain.handle('db:saveRoom', (_e, room) => {
|
||||
db.run(
|
||||
`INSERT OR REPLACE INTO rooms
|
||||
@@ -542,7 +537,6 @@ function registerDatabaseIpc() {
|
||||
persist();
|
||||
});
|
||||
|
||||
// ── Bans ───────────────────────────────────────────────────────────
|
||||
ipcMain.handle('db:saveBan', (_e, ban) => {
|
||||
db.run(
|
||||
`INSERT OR REPLACE INTO bans
|
||||
@@ -579,7 +573,6 @@ function registerDatabaseIpc() {
|
||||
return rows.some((r) => String(r.oderId) === userId);
|
||||
});
|
||||
|
||||
// ── Attachments ─────────────────────────────────────────────────────
|
||||
ipcMain.handle('db:saveAttachment', (_e, attachment) => {
|
||||
db.run(
|
||||
`INSERT OR REPLACE INTO attachments
|
||||
@@ -610,7 +603,6 @@ function registerDatabaseIpc() {
|
||||
persist();
|
||||
});
|
||||
|
||||
// ── Utilities ──────────────────────────────────────────────────────
|
||||
ipcMain.handle('db:clearAllData', () => {
|
||||
db.run('DELETE FROM messages');
|
||||
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 fsp = fs.promises;
|
||||
const path = require('path');
|
||||
@@ -50,11 +50,36 @@ function createWindow() {
|
||||
mainWindow.on('closed', () => {
|
||||
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
|
||||
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.on('window-all-closed', () => {
|
||||
|
||||
@@ -6,6 +6,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
maximizeWindow: () => ipcRenderer.send('window-maximize'),
|
||||
closeWindow: () => ipcRenderer.send('window-close'),
|
||||
|
||||
// Open URL in system default browser
|
||||
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
||||
|
||||
// Desktop capturer for screen sharing
|
||||
getSources: () => ipcRenderer.invoke('get-sources'),
|
||||
|
||||
@@ -18,7 +21,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
fileExists: (filePath) => ipcRenderer.invoke('file-exists', filePath),
|
||||
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
|
||||
|
||||
// ── Database operations (all SQL lives in main process) ───────────
|
||||
db: {
|
||||
initialize: () => ipcRenderer.invoke('db:initialize'),
|
||||
|
||||
|
||||
19
package.json
19
package.json
@@ -59,14 +59,16 @@
|
||||
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
||||
"class-variance-authority": "^0.7.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",
|
||||
"simple-peer": "^9.11.1",
|
||||
"sql.js": "^1.13.0",
|
||||
"tslib": "^2.3.0",
|
||||
"uuid": "^13.0.0",
|
||||
"marked": "^12.0.2",
|
||||
"dompurify": "^3.0.6",
|
||||
"highlight.js": "^11.9.0"
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^21.0.4",
|
||||
@@ -115,10 +117,15 @@
|
||||
"allowToChangeInstallationDirectory": true
|
||||
},
|
||||
"linux": {
|
||||
"target": ["AppImage", "deb"],
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb"
|
||||
],
|
||||
"category": "Network;Chat",
|
||||
"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 { roomsReducer } from './store/rooms/rooms.reducer';
|
||||
import { MessagesEffects } from './store/messages/messages.effects';
|
||||
import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
|
||||
import { UsersEffects } from './store/users/users.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 = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
@@ -23,9 +26,9 @@ export const appConfig: ApplicationConfig = {
|
||||
users: usersReducer,
|
||||
rooms: roomsReducer,
|
||||
}),
|
||||
provideEffects([MessagesEffects, UsersEffects, RoomsEffects]),
|
||||
provideEffects([MessagesEffects, MessagesSyncEffects, UsersEffects, RoomsEffects]),
|
||||
provideStoreDevtools({
|
||||
maxAge: 25,
|
||||
maxAge: STORE_DEVTOOLS_MAX_AGE,
|
||||
logOnly: !isDevMode(),
|
||||
autoPause: true,
|
||||
trace: false,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
/** Application route configuration with lazy-loaded feature components. */
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
@@ -9,28 +10,28 @@ export const routes: Routes = [
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () =>
|
||||
import('./features/auth/login/login.component').then((m) => m.LoginComponent),
|
||||
import('./features/auth/login/login.component').then((module) => module.LoginComponent),
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
loadComponent: () =>
|
||||
import('./features/auth/register/register.component').then((m) => m.RegisterComponent),
|
||||
import('./features/auth/register/register.component').then((module) => module.RegisterComponent),
|
||||
},
|
||||
{
|
||||
path: 'search',
|
||||
loadComponent: () =>
|
||||
import('./features/server-search/server-search.component').then(
|
||||
(m) => m.ServerSearchComponent
|
||||
(module) => module.ServerSearchComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'room/:roomId',
|
||||
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',
|
||||
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 { CommonModule } from '@angular/common';
|
||||
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 { TimeSyncService } from './core/services/time-sync.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 { TitleBarComponent } from './features/shell/title-bar.component';
|
||||
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
|
||||
import * as UsersActions from './store/users/users.actions';
|
||||
import * as RoomsActions from './store/rooms/rooms.actions';
|
||||
import { UsersActions } from './store/users/users.actions';
|
||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||
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({
|
||||
selector: 'app-root',
|
||||
imports: [CommonModule, RouterOutlet, ServersRailComponent, TitleBarComponent, FloatingVoiceControlsComponent],
|
||||
@@ -27,9 +35,16 @@ export class App implements OnInit {
|
||||
private servers = inject(ServerDirectoryService);
|
||||
private timeSync = inject(TimeSyncService);
|
||||
private voiceSession = inject(VoiceSessionService);
|
||||
private externalLinks = inject(ExternalLinkService);
|
||||
|
||||
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> {
|
||||
// Initialize database
|
||||
await this.databaseService.initialize();
|
||||
@@ -47,13 +62,13 @@ export class App implements OnInit {
|
||||
this.store.dispatch(RoomsActions.loadRooms());
|
||||
|
||||
// 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 (this.router.url !== '/login' && this.router.url !== '/register') {
|
||||
this.router.navigate(['/login']).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
const last = localStorage.getItem('metoyou_lastVisitedRoute');
|
||||
const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE);
|
||||
if (last && typeof last === 'string') {
|
||||
const current = this.router.url;
|
||||
if (current === '/' || current === '/search') {
|
||||
@@ -67,11 +82,11 @@ export class App implements OnInit {
|
||||
if (evt instanceof NavigationEnd) {
|
||||
const url = evt.urlAfterRedirects || evt.url;
|
||||
// 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
|
||||
// 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;
|
||||
|
||||
// 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 {
|
||||
/** Local database identifier. */
|
||||
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;
|
||||
/** Human-readable display name shown in the UI. */
|
||||
displayName: string;
|
||||
/** Optional URL to the user's avatar image. */
|
||||
avatarUrl?: string;
|
||||
status: 'online' | 'away' | 'busy' | 'offline';
|
||||
role: 'host' | 'admin' | 'moderator' | 'member';
|
||||
/** Current online-presence status. */
|
||||
status: UserStatus;
|
||||
/** Role within the current room/server. */
|
||||
role: UserRole;
|
||||
/** Epoch timestamp (ms) when the user first joined. */
|
||||
joinedAt: number;
|
||||
/** WebRTC peer identifier (transient, set when connected). */
|
||||
peerId?: string;
|
||||
/** Whether the user is currently connected. */
|
||||
isOnline?: boolean;
|
||||
/** Whether the user holds admin-level privileges. */
|
||||
isAdmin?: boolean;
|
||||
/** Whether the user is the owner of the current room. */
|
||||
isRoomOwner?: boolean;
|
||||
/** Real-time voice connection state. */
|
||||
voiceState?: VoiceState;
|
||||
/** Real-time screen-sharing state. */
|
||||
screenShareState?: ScreenShareState;
|
||||
}
|
||||
|
||||
/**
|
||||
* A communication channel within a server (either text or voice).
|
||||
*/
|
||||
export interface Channel {
|
||||
/** Unique channel identifier. */
|
||||
id: string;
|
||||
/** Display name of the channel. */
|
||||
name: string;
|
||||
type: 'text' | 'voice';
|
||||
position: number; // ordering within its type group
|
||||
/** Whether this is a text chat or voice channel. */
|
||||
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 {
|
||||
/** Unique message identifier. */
|
||||
id: string;
|
||||
/** The room this message belongs to. */
|
||||
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;
|
||||
/** Display name of the sender at the time of sending. */
|
||||
senderName: string;
|
||||
/** Markdown-formatted message body. */
|
||||
content: string;
|
||||
/** Epoch timestamp (ms) when the message was created. */
|
||||
timestamp: number;
|
||||
/** Epoch timestamp (ms) of the last edit, if any. */
|
||||
editedAt?: number;
|
||||
/** Emoji reactions attached to this message. */
|
||||
reactions: Reaction[];
|
||||
/** Whether this message has been soft-deleted. */
|
||||
isDeleted: boolean;
|
||||
/** If this is a reply, the ID of the parent message. */
|
||||
replyToId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An emoji reaction on a message.
|
||||
*/
|
||||
export interface Reaction {
|
||||
/** Unique reaction identifier. */
|
||||
id: string;
|
||||
/** The message this reaction is attached to. */
|
||||
messageId: string;
|
||||
/** Network-wide user ID of the reactor. */
|
||||
oderId: string;
|
||||
userId: string; // Alias for backward compatibility
|
||||
/** Alias for `oderId` (kept for backward compatibility). */
|
||||
userId: string;
|
||||
/** The emoji character(s) used. */
|
||||
emoji: string;
|
||||
/** Epoch timestamp (ms) when the reaction was added. */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A chat room (server) that users can join to communicate.
|
||||
*/
|
||||
export interface Room {
|
||||
/** Unique room identifier. */
|
||||
id: string;
|
||||
/** Display name of the room. */
|
||||
name: string;
|
||||
/** Optional long-form description. */
|
||||
description?: string;
|
||||
/** Short topic/status line shown in the header. */
|
||||
topic?: string;
|
||||
/** User ID of the room's creator/owner. */
|
||||
hostId: string;
|
||||
/** Password required to join (if private). */
|
||||
password?: string;
|
||||
/** Whether the room requires a password to join. */
|
||||
isPrivate: boolean;
|
||||
/** Epoch timestamp (ms) when the room was created. */
|
||||
createdAt: number;
|
||||
/** Current number of connected users. */
|
||||
userCount: number;
|
||||
/** Maximum allowed concurrent users. */
|
||||
maxUsers?: number;
|
||||
// Optional server icon synced P2P
|
||||
icon?: string; // data URL (e.g., base64 PNG) or remote URL
|
||||
iconUpdatedAt?: number; // last update timestamp for conflict resolution
|
||||
// Role-based management permissions
|
||||
/** Server icon as a data-URL or remote URL. */
|
||||
icon?: string;
|
||||
/** Epoch timestamp (ms) of the last icon update (for conflict resolution). */
|
||||
iconUpdatedAt?: number;
|
||||
/** Role-based management permission overrides. */
|
||||
permissions?: RoomPermissions;
|
||||
// Channels within the server
|
||||
/** Text and voice channels within the server. */
|
||||
channels?: Channel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Editable subset of room properties exposed in the settings UI.
|
||||
*/
|
||||
export interface RoomSettings {
|
||||
/** Room display name. */
|
||||
name: string;
|
||||
/** Optional long-form description. */
|
||||
description?: string;
|
||||
/** Short topic/status line. */
|
||||
topic?: string;
|
||||
/** Whether a password is required to join. */
|
||||
isPrivate: boolean;
|
||||
/** Password for private rooms. */
|
||||
password?: string;
|
||||
/** Maximum allowed concurrent users. */
|
||||
maxUsers?: number;
|
||||
/** Optional list of room rules. */
|
||||
rules?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fine-grained permission toggles for a room.
|
||||
* Controls which roles can perform management actions.
|
||||
*/
|
||||
export interface RoomPermissions {
|
||||
// Whether admins can manage chat/voice rooms creation and modifications
|
||||
/** Whether admins can create/modify rooms. */
|
||||
adminsManageRooms?: boolean;
|
||||
/** Whether moderators can create/modify rooms. */
|
||||
moderatorsManageRooms?: boolean;
|
||||
// Whether admins/moderators can change server icon
|
||||
/** Whether admins can change the server icon. */
|
||||
adminsManageIcon?: boolean;
|
||||
/** Whether moderators can change the server icon. */
|
||||
moderatorsManageIcon?: boolean;
|
||||
// Existing capability toggles
|
||||
/** Whether voice channels are enabled. */
|
||||
allowVoice?: boolean;
|
||||
/** Whether screen sharing is enabled. */
|
||||
allowScreenShare?: boolean;
|
||||
/** Whether file uploads are enabled. */
|
||||
allowFileUploads?: boolean;
|
||||
/** Minimum delay (seconds) between messages (0 = disabled). */
|
||||
slowModeInterval?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A record of a user being banned from a room.
|
||||
*/
|
||||
export interface BanEntry {
|
||||
/** Unique ban identifier (also used as the banned user's oderId). */
|
||||
oderId: string;
|
||||
/** The banned user's local ID. */
|
||||
userId: string;
|
||||
/** The room the ban applies to. */
|
||||
roomId: string;
|
||||
/** User ID of the admin who issued the ban. */
|
||||
bannedBy: string;
|
||||
/** Display name of the banned user at the time of banning. */
|
||||
displayName?: string;
|
||||
/** Human-readable reason for the ban. */
|
||||
reason?: string;
|
||||
/** Epoch timestamp (ms) when the ban expires (undefined = permanent). */
|
||||
expiresAt?: number;
|
||||
/** Epoch timestamp (ms) when the ban was issued. */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the state of a WebRTC peer connection.
|
||||
*/
|
||||
export interface PeerConnection {
|
||||
/** Remote peer identifier. */
|
||||
peerId: string;
|
||||
/** Local user identifier. */
|
||||
userId: string;
|
||||
/** Current connection lifecycle state. */
|
||||
status: 'connecting' | 'connected' | 'disconnected' | 'failed';
|
||||
/** The RTCDataChannel used for P2P messaging. */
|
||||
dataChannel?: RTCDataChannel;
|
||||
/** The underlying RTCPeerConnection. */
|
||||
connection?: RTCPeerConnection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-time voice connection state for a user in a voice channel.
|
||||
*/
|
||||
export interface VoiceState {
|
||||
/** Whether the user is connected to a voice channel. */
|
||||
isConnected: boolean;
|
||||
/** Whether the user's microphone is muted (self or by admin). */
|
||||
isMuted: boolean;
|
||||
/** Whether the user has deafened themselves. */
|
||||
isDeafened: boolean;
|
||||
/** Whether the user is currently speaking (voice activity detection). */
|
||||
isSpeaking: boolean;
|
||||
/** Whether the user was server-muted by an admin. */
|
||||
isMutedByAdmin?: boolean;
|
||||
/** User's output volume level (0-1). */
|
||||
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;
|
||||
/** The server ID the user is connected to voice in */
|
||||
/** The server ID the user is connected to voice in. */
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-time screen-sharing state for a user.
|
||||
*/
|
||||
export interface ScreenShareState {
|
||||
/** Whether the user is actively sharing their screen. */
|
||||
isSharing: boolean;
|
||||
/** MediaStream ID of the screen capture. */
|
||||
streamId?: string;
|
||||
/** Desktop capturer source ID (Electron only). */
|
||||
sourceId?: string;
|
||||
/** Human-readable name of the captured source. */
|
||||
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 {
|
||||
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;
|
||||
/** Optional target peer ID (for directed messages). */
|
||||
to?: string;
|
||||
/** Arbitrary payload specific to the message type. */
|
||||
payload: unknown;
|
||||
/** Epoch timestamp (ms) when the message was sent. */
|
||||
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 {
|
||||
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;
|
||||
/** Full message payload (for new messages). */
|
||||
message?: Message;
|
||||
/** Reaction payload (for reaction events). */
|
||||
reaction?: Reaction;
|
||||
/** Partial message updates (for edits). */
|
||||
data?: Partial<Message>;
|
||||
/** Event timestamp. */
|
||||
timestamp?: number;
|
||||
/** Target user ID (for kick/ban). */
|
||||
targetUserId?: string;
|
||||
/** Room ID the event pertains to. */
|
||||
roomId?: string;
|
||||
/** User who issued a kick. */
|
||||
kickedBy?: string;
|
||||
/** User who issued a ban. */
|
||||
bannedBy?: string;
|
||||
/** Text content (for messages/edits). */
|
||||
content?: string;
|
||||
/** Edit timestamp. */
|
||||
editedAt?: number;
|
||||
/** User who performed a delete. */
|
||||
deletedBy?: string;
|
||||
/** Network-wide user identifier. */
|
||||
oderId?: string;
|
||||
/** Display name of the event sender. */
|
||||
displayName?: string;
|
||||
/** Emoji character (for reactions). */
|
||||
emoji?: string;
|
||||
/** Ban/kick reason. */
|
||||
reason?: string;
|
||||
/** Updated room settings. */
|
||||
settings?: RoomSettings;
|
||||
/** Partial voice state update. */
|
||||
voiceState?: Partial<VoiceState>;
|
||||
/** Screen-sharing flag. */
|
||||
isScreenSharing?: boolean;
|
||||
role?: 'host' | 'admin' | 'moderator' | 'member';
|
||||
/** New role assignment. */
|
||||
role?: UserRole;
|
||||
/** Updated channel list. */
|
||||
channels?: Channel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Server listing as returned by the directory API.
|
||||
*/
|
||||
export interface ServerInfo {
|
||||
/** Unique server identifier. */
|
||||
id: string;
|
||||
/** Display name. */
|
||||
name: string;
|
||||
/** Optional description. */
|
||||
description?: string;
|
||||
/** Optional topic. */
|
||||
topic?: string;
|
||||
/** Display name of the host. */
|
||||
hostName: string;
|
||||
/** Owner's user ID. */
|
||||
ownerId?: string;
|
||||
/** Owner's public key / oderId. */
|
||||
ownerPublicKey?: string;
|
||||
/** Current number of connected users. */
|
||||
userCount: number;
|
||||
/** Maximum allowed users. */
|
||||
maxUsers: number;
|
||||
/** Whether a password is required. */
|
||||
isPrivate: boolean;
|
||||
/** Searchable tags. */
|
||||
tags?: string[];
|
||||
/** Epoch timestamp (ms) when the server was created. */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request payload for joining a server.
|
||||
*/
|
||||
export interface JoinRequest {
|
||||
/** Target room/server ID. */
|
||||
roomId: string;
|
||||
/** Requesting user's ID. */
|
||||
userId: string;
|
||||
/** Requesting user's username. */
|
||||
username: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level application state snapshot (used for diagnostics).
|
||||
*/
|
||||
export interface AppState {
|
||||
/** The currently authenticated user, or null if logged out. */
|
||||
currentUser: User | null;
|
||||
/** The room the user is currently viewing, or null. */
|
||||
currentRoom: Room | null;
|
||||
/** Whether a connection attempt is in progress. */
|
||||
isConnecting: boolean;
|
||||
/** Last error message, or 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 { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { ServerDirectoryService, ServerEndpoint } from './server-directory.service';
|
||||
|
||||
/**
|
||||
* Response returned by the authentication endpoints (login / register).
|
||||
*/
|
||||
export interface LoginResponse {
|
||||
/** Unique user identifier assigned by the server. */
|
||||
id: string;
|
||||
/** Login username. */
|
||||
username: string;
|
||||
/** Human-readable display name. */
|
||||
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' })
|
||||
export class AuthService {
|
||||
private http = inject(HttpClient);
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
private readonly http = inject(HttpClient);
|
||||
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 {
|
||||
let base: ServerEndpoint | undefined;
|
||||
let endpoint: ServerEndpoint | undefined;
|
||||
|
||||
if (serverId) {
|
||||
base = this.serverDirectory.servers().find((s) => s.id === serverId);
|
||||
}
|
||||
const active = base || this.serverDirectory.activeServer();
|
||||
return active ? `${active.url}/api` : 'http://localhost:3001/api';
|
||||
endpoint = this.serverDirectory.servers().find(
|
||||
(server) => server.id === serverId,
|
||||
);
|
||||
}
|
||||
|
||||
register(params: { username: string; password: string; displayName?: string; serverId?: string }): Observable<LoginResponse> {
|
||||
const activeEndpoint = endpoint ?? this.serverDirectory.activeServer();
|
||||
return activeEndpoint ? `${activeEndpoint.url}/api` : DEFAULT_API_BASE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`;
|
||||
return this.http.post<LoginResponse>(url, {
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
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`;
|
||||
return this.http.post<LoginResponse>(url, {
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
}).pipe(map((resp) => resp));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,233 +1,319 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Message, User, Room, Reaction, BanEntry } from '../models';
|
||||
|
||||
const DB_NAME = 'metoyou';
|
||||
const DB_VERSION = 2;
|
||||
/** IndexedDB database name for the MetoYou application. */
|
||||
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
|
||||
* 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.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BrowserDatabaseService {
|
||||
private db: IDBDatabase | null = null;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Lifecycle */
|
||||
/* ------------------------------------------------------------------ */
|
||||
/** Handle to the opened IndexedDB database, or `null` before {@link initialize}. */
|
||||
private database: IDBDatabase | null = null;
|
||||
|
||||
/** Open (or create) the IndexedDB database. Safe to call multiple times. */
|
||||
async initialize(): Promise<void> {
|
||||
if (this.db) return;
|
||||
this.db = await this.openDatabase();
|
||||
if (this.database) return;
|
||||
this.database = await this.openDatabase();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Messages */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Persist a single message. */
|
||||
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[]> {
|
||||
const all = await this.getAllFromIndex<Message>('messages', 'roomId', roomId);
|
||||
return all
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
const allRoomMessages = await this.getAllFromIndex<Message>(
|
||||
STORE_MESSAGES, 'roomId', roomId,
|
||||
);
|
||||
return allRoomMessages
|
||||
.sort((first, second) => first.timestamp - second.timestamp)
|
||||
.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
/** Delete a message by its ID. */
|
||||
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> {
|
||||
const msg = await this.get<Message>('messages', messageId);
|
||||
if (msg) await this.put('messages', { ...msg, ...updates });
|
||||
const existing = await this.get<Message>(STORE_MESSAGES, messageId);
|
||||
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> {
|
||||
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> {
|
||||
const msgs = await this.getAllFromIndex<Message>('messages', 'roomId', roomId);
|
||||
const tx = this.transaction('messages', 'readwrite');
|
||||
for (const m of msgs) tx.objectStore('messages').delete(m.id);
|
||||
await this.complete(tx);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Reactions */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async saveReaction(reaction: Reaction): Promise<void> {
|
||||
const existing = await this.getAllFromIndex<Reaction>('reactions', 'messageId', reaction.messageId);
|
||||
const dup = existing.some(
|
||||
(r) => r.userId === reaction.userId && r.emoji === reaction.emoji,
|
||||
const messages = await this.getAllFromIndex<Message>(
|
||||
STORE_MESSAGES, 'roomId', roomId,
|
||||
);
|
||||
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> {
|
||||
const all = await this.getAllFromIndex<Reaction>('reactions', 'messageId', messageId);
|
||||
const target = all.find((r) => r.userId === userId && r.emoji === emoji);
|
||||
if (target) await this.delete('reactions', target.id);
|
||||
const reactions = await this.getAllFromIndex<Reaction>(
|
||||
STORE_REACTIONS, 'messageId', messageId,
|
||||
);
|
||||
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[]> {
|
||||
return this.getAllFromIndex<Reaction>('reactions', 'messageId', messageId);
|
||||
return this.getAllFromIndex<Reaction>(STORE_REACTIONS, 'messageId', messageId);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Users */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Persist a user record. */
|
||||
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> {
|
||||
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> {
|
||||
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;
|
||||
return this.getUser(meta.value);
|
||||
}
|
||||
|
||||
/** Store which user ID is considered "current" (logged-in). */
|
||||
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[]> {
|
||||
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> {
|
||||
const user = await this.get<User>('users', userId);
|
||||
if (user) await this.put('users', { ...user, ...updates });
|
||||
const existing = await this.get<User>(STORE_USERS, userId);
|
||||
if (existing) {
|
||||
await this.put(STORE_USERS, { ...existing, ...updates });
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Rooms */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Persist a room record. */
|
||||
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> {
|
||||
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[]> {
|
||||
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> {
|
||||
await this.delete('rooms', roomId);
|
||||
await this.deleteRecord(STORE_ROOMS, roomId);
|
||||
await this.clearRoomMessages(roomId);
|
||||
}
|
||||
|
||||
/** Apply partial updates to an existing room. */
|
||||
async updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
|
||||
const room = await this.get<Room>('rooms', roomId);
|
||||
if (room) await this.put('rooms', { ...room, ...updates });
|
||||
const existing = await this.get<Room>(STORE_ROOMS, roomId);
|
||||
if (existing) {
|
||||
await this.put(STORE_ROOMS, { ...existing, ...updates });
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Bans */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Persist a ban entry. */
|
||||
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> {
|
||||
const all = await this.getAll<BanEntry>('bans');
|
||||
const match = all.find((b) => b.oderId === oderId);
|
||||
if (match) await this.delete('bans', (match as any).id ?? match.oderId);
|
||||
const allBans = await this.getAll<BanEntry>(STORE_BANS);
|
||||
const match = allBans.find((ban) => ban.oderId === 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[]> {
|
||||
const all = await this.getAllFromIndex<BanEntry>('bans', 'roomId', roomId);
|
||||
const allBans = await this.getAllFromIndex<BanEntry>(
|
||||
STORE_BANS, 'roomId', roomId,
|
||||
);
|
||||
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> {
|
||||
const bans = await this.getBansForRoom(roomId);
|
||||
return bans.some((b) => b.oderId === userId);
|
||||
const activeBans = await this.getBansForRoom(roomId);
|
||||
return activeBans.some((ban) => ban.oderId === userId);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Attachments */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Persist an attachment metadata record. */
|
||||
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[]> {
|
||||
return this.getAllFromIndex<any>('attachments', 'messageId', messageId);
|
||||
return this.getAllFromIndex<any>(STORE_ATTACHMENTS, 'messageId', messageId);
|
||||
}
|
||||
|
||||
/** Return every persisted attachment record. */
|
||||
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> {
|
||||
const atts = await this.getAllFromIndex<any>('attachments', 'messageId', messageId);
|
||||
if (atts.length === 0) return;
|
||||
const tx = this.transaction('attachments', 'readwrite');
|
||||
for (const a of atts) tx.objectStore('attachments').delete(a.id);
|
||||
await this.complete(tx);
|
||||
const attachments = await this.getAllFromIndex<any>(
|
||||
STORE_ATTACHMENTS, 'messageId', messageId,
|
||||
);
|
||||
if (attachments.length === 0) return;
|
||||
|
||||
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> {
|
||||
const storeNames: string[] = ['messages', 'users', 'rooms', 'reactions', 'bans', 'attachments', 'meta'];
|
||||
const tx = this.transaction(storeNames, 'readwrite');
|
||||
for (const name of storeNames) tx.objectStore(name).clear();
|
||||
await this.complete(tx);
|
||||
const transaction = this.createTransaction(ALL_STORE_NAMES, 'readwrite');
|
||||
for (const storeName of ALL_STORE_NAMES) {
|
||||
transaction.objectStore(storeName).clear();
|
||||
}
|
||||
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> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains('messages')) {
|
||||
const msgs = db.createObjectStore('messages', { keyPath: 'id' });
|
||||
msgs.createIndex('roomId', 'roomId', { unique: false });
|
||||
const database = request.result;
|
||||
|
||||
if (!database.objectStoreNames.contains(STORE_MESSAGES)) {
|
||||
const messagesStore = database.createObjectStore(STORE_MESSAGES, { keyPath: 'id' });
|
||||
messagesStore.createIndex('roomId', 'roomId', { unique: false });
|
||||
}
|
||||
if (!db.objectStoreNames.contains('users')) {
|
||||
db.createObjectStore('users', { keyPath: 'id' });
|
||||
if (!database.objectStoreNames.contains(STORE_USERS)) {
|
||||
database.createObjectStore(STORE_USERS, { keyPath: 'id' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains('rooms')) {
|
||||
db.createObjectStore('rooms', { keyPath: 'id' });
|
||||
if (!database.objectStoreNames.contains(STORE_ROOMS)) {
|
||||
database.createObjectStore(STORE_ROOMS, { keyPath: 'id' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains('reactions')) {
|
||||
const rxns = db.createObjectStore('reactions', { keyPath: 'id' });
|
||||
rxns.createIndex('messageId', 'messageId', { unique: false });
|
||||
if (!database.objectStoreNames.contains(STORE_REACTIONS)) {
|
||||
const reactionsStore = database.createObjectStore(STORE_REACTIONS, { keyPath: 'id' });
|
||||
reactionsStore.createIndex('messageId', 'messageId', { unique: false });
|
||||
}
|
||||
if (!db.objectStoreNames.contains('bans')) {
|
||||
const bans = db.createObjectStore('bans', { keyPath: 'oderId' });
|
||||
bans.createIndex('roomId', 'roomId', { unique: false });
|
||||
if (!database.objectStoreNames.contains(STORE_BANS)) {
|
||||
const bansStore = database.createObjectStore(STORE_BANS, { keyPath: 'oderId' });
|
||||
bansStore.createIndex('roomId', 'roomId', { unique: false });
|
||||
}
|
||||
if (!db.objectStoreNames.contains('meta')) {
|
||||
db.createObjectStore('meta', { keyPath: 'id' });
|
||||
if (!database.objectStoreNames.contains(STORE_META)) {
|
||||
database.createObjectStore(STORE_META, { keyPath: 'id' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains('attachments')) {
|
||||
const atts = db.createObjectStore('attachments', { keyPath: 'id' });
|
||||
atts.createIndex('messageId', 'messageId', { unique: false });
|
||||
if (!database.objectStoreNames.contains(STORE_ATTACHMENTS)) {
|
||||
const attachmentsStore = database.createObjectStore(STORE_ATTACHMENTS, { keyPath: 'id' });
|
||||
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[],
|
||||
mode: IDBTransactionMode = 'readonly',
|
||||
): 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) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
transaction.oncomplete = () => resolve();
|
||||
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) => {
|
||||
const tx = this.transaction(store);
|
||||
const req = tx.objectStore(store).get(key);
|
||||
req.onsuccess = () => resolve(req.result as T | undefined);
|
||||
req.onerror = () => reject(req.error);
|
||||
const transaction = this.createTransaction(storeName);
|
||||
const request = transaction.objectStore(storeName).get(key);
|
||||
request.onsuccess = () => resolve(request.result as T | undefined);
|
||||
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) => {
|
||||
const tx = this.transaction(store);
|
||||
const req = tx.objectStore(store).getAll();
|
||||
req.onsuccess = () => resolve(req.result as T[]);
|
||||
req.onerror = () => reject(req.error);
|
||||
const transaction = this.createTransaction(storeName);
|
||||
const request = transaction.objectStore(storeName).getAll();
|
||||
request.onsuccess = () => resolve(request.result as T[]);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/** Retrieve all records from an index that match a key. */
|
||||
private getAllFromIndex<T>(
|
||||
store: string,
|
||||
storeName: string,
|
||||
indexName: string,
|
||||
key: IDBValidKey,
|
||||
): Promise<T[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.transaction(store);
|
||||
const idx = tx.objectStore(store).index(indexName);
|
||||
const req = idx.getAll(key);
|
||||
req.onsuccess = () => resolve(req.result as T[]);
|
||||
req.onerror = () => reject(req.error);
|
||||
const transaction = this.createTransaction(storeName);
|
||||
const index = transaction.objectStore(storeName).index(indexName);
|
||||
const request = index.getAll(key);
|
||||
request.onsuccess = () => resolve(request.result as T[]);
|
||||
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) => {
|
||||
const tx = this.transaction(store, 'readwrite');
|
||||
tx.objectStore(store).put(value);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
const transaction = this.createTransaction(storeName, 'readwrite');
|
||||
transaction.objectStore(storeName).put(value);
|
||||
transaction.oncomplete = () => resolve();
|
||||
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) => {
|
||||
const tx = this.transaction(store, 'readwrite');
|
||||
tx.objectStore(store).delete(key);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
const transaction = this.createTransaction(storeName, 'readwrite');
|
||||
transaction.objectStore(storeName).delete(key);
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,101 +5,119 @@ import { BrowserDatabaseService } from './browser-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
|
||||
* persists data in a local SQLite file (via sql.js + Electron IPC).
|
||||
* - **Browser** → delegates to {@link BrowserDatabaseService} which
|
||||
* persists data in IndexedDB.
|
||||
* - **Electron** → SQLite via {@link ElectronDatabaseService} (IPC to main process).
|
||||
* - **Browser** → IndexedDB via {@link BrowserDatabaseService}.
|
||||
*
|
||||
* All consumers keep injecting `DatabaseService` – the underlying storage
|
||||
* engine is selected automatically at startup.
|
||||
* All consumers inject `DatabaseService` — the underlying storage engine
|
||||
* is selected automatically.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DatabaseService {
|
||||
private readonly platform = inject(PlatformService);
|
||||
private readonly browserDb = inject(BrowserDatabaseService);
|
||||
private readonly electronDb = inject(ElectronDatabaseService);
|
||||
|
||||
/** Reactive flag: `true` once {@link initialize} has completed. */
|
||||
isReady = signal(false);
|
||||
|
||||
/** The active backend for the current platform. */
|
||||
/** The active storage backend for the current platform. */
|
||||
private get backend() {
|
||||
return this.platform.isBrowser ? this.browserDb : this.electronDb;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Lifecycle */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Initialise the platform-specific database. */
|
||||
async initialize(): Promise<void> {
|
||||
await this.backend.initialize();
|
||||
this.isReady.set(true);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Messages */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Persist a single chat 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); }
|
||||
|
||||
/** Permanently delete a message by ID. */
|
||||
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); }
|
||||
|
||||
/** Retrieve a single message by ID. */
|
||||
getMessageById(messageId: string) { return this.backend.getMessageById(messageId); }
|
||||
|
||||
/** Remove every message belonging to a room. */
|
||||
clearRoomMessages(roomId: string) { return this.backend.clearRoomMessages(roomId); }
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Reactions */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Persist a 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); }
|
||||
|
||||
/** Return all reactions for a given message. */
|
||||
getReactionsForMessage(messageId: string) { return this.backend.getReactionsForMessage(messageId); }
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Users */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Persist a user record. */
|
||||
saveUser(user: User) { return this.backend.saveUser(user); }
|
||||
|
||||
/** Retrieve a user by ID. */
|
||||
getUser(userId: string) { return this.backend.getUser(userId); }
|
||||
|
||||
/** Retrieve the current (logged-in) user. */
|
||||
getCurrentUser() { return this.backend.getCurrentUser(); }
|
||||
|
||||
/** Store the current user ID. */
|
||||
setCurrentUserId(userId: string) { return this.backend.setCurrentUserId(userId); }
|
||||
|
||||
/** Retrieve users in a room. */
|
||||
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); }
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Rooms */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Persist a room record. */
|
||||
saveRoom(room: Room) { return this.backend.saveRoom(room); }
|
||||
|
||||
/** Retrieve a room by ID. */
|
||||
getRoom(roomId: string) { return this.backend.getRoom(roomId); }
|
||||
|
||||
/** Return every persisted room. */
|
||||
getAllRooms() { return this.backend.getAllRooms(); }
|
||||
|
||||
/** Delete a room and its associated messages. */
|
||||
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); }
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Bans */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Persist a ban entry. */
|
||||
saveBan(ban: BanEntry) { return this.backend.saveBan(ban); }
|
||||
|
||||
/** Remove a ban by oderId. */
|
||||
removeBan(oderId: string) { return this.backend.removeBan(oderId); }
|
||||
|
||||
/** Return active bans for a room. */
|
||||
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); }
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Attachments */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Persist attachment metadata. */
|
||||
saveAttachment(attachment: any) { return this.backend.saveAttachment(attachment); }
|
||||
|
||||
/** Return all attachment records for a message. */
|
||||
getAttachmentsForMessage(messageId: string) { return this.backend.getAttachmentsForMessage(messageId); }
|
||||
|
||||
/** Return every persisted attachment record. */
|
||||
getAllAttachments() { return this.backend.getAllAttachments(); }
|
||||
|
||||
/** Delete all attachment records for a message. */
|
||||
deleteAttachmentsForMessage(messageId: string) { return this.backend.deleteAttachmentsForMessage(messageId); }
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Utilities */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Wipe all persisted data. */
|
||||
clearAllData() { return this.backend.clearAllData(); }
|
||||
}
|
||||
|
||||
@@ -10,163 +10,168 @@ import { Message, User, Room, Reaction, BanEntry } from '../models';
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
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() {
|
||||
return (window as any).electronAPI.db;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Lifecycle */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Initialise the SQLite database via the main-process IPC bridge. */
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
if (this.isInitialised) return;
|
||||
await this.api.initialize();
|
||||
this.initialized = true;
|
||||
this.isInitialised = true;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Messages */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Persist a single chat message. */
|
||||
saveMessage(message: Message): Promise<void> {
|
||||
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[]> {
|
||||
return this.api.getMessages(roomId, limit, offset);
|
||||
}
|
||||
|
||||
/** Permanently delete a message by ID. */
|
||||
deleteMessage(messageId: string): Promise<void> {
|
||||
return this.api.deleteMessage(messageId);
|
||||
}
|
||||
|
||||
/** Apply partial updates to an existing message. */
|
||||
updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
|
||||
return this.api.updateMessage(messageId, updates);
|
||||
}
|
||||
|
||||
/** Retrieve a single message by ID, or `null` if not found. */
|
||||
getMessageById(messageId: string): Promise<Message | null> {
|
||||
return this.api.getMessageById(messageId);
|
||||
}
|
||||
|
||||
/** Remove every message belonging to a room. */
|
||||
clearRoomMessages(roomId: string): Promise<void> {
|
||||
return this.api.clearRoomMessages(roomId);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Reactions */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Persist a reaction (deduplication is handled server-side). */
|
||||
saveReaction(reaction: Reaction): Promise<void> {
|
||||
return this.api.saveReaction(reaction);
|
||||
}
|
||||
|
||||
/** Remove a specific reaction (user + emoji + message). */
|
||||
removeReaction(messageId: string, userId: string, emoji: string): Promise<void> {
|
||||
return this.api.removeReaction(messageId, userId, emoji);
|
||||
}
|
||||
|
||||
/** Return all reactions for a given message. */
|
||||
getReactionsForMessage(messageId: string): Promise<Reaction[]> {
|
||||
return this.api.getReactionsForMessage(messageId);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Users */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Persist a user record. */
|
||||
saveUser(user: User): Promise<void> {
|
||||
return this.api.saveUser(user);
|
||||
}
|
||||
|
||||
/** Retrieve a user by ID, or `null` if not found. */
|
||||
getUser(userId: string): Promise<User | null> {
|
||||
return this.api.getUser(userId);
|
||||
}
|
||||
|
||||
/** Retrieve the last-authenticated ("current") user, or `null`. */
|
||||
getCurrentUser(): Promise<User | null> {
|
||||
return this.api.getCurrentUser();
|
||||
}
|
||||
|
||||
/** Store which user ID is considered "current" (logged-in). */
|
||||
setCurrentUserId(userId: string): Promise<void> {
|
||||
return this.api.setCurrentUserId(userId);
|
||||
}
|
||||
|
||||
/** Retrieve users associated with a room. */
|
||||
getUsersByRoom(roomId: string): Promise<User[]> {
|
||||
return this.api.getUsersByRoom(roomId);
|
||||
}
|
||||
|
||||
/** Apply partial updates to an existing user. */
|
||||
updateUser(userId: string, updates: Partial<User>): Promise<void> {
|
||||
return this.api.updateUser(userId, updates);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Rooms */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Persist a room record. */
|
||||
saveRoom(room: Room): Promise<void> {
|
||||
return this.api.saveRoom(room);
|
||||
}
|
||||
|
||||
/** Retrieve a room by ID, or `null` if not found. */
|
||||
getRoom(roomId: string): Promise<Room | null> {
|
||||
return this.api.getRoom(roomId);
|
||||
}
|
||||
|
||||
/** Return every persisted room. */
|
||||
getAllRooms(): Promise<Room[]> {
|
||||
return this.api.getAllRooms();
|
||||
}
|
||||
|
||||
/** Delete a room by ID. */
|
||||
deleteRoom(roomId: string): Promise<void> {
|
||||
return this.api.deleteRoom(roomId);
|
||||
}
|
||||
|
||||
/** Apply partial updates to an existing room. */
|
||||
updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
|
||||
return this.api.updateRoom(roomId, updates);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Bans */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Persist a ban entry. */
|
||||
saveBan(ban: BanEntry): Promise<void> {
|
||||
return this.api.saveBan(ban);
|
||||
}
|
||||
|
||||
/** Remove a ban by the banned user's `oderId`. */
|
||||
removeBan(oderId: string): Promise<void> {
|
||||
return this.api.removeBan(oderId);
|
||||
}
|
||||
|
||||
/** Return active bans for a room. */
|
||||
getBansForRoom(roomId: string): Promise<BanEntry[]> {
|
||||
return this.api.getBansForRoom(roomId);
|
||||
}
|
||||
|
||||
/** Check whether a user is currently banned from a room. */
|
||||
isUserBanned(userId: string, roomId: string): Promise<boolean> {
|
||||
return this.api.isUserBanned(userId, roomId);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Attachments */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Persist attachment metadata. */
|
||||
saveAttachment(attachment: any): Promise<void> {
|
||||
return this.api.saveAttachment(attachment);
|
||||
}
|
||||
|
||||
/** Return all attachment records for a message. */
|
||||
getAttachmentsForMessage(messageId: string): Promise<any[]> {
|
||||
return this.api.getAttachmentsForMessage(messageId);
|
||||
}
|
||||
|
||||
/** Return every persisted attachment record. */
|
||||
getAllAttachments(): Promise<any[]> {
|
||||
return this.api.getAllAttachments();
|
||||
}
|
||||
|
||||
/** Delete all attachment records for a message. */
|
||||
deleteAttachmentsForMessage(messageId: string): Promise<void> {
|
||||
return this.api.deleteAttachmentsForMessage(messageId);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Utilities */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Wipe every table, removing all persisted data. */
|
||||
clearAllData(): Promise<void> {
|
||||
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 './server-directory.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 { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* A configured server endpoint that the user can connect to.
|
||||
*/
|
||||
export interface ServerEndpoint {
|
||||
/** Unique endpoint identifier. */
|
||||
id: string;
|
||||
/** Human-readable label shown in the UI. */
|
||||
name: string;
|
||||
/** Base URL (e.g. `http://localhost:3001`). */
|
||||
url: string;
|
||||
/** Whether this is the currently selected endpoint. */
|
||||
isActive: boolean;
|
||||
/** Whether this is the built-in default endpoint. */
|
||||
isDefault: boolean;
|
||||
/** Most recent health-check result. */
|
||||
status: 'online' | 'offline' | 'checking' | 'unknown';
|
||||
/** Last measured round-trip latency (ms). */
|
||||
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). */
|
||||
function getDefaultServerUrl(): string {
|
||||
/** Timeout (ms) for server health-check and alternative-endpoint pings. */
|
||||
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) {
|
||||
const proto = window.location.protocol === 'https:' ? 'https' : 'http';
|
||||
return `${proto}://localhost:3001`;
|
||||
const protocol = window.location.protocol === 'https:' ? 'https' : 'http';
|
||||
return `${protocol}://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',
|
||||
url: getDefaultServerUrl(),
|
||||
url: buildDefaultServerUrl(),
|
||||
isActive: true,
|
||||
isDefault: true,
|
||||
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 {
|
||||
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 activeServer = computed(() => this._servers().find((s) => s.isActive) || this._servers()[0]);
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
this.loadServers();
|
||||
/** The currently active endpoint, falling back to the first in the list. */
|
||||
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);
|
||||
if (stored) {
|
||||
try {
|
||||
let servers = JSON.parse(stored) as ServerEndpoint[];
|
||||
// Ensure at least one is active
|
||||
if (!servers.some((s) => s.isActive) && servers.length > 0) {
|
||||
servers[0].isActive = true;
|
||||
}
|
||||
// Migrate default localhost entries to match current protocol
|
||||
const expectedProto = (typeof window !== 'undefined' && window.location?.protocol === 'https:') ? 'https' : 'http';
|
||||
servers = servers.map((s) => {
|
||||
if (s.isDefault && /^https?:\/\/localhost:\d+$/.test(s.url)) {
|
||||
return { ...s, url: s.url.replace(/^https?/, expectedProto) };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
this._servers.set(servers);
|
||||
this.saveServers();
|
||||
} catch {
|
||||
this.initializeDefaultServer();
|
||||
}
|
||||
} else {
|
||||
this.initializeDefaultServer();
|
||||
}
|
||||
}
|
||||
|
||||
private initializeDefaultServer(): void {
|
||||
const defaultServer: ServerEndpoint = {
|
||||
...DEFAULT_SERVER,
|
||||
/**
|
||||
* Add a new server endpoint (inactive by default).
|
||||
*
|
||||
* @param server - Name and URL of the endpoint to add.
|
||||
*/
|
||||
addServer(server: { name: string; url: string }): void {
|
||||
const sanitisedUrl = this.sanitiseUrl(server.url);
|
||||
const newEndpoint: ServerEndpoint = {
|
||||
id: uuidv4(),
|
||||
name: server.name,
|
||||
url: sanitisedUrl,
|
||||
isActive: false,
|
||||
isDefault: false,
|
||||
status: 'unknown',
|
||||
};
|
||||
this._servers.set([defaultServer]);
|
||||
this.saveServers();
|
||||
this._servers.update((endpoints) => [...endpoints, newEndpoint]);
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
private saveServers(): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this._servers()));
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
/** 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;
|
||||
}
|
||||
this.updateServerStatus(endpointId, 'offline');
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Probe all configured endpoints in parallel. */
|
||||
async testAllServers(): Promise<void> {
|
||||
const endpoints = this._servers();
|
||||
await Promise.all(endpoints.map((endpoint) => this.testServer(endpoint.id)));
|
||||
}
|
||||
|
||||
/** Expose the API base URL for external consumers. */
|
||||
getApiBaseUrl(): string {
|
||||
return this.buildApiBaseUrl();
|
||||
}
|
||||
|
||||
/** Get the WebSocket URL derived from the active endpoint. */
|
||||
getWebSocketUrl(): string {
|
||||
const active = this.activeServer();
|
||||
const raw = active ? active.url : getDefaultServerUrl();
|
||||
// Strip trailing slashes and any accidental '/api'
|
||||
let base = raw.replace(/\/+$/,'');
|
||||
if (!active) {
|
||||
const protocol = (typeof window !== 'undefined' && window.location?.protocol === 'https:') ? 'wss' : 'ws';
|
||||
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')) {
|
||||
base = base.slice(0, -4);
|
||||
}
|
||||
return `${base}/api`;
|
||||
}
|
||||
|
||||
// Expose API base URL for consumers that need to call server endpoints
|
||||
getApiBaseUrl(): string {
|
||||
return this.baseUrl;
|
||||
/** Strip trailing slashes and `/api` suffix from a URL. */
|
||||
private sanitiseUrl(rawUrl: string): string {
|
||||
let cleaned = rawUrl.trim().replace(/\/+$/, '');
|
||||
if (cleaned.toLowerCase().endsWith('/api')) {
|
||||
cleaned = cleaned.slice(0, -4);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
// 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();
|
||||
/**
|
||||
* Handle both `{ servers: [...] }` and direct `ServerInfo[]`
|
||||
* response shapes from the directory API.
|
||||
*/
|
||||
private unwrapServersResponse(
|
||||
response: { servers: ServerInfo[]; total: number } | ServerInfo[],
|
||||
): ServerInfo[] {
|
||||
if (Array.isArray(response)) return response;
|
||||
return response.servers ?? [];
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
setActiveServer(id: string): void {
|
||||
this._servers.update((servers) =>
|
||||
servers.map((s) => ({
|
||||
...s,
|
||||
isActive: s.id === id,
|
||||
}))
|
||||
);
|
||||
this.saveServers();
|
||||
}
|
||||
|
||||
updateServerStatus(id: string, status: ServerEndpoint['status'], latency?: number): void {
|
||||
this._servers.update((servers) =>
|
||||
servers.map((s) => (s.id === id ? { ...s, status, latency } : s))
|
||||
);
|
||||
this.saveServers();
|
||||
}
|
||||
|
||||
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[]> {
|
||||
/** Search a single endpoint for servers matching a query. */
|
||||
private searchSingleEndpoint(
|
||||
query: string,
|
||||
apiBaseUrl: string,
|
||||
): Observable<ServerInfo[]> {
|
||||
const params = new HttpParams().set('q', query);
|
||||
|
||||
return this.http.get<{ servers: ServerInfo[]; total: number }>(`${baseUrl}/servers`, { params }).pipe(
|
||||
map((response) => {
|
||||
// Handle both wrapped response { servers: [...] } and direct array
|
||||
if (Array.isArray(response)) {
|
||||
return response;
|
||||
}
|
||||
return response.servers || [];
|
||||
}),
|
||||
return this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params })
|
||||
.pipe(
|
||||
map((response) => this.unwrapServersResponse(response)),
|
||||
catchError((error) => {
|
||||
console.error('Failed to search servers:', error);
|
||||
return of([]);
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private searchAllServerEndpoints(query: string): Observable<ServerInfo[]> {
|
||||
const servers = this._servers().filter((s) => s.status !== 'offline');
|
||||
/** Fan-out search across all non-offline endpoints, deduplicating results. */
|
||||
private searchAllEndpoints(query: string): Observable<ServerInfo[]> {
|
||||
const onlineEndpoints = this._servers().filter(
|
||||
(endpoint) => endpoint.status !== 'offline',
|
||||
);
|
||||
|
||||
if (servers.length === 0) {
|
||||
return this.searchSingleServer(query, this.baseUrl);
|
||||
if (onlineEndpoints.length === 0) {
|
||||
return this.searchSingleEndpoint(query, this.buildApiBaseUrl());
|
||||
}
|
||||
|
||||
const requests = servers.map((server) =>
|
||||
this.searchSingleServer(query, `${server.url}/api`).pipe(
|
||||
const requests = onlineEndpoints.map((endpoint) =>
|
||||
this.searchSingleEndpoint(query, `${endpoint.url}/api`).pipe(
|
||||
map((results) =>
|
||||
results.map((r) => ({
|
||||
...r,
|
||||
sourceId: server.id,
|
||||
sourceName: server.name,
|
||||
}))
|
||||
)
|
||||
)
|
||||
results.map((server) => ({
|
||||
...server,
|
||||
sourceId: endpoint.id,
|
||||
sourceName: endpoint.name,
|
||||
})),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return forkJoin(requests).pipe(
|
||||
map((results) => results.flat()),
|
||||
// Remove duplicates based on server ID
|
||||
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([]);
|
||||
})
|
||||
map((resultArrays) => resultArrays.flat()),
|
||||
map((servers) => this.deduplicateById(servers)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Retrieve all servers from all non-offline endpoints. */
|
||||
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) {
|
||||
return this.http.get<{ servers: ServerInfo[]; total: number }>(`${this.baseUrl}/servers`).pipe(
|
||||
map((response) => (Array.isArray(response) ? response : response.servers || [])),
|
||||
catchError(() => of([]))
|
||||
if (onlineEndpoints.length === 0) {
|
||||
return this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`)
|
||||
.pipe(
|
||||
map((response) => this.unwrapServersResponse(response)),
|
||||
catchError(() => of([])),
|
||||
);
|
||||
}
|
||||
|
||||
const requests = servers.map((server) =>
|
||||
this.http.get<{ servers: ServerInfo[]; total: number }>(`${server.url}/api/servers`).pipe(
|
||||
const requests = onlineEndpoints.map((endpoint) =>
|
||||
this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`)
|
||||
.pipe(
|
||||
map((response) => {
|
||||
const results = Array.isArray(response) ? response : response.servers || [];
|
||||
return results.map((r) => ({
|
||||
...r,
|
||||
sourceId: server.id,
|
||||
sourceName: server.name,
|
||||
const results = this.unwrapServersResponse(response);
|
||||
return results.map((server) => ({
|
||||
...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
|
||||
getServer(serverId: string): Observable<ServerInfo | null> {
|
||||
return this.http.get<ServerInfo>(`${this.baseUrl}/servers/${serverId}`).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to get server:', error);
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
/** Remove duplicate servers (by `id`), keeping the first occurrence. */
|
||||
private deduplicateById<T extends { id: string }>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
return items.filter((item) => {
|
||||
if (seen.has(item.id)) return false;
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Register a new server (with optional pre-generated ID)
|
||||
registerServer(server: Omit<ServerInfo, 'createdAt'> & { id?: string }): Observable<ServerInfo> {
|
||||
return this.http.post<ServerInfo>(`${this.baseUrl}/servers`, server).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to register server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
/** Load endpoints from localStorage, migrating protocol if needed. */
|
||||
private loadEndpoints(): void {
|
||||
const stored = localStorage.getItem(ENDPOINTS_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
this.initialiseDefaultEndpoint();
|
||||
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);
|
||||
})
|
||||
);
|
||||
try {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
})
|
||||
);
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
// 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([]);
|
||||
})
|
||||
);
|
||||
/** Create and persist the built-in default endpoint. */
|
||||
private initialiseDefaultEndpoint(): void {
|
||||
const defaultEndpoint: ServerEndpoint = { ...DEFAULT_ENDPOINT, id: uuidv4() };
|
||||
this._servers.set([defaultEndpoint]);
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
// 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)
|
||||
return active.url.replace(/^http/, 'ws');
|
||||
/** 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';
|
||||
|
||||
/** 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' })
|
||||
export class TimeSyncService {
|
||||
// serverTime - clientTime offset (milliseconds)
|
||||
/**
|
||||
* Internal offset signal:
|
||||
* `serverTime = Date.now() + offset`.
|
||||
*/
|
||||
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());
|
||||
|
||||
// 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 {
|
||||
return Date.now() + this._offset();
|
||||
}
|
||||
|
||||
// Set offset based on a serverTime observed at approximately receiveAt
|
||||
setFromServerTime(serverTime: number, receiveAt?: number): void {
|
||||
const observedAt = receiveAt ?? Date.now();
|
||||
const offset = serverTime - observedAt;
|
||||
this._offset.set(offset);
|
||||
this._lastSyncAt = Date.now();
|
||||
/**
|
||||
* Set the offset from a known server timestamp.
|
||||
*
|
||||
* @param serverTime - Epoch timestamp reported by the server.
|
||||
* @param receiveTimestamp - Local epoch timestamp when the server time was
|
||||
* 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 {
|
||||
const controller = new AbortController();
|
||||
const t0 = Date.now();
|
||||
const clientSendTime = Date.now();
|
||||
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);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
// Estimate one-way latency and offset
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
const serverNow = Number(data?.now) || Date.now();
|
||||
const midpoint = (t0 + t2) / 2;
|
||||
const offset = serverNow - midpoint;
|
||||
this._offset.set(offset);
|
||||
this._lastSyncAt = Date.now();
|
||||
const midpoint = (clientSendTime + clientReceiveTime) / 2;
|
||||
|
||||
this._offset.set(serverNow - midpoint);
|
||||
this.lastSyncTimestamp = Date.now();
|
||||
} 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 { Router } from '@angular/router';
|
||||
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 {
|
||||
/** Unique server identifier. */
|
||||
serverId: string;
|
||||
/** Display name of the server. */
|
||||
serverName: string;
|
||||
/** Room/channel ID within the server. */
|
||||
roomId: string;
|
||||
/** Display name of the room/channel. */
|
||||
roomName: string;
|
||||
/** Optional server icon (data-URL or remote URL). */
|
||||
serverIcon?: string;
|
||||
/** Optional server description. */
|
||||
serverDescription?: string;
|
||||
/** The route path to navigate back to the server */
|
||||
/** Angular route path to navigate back to the server. */
|
||||
serverRoute: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service to track the current voice session across navigation.
|
||||
* When a user is connected to voice in a server and navigates away,
|
||||
* this service maintains the session info for the floating controls.
|
||||
* Tracks the user's current voice session across client-side
|
||||
* navigation so that floating voice controls remain visible when
|
||||
* 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({
|
||||
providedIn: 'root',
|
||||
})
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VoiceSessionService {
|
||||
private router = inject(Router);
|
||||
private store = inject(Store);
|
||||
private readonly router = inject(Router);
|
||||
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);
|
||||
|
||||
// Whether the user is currently viewing the voice-connected server
|
||||
private readonly _isViewingVoiceServer = signal(true);
|
||||
/** Whether the user is currently viewing the voice-connected server. */
|
||||
private readonly _isViewingVoiceServer = signal<boolean>(true);
|
||||
|
||||
// Public computed signals
|
||||
/** Reactive read-only voice session. */
|
||||
readonly voiceSession = computed(() => this._voiceSession());
|
||||
|
||||
/** Reactive flag: is the user's current view the voice server? */
|
||||
readonly isViewingVoiceServer = computed(() => this._isViewingVoiceServer());
|
||||
|
||||
/**
|
||||
* Whether to show floating voice controls:
|
||||
* True when connected to voice AND not viewing the voice-connected server
|
||||
* Whether the floating voice-controls overlay should be visible.
|
||||
* `true` when a voice session is active AND the user is viewing
|
||||
* a different server.
|
||||
*/
|
||||
readonly showFloatingControls = computed(() => {
|
||||
return this._voiceSession() !== null && !this._isViewingVoiceServer();
|
||||
});
|
||||
readonly showFloatingControls = computed(
|
||||
() => 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 {
|
||||
this._voiceSession.set(info);
|
||||
startSession(sessionInfo: VoiceSessionInfo): void {
|
||||
this._voiceSession.set(sessionInfo);
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
const session = this._voiceSession();
|
||||
if (session) {
|
||||
// Use viewServer to switch view without leaving current server
|
||||
this.store.dispatch(RoomsActions.viewServer({
|
||||
if (!session) return;
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.viewServer({
|
||||
room: {
|
||||
id: session.serverId,
|
||||
name: session.serverName,
|
||||
@@ -98,13 +125,14 @@ export class VoiceSessionService {
|
||||
maxUsers: 50,
|
||||
icon: session.serverIcon,
|
||||
} as any,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
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 {
|
||||
return this._voiceSession()?.serverId ?? null;
|
||||
|
||||
@@ -52,17 +52,14 @@ import {
|
||||
export class WebRTCService implements OnDestroy {
|
||||
private readonly timeSync = inject(TimeSyncService);
|
||||
|
||||
// ─── Logger ────────────────────────────────────────────────────────
|
||||
private readonly logger = new WebRTCLogger(/* debugEnabled */ true);
|
||||
|
||||
// ─── Identity & server membership ──────────────────────────────────
|
||||
private lastIdentifyCredentials: IdentifyCredentials | null = null;
|
||||
private lastJoinedServer: JoinedServerInfo | null = null;
|
||||
private readonly memberServerIds = new Set<string>();
|
||||
private activeServerId: string | null = null;
|
||||
private readonly serviceDestroyed$ = new Subject<void>();
|
||||
|
||||
// ─── Angular signals (reactive state) ──────────────────────────────
|
||||
private readonly _localPeerId = signal<string>(uuidv4());
|
||||
private readonly _isSignalingConnected = 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 _hasConnectionError = signal(false);
|
||||
private readonly _connectionErrorMessage = signal<string | null>(null);
|
||||
private readonly _hasEverConnected = signal(false);
|
||||
|
||||
// Public computed signals (unchanged external API)
|
||||
readonly peerId = computed(() => this._localPeerId());
|
||||
readonly isConnected = computed(() => this._isSignalingConnected());
|
||||
readonly hasEverConnected = computed(() => this._hasEverConnected());
|
||||
readonly isVoiceConnected = computed(() => this._isVoiceConnected());
|
||||
readonly connectedPeers = computed(() => this._connectedPeers());
|
||||
readonly isMuted = computed(() => this._isMuted());
|
||||
@@ -91,7 +90,6 @@ export class WebRTCService implements OnDestroy {
|
||||
return true;
|
||||
});
|
||||
|
||||
// ─── Public observables (unchanged external API) ───────────────────
|
||||
private readonly signalingMessage$ = new Subject<SignalingMessage>();
|
||||
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 onVoiceConnected(): Observable<void> { return this.mediaManager.voiceConnected$.asObservable(); }
|
||||
|
||||
// ─── Sub-managers ──────────────────────────────────────────────────
|
||||
|
||||
private readonly signalingManager: SignalingManager;
|
||||
private readonly peerManager: PeerConnectionManager;
|
||||
private readonly mediaManager: MediaManager;
|
||||
@@ -162,12 +158,11 @@ export class WebRTCService implements OnDestroy {
|
||||
this.wireManagerEvents();
|
||||
}
|
||||
|
||||
// ─── Event wiring ──────────────────────────────────────────────────
|
||||
|
||||
private wireManagerEvents(): void {
|
||||
// Signaling → connection status
|
||||
this.signalingManager.connectionStatus$.subscribe(({ connected, errorMessage }) => {
|
||||
this._isSignalingConnected.set(connected);
|
||||
if (connected) this._hasEverConnected.set(true);
|
||||
this._hasConnectionError.set(!connected);
|
||||
this._connectionErrorMessage.set(connected ? null : (errorMessage ?? null));
|
||||
});
|
||||
@@ -187,8 +182,6 @@ export class WebRTCService implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Signaling message routing ─────────────────────────────────────
|
||||
|
||||
private handleSignalingMessage(message: any): void {
|
||||
this.signalingMessage$.next(message);
|
||||
this.logger.info('Signaling message', { type: message.type });
|
||||
@@ -242,8 +235,6 @@ export class WebRTCService implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Voice state snapshot ──────────────────────────────────────────
|
||||
|
||||
private getCurrentVoiceState(): VoiceStateSnapshot {
|
||||
return {
|
||||
isConnected: this._isVoiceConnected(),
|
||||
@@ -259,41 +250,85 @@ export class WebRTCService implements OnDestroy {
|
||||
// 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> {
|
||||
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> {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
this.lastIdentifyCredentials = { 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 {
|
||||
this.lastJoinedServer = { serverId: roomId, userId };
|
||||
this.memberServerIds.add(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 {
|
||||
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 {
|
||||
if (serverId) {
|
||||
this.memberServerIds.delete(serverId);
|
||||
@@ -323,86 +366,159 @@ export class WebRTCService implements OnDestroy {
|
||||
this.fullCleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the local client has joined a given server.
|
||||
*
|
||||
* @param serverId - The server to check.
|
||||
*/
|
||||
hasJoinedServer(serverId: string): boolean {
|
||||
return this.memberServerIds.has(serverId);
|
||||
}
|
||||
|
||||
/** Returns a read-only set of all currently-joined server IDs. */
|
||||
getJoinedServerIds(): ReadonlySet<string> {
|
||||
return this.memberServerIds;
|
||||
}
|
||||
|
||||
// ─── Peer messaging ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Broadcast a {@link ChatEvent} to every connected peer.
|
||||
*
|
||||
* @param event - The chat event to send.
|
||||
*/
|
||||
broadcastMessage(event: ChatEvent): void {
|
||||
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 {
|
||||
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> {
|
||||
return this.peerManager.sendToPeerBuffered(peerId, event);
|
||||
}
|
||||
|
||||
/** Returns an array of currently-connected peer IDs. */
|
||||
getConnectedPeers(): string[] {
|
||||
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 {
|
||||
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> {
|
||||
const stream = await this.mediaManager.enableVoice();
|
||||
this.syncMediaSignals();
|
||||
return stream;
|
||||
}
|
||||
|
||||
/** Stop local voice capture and remove audio senders from peers. */
|
||||
disableVoice(): void {
|
||||
this.mediaManager.disableVoice();
|
||||
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 {
|
||||
this.mediaManager.setLocalStream(stream);
|
||||
this.syncMediaSignals();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the local microphone mute state.
|
||||
*
|
||||
* @param muted - Explicit state; if omitted, the current state is toggled.
|
||||
*/
|
||||
toggleMute(muted?: boolean): void {
|
||||
this.mediaManager.toggleMute(muted);
|
||||
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 {
|
||||
this.mediaManager.toggleDeafen(deafened);
|
||||
this._isDeafened.set(this.mediaManager.getIsSelfDeafened());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the output volume for remote audio playback.
|
||||
*
|
||||
* @param volume - Normalised volume (0–1).
|
||||
*/
|
||||
setOutputVolume(volume: number): void {
|
||||
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> {
|
||||
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> {
|
||||
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 {
|
||||
this.mediaManager.startVoiceHeartbeat(roomId, serverId);
|
||||
}
|
||||
|
||||
/** Stop the voice-presence heartbeat. */
|
||||
stopVoiceHeartbeat(): void {
|
||||
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> {
|
||||
const stream = await this.screenShareManager.startScreenShare(includeAudio);
|
||||
this._isScreenSharing.set(true);
|
||||
@@ -410,24 +526,26 @@ export class WebRTCService implements OnDestroy {
|
||||
return stream;
|
||||
}
|
||||
|
||||
/** Stop screen sharing and restore microphone audio on all peers. */
|
||||
stopScreenShare(): void {
|
||||
this.screenShareManager.stopScreenShare();
|
||||
this._isScreenSharing.set(false);
|
||||
this._screenStreamSignal.set(null);
|
||||
}
|
||||
|
||||
// ─── Disconnect / cleanup ─────────────────────────────────────────
|
||||
|
||||
/** Disconnect from the signaling server and clean up all state. */
|
||||
disconnect(): void {
|
||||
this.leaveRoom();
|
||||
this.mediaManager.stopVoiceHeartbeat();
|
||||
this.signalingManager.close();
|
||||
this._isSignalingConnected.set(false);
|
||||
this._hasEverConnected.set(false);
|
||||
this._hasConnectionError.set(false);
|
||||
this._connectionErrorMessage.set(null);
|
||||
this.serviceDestroyed$.next();
|
||||
}
|
||||
|
||||
/** Alias for {@link disconnect}. */
|
||||
disconnectAll(): void {
|
||||
this.disconnect();
|
||||
}
|
||||
@@ -442,8 +560,6 @@ export class WebRTCService implements OnDestroy {
|
||||
this._screenStreamSignal.set(null);
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/** Synchronise Angular signals from the MediaManager's internal state. */
|
||||
private syncMediaSignals(): void {
|
||||
this._isVoiceConnected.set(this.mediaManager.getIsVoiceActive());
|
||||
@@ -451,8 +567,6 @@ export class WebRTCService implements OnDestroy {
|
||||
this._isDeafened.set(this.mediaManager.getIsSelfDeafened());
|
||||
}
|
||||
|
||||
// ─── Lifecycle ─────────────────────────────────────────────────────
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.disconnect();
|
||||
this.serviceDestroyed$.complete();
|
||||
|
||||
@@ -66,22 +66,40 @@ export class MediaManager {
|
||||
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 {
|
||||
this.callbacks = cb;
|
||||
}
|
||||
|
||||
// ─── Accessors ─────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the current local media stream, or `null` if voice is disabled. */
|
||||
getLocalStream(): MediaStream | null { return this.localMediaStream; }
|
||||
/** Whether voice is currently active (mic captured). */
|
||||
getIsVoiceActive(): boolean { return this.isVoiceActive; }
|
||||
/** Whether the local microphone is muted. */
|
||||
getIsMicMuted(): boolean { return this.isMicMuted; }
|
||||
/** Whether the user has self-deafened. */
|
||||
getIsSelfDeafened(): boolean { return this.isSelfDeafened; }
|
||||
/** Current remote audio output volume (normalised 0–1). */
|
||||
getRemoteAudioVolume(): number { return this.remoteAudioVolume; }
|
||||
/** The voice channel room ID, if currently in voice. */
|
||||
getCurrentVoiceRoomId(): string | undefined { return this.currentVoiceRoomId; }
|
||||
/** The voice channel server ID, if currently in voice. */
|
||||
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> {
|
||||
try {
|
||||
// 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 {
|
||||
if (this.localMediaStream) {
|
||||
this.localMediaStream.getTracks().forEach((track) => track.stop());
|
||||
@@ -154,8 +176,11 @@ export class MediaManager {
|
||||
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 {
|
||||
if (this.localMediaStream) {
|
||||
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 {
|
||||
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 {
|
||||
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> {
|
||||
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;
|
||||
|
||||
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[0].maxBitrate = targetBps;
|
||||
|
||||
try {
|
||||
await sender.setParameters(params);
|
||||
console.log('Applied audio bitrate:', targetBps);
|
||||
} catch (e) {
|
||||
console.warn('Failed to set audio bitrate', e);
|
||||
this.logger.info('Applied audio bitrate', { targetBps });
|
||||
} catch (error) {
|
||||
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> {
|
||||
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 {
|
||||
this.stopVoiceHeartbeat();
|
||||
|
||||
@@ -224,6 +274,7 @@ export class MediaManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop the voice-presence heartbeat timer. */
|
||||
stopVoiceHeartbeat(): void {
|
||||
if (this.voicePresenceTimer) {
|
||||
clearInterval(this.voicePresenceTimer);
|
||||
@@ -231,8 +282,6 @@ export class MediaManager {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internal helpers ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Bind local audio/video tracks to all existing peer transceivers.
|
||||
* 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 {
|
||||
const oderId = this.callbacks.getIdentifyOderId();
|
||||
const displayName = this.callbacks.getIdentifyDisplayName();
|
||||
|
||||
@@ -64,7 +64,6 @@ export class PeerConnectionManager {
|
||||
private disconnectedPeerTracker = new Map<string, DisconnectedPeerEntry>();
|
||||
private peerReconnectTimers = new Map<string, ReturnType<typeof setInterval>>();
|
||||
|
||||
// ─── Public event subjects ─────────────────────────────────────────
|
||||
readonly peerConnected$ = new Subject<string>();
|
||||
readonly peerDisconnected$ = new Subject<string>();
|
||||
readonly remoteStream$ = new Subject<{ peerId: string; stream: MediaStream }>();
|
||||
@@ -77,13 +76,27 @@ export class PeerConnectionManager {
|
||||
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 {
|
||||
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 {
|
||||
this.logger.info('Creating peer connection', { remotePeerId, isInitiator });
|
||||
|
||||
@@ -201,8 +214,11 @@ export class PeerConnectionManager {
|
||||
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> {
|
||||
const peerData = this.activePeerConnections.get(remotePeerId);
|
||||
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> {
|
||||
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> {
|
||||
this.logger.info('Handling answer', { 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> {
|
||||
let peerData = this.activePeerConnections.get(fromUserId);
|
||||
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 {
|
||||
channel.onopen = () => {
|
||||
console.log(`Data channel open with ${remotePeerId}`);
|
||||
this.logger.info('Data channel open', { remotePeerId });
|
||||
this.sendCurrentStatesToChannel(channel, remotePeerId);
|
||||
try { channel.send(JSON.stringify({ type: P2P_TYPE_STATE_REQUEST })); } catch { /* ignore */ }
|
||||
};
|
||||
|
||||
channel.onclose = () => {
|
||||
console.log(`Data channel closed with ${remotePeerId}`);
|
||||
this.logger.info('Data channel closed', { remotePeerId });
|
||||
};
|
||||
|
||||
channel.onerror = (error) => {
|
||||
console.error(`Data channel error with ${remotePeerId}:`, error);
|
||||
this.logger.error('Data channel error', error, { remotePeerId });
|
||||
};
|
||||
|
||||
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 {
|
||||
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) {
|
||||
this.sendCurrentStatesToPeer(peerId);
|
||||
@@ -377,8 +438,6 @@ export class PeerConnectionManager {
|
||||
this.messageReceived$.next(enriched);
|
||||
}
|
||||
|
||||
// ─── Messaging helpers ─────────────────────────────────────────────
|
||||
|
||||
/** Broadcast a ChatEvent to every peer with an open data channel. */
|
||||
broadcastMessage(event: ChatEvent): void {
|
||||
const data = JSON.stringify(event);
|
||||
@@ -386,33 +445,48 @@ export class PeerConnectionManager {
|
||||
try {
|
||||
if (peerData.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) {
|
||||
peerData.dataChannel.send(data);
|
||||
console.log('Sent message via P2P to:', peerId);
|
||||
this.logger.info('Sent message via P2P', { peerId });
|
||||
}
|
||||
} 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 {
|
||||
const peerData = this.activePeerConnections.get(peerId);
|
||||
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;
|
||||
}
|
||||
try {
|
||||
peerData.dataChannel.send(JSON.stringify(event));
|
||||
} 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> {
|
||||
const peerData = this.activePeerConnections.get(peerId);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
const credentials = this.callbacks.getIdentifyCredentials();
|
||||
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 {
|
||||
const credentials = this.callbacks.getIdentifyCredentials();
|
||||
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);
|
||||
}
|
||||
|
||||
// ─── Remote tracks ─────────────────────────────────────────────────
|
||||
|
||||
private handleRemoteTrack(event: RTCTrackEvent, remotePeerId: string): void {
|
||||
const track = event.track;
|
||||
const settings = typeof track.getSettings === 'function' ? track.getSettings() : {} as MediaTrackSettings;
|
||||
@@ -495,7 +571,7 @@ export class PeerConnectionManager {
|
||||
|
||||
// Merge into composite stream per peer
|
||||
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) {
|
||||
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 });
|
||||
}
|
||||
|
||||
// ─── 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 {
|
||||
const peerData = this.activePeerConnections.get(peerId);
|
||||
if (peerData) {
|
||||
@@ -516,6 +595,7 @@ export class PeerConnectionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Close every active peer connection and clear internal state. */
|
||||
closeAllPeers(): void {
|
||||
this.clearAllPeerReconnectTimers();
|
||||
this.activePeerConnections.forEach((peerData) => {
|
||||
@@ -526,8 +606,6 @@ export class PeerConnectionManager {
|
||||
this.connectedPeersChanged$.next([]);
|
||||
}
|
||||
|
||||
// ─── P2P reconnection ─────────────────────────────────────────────
|
||||
|
||||
private trackDisconnectedPeer(peerId: string): void {
|
||||
this.disconnectedPeerTracker.set(peerId, { lastSeenTimestamp: Date.now(), reconnectAttempts: 0 });
|
||||
}
|
||||
@@ -537,6 +615,7 @@ export class PeerConnectionManager {
|
||||
if (timer) { clearInterval(timer); this.peerReconnectTimers.delete(peerId); }
|
||||
}
|
||||
|
||||
/** Cancel all pending peer reconnect timers and clear the tracker. */
|
||||
clearAllPeerReconnectTimers(): void {
|
||||
this.peerReconnectTimers.forEach((timer) => clearInterval(timer));
|
||||
this.peerReconnectTimers.clear();
|
||||
@@ -586,10 +665,9 @@ export class PeerConnectionManager {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Connected-peer helpers ────────────────────────────────────────
|
||||
|
||||
private connectedPeersList: string[] = [];
|
||||
|
||||
/** Return a snapshot copy of the currently-connected peer IDs. */
|
||||
getConnectedPeerIds(): string[] {
|
||||
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 {
|
||||
this.connectedPeersList = this.connectedPeersList.filter(p => p !== peerId);
|
||||
this.connectedPeersList = this.connectedPeersList.filter(connectedId => connectedId !== peerId);
|
||||
this.connectedPeersChanged$.next(this.connectedPeersList);
|
||||
}
|
||||
|
||||
/** Reset the connected peers list to empty and notify subscribers. */
|
||||
resetConnectedPeers(): void {
|
||||
this.connectedPeersList = [];
|
||||
this.connectedPeersChanged$.next([]);
|
||||
|
||||
@@ -43,17 +43,32 @@ export class ScreenShareManager {
|
||||
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 {
|
||||
this.callbacks = cb;
|
||||
}
|
||||
|
||||
// ─── Accessors ─────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the current screen-capture stream, or `null` if inactive. */
|
||||
getScreenStream(): MediaStream | null { return this.activeScreenStream; }
|
||||
/** Whether screen sharing is currently active. */
|
||||
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> {
|
||||
try {
|
||||
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 {
|
||||
if (this.activeScreenStream) {
|
||||
this.activeScreenStream.getTracks().forEach((track) => track.stop());
|
||||
@@ -135,14 +156,14 @@ export class ScreenShareManager {
|
||||
|
||||
// Clean up mixed audio
|
||||
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;
|
||||
}
|
||||
|
||||
// Remove video track and restore mic on all peers
|
||||
this.callbacks.getActivePeers().forEach((peerData, peerId) => {
|
||||
const transceivers = peerData.connection.getTransceivers();
|
||||
const videoTransceiver = transceivers.find(t => t.sender === peerData.videoSender || t.sender === peerData.screenVideoSender);
|
||||
const videoTransceiver = transceivers.find(transceiver => transceiver.sender === peerData.videoSender || transceiver.sender === peerData.screenVideoSender);
|
||||
if (videoTransceiver) {
|
||||
videoTransceiver.sender.replaceTrack(null).catch(() => {});
|
||||
if (videoTransceiver.direction === TRANSCEIVER_SEND_RECV) {
|
||||
@@ -161,15 +182,20 @@ export class ScreenShareManager {
|
||||
audioSender = transceiver.sender;
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Internal helpers ──────────────────────────────────────────────
|
||||
|
||||
/** Create a mixed audio stream from screen audio + mic audio. */
|
||||
/**
|
||||
* Create a mixed audio stream from screen audio + microphone 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 {
|
||||
const screenAudioTrack = includeSystemAudio ? (this.activeScreenStream?.getAudioTracks()[0] || null) : 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 {
|
||||
this.callbacks.getActivePeers().forEach((peerData, peerId) => {
|
||||
if (!this.activeScreenStream) return;
|
||||
|
||||
@@ -39,8 +39,6 @@ export class SignalingManager {
|
||||
private readonly getMemberServerIds: () => ReadonlySet<string>,
|
||||
) {}
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────────
|
||||
|
||||
/** Open (or re-open) a WebSocket to the signaling server. */
|
||||
connect(serverUrl: string): Observable<boolean> {
|
||||
this.lastSignalingUrl = serverUrl;
|
||||
@@ -146,8 +144,6 @@ export class SignalingManager {
|
||||
return this.lastSignalingUrl;
|
||||
}
|
||||
|
||||
// ─── Internals ─────────────────────────────────────────────────────
|
||||
|
||||
/** Re-identify and rejoin servers after a reconnect. */
|
||||
private reIdentifyAndRejoin(): void {
|
||||
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 {
|
||||
if (this.signalingReconnectTimer || !this.lastSignalingUrl) return;
|
||||
const delay = Math.min(
|
||||
@@ -189,6 +191,7 @@ export class SignalingManager {
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/** Cancel any pending reconnect timer and reset the attempt counter. */
|
||||
private clearReconnect(): void {
|
||||
if (this.signalingReconnectTimer) {
|
||||
clearTimeout(this.signalingReconnectTimer);
|
||||
@@ -197,11 +200,13 @@ export class SignalingManager {
|
||||
this.signalingReconnectAttempts = 0;
|
||||
}
|
||||
|
||||
/** Start the heartbeat interval that drives periodic state broadcasts. */
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat();
|
||||
this.stateHeartbeatTimer = setInterval(() => this.heartbeatTick$.next(), STATE_HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/** Stop the heartbeat interval. */
|
||||
private stopHeartbeat(): void {
|
||||
if (this.stateHeartbeatTimer) {
|
||||
clearInterval(this.stateHeartbeatTimer);
|
||||
|
||||
@@ -28,8 +28,6 @@ export class WebRTCLogger {
|
||||
try { console.error(`[WebRTC] ${prefix}`, payload); } catch { /* swallow */ }
|
||||
}
|
||||
|
||||
// ─── Track / Stream diagnostics ──────────────────────────────────
|
||||
|
||||
/** Attach lifecycle event listeners to a track for debugging. */
|
||||
attachTrackDiagnostics(track: MediaStreamTrack, label: string): void {
|
||||
const settings = typeof track.getSettings === 'function' ? track.getSettings() : {} as MediaTrackSettings;
|
||||
@@ -58,9 +56,9 @@ export class WebRTCLogger {
|
||||
id: (stream as any).id,
|
||||
audioTrackCount: audioTracks.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}`));
|
||||
videoTracks.forEach((t, i) => this.attachTrackDiagnostics(t, `${label}:video#${i}`));
|
||||
audioTracks.forEach((audioTrack, index) => this.attachTrackDiagnostics(audioTrack, `${label}:audio#${index}`));
|
||||
videoTracks.forEach((videoTrack, index) => this.attachTrackDiagnostics(videoTrack, `${label}:video#${index}`));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Centralised here so nothing is hard-coded inline.
|
||||
*/
|
||||
|
||||
// ─── ICE / STUN ──────────────────────────────────────────────────────
|
||||
export const ICE_SERVERS: RTCIceServer[] = [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
@@ -12,7 +11,6 @@ export const ICE_SERVERS: RTCIceServer[] = [
|
||||
{ urls: 'stun:stun4.l.google.com:19302' },
|
||||
];
|
||||
|
||||
// ─── Signaling reconnection ──────────────────────────────────────────
|
||||
/** Base delay (ms) for exponential backoff on signaling reconnect */
|
||||
export const SIGNALING_RECONNECT_BASE_DELAY_MS = 1_000;
|
||||
/** Maximum delay (ms) between signaling reconnect attempts */
|
||||
@@ -20,19 +18,16 @@ export const SIGNALING_RECONNECT_MAX_DELAY_MS = 30_000;
|
||||
/** Default timeout (ms) for `ensureSignalingConnected` */
|
||||
export const SIGNALING_CONNECT_TIMEOUT_MS = 5_000;
|
||||
|
||||
// ─── Peer-to-peer reconnection ──────────────────────────────────────
|
||||
/** Maximum P2P reconnect attempts before giving up */
|
||||
export const PEER_RECONNECT_MAX_ATTEMPTS = 12;
|
||||
/** Interval (ms) between P2P reconnect attempts */
|
||||
export const PEER_RECONNECT_INTERVAL_MS = 5_000;
|
||||
|
||||
// ─── Heartbeat / presence ────────────────────────────────────────────
|
||||
/** Interval (ms) for broadcasting state heartbeats */
|
||||
export const STATE_HEARTBEAT_INTERVAL_MS = 5_000;
|
||||
/** Interval (ms) for broadcasting voice presence */
|
||||
export const VOICE_HEARTBEAT_INTERVAL_MS = 5_000;
|
||||
|
||||
// ─── Data-channel back-pressure ──────────────────────────────────────
|
||||
/** Data channel name used for P2P chat */
|
||||
export const DATA_CHANNEL_LABEL = 'chat';
|
||||
/** High-water mark (bytes) – pause sending when buffered amount exceeds this */
|
||||
@@ -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 */
|
||||
export const DATA_CHANNEL_LOW_WATER_BYTES = 1 * 1024 * 1024; // 1 MB
|
||||
|
||||
// ─── Screen share defaults ───────────────────────────────────────────
|
||||
export const SCREEN_SHARE_IDEAL_WIDTH = 1920;
|
||||
export const SCREEN_SHARE_IDEAL_HEIGHT = 1080;
|
||||
export const SCREEN_SHARE_IDEAL_FRAME_RATE = 30;
|
||||
/** Electron source name to prefer for whole-screen capture */
|
||||
export const ELECTRON_ENTIRE_SCREEN_SOURCE_NAME = 'Entire Screen';
|
||||
|
||||
// ─── Audio bitrate ───────────────────────────────────────────────────
|
||||
/** Minimum audio bitrate (bps) */
|
||||
export const AUDIO_BITRATE_MIN_BPS = 16_000;
|
||||
/** Maximum audio bitrate (bps) */
|
||||
@@ -62,23 +55,19 @@ export const LATENCY_PROFILE_BITRATES = {
|
||||
} as const;
|
||||
export type LatencyProfile = keyof typeof LATENCY_PROFILE_BITRATES;
|
||||
|
||||
// ─── RTC transceiver directions ──────────────────────────────────────
|
||||
export const TRANSCEIVER_SEND_RECV: RTCRtpTransceiverDirection = 'sendrecv';
|
||||
export const TRANSCEIVER_RECV_ONLY: RTCRtpTransceiverDirection = 'recvonly';
|
||||
export const TRANSCEIVER_INACTIVE: RTCRtpTransceiverDirection = 'inactive';
|
||||
|
||||
// ─── Connection / data-channel states (for readability) ──────────────
|
||||
export const CONNECTION_STATE_CONNECTED = 'connected';
|
||||
export const CONNECTION_STATE_DISCONNECTED = 'disconnected';
|
||||
export const CONNECTION_STATE_FAILED = 'failed';
|
||||
export const CONNECTION_STATE_CLOSED = 'closed';
|
||||
export const DATA_CHANNEL_STATE_OPEN = 'open';
|
||||
|
||||
// ─── Track kinds ─────────────────────────────────────────────────────
|
||||
export const TRACK_KIND_AUDIO = 'audio';
|
||||
export const TRACK_KIND_VIDEO = 'video';
|
||||
|
||||
// ─── Signaling message types ─────────────────────────────────────────
|
||||
export const SIGNALING_TYPE_IDENTIFY = 'identify';
|
||||
export const SIGNALING_TYPE_JOIN_SERVER = 'join_server';
|
||||
export const SIGNALING_TYPE_VIEW_SERVER = 'view_server';
|
||||
@@ -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_LEFT = 'user_left';
|
||||
|
||||
// ─── P2P message types ──────────────────────────────────────────────
|
||||
export const P2P_TYPE_STATE_REQUEST = 'state-request';
|
||||
export const P2P_TYPE_VOICE_STATE_REQUEST = 'voice-state-request';
|
||||
export const P2P_TYPE_VOICE_STATE = 'voice-state';
|
||||
export const P2P_TYPE_SCREEN_STATE = 'screen-state';
|
||||
|
||||
// ─── Misc ────────────────────────────────────────────────────────────
|
||||
/** Default display name fallback */
|
||||
export const DEFAULT_DISPLAY_NAME = 'User';
|
||||
/** Minimum volume (normalised 0-1) */
|
||||
|
||||
@@ -4,40 +4,60 @@
|
||||
|
||||
/** Tracks a single peer's connection, data channel, and RTP senders. */
|
||||
export interface PeerData {
|
||||
/** The underlying RTCPeerConnection instance. */
|
||||
connection: RTCPeerConnection;
|
||||
/** The negotiated data channel, or `null` before the channel is established. */
|
||||
dataChannel: RTCDataChannel | null;
|
||||
/** `true` when this side created the offer (and data channel). */
|
||||
isInitiator: boolean;
|
||||
/** ICE candidates received before the remote description was set. */
|
||||
pendingIceCandidates: RTCIceCandidateInit[];
|
||||
/** The RTP sender carrying the local audio track. */
|
||||
audioSender?: RTCRtpSender;
|
||||
/** The RTP sender carrying the local video (camera) track. */
|
||||
videoSender?: RTCRtpSender;
|
||||
/** The RTP sender carrying the screen-share video track. */
|
||||
screenVideoSender?: RTCRtpSender;
|
||||
/** The RTP sender carrying the screen-share audio track. */
|
||||
screenAudioSender?: RTCRtpSender;
|
||||
}
|
||||
|
||||
/** Credentials cached for automatic re-identification after reconnect. */
|
||||
export interface IdentifyCredentials {
|
||||
/** The user's unique order / peer identifier. */
|
||||
oderId: string;
|
||||
/** The user's display name shown to other peers. */
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
/** Last-joined server info, used for reconnection. */
|
||||
export interface JoinedServerInfo {
|
||||
/** The server (room) that was last joined. */
|
||||
serverId: string;
|
||||
/** The local user ID at the time of joining. */
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/** Entry in the disconnected-peer tracker for P2P reconnect scheduling. */
|
||||
export interface DisconnectedPeerEntry {
|
||||
/** Timestamp (ms since epoch) when the peer was last seen connected. */
|
||||
lastSeenTimestamp: number;
|
||||
/** Number of reconnect attempts made so far. */
|
||||
reconnectAttempts: number;
|
||||
}
|
||||
|
||||
/** Snapshot of current voice / screen state (broadcast to peers). */
|
||||
export interface VoiceStateSnapshot {
|
||||
/** Whether the user's voice is currently active. */
|
||||
isConnected: boolean;
|
||||
/** Whether the user's microphone is muted. */
|
||||
isMuted: boolean;
|
||||
/** Whether the user has self-deafened. */
|
||||
isDeafened: boolean;
|
||||
/** Whether the user is sharing their screen. */
|
||||
isScreenSharing: boolean;
|
||||
/** The voice channel room ID, if applicable. */
|
||||
roomId?: string;
|
||||
/** The voice channel server ID, if applicable. */
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
@@ -147,9 +147,7 @@
|
||||
} @else {
|
||||
@for (user of membersFiltered(); track user.id) {
|
||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
||||
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold text-sm">
|
||||
{{ user.displayName ? user.displayName.charAt(0).toUpperCase() : '?' }}
|
||||
</div>
|
||||
<app-user-avatar [name]="user.displayName || '?'" size="sm" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="text-sm font-medium text-foreground truncate">{{ user.displayName }}</p>
|
||||
@@ -302,7 +300,11 @@
|
||||
<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>
|
||||
</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 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-xs text-muted-foreground">Allow moderators to create/modify chat & voice rooms</p>
|
||||
</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 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-xs text-muted-foreground">Grant icon management to admins</p>
|
||||
</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 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-xs text-muted-foreground">Grant icon management to moderators</p>
|
||||
</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>
|
||||
|
||||
@@ -346,28 +360,16 @@
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
@if (showDeleteConfirm()) {
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="showDeleteConfirm.set(false)">
|
||||
<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-2">Delete Room</h3>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Are you sure you want to delete this room? This action cannot be undone.
|
||||
</p>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button
|
||||
(click)="showDeleteConfirm.set(false)"
|
||||
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||
<app-confirm-dialog
|
||||
title="Delete Room"
|
||||
confirmLabel="Delete Room"
|
||||
variant="danger"
|
||||
[widthClass]="'w-96 max-w-[90vw]'"
|
||||
(confirmed)="deleteRoom()"
|
||||
(cancelled)="showDeleteConfirm.set(false)"
|
||||
>
|
||||
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>
|
||||
<p>Are you sure you want to delete this room? This action cannot be undone.</p>
|
||||
</app-confirm-dialog>
|
||||
}
|
||||
} @else {
|
||||
<div class="h-full flex items-center justify-center text-muted-foreground">
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
lucideUnlock,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import * as UsersActions from '../../../store/users/users.actions';
|
||||
import * as RoomsActions from '../../../store/rooms/rooms.actions';
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import {
|
||||
selectBannedUsers,
|
||||
@@ -27,13 +27,14 @@ import {
|
||||
} from '../../../store/users/users.selectors';
|
||||
import { BanEntry, Room, User } from '../../../core/models';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||
|
||||
type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent, ConfirmDialogComponent],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideShield,
|
||||
@@ -50,6 +51,10 @@ type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
|
||||
],
|
||||
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 {
|
||||
private store = inject(Store);
|
||||
private webrtc = inject(WebRTCService);
|
||||
@@ -99,10 +104,12 @@ export class AdminPanelComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle the room's private visibility setting. */
|
||||
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 {
|
||||
const room = this.currentRoom();
|
||||
if (!room) return;
|
||||
@@ -120,6 +127,7 @@ export class AdminPanelComponent {
|
||||
);
|
||||
}
|
||||
|
||||
/** Persist updated room permissions (voice, screen-share, uploads, slow-mode, role grants). */
|
||||
savePermissions(): void {
|
||||
const room = this.currentRoom();
|
||||
if (!room) return;
|
||||
@@ -141,14 +149,17 @@ export class AdminPanelComponent {
|
||||
);
|
||||
}
|
||||
|
||||
/** Remove a user's ban entry. */
|
||||
unbanUser(ban: BanEntry): void {
|
||||
this.store.dispatch(UsersActions.unbanUser({ oderId: ban.oderId }));
|
||||
}
|
||||
|
||||
/** Show the delete-room confirmation dialog. */
|
||||
confirmDeleteRoom(): void {
|
||||
this.showDeleteConfirm.set(true);
|
||||
}
|
||||
|
||||
/** Delete the current room after confirmation. */
|
||||
deleteRoom(): void {
|
||||
const room = this.currentRoom();
|
||||
if (!room) return;
|
||||
@@ -157,17 +168,20 @@ export class AdminPanelComponent {
|
||||
this.showDeleteConfirm.set(false);
|
||||
}
|
||||
|
||||
/** Format a ban expiry timestamp into a human-readable date/time string. */
|
||||
formatExpiry(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// Members tab: get all users except self
|
||||
/** Return online users excluding the current user (for the members list). */
|
||||
membersFiltered(): User[] {
|
||||
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 {
|
||||
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
|
||||
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 {
|
||||
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
||||
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 {
|
||||
this.store.dispatch(UsersActions.banUser({ userId: user.id }));
|
||||
this.webrtc.broadcastMessage({
|
||||
|
||||
@@ -8,20 +8,40 @@
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
<option *ngFor="let s of servers(); trackBy: trackById" [value]="s.id">{{ s.name }}</option>
|
||||
<select
|
||||
[(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>
|
||||
</div>
|
||||
<p *ngIf="error()" 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>
|
||||
@if (error()) {
|
||||
<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">
|
||||
No account? <a class="text-primary hover:underline" (click)="goRegister()">Register</a>
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,9 @@ import { lucideLogIn } from '@ng-icons/lucide';
|
||||
|
||||
import { AuthService } from '../../../core/services/auth.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 { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
@@ -18,6 +19,9 @@ import { User } from '../../../core/models';
|
||||
viewProviders: [provideIcons({ lucideLogIn })],
|
||||
templateUrl: './login.component.html',
|
||||
})
|
||||
/**
|
||||
* Login form allowing existing users to authenticate against a selected server.
|
||||
*/
|
||||
export class LoginComponent {
|
||||
private auth = inject(AuthService);
|
||||
private serversSvc = inject(ServerDirectoryService);
|
||||
@@ -30,8 +34,10 @@ export class LoginComponent {
|
||||
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
||||
error = signal<string | null>(null);
|
||||
|
||||
/** TrackBy function for server list rendering. */
|
||||
trackById(_index: number, item: { id: string }) { return item.id; }
|
||||
|
||||
/** Validate and submit the login form, then navigate to search on success. */
|
||||
submit() {
|
||||
this.error.set(null);
|
||||
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
||||
@@ -47,7 +53,7 @@ export class LoginComponent {
|
||||
role: 'member',
|
||||
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.router.navigate(['/search']);
|
||||
},
|
||||
@@ -57,6 +63,7 @@ export class LoginComponent {
|
||||
});
|
||||
}
|
||||
|
||||
/** Navigate to the registration page. */
|
||||
goRegister() {
|
||||
this.router.navigate(['/register']);
|
||||
}
|
||||
|
||||
@@ -8,24 +8,48 @@
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
<option *ngFor="let s of servers(); trackBy: trackById" [value]="s.id">{{ s.name }}</option>
|
||||
<select
|
||||
[(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>
|
||||
</div>
|
||||
<p *ngIf="error()" 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>
|
||||
@if (error()) {
|
||||
<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">
|
||||
Have an account? <a class="text-primary hover:underline" (click)="goLogin()">Login</a>
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,9 @@ import { lucideUserPlus } from '@ng-icons/lucide';
|
||||
|
||||
import { AuthService } from '../../../core/services/auth.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 { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
@@ -18,6 +19,9 @@ import { User } from '../../../core/models';
|
||||
viewProviders: [provideIcons({ lucideUserPlus })],
|
||||
templateUrl: './register.component.html',
|
||||
})
|
||||
/**
|
||||
* Registration form allowing new users to create an account on a selected server.
|
||||
*/
|
||||
export class RegisterComponent {
|
||||
private auth = inject(AuthService);
|
||||
private serversSvc = inject(ServerDirectoryService);
|
||||
@@ -31,8 +35,10 @@ export class RegisterComponent {
|
||||
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
||||
error = signal<string | null>(null);
|
||||
|
||||
/** TrackBy function for server list rendering. */
|
||||
trackById(_index: number, item: { id: string }) { return item.id; }
|
||||
|
||||
/** Validate and submit the registration form, then navigate to search on success. */
|
||||
submit() {
|
||||
this.error.set(null);
|
||||
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
||||
@@ -48,7 +54,7 @@ export class RegisterComponent {
|
||||
role: 'member',
|
||||
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.router.navigate(['/search']);
|
||||
},
|
||||
@@ -58,6 +64,7 @@ export class RegisterComponent {
|
||||
});
|
||||
}
|
||||
|
||||
/** Navigate to the login page. */
|
||||
goLogin() {
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
@@ -13,11 +13,15 @@ import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
viewProviders: [provideIcons({ lucideUser, lucideLogIn, lucideUserPlus })],
|
||||
templateUrl: './user-bar.component.html',
|
||||
})
|
||||
/**
|
||||
* Compact user status bar showing the current user with login/register navigation links.
|
||||
*/
|
||||
export class UserBarComponent {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
user = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
/** Navigate to the specified authentication page. */
|
||||
goto(path: 'login' | 'register') {
|
||||
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,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ChatMessagesComponent } from '../../chat/chat-messages.component';
|
||||
import { UserListComponent } from '../../chat/user-list.component';
|
||||
import { ChatMessagesComponent } from '../../chat/chat-messages/chat-messages.component';
|
||||
import { UserListComponent } from '../../chat/user-list/user-list.component';
|
||||
import { ScreenShareViewerComponent } from '../../voice/screen-share-viewer/screen-share-viewer.component';
|
||||
import { AdminPanelComponent } from '../../admin/admin-panel/admin-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',
|
||||
})
|
||||
/**
|
||||
* Main chat room view combining the messages panel, side panels, and admin controls.
|
||||
*/
|
||||
export class ChatRoomComponent {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
@@ -57,13 +60,15 @@ export class ChatRoomComponent {
|
||||
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
textChannels = this.store.selectSignal(selectTextChannels);
|
||||
|
||||
/** Returns the display name of the currently active text channel. */
|
||||
get activeChannelName(): string {
|
||||
const id = this.activeChannelId();
|
||||
const ch = this.textChannels().find(c => c.id === id);
|
||||
return ch ? ch.name : id;
|
||||
const activeChannel = this.textChannels().find(channel => channel.id === id);
|
||||
return activeChannel ? activeChannel.name : id;
|
||||
}
|
||||
|
||||
/** Toggle the admin panel sidebar visibility. */
|
||||
toggleAdminPanel() {
|
||||
this.showAdminPanel.update(v => !v);
|
||||
this.showAdminPanel.update((current) => !current);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,25 +126,12 @@
|
||||
<div class="ml-5 mt-1 space-y-1">
|
||||
@for (u of voiceUsersInRoom(ch.id); track u.id) {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
|
||||
@if (u.avatarUrl) {
|
||||
<img
|
||||
[src]="u.avatarUrl"
|
||||
alt=""
|
||||
class="w-7 h-7 rounded-full ring-2 object-cover"
|
||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||
<app-user-avatar
|
||||
[name]="u.displayName"
|
||||
[avatarUrl]="u.avatarUrl"
|
||||
size="xs"
|
||||
[ringClass]="u.voiceState?.isDeafened ? 'ring-2 ring-red-500' : u.voiceState?.isMuted ? 'ring-2 ring-yellow-500' : 'ring-2 ring-green-500'"
|
||||
/>
|
||||
} @else {
|
||||
<div
|
||||
class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-medium ring-2"
|
||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||
>
|
||||
{{ u.displayName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
|
||||
<button
|
||||
@@ -177,13 +164,7 @@
|
||||
<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="relative">
|
||||
@if (currentUser()?.avatarUrl) {
|
||||
<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>
|
||||
}
|
||||
<app-user-avatar [name]="currentUser()?.displayName || '?'" [avatarUrl]="currentUser()?.avatarUrl" size="sm" />
|
||||
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -220,13 +201,7 @@
|
||||
(contextmenu)="openUserContextMenu($event, user)"
|
||||
>
|
||||
<div class="relative">
|
||||
@if (user.avatarUrl) {
|
||||
<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>
|
||||
}
|
||||
<app-user-avatar [name]="user.displayName" [avatarUrl]="user.avatarUrl" size="sm" />
|
||||
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -283,66 +258,47 @@
|
||||
|
||||
<!-- Channel context menu -->
|
||||
@if (showChannelMenu()) {
|
||||
<div class="fixed inset-0 z-40" (click)="closeChannelMenu()"></div>
|
||||
<div class="fixed z-50 bg-card border border-border rounded-lg shadow-lg w-44 py-1" [style.left.px]="channelMenuX()" [style.top.px]="channelMenuY()">
|
||||
<button (click)="resyncMessages()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
|
||||
Resync Messages
|
||||
</button>
|
||||
<app-context-menu [x]="channelMenuX()" [y]="channelMenuY()" (closed)="closeChannelMenu()" [width]="'w-44'">
|
||||
<button (click)="resyncMessages()" class="context-menu-item">Resync Messages</button>
|
||||
@if (canManageChannels()) {
|
||||
<div class="border-t border-border my-1"></div>
|
||||
<button (click)="startRename()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
|
||||
Rename Channel
|
||||
</button>
|
||||
<button (click)="deleteChannel()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-destructive">
|
||||
Delete Channel
|
||||
</button>
|
||||
<div class="context-menu-divider"></div>
|
||||
<button (click)="startRename()" class="context-menu-item">Rename Channel</button>
|
||||
<button (click)="deleteChannel()" class="context-menu-item-danger">Delete Channel</button>
|
||||
}
|
||||
</div>
|
||||
</app-context-menu>
|
||||
}
|
||||
|
||||
<!-- User context menu (kick / role management) -->
|
||||
@if (showUserMenu()) {
|
||||
<div class="fixed inset-0 z-40" (click)="closeUserMenu()"></div>
|
||||
<div class="fixed z-50 bg-card border border-border rounded-lg shadow-lg w-48 py-1" [style.left.px]="userMenuX()" [style.top.px]="userMenuY()">
|
||||
<app-context-menu [x]="userMenuX()" [y]="userMenuY()" (closed)="closeUserMenu()">
|
||||
@if (isAdmin()) {
|
||||
<!-- Role management -->
|
||||
@if (contextMenuUser()?.role === 'member') {
|
||||
<button (click)="changeUserRole('moderator')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
|
||||
Promote to Moderator
|
||||
</button>
|
||||
<button (click)="changeUserRole('admin')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
|
||||
Promote to Admin
|
||||
</button>
|
||||
<button (click)="changeUserRole('moderator')" class="context-menu-item">Promote to Moderator</button>
|
||||
<button (click)="changeUserRole('admin')" class="context-menu-item">Promote to Admin</button>
|
||||
}
|
||||
@if (contextMenuUser()?.role === 'moderator') {
|
||||
<button (click)="changeUserRole('admin')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
|
||||
Promote to Admin
|
||||
</button>
|
||||
<button (click)="changeUserRole('member')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
|
||||
Demote to Member
|
||||
</button>
|
||||
<button (click)="changeUserRole('admin')" class="context-menu-item">Promote to Admin</button>
|
||||
<button (click)="changeUserRole('member')" class="context-menu-item">Demote to Member</button>
|
||||
}
|
||||
@if (contextMenuUser()?.role === 'admin') {
|
||||
<button (click)="changeUserRole('member')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
|
||||
Demote to Member
|
||||
</button>
|
||||
<button (click)="changeUserRole('member')" class="context-menu-item">Demote to Member</button>
|
||||
}
|
||||
<div class="border-t border-border my-1"></div>
|
||||
<button (click)="kickUserAction()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-destructive">
|
||||
Kick User
|
||||
</button>
|
||||
<div class="context-menu-divider"></div>
|
||||
<button (click)="kickUserAction()" class="context-menu-item-danger">Kick User</button>
|
||||
} @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 -->
|
||||
@if (showCreateChannelDialog()) {
|
||||
<div class="fixed inset-0 z-40 bg-black/30" (click)="cancelCreateChannel()"></div>
|
||||
<div class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg w-[320px]">
|
||||
<div class="p-4">
|
||||
<h4 class="font-semibold text-foreground mb-3">Create {{ createChannelType() === 'text' ? 'Text' : 'Voice' }} Channel</h4>
|
||||
<app-confirm-dialog
|
||||
[title]="'Create ' + (createChannelType() === 'text' ? 'Text' : 'Voice') + ' Channel'"
|
||||
confirmLabel="Create"
|
||||
(confirmed)="confirmCreateChannel()"
|
||||
(cancelled)="cancelCreateChannel()"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newChannelName"
|
||||
@@ -350,10 +306,5 @@
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary text-sm"
|
||||
(keydown.enter)="confirmCreateChannel()"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 p-3 border-t border-border">
|
||||
<button (click)="cancelCreateChannel()" class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm">Cancel</button>
|
||||
<button (click)="confirmCreateChannel()" class="flex-1 px-3 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors text-sm">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</app-confirm-dialog>
|
||||
}
|
||||
|
||||
@@ -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 { selectOnlineUsers, selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||
import { selectCurrentRoom, selectActiveChannelId, selectTextChannels, selectVoiceChannels } from '../../../store/rooms/rooms.selectors';
|
||||
import * as UsersActions from '../../../store/users/users.actions';
|
||||
import * as RoomsActions from '../../../store/rooms/rooms.actions';
|
||||
import * as MessagesActions from '../../../store/messages/messages.actions';
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||
import { MessagesActions } from '../../../store/messages/messages.actions';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
||||
import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component';
|
||||
import { ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||
import { Channel, User } from '../../../core/models';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@@ -20,12 +21,15 @@ type TabView = 'channels' | 'users';
|
||||
@Component({
|
||||
selector: 'app-rooms-side-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon, VoiceControlsComponent],
|
||||
imports: [CommonModule, FormsModule, NgIcon, VoiceControlsComponent, ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent],
|
||||
viewProviders: [
|
||||
provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers, lucidePlus })
|
||||
],
|
||||
templateUrl: './rooms-side-panel.component.html',
|
||||
})
|
||||
/**
|
||||
* Side panel listing text and voice channels, online users, and channel management actions.
|
||||
*/
|
||||
export class RoomsSidePanelComponent {
|
||||
private store = inject(Store);
|
||||
private webrtc = inject(WebRTCService);
|
||||
@@ -61,14 +65,16 @@ export class RoomsSidePanelComponent {
|
||||
userMenuY = signal(0);
|
||||
contextMenuUser = signal<User | null>(null);
|
||||
|
||||
/** Return online users excluding the current user. */
|
||||
// Filter out current user from online users list
|
||||
onlineUsersFiltered() {
|
||||
const current = this.currentUser();
|
||||
const currentId = current?.id;
|
||||
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 {
|
||||
const room = this.currentRoom();
|
||||
const user = this.currentUser();
|
||||
@@ -81,12 +87,14 @@ export class RoomsSidePanelComponent {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Select a text channel (no-op if currently renaming). */
|
||||
// ---- Text channel selection ----
|
||||
selectTextChannel(channelId: string) {
|
||||
if (this.renamingChannelId()) return; // don't switch while renaming
|
||||
this.store.dispatch(RoomsActions.selectChannel({ channelId }));
|
||||
}
|
||||
|
||||
/** Open the context menu for a channel at the cursor position. */
|
||||
// ---- Channel context menu ----
|
||||
openChannelContextMenu(evt: MouseEvent, channel: Channel) {
|
||||
evt.preventDefault();
|
||||
@@ -96,10 +104,12 @@ export class RoomsSidePanelComponent {
|
||||
this.showChannelMenu.set(true);
|
||||
}
|
||||
|
||||
/** Close the channel context menu. */
|
||||
closeChannelMenu() {
|
||||
this.showChannelMenu.set(false);
|
||||
}
|
||||
|
||||
/** Begin inline renaming of the context-menu channel. */
|
||||
startRename() {
|
||||
const ch = this.contextChannel();
|
||||
this.closeChannelMenu();
|
||||
@@ -108,6 +118,7 @@ export class RoomsSidePanelComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Commit the channel rename from the inline input value. */
|
||||
confirmRename(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const name = input.value.trim();
|
||||
@@ -118,10 +129,12 @@ export class RoomsSidePanelComponent {
|
||||
this.renamingChannelId.set(null);
|
||||
}
|
||||
|
||||
/** Cancel the inline rename operation. */
|
||||
cancelRename() {
|
||||
this.renamingChannelId.set(null);
|
||||
}
|
||||
|
||||
/** Delete the context-menu channel. */
|
||||
deleteChannel() {
|
||||
const ch = this.contextChannel();
|
||||
this.closeChannelMenu();
|
||||
@@ -130,11 +143,11 @@ export class RoomsSidePanelComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Trigger a message inventory re-sync from all connected peers. */
|
||||
resyncMessages() {
|
||||
this.closeChannelMenu();
|
||||
const room = this.currentRoom();
|
||||
if (!room) {
|
||||
console.warn('[Resync] No current room');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -143,19 +156,19 @@ export class RoomsSidePanelComponent {
|
||||
|
||||
// Request inventory from all connected peers
|
||||
const peers = this.webrtc.getConnectedPeers();
|
||||
console.log(`[Resync] Requesting inventory from ${peers.length} peer(s) for room ${room.id}`);
|
||||
if (peers.length === 0) {
|
||||
console.warn('[Resync] No connected peers — sync will time out');
|
||||
// No connected peers — sync will time out
|
||||
}
|
||||
peers.forEach((pid) => {
|
||||
try {
|
||||
this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: room.id } as any);
|
||||
} catch (e) {
|
||||
console.error(`[Resync] Failed to send to peer ${pid}:`, e);
|
||||
} catch (_error) {
|
||||
// Failed to send inventory request to this peer
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Open the create-channel dialog for the given channel type. */
|
||||
// ---- Create channel ----
|
||||
createChannel(type: 'text' | 'voice') {
|
||||
this.createChannelType.set(type);
|
||||
@@ -163,6 +176,7 @@ export class RoomsSidePanelComponent {
|
||||
this.showCreateChannelDialog.set(true);
|
||||
}
|
||||
|
||||
/** Confirm channel creation and dispatch the add-channel action. */
|
||||
confirmCreateChannel() {
|
||||
const name = this.newChannelName.trim();
|
||||
if (!name) return;
|
||||
@@ -178,10 +192,12 @@ export class RoomsSidePanelComponent {
|
||||
this.showCreateChannelDialog.set(false);
|
||||
}
|
||||
|
||||
/** Cancel channel creation and close the dialog. */
|
||||
cancelCreateChannel() {
|
||||
this.showCreateChannelDialog.set(false);
|
||||
}
|
||||
|
||||
/** Open the user context menu for admin actions (kick/role change). */
|
||||
// ---- User context menu (kick/role) ----
|
||||
openUserContextMenu(evt: MouseEvent, user: User) {
|
||||
evt.preventDefault();
|
||||
@@ -192,10 +208,12 @@ export class RoomsSidePanelComponent {
|
||||
this.showUserMenu.set(true);
|
||||
}
|
||||
|
||||
/** Close the user context menu. */
|
||||
closeUserMenu() {
|
||||
this.showUserMenu.set(false);
|
||||
}
|
||||
|
||||
/** Change a user's role and broadcast the update to connected peers. */
|
||||
changeUserRole(role: 'admin' | 'moderator' | 'member') {
|
||||
const user = this.contextMenuUser();
|
||||
this.closeUserMenu();
|
||||
@@ -210,6 +228,7 @@ export class RoomsSidePanelComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Kick a user and broadcast the action to peers. */
|
||||
kickUserAction() {
|
||||
const user = this.contextMenuUser();
|
||||
this.closeUserMenu();
|
||||
@@ -224,12 +243,13 @@ export class RoomsSidePanelComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Join a voice channel, managing permissions and existing voice connections. */
|
||||
// ---- Voice ----
|
||||
joinVoice(roomId: string) {
|
||||
// Gate by room permissions
|
||||
const room = this.currentRoom();
|
||||
if (room && room.permissions && room.permissions.allowVoice === false) {
|
||||
console.warn('Voice is disabled by room permissions');
|
||||
// Voice is disabled by room permissions
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -248,7 +268,7 @@ export class RoomsSidePanelComponent {
|
||||
}));
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@@ -280,8 +300,8 @@ export class RoomsSidePanelComponent {
|
||||
// Update voice session for floating controls
|
||||
if (room) {
|
||||
// Find label from channel list
|
||||
const vc = this.voiceChannels().find(c => c.id === roomId);
|
||||
const voiceRoomName = vc ? `🔊 ${vc.name}` : roomId;
|
||||
const voiceChannel = this.voiceChannels().find(channel => channel.id === roomId);
|
||||
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
|
||||
this.voiceSessionService.startSession({
|
||||
serverId: room.id,
|
||||
serverName: room.name,
|
||||
@@ -292,9 +312,12 @@ export class RoomsSidePanelComponent {
|
||||
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) {
|
||||
const current = this.currentUser();
|
||||
// Only leave if currently in this room
|
||||
@@ -326,32 +349,36 @@ export class RoomsSidePanelComponent {
|
||||
this.voiceSessionService.endSession();
|
||||
}
|
||||
|
||||
/** Count the number of users connected to a voice channel in the current room. */
|
||||
voiceOccupancy(roomId: string): number {
|
||||
const users = this.onlineUsers();
|
||||
const room = this.currentRoom();
|
||||
return users.filter(u =>
|
||||
!!u.voiceState?.isConnected &&
|
||||
u.voiceState?.roomId === roomId &&
|
||||
u.voiceState?.serverId === room?.id
|
||||
return users.filter(user =>
|
||||
!!user.voiceState?.isConnected &&
|
||||
user.voiceState?.roomId === roomId &&
|
||||
user.voiceState?.serverId === room?.id
|
||||
).length;
|
||||
}
|
||||
|
||||
/** Dispatch a viewer:focus event to display a remote user's screen share. */
|
||||
viewShare(userId: string) {
|
||||
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
||||
window.dispatchEvent(evt);
|
||||
}
|
||||
|
||||
/** Dispatch a viewer:focus event to display a remote user's stream. */
|
||||
viewStream(userId: string) {
|
||||
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
||||
window.dispatchEvent(evt);
|
||||
}
|
||||
|
||||
/** Check whether a user is currently sharing their screen. */
|
||||
isUserSharing(userId: string): boolean {
|
||||
const me = this.currentUser();
|
||||
if (me?.id === userId) {
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
@@ -359,15 +386,17 @@ export class RoomsSidePanelComponent {
|
||||
return !!stream && stream.getVideoTracks().length > 0;
|
||||
}
|
||||
|
||||
/** Return all users currently connected to a specific voice channel. */
|
||||
voiceUsersInRoom(roomId: string) {
|
||||
const room = this.currentRoom();
|
||||
return this.onlineUsers().filter(u =>
|
||||
!!u.voiceState?.isConnected &&
|
||||
u.voiceState?.roomId === roomId &&
|
||||
u.voiceState?.serverId === room?.id
|
||||
return this.onlineUsers().filter(user =>
|
||||
!!user.voiceState?.isConnected &&
|
||||
user.voiceState?.roomId === roomId &&
|
||||
user.voiceState?.serverId === room?.id
|
||||
);
|
||||
}
|
||||
|
||||
/** Check whether the current user is connected to the specified voice channel. */
|
||||
isCurrentRoom(roomId: string): boolean {
|
||||
const me = this.currentUser();
|
||||
const room = this.currentRoom();
|
||||
@@ -378,6 +407,7 @@ export class RoomsSidePanelComponent {
|
||||
);
|
||||
}
|
||||
|
||||
/** Check whether voice is enabled by the current room's permissions. */
|
||||
voiceEnabled(): boolean {
|
||||
const room = this.currentRoom();
|
||||
return room?.permissions?.allowVoice !== false;
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
lucideSettings,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import * as RoomsActions from '../../store/rooms/rooms.actions';
|
||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||
import {
|
||||
selectSearchResults,
|
||||
selectIsSearching,
|
||||
@@ -33,6 +33,10 @@ import { ServerInfo } from '../../core/models';
|
||||
],
|
||||
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 {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
@@ -52,6 +56,7 @@ export class ServerSearchComponent implements OnInit {
|
||||
newServerPrivate = signal(false);
|
||||
newServerPassword = signal('');
|
||||
|
||||
/** Initialize server search, load saved rooms, and set up debounced search. */
|
||||
ngOnInit(): void {
|
||||
// Initial load
|
||||
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 {
|
||||
this.searchSubject.next(query);
|
||||
}
|
||||
|
||||
/** Join a server from the search results. Redirects to login if unauthenticated. */
|
||||
joinServer(server: ServerInfo): void {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
if (!currentUserId) {
|
||||
@@ -85,15 +92,18 @@ export class ServerSearchComponent implements OnInit {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Open the create-server dialog. */
|
||||
openCreateDialog(): void {
|
||||
this.showCreateDialog.set(true);
|
||||
}
|
||||
|
||||
/** Close the create-server dialog and reset the form. */
|
||||
closeCreateDialog(): void {
|
||||
this.showCreateDialog.set(false);
|
||||
this.resetCreateForm();
|
||||
}
|
||||
|
||||
/** Submit the new server creation form and dispatch the create action. */
|
||||
createServer(): void {
|
||||
if (!this.newServerName()) return;
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
@@ -115,10 +125,12 @@ export class ServerSearchComponent implements OnInit {
|
||||
this.closeCreateDialog();
|
||||
}
|
||||
|
||||
/** Navigate to the application settings page. */
|
||||
openSettings(): void {
|
||||
this.router.navigate(['/settings']);
|
||||
}
|
||||
|
||||
/** Join a previously saved room by converting it to a ServerInfo payload. */
|
||||
joinSavedRoom(room: Room): void {
|
||||
this.joinServer({
|
||||
id: room.id,
|
||||
|
||||
@@ -10,48 +10,44 @@
|
||||
|
||||
<!-- Saved servers icons -->
|
||||
<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
|
||||
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"
|
||||
(click)="joinSavedRoom(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" />
|
||||
</ng-container>
|
||||
<ng-template #noIcon>
|
||||
} @else {
|
||||
<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>
|
||||
</div>
|
||||
</ng-template>
|
||||
}
|
||||
</button>
|
||||
</ng-container>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Context menu -->
|
||||
<div *ngIf="showMenu()" class="">
|
||||
<div class="fixed inset-0 z-40" (click)="closeMenu()"></div>
|
||||
<div class="fixed z-50 bg-card border border-border rounded-lg shadow-md w-44" [style.left.px]="menuX()" [style.top.px]="menuY()">
|
||||
<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)="openForgetConfirm()" class="w-full text-left px-3 py-2 hover:bg-secondary transition-colors text-foreground">Forget Server</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (showMenu()) {
|
||||
<app-context-menu [x]="menuX()" [y]="menuY()" (closed)="closeMenu()" [width]="'w-44'">
|
||||
@if (isCurrentContextRoom()) {
|
||||
<button (click)="leaveServer()" class="context-menu-item">Leave Server</button>
|
||||
}
|
||||
<button (click)="openForgetConfirm()" class="context-menu-item">Forget Server</button>
|
||||
</app-context-menu>
|
||||
}
|
||||
|
||||
<!-- Forget confirmation dialog -->
|
||||
<div *ngIf="showConfirm()">
|
||||
<div class="fixed inset-0 z-40 bg-black/30" (click)="cancelForget()"></div>
|
||||
<div class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg w-[280px]">
|
||||
<div class="p-4">
|
||||
<h4 class="font-semibold text-foreground mb-2">Forget Server?</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Remove <span class="font-medium text-foreground">{{ contextRoom()?.name }}</span> from your My Servers list.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2 p-3 border-t border-border">
|
||||
<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>
|
||||
@if (showConfirm()) {
|
||||
<app-confirm-dialog
|
||||
title="Forget Server?"
|
||||
confirmLabel="Forget"
|
||||
(confirmed)="confirmForget()"
|
||||
(cancelled)="cancelForget()"
|
||||
[widthClass]="'w-[280px]'"
|
||||
>
|
||||
<p>Remove <span class="font-medium text-foreground">{{ contextRoom()?.name }}</span> from your My Servers list.</p>
|
||||
</app-confirm-dialog>
|
||||
}
|
||||
|
||||
@@ -9,15 +9,19 @@ import { Room } from '../../core/models';
|
||||
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||
import { VoiceSessionService } from '../../core/services/voice-session.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({
|
||||
selector: 'app-servers-rail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
imports: [CommonModule, NgIcon, ContextMenuComponent, ConfirmDialogComponent],
|
||||
viewProviders: [provideIcons({ lucidePlus })],
|
||||
templateUrl: './servers-rail.component.html',
|
||||
})
|
||||
/**
|
||||
* Vertical rail of saved server icons with context-menu actions for leaving/forgetting.
|
||||
*/
|
||||
export class ServersRailComponent {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
@@ -28,12 +32,13 @@ export class ServersRailComponent {
|
||||
|
||||
// Context menu state
|
||||
showMenu = signal(false);
|
||||
menuX = signal(72); // rail width (~64px) + padding, position menu to the right
|
||||
menuY = signal(100);
|
||||
menuX = signal(72); // default X: rail width (~64px) + padding
|
||||
menuY = signal(100); // default Y: arbitrary initial offset
|
||||
contextRoom = signal<Room | null>(null);
|
||||
// Confirmation dialog state
|
||||
showConfirm = signal(false);
|
||||
|
||||
/** Return the first character of a server name as its icon initial. */
|
||||
initial(name?: string): string {
|
||||
if (!name) return '?';
|
||||
const ch = name.trim()[0]?.toUpperCase();
|
||||
@@ -42,6 +47,7 @@ export class ServersRailComponent {
|
||||
|
||||
trackRoomId = (index: number, room: Room) => room.id;
|
||||
|
||||
/** Navigate to the server search view. Updates voice session state if applicable. */
|
||||
createServer(): void {
|
||||
// Navigate to server list (has create button)
|
||||
// Update voice session state if connected to voice
|
||||
@@ -52,6 +58,7 @@ export class ServersRailComponent {
|
||||
this.router.navigate(['/search']);
|
||||
}
|
||||
|
||||
/** Join or switch to a saved room. Manages voice session and authentication state. */
|
||||
joinSavedRoom(room: Room): void {
|
||||
// Require auth: if no current user, go to login
|
||||
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 {
|
||||
evt.preventDefault();
|
||||
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.menuY.set(evt.clientY);
|
||||
this.showMenu.set(true);
|
||||
}
|
||||
|
||||
/** Close the context menu (keeps contextRoom for potential confirmation). */
|
||||
closeMenu(): void {
|
||||
this.showMenu.set(false);
|
||||
// keep contextRoom for potential confirmation dialog
|
||||
}
|
||||
|
||||
/** Check whether the context-menu room is the currently active room. */
|
||||
isCurrentContextRoom(): boolean {
|
||||
const ctx = this.contextRoom();
|
||||
const cur = this.currentRoom();
|
||||
return !!ctx && !!cur && ctx.id === cur.id;
|
||||
}
|
||||
|
||||
/** Leave the current server and navigate to the servers list. */
|
||||
leaveServer(): void {
|
||||
this.closeMenu();
|
||||
this.store.dispatch(RoomsActions.leaveRoom());
|
||||
window.dispatchEvent(new CustomEvent('navigate:servers'));
|
||||
}
|
||||
|
||||
/** Show the forget-server confirmation dialog. */
|
||||
openForgetConfirm(): void {
|
||||
this.showConfirm.set(true);
|
||||
this.closeMenu();
|
||||
}
|
||||
|
||||
/** Forget (remove) a server from the saved list, leaving if it is the current room. */
|
||||
confirmForget(): void {
|
||||
const ctx = this.contextRoom();
|
||||
if (!ctx) return;
|
||||
@@ -131,6 +144,7 @@ export class ServersRailComponent {
|
||||
this.contextRoom.set(null);
|
||||
}
|
||||
|
||||
/** Cancel the forget-server confirmation dialog. */
|
||||
cancelForget(): void {
|
||||
this.showConfirm.set(false);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../core/constants';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
@@ -36,6 +37,9 @@ import { ServerDirectoryService } from '../../core/services/server-directory.ser
|
||||
],
|
||||
templateUrl: './settings.component.html',
|
||||
})
|
||||
/**
|
||||
* Settings page for managing signaling servers and connection preferences.
|
||||
*/
|
||||
export class SettingsComponent implements OnInit {
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
private router = inject(Router);
|
||||
@@ -49,10 +53,12 @@ export class SettingsComponent implements OnInit {
|
||||
autoReconnect = true;
|
||||
searchAllServers = true;
|
||||
|
||||
/** Load persisted connection settings on component init. */
|
||||
ngOnInit(): void {
|
||||
this.loadConnectionSettings();
|
||||
}
|
||||
|
||||
/** Add a new signaling server after URL validation and duplicate checking. */
|
||||
addServer(): void {
|
||||
this.addError.set(null);
|
||||
|
||||
@@ -65,7 +71,7 @@ export class SettingsComponent implements OnInit {
|
||||
}
|
||||
|
||||
// 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');
|
||||
return;
|
||||
}
|
||||
@@ -87,22 +93,26 @@ export class SettingsComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a signaling server by its ID. */
|
||||
removeServer(id: string): void {
|
||||
this.serverDirectory.removeServer(id);
|
||||
}
|
||||
|
||||
/** Set the active signaling server used for connections. */
|
||||
setActiveServer(id: string): void {
|
||||
this.serverDirectory.setActiveServer(id);
|
||||
}
|
||||
|
||||
/** Test connectivity to all configured servers. */
|
||||
async testAllServers(): Promise<void> {
|
||||
this.isTesting.set(true);
|
||||
await this.serverDirectory.testAllServers();
|
||||
this.isTesting.set(false);
|
||||
}
|
||||
|
||||
/** Load connection settings (auto-reconnect, search scope) from localStorage. */
|
||||
loadConnectionSettings(): void {
|
||||
const settings = localStorage.getItem('metoyou_connection_settings');
|
||||
const settings = localStorage.getItem(STORAGE_KEY_CONNECTION_SETTINGS);
|
||||
if (settings) {
|
||||
const parsed = JSON.parse(settings);
|
||||
this.autoReconnect = parsed.autoReconnect ?? true;
|
||||
@@ -111,9 +121,10 @@ export class SettingsComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist current connection settings to localStorage. */
|
||||
saveConnectionSettings(): void {
|
||||
localStorage.setItem(
|
||||
'metoyou_connection_settings',
|
||||
STORAGE_KEY_CONNECTION_SETTINGS,
|
||||
JSON.stringify({
|
||||
autoReconnect: this.autoReconnect,
|
||||
searchAllServers: this.searchAllServers,
|
||||
@@ -122,6 +133,7 @@ export class SettingsComponent implements OnInit {
|
||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||
}
|
||||
|
||||
/** Navigate back to the main page. */
|
||||
goBack(): void {
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,62 @@
|
||||
<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
|
||||
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 class="flex items-center gap-2 min-w-0 relative" style="-webkit-app-region: no-drag;">
|
||||
<button *ngIf="inRoom()" (click)="onBack()" class="p-2 hover:bg-secondary rounded" title="Back">
|
||||
@if (inRoom()) {
|
||||
<button (click)="onBack()" class="p-2 hover:bg-secondary rounded" title="Back">
|
||||
<ng-icon name="lucideChevronLeft" class="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
<ng-container *ngIf="inRoom(); else userServer">
|
||||
}
|
||||
@if (inRoom()) {
|
||||
<ng-icon name="lucideHash" class="w-5 h-5 text-muted-foreground" />
|
||||
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
|
||||
<span *ngIf="roomDescription()" class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">{{ roomDescription() }}</span>
|
||||
@if (roomDescription()) {
|
||||
<span class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">
|
||||
{{ roomDescription() }}
|
||||
</span>
|
||||
}
|
||||
<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" />
|
||||
</button>
|
||||
<!-- Anchored dropdown under the menu button -->
|
||||
<div *ngIf="showMenu()" class="absolute right-0 top-full mt-1 z-50 bg-card border border-border rounded-lg shadow-lg w-48">
|
||||
<button (click)="leaveServer()" class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground">Leave Server</button>
|
||||
@if (showMenu()) {
|
||||
<div class="absolute right-0 top-full mt-1 z-50 bg-card border border-border rounded-lg shadow-lg w-48">
|
||||
<button
|
||||
(click)="leaveServer()"
|
||||
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground"
|
||||
>
|
||||
Leave Server
|
||||
</button>
|
||||
<div class="border-t border-border"></div>
|
||||
<button (click)="logout()" class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground">Logout</button>
|
||||
<button
|
||||
(click)="logout()"
|
||||
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #userServer>
|
||||
}
|
||||
} @else {
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-sm text-muted-foreground truncate">{{ username() }} | {{ serverName() }}</span>
|
||||
<span *ngIf="!isConnected()" class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive">Reconnecting…</span>
|
||||
@if (isReconnecting()) {
|
||||
<span class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive">Reconnecting…</span>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2" style="-webkit-app-region: no-drag;">
|
||||
<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>
|
||||
@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>
|
||||
@@ -35,7 +66,10 @@
|
||||
<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>
|
||||
<!-- 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 { Router } from '@angular/router';
|
||||
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 { ServerDirectoryService } from '../../core/services/server-directory.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({
|
||||
selector: 'app-title-bar',
|
||||
@@ -17,17 +19,24 @@ import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
viewProviders: [provideIcons({ lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu })],
|
||||
templateUrl: './title-bar.component.html',
|
||||
})
|
||||
/**
|
||||
* Electron-style title bar with window controls, navigation, and server menu.
|
||||
*/
|
||||
export class TitleBarComponent {
|
||||
private store = inject(Store);
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
private router = inject(Router);
|
||||
private webrtc = inject(WebRTCService);
|
||||
private platform = inject(PlatformService);
|
||||
|
||||
isElectron = computed(() => this.platform.isElectron);
|
||||
showMenuState = computed(() => false);
|
||||
|
||||
private currentUserSig = this.store.selectSignal(selectCurrentUser);
|
||||
username = computed(() => this.currentUserSig()?.displayName || 'Guest');
|
||||
serverName = computed(() => this.serverDirectory.activeServer()?.name || 'No Server');
|
||||
isConnected = computed(() => this.webrtc.isConnected());
|
||||
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
|
||||
isAuthed = computed(() => !!this.currentUserSig());
|
||||
private currentRoomSig = this.store.selectSignal(selectCurrentRoom);
|
||||
inRoom = computed(() => !!this.currentRoomSig());
|
||||
@@ -36,52 +45,61 @@ export class TitleBarComponent {
|
||||
private _showMenu = signal(false);
|
||||
showMenu = computed(() => this._showMenu());
|
||||
|
||||
/** Minimize the Electron window. */
|
||||
minimize() {
|
||||
const api = (window as any).electronAPI;
|
||||
if (api?.minimizeWindow) api.minimizeWindow();
|
||||
}
|
||||
|
||||
/** Maximize or restore the Electron window. */
|
||||
maximize() {
|
||||
const api = (window as any).electronAPI;
|
||||
if (api?.maximizeWindow) api.maximizeWindow();
|
||||
}
|
||||
|
||||
/** Close the Electron window. */
|
||||
close() {
|
||||
const api = (window as any).electronAPI;
|
||||
if (api?.closeWindow) api.closeWindow();
|
||||
}
|
||||
|
||||
/** Navigate to the login page. */
|
||||
goLogin() {
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
/** Leave the current room and navigate back to the server search. */
|
||||
onBack() {
|
||||
// Leave room to ensure header switches to user/server view
|
||||
this.store.dispatch(RoomsActions.leaveRoom());
|
||||
this.router.navigate(['/search']);
|
||||
}
|
||||
|
||||
/** Toggle the server dropdown menu. */
|
||||
toggleMenu() {
|
||||
this._showMenu.set(!this._showMenu());
|
||||
}
|
||||
|
||||
/** Leave the current server and navigate to the servers list. */
|
||||
leaveServer() {
|
||||
this._showMenu.set(false);
|
||||
this.store.dispatch(RoomsActions.leaveRoom());
|
||||
window.dispatchEvent(new CustomEvent('navigate:servers'));
|
||||
}
|
||||
|
||||
/** Close the server dropdown menu. */
|
||||
closeMenu() {
|
||||
this._showMenu.set(false);
|
||||
}
|
||||
|
||||
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
||||
logout() {
|
||||
this._showMenu.set(false);
|
||||
// Disconnect from signaling server – this broadcasts "user_left" to all
|
||||
// servers the user was a member of, so other users see them go offline.
|
||||
this.webrtc.disconnect();
|
||||
try {
|
||||
localStorage.removeItem('metoyou_currentUserId');
|
||||
localStorage.removeItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||
} catch {}
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
|
||||
import { WebRTCService } from '../../../core/services/webrtc.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';
|
||||
|
||||
@Component({
|
||||
@@ -34,6 +34,10 @@ import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
],
|
||||
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 {
|
||||
private webrtcService = inject(WebRTCService);
|
||||
private voiceSessionService = inject(VoiceSessionService);
|
||||
@@ -52,6 +56,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
private stateSubscription: Subscription | null = null;
|
||||
|
||||
/** Sync local mute/deafen/screen-share state from the WebRTC service on init. */
|
||||
ngOnInit(): void {
|
||||
// Sync mute/deafen state from webrtc service
|
||||
this.isMuted.set(this.webrtcService.isMuted());
|
||||
@@ -63,12 +68,14 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
this.stateSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
/** Navigate back to the voice-connected server. */
|
||||
navigateToServer(): void {
|
||||
this.voiceSessionService.navigateToVoiceServer();
|
||||
}
|
||||
|
||||
/** Toggle microphone mute and broadcast the updated voice state. */
|
||||
toggleMute(): void {
|
||||
this.isMuted.update(v => !v);
|
||||
this.isMuted.update((current) => !current);
|
||||
this.webrtcService.toggleMute(this.isMuted());
|
||||
|
||||
// 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 {
|
||||
this.isDeafened.update(v => !v);
|
||||
this.isDeafened.update((current) => !current);
|
||||
this.webrtcService.toggleDeafen(this.isDeafened());
|
||||
|
||||
// When deafening, also mute
|
||||
@@ -107,6 +115,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle screen sharing on or off. */
|
||||
async toggleScreenShare(): Promise<void> {
|
||||
if (this.isScreenSharing()) {
|
||||
this.webrtcService.stopScreenShare();
|
||||
@@ -115,12 +124,13 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
try {
|
||||
await this.webrtcService.startScreenShare(false);
|
||||
this.isScreenSharing.set(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to start screen share:', error);
|
||||
} catch (_error) {
|
||||
// Screen share request was denied or failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Disconnect from the voice session entirely, cleaning up all voice state. */
|
||||
disconnect(): void {
|
||||
// Stop voice heartbeat
|
||||
this.webrtcService.stopVoiceHeartbeat();
|
||||
@@ -163,6 +173,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
this.isDeafened.set(false);
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the compact control button based on active state. */
|
||||
getCompactButtonClass(isActive: boolean): string {
|
||||
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
|
||||
if (isActive) {
|
||||
@@ -171,6 +182,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the compact screen-share button. */
|
||||
getCompactScreenShareClass(): string {
|
||||
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
|
||||
if (this.isScreenSharing()) {
|
||||
@@ -179,6 +191,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the mute toggle button. */
|
||||
getMuteButtonClass(): string {
|
||||
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
||||
if (this.isMuted()) {
|
||||
@@ -187,6 +200,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the deafen toggle button. */
|
||||
getDeafenButtonClass(): string {
|
||||
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
||||
if (this.isDeafened()) {
|
||||
@@ -195,6 +209,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the screen-share toggle button. */
|
||||
getScreenShareButtonClass(): string {
|
||||
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
||||
if (this.isScreenSharing()) {
|
||||
|
||||
@@ -19,12 +19,13 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-white">
|
||||
<ng-icon name="lucideMonitor" class="w-4 h-4" />
|
||||
<span class="text-sm font-medium" *ngIf="activeScreenSharer(); else sharingUnknown">
|
||||
@if (activeScreenSharer()) {
|
||||
<span class="text-sm font-medium">
|
||||
{{ activeScreenSharer()?.displayName }} is sharing their screen
|
||||
</span>
|
||||
<ng-template #sharingUnknown>
|
||||
} @else {
|
||||
<span class="text-sm font-medium">Someone is sharing their screen</span>
|
||||
</ng-template>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Viewer volume -->
|
||||
@@ -71,10 +72,12 @@
|
||||
</div>
|
||||
|
||||
<!-- No Stream Placeholder -->
|
||||
<div *ngIf="!hasStream()" class="absolute inset-0 flex items-center justify-center bg-secondary">
|
||||
@if (!hasStream()) {
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-secondary">
|
||||
<div class="text-center text-muted-foreground">
|
||||
<ng-icon name="lucideMonitor" class="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>Waiting for screen share...</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { selectOnlineUsers } from '../../../store/users/users.selectors';
|
||||
import { User } from '../../../core/models';
|
||||
import { DEFAULT_VOLUME } from '../../../core/constants';
|
||||
|
||||
@Component({
|
||||
selector: 'app-screen-share-viewer',
|
||||
@@ -28,6 +29,10 @@ import { User } from '../../../core/models';
|
||||
],
|
||||
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 {
|
||||
@ViewChild('screenVideo') videoRef!: ElementRef<HTMLVideoElement>;
|
||||
|
||||
@@ -43,7 +48,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
isFullscreen = signal(false);
|
||||
hasStream = signal(false);
|
||||
isLocalShare = signal(false);
|
||||
screenVolume = signal(100);
|
||||
screenVolume = signal(DEFAULT_VOLUME);
|
||||
|
||||
private streamSubscription: (() => void) | null = null;
|
||||
private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => {
|
||||
@@ -51,7 +56,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
const userId = evt.detail?.userId;
|
||||
if (!userId) return;
|
||||
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 (user) {
|
||||
this.setRemoteStream(stream, user);
|
||||
@@ -63,8 +68,8 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
this.isLocalShare.set(false);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to focus viewer on user stream:', e);
|
||||
} catch (_error) {
|
||||
// Failed to focus viewer on user stream
|
||||
}
|
||||
};
|
||||
|
||||
@@ -95,7 +100,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
if (!watchingId || !isWatchingRemote) return;
|
||||
|
||||
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 (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
|
||||
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) {
|
||||
// Stream or video tracks are gone - stop watching
|
||||
this.stopWatching();
|
||||
@@ -136,6 +141,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
window.removeEventListener('viewer:focus', this.viewerFocusHandler as EventListener);
|
||||
}
|
||||
|
||||
/** Toggle between fullscreen and windowed display. */
|
||||
toggleFullscreen(): void {
|
||||
if (this.isFullscreen()) {
|
||||
this.exitFullscreen();
|
||||
@@ -144,6 +150,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/** Enter fullscreen mode, requesting browser fullscreen if available. */
|
||||
enterFullscreen(): void {
|
||||
this.isFullscreen.set(true);
|
||||
// Request browser fullscreen if available
|
||||
@@ -154,6 +161,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/** Exit fullscreen mode. */
|
||||
exitFullscreen(): void {
|
||||
this.isFullscreen.set(false);
|
||||
if (document.fullscreenElement) {
|
||||
@@ -161,6 +169,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop the local screen share and reset viewer state. */
|
||||
stopSharing(): void {
|
||||
this.webrtcService.stopScreenShare();
|
||||
this.activeScreenSharer.set(null);
|
||||
@@ -168,6 +177,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
this.isLocalShare.set(false);
|
||||
}
|
||||
|
||||
/** Stop watching a remote stream and reset the viewer. */
|
||||
// Stop watching a remote stream (for viewers)
|
||||
stopWatching(): void {
|
||||
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
|
||||
setRemoteStream(stream: MediaStream, user: User): void {
|
||||
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
|
||||
setLocalStream(stream: MediaStream, user: User): void {
|
||||
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 {
|
||||
const input = event.target as HTMLInputElement;
|
||||
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 -->
|
||||
@if (showConnectionError()) {
|
||||
<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 -->
|
||||
<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">
|
||||
{{ currentUser()?.displayName?.charAt(0)?.toUpperCase() || '?' }}
|
||||
</div>
|
||||
<app-user-avatar [name]="currentUser()?.displayName || '?'" size="sm" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm text-foreground truncate">
|
||||
{{ currentUser()?.displayName || 'Unknown' }}
|
||||
@@ -145,7 +143,10 @@
|
||||
|
||||
<div>
|
||||
<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="balanced" selected>Balanced</option>
|
||||
<option value="high">High (quality)</option>
|
||||
@@ -154,7 +155,12 @@
|
||||
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -162,7 +168,15 @@
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Audio Bitrate: {{ audioBitrate() }} kbps
|
||||
</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>
|
||||
|
||||
|
||||
@@ -17,9 +17,11 @@ import {
|
||||
|
||||
import { WebRTCService } from '../../../core/services/webrtc.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 { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../core/constants';
|
||||
import { UserAvatarComponent } from '../../../shared';
|
||||
|
||||
interface AudioDevice {
|
||||
deviceId: string;
|
||||
@@ -29,7 +31,7 @@ interface AudioDevice {
|
||||
@Component({
|
||||
selector: 'app-voice-controls',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
imports: [CommonModule, NgIcon, UserAvatarComponent],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMic,
|
||||
@@ -74,7 +76,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
latencyProfile = signal<'low'|'balanced'|'high'>('balanced');
|
||||
includeSystemAudio = signal(false);
|
||||
|
||||
private SETTINGS_KEY = 'metoyou_voice_settings';
|
||||
private voiceConnectedSubscription: Subscription | null = null;
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
@@ -87,14 +88,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
// Subscribe to remote streams to play audio from peers
|
||||
this.remoteStreamSubscription = this.webrtcService.onRemoteStream.subscribe(
|
||||
({ peerId, stream }) => {
|
||||
console.log('Received remote stream from:', peerId, 'tracks:', stream.getTracks().map(t => t.kind));
|
||||
this.playRemoteAudio(peerId, stream);
|
||||
}
|
||||
);
|
||||
|
||||
// Subscribe to voice connected event to play pending streams and ensure all remote audio is set up
|
||||
this.voiceConnectedSubscription = this.webrtcService.onVoiceConnected.subscribe(() => {
|
||||
console.log('Voice connected, playing pending streams:', this.pendingRemoteStreams.size);
|
||||
this.playPendingStreams();
|
||||
// Also ensure all remote streams from connected peers are playing
|
||||
// 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 {
|
||||
this.pendingRemoteStreams.forEach((stream, peerId) => {
|
||||
console.log('Playing pending stream from:', peerId, 'tracks:', stream.getTracks().map(t => t.kind));
|
||||
this.playRemoteAudio(peerId, stream);
|
||||
});
|
||||
this.pendingRemoteStreams.clear();
|
||||
@@ -143,7 +141,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
*/
|
||||
private ensureAllRemoteStreamsPlaying(): void {
|
||||
const connectedPeers = this.webrtcService.getConnectedPeers();
|
||||
console.log('Ensuring audio for connected peers:', connectedPeers.length);
|
||||
|
||||
for (const peerId of connectedPeers) {
|
||||
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
|
||||
const existingAudio = this.remoteAudioElements.get(peerId);
|
||||
if (!existingAudio || existingAudio.srcObject !== stream) {
|
||||
console.log('Setting up remote audio for peer:', peerId);
|
||||
this.playRemoteAudio(peerId, stream);
|
||||
}
|
||||
}
|
||||
@@ -168,14 +164,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
audio.srcObject = null;
|
||||
audio.remove();
|
||||
this.remoteAudioElements.delete(peerId);
|
||||
console.log('Removed remote audio for:', peerId);
|
||||
}
|
||||
}
|
||||
|
||||
private playRemoteAudio(peerId: string, stream: MediaStream): void {
|
||||
// Only play remote audio if we have joined voice
|
||||
if (!this.isConnected()) {
|
||||
console.log('Not connected to voice, storing pending stream from:', peerId);
|
||||
// Store the stream to play later when we connect
|
||||
this.pendingRemoteStreams.set(peerId, stream);
|
||||
return;
|
||||
@@ -184,21 +178,18 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
// Check if stream has audio tracks
|
||||
const audioTracks = stream.getAudioTracks();
|
||||
if (audioTracks.length === 0) {
|
||||
console.log('No audio tracks in stream from:', peerId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if audio track is live
|
||||
const audioTrack = audioTracks[0];
|
||||
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
|
||||
}
|
||||
|
||||
// Remove existing audio element for this peer if any
|
||||
const existingAudio = this.remoteAudioElements.get(peerId);
|
||||
if (existingAudio) {
|
||||
console.log('Removing existing audio element for:', peerId);
|
||||
existingAudio.srcObject = null;
|
||||
existingAudio.remove();
|
||||
}
|
||||
@@ -216,9 +207,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
// Play the audio
|
||||
audio.play().then(() => {
|
||||
console.log('Playing remote audio from:', peerId, 'track state:', audioTrack.readyState, 'enabled:', audioTrack.enabled);
|
||||
}).catch((error) => {
|
||||
console.error('Failed to play remote audio from:', peerId, error);
|
||||
});
|
||||
|
||||
this.remoteAudioElements.set(peerId, audio);
|
||||
@@ -227,22 +216,20 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
async loadAudioDevices(): Promise<void> {
|
||||
try {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) {
|
||||
console.warn('navigator.mediaDevices not available (requires HTTPS or localhost)');
|
||||
return;
|
||||
}
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
this.inputDevices.set(
|
||||
devices
|
||||
.filter((d) => d.kind === 'audioinput')
|
||||
.map((d) => ({ deviceId: d.deviceId, label: d.label }))
|
||||
.filter((device) => device.kind === 'audioinput')
|
||||
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||
);
|
||||
this.outputDevices.set(
|
||||
devices
|
||||
.filter((d) => d.kind === 'audiooutput')
|
||||
.map((d) => ({ deviceId: d.deviceId, label: d.label }))
|
||||
.filter((device) => device.kind === 'audiooutput')
|
||||
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to enumerate devices:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,12 +238,10 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
// Require signaling connectivity first
|
||||
const ok = await this.webrtcService.ensureSignalingConnected();
|
||||
if (!ok) {
|
||||
console.error('Cannot join call: signaling server unreachable');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
console.error('Cannot join call: navigator.mediaDevices not available (requires HTTPS or localhost)');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -289,7 +274,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
// Play any pending remote streams now that we're connected
|
||||
this.pendingRemoteStreams.forEach((pendingStream, peerId) => {
|
||||
console.log('Playing pending stream from:', peerId);
|
||||
this.playRemoteAudio(peerId, pendingStream);
|
||||
});
|
||||
this.pendingRemoteStreams.clear();
|
||||
@@ -297,7 +281,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
// Persist settings after successful connection
|
||||
this.saveSettings();
|
||||
} catch (error) {
|
||||
console.error('Failed to get user media:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,8 +288,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
async retryConnection(): Promise<void> {
|
||||
try {
|
||||
await this.webrtcService.ensureSignalingConnected(10000);
|
||||
} catch (e) {
|
||||
console.error('Retry connection failed:', e);
|
||||
} catch (_error) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,7 +341,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
toggleMute(): void {
|
||||
this.isMuted.update((v) => !v);
|
||||
this.isMuted.update((current) => !current);
|
||||
this.webrtcService.toggleMute(this.isMuted());
|
||||
|
||||
// Broadcast mute state change
|
||||
@@ -376,7 +358,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
toggleDeafen(): void {
|
||||
this.isDeafened.update((v) => !v);
|
||||
this.isDeafened.update((current) => !current);
|
||||
this.webrtcService.toggleDeafen(this.isDeafened());
|
||||
|
||||
// Mute/unmute all remote audio elements
|
||||
@@ -412,13 +394,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
await this.webrtcService.startScreenShare(this.includeSystemAudio());
|
||||
this.isScreenSharing.set(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to start screen share:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggleSettings(): void {
|
||||
this.showSettings.update((v) => !v);
|
||||
this.showSettings.update((current) => !current);
|
||||
}
|
||||
|
||||
closeSettings(): void {
|
||||
@@ -485,9 +466,9 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
|
||||
private loadSettings(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(this.SETTINGS_KEY);
|
||||
const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
|
||||
if (!raw) return;
|
||||
const s = JSON.parse(raw) as {
|
||||
const settings = JSON.parse(raw) as {
|
||||
inputDevice?: string;
|
||||
outputDevice?: string;
|
||||
inputVolume?: number;
|
||||
@@ -496,19 +477,19 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
latencyProfile?: 'low'|'balanced'|'high';
|
||||
includeSystemAudio?: boolean;
|
||||
};
|
||||
if (s.inputDevice) this.selectedInputDevice.set(s.inputDevice);
|
||||
if (s.outputDevice) this.selectedOutputDevice.set(s.outputDevice);
|
||||
if (typeof s.inputVolume === 'number') this.inputVolume.set(s.inputVolume);
|
||||
if (typeof s.outputVolume === 'number') this.outputVolume.set(s.outputVolume);
|
||||
if (typeof s.audioBitrate === 'number') this.audioBitrate.set(s.audioBitrate);
|
||||
if (s.latencyProfile) this.latencyProfile.set(s.latencyProfile);
|
||||
if (typeof s.includeSystemAudio === 'boolean') this.includeSystemAudio.set(s.includeSystemAudio);
|
||||
if (settings.inputDevice) this.selectedInputDevice.set(settings.inputDevice);
|
||||
if (settings.outputDevice) this.selectedOutputDevice.set(settings.outputDevice);
|
||||
if (typeof settings.inputVolume === 'number') this.inputVolume.set(settings.inputVolume);
|
||||
if (typeof settings.outputVolume === 'number') this.outputVolume.set(settings.outputVolume);
|
||||
if (typeof settings.audioBitrate === 'number') this.audioBitrate.set(settings.audioBitrate);
|
||||
if (settings.latencyProfile) this.latencyProfile.set(settings.latencyProfile);
|
||||
if (typeof settings.includeSystemAudio === 'boolean') this.includeSystemAudio.set(settings.includeSystemAudio);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private saveSettings(): void {
|
||||
try {
|
||||
const s = {
|
||||
const voiceSettings = {
|
||||
inputDevice: this.selectedInputDevice(),
|
||||
outputDevice: this.selectedOutputDevice(),
|
||||
inputVolume: this.inputVolume(),
|
||||
@@ -517,7 +498,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
latencyProfile: this.latencyProfile(),
|
||||
includeSystemAudio: this.includeSystemAudio(),
|
||||
};
|
||||
localStorage.setItem(this.SETTINGS_KEY, JSON.stringify(s));
|
||||
localStorage.setItem(STORAGE_KEY_VOICE_SETTINGS, JSON.stringify(voiceSettings));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -536,7 +517,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
this.remoteAudioElements.forEach((audio) => {
|
||||
const anyAudio = audio as any;
|
||||
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 { ActionReducerMap, MetaReducer } from '@ngrx/store';
|
||||
import { messagesReducer, MessagesState } from './messages/messages.reducer';
|
||||
import { usersReducer, UsersState } from './users/users.reducer';
|
||||
import { roomsReducer, RoomsState } from './rooms/rooms.reducer';
|
||||
|
||||
/** Combined root state of the application. */
|
||||
export interface AppState {
|
||||
/** Chat messages feature slice. */
|
||||
messages: MessagesState;
|
||||
/** Users / presence feature slice. */
|
||||
users: UsersState;
|
||||
/** Rooms / servers feature slice. */
|
||||
rooms: RoomsState;
|
||||
}
|
||||
|
||||
/** Top-level reducer map registered with `StoreModule.forRoot()`. */
|
||||
export const reducers: ActionReducerMap<AppState> = {
|
||||
messages: messagesReducer,
|
||||
users: usersReducer,
|
||||
rooms: roomsReducer,
|
||||
};
|
||||
|
||||
/** Meta-reducers (e.g. logging) enabled only in development builds. */
|
||||
export const metaReducers: MetaReducer<AppState>[] = isDevMode() ? [] : [];
|
||||
|
||||
// Re-export actions
|
||||
export * as MessagesActions from './messages/messages.actions';
|
||||
export * as UsersActions from './users/users.actions';
|
||||
export * as RoomsActions from './rooms/rooms.actions';
|
||||
export { MessagesActions } from './messages/messages.actions';
|
||||
export { UsersActions } from './users/users.actions';
|
||||
export { RoomsActions } from './rooms/rooms.actions';
|
||||
|
||||
// Re-export selectors explicitly to avoid conflicts
|
||||
export {
|
||||
@@ -57,6 +71,7 @@ export {
|
||||
|
||||
// Re-export effects
|
||||
export { MessagesEffects } from './messages/messages.effects';
|
||||
export { MessagesSyncEffects } from './messages/messages-sync.effects';
|
||||
export { UsersEffects } from './users/users.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';
|
||||
|
||||
// Load messages
|
||||
export const loadMessages = createAction(
|
||||
'[Messages] Load Messages',
|
||||
props<{ roomId: string }>()
|
||||
);
|
||||
export const MessagesActions = createActionGroup({
|
||||
source: 'Messages',
|
||||
events: {
|
||||
/** 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(
|
||||
'[Messages] Load Messages Success',
|
||||
props<{ messages: Message[] }>()
|
||||
);
|
||||
/** Sends a new chat message to the current room and broadcasts to peers. */
|
||||
'Send Message': props<{ content: string; replyToId?: string; channelId?: string }>(),
|
||||
'Send Message Success': props<{ message: Message }>(),
|
||||
'Send Message Failure': props<{ error: string }>(),
|
||||
|
||||
export const loadMessagesFailure = createAction(
|
||||
'[Messages] Load Messages Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
/** Applies a message received from a remote peer to the local store. */
|
||||
'Receive Message': props<{ message: Message }>(),
|
||||
|
||||
// Send message
|
||||
export const sendMessage = createAction(
|
||||
'[Messages] Send Message',
|
||||
props<{ content: string; replyToId?: string; channelId?: string }>()
|
||||
);
|
||||
'Edit Message': props<{ messageId: string; content: string }>(),
|
||||
'Edit Message Success': props<{ messageId: string; content: string; editedAt: number }>(),
|
||||
'Edit Message Failure': props<{ error: string }>(),
|
||||
|
||||
export const sendMessageSuccess = createAction(
|
||||
'[Messages] Send Message Success',
|
||||
props<{ message: Message }>()
|
||||
);
|
||||
'Delete Message': props<{ messageId: string }>(),
|
||||
'Delete Message Success': props<{ messageId: string }>(),
|
||||
'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(
|
||||
'[Messages] Send Message Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
'Add Reaction': props<{ messageId: string; emoji: string }>(),
|
||||
'Add Reaction Success': props<{ reaction: Reaction }>(),
|
||||
'Remove Reaction': props<{ messageId: string; emoji: string }>(),
|
||||
'Remove Reaction Success': props<{ messageId: string; emoji: string; oderId: string }>(),
|
||||
|
||||
// Receive message from peer
|
||||
export const receiveMessage = createAction(
|
||||
'[Messages] Receive Message',
|
||||
props<{ message: Message }>()
|
||||
);
|
||||
/** Merges a batch of messages received from a peer into the local store. */
|
||||
'Sync Messages': props<{ messages: Message[] }>(),
|
||||
/** Marks the start of a message sync cycle. */
|
||||
'Start Sync': emptyProps(),
|
||||
/** Marks the end of a message sync cycle. */
|
||||
'Sync Complete': emptyProps(),
|
||||
|
||||
// Edit message
|
||||
export const editMessage = createAction(
|
||||
'[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');
|
||||
/** Removes all messages from the store (e.g. when leaving a room). */
|
||||
'Clear Messages': emptyProps(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { of, from, timer, Subject } from 'rxjs';
|
||||
import { map, mergeMap, catchError, withLatestFrom, tap, switchMap, filter, exhaustMap, repeat, takeUntil } from 'rxjs/operators';
|
||||
import { of, from, EMPTY } from 'rxjs';
|
||||
import { mergeMap, catchError, withLatestFrom, switchMap } from 'rxjs/operators';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as MessagesActions from './messages.actions';
|
||||
import { selectMessagesSyncing } from './messages.selectors';
|
||||
import { MessagesActions } from './messages.actions';
|
||||
import { selectCurrentUser } from '../users/users.selectors';
|
||||
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
||||
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 { AttachmentService } from '../../core/services/attachment.service';
|
||||
import { Message, Reaction } from '../../core/models';
|
||||
import * as UsersActions from '../users/users.actions';
|
||||
import * as RoomsActions from '../rooms/rooms.actions';
|
||||
import { hydrateMessages } from './messages.helpers';
|
||||
import {
|
||||
dispatchIncomingMessage,
|
||||
IncomingMessageContext,
|
||||
} from './messages-incoming.handlers';
|
||||
|
||||
@Injectable()
|
||||
export class MessagesEffects {
|
||||
private actions$ = inject(Actions);
|
||||
private store = inject(Store);
|
||||
private db = inject(DatabaseService);
|
||||
private webrtc = inject(WebRTCService);
|
||||
private timeSync = inject(TimeSyncService);
|
||||
private attachments = inject(AttachmentService);
|
||||
private readonly actions$ = inject(Actions);
|
||||
private readonly store = inject(Store);
|
||||
private readonly db = inject(DatabaseService);
|
||||
private readonly webrtc = inject(WebRTCService);
|
||||
private readonly timeSync = inject(TimeSyncService);
|
||||
private readonly attachments = inject(AttachmentService);
|
||||
|
||||
private readonly INVENTORY_LIMIT = 1000; // number of recent messages to consider
|
||||
private readonly CHUNK_SIZE = 200; // chunk size for inventory/batch transfers
|
||||
private readonly SYNC_POLL_FAST_MS = 10_000; // 10s — aggressive poll
|
||||
private readonly SYNC_POLL_SLOW_MS = 900_000; // 15min — idle poll after clean sync
|
||||
private lastSyncClean = false; // true after a sync cycle with no new messages
|
||||
|
||||
// Load messages from local database (hydrate reactions from separate table)
|
||||
/** Loads messages for a room from the local database, hydrating reactions. */
|
||||
loadMessages$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MessagesActions.loadMessages),
|
||||
switchMap(({ roomId }) =>
|
||||
from(this.db.getMessages(roomId)).pipe(
|
||||
mergeMap(async (messages) => {
|
||||
// Hydrate each message with its reactions from the reactions table
|
||||
const hydrated = await Promise.all(
|
||||
messages.map(async (m) => {
|
||||
const reactions = await this.db.getReactionsForMessage(m.id);
|
||||
return reactions.length > 0 ? { ...m, reactions } : m;
|
||||
})
|
||||
);
|
||||
const hydrated = await hydrateMessages(messages, this.db);
|
||||
return MessagesActions.loadMessagesSuccess({ messages: hydrated });
|
||||
}),
|
||||
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(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MessagesActions.sendMessage),
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom)
|
||||
this.store.select(selectCurrentRoom),
|
||||
),
|
||||
mergeMap(([{ content, replyToId, channelId }, currentUser, currentRoom]) => {
|
||||
if (!currentUser || !currentRoom) {
|
||||
@@ -81,24 +81,18 @@ export class MessagesEffects {
|
||||
replyToId,
|
||||
};
|
||||
|
||||
// Save to local DB
|
||||
this.db.saveMessage(message);
|
||||
|
||||
// Broadcast to all peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'chat-message',
|
||||
message,
|
||||
});
|
||||
this.webrtc.broadcastMessage({ type: 'chat-message', message });
|
||||
|
||||
return of(MessagesActions.sendMessageSuccess({ message }));
|
||||
}),
|
||||
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(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MessagesActions.editMessage),
|
||||
@@ -109,40 +103,29 @@ export class MessagesEffects {
|
||||
}
|
||||
|
||||
return from(this.db.getMessageById(messageId)).pipe(
|
||||
mergeMap((existingMessage) => {
|
||||
if (!existingMessage) {
|
||||
mergeMap((existing) => {
|
||||
if (!existing) {
|
||||
return of(MessagesActions.editMessageFailure({ error: 'Message not found' }));
|
||||
}
|
||||
|
||||
// Check if user owns the message
|
||||
if (existingMessage.senderId !== currentUser.id) {
|
||||
if (existing.senderId !== currentUser.id) {
|
||||
return of(MessagesActions.editMessageFailure({ error: 'Cannot edit others messages' }));
|
||||
}
|
||||
|
||||
const editedAt = this.timeSync.now();
|
||||
|
||||
// Update in DB
|
||||
this.db.updateMessage(messageId, { content, editedAt });
|
||||
|
||||
// Broadcast to peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'message-edited',
|
||||
messageId,
|
||||
content,
|
||||
editedAt,
|
||||
});
|
||||
this.webrtc.broadcastMessage({ type: 'message-edited', messageId, content, editedAt });
|
||||
|
||||
return of(MessagesActions.editMessageSuccess({ messageId, content, editedAt }));
|
||||
}),
|
||||
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(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MessagesActions.deleteMessage),
|
||||
@@ -153,36 +136,28 @@ export class MessagesEffects {
|
||||
}
|
||||
|
||||
return from(this.db.getMessageById(messageId)).pipe(
|
||||
mergeMap((existingMessage) => {
|
||||
if (!existingMessage) {
|
||||
mergeMap((existing) => {
|
||||
if (!existing) {
|
||||
return of(MessagesActions.deleteMessageFailure({ error: 'Message not found' }));
|
||||
}
|
||||
|
||||
// Check if user owns the message
|
||||
if (existingMessage.senderId !== currentUser.id) {
|
||||
if (existing.senderId !== currentUser.id) {
|
||||
return of(MessagesActions.deleteMessageFailure({ error: 'Cannot delete others messages' }));
|
||||
}
|
||||
|
||||
// Soft delete - mark as deleted
|
||||
this.db.updateMessage(messageId, { isDeleted: true });
|
||||
|
||||
// Broadcast to peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'message-deleted',
|
||||
messageId,
|
||||
});
|
||||
this.webrtc.broadcastMessage({ type: 'message-deleted', messageId });
|
||||
|
||||
return of(MessagesActions.deleteMessageSuccess({ messageId }));
|
||||
}),
|
||||
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(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MessagesActions.adminDeleteMessage),
|
||||
@@ -192,38 +167,33 @@ export class MessagesEffects {
|
||||
return of(MessagesActions.deleteMessageFailure({ error: 'Not logged in' }));
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
if (currentUser.role !== 'host' && currentUser.role !== 'admin' && currentUser.role !== 'moderator') {
|
||||
const hasPermission =
|
||||
currentUser.role === 'host' ||
|
||||
currentUser.role === 'admin' ||
|
||||
currentUser.role === 'moderator';
|
||||
|
||||
if (!hasPermission) {
|
||||
return of(MessagesActions.deleteMessageFailure({ error: 'Permission denied' }));
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
this.db.updateMessage(messageId, { isDeleted: true });
|
||||
|
||||
// Broadcast to peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'message-deleted',
|
||||
messageId,
|
||||
deletedBy: currentUser.id,
|
||||
});
|
||||
this.webrtc.broadcastMessage({ type: 'message-deleted', messageId, deletedBy: currentUser.id });
|
||||
|
||||
return of(MessagesActions.deleteMessageSuccess({ messageId }));
|
||||
}),
|
||||
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(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MessagesActions.addReaction),
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
mergeMap(([{ messageId, emoji }, currentUser]) => {
|
||||
if (!currentUser) {
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
if (!currentUser) return EMPTY;
|
||||
|
||||
const reaction: Reaction = {
|
||||
id: uuidv4(),
|
||||
@@ -234,35 +204,23 @@ export class MessagesEffects {
|
||||
timestamp: this.timeSync.now(),
|
||||
};
|
||||
|
||||
// Save to DB
|
||||
this.db.saveReaction(reaction);
|
||||
|
||||
// Broadcast to peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'reaction-added',
|
||||
messageId,
|
||||
reaction,
|
||||
});
|
||||
this.webrtc.broadcastMessage({ type: 'reaction-added', messageId, reaction });
|
||||
|
||||
return of(MessagesActions.addReactionSuccess({ reaction }));
|
||||
})
|
||||
)
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Remove reaction
|
||||
/** Removes the current user's reaction from a message, deletes from DB, and broadcasts. */
|
||||
removeReaction$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MessagesActions.removeReaction),
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
mergeMap(([{ messageId, emoji }, currentUser]) => {
|
||||
if (!currentUser) {
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
if (!currentUser) return EMPTY;
|
||||
|
||||
// Remove from DB
|
||||
this.db.removeReaction(messageId, currentUser.id, emoji);
|
||||
|
||||
// Broadcast to peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'reaction-removed',
|
||||
messageId,
|
||||
@@ -270,412 +228,37 @@ export class MessagesEffects {
|
||||
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(() =>
|
||||
this.webrtc.onMessageReceived.pipe(
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom)
|
||||
this.store.select(selectCurrentRoom),
|
||||
),
|
||||
mergeMap(([event, currentUser, currentRoom]: [any, any, any]) => {
|
||||
console.log('Received peer message:', event.type, event);
|
||||
|
||||
switch (event.type) {
|
||||
// Precise sync via ID inventory and targeted requests
|
||||
case 'chat-inventory-request': {
|
||||
const reqRoomId = event.roomId;
|
||||
if (!reqRoomId || !event.fromPeerId) return of({ type: 'NO_OP' });
|
||||
return from(this.db.getMessages(reqRoomId, this.INVENTORY_LIMIT, 0)).pipe(
|
||||
mergeMap(async (messages) => {
|
||||
const items = await Promise.all(
|
||||
messages.map(async (m) => {
|
||||
const reactions = await this.db.getReactionsForMessage(m.id);
|
||||
return { id: m.id, ts: m.editedAt || m.timestamp || 0, rc: reactions.length };
|
||||
})
|
||||
);
|
||||
items.sort((a, b) => a.ts - b.ts);
|
||||
console.log(`[Sync] Sending inventory of ${items.length} items for room ${reqRoomId}`);
|
||||
for (let i = 0; i < items.length; i += this.CHUNK_SIZE) {
|
||||
const chunk = items.slice(i, i + this.CHUNK_SIZE);
|
||||
this.webrtc.sendToPeer(event.fromPeerId, {
|
||||
type: 'chat-inventory',
|
||||
roomId: reqRoomId,
|
||||
items: chunk,
|
||||
total: items.length,
|
||||
index: i,
|
||||
} as any);
|
||||
}
|
||||
const ctx: IncomingMessageContext = {
|
||||
db: this.db,
|
||||
webrtc: this.webrtc,
|
||||
attachments: this.attachments,
|
||||
currentUser,
|
||||
currentRoom,
|
||||
};
|
||||
return dispatchIncomingMessage(event, ctx);
|
||||
}),
|
||||
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 { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
|
||||
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> {
|
||||
/** Whether messages are being loaded from the database. */
|
||||
loading: boolean;
|
||||
/** Whether a peer-to-peer sync cycle is in progress. */
|
||||
syncing: boolean;
|
||||
/** Most recent error message from message operations. */
|
||||
error: string | null;
|
||||
/** ID of the room whose messages are currently loaded. */
|
||||
currentRoomId: string | null;
|
||||
}
|
||||
|
||||
@@ -108,7 +113,7 @@ export const messagesReducer = createReducer(
|
||||
if (!message) return state;
|
||||
|
||||
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;
|
||||
@@ -134,7 +139,7 @@ export const messagesReducer = createReducer(
|
||||
id: messageId,
|
||||
changes: {
|
||||
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)
|
||||
on(MessagesActions.syncMessages, (state, { messages }) => {
|
||||
const merged = messages.map(m => {
|
||||
const existing = state.entities[m.id];
|
||||
const merged = messages.map(message => {
|
||||
const existing = state.entities[message.id];
|
||||
if (existing?.reactions?.length) {
|
||||
const combined = [...(m.reactions ?? [])];
|
||||
for (const r of existing.reactions) {
|
||||
if (!combined.some(c => c.userId === r.userId && c.emoji === r.emoji && c.messageId === r.messageId)) {
|
||||
combined.push(r);
|
||||
const combined = [...(message.reactions ?? [])];
|
||||
for (const existingReaction of existing.reactions) {
|
||||
if (!combined.some(combinedReaction => combinedReaction.userId === existingReaction.userId && combinedReaction.emoji === existingReaction.emoji && combinedReaction.messageId === existingReaction.messageId)) {
|
||||
combined.push(existingReaction);
|
||||
}
|
||||
}
|
||||
return { ...m, reactions: combined };
|
||||
return { ...message, reactions: combined };
|
||||
}
|
||||
return m;
|
||||
return message;
|
||||
});
|
||||
return messagesAdapter.upsertMany(merged, {
|
||||
...state,
|
||||
|
||||
@@ -1,45 +1,55 @@
|
||||
import { createFeatureSelector, createSelector } from '@ngrx/store';
|
||||
import { MessagesState, messagesAdapter } from './messages.reducer';
|
||||
|
||||
/** Selects the top-level messages feature state. */
|
||||
export const selectMessagesState = createFeatureSelector<MessagesState>('messages');
|
||||
|
||||
const { selectIds, selectEntities, selectAll, selectTotal } = messagesAdapter.getSelectors();
|
||||
|
||||
/** Selects all message entities as a flat array. */
|
||||
export const selectAllMessages = createSelector(selectMessagesState, selectAll);
|
||||
|
||||
/** Selects the message entity dictionary keyed by ID. */
|
||||
export const selectMessagesEntities = createSelector(selectMessagesState, selectEntities);
|
||||
|
||||
/** Selects all message IDs. */
|
||||
export const selectMessagesIds = createSelector(selectMessagesState, selectIds);
|
||||
|
||||
/** Selects the total count of messages. */
|
||||
export const selectMessagesTotal = createSelector(selectMessagesState, selectTotal);
|
||||
|
||||
/** Whether messages are currently being loaded from the database. */
|
||||
export const selectMessagesLoading = createSelector(
|
||||
selectMessagesState,
|
||||
(state) => state.loading
|
||||
);
|
||||
|
||||
/** Selects the most recent messages-related error message. */
|
||||
export const selectMessagesError = createSelector(
|
||||
selectMessagesState,
|
||||
(state) => state.error
|
||||
);
|
||||
|
||||
/** Whether a peer-to-peer message sync cycle is in progress. */
|
||||
export const selectMessagesSyncing = createSelector(
|
||||
selectMessagesState,
|
||||
(state) => state.syncing
|
||||
);
|
||||
|
||||
/** Selects the ID of the room whose messages are currently loaded. */
|
||||
export const selectCurrentRoomId = createSelector(
|
||||
selectMessagesState,
|
||||
(state) => state.currentRoomId
|
||||
);
|
||||
|
||||
/** Selects all messages belonging to the currently active room. */
|
||||
export const selectCurrentRoomMessages = createSelector(
|
||||
selectAllMessages,
|
||||
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) =>
|
||||
createSelector(
|
||||
selectAllMessages,
|
||||
@@ -47,25 +57,29 @@ export const selectChannelMessages = (channelId: string) =>
|
||||
(messages, roomId) => {
|
||||
if (!roomId) return [];
|
||||
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) =>
|
||||
createSelector(selectMessagesEntities, (entities) => entities[id]);
|
||||
|
||||
/** Creates a selector that returns all messages for a specific room. */
|
||||
export const selectMessagesByRoomId = (roomId: string) =>
|
||||
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) =>
|
||||
createSelector(selectAllMessages, (messages) =>
|
||||
messages.slice(-limit)
|
||||
);
|
||||
|
||||
/** Selects only messages that have at least one reaction. */
|
||||
export const selectMessagesWithReactions = createSelector(
|
||||
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';
|
||||
|
||||
// Load rooms from storage
|
||||
export const loadRooms = createAction('[Rooms] Load Rooms');
|
||||
export const RoomsActions = createActionGroup({
|
||||
source: 'Rooms',
|
||||
events: {
|
||||
'Load Rooms': emptyProps(),
|
||||
'Load Rooms Success': props<{ rooms: Room[] }>(),
|
||||
'Load Rooms Failure': props<{ error: string }>(),
|
||||
|
||||
export const loadRoomsSuccess = createAction(
|
||||
'[Rooms] Load Rooms Success',
|
||||
props<{ rooms: Room[] }>()
|
||||
);
|
||||
'Search Servers': props<{ query: string }>(),
|
||||
'Search Servers Success': props<{ servers: ServerInfo[] }>(),
|
||||
'Search Servers Failure': props<{ error: string }>(),
|
||||
|
||||
export const loadRoomsFailure = createAction(
|
||||
'[Rooms] Load Rooms Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
'Create Room': props<{ name: string; description?: string; topic?: string; isPrivate?: boolean; password?: string }>(),
|
||||
'Create Room Success': props<{ room: Room }>(),
|
||||
'Create Room Failure': props<{ error: string }>(),
|
||||
|
||||
// Search servers
|
||||
export const searchServers = createAction(
|
||||
'[Rooms] Search Servers',
|
||||
props<{ query: string }>()
|
||||
);
|
||||
'Join Room': props<{ roomId: string; password?: string; serverInfo?: { name: string; description?: string; hostName?: string } }>(),
|
||||
'Join Room Success': props<{ room: Room }>(),
|
||||
'Join Room Failure': props<{ error: string }>(),
|
||||
|
||||
export const searchServersSuccess = createAction(
|
||||
'[Rooms] Search Servers Success',
|
||||
props<{ servers: ServerInfo[] }>()
|
||||
);
|
||||
'Leave Room': emptyProps(),
|
||||
'Leave Room Success': emptyProps(),
|
||||
|
||||
export const searchServersFailure = createAction(
|
||||
'[Rooms] Search Servers Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
'View Server': props<{ room: Room }>(),
|
||||
'View Server Success': props<{ room: Room }>(),
|
||||
|
||||
// Create room
|
||||
export const createRoom = createAction(
|
||||
'[Rooms] Create Room',
|
||||
props<{ name: string; description?: string; topic?: string; isPrivate?: boolean; password?: string }>()
|
||||
);
|
||||
'Delete Room': props<{ roomId: string }>(),
|
||||
'Delete Room Success': props<{ roomId: string }>(),
|
||||
|
||||
export const createRoomSuccess = createAction(
|
||||
'[Rooms] Create Room Success',
|
||||
props<{ room: Room }>()
|
||||
);
|
||||
'Forget Room': props<{ roomId: string }>(),
|
||||
'Forget Room Success': props<{ roomId: string }>(),
|
||||
|
||||
export const createRoomFailure = createAction(
|
||||
'[Rooms] Create Room Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
'Update Room Settings': props<{ settings: Partial<RoomSettings> }>(),
|
||||
'Update Room Settings Success': props<{ settings: RoomSettings }>(),
|
||||
'Update Room Settings Failure': props<{ error: string }>(),
|
||||
|
||||
// Join room
|
||||
export const joinRoom = createAction(
|
||||
'[Rooms] Join Room',
|
||||
props<{ roomId: string; password?: string; serverInfo?: { name: string; description?: string; hostName?: string } }>()
|
||||
);
|
||||
'Update Room Permissions': props<{ roomId: string; permissions: Partial<RoomPermissions> }>(),
|
||||
|
||||
export const joinRoomSuccess = createAction(
|
||||
'[Rooms] Join Room Success',
|
||||
props<{ room: Room }>()
|
||||
);
|
||||
'Update Server Icon': props<{ roomId: string; icon: string }>(),
|
||||
'Update Server Icon Success': props<{ roomId: string; icon: string; iconUpdatedAt: number }>(),
|
||||
'Update Server Icon Failure': props<{ error: string }>(),
|
||||
|
||||
export const joinRoomFailure = createAction(
|
||||
'[Rooms] Join Room Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
'Set Current Room': props<{ room: Room }>(),
|
||||
'Clear Current Room': emptyProps(),
|
||||
|
||||
// Leave room
|
||||
export const leaveRoom = createAction('[Rooms] Leave Room');
|
||||
'Update Room': props<{ roomId: string; changes: Partial<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)
|
||||
export const viewServer = createAction(
|
||||
'[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 }>()
|
||||
);
|
||||
'Clear Search Results': emptyProps(),
|
||||
'Set Connecting': props<{ isConnecting: boolean }>(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { of, from } from 'rxjs';
|
||||
import { of, from, EMPTY } from 'rxjs';
|
||||
import {
|
||||
map,
|
||||
mergeMap,
|
||||
@@ -11,18 +11,41 @@ import {
|
||||
tap,
|
||||
debounceTime,
|
||||
switchMap,
|
||||
filter,
|
||||
} from 'rxjs/operators';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as RoomsActions from './rooms.actions';
|
||||
import * as UsersActions from '../users/users.actions';
|
||||
import * as MessagesActions from '../messages/messages.actions';
|
||||
import { selectCurrentUser } from '../users/users.selectors';
|
||||
import { RoomsActions } from './rooms.actions';
|
||||
import { UsersActions } from '../users/users.actions';
|
||||
import { MessagesActions } from '../messages/messages.actions';
|
||||
import { selectCurrentUser, selectAllUsers } from '../users/users.selectors';
|
||||
import { selectCurrentRoom } from './rooms.selectors';
|
||||
import { DatabaseService } from '../../core/services/database.service';
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||
import { Room, RoomSettings, RoomPermissions } from '../../core/models';
|
||||
import { selectAllUsers } from '../users/users.selectors';
|
||||
import { Room, RoomSettings, RoomPermissions, VoiceState } from '../../core/models';
|
||||
|
||||
/** 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()
|
||||
export class RoomsEffects {
|
||||
@@ -33,7 +56,7 @@ export class RoomsEffects {
|
||||
private webrtc = inject(WebRTCService);
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
|
||||
// Load rooms from database
|
||||
/** Loads all saved rooms from the local database. */
|
||||
loadRooms$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.loadRooms),
|
||||
@@ -48,7 +71,7 @@ export class RoomsEffects {
|
||||
)
|
||||
);
|
||||
|
||||
// Search servers with debounce
|
||||
/** Searches the server directory with debounced input. */
|
||||
searchServers$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
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(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.createRoom),
|
||||
@@ -104,10 +127,7 @@ export class RoomsEffects {
|
||||
maxUsers: room.maxUsers || 50,
|
||||
tags: [],
|
||||
})
|
||||
.subscribe({
|
||||
next: () => console.log('Room registered with directory, ID:', room.id),
|
||||
error: (err) => console.warn('Failed to register room:', err),
|
||||
});
|
||||
.subscribe();
|
||||
|
||||
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(() =>
|
||||
this.actions$.pipe(
|
||||
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(
|
||||
() =>
|
||||
this.actions$.pipe(
|
||||
@@ -196,23 +216,18 @@ export class RoomsEffects {
|
||||
|
||||
// Check if already connected to signaling server
|
||||
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.switchServer(room.id, oderId);
|
||||
} else {
|
||||
// Not connected - establish new connection
|
||||
console.log('Connecting to signaling server:', wsUrl);
|
||||
this.webrtc.connectToSignalingServer(wsUrl).subscribe({
|
||||
next: (connected) => {
|
||||
if (connected) {
|
||||
console.log('Connected to signaling, identifying user and joining room');
|
||||
this.webrtc.setCurrentServer(room.id);
|
||||
this.webrtc.identify(oderId, displayName);
|
||||
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 }
|
||||
);
|
||||
|
||||
// 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(() =>
|
||||
this.actions$.pipe(
|
||||
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(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.viewServerSuccess),
|
||||
@@ -253,20 +268,15 @@ export class RoomsEffects {
|
||||
)
|
||||
);
|
||||
|
||||
// Leave room
|
||||
/** Handles leave-room dispatches (navigation only, peers stay connected). */
|
||||
leaveRoom$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.leaveRoom),
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
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());
|
||||
})
|
||||
)
|
||||
map(() => RoomsActions.leaveRoomSuccess()),
|
||||
),
|
||||
);
|
||||
|
||||
// Delete room
|
||||
/** Deletes a room (host-only): removes from DB, notifies peers, and disconnects. */
|
||||
deleteRoom$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.deleteRoom),
|
||||
@@ -274,34 +284,19 @@ export class RoomsEffects {
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom)
|
||||
),
|
||||
switchMap(([{ roomId }, currentUser, currentRoom]) => {
|
||||
if (!currentUser) {
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
|
||||
// Only host can delete the room
|
||||
if (currentRoom?.hostId !== currentUser.id) {
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
|
||||
// Delete from local DB
|
||||
filter(([, currentUser, currentRoom]) =>
|
||||
!!currentUser && currentRoom?.hostId === currentUser.id,
|
||||
),
|
||||
switchMap(([{ roomId }]) => {
|
||||
this.db.deleteRoom(roomId);
|
||||
|
||||
// Notify all connected peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'room-deleted',
|
||||
roomId,
|
||||
});
|
||||
|
||||
// Disconnect everyone
|
||||
this.webrtc.broadcastMessage({ type: 'room-deleted', roomId });
|
||||
this.webrtc.disconnectAll();
|
||||
|
||||
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(() =>
|
||||
this.actions$.pipe(
|
||||
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(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.updateRoomSettings),
|
||||
@@ -373,7 +368,7 @@ export class RoomsEffects {
|
||||
)
|
||||
);
|
||||
|
||||
// Update room
|
||||
/** Persists room field changes to the local database. */
|
||||
updateRoom$ = createEffect(
|
||||
() =>
|
||||
this.actions$.pipe(
|
||||
@@ -388,21 +383,17 @@ export class RoomsEffects {
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
// Update room permissions (host only)
|
||||
/** Updates room permission grants (host-only) and broadcasts to peers. */
|
||||
updateRoomPermissions$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.updateRoomPermissions),
|
||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
||||
mergeMap(([{ roomId, permissions }, currentUser, currentRoom]) => {
|
||||
if (!currentUser || !currentRoom || currentRoom.id !== roomId) {
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
// Only host can change permission grant settings
|
||||
if (currentRoom.hostId !== currentUser.id) {
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
filter(([{ roomId }, currentUser, currentRoom]) =>
|
||||
!!currentUser && !!currentRoom && currentRoom.id === roomId && currentRoom.hostId === currentUser.id,
|
||||
),
|
||||
mergeMap(([{ roomId, permissions }, , currentRoom]) => {
|
||||
const updated: Partial<Room> = {
|
||||
permissions: { ...(currentRoom.permissions || {}), ...permissions } as RoomPermissions,
|
||||
permissions: { ...(currentRoom!.permissions || {}), ...permissions } as RoomPermissions,
|
||||
};
|
||||
this.db.updateRoom(roomId, updated);
|
||||
// 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(() =>
|
||||
this.actions$.pipe(
|
||||
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(
|
||||
() =>
|
||||
this.actions$.pipe(
|
||||
@@ -452,7 +443,7 @@ export class RoomsEffects {
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
// When joining a room, also load messages and users
|
||||
/** Loads messages and bans when joining a room. */
|
||||
onJoinRoomSuccess$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
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(() =>
|
||||
this.actions$.pipe(
|
||||
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(() =>
|
||||
this.webrtc.onSignalingMessage.pipe(
|
||||
withLatestFrom(
|
||||
@@ -484,108 +475,82 @@ export class RoomsEffects {
|
||||
this.store.select(selectCurrentRoom),
|
||||
),
|
||||
mergeMap(([message, currentUser, currentRoom]: [any, any, any]) => {
|
||||
const actions: any[] = [];
|
||||
const myId = currentUser?.oderId || currentUser?.id;
|
||||
const viewedServerId = currentRoom?.id;
|
||||
|
||||
if (message.type === 'server_users' && message.users) {
|
||||
// Only populate for the currently viewed server
|
||||
const msgServerId = message.serverId;
|
||||
if (msgServerId && viewedServerId && msgServerId !== viewedServerId) {
|
||||
return [{ type: 'NO_OP' }];
|
||||
switch (message.type) {
|
||||
case 'server_users': {
|
||||
if (!message.users || isWrongServer(message.serverId, viewedServerId)) return EMPTY;
|
||||
const joinActions = (message.users as { oderId: string; displayName: string }[])
|
||||
.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
|
||||
actions.push(UsersActions.clearUsers());
|
||||
// Add all existing users to the store (excluding ourselves)
|
||||
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(),
|
||||
},
|
||||
})
|
||||
);
|
||||
case 'user_joined': {
|
||||
if (isWrongServer(message.serverId, viewedServerId) || message.oderId === myId) return EMPTY;
|
||||
return [UsersActions.userJoined({ user: buildSignalingUser(message) })];
|
||||
}
|
||||
});
|
||||
} 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' }];
|
||||
case 'user_left': {
|
||||
if (isWrongServer(message.serverId, viewedServerId)) return EMPTY;
|
||||
return [UsersActions.userLeft({ userId: message.oderId })];
|
||||
}
|
||||
// Don't add ourselves to the list
|
||||
if (message.oderId !== myId) {
|
||||
actions.push(
|
||||
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(),
|
||||
},
|
||||
})
|
||||
);
|
||||
default:
|
||||
return EMPTY;
|
||||
}
|
||||
} else if (message.type === 'user_left') {
|
||||
// Only remove from user list if this event is for the currently viewed server
|
||||
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(() =>
|
||||
this.webrtc.onMessageReceived.pipe(
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentRoom),
|
||||
this.store.select(selectAllUsers)
|
||||
this.store.select(selectAllUsers),
|
||||
),
|
||||
mergeMap(([event, currentRoom, allUsers]: [any, Room | null, any[]]) => {
|
||||
if (!currentRoom) return of({ type: 'NO_OP' });
|
||||
|
||||
filter(([, room]) => !!room),
|
||||
mergeMap(([event, currentRoom, allUsers]: [any, any, any[]]) => {
|
||||
const room = currentRoom as Room;
|
||||
switch (event.type) {
|
||||
case 'voice-state': {
|
||||
const userId = (event.fromPeerId as string) || (event.oderId as string);
|
||||
const vs = event.voiceState as Partial<import('../../core/models').VoiceState> | undefined;
|
||||
if (!userId || !vs) return of({ type: 'NO_OP' });
|
||||
case 'voice-state':
|
||||
return this.handleVoiceOrScreenState(event, allUsers, 'voice');
|
||||
case 'screen-state':
|
||||
return this.handleVoiceOrScreenState(event, allUsers, 'screen');
|
||||
case 'room-settings-update':
|
||||
return this.handleRoomSettingsUpdate(event, room);
|
||||
case 'server-icon-summary':
|
||||
return this.handleIconSummary(event, room);
|
||||
case 'server-icon-request':
|
||||
return this.handleIconRequest(event, room);
|
||||
case 'server-icon-full':
|
||||
case 'server-icon-update':
|
||||
return this.handleIconData(event, room);
|
||||
default:
|
||||
return EMPTY;
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Check if user exists in the store
|
||||
const userExists = allUsers.some(u => u.id === userId || u.oderId === userId);
|
||||
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) {
|
||||
// User doesn't exist yet - create them with the voice state
|
||||
// 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(),
|
||||
user: buildSignalingUser(
|
||||
{ oderId: userId, displayName: event.displayName || 'User' },
|
||||
{
|
||||
voiceState: {
|
||||
isConnected: vs.isConnected ?? false,
|
||||
isMuted: vs.isMuted ?? false,
|
||||
@@ -597,110 +562,87 @@ export class RoomsEffects {
|
||||
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);
|
||||
// screen-state
|
||||
const isSharing = event.isScreenSharing as boolean | undefined;
|
||||
if (isSharing === undefined) return EMPTY;
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
user: buildSignalingUser(
|
||||
{ oderId: userId, displayName: event.displayName || 'User' },
|
||||
{ screenShareState: { isSharing } },
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
return of(UsersActions.updateScreenShareState({
|
||||
userId,
|
||||
screenShareState: { isSharing },
|
||||
}));
|
||||
}
|
||||
case 'room-settings-update': {
|
||||
|
||||
private handleRoomSettingsUpdate(event: any, room: Room) {
|
||||
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> })
|
||||
);
|
||||
if (!settings) return EMPTY;
|
||||
this.db.updateRoom(room.id, settings);
|
||||
return of(RoomsActions.receiveRoomUpdate({ room: { ...settings } as Partial<Room> }));
|
||||
}
|
||||
|
||||
// Server icon sync handshake
|
||||
case 'server-icon-summary': {
|
||||
private handleIconSummary(event: any, room: Room) {
|
||||
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);
|
||||
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 of({ type: 'NO_OP' });
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
case 'server-icon-request': {
|
||||
private handleIconRequest(event: any, room: Room) {
|
||||
if (event.fromPeerId) {
|
||||
this.webrtc.sendToPeer(event.fromPeerId, {
|
||||
type: 'server-icon-full',
|
||||
roomId: currentRoom.id,
|
||||
icon: currentRoom.icon,
|
||||
iconUpdatedAt: currentRoom.iconUpdatedAt || 0,
|
||||
roomId: room.id,
|
||||
icon: room.icon,
|
||||
iconUpdatedAt: room.iconUpdatedAt || 0,
|
||||
} as any);
|
||||
}
|
||||
return of({ type: 'NO_OP' });
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
case 'server-icon-full':
|
||||
case 'server-icon-update': {
|
||||
if (typeof event.icon !== 'string') return of({ type: 'NO_OP' });
|
||||
// Enforce that only owner or permitted roles can update
|
||||
private handleIconData(event: any, room: Room) {
|
||||
const senderId = event.fromPeerId as string | undefined;
|
||||
if (!senderId) return of({ type: 'NO_OP' });
|
||||
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 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' });
|
||||
}
|
||||
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(currentRoom.id, updates);
|
||||
return of(RoomsActions.updateRoom({ roomId: currentRoom.id, changes: updates }));
|
||||
})
|
||||
this.db.updateRoom(room.id, updates);
|
||||
return of(RoomsActions.updateRoom({ roomId: room.id, changes: updates }));
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return of({ type: 'NO_OP' });
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// On peer connect, broadcast local server icon summary (sync upon join/connect)
|
||||
/** Broadcasts the local server icon summary to peers when a new peer connects. */
|
||||
peerConnectedIconSync$ = createEffect(
|
||||
() =>
|
||||
this.webrtc.onPeerConnected.pipe(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createReducer, on } from '@ngrx/store';
|
||||
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 */
|
||||
export function defaultChannels(): Channel[] {
|
||||
@@ -15,15 +15,15 @@ export function defaultChannels(): Channel[] {
|
||||
/** Deduplicate rooms by id, keeping the last occurrence */
|
||||
function deduplicateRooms(rooms: Room[]): Room[] {
|
||||
const seen = new Map<string, Room>();
|
||||
for (const r of rooms) {
|
||||
seen.set(r.id, r);
|
||||
for (const room of rooms) {
|
||||
seen.set(room.id, room);
|
||||
}
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
/** Upsert a room into a saved-rooms list (add or replace by id) */
|
||||
function upsertRoom(savedRooms: Room[], room: Room): Room[] {
|
||||
const idx = savedRooms.findIndex(r => r.id === room.id);
|
||||
const idx = savedRooms.findIndex(existingRoom => existingRoom.id === room.id);
|
||||
if (idx >= 0) {
|
||||
const updated = [...savedRooms];
|
||||
updated[idx] = room;
|
||||
@@ -32,17 +32,28 @@ function upsertRoom(savedRooms: Room[], room: Room): Room[] {
|
||||
return [...savedRooms, room];
|
||||
}
|
||||
|
||||
/** State shape for the rooms feature slice. */
|
||||
export interface RoomsState {
|
||||
/** The room the user is currently viewing. */
|
||||
currentRoom: Room | null;
|
||||
/** All rooms persisted locally (joined or created). */
|
||||
savedRooms: Room[];
|
||||
/** Editable settings for the current room. */
|
||||
roomSettings: RoomSettings | null;
|
||||
/** Results returned from the server directory search. */
|
||||
searchResults: ServerInfo[];
|
||||
/** Whether a server directory search is in progress. */
|
||||
isSearching: boolean;
|
||||
/** Whether a connection to a room is being established. */
|
||||
isConnecting: boolean;
|
||||
/** Whether the user is connected to a room. */
|
||||
isConnected: boolean;
|
||||
/** Whether rooms are being loaded from local storage. */
|
||||
loading: boolean;
|
||||
/** Most recent error message, if any. */
|
||||
error: string | null;
|
||||
activeChannelId: string; // currently selected text channel
|
||||
/** ID of the currently selected text channel. */
|
||||
activeChannelId: string;
|
||||
}
|
||||
|
||||
export const initialState: RoomsState = {
|
||||
@@ -212,14 +223,14 @@ export const roomsReducer = createReducer(
|
||||
// Delete room
|
||||
on(RoomsActions.deleteRoomSuccess, (state, { roomId }) => ({
|
||||
...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,
|
||||
})),
|
||||
|
||||
// Forget room (local only)
|
||||
on(RoomsActions.forgetRoomSuccess, (state, { roomId }) => ({
|
||||
...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,
|
||||
})),
|
||||
|
||||
@@ -295,7 +306,7 @@ export const roomsReducer = createReducer(
|
||||
on(RoomsActions.removeChannel, (state, { channelId }) => {
|
||||
if (!state.currentRoom) return state;
|
||||
const existing = state.currentRoom.channels || defaultChannels();
|
||||
const updatedChannels = existing.filter(c => c.id !== channelId);
|
||||
const updatedChannels = existing.filter(channel => channel.id !== channelId);
|
||||
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
|
||||
return {
|
||||
...state,
|
||||
@@ -308,7 +319,7 @@ export const roomsReducer = createReducer(
|
||||
on(RoomsActions.renameChannel, (state, { channelId, name }) => {
|
||||
if (!state.currentRoom) return state;
|
||||
const existing = state.currentRoom.channels || defaultChannels();
|
||||
const updatedChannels = existing.map(c => c.id === channelId ? { ...c, name } : c);
|
||||
const updatedChannels = existing.map(channel => channel.id === channelId ? { ...channel, name } : channel);
|
||||
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -1,84 +1,101 @@
|
||||
import { createFeatureSelector, createSelector } from '@ngrx/store';
|
||||
import { RoomsState } from './rooms.reducer';
|
||||
|
||||
/** Selects the top-level rooms feature state. */
|
||||
export const selectRoomsState = createFeatureSelector<RoomsState>('rooms');
|
||||
|
||||
/** Selects the room the user is currently viewing. */
|
||||
export const selectCurrentRoom = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.currentRoom
|
||||
);
|
||||
|
||||
/** Selects the current room's settings (name, topic, privacy, etc.). */
|
||||
export const selectRoomSettings = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.roomSettings
|
||||
);
|
||||
|
||||
/** Selects server search results from the directory. */
|
||||
export const selectSearchResults = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.searchResults
|
||||
);
|
||||
|
||||
/** Whether a server directory search is currently in progress. */
|
||||
export const selectIsSearching = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.isSearching
|
||||
);
|
||||
|
||||
/** Whether a room connection is being established. */
|
||||
export const selectIsConnecting = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.isConnecting
|
||||
);
|
||||
|
||||
/** Whether the user is currently connected to a room. */
|
||||
export const selectIsConnected = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.isConnected
|
||||
);
|
||||
|
||||
/** Selects the most recent rooms-related error message. */
|
||||
export const selectRoomsError = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.error
|
||||
);
|
||||
|
||||
/** Selects the ID of the current room, or null. */
|
||||
export const selectCurrentRoomId = createSelector(
|
||||
selectCurrentRoom,
|
||||
(room) => room?.id ?? null
|
||||
);
|
||||
|
||||
/** Selects the display name of the current room. */
|
||||
export const selectCurrentRoomName = createSelector(
|
||||
selectCurrentRoom,
|
||||
(room) => room?.name ?? ''
|
||||
);
|
||||
|
||||
/** Selects the host ID of the current room (for ownership checks). */
|
||||
export const selectIsCurrentUserHost = createSelector(
|
||||
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(
|
||||
selectRoomsState,
|
||||
(state) => state.savedRooms
|
||||
);
|
||||
|
||||
/** Whether rooms are currently being loaded from local storage. */
|
||||
export const selectRoomsLoading = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.loading
|
||||
);
|
||||
|
||||
/** Selects the ID of the currently active text channel. */
|
||||
export const selectActiveChannelId = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.activeChannelId
|
||||
);
|
||||
|
||||
/** Selects all channels defined on the current room. */
|
||||
export const selectCurrentRoomChannels = createSelector(
|
||||
selectCurrentRoom,
|
||||
(room) => room?.channels ?? []
|
||||
);
|
||||
|
||||
/** Selects only text channels, sorted by position. */
|
||||
export const selectTextChannels = createSelector(
|
||||
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(
|
||||
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';
|
||||
|
||||
// Load current user from storage
|
||||
export const loadCurrentUser = createAction('[Users] Load Current User');
|
||||
export const UsersActions = createActionGroup({
|
||||
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(
|
||||
'[Users] Load Current User Success',
|
||||
props<{ user: User }>()
|
||||
);
|
||||
'Set Current User': props<{ user: User }>(),
|
||||
'Update Current User': props<{ updates: Partial<User> }>(),
|
||||
|
||||
export const loadCurrentUserFailure = createAction(
|
||||
'[Users] Load Current User Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
'Load Room Users': props<{ roomId: string }>(),
|
||||
'Load Room Users Success': props<{ users: User[] }>(),
|
||||
'Load Room Users Failure': props<{ error: string }>(),
|
||||
|
||||
// Set current user
|
||||
export const setCurrentUser = createAction(
|
||||
'[Users] Set Current User',
|
||||
props<{ user: User }>()
|
||||
);
|
||||
'User Joined': props<{ user: User }>(),
|
||||
'User Left': props<{ userId: string }>(),
|
||||
|
||||
// Update current user
|
||||
export const updateCurrentUser = createAction(
|
||||
'[Users] Update Current User',
|
||||
props<{ updates: Partial<User> }>()
|
||||
);
|
||||
'Update User': props<{ userId: string; updates: Partial<User> }>(),
|
||||
'Update User Role': props<{ userId: string; role: User['role'] }>(),
|
||||
|
||||
// Load users in room
|
||||
export const loadRoomUsers = createAction(
|
||||
'[Users] Load Room Users',
|
||||
props<{ roomId: string }>()
|
||||
);
|
||||
'Kick User': props<{ userId: string }>(),
|
||||
'Kick User Success': props<{ userId: string }>(),
|
||||
|
||||
export const loadRoomUsersSuccess = createAction(
|
||||
'[Users] Load Room Users Success',
|
||||
props<{ users: User[] }>()
|
||||
);
|
||||
'Ban User': props<{ userId: string; reason?: string; expiresAt?: number }>(),
|
||||
'Ban User Success': props<{ userId: string; ban: BanEntry }>(),
|
||||
'Unban User': props<{ oderId: string }>(),
|
||||
'Unban User Success': props<{ oderId: string }>(),
|
||||
|
||||
export const loadRoomUsersFailure = createAction(
|
||||
'[Users] Load Room Users Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
'Load Bans': emptyProps(),
|
||||
'Load Bans Success': props<{ bans: BanEntry[] }>(),
|
||||
|
||||
// User joined
|
||||
export const userJoined = createAction(
|
||||
'[Users] User Joined',
|
||||
props<{ user: User }>()
|
||||
);
|
||||
'Admin Mute User': props<{ userId: string }>(),
|
||||
'Admin Unmute User': props<{ userId: string }>(),
|
||||
|
||||
// User left
|
||||
export const userLeft = createAction(
|
||||
'[Users] User Left',
|
||||
props<{ userId: string }>()
|
||||
);
|
||||
'Sync Users': props<{ users: User[] }>(),
|
||||
'Clear Users': emptyProps(),
|
||||
'Update Host': props<{ userId: string }>(),
|
||||
|
||||
// Update user
|
||||
export const updateUser = createAction(
|
||||
'[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> }>()
|
||||
);
|
||||
'Update Voice State': props<{ userId: string; voiceState: Partial<VoiceState> }>(),
|
||||
'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 { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||
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 { v4 as uuidv4 } from 'uuid';
|
||||
import * as UsersActions from './users.actions';
|
||||
import { UsersActions } from './users.actions';
|
||||
import { selectCurrentUser, selectCurrentUserId, selectHostId } from './users.selectors';
|
||||
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
||||
import { DatabaseService } from '../../core/services/database.service';
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
import { User, BanEntry } from '../../core/models';
|
||||
import { BanEntry } from '../../core/models';
|
||||
|
||||
@Injectable()
|
||||
export class UsersEffects {
|
||||
@@ -19,6 +22,7 @@ export class UsersEffects {
|
||||
private webrtc = inject(WebRTCService);
|
||||
|
||||
// Load current user from storage
|
||||
/** Loads the persisted current user from the local database on startup. */
|
||||
loadCurrentUser$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
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(() =>
|
||||
this.actions$.pipe(
|
||||
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(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.kickUser),
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom)
|
||||
this.store.select(selectCurrentRoom),
|
||||
),
|
||||
mergeMap(([{ userId }, currentUser, currentRoom]) => {
|
||||
if (!currentUser || !currentRoom) {
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
if (!currentUser || !currentRoom) return EMPTY;
|
||||
|
||||
// Check if current user has permission to kick
|
||||
if (currentUser.role !== 'host' && currentUser.role !== 'admin' && currentUser.role !== 'moderator') {
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
const canKick =
|
||||
currentUser.role === 'host' ||
|
||||
currentUser.role === 'admin' ||
|
||||
currentUser.role === 'moderator';
|
||||
if (!canKick) return EMPTY;
|
||||
|
||||
// Send kick signal to the target user
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'kick',
|
||||
targetUserId: userId,
|
||||
@@ -80,32 +82,26 @@ export class UsersEffects {
|
||||
});
|
||||
|
||||
return of(UsersActions.kickUserSuccess({ userId }));
|
||||
})
|
||||
)
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Ban user
|
||||
/** Bans a user, persists the ban locally, and broadcasts a ban signal to peers. */
|
||||
banUser$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.banUser),
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom)
|
||||
this.store.select(selectCurrentRoom),
|
||||
),
|
||||
mergeMap(([{ userId, reason, expiresAt }, currentUser, currentRoom]) => {
|
||||
if (!currentUser || !currentRoom) {
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
if (!currentUser || !currentRoom) return EMPTY;
|
||||
|
||||
// Check permission
|
||||
if (currentUser.role !== 'host' && currentUser.role !== 'admin') {
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
const canBan = currentUser.role === 'host' || currentUser.role === 'admin';
|
||||
if (!canBan) return EMPTY;
|
||||
|
||||
// Add to ban list
|
||||
const banId = uuidv4();
|
||||
const ban: BanEntry = {
|
||||
oderId: banId,
|
||||
oderId: uuidv4(),
|
||||
userId,
|
||||
roomId: currentRoom.id,
|
||||
bannedBy: currentUser.id,
|
||||
@@ -115,8 +111,6 @@ export class UsersEffects {
|
||||
};
|
||||
|
||||
this.db.saveBan(ban);
|
||||
|
||||
// Send ban signal
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'ban',
|
||||
targetUserId: userId,
|
||||
@@ -126,24 +120,24 @@ export class UsersEffects {
|
||||
});
|
||||
|
||||
return of(UsersActions.banUserSuccess({ userId, ban }));
|
||||
})
|
||||
)
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Unban user
|
||||
/** Removes a ban entry from the local database. */
|
||||
unbanUser$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.unbanUser),
|
||||
switchMap(({ oderId }) =>
|
||||
from(this.db.removeBan(oderId)).pipe(
|
||||
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(() =>
|
||||
this.actions$.pipe(
|
||||
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(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.userLeft),
|
||||
@@ -168,17 +162,15 @@ export class UsersEffects {
|
||||
this.store.select(selectHostId),
|
||||
this.store.select(selectCurrentUserId)
|
||||
),
|
||||
mergeMap(([{ userId }, hostId, currentUserId]) => {
|
||||
// If the leaving user is the host, elect a new host
|
||||
if (userId === hostId && currentUserId) {
|
||||
return of(UsersActions.updateHost({ userId: currentUserId }));
|
||||
}
|
||||
return of({ type: 'NO_OP' });
|
||||
})
|
||||
mergeMap(([{ userId }, hostId, currentUserId]) =>
|
||||
userId === hostId && currentUserId
|
||||
? of(UsersActions.updateHost({ userId: currentUserId }))
|
||||
: EMPTY,
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Persist user changes to database
|
||||
/** Persists user profile changes to the local database whenever the current user is updated. */
|
||||
persistUser$ = createEffect(
|
||||
() =>
|
||||
this.actions$.pipe(
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { createReducer, on } from '@ngrx/store';
|
||||
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
|
||||
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> {
|
||||
/** ID of the locally authenticated user. */
|
||||
currentUserId: string | null;
|
||||
/** ID of the room host (owner). */
|
||||
hostId: string | null;
|
||||
/** Whether a user-loading operation is in progress. */
|
||||
loading: boolean;
|
||||
/** Most recent error message from user operations. */
|
||||
error: string | null;
|
||||
/** List of active bans for the current room. */
|
||||
bans: BanEntry[];
|
||||
}
|
||||
|
||||
@@ -137,7 +143,7 @@ export const usersReducer = createReducer(
|
||||
// Unban user
|
||||
on(UsersActions.unbanUserSuccess, (state, { oderId }) => ({
|
||||
...state,
|
||||
bans: state.bans.filter((b) => b.oderId !== oderId),
|
||||
bans: state.bans.filter((ban) => ban.oderId !== oderId),
|
||||
})),
|
||||
|
||||
// Load bans
|
||||
|
||||
@@ -1,84 +1,103 @@
|
||||
import { createFeatureSelector, createSelector } from '@ngrx/store';
|
||||
import { UsersState, usersAdapter } from './users.reducer';
|
||||
|
||||
/** Selects the top-level users feature state. */
|
||||
export const selectUsersState = createFeatureSelector<UsersState>('users');
|
||||
|
||||
const { selectIds, selectEntities, selectAll, selectTotal } = usersAdapter.getSelectors();
|
||||
|
||||
/** Selects all user entities as a flat array. */
|
||||
export const selectAllUsers = createSelector(selectUsersState, selectAll);
|
||||
|
||||
/** Selects the user entity dictionary keyed by ID. */
|
||||
export const selectUsersEntities = createSelector(selectUsersState, selectEntities);
|
||||
|
||||
/** Selects all user IDs. */
|
||||
export const selectUsersIds = createSelector(selectUsersState, selectIds);
|
||||
|
||||
/** Selects the total count of users. */
|
||||
export const selectUsersTotal = createSelector(selectUsersState, selectTotal);
|
||||
|
||||
/** Whether a user-loading operation is in progress. */
|
||||
export const selectUsersLoading = createSelector(
|
||||
selectUsersState,
|
||||
(state) => state.loading
|
||||
);
|
||||
|
||||
/** Selects the most recent users-related error message. */
|
||||
export const selectUsersError = createSelector(
|
||||
selectUsersState,
|
||||
(state) => state.error
|
||||
);
|
||||
|
||||
/** Selects the current (local) user's ID, or null. */
|
||||
export const selectCurrentUserId = createSelector(
|
||||
selectUsersState,
|
||||
(state) => state.currentUserId
|
||||
);
|
||||
|
||||
/** Selects the host's user ID. */
|
||||
export const selectHostId = createSelector(
|
||||
selectUsersState,
|
||||
(state) => state.hostId
|
||||
);
|
||||
|
||||
/** Selects all active ban entries for the current room. */
|
||||
export const selectBannedUsers = createSelector(
|
||||
selectUsersState,
|
||||
(state) => state.bans
|
||||
);
|
||||
|
||||
/** Selects the full User entity for the current (local) user. */
|
||||
export const selectCurrentUser = createSelector(
|
||||
selectUsersEntities,
|
||||
selectCurrentUserId,
|
||||
(entities, currentUserId) => (currentUserId ? entities[currentUserId] : null)
|
||||
);
|
||||
|
||||
/** Selects the full User entity for the room host. */
|
||||
export const selectHost = createSelector(
|
||||
selectUsersEntities,
|
||||
selectHostId,
|
||||
(entities, hostId) => (hostId ? entities[hostId] : null)
|
||||
);
|
||||
|
||||
/** Creates a selector that returns a single user by their ID. */
|
||||
export const selectUserById = (id: string) =>
|
||||
createSelector(selectUsersEntities, (entities) => entities[id]);
|
||||
|
||||
/** Whether the current user is the room host. */
|
||||
export const selectIsCurrentUserHost = createSelector(
|
||||
selectCurrentUserId,
|
||||
selectHostId,
|
||||
(currentUserId, hostId) => currentUserId === hostId
|
||||
);
|
||||
|
||||
/** Whether the current user holds an elevated role (host, admin, or moderator). */
|
||||
export const selectIsCurrentUserAdmin = createSelector(
|
||||
selectCurrentUser,
|
||||
(user) => user?.role === 'host' || user?.role === 'admin' || user?.role === 'moderator'
|
||||
);
|
||||
|
||||
/** Selects users who are currently online (not offline). */
|
||||
export const selectOnlineUsers = createSelector(
|
||||
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) =>
|
||||
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(
|
||||
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(
|
||||
selectCurrentUser,
|
||||
(user) => user?.role === 'host'
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
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)
|
||||
.catch((err) => console.error(err));
|
||||
|
||||
@@ -64,27 +64,3 @@
|
||||
-ms-overflow-style: 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