diff --git a/angular.json b/angular.json index ad43434..4f191d5 100644 --- a/angular.json +++ b/angular.json @@ -65,8 +65,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "500kB", - "maximumError": "1MB" + "maximumWarning": "1MB", + "maximumError": "2MB" }, { "type": "anyComponentStyle", diff --git a/electron/database.js b/electron/database.js index a106347..d365a05 100644 --- a/electron/database.js +++ b/electron/database.js @@ -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'); diff --git a/electron/main.js b/electron/main.js index 95947e4..bbe13bb 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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', () => { diff --git a/electron/preload.js b/electron/preload.js index 6443e2e..027aa3a 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -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'), diff --git a/package.json b/package.json index 676590f..f7519df 100644 --- a/package.json +++ b/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" + ] } } } diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 25fef3f..55b239f 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -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, diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 14c0b17..fe79b3f 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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), }, ]; diff --git a/src/app/app.ts b/src/app/app.ts index e27564c..760f212 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -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 clicks and open them externally. */ + @HostListener('document:click', ['$event']) + onGlobalLinkClick(evt: MouseEvent): void { + this.externalLinks.handleClick(evt); + } + async ngOnInit(): Promise { // 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 diff --git a/src/app/core/constants.ts b/src/app/core/constants.ts new file mode 100644 index 0000000..c44e7ed --- /dev/null +++ b/src/app/core/constants.ts @@ -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; diff --git a/src/app/core/models/index.ts b/src/app/core/models/index.ts index ec66ed0..7ecb16e 100644 --- a/src/app/core/models/index.ts +++ b/src/app/core/models/index.ts @@ -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; + /** 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; + /** 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; } diff --git a/src/app/core/services/attachment.service.ts b/src/app/core/services/attachment.service.ts index e1c3484..d0d58cf 100644 --- a/src/app/core/services/attachment.service.ts +++ b/src/app/core/services/attachment.service.ts @@ -5,87 +5,159 @@ import { Store } from '@ngrx/store'; import { selectCurrentRoomName } from '../../store/rooms/rooms.selectors'; import { DatabaseService } from './database.service'; +/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */ +const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB + +/** Maximum file size (bytes) that is automatically saved to disk (Electron). */ +const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB + +/** + * EWMA smoothing weight for the *previous* speed estimate. + * The complementary weight (1 − this value) is applied to the + * instantaneous measurement. + */ +const EWMA_PREVIOUS_WEIGHT = 0.7; +const EWMA_CURRENT_WEIGHT = 1 - EWMA_PREVIOUS_WEIGHT; + +/** Fallback MIME type when none is provided by the sender. */ +const DEFAULT_MIME_TYPE = 'application/octet-stream'; + +/** localStorage key used by the legacy attachment store (migration target). */ +const LEGACY_STORAGE_KEY = 'metoyou_attachments'; + +/** + * Metadata describing a file attachment linked to a chat message. + */ export interface AttachmentMeta { + /** Unique attachment identifier. */ id: string; + /** ID of the parent message. */ messageId: string; + /** Original file name. */ filename: string; + /** File size in bytes. */ size: number; + /** MIME type (e.g. `image/png`). */ mime: string; + /** Whether the file is a raster/vector image. */ isImage: boolean; + /** Peer ID of the user who originally uploaded the file. */ uploaderPeerId?: string; - filePath?: string; // Electron-only: absolute path to original file - savedPath?: string; // Electron-only: disk cache path where image was saved + /** Electron-only: absolute path to the uploader's original file. */ + filePath?: string; + /** Electron-only: disk-cache path where the file was saved locally. */ + savedPath?: string; } +/** + * Runtime representation of an attachment including download + * progress and blob URL state. + */ export interface Attachment extends AttachmentMeta { + /** Whether the file content is available locally (blob URL set). */ available: boolean; + /** Object URL for in-browser rendering / download. */ objectUrl?: string; + /** Number of bytes received so far (during chunked download). */ receivedBytes?: number; - // Runtime-only stats + /** Estimated download speed (bytes / second), EWMA-smoothed. */ speedBps?: number; + /** Epoch ms when the download started. */ startedAtMs?: number; + /** Epoch ms of the most recent chunk received. */ lastUpdateMs?: number; } +/** + * Manages peer-to-peer file transfer, local persistence, and + * in-memory caching of file attachments linked to chat messages. + * + * Files are announced to peers via a `file-announce` event and + * transferred using a chunked base-64 protocol over WebRTC data + * channels. On Electron, files under {@link MAX_AUTO_SAVE_SIZE_BYTES} + * are automatically persisted to the app-data directory. + */ @Injectable({ providedIn: 'root' }) export class AttachmentService { private readonly webrtc = inject(WebRTCService); private readonly ngrxStore = inject(Store); - private readonly db = inject(DatabaseService); + private readonly database = inject(DatabaseService); - // messageId -> attachments + /** Primary index: `messageId → Attachment[]`. */ private attachmentsByMessage = new Map(); + + /** Incremented on every mutation so signal consumers re-render. */ updated = signal(0); - // Keep original files for uploaders to fulfill requests - private originals = new Map(); // key: messageId:fileId - // Track cancelled transfers (uploader side) keyed by messageId:fileId:peerId + /** + * In-memory map of original `File` objects retained by the uploader + * so that file-request handlers can stream them on demand. + * Key format: `"messageId:fileId"`. + */ + private originalFiles = new Map(); + + /** Set of `"messageId:fileId:peerId"` keys representing cancelled transfers. */ private cancelledTransfers = new Set(); - private makeKey(messageId: string, fileId: string, peerId: string): string { return `${messageId}:${fileId}:${peerId}`; } - private isCancelled(targetPeerId: string, messageId: string, fileId: string): boolean { - return this.cancelledTransfers.has(this.makeKey(messageId, fileId, targetPeerId)); - } - /** Check whether a file is an image or video. */ - private isMedia(att: { mime: string }): boolean { - return att.mime.startsWith('image/') || att.mime.startsWith('video/'); - } + /** + * Map of `"messageId:fileId" → Set` tracking which peers + * have already been asked for a particular file. + */ + private pendingRequests = new Map>(); - private dbInitDone = false; + /** + * In-flight chunk assembly buffers. + * `"messageId:fileId" → ArrayBuffer[]` (indexed by chunk ordinal). + */ + private chunkBuffers = new Map(); + + /** + * Number of chunks received for each in-flight transfer. + * `"messageId:fileId" → number`. + */ + private chunkCounts = new Map(); + + /** Whether the initial DB load has been performed. */ + private isDatabaseInitialised = false; constructor() { effect(() => { - if (this.db.isReady() && !this.dbInitDone) { - this.dbInitDone = true; - this.initFromDb(); + if (this.database.isReady() && !this.isDatabaseInitialised) { + this.isDatabaseInitialised = true; + this.initFromDatabase(); } }); } - private async initFromDb(): Promise { - await this.loadFromDb(); - await this.migrateFromLocalStorage(); - await this.tryLoadSavedFiles(); - } - + /** Return the attachment list for a given message. */ getForMessage(messageId: string): Attachment[] { - return this.attachmentsByMessage.get(messageId) || []; + return this.attachmentsByMessage.get(messageId) ?? []; } - /** Return minimal attachment metadata for a set of message IDs (for sync). */ - getAttachmentMetasForMessages(messageIds: string[]): Record { + /** + * Build a map of minimal attachment metadata for a set of message IDs. + * Used during inventory-based message synchronisation so that peers + * learn about attachments without transferring file content. + * + * @param messageIds - Messages to collect metadata for. + * @returns Record keyed by messageId whose values are arrays of + * {@link AttachmentMeta} (local paths are scrubbed). + */ + getAttachmentMetasForMessages( + messageIds: string[], + ): Record { const result: Record = {}; - for (const mid of messageIds) { - const list = this.attachmentsByMessage.get(mid); - if (list && list.length > 0) { - result[mid] = list.map(a => ({ - id: a.id, - messageId: a.messageId, - filename: a.filename, - size: a.size, - mime: a.mime, - isImage: a.isImage, - uploaderPeerId: a.uploaderPeerId, + for (const messageId of messageIds) { + const attachments = this.attachmentsByMessage.get(messageId); + if (attachments && attachments.length > 0) { + result[messageId] = attachments.map((attachment) => ({ + id: attachment.id, + messageId: attachment.messageId, + filename: attachment.filename, + size: attachment.size, + mime: attachment.mime, + isImage: attachment.isImage, + uploaderPeerId: attachment.uploaderPeerId, filePath: undefined, // never share local paths savedPath: undefined, // never share local paths })); @@ -94,462 +166,354 @@ export class AttachmentService { return result; } - /** Register attachments received via message sync (metadata only). */ - registerSyncedAttachments(attachmentMap: Record): void { - const newAtts: Attachment[] = []; + /** + * Register attachment metadata received via message sync + * (content is not yet available — only metadata). + * + * @param attachmentMap - Map of `messageId → AttachmentMeta[]` from peer. + */ + registerSyncedAttachments( + attachmentMap: Record, + ): void { + const newAttachments: Attachment[] = []; + for (const [messageId, metas] of Object.entries(attachmentMap)) { - const existing = this.attachmentsByMessage.get(messageId) || []; + const existing = this.attachmentsByMessage.get(messageId) ?? []; for (const meta of metas) { - if (!existing.find(e => e.id === meta.id)) { - const att: Attachment = { ...meta, available: false, receivedBytes: 0 }; - existing.push(att); - newAtts.push(att); + const alreadyKnown = existing.find((entry) => entry.id === meta.id); + if (!alreadyKnown) { + const attachment: Attachment = { ...meta, available: false, receivedBytes: 0 }; + existing.push(attachment); + newAttachments.push(attachment); } } if (existing.length > 0) { this.attachmentsByMessage.set(messageId, existing); } } - if (newAtts.length > 0) { - this.updated.set(this.updated() + 1); - for (const att of newAtts) { - void this.persistAttachmentMeta(att); + + if (newAttachments.length > 0) { + this.touch(); + for (const attachment of newAttachments) { + void this.persistAttachmentMeta(attachment); } } } - // Track pending requests so we can retry with other peers - // key: messageId:fileId -> Set of peer IDs already tried - private pendingRequests = new Map>(); - - /** Request a file from any connected peer that might have it. */ - requestFromAnyPeer(messageId: string, att: Attachment): void { - const connected = this.webrtc.getConnectedPeers(); - if (connected.length === 0) { + /** + * Request a file from any connected peer that might have it. + * Automatically cycles through all connected peers if the first + * one does not have the file. + * + * @param messageId - Parent message. + * @param attachment - Attachment to request. + */ + requestFromAnyPeer(messageId: string, attachment: Attachment): void { + const connectedPeers = this.webrtc.getConnectedPeers(); + if (connectedPeers.length === 0) { console.warn('[Attachments] No connected peers to request file from'); return; } - const reqKey = `${messageId}:${att.id}`; - // Reset tried-peers for a fresh request - this.pendingRequests.set(reqKey, new Set()); - this.sendFileRequestToNextPeer(messageId, att.id, att.uploaderPeerId); + const requestKey = this.buildRequestKey(messageId, attachment.id); + this.pendingRequests.set(requestKey, new Set()); + this.sendFileRequestToNextPeer(messageId, attachment.id, attachment.uploaderPeerId); } - /** Send file-request to the next untried peer. Returns true if a request was sent. */ - private sendFileRequestToNextPeer(messageId: string, fileId: string, preferredPeerId?: string): boolean { - const connected = this.webrtc.getConnectedPeers(); - const reqKey = `${messageId}:${fileId}`; - const tried = this.pendingRequests.get(reqKey) || new Set(); - - // Pick the best untried peer: preferred first, then any - let target: string | undefined; - if (preferredPeerId && connected.includes(preferredPeerId) && !tried.has(preferredPeerId)) { - target = preferredPeerId; - } else { - target = connected.find(p => !tried.has(p)); - } - - if (!target) { - console.warn(`[Attachments] All ${tried.size} peers tried for ${reqKey}, none could serve`); - this.pendingRequests.delete(reqKey); - return false; - } - - tried.add(target); - this.pendingRequests.set(reqKey, tried); - console.log(`[Attachments] Requesting ${fileId} from peer ${target} (tried ${tried.size}/${connected.length})`); - this.webrtc.sendToPeer(target, { - type: 'file-request', - messageId, - fileId, - } as any); - return true; - } - - /** Handle a file-not-found response – try the next peer. */ + /** + * Handle a `file-not-found` response — try the next available peer. + */ handleFileNotFound(payload: any): void { const { messageId, fileId } = payload; if (!messageId || !fileId) return; - const list = this.attachmentsByMessage.get(messageId) || []; - const att = list.find(a => a.id === fileId); - this.sendFileRequestToNextPeer(messageId, fileId, att?.uploaderPeerId); + const attachments = this.attachmentsByMessage.get(messageId) ?? []; + const attachment = attachments.find((entry) => entry.id === fileId); + this.sendFileRequestToNextPeer(messageId, fileId, attachment?.uploaderPeerId); } - /** @deprecated Use requestFromAnyPeer instead */ - requestImageFromAnyPeer(messageId: string, att: Attachment): void { - this.requestFromAnyPeer(messageId, att); + /** + * Alias for {@link requestFromAnyPeer}. + * Convenience wrapper for image-specific call-sites. + */ + requestImageFromAnyPeer(messageId: string, attachment: Attachment): void { + this.requestFromAnyPeer(messageId, attachment); } - /** On startup, try loading previously saved files from disk (Electron). */ - private async tryLoadSavedFiles(): Promise { - const w: any = window as any; - if (!w?.electronAPI?.fileExists || !w?.electronAPI?.readFile) return; - try { - let changed = false; - for (const [, attachments] of this.attachmentsByMessage) { - for (const att of attachments) { - if (att.available) continue; - // 1. Try savedPath (disk cache — all file types) - if (att.savedPath) { - try { - const exists = await w.electronAPI.fileExists(att.savedPath); - if (exists) { - const base64 = await w.electronAPI.readFile(att.savedPath); - const bytes = this.base64ToUint8Array(base64); - const blob = new Blob([bytes.buffer as ArrayBuffer], { type: att.mime }); - att.objectUrl = URL.createObjectURL(blob); - att.available = true; - // Re-populate originals so handleFileRequest step 1 works after restart - const file = new File([blob], att.filename, { type: att.mime }); - this.originals.set(`${att.messageId}:${att.id}`, file); - changed = true; - continue; - } - } catch {} - } - // 2. Try filePath (uploader's original) - if (att.filePath) { - try { - const exists = await w.electronAPI.fileExists(att.filePath); - if (exists) { - const base64 = await w.electronAPI.readFile(att.filePath); - const bytes = this.base64ToUint8Array(base64); - const blob = new Blob([bytes.buffer as ArrayBuffer], { type: att.mime }); - att.objectUrl = URL.createObjectURL(blob); - att.available = true; - // Re-populate originals so handleFileRequest step 1 works after restart - const file = new File([blob], att.filename, { type: att.mime }); - this.originals.set(`${att.messageId}:${att.id}`, file); - changed = true; - // Save to disk cache for future use - if (att.size <= 10 * 1024 * 1024) { - void this.saveFileToDisk(att, blob); - } - continue; - } - } catch {} - } - } - } - if (changed) { - this.updated.set(this.updated() + 1); - } - } catch {} + /** Alias for {@link requestFromAnyPeer}. */ + requestFile(messageId: string, attachment: Attachment): void { + this.requestFromAnyPeer(messageId, attachment); } - // Publish attachments for a sent message and stream images <=10MB - async publishAttachments(messageId: string, files: File[], uploaderPeerId?: string): Promise { + /** + * Announce and optionally stream files attached to a newly sent + * message to all connected peers. + * + * 1. Each file is assigned a UUID. + * 2. A `file-announce` event is broadcast to peers. + * 3. Images ≤ {@link MAX_AUTO_SAVE_SIZE_BYTES} are immediately + * streamed as chunked base-64. + * + * @param messageId - ID of the parent message. + * @param files - Array of user-selected `File` objects. + * @param uploaderPeerId - Peer ID of the uploader (used by receivers + * to prefer the original source when requesting content). + */ + async publishAttachments( + messageId: string, + files: File[], + uploaderPeerId?: string, + ): Promise { const attachments: Attachment[] = []; + for (const file of files) { - const id = uuidv4(); - const meta: Attachment = { - id, + const fileId = uuidv4(); + const attachment: Attachment = { + id: fileId, messageId, filename: file.name, size: file.size, - mime: file.type || 'application/octet-stream', + mime: file.type || DEFAULT_MIME_TYPE, isImage: file.type.startsWith('image/'), uploaderPeerId, filePath: (file as any)?.path, available: false, }; - attachments.push(meta); + attachments.push(attachment); - // Save original for request-based transfer - this.originals.set(`${messageId}:${id}`, file); - console.log(`[Attachments] publishAttachments: stored original key="${messageId}:${id}" (${file.name}, ${file.size} bytes)`); + // Retain the original File so we can serve file-request later + this.originalFiles.set(`${messageId}:${fileId}`, file); - // Ensure uploader sees their own files immediately (all types, not just images) + // Make the file immediately visible to the uploader try { - const url = URL.createObjectURL(file); - meta.objectUrl = url; - meta.available = true; - } catch {} + attachment.objectUrl = URL.createObjectURL(file); + attachment.available = true; + } catch { /* non-critical */ } - // Save ALL files ≤10MB to disk (Electron) for persistence across restarts - if (meta.size <= 10 * 1024 * 1024) { - void this.saveFileToDisk(meta, file); + // Auto-save small files to Electron disk cache + if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) { + void this.saveFileToDisk(attachment, file); } - // Announce to peers + // Broadcast metadata to peers this.webrtc.broadcastMessage({ type: 'file-announce', messageId, file: { - id, - filename: meta.filename, - size: meta.size, - mime: meta.mime, - isImage: meta.isImage, + id: fileId, + filename: attachment.filename, + size: attachment.size, + mime: attachment.mime, + isImage: attachment.isImage, uploaderPeerId, }, } as any); - // Stream image content if small enough (<= 10MB) - if (meta.isImage && meta.size <= 10 * 1024 * 1024) { - await this.streamFileToPeers(messageId, id, file); + // Auto-stream small images + if (attachment.isImage && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) { + await this.streamFileToPeers(messageId, fileId, file); } } - this.attachmentsByMessage.set(messageId, [ ...(this.attachmentsByMessage.get(messageId) || []), ...attachments ]); - this.updated.set(this.updated() + 1); - for (const att of attachments) { - void this.persistAttachmentMeta(att); + const existingList = this.attachmentsByMessage.get(messageId) ?? []; + this.attachmentsByMessage.set(messageId, [...existingList, ...attachments]); + this.touch(); + + for (const attachment of attachments) { + void this.persistAttachmentMeta(attachment); } } - private async streamFileToPeers(messageId: string, fileId: string, file: File): Promise { - const chunkSize = 64 * 1024; // 64KB - const totalChunks = Math.ceil(file.size / chunkSize); - let offset = 0; - let index = 0; - while (offset < file.size) { - const slice = file.slice(offset, offset + chunkSize); - const arrayBuffer = await slice.arrayBuffer(); - // Convert to base64 for JSON transport - const base64 = this.arrayBufferToBase64(arrayBuffer); - this.webrtc.broadcastMessage({ - type: 'file-chunk', - messageId, - fileId, - index, - total: totalChunks, - data: base64, - } as any); - offset += chunkSize; - index++; - } - } - - // Incoming events from peers + /** Handle a `file-announce` event from a peer. */ handleFileAnnounce(payload: any): void { const { messageId, file } = payload; if (!messageId || !file) return; - const list = this.attachmentsByMessage.get(messageId) || []; - const exists = list.find((a: Attachment) => a.id === file.id); - if (!exists) { - const att: Attachment = { - id: file.id, - messageId, - filename: file.filename, - size: file.size, - mime: file.mime, - isImage: !!file.isImage, - uploaderPeerId: file.uploaderPeerId, - available: false, - receivedBytes: 0, - }; - list.push(att); - this.attachmentsByMessage.set(messageId, list); - this.updated.set(this.updated() + 1); - void this.persistAttachmentMeta(att); - } + + const list = this.attachmentsByMessage.get(messageId) ?? []; + const alreadyKnown = list.find((entry) => entry.id === file.id); + if (alreadyKnown) return; + + const attachment: Attachment = { + id: file.id, + messageId, + filename: file.filename, + size: file.size, + mime: file.mime, + isImage: !!file.isImage, + uploaderPeerId: file.uploaderPeerId, + available: false, + receivedBytes: 0, + }; + list.push(attachment); + this.attachmentsByMessage.set(messageId, list); + this.touch(); + void this.persistAttachmentMeta(attachment); } + /** + * Handle an incoming `file-chunk` event. + * + * Chunks are collected in {@link chunkBuffers} until the total + * expected count is reached, at which point the buffers are + * assembled into a Blob and an object URL is created. + */ handleFileChunk(payload: any): void { const { messageId, fileId, index, total, data } = payload; - if (!messageId || !fileId || typeof index !== 'number' || typeof total !== 'number' || !data) return; - const list = this.attachmentsByMessage.get(messageId) || []; - const att = list.find((a: Attachment) => a.id === fileId); - if (!att) return; + if ( + !messageId || !fileId || + typeof index !== 'number' || + typeof total !== 'number' || + !data + ) return; - // Decode base64 and append to Blob parts - const bytes = this.base64ToUint8Array(data); - const partsKey = `${messageId}:${fileId}:parts`; - const countKey = `${messageId}:${fileId}:count`; - let parts = (this as any)[partsKey] as ArrayBuffer[] | undefined; - if (!parts) { - parts = new Array(total); - (this as any)[partsKey] = parts; - (this as any)[countKey] = 0; + const list = this.attachmentsByMessage.get(messageId) ?? []; + const attachment = list.find((entry) => entry.id === fileId); + if (!attachment) return; + + const decodedBytes = this.base64ToUint8Array(data); + const assemblyKey = `${messageId}:${fileId}`; + + // Initialise assembly buffer on first chunk + let chunkBuffer = this.chunkBuffers.get(assemblyKey); + if (!chunkBuffer) { + chunkBuffer = new Array(total); + this.chunkBuffers.set(assemblyKey, chunkBuffer); + this.chunkCounts.set(assemblyKey, 0); } - if (!parts[index]) { - parts[index] = bytes.buffer as ArrayBuffer; - (this as any)[countKey] = ((this as any)[countKey] as number) + 1; + + // Store the chunk (idempotent: ignore duplicate indices) + if (!chunkBuffer[index]) { + chunkBuffer[index] = decodedBytes.buffer as ArrayBuffer; + this.chunkCounts.set(assemblyKey, (this.chunkCounts.get(assemblyKey) ?? 0) + 1); } + + // Update progress stats const now = Date.now(); - const prevReceived = att.receivedBytes || 0; - att.receivedBytes = prevReceived + bytes.byteLength; - if (!att.startedAtMs) att.startedAtMs = now; - if (!att.lastUpdateMs) att.lastUpdateMs = now; - const deltaMs = Math.max(1, now - att.lastUpdateMs); - const instBps = (bytes.byteLength / deltaMs) * 1000; - const prevSpeed = att.speedBps || instBps; - // EWMA smoothing - att.speedBps = 0.7 * prevSpeed + 0.3 * instBps; - att.lastUpdateMs = now; - // Trigger UI update for real-time progress - this.updated.set(this.updated() + 1); + const previousReceived = attachment.receivedBytes ?? 0; + attachment.receivedBytes = previousReceived + decodedBytes.byteLength; - const receivedCount = (this as any)[countKey] as number; - if (receivedCount === total || (att.receivedBytes || 0) >= att.size) { - const finalParts = (this as any)[partsKey] as ArrayBuffer[]; - if (finalParts.every((p) => p instanceof ArrayBuffer)) { - const blob = new Blob(finalParts, { type: att.mime }); - att.available = true; - att.objectUrl = URL.createObjectURL(blob); - // Auto-save ALL received files to disk under app data (Electron) - if (att.size <= 10 * 1024 * 1024) { - void this.saveFileToDisk(att, blob); + if (!attachment.startedAtMs) attachment.startedAtMs = now; + if (!attachment.lastUpdateMs) attachment.lastUpdateMs = now; + + const elapsedMs = Math.max(1, now - attachment.lastUpdateMs); + const instantaneousBps = (decodedBytes.byteLength / elapsedMs) * 1000; + const previousSpeed = attachment.speedBps ?? instantaneousBps; + attachment.speedBps = + EWMA_PREVIOUS_WEIGHT * previousSpeed + + EWMA_CURRENT_WEIGHT * instantaneousBps; + attachment.lastUpdateMs = now; + + this.touch(); // trigger UI update for progress bars + + // Check if assembly is complete + const receivedChunkCount = this.chunkCounts.get(assemblyKey) ?? 0; + if (receivedChunkCount === total || (attachment.receivedBytes ?? 0) >= attachment.size) { + const completeBuffer = this.chunkBuffers.get(assemblyKey); + if (completeBuffer && completeBuffer.every((part) => part instanceof ArrayBuffer)) { + const blob = new Blob(completeBuffer, { type: attachment.mime }); + attachment.available = true; + attachment.objectUrl = URL.createObjectURL(blob); + + if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) { + void this.saveFileToDisk(attachment, blob); } - // Final update - delete (this as any)[partsKey]; - delete (this as any)[countKey]; - this.updated.set(this.updated() + 1); - void this.persistAttachmentMeta(att); + + // Clean up assembly state + this.chunkBuffers.delete(assemblyKey); + this.chunkCounts.delete(assemblyKey); + this.touch(); + void this.persistAttachmentMeta(attachment); } } } - private async saveFileToDisk(att: Attachment, blob: Blob): Promise { - try { - const w: any = window as any; - const appData: string | undefined = await w?.electronAPI?.getAppDataPath?.(); - if (!appData) return; - const roomName = await new Promise((resolve) => { - let name = ''; - const sub = this.ngrxStore.select(selectCurrentRoomName).subscribe((n) => { name = n || ''; resolve(name); sub.unsubscribe(); }); - }); - const safeRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room'; - const subDir = att.mime.startsWith('video/') ? 'video' : att.mime.startsWith('image/') ? 'image' : 'files'; - const dir = `${appData}/server/${safeRoom}/${subDir}`; - await w.electronAPI.ensureDir(dir); - const arrayBuffer = await blob.arrayBuffer(); - const base64 = this.arrayBufferToBase64(arrayBuffer); - const diskPath = `${dir}/${att.filename}`; - await w.electronAPI.writeFile(diskPath, base64); - att.savedPath = diskPath; - void this.persistAttachmentMeta(att); - } catch {} - } - - requestFile(messageId: string, att: Attachment): void { - this.requestFromAnyPeer(messageId, att); - } - - // Cancel an in-progress request from the requester side - cancelRequest(messageId: string, att: Attachment): void { - const target = att.uploaderPeerId; - if (!target) return; - try { - // Reset local assembly state - const partsKey = `${messageId}:${att.id}:parts`; - const countKey = `${messageId}:${att.id}:count`; - delete (this as any)[partsKey]; - delete (this as any)[countKey]; - att.receivedBytes = 0; - att.speedBps = 0; - att.startedAtMs = undefined; - att.lastUpdateMs = undefined; - if (att.objectUrl) { - try { URL.revokeObjectURL(att.objectUrl); } catch {} - att.objectUrl = undefined; - } - att.available = false; - this.updated.set(this.updated() + 1); - // Notify uploader to stop streaming - this.webrtc.sendToPeer(target, { - type: 'file-cancel', - messageId, - fileId: att.id, - } as any); - } catch {} - } - - // When we receive a request, stream the file if we have it (uploader or any peer with cached copy) + /** + * Handle an incoming `file-request` from a peer by streaming the + * file content if available locally. + * + * Lookup order: + * 1. In-memory original (`originalFiles` map). + * 2. Electron `filePath` (uploader's original on disk). + * 3. Electron `savedPath` (disk-cache copy). + * 4. Electron disk-cache by room name (backward compat). + * 5. In-memory object-URL blob (browser fallback). + * + * If none of these sources has the file, a `file-not-found` + * message is sent so the requester can try another peer. + */ async handleFileRequest(payload: any): Promise { const { messageId, fileId, fromPeerId } = payload; - if (!messageId || !fileId || !fromPeerId) { - console.warn('[Attachments] handleFileRequest: missing fields', { messageId, fileId, fromPeerId }); - return; - } - console.log(`[Attachments] handleFileRequest for ${fileId} (msg=${messageId}) from peer ${fromPeerId}`); - console.log(`[Attachments] originals map has ${this.originals.size} entries: [${[...this.originals.keys()].join(', ')}]`); + if (!messageId || !fileId || !fromPeerId) return; - // 1. Check in-memory originals (uploader case) + // 1. In-memory original const exactKey = `${messageId}:${fileId}`; - let original = this.originals.get(exactKey); + let originalFile = this.originalFiles.get(exactKey); - // 1b. Fallback: search originals by fileId alone (handles rare messageId drift) - if (!original) { - for (const [key, file] of this.originals) { + // 1b. Fallback: search by fileId suffix (handles rare messageId drift) + if (!originalFile) { + for (const [key, file] of this.originalFiles) { if (key.endsWith(`:${fileId}`)) { - console.warn(`[Attachments] Exact key "${exactKey}" not found, but matched by fileId via key "${key}"`); - original = file; + originalFile = file; break; } } } - if (original) { - console.log(`[Attachments] Serving ${fileId} from in-memory original (${original.size} bytes)`); - await this.streamFileToPeer(fromPeerId, messageId, fileId, original); + if (originalFile) { + await this.streamFileToPeer(fromPeerId, messageId, fileId, originalFile); return; } - const list = this.attachmentsByMessage.get(messageId) || []; - const att = list.find((a: Attachment) => a.id === fileId); - const w: any = window as any; + const list = this.attachmentsByMessage.get(messageId) ?? []; + const attachment = list.find((entry) => entry.id === fileId); + const electronApi = (window as any)?.electronAPI; - // 2. Check Electron file-path fallback (uploader's original path) - if (att?.filePath && w?.electronAPI?.fileExists && w?.electronAPI?.readFile) { + // 2. Electron filePath + if (attachment?.filePath && electronApi?.fileExists && electronApi?.readFile) { try { - const exists = await w.electronAPI.fileExists(att.filePath); - if (exists) { - console.log(`[Attachments] Serving ${fileId} from original filePath: ${att.filePath}`); - await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, att.filePath); + if (await electronApi.fileExists(attachment.filePath)) { + await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, attachment.filePath); return; } - } catch {} + } catch { /* fall through */ } } - // 3. Check savedPath (disk cache recorded path) - if (att?.savedPath && w?.electronAPI?.fileExists && w?.electronAPI?.readFile) { + // 3. Electron savedPath + if (attachment?.savedPath && electronApi?.fileExists && electronApi?.readFile) { try { - const exists = await w.electronAPI.fileExists(att.savedPath); - if (exists) { - console.log(`[Attachments] Serving ${fileId} from savedPath: ${att.savedPath}`); - await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, att.savedPath); + if (await electronApi.fileExists(attachment.savedPath)) { + await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, attachment.savedPath); return; } - } catch {} + } catch { /* fall through */ } } - // 3b. Fallback: Check Electron disk cache by room name (backward compat) - if (att?.isImage && w?.electronAPI?.getAppDataPath && w?.electronAPI?.fileExists && w?.electronAPI?.readFile) { + // 3b. Disk cache by room name (backward compatibility) + if (attachment?.isImage && electronApi?.getAppDataPath && electronApi?.fileExists && electronApi?.readFile) { try { - const appData = await w.electronAPI.getAppDataPath(); - if (appData) { - const roomName = await new Promise((resolve) => { - const sub = this.ngrxStore.select(selectCurrentRoomName).subscribe((n) => { resolve(n || ''); sub.unsubscribe(); }); - }); - const safeRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room'; - const path = `${appData}/server/${safeRoom}/image/${att.filename}`; - const exists = await w.electronAPI.fileExists(path); - if (exists) { - console.log(`[Attachments] Serving ${fileId} from disk cache: ${path}`); - await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, path); + const appDataPath = await electronApi.getAppDataPath(); + if (appDataPath) { + const roomName = await this.resolveCurrentRoomName(); + const sanitisedRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room'; + const diskPath = `${appDataPath}/server/${sanitisedRoom}/image/${attachment.filename}`; + if (await electronApi.fileExists(diskPath)) { + await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, diskPath); return; } } - } catch {} + } catch { /* fall through */ } } - // 4. Check in-memory blob (received this session but not saved to disk, e.g. browser mode) - if (att?.available && att.objectUrl) { + // 4. In-memory blob + if (attachment?.available && attachment.objectUrl) { try { - const resp = await fetch(att.objectUrl); - const blob = await resp.blob(); - const file = new File([blob], att.filename, { type: att.mime }); - console.log(`[Attachments] Serving ${fileId} from in-memory blob (${blob.size} bytes)`); + const response = await fetch(attachment.objectUrl); + const blob = await response.blob(); + const file = new File([blob], attachment.filename, { type: attachment.mime }); await this.streamFileToPeer(fromPeerId, messageId, fileId, file); return; - } catch {} + } catch { /* fall through */ } } - // 5. Cannot serve – notify requester so they can try another peer - console.warn(`[Attachments] Cannot fulfill file-request for ${fileId} (msg=${messageId}) – no source found. ` + - `originals=${this.originals.size}, att=${att ? `available=${att.available},savedPath=${att.savedPath},filePath=${att.filePath}` : 'not in map'}`); + // 5. File not available locally this.webrtc.sendToPeer(fromPeerId, { type: 'file-not-found', messageId, @@ -557,134 +521,409 @@ export class AttachmentService { } as any); } - private async streamFileToPeer(targetPeerId: string, messageId: string, fileId: string, file: File): Promise { - const chunkSize = 64 * 1024; // 64KB - const totalChunks = Math.ceil(file.size / chunkSize); + /** + * Cancel an in-progress download from the requester side. + * Resets local assembly state and notifies the uploader to stop. + */ + cancelRequest(messageId: string, attachment: Attachment): void { + const targetPeerId = attachment.uploaderPeerId; + if (!targetPeerId) return; + + try { + // Reset assembly state + const assemblyKey = `${messageId}:${attachment.id}`; + this.chunkBuffers.delete(assemblyKey); + this.chunkCounts.delete(assemblyKey); + + attachment.receivedBytes = 0; + attachment.speedBps = 0; + attachment.startedAtMs = undefined; + attachment.lastUpdateMs = undefined; + + if (attachment.objectUrl) { + try { URL.revokeObjectURL(attachment.objectUrl); } catch { /* ignore */ } + attachment.objectUrl = undefined; + } + attachment.available = false; + this.touch(); + + // Notify uploader to stop streaming + this.webrtc.sendToPeer(targetPeerId, { + type: 'file-cancel', + messageId, + fileId: attachment.id, + } as any); + } catch { /* best-effort */ } + } + + /** + * Handle a `file-cancel` from the requester — record the + * cancellation so the streaming loop breaks early. + */ + handleFileCancel(payload: any): void { + const { messageId, fileId, fromPeerId } = payload; + if (!messageId || !fileId || !fromPeerId) return; + this.cancelledTransfers.add( + this.buildTransferKey(messageId, fileId, fromPeerId), + ); + } + + /** + * Provide a `File` for a pending request (uploader side) and + * stream it to the requesting peer. + */ + async fulfillRequestWithFile( + messageId: string, + fileId: string, + targetPeerId: string, + file: File, + ): Promise { + this.originalFiles.set(`${messageId}:${fileId}`, file); + await this.streamFileToPeer(targetPeerId, messageId, fileId, file); + } + + /** Bump the reactive update counter so signal-based consumers re-render. */ + private touch(): void { + this.updated.set(this.updated() + 1); + } + + /** Composite key for transfer-cancellation tracking. */ + private buildTransferKey(messageId: string, fileId: string, peerId: string): string { + return `${messageId}:${fileId}:${peerId}`; + } + + /** Composite key for pending-request tracking. */ + private buildRequestKey(messageId: string, fileId: string): string { + return `${messageId}:${fileId}`; + } + + /** Check whether a specific transfer has been cancelled. */ + private isTransferCancelled(targetPeerId: string, messageId: string, fileId: string): boolean { + return this.cancelledTransfers.has( + this.buildTransferKey(messageId, fileId, targetPeerId), + ); + } + + /** Check whether a file is an image or video. */ + private isMedia(attachment: { mime: string }): boolean { + return attachment.mime.startsWith('image/') || attachment.mime.startsWith('video/'); + } + + /** + * Send a `file-request` to the best untried peer. + * @returns `true` if a request was dispatched. + */ + private sendFileRequestToNextPeer( + messageId: string, + fileId: string, + preferredPeerId?: string, + ): boolean { + const connectedPeers = this.webrtc.getConnectedPeers(); + const requestKey = this.buildRequestKey(messageId, fileId); + const triedPeers = this.pendingRequests.get(requestKey) ?? new Set(); + + // Pick the best untried peer: preferred first, then any + let targetPeerId: string | undefined; + if (preferredPeerId && connectedPeers.includes(preferredPeerId) && !triedPeers.has(preferredPeerId)) { + targetPeerId = preferredPeerId; + } else { + targetPeerId = connectedPeers.find((peerId) => !triedPeers.has(peerId)); + } + + if (!targetPeerId) { + this.pendingRequests.delete(requestKey); + return false; + } + + triedPeers.add(targetPeerId); + this.pendingRequests.set(requestKey, triedPeers); + + this.webrtc.sendToPeer(targetPeerId, { + type: 'file-request', + messageId, + fileId, + } as any); + return true; + } + + /** Broadcast a file in base-64 chunks to all connected peers. */ + private async streamFileToPeers( + messageId: string, + fileId: string, + file: File, + ): Promise { + const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES); let offset = 0; - let index = 0; + let chunkIndex = 0; + while (offset < file.size) { - if (this.isCancelled(targetPeerId, messageId, fileId)) break; - const slice = file.slice(offset, offset + chunkSize); + const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES); const arrayBuffer = await slice.arrayBuffer(); const base64 = this.arrayBufferToBase64(arrayBuffer); + + this.webrtc.broadcastMessage({ + type: 'file-chunk', + messageId, + fileId, + index: chunkIndex, + total: totalChunks, + data: base64, + } as any); + + offset += FILE_CHUNK_SIZE_BYTES; + chunkIndex++; + } + } + + /** Stream a file in base-64 chunks to a single peer. */ + private async streamFileToPeer( + targetPeerId: string, + messageId: string, + fileId: string, + file: File, + ): Promise { + const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES); + let offset = 0; + let chunkIndex = 0; + + while (offset < file.size) { + if (this.isTransferCancelled(targetPeerId, messageId, fileId)) break; + + const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES); + const arrayBuffer = await slice.arrayBuffer(); + const base64 = this.arrayBufferToBase64(arrayBuffer); + await this.webrtc.sendToPeerBuffered(targetPeerId, { type: 'file-chunk', messageId, fileId, - index, + index: chunkIndex, total: totalChunks, data: base64, } as any); - offset += chunkSize; - index++; + + offset += FILE_CHUNK_SIZE_BYTES; + chunkIndex++; } } - // Handle cancellation message (uploader side): stop any in-progress stream to requester - handleFileCancel(payload: any): void { - const { messageId, fileId, fromPeerId } = payload; - if (!messageId || !fileId || !fromPeerId) return; - this.cancelledTransfers.add(this.makeKey(messageId, fileId, fromPeerId)); - // Optionally clear original if desired (keep for re-request) - } + /** + * Read a file from Electron disk and stream it to a peer as + * base-64 chunks. + */ + private async streamFileFromDiskToPeer( + targetPeerId: string, + messageId: string, + fileId: string, + diskPath: string, + ): Promise { + const electronApi = (window as any)?.electronAPI; + const base64Full = await electronApi.readFile(diskPath); + const fileBytes = this.base64ToUint8Array(base64Full); + const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES); + + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + if (this.isTransferCancelled(targetPeerId, messageId, fileId)) break; + + const start = chunkIndex * FILE_CHUNK_SIZE_BYTES; + const end = Math.min(fileBytes.byteLength, start + FILE_CHUNK_SIZE_BYTES); + const slice = fileBytes.subarray(start, end); + const sliceBuffer = (slice.buffer as ArrayBuffer).slice( + slice.byteOffset, + slice.byteOffset + slice.byteLength, + ); + const base64Chunk = this.arrayBufferToBase64(sliceBuffer); - /** Stream a file from Electron disk to a peer. */ - private async streamFileFromDiskToPeer(targetPeerId: string, messageId: string, fileId: string, filePath: string): Promise { - const w: any = window as any; - const base64 = await w.electronAPI.readFile(filePath); - const bytes = this.base64ToUint8Array(base64); - const chunkSize = 64 * 1024; - const totalChunks = Math.ceil(bytes.byteLength / chunkSize); - for (let i = 0; i < totalChunks; i++) { - if (this.isCancelled(targetPeerId, messageId, fileId)) break; - const slice = bytes.subarray(i * chunkSize, Math.min(bytes.byteLength, (i + 1) * chunkSize)); - const slicedBuffer = (slice.buffer as ArrayBuffer).slice(slice.byteOffset, slice.byteOffset + slice.byteLength); - const b64 = this.arrayBufferToBase64(slicedBuffer); this.webrtc.sendToPeer(targetPeerId, { type: 'file-chunk', messageId, fileId, - index: i, + index: chunkIndex, total: totalChunks, - data: b64, + data: base64Chunk, } as any); } } - // Fulfill a pending request with a user-provided file (uploader side) - async fulfillRequestWithFile(messageId: string, fileId: string, targetPeerId: string, file: File): Promise { - this.originals.set(`${messageId}:${fileId}`, file); - await this.streamFileToPeer(targetPeerId, messageId, fileId, file); + /** + * Save a file to the Electron app-data directory, organised by + * room name and media type. + */ + private async saveFileToDisk(attachment: Attachment, blob: Blob): Promise { + try { + const electronApi = (window as any)?.electronAPI; + const appDataPath: string | undefined = await electronApi?.getAppDataPath?.(); + if (!appDataPath) return; + + const roomName = await this.resolveCurrentRoomName(); + const sanitisedRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room'; + const subDirectory = attachment.mime.startsWith('video/') + ? 'video' + : attachment.mime.startsWith('image/') + ? 'image' + : 'files'; + + const directoryPath = `${appDataPath}/server/${sanitisedRoom}/${subDirectory}`; + await electronApi.ensureDir(directoryPath); + + const arrayBuffer = await blob.arrayBuffer(); + const base64 = this.arrayBufferToBase64(arrayBuffer); + const diskPath = `${directoryPath}/${attachment.filename}`; + await electronApi.writeFile(diskPath, base64); + + attachment.savedPath = diskPath; + void this.persistAttachmentMeta(attachment); + } catch { /* disk save is best-effort */ } } - private async persistAttachmentMeta(att: Attachment): Promise { - if (!this.db.isReady()) return; - try { - await this.db.saveAttachment({ - id: att.id, - messageId: att.messageId, - filename: att.filename, - size: att.size, - mime: att.mime, - isImage: att.isImage, - uploaderPeerId: att.uploaderPeerId, - filePath: att.filePath, - savedPath: att.savedPath, - }); - } catch {} - } + /** On startup, try loading previously saved files from disk (Electron). */ + private async tryLoadSavedFiles(): Promise { + const electronApi = (window as any)?.electronAPI; + if (!electronApi?.fileExists || !electronApi?.readFile) return; - private async loadFromDb(): Promise { try { - const all: AttachmentMeta[] = await this.db.getAllAttachments(); - const grouped = new Map(); - for (const a of all) { - const att: Attachment = { ...a, available: false }; - const arr = grouped.get(a.messageId) || []; - arr.push(att); - grouped.set(a.messageId, arr); - } - this.attachmentsByMessage = grouped; - this.updated.set(this.updated() + 1); - } catch {} - } + let hasChanges = false; - /** One-time migration from localStorage to database. */ - private async migrateFromLocalStorage(): Promise { - try { - const raw = localStorage.getItem('metoyou_attachments'); - if (!raw) return; - const list: AttachmentMeta[] = JSON.parse(raw); - for (const meta of list) { - const existing = this.attachmentsByMessage.get(meta.messageId) || []; - if (!existing.find(e => e.id === meta.id)) { - const att: Attachment = { ...meta, available: false }; - existing.push(att); - this.attachmentsByMessage.set(meta.messageId, existing); - void this.persistAttachmentMeta(att); + for (const [, attachments] of this.attachmentsByMessage) { + for (const attachment of attachments) { + if (attachment.available) continue; + + // 1. Try savedPath (disk cache) + if (attachment.savedPath) { + try { + if (await electronApi.fileExists(attachment.savedPath)) { + this.restoreAttachmentFromDisk(attachment, await electronApi.readFile(attachment.savedPath)); + hasChanges = true; + continue; + } + } catch { /* fall through */ } + } + + // 2. Try filePath (uploader's original) + if (attachment.filePath) { + try { + if (await electronApi.fileExists(attachment.filePath)) { + this.restoreAttachmentFromDisk(attachment, await electronApi.readFile(attachment.filePath)); + hasChanges = true; + if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) { + const response = await fetch(attachment.objectUrl!); + void this.saveFileToDisk(attachment, await response.blob()); + } + continue; + } + } catch { /* fall through */ } + } } } - localStorage.removeItem('metoyou_attachments'); - this.updated.set(this.updated() + 1); - } catch {} + + if (hasChanges) this.touch(); + } catch { /* startup load is best-effort */ } } + /** + * Helper: decode a base-64 string from disk, create blob + object URL, + * and populate the `originalFiles` map for serving file requests. + */ + private restoreAttachmentFromDisk(attachment: Attachment, base64: string): void { + const bytes = this.base64ToUint8Array(base64); + const blob = new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime }); + attachment.objectUrl = URL.createObjectURL(blob); + attachment.available = true; + const file = new File([blob], attachment.filename, { type: attachment.mime }); + this.originalFiles.set(`${attachment.messageId}:${attachment.id}`, file); + } + + /** Save attachment metadata to the database (without file content). */ + private async persistAttachmentMeta(attachment: Attachment): Promise { + if (!this.database.isReady()) return; + try { + await this.database.saveAttachment({ + id: attachment.id, + messageId: attachment.messageId, + filename: attachment.filename, + size: attachment.size, + mime: attachment.mime, + isImage: attachment.isImage, + uploaderPeerId: attachment.uploaderPeerId, + filePath: attachment.filePath, + savedPath: attachment.savedPath, + }); + } catch { /* persistence is best-effort */ } + } + + /** Load all attachment metadata from the database. */ + private async loadFromDatabase(): Promise { + try { + const allRecords: AttachmentMeta[] = await this.database.getAllAttachments(); + const grouped = new Map(); + for (const record of allRecords) { + const attachment: Attachment = { ...record, available: false }; + const bucket = grouped.get(record.messageId) ?? []; + bucket.push(attachment); + grouped.set(record.messageId, bucket); + } + this.attachmentsByMessage = grouped; + this.touch(); + } catch { /* load is best-effort */ } + } + + /** One-time migration from localStorage to the database. */ + private async migrateFromLocalStorage(): Promise { + try { + const raw = localStorage.getItem(LEGACY_STORAGE_KEY); + if (!raw) return; + + const legacyRecords: AttachmentMeta[] = JSON.parse(raw); + for (const meta of legacyRecords) { + const existing = this.attachmentsByMessage.get(meta.messageId) ?? []; + if (!existing.find((entry) => entry.id === meta.id)) { + const attachment: Attachment = { ...meta, available: false }; + existing.push(attachment); + this.attachmentsByMessage.set(meta.messageId, existing); + void this.persistAttachmentMeta(attachment); + } + } + + localStorage.removeItem(LEGACY_STORAGE_KEY); + this.touch(); + } catch { /* migration is best-effort */ } + } + + /** Full initialisation sequence: load DB → migrate → restore files. */ + private async initFromDatabase(): Promise { + await this.loadFromDatabase(); + await this.migrateFromLocalStorage(); + await this.tryLoadSavedFiles(); + } + + /** Resolve the display name of the current room via the NgRx store. */ + private resolveCurrentRoomName(): Promise { + return new Promise((resolve) => { + const subscription = this.ngrxStore + .select(selectCurrentRoomName) + .subscribe((name) => { + resolve(name || ''); + subscription.unsubscribe(); + }); + }); + } + + /** Convert an ArrayBuffer to a base-64 string. */ private arrayBufferToBase64(buffer: ArrayBuffer): string { let binary = ''; const bytes = new Uint8Array(buffer); - const len = bytes.byteLength; - for (let i = 0; i < len; i++) { - binary += String.fromCharCode(bytes[i]); + for (let index = 0; index < bytes.byteLength; index++) { + binary += String.fromCharCode(bytes[index]); } return btoa(binary); } + /** Convert a base-64 string to a Uint8Array. */ private base64ToUint8Array(base64: string): Uint8Array { const binary = atob(base64); - const len = binary.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) { - bytes[i] = binary.charCodeAt(i); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); } return bytes; } diff --git a/src/app/core/services/auth.service.ts b/src/app/core/services/auth.service.ts index dd44d6f..e3de725 100644 --- a/src/app/core/services/auth.service.ts +++ b/src/app/core/services/auth.service.ts @@ -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); + endpoint = this.serverDirectory.servers().find( + (server) => server.id === serverId, + ); } - const active = base || this.serverDirectory.activeServer(); - return active ? `${active.url}/api` : 'http://localhost:3001/api'; + + const activeEndpoint = endpoint ?? this.serverDirectory.activeServer(); + return activeEndpoint ? `${activeEndpoint.url}/api` : DEFAULT_API_BASE; } - register(params: { username: string; password: string; displayName?: string; serverId?: string }): Observable { + /** + * 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 { const url = `${this.endpointFor(params.serverId)}/users/register`; return this.http.post(url, { username: params.username, password: params.password, displayName: params.displayName, - }).pipe(map((resp) => resp)); + }); } - login(params: { username: string; password: string; serverId?: string }): Observable { + /** + * 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 { const url = `${this.endpointFor(params.serverId)}/users/login`; return this.http.post(url, { username: params.username, password: params.password, - }).pipe(map((resp) => resp)); + }); } } diff --git a/src/app/core/services/browser-database.service.ts b/src/app/core/services/browser-database.service.ts index 1848711..ae77a20 100644 --- a/src/app/core/services/browser-database.service.ts +++ b/src/app/core/services/browser-database.service.ts @@ -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 { - 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 { - 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 { - const all = await this.getAllFromIndex('messages', 'roomId', roomId); - return all - .sort((a, b) => a.timestamp - b.timestamp) + const allRoomMessages = await this.getAllFromIndex( + 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 { - await this.delete('messages', messageId); + await this.deleteRecord(STORE_MESSAGES, messageId); } + /** Apply partial updates to an existing message. */ async updateMessage(messageId: string, updates: Partial): Promise { - const msg = await this.get('messages', messageId); - if (msg) await this.put('messages', { ...msg, ...updates }); + const existing = await this.get(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 { - return (await this.get('messages', messageId)) ?? null; + return (await this.get(STORE_MESSAGES, messageId)) ?? null; } + /** Remove every message belonging to a room. */ async clearRoomMessages(roomId: string): Promise { - const msgs = await this.getAllFromIndex('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 { - const existing = await this.getAllFromIndex('reactions', 'messageId', reaction.messageId); - const dup = existing.some( - (r) => r.userId === reaction.userId && r.emoji === reaction.emoji, + const messages = await this.getAllFromIndex( + 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 { + const existing = await this.getAllFromIndex( + 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 { - const all = await this.getAllFromIndex('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( + 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 { - return this.getAllFromIndex('reactions', 'messageId', messageId); + return this.getAllFromIndex(STORE_REACTIONS, 'messageId', messageId); } - /* ------------------------------------------------------------------ */ - /* Users */ - /* ------------------------------------------------------------------ */ - + /** Persist a user record. */ async saveUser(user: User): Promise { - 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 { - return (await this.get('users', userId)) ?? null; + return (await this.get(STORE_USERS, userId)) ?? null; } + /** Retrieve the last-authenticated ("current") user, or `null`. */ async getCurrentUser(): Promise { - 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 { - 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 { - return this.getAll('users'); + return this.getAll(STORE_USERS); } + /** Apply partial updates to an existing user. */ async updateUser(userId: string, updates: Partial): Promise { - const user = await this.get('users', userId); - if (user) await this.put('users', { ...user, ...updates }); + const existing = await this.get(STORE_USERS, userId); + if (existing) { + await this.put(STORE_USERS, { ...existing, ...updates }); + } } - /* ------------------------------------------------------------------ */ - /* Rooms */ - /* ------------------------------------------------------------------ */ - + /** Persist a room record. */ async saveRoom(room: Room): Promise { - 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 { - return (await this.get('rooms', roomId)) ?? null; + return (await this.get(STORE_ROOMS, roomId)) ?? null; } + /** Return every persisted room. */ async getAllRooms(): Promise { - return this.getAll('rooms'); + return this.getAll(STORE_ROOMS); } + /** Delete a room and all of its messages. */ async deleteRoom(roomId: string): Promise { - 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): Promise { - const room = await this.get('rooms', roomId); - if (room) await this.put('rooms', { ...room, ...updates }); + const existing = await this.get(STORE_ROOMS, roomId); + if (existing) { + await this.put(STORE_ROOMS, { ...existing, ...updates }); + } } - /* ------------------------------------------------------------------ */ - /* Bans */ - /* ------------------------------------------------------------------ */ - + /** Persist a ban entry. */ async saveBan(ban: BanEntry): Promise { - 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 { - const all = await this.getAll('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(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 { - const all = await this.getAllFromIndex('bans', 'roomId', roomId); + const allBans = await this.getAllFromIndex( + 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 { - 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 { - await this.put('attachments', attachment); + await this.put(STORE_ATTACHMENTS, attachment); } + /** Return all attachment records for a message. */ async getAttachmentsForMessage(messageId: string): Promise { - return this.getAllFromIndex('attachments', 'messageId', messageId); + return this.getAllFromIndex(STORE_ATTACHMENTS, 'messageId', messageId); } + /** Return every persisted attachment record. */ async getAllAttachments(): Promise { - return this.getAll('attachments'); + return this.getAll(STORE_ATTACHMENTS); } + /** Delete all attachment records for a message. */ async deleteAttachmentsForMessage(messageId: string): Promise { - const atts = await this.getAllFromIndex('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( + 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 { - 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 { 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 { + /** Wrap a transaction's completion event as a Promise. */ + private awaitTransaction(transaction: IDBTransaction): Promise { return new Promise((resolve, reject) => { - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); }); } - private get(store: string, key: IDBValidKey): Promise { + /** Retrieve a single record by primary key. */ + private get(storeName: string, key: IDBValidKey): Promise { 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(store: string): Promise { + /** Retrieve every record from an object store. */ + private getAll(storeName: string): Promise { 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( - store: string, + storeName: string, indexName: string, key: IDBValidKey, ): Promise { 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 { + /** Insert or update a record in the given object store. */ + private put(storeName: string, value: any): Promise { 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 { + /** Delete a record by primary key. */ + private deleteRecord(storeName: string, key: IDBValidKey): Promise { 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); }); } } diff --git a/src/app/core/services/database.service.ts b/src/app/core/services/database.service.ts index d4a0840..ab247da 100644 --- a/src/app/core/services/database.service.ts +++ b/src/app/core/services/database.service.ts @@ -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 { 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) { 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) { 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) { 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(); } } diff --git a/src/app/core/services/electron-database.service.ts b/src/app/core/services/electron-database.service.ts index 83f13df..9ff4b44 100644 --- a/src/app/core/services/electron-database.service.ts +++ b/src/app/core/services/electron-database.service.ts @@ -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 { - 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 { 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 { return this.api.getMessages(roomId, limit, offset); } + /** Permanently delete a message by ID. */ deleteMessage(messageId: string): Promise { return this.api.deleteMessage(messageId); } + /** Apply partial updates to an existing message. */ updateMessage(messageId: string, updates: Partial): Promise { return this.api.updateMessage(messageId, updates); } + /** Retrieve a single message by ID, or `null` if not found. */ getMessageById(messageId: string): Promise { return this.api.getMessageById(messageId); } + /** Remove every message belonging to a room. */ clearRoomMessages(roomId: string): Promise { return this.api.clearRoomMessages(roomId); } - /* ------------------------------------------------------------------ */ - /* Reactions */ - /* ------------------------------------------------------------------ */ - + /** Persist a reaction (deduplication is handled server-side). */ saveReaction(reaction: Reaction): Promise { return this.api.saveReaction(reaction); } + /** Remove a specific reaction (user + emoji + message). */ removeReaction(messageId: string, userId: string, emoji: string): Promise { return this.api.removeReaction(messageId, userId, emoji); } + /** Return all reactions for a given message. */ getReactionsForMessage(messageId: string): Promise { return this.api.getReactionsForMessage(messageId); } - /* ------------------------------------------------------------------ */ - /* Users */ - /* ------------------------------------------------------------------ */ - + /** Persist a user record. */ saveUser(user: User): Promise { return this.api.saveUser(user); } + /** Retrieve a user by ID, or `null` if not found. */ getUser(userId: string): Promise { return this.api.getUser(userId); } + /** Retrieve the last-authenticated ("current") user, or `null`. */ getCurrentUser(): Promise { return this.api.getCurrentUser(); } + /** Store which user ID is considered "current" (logged-in). */ setCurrentUserId(userId: string): Promise { return this.api.setCurrentUserId(userId); } + /** Retrieve users associated with a room. */ getUsersByRoom(roomId: string): Promise { return this.api.getUsersByRoom(roomId); } + /** Apply partial updates to an existing user. */ updateUser(userId: string, updates: Partial): Promise { return this.api.updateUser(userId, updates); } - /* ------------------------------------------------------------------ */ - /* Rooms */ - /* ------------------------------------------------------------------ */ - + /** Persist a room record. */ saveRoom(room: Room): Promise { return this.api.saveRoom(room); } + /** Retrieve a room by ID, or `null` if not found. */ getRoom(roomId: string): Promise { return this.api.getRoom(roomId); } + /** Return every persisted room. */ getAllRooms(): Promise { return this.api.getAllRooms(); } + /** Delete a room by ID. */ deleteRoom(roomId: string): Promise { return this.api.deleteRoom(roomId); } + /** Apply partial updates to an existing room. */ updateRoom(roomId: string, updates: Partial): Promise { return this.api.updateRoom(roomId, updates); } - /* ------------------------------------------------------------------ */ - /* Bans */ - /* ------------------------------------------------------------------ */ - + /** Persist a ban entry. */ saveBan(ban: BanEntry): Promise { return this.api.saveBan(ban); } + /** Remove a ban by the banned user's `oderId`. */ removeBan(oderId: string): Promise { return this.api.removeBan(oderId); } + /** Return active bans for a room. */ getBansForRoom(roomId: string): Promise { return this.api.getBansForRoom(roomId); } + /** Check whether a user is currently banned from a room. */ isUserBanned(userId: string, roomId: string): Promise { return this.api.isUserBanned(userId, roomId); } - /* ------------------------------------------------------------------ */ - /* Attachments */ - /* ------------------------------------------------------------------ */ - + /** Persist attachment metadata. */ saveAttachment(attachment: any): Promise { return this.api.saveAttachment(attachment); } + /** Return all attachment records for a message. */ getAttachmentsForMessage(messageId: string): Promise { return this.api.getAttachmentsForMessage(messageId); } + /** Return every persisted attachment record. */ getAllAttachments(): Promise { return this.api.getAllAttachments(); } + /** Delete all attachment records for a message. */ deleteAttachmentsForMessage(messageId: string): Promise { return this.api.deleteAttachmentsForMessage(messageId); } - /* ------------------------------------------------------------------ */ - /* Utilities */ - /* ------------------------------------------------------------------ */ - + /** Wipe every table, removing all persisted data. */ clearAllData(): Promise { return this.api.clearAllData(); } diff --git a/src/app/core/services/external-link.service.ts b/src/app/core/services/external-link.service.ts new file mode 100644 index 0000000..a4ba057 --- /dev/null +++ b/src/app/core/services/external-link.service.ts @@ -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; + } +} diff --git a/src/app/core/services/index.ts b/src/app/core/services/index.ts index 2e1a67e..a27f4eb 100644 --- a/src/app/core/services/index.ts +++ b/src/app/core/services/index.ts @@ -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'; diff --git a/src/app/core/services/server-directory.service.ts b/src/app/core/services/server-directory.service.ts index 15a6a9e..d8f78f1 100644 --- a/src/app/core/services/server-directory.service.ts +++ b/src/app/core/services/server-directory.service.ts @@ -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 = { +/** Blueprint for the built-in default endpoint. */ +const DEFAULT_ENDPOINT: Omit = { 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([]); - 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(); + /** + * 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.update((endpoints) => [...endpoints, newEndpoint]); + this.saveEndpoints(); + } + + /** + * Remove an endpoint by ID. + * The built-in default endpoint cannot be removed. If the removed + * endpoint was active, the first remaining endpoint is activated. + */ + removeServer(endpointId: string): void { + const endpoints = this._servers(); + const target = endpoints.find((endpoint) => endpoint.id === endpointId); + if (target?.isDefault) return; + + const wasActive = target?.isActive; + this._servers.update((list) => list.filter((endpoint) => endpoint.id !== endpointId)); + + if (wasActive) { + this._servers.update((list) => { + if (list.length > 0) list[0].isActive = true; + return [...list]; + }); + } + this.saveEndpoints(); + } + + /** Activate a specific endpoint and deactivate all others. */ + setActiveServer(endpointId: string): void { + this._servers.update((endpoints) => + endpoints.map((endpoint) => ({ + ...endpoint, + isActive: endpoint.id === endpointId, + })), + ); + this.saveEndpoints(); + } + + /** Update the health status and optional latency of an endpoint. */ + updateServerStatus( + endpointId: string, + status: ServerEndpoint['status'], + latency?: number, + ): void { + this._servers.update((endpoints) => + endpoints.map((endpoint) => + endpoint.id === endpointId ? { ...endpoint, status, latency } : endpoint, + ), + ); + this.saveEndpoints(); + } + + /** Enable or disable fan-out search across all endpoints. */ + setSearchAllServers(enabled: boolean): void { + this.shouldSearchAllServers = enabled; + } + + /** + * Probe a single endpoint's health and update its status. + * + * @param endpointId - ID of the endpoint to test. + * @returns `true` if the server responded successfully. + */ + async testServer(endpointId: string): Promise { + const endpoint = this._servers().find((entry) => entry.id === endpointId); + if (!endpoint) return false; + + this.updateServerStatus(endpointId, 'checking'); + const startTime = Date.now(); + + try { + const response = await fetch(`${endpoint.url}/api/health`, { + method: 'GET', + signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS), + }); + const latency = Date.now() - startTime; + + if (response.ok) { + this.updateServerStatus(endpointId, 'online', latency); + return true; } - } else { - this.initializeDefaultServer(); + 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; } } - private initializeDefaultServer(): void { - const defaultServer: ServerEndpoint = { - ...DEFAULT_SERVER, - id: uuidv4(), - }; - this._servers.set([defaultServer]); - this.saveServers(); + /** Probe all configured endpoints in parallel. */ + async testAllServers(): Promise { + const endpoints = this._servers(); + await Promise.all(endpoints.map((endpoint) => this.testServer(endpoint.id))); } - private saveServers(): void { - localStorage.setItem(STORAGE_KEY, JSON.stringify(this._servers())); + /** Expose the API base URL for external consumers. */ + getApiBaseUrl(): string { + return this.buildApiBaseUrl(); } - private get baseUrl(): string { + /** Get the WebSocket URL derived from the active endpoint. */ + getWebSocketUrl(): string { const active = this.activeServer(); - const 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 { + if (this.shouldSearchAllServers) { + return this.searchAllEndpoints(query); + } + return this.searchSingleEndpoint(query, this.buildApiBaseUrl()); + } + + /** Retrieve the full list of public servers. */ + getServers(): Observable { + 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 { + return this.http + .get(`${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 & { id?: string }, + ): Observable { + return this.http + .post(`${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, + ): Observable { + return this.http + .patch(`${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 { + return this.http + .delete(`${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 { + return this.http + .get(`${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 { + return this.http + .post(`${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 { + return this.http + .patch(`${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 { + return this.http + .post(`${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; - } - - // Server management methods - addServer(server: { name: string; url: string }): void { - const newServer: ServerEndpoint = { - id: uuidv4(), - name: server.name, - // Sanitize: remove trailing slashes and any '/api' - url: (() => { - let u = server.url.trim(); - u = u.replace(/\/+$/,''); - if (u.toLowerCase().endsWith('/api')) u = u.slice(0, -4); - return u; - })(), - isActive: false, - isDefault: false, - status: 'unknown', - }; - this._servers.update((servers) => [...servers, newServer]); - this.saveServers(); - } - - removeServer(id: string): void { - const servers = this._servers(); - const server = servers.find((s) => s.id === id); - if (server?.isDefault) return; // Can't remove default server - - const wasActive = server?.isActive; - this._servers.update((servers) => servers.filter((s) => s.id !== id)); - - // If removed server was active, activate the first server - if (wasActive) { - this._servers.update((servers) => { - if (servers.length > 0) { - servers[0].isActive = true; - } - return [...servers]; - }); + /** 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); } - this.saveServers(); + return cleaned; } - setActiveServer(id: string): void { - this._servers.update((servers) => - servers.map((s) => ({ - ...s, - isActive: s.id === id, - })) - ); - 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 ?? []; } - 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 { - 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 { - 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 { - if (this._searchAllServers) { - return this.searchAllServerEndpoints(query); - } - return this.searchSingleServer(query, this.baseUrl); - } - - private searchSingleServer(query: string, baseUrl: string): Observable { + /** Search a single endpoint for servers matching a query. */ + private searchSingleEndpoint( + query: string, + apiBaseUrl: string, + ): Observable { 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 || []; - }), - catchError((error) => { - console.error('Failed to search servers:', error); - return of([]); - }) - ); + 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 { - const servers = this._servers().filter((s) => s.status !== 'offline'); + /** Fan-out search across all non-offline endpoints, deduplicating results. */ + private searchAllEndpoints(query: string): Observable { + 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(); - return servers.filter((s) => { - if (seen.has(s.id)) return false; - seen.add(s.id); - return true; - }); - }) - ); - } - - // Get all available servers - getServers(): Observable { - 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 { - 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( - map((response) => { - const results = Array.isArray(response) ? response : response.servers || []; - return results.map((r) => ({ - ...r, - sourceId: server.id, - sourceName: server.name, - })); - }), - catchError(() => of([])) - ) + const requests = onlineEndpoints.map((endpoint) => + this.http + .get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`) + .pipe( + map((response) => { + const results = this.unwrapServersResponse(response); + return results.map((server) => ({ + ...server, + sourceId: endpoint.id, + sourceName: endpoint.name, + })); + }), + 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 { - return this.http.get(`${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(items: T[]): T[] { + const seen = new Set(); + 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 & { id?: string }): Observable { - return this.http.post(`${this.baseUrl}/servers`, server).pipe( - catchError((error) => { - console.error('Failed to register server:', error); - return throwError(() => error); - }) - ); - } - - // Update server info - updateServer(serverId: string, updates: Partial): Observable { - return this.http.patch(`${this.baseUrl}/servers/${serverId}`, updates).pipe( - catchError((error) => { - console.error('Failed to update server:', error); - return throwError(() => error); - }) - ); - } - - // Remove server from directory - unregisterServer(serverId: string): Observable { - return this.http.delete(`${this.baseUrl}/servers/${serverId}`).pipe( - catchError((error) => { - console.error('Failed to unregister server:', error); - return throwError(() => error); - }) - ); - } - - // Get users in a server - getServerUsers(serverId: string): Observable { - return this.http.get(`${this.baseUrl}/servers/${serverId}/users`).pipe( - catchError((error) => { - console.error('Failed to get server users:', error); - return of([]); - }) - ); - } - - // Send join request - requestJoin(request: JoinRequest): Observable<{ success: boolean; signalingUrl?: string }> { - return this.http - .post<{ success: boolean; signalingUrl?: string }>( - `${this.baseUrl}/servers/${request.roomId}/join`, - request - ) - .pipe( - catchError((error) => { - console.error('Failed to send join request:', error); - return throwError(() => error); - }) - ); - } - - // Notify server of user leaving - notifyLeave(serverId: string, userId: string): Observable { - return this.http.post(`${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 { - return this.http.patch(`${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 { - return this.http.post(`${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`; + /** Load endpoints from localStorage, migrating protocol if needed. */ + private loadEndpoints(): void { + const stored = localStorage.getItem(ENDPOINTS_STORAGE_KEY); + if (!stored) { + this.initialiseDefaultEndpoint(); + return; } - // Convert http(s) to ws(s) - return active.url.replace(/^http/, 'ws'); + 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; + } + + // Migrate localhost entries to match the current page protocol + const expectedProtocol = + typeof window !== 'undefined' && window.location?.protocol === 'https:' + ? 'https' + : 'http'; + + endpoints = endpoints.map((endpoint) => { + if (endpoint.isDefault && /^https?:\/\/localhost:\d+$/.test(endpoint.url)) { + return { ...endpoint, url: endpoint.url.replace(/^https?/, expectedProtocol) }; + } + return endpoint; + }); + + this._servers.set(endpoints); + this.saveEndpoints(); + } catch { + this.initialiseDefaultEndpoint(); + } + } + + /** Create and persist the built-in default endpoint. */ + private initialiseDefaultEndpoint(): void { + const defaultEndpoint: ServerEndpoint = { ...DEFAULT_ENDPOINT, id: uuidv4() }; + this._servers.set([defaultEndpoint]); + this.saveEndpoints(); + } + + /** Persist the current endpoint list to localStorage. */ + private saveEndpoints(): void { + localStorage.setItem(ENDPOINTS_STORAGE_KEY, JSON.stringify(this._servers())); } } diff --git a/src/app/core/services/time-sync.service.ts b/src/app/core/services/time-sync.service.ts index a4510bf..a0f929c 100644 --- a/src/app/core/services/time-sync.service.ts +++ b/src/app/core/services/time-sync.service.ts @@ -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(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 { + /** + * 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 { 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. } } } diff --git a/src/app/core/services/voice-session.service.ts b/src/app/core/services/voice-session.service.ts index 1c51ea1..113c552 100644 --- a/src/app/core/services/voice-session.service.ts +++ b/src/app/core/services/voice-session.service.ts @@ -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(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(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); - } + }), + ); + 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; diff --git a/src/app/core/services/webrtc.service.ts b/src/app/core/services/webrtc.service.ts index 9537b55..ae3124e 100644 --- a/src/app/core/services/webrtc.service.ts +++ b/src/app/core/services/webrtc.service.ts @@ -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(); private activeServerId: string | null = null; private readonly serviceDestroyed$ = new Subject(); - // ─── Angular signals (reactive state) ────────────────────────────── private readonly _localPeerId = signal(uuidv4()); private readonly _isSignalingConnected = signal(false); private readonly _isVoiceConnected = signal(false); @@ -73,10 +70,12 @@ export class WebRTCService implements OnDestroy { private readonly _screenStreamSignal = signal(null); private readonly _hasConnectionError = signal(false); private readonly _connectionErrorMessage = signal(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(); 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 { 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 { 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 { 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): void { this.signalingManager.sendSignalingMessage(message, this._localPeerId()); } + /** + * Send a raw JSON payload through the signaling WebSocket. + * + * @param message - Arbitrary JSON message. + */ sendRawMessage(message: Record): 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 { 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 { 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 { 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 { 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 { 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 { 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(); diff --git a/src/app/core/services/webrtc/media.manager.ts b/src/app/core/services/webrtc/media.manager.ts index cac34fc..a83ddd2 100644 --- a/src/app/core/services/webrtc/media.manager.ts +++ b/src/app/core/services/webrtc/media.manager.ts @@ -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 { 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 { 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 { 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(); diff --git a/src/app/core/services/webrtc/peer-connection.manager.ts b/src/app/core/services/webrtc/peer-connection.manager.ts index c7ead0e..3305431 100644 --- a/src/app/core/services/webrtc/peer-connection.manager.ts +++ b/src/app/core/services/webrtc/peer-connection.manager.ts @@ -64,7 +64,6 @@ export class PeerConnectionManager { private disconnectedPeerTracker = new Map(); private peerReconnectTimers = new Map>(); - // ─── Public event subjects ───────────────────────────────────────── readonly peerConnected$ = new Subject(); readonly peerDisconnected$ = new Subject(); 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 { 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 { 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 { 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 { 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 { 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([]); diff --git a/src/app/core/services/webrtc/screen-share.manager.ts b/src/app/core/services/webrtc/screen-share.manager.ts index b28afa6..58c3299 100644 --- a/src/app/core/services/webrtc/screen-share.manager.ts +++ b/src/app/core/services/webrtc/screen-share.manager.ts @@ -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 { 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; diff --git a/src/app/core/services/webrtc/signaling.manager.ts b/src/app/core/services/webrtc/signaling.manager.ts index c636a05..e77381d 100644 --- a/src/app/core/services/webrtc/signaling.manager.ts +++ b/src/app/core/services/webrtc/signaling.manager.ts @@ -39,8 +39,6 @@ export class SignalingManager { private readonly getMemberServerIds: () => ReadonlySet, ) {} - // ─── Public API ──────────────────────────────────────────────────── - /** Open (or re-open) a WebSocket to the signaling server. */ connect(serverUrl: string): Observable { 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); diff --git a/src/app/core/services/webrtc/webrtc-logger.ts b/src/app/core/services/webrtc/webrtc-logger.ts index 72afc09..51df1ae 100644 --- a/src/app/core/services/webrtc/webrtc-logger.ts +++ b/src/app/core/services/webrtc/webrtc-logger.ts @@ -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}`)); } } diff --git a/src/app/core/services/webrtc/webrtc.constants.ts b/src/app/core/services/webrtc/webrtc.constants.ts index 6312a6d..c1e1138 100644 --- a/src/app/core/services/webrtc/webrtc.constants.ts +++ b/src/app/core/services/webrtc/webrtc.constants.ts @@ -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) */ diff --git a/src/app/core/services/webrtc/webrtc.types.ts b/src/app/core/services/webrtc/webrtc.types.ts index 64d2dc9..a3b4cb1 100644 --- a/src/app/core/services/webrtc/webrtc.types.ts +++ b/src/app/core/services/webrtc/webrtc.types.ts @@ -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; } diff --git a/src/app/features/admin/admin-panel/admin-panel.component.html b/src/app/features/admin/admin-panel/admin-panel.component.html index 73db809..21601af 100644 --- a/src/app/features/admin/admin-panel/admin-panel.component.html +++ b/src/app/features/admin/admin-panel/admin-panel.component.html @@ -147,9 +147,7 @@ } @else { @for (user of membersFiltered(); track user.id) {
-
- {{ user.displayName ? user.displayName.charAt(0).toUpperCase() : '?' }} -
+

{{ user.displayName }}

@@ -302,7 +300,11 @@

Admins Can Manage Rooms

Allow admins to create/modify chat & voice rooms

- +
@@ -310,7 +312,11 @@

Moderators Can Manage Rooms

Allow moderators to create/modify chat & voice rooms

- +
@@ -318,7 +324,11 @@

Admins Can Change Server Icon

Grant icon management to admins

- +
@@ -326,7 +336,11 @@

Moderators Can Change Server Icon

Grant icon management to moderators

- + @@ -346,28 +360,16 @@ @if (showDeleteConfirm()) { -
-
-

Delete Room

-

- Are you sure you want to delete this room? This action cannot be undone. -

-
- - -
-
-
+ +

Are you sure you want to delete this room? This action cannot be undone.

+
} } @else {
diff --git a/src/app/features/admin/admin-panel/admin-panel.component.ts b/src/app/features/admin/admin-panel/admin-panel.component.ts index 8ae3a86..eb23b42 100644 --- a/src/app/features/admin/admin-panel/admin-panel.component.ts +++ b/src/app/features/admin/admin-panel/admin-panel.component.ts @@ -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({ diff --git a/src/app/features/auth/login/login.component.html b/src/app/features/auth/login/login.component.html index 9040f98..8a52e58 100644 --- a/src/app/features/auth/login/login.component.html +++ b/src/app/features/auth/login/login.component.html @@ -8,20 +8,40 @@
- +
- +
- + @for (s of servers(); track s.id) { + + }
-

{{ error() }}

- + @if (error()) { +

{{ error() }}

+ } +
diff --git a/src/app/features/auth/login/login.component.ts b/src/app/features/auth/login/login.component.ts index 41b899b..b416372 100644 --- a/src/app/features/auth/login/login.component.ts +++ b/src/app/features/auth/login/login.component.ts @@ -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(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']); } diff --git a/src/app/features/auth/register/register.component.html b/src/app/features/auth/register/register.component.html index 877b802..bfd175c 100644 --- a/src/app/features/auth/register/register.component.html +++ b/src/app/features/auth/register/register.component.html @@ -8,24 +8,48 @@
- +
- +
- +
- + @for (s of servers(); track s.id) { + + }
-

{{ error() }}

- + @if (error()) { +

{{ error() }}

+ } +
Have an account? Login
diff --git a/src/app/features/auth/register/register.component.ts b/src/app/features/auth/register/register.component.ts index 4457aaf..25ece34 100644 --- a/src/app/features/auth/register/register.component.ts +++ b/src/app/features/auth/register/register.component.ts @@ -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(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']); } diff --git a/src/app/features/auth/user-bar/user-bar.component.ts b/src/app/features/auth/user-bar/user-bar.component.ts index 9d0bcd1..8334b50 100644 --- a/src/app/features/auth/user-bar/user-bar.component.ts +++ b/src/app/features/auth/user-bar/user-bar.component.ts @@ -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}`]); } diff --git a/src/app/features/chat/chat-messages.component.ts b/src/app/features/chat/chat-messages.component.ts deleted file mode 100644 index f72758a..0000000 --- a/src/app/features/chat/chat-messages.component.ts +++ /dev/null @@ -1,1507 +0,0 @@ -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 * as 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 { DomSanitizer, SafeHtml } from '@angular/platform-browser'; -import { marked } from 'marked'; -import DOMPurify from 'dompurify'; -import { ServerDirectoryService } from '../../core/services/server-directory.service'; - -const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥', '👀']; - -@Component({ - selector: 'app-chat-messages', - standalone: true, - imports: [CommonModule, FormsModule, NgIcon], - viewProviders: [ - provideIcons({ - lucideSend, - lucideSmile, - lucideEdit, - lucideTrash2, - lucideReply, - lucideMoreVertical, - lucideCheck, - lucideX, - lucideDownload, - lucideExpand, - lucideImage, - lucideCopy, - }), - ], - template: ` -
- -
- - @if (syncing() && !loading()) { -
-
- Syncing messages… -
- } - @if (loading()) { -
-
-
- } @else if (messages().length === 0) { -
-

No messages yet

-

Be the first to say something!

-
- } @else { - - @if (hasMoreMessages()) { -
- @if (loadingMore()) { -
- } @else { - - } -
- } - @for (message of messages(); track message.id) { -
- -
- {{ message.senderName.charAt(0).toUpperCase() }} -
- - -
- - @if (message.replyToId) { - @let repliedMsg = getRepliedMessage(message.replyToId); -
-
- - @if (repliedMsg) { - {{ repliedMsg.senderName }} - {{ repliedMsg.content }} - } @else { - Original message not found - } -
- } -
- {{ message.senderName }} - - {{ formatTimestamp(message.timestamp) }} - - @if (message.editedAt) { - (edited) - } -
- - @if (editingMessageId() === message.id) { - -
- - - -
- } @else { -
- @if (getAttachments(message.id).length > 0) { -
- @for (att of getAttachments(message.id); track att.id) { - @if (att.isImage) { - @if (att.available && att.objectUrl) { - -
- -
-
- - -
-
- } @else if ((att.receivedBytes || 0) > 0) { - -
-
-
- -
-
-
{{ att.filename }}
-
{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}
-
-
{{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}%
-
-
-
-
-
- } @else { - -
-
-
- -
-
-
{{ att.filename }}
-
{{ formatBytes(att.size) }}
-
Waiting for image source…
-
-
- -
- } - } @else { -
-
-
-
{{ att.filename }}
-
{{ formatBytes(att.size) }}
-
-
- @if (!isUploader(att)) { - @if (!att.available) { -
-
-
-
- {{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}% - @if (att.speedBps) { - • {{ formatSpeed(att.speedBps) }} - } -
- @if (!(att.receivedBytes || 0)) { - - } @else { - - } - } @else { - - } - } @else { -
Shared from your device
- } -
-
-
- } - } -
- } - } - - - @if (message.reactions.length > 0) { -
- @for (reaction of getGroupedReactions(message); track reaction.emoji) { - - } -
- } -
- - - @if (!message.isDeleted) { -
- -
- - - @if (showEmojiPicker() === message.id) { -
- @for (emoji of commonEmojis; track emoji) { - - } -
- } -
- - - - - - @if (isOwnMessage(message)) { - - } - - - @if (isOwnMessage(message) || isAdmin()) { - - } -
- } -
- } - } - - @if (showNewMessagesBar()) { -
-
- New messages - -
-
- } -
- - - @if (replyTo()) { -
- - - Replying to {{ replyTo()?.senderName }} - - -
- } - - - @if (typingDisplay().length > 0) { -
- - {{ typingDisplay().join(', ') }} - @if (typingOthersCount() > 0) { - and {{ typingOthersCount() }} others are typing... - } - -
- } - - - @if (toolbarVisible()) { -
-
- - - - - | - - - - - - - - - - -
-
- } - - -
-
-
- - @if (dragActive()) { -
-
Drop files to attach
-
- } - - @if (pendingFiles.length > 0) { -
- @for (file of pendingFiles; track file.name) { -
-
{{ file.name }}
-
{{ formatBytes(file.size) }}
- -
- } -
- } -
- -
-
-
- - - @if (lightboxAttachment()) { -
-
- - -
- - -
- -
-
- {{ lightboxAttachment()!.filename }} - {{ formatBytes(lightboxAttachment()!.size) }} -
-
-
-
- } - - - @if (imageContextMenu()) { -
-
- - -
- } - `, -}) -export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestroy { - @ViewChild('messagesContainer') messagesContainer!: ElementRef; - @ViewChild('messageInputRef') messageInputRef!: ElementRef; - - private store = inject(Store); - private webrtc = inject(WebRTCService); - private sanitizer = inject(DomSanitizer); - private serverDirectory = inject(ServerDirectoryService); - private attachmentsSvc = inject(AttachmentService); - private cdr = inject(ChangeDetectorRef); - - private allMessages = this.store.selectSignal(selectAllMessages); - private activeChannelId = this.store.selectSignal(selectActiveChannelId); - - // --- Infinite scroll (upwards) pagination --- - private readonly PAGE_SIZE = 50; - displayLimit = signal(this.PAGE_SIZE); - loadingMore = signal(false); - - /** All messages for the current channel (full list, unsliced) */ - private allChannelMessages = computed(() => { - const channelId = this.activeChannelId(); - const roomId = this.currentRoom()?.id; - return this.allMessages().filter(m => - m.roomId === roomId && (m.channelId || 'general') === channelId - ); - }); - - /** Paginated view — only the most recent `displayLimit` messages */ - messages = computed(() => { - const all = this.allChannelMessages(); - const limit = this.displayLimit(); - if (all.length <= limit) return all; - return all.slice(all.length - limit); - }); - - /** Whether there are older messages that can be loaded */ - hasMoreMessages = computed(() => this.allChannelMessages().length > this.displayLimit()); - loading = this.store.selectSignal(selectMessagesLoading); - 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(null); - replyTo = signal(null); - showEmojiPicker = signal(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 typingSub?: Subscription; - private lastTypingSentAt = 0; - private readonly typingTTL = 3000; // ms to keep a user as typing - private lastMessageCount = 0; - private initialScrollPending = true; - pendingFiles: File[] = []; - - // Track typing users by name and expire them - private typingMap = new Map(); - typingDisplay = signal([]); - typingOthersCount = signal(0); - // 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); - // Cache blob URLs for proxied images to prevent repeated network fetches on re-render - private imageBlobCache = new Map(); - // Cache rendered markdown to preserve text selection across re-renders - private markdownCache = new Map(); - - // Image lightbox modal state - lightboxAttachment = signal(null); - // Image right-click context menu state - imageContextMenu = signal<{ x: number; y: number; attachment: Attachment } | null>(null); - private boundOnKeydown: ((e: KeyboardEvent) => void) | null = null; - - // Reset scroll state when room/server changes (handles reuse of component on navigation) - private onRoomChanged = effect(() => { - void this.currentRoom(); // track room signal - this.initialScrollPending = true; - this.stopInitialScrollWatch(); - this.showNewMessagesBar.set(false); - this.lastMessageCount = 0; - this.displayLimit.set(this.PAGE_SIZE); - this.markdownCache.clear(); - }); - - // Reset pagination when switching channels within the same room - private onChannelChanged = effect(() => { - void this.activeChannelId(); // track channel signal - this.displayLimit.set(this.PAGE_SIZE); - this.initialScrollPending = true; - this.showNewMessagesBar.set(false); - this.lastMessageCount = 0; - this.markdownCache.clear(); - }); - // Re-render when attachments update (e.g. download progress from WebRTC callbacks) - private attachmentsUpdatedEffect = effect(() => { - void this.attachmentsSvc.updated(); - this.cdr.markForCheck(); - }); - - // Track total channel messages (not paginated) for new-message detection - private totalChannelMessagesLength = computed(() => this.allChannelMessages().length); - messagesLength = computed(() => this.messages().length); - 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; - } - this.loadCspImages(); - return; - } - // Attempt to resolve any deferred images after each check - this.loadCspImages(); - } - - ngOnInit(): void { - this.typingSub = this.webrtc.onSignalingMessage.subscribe((msg: any) => { - if (msg?.type === 'user_typing' && msg.displayName && msg.oderId) { - const now = Date.now(); - this.typingMap.set(String(msg.oderId), { name: String(msg.displayName), expiresAt: now + this.typingTTL }); - this.recomputeTypingDisplay(now); - } - }); - - // Periodically purge expired typing entries - const purge = () => { - const now = Date.now(); - let changed = false; - for (const [key, entry] of Array.from(this.typingMap.entries())) { - if (entry.expiresAt <= now) { - this.typingMap.delete(key); - changed = true; - } - } - if (changed) this.recomputeTypingDisplay(now); - // schedule next purge - setTimeout(purge, 1000); - }; - setTimeout(purge, 1000); - - // 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 = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - if (this.imageContextMenu()) { this.closeImageContextMenu(); return; } - if (this.lightboxAttachment()) { this.closeLightbox(); return; } - } - }; - document.addEventListener('keydown', this.boundOnKeydown); - } - - ngOnDestroy(): void { - this.typingSub?.unsubscribe(); - this.stopInitialScrollWatch(); - if (this.nowTimer) { - clearInterval(this.nowTimer); - this.nowTimer = null; - } - if (this.boundOnKeydown) { - document.removeEventListener('keydown', this.boundOnKeydown); - } - } - - 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; - 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); - } - } - - onInputChange(): void { - const now = Date.now(); - if (now - this.lastTypingSentAt > 1000) { // throttle typing events - try { - this.webrtc.sendRawMessage({ type: 'typing' }); - this.lastTypingSentAt = now; - } catch {} - } - } - - startEdit(message: Message): void { - this.editingMessageId.set(message.id); - this.editContent = message.content; - } - - saveEdit(messageId: string): void { - if (!this.editContent.trim()) return; - - this.store.dispatch( - MessagesActions.editMessage({ - messageId, - content: this.editContent.trim(), - }) - ); - - this.cancelEdit(); - } - - cancelEdit(): void { - this.editingMessageId.set(null); - this.editContent = ''; - } - - 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 })); - } - } - - setReplyTo(message: Message): void { - this.replyTo.set(message); - } - - clearReply(): void { - this.replyTo.set(null); - } - - getRepliedMessage(messageId: string): Message | undefined { - return this.allMessages().find(m => m.id === messageId); - } - - scrollToMessage(messageId: string): void { - const container = this.messagesContainer?.nativeElement; - if (!container) return; - const el = container.querySelector(`[data-message-id="${messageId}"]`); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'center' }); - el.classList.add('bg-primary/10'); - setTimeout(() => el.classList.remove('bg-primary/10'), 2000); - } - } - - toggleEmojiPicker(messageId: string): void { - this.showEmojiPicker.update((current) => - current === messageId ? null : messageId - ); - } - - addReaction(messageId: string, emoji: string): void { - this.store.dispatch(MessagesActions.addReaction({ messageId, emoji })); - this.showEmojiPicker.set(null); - } - - toggleReaction(messageId: string, emoji: string): void { - const message = this.messages().find((m) => m.id === messageId); - const currentUserId = this.currentUser()?.id; - - if (!message || !currentUserId) return; - - const hasReacted = message.reactions.some( - (r) => r.emoji === emoji && r.userId === currentUserId - ); - - if (hasReacted) { - this.store.dispatch(MessagesActions.removeReaction({ messageId, emoji })); - } else { - this.store.dispatch(MessagesActions.addReaction({ messageId, emoji })); - } - } - - isOwnMessage(message: Message): boolean { - return message.senderId === this.currentUser()?.id; - } - - getGroupedReactions(message: Message): { emoji: string; count: number; hasCurrentUser: boolean }[] { - const groups = new Map(); - 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, - })); - } - - 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()); - }); - } - - 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); - }); - }); - } - - private recomputeTypingDisplay(now: number): void { - const entries = Array.from(this.typingMap.values()) - .filter(e => e.expiresAt > now) - .map(e => e.name); - const maxShow = 4; - const shown = entries.slice(0, maxShow); - const others = Math.max(0, entries.length - shown.length); - this.typingDisplay.set(shown); - this.typingOthersCount.set(others); - } - - // Markdown rendering (cached so re-renders don't replace innerHTML and kill text selection) - renderMarkdown(content: string): SafeHtml { - const cached = this.markdownCache.get(content); - if (cached) return cached; - - marked.setOptions({ breaks: true }); - const html = marked.parse(content ?? '') as string; - // Sanitize to a DOM fragment so we can post-process disallowed images - const frag = DOMPurify.sanitize(html, { RETURN_DOM_FRAGMENT: true }) as DocumentFragment; - const container = document.createElement('div'); - container.appendChild(frag); - - const imgs = Array.from(container.querySelectorAll('img')); - for (const img of imgs) { - const src = img.getAttribute('src') || ''; - const isData = src.startsWith('data:'); - const isBlob = src.startsWith('blob:'); - let isSameOrigin = false; - try { - const resolved = new URL(src, window.location.href); - isSameOrigin = resolved.origin === window.location.origin; - } catch { - // non-URL values are treated as not allowed - isSameOrigin = false; - } - // Rewrite external images to deferred proxy and load as blob to satisfy CSP - if (!(isData || isBlob || isSameOrigin)) { - const apiBase = this.serverDirectory.getApiBaseUrl(); - // Robust join to avoid relative paths and double slashes - const baseWithSlash = apiBase.endsWith('/') ? apiBase : apiBase + '/'; - const proxied = new URL(`image-proxy?url=${encodeURIComponent(src)}`, baseWithSlash).toString(); - img.setAttribute('data-src', proxied); - img.classList.add('csp-img'); - // Tiny transparent placeholder to avoid empty src fetch - img.setAttribute('src', 'data:image/gif;base64,R0lGODlhAQABAAAAACw='); - } - - // Apply reasonable sizing and lazy loading - img.setAttribute('loading', 'lazy'); - img.classList.add('rounded-md'); - img.style.maxWidth = '100%'; - img.style.height = 'auto'; - img.style.maxHeight = '320px'; - } - - const safeHtml = DOMPurify.sanitize(container.innerHTML); - const result = this.sanitizer.bypassSecurityTrustHtml(safeHtml); - this.markdownCache.set(content, result); - return result; - } - - // Resolve images marked for CSP-safe loading by converting to blob URLs - private async loadCspImages(): Promise { - const root = this.messagesContainer?.nativeElement; - if (!root) return; - const imgs = Array.from(root.querySelectorAll('img.csp-img[data-src]')) as HTMLImageElement[]; - if (imgs.length === 0) return; - for (const img of imgs) { - const url = img.getAttribute('data-src'); - if (!url) continue; - try { - // Use cached blob URL if available to avoid refetching - const cached = this.imageBlobCache.get(url); - if (cached) { - img.src = cached; - } else { - const res = await fetch(url, { mode: 'cors' }); - if (!res.ok) continue; - const blob = await res.blob(); - const obj = URL.createObjectURL(blob); - this.imageBlobCache.set(url, obj); - img.src = obj; - } - img.removeAttribute('data-src'); - img.classList.remove('csp-img'); - } catch {} - } - } - - // Markdown toolbar actions - onEnter(evt: Event): void { - const e = evt as KeyboardEvent; - if (e.shiftKey) { - // allow newline - return; - } - e.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(); - } - } - - 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); - } - - 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); - } - - 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); - } - - 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, i) => `${i + 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); - } - - 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); - } - - 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); - } - - 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 = `![${selected}](https://)`; - const newText = `${before}${img}${after}`; - this.messageContent = newText; - const cursorStart = before.length + img.length - 1; - this.setSelection(cursorStart - 8, cursorStart - 1); - } - - 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); - } - - // Attachments: drag/drop and rendering - onDragEnter(evt: DragEvent): void { - evt.preventDefault(); - this.dragActive.set(true); - } - - onDragOver(evt: DragEvent): void { - evt.preventDefault(); - this.dragActive.set(true); - } - - onDragLeave(evt: DragEvent): void { - evt.preventDefault(); - this.dragActive.set(false); - } - - 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((f) => this.pendingFiles.push(f)); - // Keep toolbar visible so user sees options - this.toolbarVisible.set(true); - this.dragActive.set(false); - } - - getAttachments(messageId: string): Attachment[] { - return this.attachmentsSvc.getForMessage(messageId); - } - - 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]}`; - } - - 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]}`; - } - - removePendingFile(file: File): void { - const idx = this.pendingFiles.findIndex((f) => f === file); - if (idx >= 0) { - this.pendingFiles.splice(idx, 1); - } - } - - 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); - } - - requestAttachment(att: Attachment, messageId: string): void { - this.attachmentsSvc.requestFile(messageId, att); - } - - cancelAttachment(att: Attachment, messageId: string): void { - this.attachmentsSvc.cancelRequest(messageId, att); - } - - isUploader(att: Attachment): boolean { - const myUserId = this.currentUser()?.id; - return !!att.uploaderPeerId && !!myUserId && att.uploaderPeerId === myUserId; - } - - // ---- Image lightbox ---- - openLightbox(att: Attachment): void { - if (att.available && att.objectUrl) { - this.lightboxAttachment.set(att); - } - } - - closeLightbox(): void { - this.lightboxAttachment.set(null); - } - - // ---- Image context menu ---- - openImageContextMenu(event: MouseEvent, att: Attachment): void { - event.preventDefault(); - event.stopPropagation(); - this.imageContextMenu.set({ x: event.clientX, y: event.clientY, attachment: att }); - } - - closeImageContextMenu(): void { - this.imageContextMenu.set(null); - } - - async copyImageToClipboard(att: Attachment): Promise { - this.closeImageContextMenu(); - if (!att.objectUrl) return; - try { - const resp = await fetch(att.objectUrl); - const blob = await resp.blob(); - // Convert to PNG for clipboard compatibility - const pngBlob = await this.convertToPng(blob); - await navigator.clipboard.write([ - new ClipboardItem({ 'image/png': pngBlob }), - ]); - } catch (err) { - console.error('Failed to copy image to clipboard:', err); - } - } - - private convertToPng(blob: Blob): Promise { - return new Promise((resolve, reject) => { - if (blob.type === 'image/png') { - resolve(blob); - return; - } - const img = new Image(); - const url = URL.createObjectURL(blob); - img.onload = () => { - const canvas = document.createElement('canvas'); - canvas.width = img.naturalWidth; - canvas.height = img.naturalHeight; - const ctx = canvas.getContext('2d'); - if (!ctx) { reject(new Error('Canvas not supported')); return; } - ctx.drawImage(img, 0, 0); - canvas.toBlob((pngBlob) => { - URL.revokeObjectURL(url); - if (pngBlob) resolve(pngBlob); - else reject(new Error('PNG conversion failed')); - }, 'image/png'); - }; - img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Image load failed')); }; - img.src = url; - }); - } - - retryImageRequest(att: Attachment, messageId: string): void { - this.attachmentsSvc.requestImageFromAnyPeer(messageId, att); - } - - private attachFilesToLastOwnMessage(content: string): void { - const me = this.currentUser()?.id; - if (!me) return; - const msg = [...this.messages()].reverse().find((m) => m.senderId === me && m.content === content && !m.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(); - 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![](${url})`; - } - } - - return append ? content + append : content; - } - - private escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - } - - onContentClick(evt: Event): void { - const target = evt.target as HTMLElement; - if (target && target.tagName.toLowerCase() === 'a') { - evt.preventDefault(); - const href = (target as HTMLAnchorElement).href; - try { - const w: any = window as any; - if (w?.process?.type === 'renderer' && typeof w.require === 'function') { - const { shell } = w.require('electron'); - shell.openExternal(href); - } else { - window.open(href, '_blank', 'noopener,noreferrer'); - } - } catch { - window.open(href, '_blank', 'noopener,noreferrer'); - } - } - } - - onInputFocus(): void { - this.toolbarVisible.set(true); - } - - onInputBlur(): void { - setTimeout(() => { - if (!this.toolbarHovering) { - this.toolbarVisible.set(false); - } - }, 150); - } - - onToolbarMouseEnter(): void { - this.toolbarHovering = true; - } - - onToolbarMouseLeave(): void { - this.toolbarHovering = false; - if (document.activeElement !== this.messageInputRef?.nativeElement) { - this.toolbarVisible.set(false); - } - } - - // Snackbar: scroll to latest - readLatest(): void { - this.shouldScrollToBottom = true; - this.scrollToBottomSmooth(); - this.showNewMessagesBar.set(false); - } -} diff --git a/src/app/features/chat/chat-messages/chat-messages.component.html b/src/app/features/chat/chat-messages/chat-messages.component.html new file mode 100644 index 0000000..6c052a9 --- /dev/null +++ b/src/app/features/chat/chat-messages/chat-messages.component.html @@ -0,0 +1,462 @@ +
+ +
+ + @if (syncing() && !loading()) { +
+
+ Syncing messages… +
+ } + @if (loading()) { +
+
+
+ } @else if (messages().length === 0) { +
+

No messages yet

+

Be the first to say something!

+
+ } @else { + + @if (hasMoreMessages()) { +
+ @if (loadingMore()) { +
+ } @else { + + } +
+ } + @for (message of messages(); track message.id) { +
+ + + + +
+ + @if (message.replyToId) { + @let repliedMsg = getRepliedMessage(message.replyToId); +
+
+ + @if (repliedMsg) { + {{ repliedMsg.senderName }} + {{ repliedMsg.content }} + } @else { + Original message not found + } +
+ } +
+ {{ message.senderName }} + + {{ formatTimestamp(message.timestamp) }} + + @if (message.editedAt) { + (edited) + } +
+ + @if (editingMessageId() === message.id) { + +
+ + + +
+ } @else { +
+ + + @if (node.lang === 'mermaid') { + + } @else { +
{{ node.value }}
+ } +
+
+
+ @if (getAttachments(message.id).length > 0) { +
+ @for (att of getAttachments(message.id); track att.id) { + @if (att.isImage) { + @if (att.available && att.objectUrl) { + +
+ +
+
+ + +
+
+ } @else if ((att.receivedBytes || 0) > 0) { + +
+
+
+ +
+
+
{{ att.filename }}
+
{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}
+
+
{{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}%
+
+
+
+
+
+ } @else { + +
+
+
+ +
+
+
{{ att.filename }}
+
{{ formatBytes(att.size) }}
+
Waiting for image source…
+
+
+ +
+ } + } @else { +
+
+
+
{{ att.filename }}
+
{{ formatBytes(att.size) }}
+
+
+ @if (!isUploader(att)) { + @if (!att.available) { +
+
+
+
+ {{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}% + @if (att.speedBps) { + • {{ formatSpeed(att.speedBps) }} + } +
+ @if (!(att.receivedBytes || 0)) { + + } @else { + + } + } @else { + + } + } @else { +
Shared from your device
+ } +
+
+
+ } + } +
+ } + } + + + @if (message.reactions.length > 0) { +
+ @for (reaction of getGroupedReactions(message); track reaction.emoji) { + + } +
+ } +
+ + + @if (!message.isDeleted) { +
+ +
+ + + @if (showEmojiPicker() === message.id) { +
+ @for (emoji of commonEmojis; track emoji) { + + } +
+ } +
+ + + + + + @if (isOwnMessage(message)) { + + } + + + @if (isOwnMessage(message) || isAdmin()) { + + } +
+ } +
+ } + } + + @if (showNewMessagesBar()) { +
+
+ New messages + +
+
+ } +
+ + +
+ + + @if (replyTo()) { +
+ + + Replying to {{ replyTo()?.senderName }} + + +
+ } + + + + + + @if (toolbarVisible()) { +
+
+ + + + + | + + + + + + + + + + +
+
+ } + + +
+
+ + + + + @if (dragActive()) { +
+
Drop files to attach
+
+ } + + @if (pendingFiles.length > 0) { +
+ @for (file of pendingFiles; track file.name) { +
+
{{ file.name }}
+
{{ formatBytes(file.size) }}
+ +
+ } +
+ } +
+
+
+ + + @if (lightboxAttachment()) { +
+
+ + +
+ + +
+ +
+
+ {{ lightboxAttachment()!.filename }} + {{ formatBytes(lightboxAttachment()!.size) }} +
+
+
+
+ } + + + @if (imageContextMenu()) { + + + + + } diff --git a/src/app/features/chat/chat-messages/chat-messages.component.scss b/src/app/features/chat/chat-messages/chat-messages.component.scss new file mode 100644 index 0000000..a158d3c --- /dev/null +++ b/src/app/features/chat/chat-messages/chat-messages.component.scss @@ -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; + } + } + } +} diff --git a/src/app/features/chat/chat-messages/chat-messages.component.ts b/src/app/features/chat/chat-messages/chat-messages.component.ts new file mode 100644 index 0000000..e623518 --- /dev/null +++ b/src/app/features/chat/chat-messages/chat-messages.component.ts @@ -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; + @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(null); + replyTo = signal(null); + showEmojiPicker = signal(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(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(); + 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 = `![${selected}](https://)`; + 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 { + 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 { + 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(); + 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![](${url})`; + } + } + + 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); + } +} diff --git a/src/app/features/chat/typing-indicator/typing-indicator.component.html b/src/app/features/chat/typing-indicator/typing-indicator.component.html new file mode 100644 index 0000000..267395e --- /dev/null +++ b/src/app/features/chat/typing-indicator/typing-indicator.component.html @@ -0,0 +1,12 @@ +@if (typingDisplay().length > 0) { +
+ + {{ typingDisplay().join(', ') }} + @if (typingOthersCount() > 0) { + and {{ typingOthersCount() }} others are typing... + } @else { + {{ typingDisplay().length === 1 ? 'is' : 'are' }} typing... + } + +
+} diff --git a/src/app/features/chat/typing-indicator/typing-indicator.component.ts b/src/app/features/chat/typing-indicator/typing-indicator.component.ts new file mode 100644 index 0000000..e9ea590 --- /dev/null +++ b/src/app/features/chat/typing-indicator/typing-indicator.component.ts @@ -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(); + + typingDisplay = signal([]); + typingOthersCount = signal(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)); + } +} diff --git a/src/app/features/chat/user-list.component.ts b/src/app/features/chat/user-list.component.ts deleted file mode 100644 index aaabbab..0000000 --- a/src/app/features/chat/user-list.component.ts +++ /dev/null @@ -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: ` -
- -
-

Members

-

{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice

- @if (voiceUsers().length > 0) { -
- @for (v of voiceUsers(); track v.id) { - - - {{ v.displayName }} - - } -
- } -
- - -
- @for (user of onlineUsers(); track user.id) { -
- -
-
- {{ user.displayName.charAt(0).toUpperCase() }} -
- -
- - -
-
- - {{ user.displayName }} - - @if (user.isAdmin) { - - } - @if (user.isRoomOwner) { - - } -
-
- - -
- @if (user.voiceState?.isSpeaking) { - - } @else if (user.voiceState?.isMuted) { - - } @else if (user.voiceState?.isConnected) { - - } - - @if (user.screenShareState?.isSharing) { - - } -
- - - @if (showUserMenu() === user.id && isAdmin() && !isCurrentUser(user)) { -
- @if (user.voiceState?.isConnected) { - - } - - -
- } -
- } - - @if (onlineUsers().length === 0) { -
- No users online -
- } -
-
- - - @if (showBanDialog()) { -
-
-

Ban User

-

- Are you sure you want to ban {{ userToBan()?.displayName }}? -

- -
- - -
- -
- - -
- -
- - -
-
-
- } - `, -}) -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(null); - showBanDialog = signal(false); - userToBan = signal(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(); - } -} diff --git a/src/app/features/chat/user-list/user-list.component.html b/src/app/features/chat/user-list/user-list.component.html new file mode 100644 index 0000000..59539a8 --- /dev/null +++ b/src/app/features/chat/user-list/user-list.component.html @@ -0,0 +1,147 @@ + +
+

Members

+

{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice

+ @if (voiceUsers().length > 0) { +
+ @for (v of voiceUsers(); track v.id) { + + + {{ v.displayName }} + + } +
+ } +
+ + +
+ @for (user of onlineUsers(); track user.id) { +
+ +
+ + +
+ + +
+
+ + {{ user.displayName }} + + @if (user.isAdmin) { + + } + @if (user.isRoomOwner) { + + } +
+
+ + +
+ @if (user.voiceState?.isSpeaking) { + + } @else if (user.voiceState?.isMuted) { + + } @else if (user.voiceState?.isConnected) { + + } + + @if (user.screenShareState?.isSharing) { + + } +
+ + + @if (showUserMenu() === user.id && isAdmin() && !isCurrentUser(user)) { +
+ @if (user.voiceState?.isConnected) { + + } + + +
+ } +
+ } + + @if (onlineUsers().length === 0) { +
+ No users online +
+ } +
+ + + @if (showBanDialog()) { + +

+ Are you sure you want to ban {{ userToBan()?.displayName }}? +

+ +
+ + +
+ +
+ + +
+
+ } diff --git a/src/app/features/chat/user-list/user-list.component.ts b/src/app/features/chat/user-list/user-list.component.ts new file mode 100644 index 0000000..c3a3c35 --- /dev/null +++ b/src/app/features/chat/user-list/user-list.component.ts @@ -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; + voiceUsers = computed(() => this.onlineUsers().filter((user: User) => !!user.voiceState?.isConnected)); + currentUser = this.store.selectSignal(selectCurrentUser) as import('@angular/core').Signal; + isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin); + + showUserMenu = signal(null); + showBanDialog = signal(false); + userToBan = signal(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(); + } +} diff --git a/src/app/features/room/chat-room/chat-room.component.ts b/src/app/features/room/chat-room/chat-room.component.ts index d8954d4..a7f16d4 100644 --- a/src/app/features/room/chat-room/chat-room.component.ts +++ b/src/app/features/room/chat-room/chat-room.component.ts @@ -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); } } diff --git a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html index 8d7f7f7..64689a3 100644 --- a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html +++ b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html @@ -126,25 +126,12 @@
@for (u of voiceUsersInRoom(ch.id); track u.id) {
- @if (u.avatarUrl) { - - } @else { -
- {{ u.displayName.charAt(0).toUpperCase() }} -
- } + {{ u.displayName }} @if (u.screenShareState?.isSharing || isUserSharing(u.id)) { + + @if (canManageChannels()) { -
- - +
+ + } -
+ } @if (showUserMenu()) { -
-
+ @if (isAdmin()) { - @if (contextMenuUser()?.role === 'member') { - - + + } @if (contextMenuUser()?.role === 'moderator') { - - + + } @if (contextMenuUser()?.role === 'admin') { - + } -
- +
+ } @else { -
No actions available
+
No actions available
} -
+ } @if (showCreateChannelDialog()) { -
-
-
-

Create {{ createChannelType() === 'text' ? 'Text' : 'Voice' }} Channel

- -
-
- - -
-
+ + + } diff --git a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts index e609f8a..50dbf69 100644 --- a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts +++ b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts @@ -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(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; diff --git a/src/app/features/server-search/server-search.component.ts b/src/app/features/server-search/server-search.component.ts index 6182933..4247bd5 100644 --- a/src/app/features/server-search/server-search.component.ts +++ b/src/app/features/server-search/server-search.component.ts @@ -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, diff --git a/src/app/features/servers/servers-rail.component.html b/src/app/features/servers/servers-rail.component.html index 99a1b89..56b22a0 100644 --- a/src/app/features/servers/servers-rail.component.html +++ b/src/app/features/servers/servers-rail.component.html @@ -10,48 +10,44 @@
- + @for (room of savedRooms(); track room.id) { - + }
-
-
-
- - -
-
+@if (showMenu()) { + + @if (isCurrentContextRoom()) { + + } + + +} -
-
-
-
-

Forget Server?

-

- Remove {{ contextRoom()?.name }} from your My Servers list. -

-
-
- - -
-
-
+@if (showConfirm()) { + +

Remove {{ contextRoom()?.name }} from your My Servers list.

+
+} diff --git a/src/app/features/servers/servers-rail.component.ts b/src/app/features/servers/servers-rail.component.ts index 7e353a9..ea4092e 100644 --- a/src/app/features/servers/servers-rail.component.ts +++ b/src/app/features/servers/servers-rail.component.ts @@ -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(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); } diff --git a/src/app/features/settings/settings.component.ts b/src/app/features/settings/settings.component.ts index fdd3b10..69d8661 100644 --- a/src/app/features/settings/settings.component.ts +++ b/src/app/features/settings/settings.component.ts @@ -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 { 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(['/']); } diff --git a/src/app/features/shell/title-bar.component.html b/src/app/features/shell/title-bar.component.html index f0c13a7..a87f0cf 100644 --- a/src/app/features/shell/title-bar.component.html +++ b/src/app/features/shell/title-bar.component.html @@ -1,41 +1,75 @@ -
-
- - - - {{ roomName() }} - - - -
- -
- -
-
- -
- {{ username() }} | {{ serverName() }} - Reconnecting… -
-
-
-
- - - - -
+
+
+ @if (inRoom()) { + + } + @if (inRoom()) { + + {{ roomName() }} + @if (roomDescription()) { + + } + + + @if (showMenu()) { +
+ +
+ +
+ } + } @else { +
+ {{ username() }} | {{ serverName() }} + @if (isReconnecting()) { + Reconnecting… + } +
+ } +
+
+ @if (!isAuthed()) { + + } + @if (isElectron()) { + + + + } +
-
+@if (showMenu()) { +
+} diff --git a/src/app/features/shell/title-bar.component.ts b/src/app/features/shell/title-bar.component.ts index 41a16c7..1b7563e 100644 --- a/src/app/features/shell/title-bar.component.ts +++ b/src/app/features/shell/title-bar.component.ts @@ -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']); } diff --git a/src/app/features/voice/floating-voice-controls/floating-voice-controls.component.ts b/src/app/features/voice/floating-voice-controls/floating-voice-controls.component.ts index 0b0d346..bfacb4a 100644 --- a/src/app/features/voice/floating-voice-controls/floating-voice-controls.component.ts +++ b/src/app/features/voice/floating-voice-controls/floating-voice-controls.component.ts @@ -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 { 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()) { diff --git a/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.html b/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.html index 75cfaa4..eec3209 100644 --- a/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.html +++ b/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.html @@ -19,12 +19,13 @@
- - {{ activeScreenSharer()?.displayName }} is sharing their screen - - + @if (activeScreenSharer()) { + + {{ activeScreenSharer()?.displayName }} is sharing their screen + + } @else { Someone is sharing their screen - + }
@@ -71,10 +72,12 @@
-
-
- -

Waiting for screen share...

+ @if (!hasStream()) { +
+
+ +

Waiting for screen share...

+
-
+ }
diff --git a/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.ts b/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.ts index 5694b7b..139ccf8 100644 --- a/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.ts +++ b/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.ts @@ -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; @@ -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))); diff --git a/src/app/features/voice/voice-controls/voice-controls.component.html b/src/app/features/voice/voice-controls/voice-controls.component.html index fd86ac7..855695e 100644 --- a/src/app/features/voice/voice-controls/voice-controls.component.html +++ b/src/app/features/voice/voice-controls/voice-controls.component.html @@ -1,4 +1,4 @@ -
+
@if (showConnectionError()) {
@@ -10,9 +10,7 @@
-
- {{ currentUser()?.displayName?.charAt(0)?.toUpperCase() || '?' }} -
+

{{ currentUser()?.displayName || 'Unknown' }} @@ -145,7 +143,10 @@

- @@ -154,7 +155,12 @@
- +

Off by default; viewers will still hear your mic.

@@ -162,7 +168,15 @@ - +
diff --git a/src/app/features/voice/voice-controls/voice-controls.component.ts b/src/app/features/voice/voice-controls/voice-controls.component.ts index b4c2c00..2841bc7 100644 --- a/src/app/features/voice/voice-controls/voice-controls.component.ts +++ b/src/app/features/voice/voice-controls/voice-controls.component.ts @@ -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 { @@ -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 { 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 { 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(() => {}); } }); } diff --git a/src/app/shared/components/confirm-dialog/confirm-dialog.component.ts b/src/app/shared/components/confirm-dialog/confirm-dialog.component.ts new file mode 100644 index 0000000..67039ac --- /dev/null +++ b/src/app/shared/components/confirm-dialog/confirm-dialog.component.ts @@ -0,0 +1,80 @@ +import { Component, input, output, HostListener } from '@angular/core'; + +/** + * Reusable confirmation dialog modal. + * + * Usage: + * ```html + * @if (showConfirm()) { + * + *

This will permanently delete the room.

+ *
+ * } + * ``` + */ +@Component({ + selector: 'app-confirm-dialog', + standalone: true, + template: ` + +
+ +
+
+

{{ title() }}

+
+ +
+
+
+ + +
+
+ `, + styles: [`:host { display: contents; }`], +}) +export class ConfirmDialogComponent { + /** Dialog title. */ + title = input.required(); + /** Label for the confirm button. */ + confirmLabel = input('Confirm'); + /** Label for the cancel button. */ + cancelLabel = input('Cancel'); + /** Visual style of the confirm button. */ + variant = input<'primary' | 'danger'>('primary'); + /** Tailwind width class for the dialog. */ + widthClass = input('w-[320px]'); + /** Emitted when the user confirms. */ + confirmed = output(); + /** Emitted when the user cancels (backdrop click, Cancel button, or Escape). */ + cancelled = output(); + + @HostListener('document:keydown.escape') + onEscape(): void { + this.cancelled.emit(); + } +} diff --git a/src/app/shared/components/context-menu/context-menu.component.ts b/src/app/shared/components/context-menu/context-menu.component.ts new file mode 100644 index 0000000..d32afe3 --- /dev/null +++ b/src/app/shared/components/context-menu/context-menu.component.ts @@ -0,0 +1,77 @@ +import { Component, input, output, HostListener } from '@angular/core'; + +/** + * Generic positioned context-menu overlay. + * + * Usage: + * ```html + * @if (showMenu()) { + * + * + * + * } + * ``` + * + * 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: ` + +
+ +
+ +
+ `, + 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(); + /** Vertical position (px from top). */ + y = input.required(); + /** Tailwind width class for the panel (default `w-48`). */ + width = input('w-48'); + /** Emitted when the menu should close (backdrop click or Escape). */ + closed = output(); + + @HostListener('document:keydown.escape') + onEscape(): void { + this.closed.emit(); + } +} diff --git a/src/app/shared/components/user-avatar/user-avatar.component.ts b/src/app/shared/components/user-avatar/user-avatar.component.ts new file mode 100644 index 0000000..b210bcd --- /dev/null +++ b/src/app/shared/components/user-avatar/user-avatar.component.ts @@ -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 + * + * + * ``` + */ +@Component({ + selector: 'app-user-avatar', + standalone: true, + template: ` + @if (avatarUrl()) { + + } @else { +
+ {{ initial() }} +
+ } + `, + styles: [`:host { display: contents; }`], +}) +export class UserAvatarComponent { + /** Display name — first character is used as fallback initial. */ + name = input.required(); + /** Optional avatar image URL. */ + avatarUrl = input(); + /** 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(''); + + /** 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'; + } + } +} diff --git a/src/app/shared/index.ts b/src/app/shared/index.ts new file mode 100644 index 0000000..ea596ed --- /dev/null +++ b/src/app/shared/index.ts @@ -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'; diff --git a/src/app/store/index.ts b/src/app/store/index.ts index 618bd34..4d8d611 100644 --- a/src/app/store/index.ts +++ b/src/app/store/index.ts @@ -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 = { messages: messagesReducer, users: usersReducer, rooms: roomsReducer, }; +/** Meta-reducers (e.g. logging) enabled only in development builds. */ export const metaReducers: MetaReducer[] = 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'; diff --git a/src/app/store/messages/messages-incoming.handlers.ts b/src/app/store/messages/messages-incoming.handlers.ts new file mode 100644 index 0000000..9c64873 --- /dev/null +++ b/src/app/store/messages/messages-incoming.handlers.ts @@ -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` 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; + +/** + * 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 { + 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 { + 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 { + 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 = {}; + 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 { + 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 { + 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, + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + attachments.handleFileAnnounce(event); + return EMPTY; +} + +function handleFileChunk( + event: any, + { attachments }: IncomingMessageContext, +): Observable { + attachments.handleFileChunk(event); + return EMPTY; +} + +function handleFileRequest( + event: any, + { attachments }: IncomingMessageContext, +): Observable { + attachments.handleFileRequest(event); + return EMPTY; +} + +function handleFileCancel( + event: any, + { attachments }: IncomingMessageContext, +): Observable { + attachments.handleFileCancel(event); + return EMPTY; +} + +function handleFileNotFound( + event: any, + { attachments }: IncomingMessageContext, +): Observable { + 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 { + 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 { + 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 { + 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> = { + // 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 { + const handler = HANDLER_MAP[event.type]; + return handler ? handler(event, ctx) : EMPTY; +} diff --git a/src/app/store/messages/messages-sync.effects.ts b/src/app/store/messages/messages-sync.effects.ts new file mode 100644 index 0000000..9abb0b8 --- /dev/null +++ b/src/app/store/messages/messages-sync.effects.ts @@ -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(); + + /** + * 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((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 }, + ); +} diff --git a/src/app/store/messages/messages.actions.ts b/src/app/store/messages/messages.actions.ts index 1b44059..2619515 100644 --- a/src/app/store/messages/messages.actions.ts +++ b/src/app/store/messages/messages.actions.ts @@ -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(), + }, +}); diff --git a/src/app/store/messages/messages.effects.ts b/src/app/store/messages/messages.effects.ts index 2712570..243263c 100644 --- a/src/app/store/messages/messages.effects.ts +++ b/src/app/store/messages/messages.effects.ts @@ -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); - } - }), - 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(); - 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 = {}; - 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(); - - 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((resolve) => setTimeout(resolve, 5000)); + const ctx: IncomingMessageContext = { + db: this.db, + webrtc: this.webrtc, + attachments: this.attachments, + currentUser, + currentRoom, + }; + return dispatchIncomingMessage(event, ctx); }), - 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 } + ), ); } diff --git a/src/app/store/messages/messages.helpers.ts b/src/app/store/messages/messages.helpers.ts new file mode 100644 index 0000000..0bed04b --- /dev/null +++ b/src/app/store/messages/messages.helpers.ts @@ -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(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 { + 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 { + 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 { + 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> { + const map = new Map(); + 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[] { + 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 { + 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 }; +} diff --git a/src/app/store/messages/messages.reducer.ts b/src/app/store/messages/messages.reducer.ts index f7d2149..19af6cc 100644 --- a/src/app/store/messages/messages.reducer.ts +++ b/src/app/store/messages/messages.reducer.ts @@ -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 { + /** 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, diff --git a/src/app/store/messages/messages.selectors.ts b/src/app/store/messages/messages.selectors.ts index 0f3048b..3ae44d5 100644 --- a/src/app/store/messages/messages.selectors.ts +++ b/src/app/store/messages/messages.selectors.ts @@ -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('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) ); diff --git a/src/app/store/rooms/rooms.actions.ts b/src/app/store/rooms/rooms.actions.ts index 8cced90..3578383 100644 --- a/src/app/store/rooms/rooms.actions.ts +++ b/src/app/store/rooms/rooms.actions.ts @@ -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 }>(), + '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 }>(), -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 }>(), + 'Receive Room Update': props<{ room: Partial }>(), -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 }>() -); - -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 }>() -); - -// 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 }>() -); - -// Receive room update from peer -export const receiveRoomUpdate = createAction( - '[Rooms] Receive Room Update', - props<{ room: Partial }>() -); - -// 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 }>(), + }, +}); diff --git a/src/app/store/rooms/rooms.effects.ts b/src/app/store/rooms/rooms.effects.ts index 23e8804..22dc043 100644 --- a/src/app/store/rooms/rooms.effects.ts +++ b/src/app/store/rooms/rooms.effects.ts @@ -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 = {}, +) { + 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 = { - 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,223 +475,174 @@ 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(), - }, - }) - ); - } - }); - } 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_joined': { + if (isWrongServer(message.serverId, viewedServerId) || message.oderId === myId) return EMPTY; + return [UsersActions.userJoined({ user: buildSignalingUser(message) })]; } - // 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(), - }, - }) - ); + case 'user_left': { + if (isWrongServer(message.serverId, viewedServerId)) return EMPTY; + return [UsersActions.userLeft({ userId: message.oderId })]; } - } 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 })); + default: + return EMPTY; } - - 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 | undefined; - if (!userId || !vs) return of({ type: 'NO_OP' }); - - // Check if user exists in the store - const userExists = allUsers.some(u => u.id === userId || u.oderId === userId); - - if (!userExists) { - // User doesn't exist yet - create them with the 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(), - voiceState: { - isConnected: vs.isConnected ?? false, - isMuted: vs.isMuted ?? false, - isDeafened: vs.isDeafened ?? false, - isSpeaking: vs.isSpeaking ?? false, - isMutedByAdmin: vs.isMutedByAdmin, - volume: vs.volume, - roomId: vs.roomId, - serverId: vs.serverId, - }, - }, - })); - } - - return of(UsersActions.updateVoiceState({ userId, voiceState: vs })); - } - case 'screen-state': { - const userId = (event.fromPeerId as string) || (event.oderId as string); - const isSharing = event.isScreenSharing as boolean | undefined; - if (!userId || isSharing === undefined) return of({ type: 'NO_OP' }); - - // Check if user exists in the store - const userExists = allUsers.some(u => u.id === userId || u.oderId === userId); - - if (!userExists) { - // User doesn't exist yet - create them with the screen share state - const displayName = event.displayName || 'User'; - return of(UsersActions.userJoined({ - user: { - oderId: userId, - id: userId, - username: displayName.toLowerCase().replace(/\s+/g, '_'), - displayName: displayName, - status: 'online', - isOnline: true, - role: 'member', - joinedAt: Date.now(), - screenShareState: { - isSharing, - }, - }, - })); - } - - return of(UsersActions.updateScreenShareState({ - userId, - screenShareState: { isSharing }, - })); - } - case 'room-settings-update': { - const settings: RoomSettings | undefined = event.settings; - if (!settings) return of({ type: 'NO_OP' }); - this.db.updateRoom(currentRoom.id, settings); - return of( - RoomsActions.receiveRoomUpdate({ room: { ...settings } as Partial }) - ); - } - - // Server icon sync handshake - case 'server-icon-summary': { - const remoteUpdated = event.iconUpdatedAt || 0; - const localUpdated = currentRoom.iconUpdatedAt || 0; - const needsSync = remoteUpdated > localUpdated; - if (needsSync && event.fromPeerId) { - this.webrtc.sendToPeer(event.fromPeerId, { type: 'server-icon-request', roomId: currentRoom.id } as any); - } - return of({ type: 'NO_OP' }); - } - - case 'server-icon-request': { - if (event.fromPeerId) { - this.webrtc.sendToPeer(event.fromPeerId, { - type: 'server-icon-full', - roomId: currentRoom.id, - icon: currentRoom.icon, - iconUpdatedAt: currentRoom.iconUpdatedAt || 0, - } as any); - } - return of({ type: 'NO_OP' }); - } - + case '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': { - if (typeof event.icon !== 'string') return of({ type: 'NO_OP' }); - // Enforce that only owner or permitted roles can update - const senderId = event.fromPeerId as string | undefined; - if (!senderId) return of({ type: 'NO_OP' }); - return this.store.select(selectAllUsers).pipe( - map((users) => users.find((u) => u.id === senderId)), - mergeMap((sender) => { - if (!sender) return of({ type: 'NO_OP' }); - const role = sender.role; - const perms = currentRoom.permissions || {}; - const isOwner = currentRoom.hostId === sender.id; - const canByRole = (role === 'admin' && perms.adminsManageIcon) || (role === 'moderator' && perms.moderatorsManageIcon); - if (!isOwner && !canByRole) { - return of({ type: 'NO_OP' }); - } - const updates: Partial = { - icon: event.icon, - iconUpdatedAt: event.iconUpdatedAt || Date.now(), - }; - this.db.updateRoom(currentRoom.id, updates); - return of(RoomsActions.updateRoom({ roomId: currentRoom.id, changes: updates })); - }) - ); - } + case 'server-icon-update': + return this.handleIconData(event, room); + default: + return EMPTY; } - - return of({ type: 'NO_OP' }); - }) - ) + }), + ), ); - // On peer connect, broadcast local server icon summary (sync upon join/connect) + private handleVoiceOrScreenState( + event: any, + allUsers: any[], + kind: 'voice' | 'screen', + ) { + const userId: string | undefined = event.fromPeerId ?? event.oderId; + if (!userId) return EMPTY; + + const userExists = allUsers.some((u) => u.id === userId || u.oderId === userId); + + if (kind === 'voice') { + const vs = event.voiceState as Partial | undefined; + if (!vs) return EMPTY; + + if (!userExists) { + return of(UsersActions.userJoined({ + user: buildSignalingUser( + { oderId: userId, displayName: event.displayName || 'User' }, + { + voiceState: { + isConnected: vs.isConnected ?? false, + isMuted: vs.isMuted ?? false, + isDeafened: vs.isDeafened ?? false, + isSpeaking: vs.isSpeaking ?? false, + isMutedByAdmin: vs.isMutedByAdmin, + volume: vs.volume, + roomId: vs.roomId, + serverId: vs.serverId, + }, + }, + ), + })); + } + return of(UsersActions.updateVoiceState({ userId, voiceState: vs })); + } + + // screen-state + const isSharing = event.isScreenSharing as boolean | undefined; + if (isSharing === undefined) return EMPTY; + + if (!userExists) { + return of(UsersActions.userJoined({ + user: buildSignalingUser( + { oderId: userId, displayName: event.displayName || 'User' }, + { screenShareState: { isSharing } }, + ), + })); + } + return of(UsersActions.updateScreenShareState({ + userId, + screenShareState: { isSharing }, + })); + } + + private handleRoomSettingsUpdate(event: any, room: Room) { + const settings: RoomSettings | undefined = event.settings; + if (!settings) return EMPTY; + this.db.updateRoom(room.id, settings); + return of(RoomsActions.receiveRoomUpdate({ room: { ...settings } as Partial })); + } + + private handleIconSummary(event: any, room: Room) { + const remoteUpdated = event.iconUpdatedAt || 0; + const localUpdated = room.iconUpdatedAt || 0; + if (remoteUpdated > localUpdated && event.fromPeerId) { + this.webrtc.sendToPeer(event.fromPeerId, { + type: 'server-icon-request', + roomId: room.id, + } as any); + } + return EMPTY; + } + + private handleIconRequest(event: any, room: Room) { + if (event.fromPeerId) { + this.webrtc.sendToPeer(event.fromPeerId, { + type: 'server-icon-full', + roomId: room.id, + icon: room.icon, + iconUpdatedAt: room.iconUpdatedAt || 0, + } as any); + } + return EMPTY; + } + + private handleIconData(event: any, room: Room) { + const senderId = event.fromPeerId as string | undefined; + if (typeof event.icon !== 'string' || !senderId) return EMPTY; + + return this.store.select(selectAllUsers).pipe( + map((users) => users.find((u) => u.id === senderId)), + mergeMap((sender) => { + if (!sender) return EMPTY; + const perms = room.permissions || {}; + const isOwner = room.hostId === sender.id; + const canByRole = + (sender.role === 'admin' && perms.adminsManageIcon) || + (sender.role === 'moderator' && perms.moderatorsManageIcon); + if (!isOwner && !canByRole) return EMPTY; + + const updates: Partial = { + icon: event.icon, + iconUpdatedAt: event.iconUpdatedAt || Date.now(), + }; + this.db.updateRoom(room.id, updates); + return of(RoomsActions.updateRoom({ roomId: room.id, changes: updates })); + }), + ); + } + + /** Broadcasts the local server icon summary to peers when a new peer connects. */ peerConnectedIconSync$ = createEffect( () => this.webrtc.onPeerConnected.pipe( diff --git a/src/app/store/rooms/rooms.reducer.ts b/src/app/store/rooms/rooms.reducer.ts index 7eeed3e..c0d8b31 100644 --- a/src/app/store/rooms/rooms.reducer.ts +++ b/src/app/store/rooms/rooms.reducer.ts @@ -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(); - 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, diff --git a/src/app/store/rooms/rooms.selectors.ts b/src/app/store/rooms/rooms.selectors.ts index b3939b7..7b4e3ff 100644 --- a/src/app/store/rooms/rooms.selectors.ts +++ b/src/app/store/rooms/rooms.selectors.ts @@ -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('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) ); diff --git a/src/app/store/users/users.actions.ts b/src/app/store/users/users.actions.ts index 56d4964..0c44354 100644 --- a/src/app/store/users/users.actions.ts +++ b/src/app/store/users/users.actions.ts @@ -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 }>(), -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 }>() -); + 'Update User': props<{ userId: string; updates: Partial }>(), + '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 }>() -); - -// 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 }>() -); - -// Update screen share state for a user -export const updateScreenShareState = createAction( - '[Users] Update Screen Share State', - props<{ userId: string; screenShareState: Partial }>() -); + 'Update Voice State': props<{ userId: string; voiceState: Partial }>(), + 'Update Screen Share State': props<{ userId: string; screenShareState: Partial }>(), + }, +}); diff --git a/src/app/store/users/users.effects.ts b/src/app/store/users/users.effects.ts index c9635b1..aa2af32 100644 --- a/src/app/store/users/users.effects.ts +++ b/src/app/store/users/users.effects.ts @@ -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( diff --git a/src/app/store/users/users.reducer.ts b/src/app/store/users/users.reducer.ts index 2916418..5cec22c 100644 --- a/src/app/store/users/users.reducer.ts +++ b/src/app/store/users/users.reducer.ts @@ -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 { + /** 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 diff --git a/src/app/store/users/users.selectors.ts b/src/app/store/users/users.selectors.ts index 7c7c923..fffd62e 100644 --- a/src/app/store/users/users.selectors.ts +++ b/src/app/store/users/users.selectors.ts @@ -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('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' diff --git a/src/main.ts b/src/main.ts index 5df75f9..c11b244 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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)); diff --git a/src/styles.scss b/src/styles.scss index 72272a4..0f7009f 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -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';