Refactor and code designing

This commit is contained in:
2026-03-02 03:30:22 +01:00
parent 6d7465ff18
commit e231f4ed05
80 changed files with 6690 additions and 4670 deletions

View File

@@ -65,8 +65,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
"maximumWarning": "1MB",
"maximumError": "2MB"
},
{
"type": "anyComponentStyle",

View File

@@ -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');

View File

@@ -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', () => {

View File

@@ -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'),

View File

@@ -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"
]
}
}
}

View File

@@ -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,

View File

@@ -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),
},
];

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, inject } from '@angular/core';
import { Component, OnInit, inject, HostListener } from '@angular/core';
import { Router, RouterOutlet, NavigationEnd } from '@angular/router';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
@@ -7,13 +7,21 @@ import { DatabaseService } from './core/services/database.service';
import { ServerDirectoryService } from './core/services/server-directory.service';
import { TimeSyncService } from './core/services/time-sync.service';
import { VoiceSessionService } from './core/services/voice-session.service';
import { ExternalLinkService } from './core/services/external-link.service';
import { ServersRailComponent } from './features/servers/servers-rail.component';
import { TitleBarComponent } from './features/shell/title-bar.component';
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
import * as UsersActions from './store/users/users.actions';
import * as RoomsActions from './store/rooms/rooms.actions';
import { UsersActions } from './store/users/users.actions';
import { RoomsActions } from './store/rooms/rooms.actions';
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
import { ROOM_URL_PATTERN, STORAGE_KEY_CURRENT_USER_ID, STORAGE_KEY_LAST_VISITED_ROUTE } from './core/constants';
/**
* Root application component.
*
* Initialises the database, loads persisted user and room data,
* handles route restoration, and tracks voice session navigation.
*/
@Component({
selector: 'app-root',
imports: [CommonModule, RouterOutlet, ServersRailComponent, TitleBarComponent, FloatingVoiceControlsComponent],
@@ -27,9 +35,16 @@ export class App implements OnInit {
private servers = inject(ServerDirectoryService);
private timeSync = inject(TimeSyncService);
private voiceSession = inject(VoiceSessionService);
private externalLinks = inject(ExternalLinkService);
currentRoom = this.store.selectSignal(selectCurrentRoom);
/** Intercept all <a> clicks and open them externally. */
@HostListener('document:click', ['$event'])
onGlobalLinkClick(evt: MouseEvent): void {
this.externalLinks.handleClick(evt);
}
async ngOnInit(): Promise<void> {
// Initialize database
await this.databaseService.initialize();
@@ -47,13 +62,13 @@ export class App implements OnInit {
this.store.dispatch(RoomsActions.loadRooms());
// If not authenticated, redirect to login; else restore last route
const currentUserId = localStorage.getItem('metoyou_currentUserId');
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
if (!currentUserId) {
if (this.router.url !== '/login' && this.router.url !== '/register') {
this.router.navigate(['/login']).catch(() => {});
}
} else {
const last = localStorage.getItem('metoyou_lastVisitedRoute');
const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE);
if (last && typeof last === 'string') {
const current = this.router.url;
if (current === '/' || current === '/search') {
@@ -67,11 +82,11 @@ export class App implements OnInit {
if (evt instanceof NavigationEnd) {
const url = evt.urlAfterRedirects || evt.url;
// Store room route or search
localStorage.setItem('metoyou_lastVisitedRoute', url);
localStorage.setItem(STORAGE_KEY_LAST_VISITED_ROUTE, url);
// Check if user navigated away from voice-connected server
// Extract roomId from URL if on a room route
const roomMatch = url.match(/\/room\/([^/]+)/);
const roomMatch = url.match(ROOM_URL_PATTERN);
const currentRoomId = roomMatch ? roomMatch[1] : null;
// Update voice session service with current server context

36
src/app/core/constants.ts Normal file
View File

@@ -0,0 +1,36 @@
/**
* Application-wide constants shared across multiple components and services.
*
* Centralises localStorage keys, common defaults, and UI thresholds
* so that magic strings and numbers are defined in one place.
*/
/** Key used to persist the current user's ID in localStorage. */
export const STORAGE_KEY_CURRENT_USER_ID = 'metoyou_currentUserId';
/** Key used to persist the last visited route for session restore. */
export const STORAGE_KEY_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute';
/** Key used to persist signaling / API connection settings. */
export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
/** Key used to persist voice settings (input/output devices, volume). */
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings';
/** Regex that extracts a roomId from a `/room/:roomId` URL path. */
export const ROOM_URL_PATTERN = /\/room\/([^/]+)/;
/** Maximum number of actions retained by NgRx Store devtools. */
export const STORE_DEVTOOLS_MAX_AGE = 25;
/** Default maximum number of users allowed in a new room. */
export const DEFAULT_MAX_USERS = 50;
/** Default audio bitrate in kbps for voice chat. */
export const DEFAULT_AUDIO_BITRATE_KBPS = 96;
/** Default volume level (0100). */
export const DEFAULT_VOLUME = 100;
/** Default search debounce time in milliseconds. */
export const SEARCH_DEBOUNCE_MS = 300;

View File

@@ -1,192 +1,420 @@
// Models for the P2P Chat Application
/**
* Core domain models for the MetoYou P2P chat application.
*
* These interfaces define the data structures shared across
* services, store, and components.
*/
/** Possible online-presence statuses for a user. */
export type UserStatus = 'online' | 'away' | 'busy' | 'offline';
/** Role hierarchy within a room/server. */
export type UserRole = 'host' | 'admin' | 'moderator' | 'member';
/** Channel type within a server. */
export type ChannelType = 'text' | 'voice';
/**
* Represents an authenticated user in the system.
* Users are identified by both a local `id` and a network-wide `oderId`.
*/
export interface User {
/** Local database identifier. */
id: string;
oderId: string; // Unique order ID for peer identification
/** Network-wide unique identifier used for peer identification. */
oderId: string;
/** Login username (unique per auth server). */
username: string;
/** Human-readable display name shown in the UI. */
displayName: string;
/** Optional URL to the user's avatar image. */
avatarUrl?: string;
status: 'online' | 'away' | 'busy' | 'offline';
role: 'host' | 'admin' | 'moderator' | 'member';
/** Current online-presence status. */
status: UserStatus;
/** Role within the current room/server. */
role: UserRole;
/** Epoch timestamp (ms) when the user first joined. */
joinedAt: number;
/** WebRTC peer identifier (transient, set when connected). */
peerId?: string;
/** Whether the user is currently connected. */
isOnline?: boolean;
/** Whether the user holds admin-level privileges. */
isAdmin?: boolean;
/** Whether the user is the owner of the current room. */
isRoomOwner?: boolean;
/** Real-time voice connection state. */
voiceState?: VoiceState;
/** Real-time screen-sharing state. */
screenShareState?: ScreenShareState;
}
/**
* A communication channel within a server (either text or voice).
*/
export interface Channel {
/** Unique channel identifier. */
id: string;
/** Display name of the channel. */
name: string;
type: 'text' | 'voice';
position: number; // ordering within its type group
/** Whether this is a text chat or voice channel. */
type: ChannelType;
/** Sort order within its type group (lower value = higher priority). */
position: number;
}
/**
* A single chat message in a room's text channel.
*/
export interface Message {
/** Unique message identifier. */
id: string;
/** The room this message belongs to. */
roomId: string;
channelId?: string; // which text channel the message belongs to (default: 'general')
/** The text channel within the room (defaults to 'general'). */
channelId?: string;
/** Identifier of the user who sent the message. */
senderId: string;
/** Display name of the sender at the time of sending. */
senderName: string;
/** Markdown-formatted message body. */
content: string;
/** Epoch timestamp (ms) when the message was created. */
timestamp: number;
/** Epoch timestamp (ms) of the last edit, if any. */
editedAt?: number;
/** Emoji reactions attached to this message. */
reactions: Reaction[];
/** Whether this message has been soft-deleted. */
isDeleted: boolean;
/** If this is a reply, the ID of the parent message. */
replyToId?: string;
}
/**
* An emoji reaction on a message.
*/
export interface Reaction {
/** Unique reaction identifier. */
id: string;
/** The message this reaction is attached to. */
messageId: string;
/** Network-wide user ID of the reactor. */
oderId: string;
userId: string; // Alias for backward compatibility
/** Alias for `oderId` (kept for backward compatibility). */
userId: string;
/** The emoji character(s) used. */
emoji: string;
/** Epoch timestamp (ms) when the reaction was added. */
timestamp: number;
}
/**
* A chat room (server) that users can join to communicate.
*/
export interface Room {
/** Unique room identifier. */
id: string;
/** Display name of the room. */
name: string;
/** Optional long-form description. */
description?: string;
/** Short topic/status line shown in the header. */
topic?: string;
/** User ID of the room's creator/owner. */
hostId: string;
/** Password required to join (if private). */
password?: string;
/** Whether the room requires a password to join. */
isPrivate: boolean;
/** Epoch timestamp (ms) when the room was created. */
createdAt: number;
/** Current number of connected users. */
userCount: number;
/** Maximum allowed concurrent users. */
maxUsers?: number;
// Optional server icon synced P2P
icon?: string; // data URL (e.g., base64 PNG) or remote URL
iconUpdatedAt?: number; // last update timestamp for conflict resolution
// Role-based management permissions
/** Server icon as a data-URL or remote URL. */
icon?: string;
/** Epoch timestamp (ms) of the last icon update (for conflict resolution). */
iconUpdatedAt?: number;
/** Role-based management permission overrides. */
permissions?: RoomPermissions;
// Channels within the server
/** Text and voice channels within the server. */
channels?: Channel[];
}
/**
* Editable subset of room properties exposed in the settings UI.
*/
export interface RoomSettings {
/** Room display name. */
name: string;
/** Optional long-form description. */
description?: string;
/** Short topic/status line. */
topic?: string;
/** Whether a password is required to join. */
isPrivate: boolean;
/** Password for private rooms. */
password?: string;
/** Maximum allowed concurrent users. */
maxUsers?: number;
/** Optional list of room rules. */
rules?: string[];
}
/**
* Fine-grained permission toggles for a room.
* Controls which roles can perform management actions.
*/
export interface RoomPermissions {
// Whether admins can manage chat/voice rooms creation and modifications
/** Whether admins can create/modify rooms. */
adminsManageRooms?: boolean;
/** Whether moderators can create/modify rooms. */
moderatorsManageRooms?: boolean;
// Whether admins/moderators can change server icon
/** Whether admins can change the server icon. */
adminsManageIcon?: boolean;
/** Whether moderators can change the server icon. */
moderatorsManageIcon?: boolean;
// Existing capability toggles
/** Whether voice channels are enabled. */
allowVoice?: boolean;
/** Whether screen sharing is enabled. */
allowScreenShare?: boolean;
/** Whether file uploads are enabled. */
allowFileUploads?: boolean;
/** Minimum delay (seconds) between messages (0 = disabled). */
slowModeInterval?: number;
}
/**
* A record of a user being banned from a room.
*/
export interface BanEntry {
/** Unique ban identifier (also used as the banned user's oderId). */
oderId: string;
/** The banned user's local ID. */
userId: string;
/** The room the ban applies to. */
roomId: string;
/** User ID of the admin who issued the ban. */
bannedBy: string;
/** Display name of the banned user at the time of banning. */
displayName?: string;
/** Human-readable reason for the ban. */
reason?: string;
/** Epoch timestamp (ms) when the ban expires (undefined = permanent). */
expiresAt?: number;
/** Epoch timestamp (ms) when the ban was issued. */
timestamp: number;
}
/**
* Tracks the state of a WebRTC peer connection.
*/
export interface PeerConnection {
/** Remote peer identifier. */
peerId: string;
/** Local user identifier. */
userId: string;
/** Current connection lifecycle state. */
status: 'connecting' | 'connected' | 'disconnected' | 'failed';
/** The RTCDataChannel used for P2P messaging. */
dataChannel?: RTCDataChannel;
/** The underlying RTCPeerConnection. */
connection?: RTCPeerConnection;
}
/**
* Real-time voice connection state for a user in a voice channel.
*/
export interface VoiceState {
/** Whether the user is connected to a voice channel. */
isConnected: boolean;
/** Whether the user's microphone is muted (self or by admin). */
isMuted: boolean;
/** Whether the user has deafened themselves. */
isDeafened: boolean;
/** Whether the user is currently speaking (voice activity detection). */
isSpeaking: boolean;
/** Whether the user was server-muted by an admin. */
isMutedByAdmin?: boolean;
/** User's output volume level (0-1). */
volume?: number;
/** The voice channel/room ID within a server (e.g., 'general', 'afk') */
/** The voice channel ID within the server (e.g. 'vc-general'). */
roomId?: string;
/** The server ID the user is connected to voice in */
/** The server ID the user is connected to voice in. */
serverId?: string;
}
/**
* Real-time screen-sharing state for a user.
*/
export interface ScreenShareState {
/** Whether the user is actively sharing their screen. */
isSharing: boolean;
/** MediaStream ID of the screen capture. */
streamId?: string;
/** Desktop capturer source ID (Electron only). */
sourceId?: string;
/** Human-readable name of the captured source. */
sourceName?: string;
}
/** All signaling message types exchanged via the WebSocket relay. */
export type SignalingMessageType =
| 'offer'
| 'answer'
| 'ice-candidate'
| 'join'
| 'leave'
| 'chat'
| 'state-sync'
| 'kick'
| 'ban'
| 'host-change'
| 'room-update';
/**
* A message exchanged via the signaling WebSocket server.
*/
export interface SignalingMessage {
type: 'offer' | 'answer' | 'ice-candidate' | 'join' | 'leave' | 'chat' | 'state-sync' | 'kick' | 'ban' | 'host-change' | 'room-update';
/** The type of signaling event. */
type: SignalingMessageType;
/** Sender's peer ID. */
from: string;
/** Optional target peer ID (for directed messages). */
to?: string;
/** Arbitrary payload specific to the message type. */
payload: unknown;
/** Epoch timestamp (ms) when the message was sent. */
timestamp: number;
}
/** All P2P chat event types exchanged via RTCDataChannel. */
export type ChatEventType =
| 'message'
| 'chat-message'
| 'edit'
| 'message-edited'
| 'delete'
| 'message-deleted'
| 'reaction'
| 'reaction-added'
| 'reaction-removed'
| 'kick'
| 'ban'
| 'room-deleted'
| 'room-settings-update'
| 'voice-state'
| 'voice-state-request'
| 'state-request'
| 'screen-state'
| 'role-change'
| 'channels-update';
/**
* A P2P event exchanged between peers via RTCDataChannel.
* The `type` field determines which optional fields are populated.
*/
export interface ChatEvent {
type: 'message' | 'chat-message' | 'edit' | 'message-edited' | 'delete' | 'message-deleted' | 'reaction' | 'reaction-added' | 'reaction-removed' | 'kick' | 'ban' | 'room-deleted' | 'room-settings-update' | 'voice-state' | 'voice-state-request' | 'state-request' | 'screen-state' | 'role-change' | 'channels-update';
/** The type of P2P event. */
type: ChatEventType;
/** Relevant message ID (for edits, deletes, reactions). */
messageId?: string;
/** Full message payload (for new messages). */
message?: Message;
/** Reaction payload (for reaction events). */
reaction?: Reaction;
/** Partial message updates (for edits). */
data?: Partial<Message>;
/** Event timestamp. */
timestamp?: number;
/** Target user ID (for kick/ban). */
targetUserId?: string;
/** Room ID the event pertains to. */
roomId?: string;
/** User who issued a kick. */
kickedBy?: string;
/** User who issued a ban. */
bannedBy?: string;
/** Text content (for messages/edits). */
content?: string;
/** Edit timestamp. */
editedAt?: number;
/** User who performed a delete. */
deletedBy?: string;
/** Network-wide user identifier. */
oderId?: string;
/** Display name of the event sender. */
displayName?: string;
/** Emoji character (for reactions). */
emoji?: string;
/** Ban/kick reason. */
reason?: string;
/** Updated room settings. */
settings?: RoomSettings;
/** Partial voice state update. */
voiceState?: Partial<VoiceState>;
/** Screen-sharing flag. */
isScreenSharing?: boolean;
role?: 'host' | 'admin' | 'moderator' | 'member';
/** New role assignment. */
role?: UserRole;
/** Updated channel list. */
channels?: Channel[];
}
/**
* Server listing as returned by the directory API.
*/
export interface ServerInfo {
/** Unique server identifier. */
id: string;
/** Display name. */
name: string;
/** Optional description. */
description?: string;
/** Optional topic. */
topic?: string;
/** Display name of the host. */
hostName: string;
/** Owner's user ID. */
ownerId?: string;
/** Owner's public key / oderId. */
ownerPublicKey?: string;
/** Current number of connected users. */
userCount: number;
/** Maximum allowed users. */
maxUsers: number;
/** Whether a password is required. */
isPrivate: boolean;
/** Searchable tags. */
tags?: string[];
/** Epoch timestamp (ms) when the server was created. */
createdAt: number;
}
/**
* Request payload for joining a server.
*/
export interface JoinRequest {
/** Target room/server ID. */
roomId: string;
/** Requesting user's ID. */
userId: string;
/** Requesting user's username. */
username: string;
}
/**
* Top-level application state snapshot (used for diagnostics).
*/
export interface AppState {
/** The currently authenticated user, or null if logged out. */
currentUser: User | null;
/** The room the user is currently viewing, or null. */
currentRoom: Room | null;
/** Whether a connection attempt is in progress. */
isConnecting: boolean;
/** Last error message, or null. */
error: string | null;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,43 +1,98 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ServerDirectoryService, ServerEndpoint } from './server-directory.service';
/**
* Response returned by the authentication endpoints (login / register).
*/
export interface LoginResponse {
/** Unique user identifier assigned by the server. */
id: string;
/** Login username. */
username: string;
/** Human-readable display name. */
displayName: string;
}
/** Fallback API base URL used when no server endpoint is configured. */
const DEFAULT_API_BASE = 'http://localhost:3001/api';
/**
* Handles user authentication (login and registration) against a
* configurable back-end server.
*
* The target server is resolved via {@link ServerDirectoryService}: the
* caller may pass an explicit `serverId`, otherwise the currently active
* server endpoint is used.
*/
@Injectable({ providedIn: 'root' })
export class AuthService {
private http = inject(HttpClient);
private serverDirectory = inject(ServerDirectoryService);
private readonly http = inject(HttpClient);
private readonly serverDirectory = inject(ServerDirectoryService);
/**
* Resolve the API base URL for the given server.
*
* @param serverId - Optional server ID to look up. When omitted the
* currently active endpoint is used.
* @returns Fully-qualified API base URL (e.g. `http://host:3001/api`).
*/
private endpointFor(serverId?: string): string {
let base: ServerEndpoint | undefined;
let endpoint: ServerEndpoint | undefined;
if (serverId) {
base = this.serverDirectory.servers().find((s) => s.id === serverId);
}
const active = base || this.serverDirectory.activeServer();
return active ? `${active.url}/api` : 'http://localhost:3001/api';
endpoint = this.serverDirectory.servers().find(
(server) => server.id === serverId,
);
}
register(params: { username: string; password: string; displayName?: string; serverId?: string }): Observable<LoginResponse> {
const activeEndpoint = endpoint ?? this.serverDirectory.activeServer();
return activeEndpoint ? `${activeEndpoint.url}/api` : DEFAULT_API_BASE;
}
/**
* Register a new user account on the target server.
*
* @param params - Registration parameters.
* @param params.username - Desired login username.
* @param params.password - Account password.
* @param params.displayName - Optional display name (defaults to username on the server).
* @param params.serverId - Optional server ID to register against.
* @returns Observable emitting the {@link LoginResponse} on success.
*/
register(params: {
username: string;
password: string;
displayName?: string;
serverId?: string;
}): Observable<LoginResponse> {
const url = `${this.endpointFor(params.serverId)}/users/register`;
return this.http.post<LoginResponse>(url, {
username: params.username,
password: params.password,
displayName: params.displayName,
}).pipe(map((resp) => resp));
});
}
login(params: { username: string; password: string; serverId?: string }): Observable<LoginResponse> {
/**
* Log in to an existing user account on the target server.
*
* @param params - Login parameters.
* @param params.username - Login username.
* @param params.password - Account password.
* @param params.serverId - Optional server ID to authenticate against.
* @returns Observable emitting the {@link LoginResponse} on success.
*/
login(params: {
username: string;
password: string;
serverId?: string;
}): Observable<LoginResponse> {
const url = `${this.endpointFor(params.serverId)}/users/login`;
return this.http.post<LoginResponse>(url, {
username: params.username,
password: params.password,
}).pipe(map((resp) => resp));
});
}
}

View File

@@ -1,233 +1,319 @@
import { Injectable } from '@angular/core';
import { Message, User, Room, Reaction, BanEntry } from '../models';
const DB_NAME = 'metoyou';
const DB_VERSION = 2;
/** IndexedDB database name for the MetoYou application. */
const DATABASE_NAME = 'metoyou';
/** IndexedDB schema version — bump when adding/changing object stores. */
const DATABASE_VERSION = 2;
/** Names of every object store used by the application. */
const STORE_MESSAGES = 'messages';
const STORE_USERS = 'users';
const STORE_ROOMS = 'rooms';
const STORE_REACTIONS = 'reactions';
const STORE_BANS = 'bans';
const STORE_META = 'meta';
const STORE_ATTACHMENTS = 'attachments';
/** All object store names, used when clearing the entire database. */
const ALL_STORE_NAMES: string[] = [
STORE_MESSAGES,
STORE_USERS,
STORE_ROOMS,
STORE_REACTIONS,
STORE_BANS,
STORE_ATTACHMENTS,
STORE_META,
];
/**
* IndexedDB-backed database service used when the app runs in a
* plain browser (i.e. without Electron).
*
* Every public method mirrors the DatabaseService API so the
* Every public method mirrors the {@link DatabaseService} API so the
* facade can delegate transparently.
*/
@Injectable({ providedIn: 'root' })
export class BrowserDatabaseService {
private db: IDBDatabase | null = null;
/* ------------------------------------------------------------------ */
/* Lifecycle */
/* ------------------------------------------------------------------ */
/** Handle to the opened IndexedDB database, or `null` before {@link initialize}. */
private database: IDBDatabase | null = null;
/** Open (or create) the IndexedDB database. Safe to call multiple times. */
async initialize(): Promise<void> {
if (this.db) return;
this.db = await this.openDatabase();
if (this.database) return;
this.database = await this.openDatabase();
}
/* ------------------------------------------------------------------ */
/* Messages */
/* ------------------------------------------------------------------ */
/** Persist a single message. */
async saveMessage(message: Message): Promise<void> {
await this.put('messages', message);
await this.put(STORE_MESSAGES, message);
}
/**
* Retrieve messages for a room, sorted oldest-first.
*
* @param roomId - Target room.
* @param limit - Maximum number of messages to return.
* @param offset - Number of messages to skip (for pagination).
*/
async getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> {
const all = await this.getAllFromIndex<Message>('messages', 'roomId', roomId);
return all
.sort((a, b) => a.timestamp - b.timestamp)
const allRoomMessages = await this.getAllFromIndex<Message>(
STORE_MESSAGES, 'roomId', roomId,
);
return allRoomMessages
.sort((first, second) => first.timestamp - second.timestamp)
.slice(offset, offset + limit);
}
/** Delete a message by its ID. */
async deleteMessage(messageId: string): Promise<void> {
await this.delete('messages', messageId);
await this.deleteRecord(STORE_MESSAGES, messageId);
}
/** Apply partial updates to an existing message. */
async updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
const msg = await this.get<Message>('messages', messageId);
if (msg) await this.put('messages', { ...msg, ...updates });
const existing = await this.get<Message>(STORE_MESSAGES, messageId);
if (existing) {
await this.put(STORE_MESSAGES, { ...existing, ...updates });
}
}
/** Retrieve a single message by ID, or `null` if not found. */
async getMessageById(messageId: string): Promise<Message | null> {
return (await this.get<Message>('messages', messageId)) ?? null;
return (await this.get<Message>(STORE_MESSAGES, messageId)) ?? null;
}
/** Remove every message belonging to a room. */
async clearRoomMessages(roomId: string): Promise<void> {
const msgs = await this.getAllFromIndex<Message>('messages', 'roomId', roomId);
const tx = this.transaction('messages', 'readwrite');
for (const m of msgs) tx.objectStore('messages').delete(m.id);
await this.complete(tx);
}
/* ------------------------------------------------------------------ */
/* Reactions */
/* ------------------------------------------------------------------ */
async saveReaction(reaction: Reaction): Promise<void> {
const existing = await this.getAllFromIndex<Reaction>('reactions', 'messageId', reaction.messageId);
const dup = existing.some(
(r) => r.userId === reaction.userId && r.emoji === reaction.emoji,
const messages = await this.getAllFromIndex<Message>(
STORE_MESSAGES, 'roomId', roomId,
);
if (!dup) await this.put('reactions', reaction);
const transaction = this.createTransaction(STORE_MESSAGES, 'readwrite');
for (const message of messages) {
transaction.objectStore(STORE_MESSAGES).delete(message.id);
}
await this.awaitTransaction(transaction);
}
/**
* Persist a reaction, ignoring duplicates (same user + same emoji on
* the same message).
*/
async saveReaction(reaction: Reaction): Promise<void> {
const existing = await this.getAllFromIndex<Reaction>(
STORE_REACTIONS, 'messageId', reaction.messageId,
);
const isDuplicate = existing.some(
(entry) => entry.userId === reaction.userId && entry.emoji === reaction.emoji,
);
if (!isDuplicate) {
await this.put(STORE_REACTIONS, reaction);
}
}
/** Remove a specific reaction (identified by user + emoji + message). */
async removeReaction(messageId: string, userId: string, emoji: string): Promise<void> {
const all = await this.getAllFromIndex<Reaction>('reactions', 'messageId', messageId);
const target = all.find((r) => r.userId === userId && r.emoji === emoji);
if (target) await this.delete('reactions', target.id);
const reactions = await this.getAllFromIndex<Reaction>(
STORE_REACTIONS, 'messageId', messageId,
);
const target = reactions.find(
(entry) => entry.userId === userId && entry.emoji === emoji,
);
if (target) {
await this.deleteRecord(STORE_REACTIONS, target.id);
}
}
/** Return all reactions for a given message. */
async getReactionsForMessage(messageId: string): Promise<Reaction[]> {
return this.getAllFromIndex<Reaction>('reactions', 'messageId', messageId);
return this.getAllFromIndex<Reaction>(STORE_REACTIONS, 'messageId', messageId);
}
/* ------------------------------------------------------------------ */
/* Users */
/* ------------------------------------------------------------------ */
/** Persist a user record. */
async saveUser(user: User): Promise<void> {
await this.put('users', user);
await this.put(STORE_USERS, user);
}
/** Retrieve a user by ID, or `null` if not found. */
async getUser(userId: string): Promise<User | null> {
return (await this.get<User>('users', userId)) ?? null;
return (await this.get<User>(STORE_USERS, userId)) ?? null;
}
/** Retrieve the last-authenticated ("current") user, or `null`. */
async getCurrentUser(): Promise<User | null> {
const meta = await this.get<{ id: string; value: string }>('meta', 'currentUserId');
const meta = await this.get<{ id: string; value: string }>(
STORE_META, 'currentUserId',
);
if (!meta) return null;
return this.getUser(meta.value);
}
/** Store which user ID is considered "current" (logged-in). */
async setCurrentUserId(userId: string): Promise<void> {
await this.put('meta', { id: 'currentUserId', value: userId });
await this.put(STORE_META, { id: 'currentUserId', value: userId });
}
/**
* Retrieve all known users.
* @param _roomId - Accepted for API parity but currently unused.
*/
async getUsersByRoom(_roomId: string): Promise<User[]> {
return this.getAll<User>('users');
return this.getAll<User>(STORE_USERS);
}
/** Apply partial updates to an existing user. */
async updateUser(userId: string, updates: Partial<User>): Promise<void> {
const user = await this.get<User>('users', userId);
if (user) await this.put('users', { ...user, ...updates });
const existing = await this.get<User>(STORE_USERS, userId);
if (existing) {
await this.put(STORE_USERS, { ...existing, ...updates });
}
}
/* ------------------------------------------------------------------ */
/* Rooms */
/* ------------------------------------------------------------------ */
/** Persist a room record. */
async saveRoom(room: Room): Promise<void> {
await this.put('rooms', room);
await this.put(STORE_ROOMS, room);
}
/** Retrieve a room by ID, or `null` if not found. */
async getRoom(roomId: string): Promise<Room | null> {
return (await this.get<Room>('rooms', roomId)) ?? null;
return (await this.get<Room>(STORE_ROOMS, roomId)) ?? null;
}
/** Return every persisted room. */
async getAllRooms(): Promise<Room[]> {
return this.getAll<Room>('rooms');
return this.getAll<Room>(STORE_ROOMS);
}
/** Delete a room and all of its messages. */
async deleteRoom(roomId: string): Promise<void> {
await this.delete('rooms', roomId);
await this.deleteRecord(STORE_ROOMS, roomId);
await this.clearRoomMessages(roomId);
}
/** Apply partial updates to an existing room. */
async updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
const room = await this.get<Room>('rooms', roomId);
if (room) await this.put('rooms', { ...room, ...updates });
const existing = await this.get<Room>(STORE_ROOMS, roomId);
if (existing) {
await this.put(STORE_ROOMS, { ...existing, ...updates });
}
}
/* ------------------------------------------------------------------ */
/* Bans */
/* ------------------------------------------------------------------ */
/** Persist a ban entry. */
async saveBan(ban: BanEntry): Promise<void> {
await this.put('bans', ban);
await this.put(STORE_BANS, ban);
}
/** Remove a ban by the banned user's `oderId`. */
async removeBan(oderId: string): Promise<void> {
const all = await this.getAll<BanEntry>('bans');
const match = all.find((b) => b.oderId === oderId);
if (match) await this.delete('bans', (match as any).id ?? match.oderId);
const allBans = await this.getAll<BanEntry>(STORE_BANS);
const match = allBans.find((ban) => ban.oderId === oderId);
if (match) {
await this.deleteRecord(
STORE_BANS,
(match as any).id ?? match.oderId,
);
}
}
/**
* Return active (non-expired) bans for a room.
*
* @param roomId - Room to query.
*/
async getBansForRoom(roomId: string): Promise<BanEntry[]> {
const all = await this.getAllFromIndex<BanEntry>('bans', 'roomId', roomId);
const allBans = await this.getAllFromIndex<BanEntry>(
STORE_BANS, 'roomId', roomId,
);
const now = Date.now();
return all.filter((b) => !b.expiresAt || b.expiresAt > now);
return allBans.filter(
(ban) => !ban.expiresAt || ban.expiresAt > now,
);
}
/** Check whether a specific user is currently banned from a room. */
async isUserBanned(userId: string, roomId: string): Promise<boolean> {
const bans = await this.getBansForRoom(roomId);
return bans.some((b) => b.oderId === userId);
const activeBans = await this.getBansForRoom(roomId);
return activeBans.some((ban) => ban.oderId === userId);
}
/* ------------------------------------------------------------------ */
/* Attachments */
/* ------------------------------------------------------------------ */
/** Persist an attachment metadata record. */
async saveAttachment(attachment: any): Promise<void> {
await this.put('attachments', attachment);
await this.put(STORE_ATTACHMENTS, attachment);
}
/** Return all attachment records for a message. */
async getAttachmentsForMessage(messageId: string): Promise<any[]> {
return this.getAllFromIndex<any>('attachments', 'messageId', messageId);
return this.getAllFromIndex<any>(STORE_ATTACHMENTS, 'messageId', messageId);
}
/** Return every persisted attachment record. */
async getAllAttachments(): Promise<any[]> {
return this.getAll<any>('attachments');
return this.getAll<any>(STORE_ATTACHMENTS);
}
/** Delete all attachment records for a message. */
async deleteAttachmentsForMessage(messageId: string): Promise<void> {
const atts = await this.getAllFromIndex<any>('attachments', 'messageId', messageId);
if (atts.length === 0) return;
const tx = this.transaction('attachments', 'readwrite');
for (const a of atts) tx.objectStore('attachments').delete(a.id);
await this.complete(tx);
const attachments = await this.getAllFromIndex<any>(
STORE_ATTACHMENTS, 'messageId', messageId,
);
if (attachments.length === 0) return;
const transaction = this.createTransaction(STORE_ATTACHMENTS, 'readwrite');
for (const attachment of attachments) {
transaction.objectStore(STORE_ATTACHMENTS).delete(attachment.id);
}
await this.awaitTransaction(transaction);
}
/** Wipe every object store, removing all persisted data. */
async clearAllData(): Promise<void> {
const storeNames: string[] = ['messages', 'users', 'rooms', 'reactions', 'bans', 'attachments', 'meta'];
const tx = this.transaction(storeNames, 'readwrite');
for (const name of storeNames) tx.objectStore(name).clear();
await this.complete(tx);
const transaction = this.createTransaction(ALL_STORE_NAMES, 'readwrite');
for (const storeName of ALL_STORE_NAMES) {
transaction.objectStore(storeName).clear();
}
await this.awaitTransaction(transaction);
}
/* ================================================================== */
/* Private helpers thin wrappers around IndexedDB */
/* ================================================================== */
// ══════════════════════════════════════════════════════════════════
// Private helpers thin wrappers around IndexedDB
// ══════════════════════════════════════════════════════════════════
/**
* Open (or upgrade) the IndexedDB database and create any missing
* object stores.
*/
private openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('messages')) {
const msgs = db.createObjectStore('messages', { keyPath: 'id' });
msgs.createIndex('roomId', 'roomId', { unique: false });
const database = request.result;
if (!database.objectStoreNames.contains(STORE_MESSAGES)) {
const messagesStore = database.createObjectStore(STORE_MESSAGES, { keyPath: 'id' });
messagesStore.createIndex('roomId', 'roomId', { unique: false });
}
if (!db.objectStoreNames.contains('users')) {
db.createObjectStore('users', { keyPath: 'id' });
if (!database.objectStoreNames.contains(STORE_USERS)) {
database.createObjectStore(STORE_USERS, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains('rooms')) {
db.createObjectStore('rooms', { keyPath: 'id' });
if (!database.objectStoreNames.contains(STORE_ROOMS)) {
database.createObjectStore(STORE_ROOMS, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains('reactions')) {
const rxns = db.createObjectStore('reactions', { keyPath: 'id' });
rxns.createIndex('messageId', 'messageId', { unique: false });
if (!database.objectStoreNames.contains(STORE_REACTIONS)) {
const reactionsStore = database.createObjectStore(STORE_REACTIONS, { keyPath: 'id' });
reactionsStore.createIndex('messageId', 'messageId', { unique: false });
}
if (!db.objectStoreNames.contains('bans')) {
const bans = db.createObjectStore('bans', { keyPath: 'oderId' });
bans.createIndex('roomId', 'roomId', { unique: false });
if (!database.objectStoreNames.contains(STORE_BANS)) {
const bansStore = database.createObjectStore(STORE_BANS, { keyPath: 'oderId' });
bansStore.createIndex('roomId', 'roomId', { unique: false });
}
if (!db.objectStoreNames.contains('meta')) {
db.createObjectStore('meta', { keyPath: 'id' });
if (!database.objectStoreNames.contains(STORE_META)) {
database.createObjectStore(STORE_META, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains('attachments')) {
const atts = db.createObjectStore('attachments', { keyPath: 'id' });
atts.createIndex('messageId', 'messageId', { unique: false });
if (!database.objectStoreNames.contains(STORE_ATTACHMENTS)) {
const attachmentsStore = database.createObjectStore(STORE_ATTACHMENTS, { keyPath: 'id' });
attachmentsStore.createIndex('messageId', 'messageId', { unique: false });
}
};
@@ -236,67 +322,74 @@ export class BrowserDatabaseService {
});
}
private transaction(
/** Create an IndexedDB transaction on one or more stores. */
private createTransaction(
stores: string | string[],
mode: IDBTransactionMode = 'readonly',
): IDBTransaction {
return this.db!.transaction(stores, mode);
return this.database!.transaction(stores, mode);
}
private complete(tx: IDBTransaction): Promise<void> {
/** Wrap a transaction's completion event as a Promise. */
private awaitTransaction(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
private get<T>(store: string, key: IDBValidKey): Promise<T | undefined> {
/** Retrieve a single record by primary key. */
private get<T>(storeName: string, key: IDBValidKey): Promise<T | undefined> {
return new Promise((resolve, reject) => {
const tx = this.transaction(store);
const req = tx.objectStore(store).get(key);
req.onsuccess = () => resolve(req.result as T | undefined);
req.onerror = () => reject(req.error);
const transaction = this.createTransaction(storeName);
const request = transaction.objectStore(storeName).get(key);
request.onsuccess = () => resolve(request.result as T | undefined);
request.onerror = () => reject(request.error);
});
}
private getAll<T>(store: string): Promise<T[]> {
/** Retrieve every record from an object store. */
private getAll<T>(storeName: string): Promise<T[]> {
return new Promise((resolve, reject) => {
const tx = this.transaction(store);
const req = tx.objectStore(store).getAll();
req.onsuccess = () => resolve(req.result as T[]);
req.onerror = () => reject(req.error);
const transaction = this.createTransaction(storeName);
const request = transaction.objectStore(storeName).getAll();
request.onsuccess = () => resolve(request.result as T[]);
request.onerror = () => reject(request.error);
});
}
/** Retrieve all records from an index that match a key. */
private getAllFromIndex<T>(
store: string,
storeName: string,
indexName: string,
key: IDBValidKey,
): Promise<T[]> {
return new Promise((resolve, reject) => {
const tx = this.transaction(store);
const idx = tx.objectStore(store).index(indexName);
const req = idx.getAll(key);
req.onsuccess = () => resolve(req.result as T[]);
req.onerror = () => reject(req.error);
const transaction = this.createTransaction(storeName);
const index = transaction.objectStore(storeName).index(indexName);
const request = index.getAll(key);
request.onsuccess = () => resolve(request.result as T[]);
request.onerror = () => reject(request.error);
});
}
private put(store: string, value: any): Promise<void> {
/** Insert or update a record in the given object store. */
private put(storeName: string, value: any): Promise<void> {
return new Promise((resolve, reject) => {
const tx = this.transaction(store, 'readwrite');
tx.objectStore(store).put(value);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
const transaction = this.createTransaction(storeName, 'readwrite');
transaction.objectStore(storeName).put(value);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
private delete(store: string, key: IDBValidKey): Promise<void> {
/** Delete a record by primary key. */
private deleteRecord(storeName: string, key: IDBValidKey): Promise<void> {
return new Promise((resolve, reject) => {
const tx = this.transaction(store, 'readwrite');
tx.objectStore(store).delete(key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
const transaction = this.createTransaction(storeName, 'readwrite');
transaction.objectStore(storeName).delete(key);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
}

View File

@@ -5,101 +5,119 @@ import { BrowserDatabaseService } from './browser-database.service';
import { ElectronDatabaseService } from './electron-database.service';
/**
* Facade database service.
* Facade database service that transparently delegates to the correct
* storage backend based on the runtime platform.
*
* - **Electron** → delegates to {@link ElectronDatabaseService} which
* persists data in a local SQLite file (via sql.js + Electron IPC).
* - **Browser** → delegates to {@link BrowserDatabaseService} which
* persists data in IndexedDB.
* - **Electron** → SQLite via {@link ElectronDatabaseService} (IPC to main process).
* - **Browser** → IndexedDB via {@link BrowserDatabaseService}.
*
* All consumers keep injecting `DatabaseService` the underlying storage
* engine is selected automatically at startup.
* All consumers inject `DatabaseService` the underlying storage engine
* is selected automatically.
*/
@Injectable({
providedIn: 'root',
})
@Injectable({ providedIn: 'root' })
export class DatabaseService {
private readonly platform = inject(PlatformService);
private readonly browserDb = inject(BrowserDatabaseService);
private readonly electronDb = inject(ElectronDatabaseService);
/** Reactive flag: `true` once {@link initialize} has completed. */
isReady = signal(false);
/** The active backend for the current platform. */
/** The active storage backend for the current platform. */
private get backend() {
return this.platform.isBrowser ? this.browserDb : this.electronDb;
}
/* ------------------------------------------------------------------ */
/* Lifecycle */
/* ------------------------------------------------------------------ */
/** Initialise the platform-specific database. */
async initialize(): Promise<void> {
await this.backend.initialize();
this.isReady.set(true);
}
/* ------------------------------------------------------------------ */
/* Messages */
/* ------------------------------------------------------------------ */
/** Persist a single chat message. */
saveMessage(message: Message) { return this.backend.saveMessage(message); }
/** Retrieve messages for a room with optional pagination. */
getMessages(roomId: string, limit = 100, offset = 0) { return this.backend.getMessages(roomId, limit, offset); }
/** Permanently delete a message by ID. */
deleteMessage(messageId: string) { return this.backend.deleteMessage(messageId); }
/** Apply partial updates to an existing message. */
updateMessage(messageId: string, updates: Partial<Message>) { return this.backend.updateMessage(messageId, updates); }
/** Retrieve a single message by ID. */
getMessageById(messageId: string) { return this.backend.getMessageById(messageId); }
/** Remove every message belonging to a room. */
clearRoomMessages(roomId: string) { return this.backend.clearRoomMessages(roomId); }
/* ------------------------------------------------------------------ */
/* Reactions */
/* ------------------------------------------------------------------ */
/** Persist a reaction. */
saveReaction(reaction: Reaction) { return this.backend.saveReaction(reaction); }
/** Remove a specific reaction (user + emoji + message). */
removeReaction(messageId: string, userId: string, emoji: string) { return this.backend.removeReaction(messageId, userId, emoji); }
/** Return all reactions for a given message. */
getReactionsForMessage(messageId: string) { return this.backend.getReactionsForMessage(messageId); }
/* ------------------------------------------------------------------ */
/* Users */
/* ------------------------------------------------------------------ */
/** Persist a user record. */
saveUser(user: User) { return this.backend.saveUser(user); }
/** Retrieve a user by ID. */
getUser(userId: string) { return this.backend.getUser(userId); }
/** Retrieve the current (logged-in) user. */
getCurrentUser() { return this.backend.getCurrentUser(); }
/** Store the current user ID. */
setCurrentUserId(userId: string) { return this.backend.setCurrentUserId(userId); }
/** Retrieve users in a room. */
getUsersByRoom(roomId: string) { return this.backend.getUsersByRoom(roomId); }
/** Apply partial updates to an existing user. */
updateUser(userId: string, updates: Partial<User>) { return this.backend.updateUser(userId, updates); }
/* ------------------------------------------------------------------ */
/* Rooms */
/* ------------------------------------------------------------------ */
/** Persist a room record. */
saveRoom(room: Room) { return this.backend.saveRoom(room); }
/** Retrieve a room by ID. */
getRoom(roomId: string) { return this.backend.getRoom(roomId); }
/** Return every persisted room. */
getAllRooms() { return this.backend.getAllRooms(); }
/** Delete a room and its associated messages. */
deleteRoom(roomId: string) { return this.backend.deleteRoom(roomId); }
/** Apply partial updates to an existing room. */
updateRoom(roomId: string, updates: Partial<Room>) { return this.backend.updateRoom(roomId, updates); }
/* ------------------------------------------------------------------ */
/* Bans */
/* ------------------------------------------------------------------ */
/** Persist a ban entry. */
saveBan(ban: BanEntry) { return this.backend.saveBan(ban); }
/** Remove a ban by oderId. */
removeBan(oderId: string) { return this.backend.removeBan(oderId); }
/** Return active bans for a room. */
getBansForRoom(roomId: string) { return this.backend.getBansForRoom(roomId); }
/** Check whether a user is currently banned from a room. */
isUserBanned(userId: string, roomId: string) { return this.backend.isUserBanned(userId, roomId); }
/* ------------------------------------------------------------------ */
/* Attachments */
/* ------------------------------------------------------------------ */
/** Persist attachment metadata. */
saveAttachment(attachment: any) { return this.backend.saveAttachment(attachment); }
/** Return all attachment records for a message. */
getAttachmentsForMessage(messageId: string) { return this.backend.getAttachmentsForMessage(messageId); }
/** Return every persisted attachment record. */
getAllAttachments() { return this.backend.getAllAttachments(); }
/** Delete all attachment records for a message. */
deleteAttachmentsForMessage(messageId: string) { return this.backend.deleteAttachmentsForMessage(messageId); }
/* ------------------------------------------------------------------ */
/* Utilities */
/* ------------------------------------------------------------------ */
/** Wipe all persisted data. */
clearAllData() { return this.backend.clearAllData(); }
}

View File

@@ -10,163 +10,168 @@ import { Message, User, Room, Reaction, BanEntry } from '../models';
*/
@Injectable({ providedIn: 'root' })
export class ElectronDatabaseService {
private initialized = false;
/** Whether {@link initialize} has already been called successfully. */
private isInitialised = false;
/** Shorthand for the preload-exposed database API. */
/** Shorthand accessor for the preload-exposed database API. */
private get api() {
return (window as any).electronAPI.db;
}
/* ------------------------------------------------------------------ */
/* Lifecycle */
/* ------------------------------------------------------------------ */
/** Initialise the SQLite database via the main-process IPC bridge. */
async initialize(): Promise<void> {
if (this.initialized) return;
if (this.isInitialised) return;
await this.api.initialize();
this.initialized = true;
this.isInitialised = true;
}
/* ------------------------------------------------------------------ */
/* Messages */
/* ------------------------------------------------------------------ */
/** Persist a single chat message. */
saveMessage(message: Message): Promise<void> {
return this.api.saveMessage(message);
}
/**
* Retrieve messages for a room, sorted oldest-first.
*
* @param roomId - Target room.
* @param limit - Maximum number of messages to return.
* @param offset - Number of messages to skip (for pagination).
*/
getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> {
return this.api.getMessages(roomId, limit, offset);
}
/** Permanently delete a message by ID. */
deleteMessage(messageId: string): Promise<void> {
return this.api.deleteMessage(messageId);
}
/** Apply partial updates to an existing message. */
updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
return this.api.updateMessage(messageId, updates);
}
/** Retrieve a single message by ID, or `null` if not found. */
getMessageById(messageId: string): Promise<Message | null> {
return this.api.getMessageById(messageId);
}
/** Remove every message belonging to a room. */
clearRoomMessages(roomId: string): Promise<void> {
return this.api.clearRoomMessages(roomId);
}
/* ------------------------------------------------------------------ */
/* Reactions */
/* ------------------------------------------------------------------ */
/** Persist a reaction (deduplication is handled server-side). */
saveReaction(reaction: Reaction): Promise<void> {
return this.api.saveReaction(reaction);
}
/** Remove a specific reaction (user + emoji + message). */
removeReaction(messageId: string, userId: string, emoji: string): Promise<void> {
return this.api.removeReaction(messageId, userId, emoji);
}
/** Return all reactions for a given message. */
getReactionsForMessage(messageId: string): Promise<Reaction[]> {
return this.api.getReactionsForMessage(messageId);
}
/* ------------------------------------------------------------------ */
/* Users */
/* ------------------------------------------------------------------ */
/** Persist a user record. */
saveUser(user: User): Promise<void> {
return this.api.saveUser(user);
}
/** Retrieve a user by ID, or `null` if not found. */
getUser(userId: string): Promise<User | null> {
return this.api.getUser(userId);
}
/** Retrieve the last-authenticated ("current") user, or `null`. */
getCurrentUser(): Promise<User | null> {
return this.api.getCurrentUser();
}
/** Store which user ID is considered "current" (logged-in). */
setCurrentUserId(userId: string): Promise<void> {
return this.api.setCurrentUserId(userId);
}
/** Retrieve users associated with a room. */
getUsersByRoom(roomId: string): Promise<User[]> {
return this.api.getUsersByRoom(roomId);
}
/** Apply partial updates to an existing user. */
updateUser(userId: string, updates: Partial<User>): Promise<void> {
return this.api.updateUser(userId, updates);
}
/* ------------------------------------------------------------------ */
/* Rooms */
/* ------------------------------------------------------------------ */
/** Persist a room record. */
saveRoom(room: Room): Promise<void> {
return this.api.saveRoom(room);
}
/** Retrieve a room by ID, or `null` if not found. */
getRoom(roomId: string): Promise<Room | null> {
return this.api.getRoom(roomId);
}
/** Return every persisted room. */
getAllRooms(): Promise<Room[]> {
return this.api.getAllRooms();
}
/** Delete a room by ID. */
deleteRoom(roomId: string): Promise<void> {
return this.api.deleteRoom(roomId);
}
/** Apply partial updates to an existing room. */
updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
return this.api.updateRoom(roomId, updates);
}
/* ------------------------------------------------------------------ */
/* Bans */
/* ------------------------------------------------------------------ */
/** Persist a ban entry. */
saveBan(ban: BanEntry): Promise<void> {
return this.api.saveBan(ban);
}
/** Remove a ban by the banned user's `oderId`. */
removeBan(oderId: string): Promise<void> {
return this.api.removeBan(oderId);
}
/** Return active bans for a room. */
getBansForRoom(roomId: string): Promise<BanEntry[]> {
return this.api.getBansForRoom(roomId);
}
/** Check whether a user is currently banned from a room. */
isUserBanned(userId: string, roomId: string): Promise<boolean> {
return this.api.isUserBanned(userId, roomId);
}
/* ------------------------------------------------------------------ */
/* Attachments */
/* ------------------------------------------------------------------ */
/** Persist attachment metadata. */
saveAttachment(attachment: any): Promise<void> {
return this.api.saveAttachment(attachment);
}
/** Return all attachment records for a message. */
getAttachmentsForMessage(messageId: string): Promise<any[]> {
return this.api.getAttachmentsForMessage(messageId);
}
/** Return every persisted attachment record. */
getAllAttachments(): Promise<any[]> {
return this.api.getAllAttachments();
}
/** Delete all attachment records for a message. */
deleteAttachmentsForMessage(messageId: string): Promise<void> {
return this.api.deleteAttachmentsForMessage(messageId);
}
/* ------------------------------------------------------------------ */
/* Utilities */
/* ------------------------------------------------------------------ */
/** Wipe every table, removing all persisted data. */
clearAllData(): Promise<void> {
return this.api.clearAllData();
}

View File

@@ -0,0 +1,51 @@
import { Injectable, inject } from '@angular/core';
import { PlatformService } from './platform.service';
/**
* Opens URLs in the system default browser (Electron) or a new tab (browser).
*
* Usage:
* inject(ExternalLinkService).open('https://example.com');
*/
@Injectable({ providedIn: 'root' })
export class ExternalLinkService {
private platform = inject(PlatformService);
/** Open a URL externally. Only http/https URLs are allowed. */
open(url: string): void {
if (!url || !(url.startsWith('http://') || url.startsWith('https://'))) return;
if (this.platform.isElectron) {
(window as any).electronAPI?.openExternal(url);
} else {
window.open(url, '_blank', 'noopener,noreferrer');
}
}
/**
* Click handler for anchor elements. Call from a (click) binding or HostListener.
* Returns true if the click was handled (link opened externally), false otherwise.
*/
handleClick(evt: MouseEvent): boolean {
const target = (evt.target as HTMLElement)?.closest('a') as HTMLAnchorElement | null;
if (!target) return false;
const href = target.href; // resolved full URL
if (!href) return false;
// Skip non-navigable URLs
if (href.startsWith('javascript:') || href.startsWith('blob:') || href.startsWith('data:')) return false;
// Skip same-page anchors
const rawAttr = target.getAttribute('href');
if (rawAttr?.startsWith('#')) return false;
// Skip Angular router links
if (target.hasAttribute('routerlink') || target.hasAttribute('ng-reflect-router-link')) return false;
evt.preventDefault();
evt.stopPropagation();
this.open(href);
return true;
}
}

View File

@@ -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';

View File

@@ -5,423 +5,530 @@ import { catchError, map } from 'rxjs/operators';
import { ServerInfo, JoinRequest, User } from '../models';
import { v4 as uuidv4 } from 'uuid';
/**
* A configured server endpoint that the user can connect to.
*/
export interface ServerEndpoint {
/** Unique endpoint identifier. */
id: string;
/** Human-readable label shown in the UI. */
name: string;
/** Base URL (e.g. `http://localhost:3001`). */
url: string;
/** Whether this is the currently selected endpoint. */
isActive: boolean;
/** Whether this is the built-in default endpoint. */
isDefault: boolean;
/** Most recent health-check result. */
status: 'online' | 'offline' | 'checking' | 'unknown';
/** Last measured round-trip latency (ms). */
latency?: number;
}
const STORAGE_KEY = 'metoyou_server_endpoints';
/** localStorage key that persists the user's configured endpoints. */
const ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
/** Derive default server URL from current page protocol (handles SSL toggle). */
function getDefaultServerUrl(): string {
/** Timeout (ms) for server health-check and alternative-endpoint pings. */
const HEALTH_CHECK_TIMEOUT_MS = 5000;
/**
* Derive the default server URL from the current page protocol so that
* SSL/TLS is matched automatically.
*/
function buildDefaultServerUrl(): string {
if (typeof window !== 'undefined' && window.location) {
const proto = window.location.protocol === 'https:' ? 'https' : 'http';
return `${proto}://localhost:3001`;
const protocol = window.location.protocol === 'https:' ? 'https' : 'http';
return `${protocol}://localhost:3001`;
}
return 'http://localhost:3001';
}
const DEFAULT_SERVER: Omit<ServerEndpoint, 'id'> = {
/** Blueprint for the built-in default endpoint. */
const DEFAULT_ENDPOINT: Omit<ServerEndpoint, 'id'> = {
name: 'Local Server',
url: getDefaultServerUrl(),
url: buildDefaultServerUrl(),
isActive: true,
isDefault: true,
status: 'unknown',
};
@Injectable({
providedIn: 'root',
})
/**
* Manages the user's list of configured server endpoints and
* provides an HTTP client for server-directory API calls
* (search, register, join/leave, heartbeat, etc.).
*
* Endpoints are persisted in `localStorage` and exposed as
* Angular signals for reactive consumption.
*/
@Injectable({ providedIn: 'root' })
export class ServerDirectoryService {
private readonly _servers = signal<ServerEndpoint[]>([]);
private _searchAllServers = false;
/** Whether search queries should be fanned out to all non-offline endpoints. */
private shouldSearchAllServers = false;
/** Reactive list of all configured endpoints. */
readonly servers = computed(() => this._servers());
readonly activeServer = computed(() => this._servers().find((s) => s.isActive) || this._servers()[0]);
constructor(private http: HttpClient) {
this.loadServers();
/** The currently active endpoint, falling back to the first in the list. */
readonly activeServer = computed(
() => this._servers().find((endpoint) => endpoint.isActive) ?? this._servers()[0],
);
constructor(private readonly http: HttpClient) {
this.loadEndpoints();
}
private loadServers(): void {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
let servers = JSON.parse(stored) as ServerEndpoint[];
// Ensure at least one is active
if (!servers.some((s) => s.isActive) && servers.length > 0) {
servers[0].isActive = true;
}
// Migrate default localhost entries to match current protocol
const expectedProto = (typeof window !== 'undefined' && window.location?.protocol === 'https:') ? 'https' : 'http';
servers = servers.map((s) => {
if (s.isDefault && /^https?:\/\/localhost:\d+$/.test(s.url)) {
return { ...s, url: s.url.replace(/^https?/, expectedProto) };
}
return s;
});
this._servers.set(servers);
this.saveServers();
} catch {
this.initializeDefaultServer();
}
} else {
this.initializeDefaultServer();
}
}
private initializeDefaultServer(): void {
const defaultServer: ServerEndpoint = {
...DEFAULT_SERVER,
/**
* Add a new server endpoint (inactive by default).
*
* @param server - Name and URL of the endpoint to add.
*/
addServer(server: { name: string; url: string }): void {
const sanitisedUrl = this.sanitiseUrl(server.url);
const newEndpoint: ServerEndpoint = {
id: uuidv4(),
name: server.name,
url: sanitisedUrl,
isActive: false,
isDefault: false,
status: 'unknown',
};
this._servers.set([defaultServer]);
this.saveServers();
this._servers.update((endpoints) => [...endpoints, newEndpoint]);
this.saveEndpoints();
}
private saveServers(): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this._servers()));
/**
* Remove an endpoint by ID.
* The built-in default endpoint cannot be removed. If the removed
* endpoint was active, the first remaining endpoint is activated.
*/
removeServer(endpointId: string): void {
const endpoints = this._servers();
const target = endpoints.find((endpoint) => endpoint.id === endpointId);
if (target?.isDefault) return;
const wasActive = target?.isActive;
this._servers.update((list) => list.filter((endpoint) => endpoint.id !== endpointId));
if (wasActive) {
this._servers.update((list) => {
if (list.length > 0) list[0].isActive = true;
return [...list];
});
}
this.saveEndpoints();
}
private get baseUrl(): string {
/** Activate a specific endpoint and deactivate all others. */
setActiveServer(endpointId: string): void {
this._servers.update((endpoints) =>
endpoints.map((endpoint) => ({
...endpoint,
isActive: endpoint.id === endpointId,
})),
);
this.saveEndpoints();
}
/** Update the health status and optional latency of an endpoint. */
updateServerStatus(
endpointId: string,
status: ServerEndpoint['status'],
latency?: number,
): void {
this._servers.update((endpoints) =>
endpoints.map((endpoint) =>
endpoint.id === endpointId ? { ...endpoint, status, latency } : endpoint,
),
);
this.saveEndpoints();
}
/** Enable or disable fan-out search across all endpoints. */
setSearchAllServers(enabled: boolean): void {
this.shouldSearchAllServers = enabled;
}
/**
* Probe a single endpoint's health and update its status.
*
* @param endpointId - ID of the endpoint to test.
* @returns `true` if the server responded successfully.
*/
async testServer(endpointId: string): Promise<boolean> {
const endpoint = this._servers().find((entry) => entry.id === endpointId);
if (!endpoint) return false;
this.updateServerStatus(endpointId, 'checking');
const startTime = Date.now();
try {
const response = await fetch(`${endpoint.url}/api/health`, {
method: 'GET',
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
});
const latency = Date.now() - startTime;
if (response.ok) {
this.updateServerStatus(endpointId, 'online', latency);
return true;
}
this.updateServerStatus(endpointId, 'offline');
return false;
} catch {
// Fall back to the /servers endpoint
try {
const response = await fetch(`${endpoint.url}/api/servers`, {
method: 'GET',
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
});
const latency = Date.now() - startTime;
if (response.ok) {
this.updateServerStatus(endpointId, 'online', latency);
return true;
}
} catch { /* both checks failed */ }
this.updateServerStatus(endpointId, 'offline');
return false;
}
}
/** Probe all configured endpoints in parallel. */
async testAllServers(): Promise<void> {
const endpoints = this._servers();
await Promise.all(endpoints.map((endpoint) => this.testServer(endpoint.id)));
}
/** Expose the API base URL for external consumers. */
getApiBaseUrl(): string {
return this.buildApiBaseUrl();
}
/** Get the WebSocket URL derived from the active endpoint. */
getWebSocketUrl(): string {
const active = this.activeServer();
const raw = active ? active.url : getDefaultServerUrl();
// Strip trailing slashes and any accidental '/api'
let base = raw.replace(/\/+$/,'');
if (!active) {
const protocol = (typeof window !== 'undefined' && window.location?.protocol === 'https:') ? 'wss' : 'ws';
return `${protocol}://localhost:3001`;
}
return active.url.replace(/^http/, 'ws');
}
/**
* Search for public servers matching a query string.
* When {@link shouldSearchAllServers} is `true`, the search is
* fanned out to every non-offline endpoint.
*/
searchServers(query: string): Observable<ServerInfo[]> {
if (this.shouldSearchAllServers) {
return this.searchAllEndpoints(query);
}
return this.searchSingleEndpoint(query, this.buildApiBaseUrl());
}
/** Retrieve the full list of public servers. */
getServers(): Observable<ServerInfo[]> {
if (this.shouldSearchAllServers) {
return this.getAllServersFromAllEndpoints();
}
return this.http
.get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`)
.pipe(
map((response) => this.unwrapServersResponse(response)),
catchError((error) => {
console.error('Failed to get servers:', error);
return of([]);
}),
);
}
/** Fetch details for a single server. */
getServer(serverId: string): Observable<ServerInfo | null> {
return this.http
.get<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`)
.pipe(
catchError((error) => {
console.error('Failed to get server:', error);
return of(null);
}),
);
}
/** Register a new server listing in the directory. */
registerServer(
server: Omit<ServerInfo, 'createdAt'> & { id?: string },
): Observable<ServerInfo> {
return this.http
.post<ServerInfo>(`${this.buildApiBaseUrl()}/servers`, server)
.pipe(
catchError((error) => {
console.error('Failed to register server:', error);
return throwError(() => error);
}),
);
}
/** Update an existing server listing. */
updateServer(
serverId: string,
updates: Partial<ServerInfo>,
): Observable<ServerInfo> {
return this.http
.patch<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`, updates)
.pipe(
catchError((error) => {
console.error('Failed to update server:', error);
return throwError(() => error);
}),
);
}
/** Remove a server listing from the directory. */
unregisterServer(serverId: string): Observable<void> {
return this.http
.delete<void>(`${this.buildApiBaseUrl()}/servers/${serverId}`)
.pipe(
catchError((error) => {
console.error('Failed to unregister server:', error);
return throwError(() => error);
}),
);
}
/** Retrieve users currently connected to a server. */
getServerUsers(serverId: string): Observable<User[]> {
return this.http
.get<User[]>(`${this.buildApiBaseUrl()}/servers/${serverId}/users`)
.pipe(
catchError((error) => {
console.error('Failed to get server users:', error);
return of([]);
}),
);
}
/** Send a join request for a server and receive the signaling URL. */
requestJoin(
request: JoinRequest,
): Observable<{ success: boolean; signalingUrl?: string }> {
return this.http
.post<{ success: boolean; signalingUrl?: string }>(
`${this.buildApiBaseUrl()}/servers/${request.roomId}/join`,
request,
)
.pipe(
catchError((error) => {
console.error('Failed to send join request:', error);
return throwError(() => error);
}),
);
}
/** Notify the directory that a user has left a server. */
notifyLeave(serverId: string, userId: string): Observable<void> {
return this.http
.post<void>(`${this.buildApiBaseUrl()}/servers/${serverId}/leave`, { userId })
.pipe(
catchError((error) => {
console.error('Failed to notify leave:', error);
return of(undefined);
}),
);
}
/** Update the live user count for a server listing. */
updateUserCount(serverId: string, count: number): Observable<void> {
return this.http
.patch<void>(`${this.buildApiBaseUrl()}/servers/${serverId}/user-count`, { count })
.pipe(
catchError((error) => {
console.error('Failed to update user count:', error);
return of(undefined);
}),
);
}
/** Send a heartbeat to keep the server listing active. */
sendHeartbeat(serverId: string): Observable<void> {
return this.http
.post<void>(`${this.buildApiBaseUrl()}/servers/${serverId}/heartbeat`, {})
.pipe(
catchError((error) => {
console.error('Failed to send heartbeat:', error);
return of(undefined);
}),
);
}
/**
* Build the active endpoint's API base URL, stripping trailing
* slashes and accidental `/api` suffixes.
*/
private buildApiBaseUrl(): string {
const active = this.activeServer();
const rawUrl = active ? active.url : buildDefaultServerUrl();
let base = rawUrl.replace(/\/+$/, '');
if (base.toLowerCase().endsWith('/api')) {
base = base.slice(0, -4);
}
return `${base}/api`;
}
// Expose API base URL for consumers that need to call server endpoints
getApiBaseUrl(): string {
return this.baseUrl;
/** Strip trailing slashes and `/api` suffix from a URL. */
private sanitiseUrl(rawUrl: string): string {
let cleaned = rawUrl.trim().replace(/\/+$/, '');
if (cleaned.toLowerCase().endsWith('/api')) {
cleaned = cleaned.slice(0, -4);
}
return cleaned;
}
// Server management methods
addServer(server: { name: string; url: string }): void {
const newServer: ServerEndpoint = {
id: uuidv4(),
name: server.name,
// Sanitize: remove trailing slashes and any '/api'
url: (() => {
let u = server.url.trim();
u = u.replace(/\/+$/,'');
if (u.toLowerCase().endsWith('/api')) u = u.slice(0, -4);
return u;
})(),
isActive: false,
isDefault: false,
status: 'unknown',
};
this._servers.update((servers) => [...servers, newServer]);
this.saveServers();
/**
* Handle both `{ servers: [...] }` and direct `ServerInfo[]`
* response shapes from the directory API.
*/
private unwrapServersResponse(
response: { servers: ServerInfo[]; total: number } | ServerInfo[],
): ServerInfo[] {
if (Array.isArray(response)) return response;
return response.servers ?? [];
}
removeServer(id: string): void {
const servers = this._servers();
const server = servers.find((s) => s.id === id);
if (server?.isDefault) return; // Can't remove default server
const wasActive = server?.isActive;
this._servers.update((servers) => servers.filter((s) => s.id !== id));
// If removed server was active, activate the first server
if (wasActive) {
this._servers.update((servers) => {
if (servers.length > 0) {
servers[0].isActive = true;
}
return [...servers];
});
}
this.saveServers();
}
setActiveServer(id: string): void {
this._servers.update((servers) =>
servers.map((s) => ({
...s,
isActive: s.id === id,
}))
);
this.saveServers();
}
updateServerStatus(id: string, status: ServerEndpoint['status'], latency?: number): void {
this._servers.update((servers) =>
servers.map((s) => (s.id === id ? { ...s, status, latency } : s))
);
this.saveServers();
}
setSearchAllServers(value: boolean): void {
this._searchAllServers = value;
}
async testServer(id: string): Promise<boolean> {
const server = this._servers().find((s) => s.id === id);
if (!server) return false;
this.updateServerStatus(id, 'checking');
const startTime = Date.now();
try {
const response = await fetch(`${server.url}/api/health`, {
method: 'GET',
signal: AbortSignal.timeout(5000),
});
const latency = Date.now() - startTime;
if (response.ok) {
this.updateServerStatus(id, 'online', latency);
return true;
} else {
this.updateServerStatus(id, 'offline');
return false;
}
} catch {
// Try alternative endpoint
try {
const response = await fetch(`${server.url}/api/servers`, {
method: 'GET',
signal: AbortSignal.timeout(5000),
});
const latency = Date.now() - startTime;
if (response.ok) {
this.updateServerStatus(id, 'online', latency);
return true;
}
} catch {
// Server is offline
}
this.updateServerStatus(id, 'offline');
return false;
}
}
async testAllServers(): Promise<void> {
const servers = this._servers();
await Promise.all(servers.map((s) => this.testServer(s.id)));
}
// Search for servers - optionally across all configured endpoints
searchServers(query: string): Observable<ServerInfo[]> {
if (this._searchAllServers) {
return this.searchAllServerEndpoints(query);
}
return this.searchSingleServer(query, this.baseUrl);
}
private searchSingleServer(query: string, baseUrl: string): Observable<ServerInfo[]> {
/** Search a single endpoint for servers matching a query. */
private searchSingleEndpoint(
query: string,
apiBaseUrl: string,
): Observable<ServerInfo[]> {
const params = new HttpParams().set('q', query);
return this.http.get<{ servers: ServerInfo[]; total: number }>(`${baseUrl}/servers`, { params }).pipe(
map((response) => {
// Handle both wrapped response { servers: [...] } and direct array
if (Array.isArray(response)) {
return response;
}
return response.servers || [];
}),
return this.http
.get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params })
.pipe(
map((response) => this.unwrapServersResponse(response)),
catchError((error) => {
console.error('Failed to search servers:', error);
return of([]);
})
}),
);
}
private searchAllServerEndpoints(query: string): Observable<ServerInfo[]> {
const servers = this._servers().filter((s) => s.status !== 'offline');
/** Fan-out search across all non-offline endpoints, deduplicating results. */
private searchAllEndpoints(query: string): Observable<ServerInfo[]> {
const onlineEndpoints = this._servers().filter(
(endpoint) => endpoint.status !== 'offline',
);
if (servers.length === 0) {
return this.searchSingleServer(query, this.baseUrl);
if (onlineEndpoints.length === 0) {
return this.searchSingleEndpoint(query, this.buildApiBaseUrl());
}
const requests = servers.map((server) =>
this.searchSingleServer(query, `${server.url}/api`).pipe(
const requests = onlineEndpoints.map((endpoint) =>
this.searchSingleEndpoint(query, `${endpoint.url}/api`).pipe(
map((results) =>
results.map((r) => ({
...r,
sourceId: server.id,
sourceName: server.name,
}))
)
)
results.map((server) => ({
...server,
sourceId: endpoint.id,
sourceName: endpoint.name,
})),
),
),
);
return forkJoin(requests).pipe(
map((results) => results.flat()),
// Remove duplicates based on server ID
map((servers) => {
const seen = new Set<string>();
return servers.filter((s) => {
if (seen.has(s.id)) return false;
seen.add(s.id);
return true;
});
})
);
}
// Get all available servers
getServers(): Observable<ServerInfo[]> {
if (this._searchAllServers) {
return this.getAllServersFromAllEndpoints();
}
return this.http.get<{ servers: ServerInfo[]; total: number }>(`${this.baseUrl}/servers`).pipe(
map((response) => {
if (Array.isArray(response)) {
return response;
}
return response.servers || [];
}),
catchError((error) => {
console.error('Failed to get servers:', error);
return of([]);
})
map((resultArrays) => resultArrays.flat()),
map((servers) => this.deduplicateById(servers)),
);
}
/** Retrieve all servers from all non-offline endpoints. */
private getAllServersFromAllEndpoints(): Observable<ServerInfo[]> {
const servers = this._servers().filter((s) => s.status !== 'offline');
const onlineEndpoints = this._servers().filter(
(endpoint) => endpoint.status !== 'offline',
);
if (servers.length === 0) {
return this.http.get<{ servers: ServerInfo[]; total: number }>(`${this.baseUrl}/servers`).pipe(
map((response) => (Array.isArray(response) ? response : response.servers || [])),
catchError(() => of([]))
if (onlineEndpoints.length === 0) {
return this.http
.get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`)
.pipe(
map((response) => this.unwrapServersResponse(response)),
catchError(() => of([])),
);
}
const requests = servers.map((server) =>
this.http.get<{ servers: ServerInfo[]; total: number }>(`${server.url}/api/servers`).pipe(
const requests = onlineEndpoints.map((endpoint) =>
this.http
.get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`)
.pipe(
map((response) => {
const results = Array.isArray(response) ? response : response.servers || [];
return results.map((r) => ({
...r,
sourceId: server.id,
sourceName: server.name,
const results = this.unwrapServersResponse(response);
return results.map((server) => ({
...server,
sourceId: endpoint.id,
sourceName: endpoint.name,
}));
}),
catchError(() => of([]))
)
catchError(() => of([] as ServerInfo[])),
),
);
return forkJoin(requests).pipe(map((results) => results.flat()));
return forkJoin(requests).pipe(map((resultArrays) => resultArrays.flat()));
}
// Get server details
getServer(serverId: string): Observable<ServerInfo | null> {
return this.http.get<ServerInfo>(`${this.baseUrl}/servers/${serverId}`).pipe(
catchError((error) => {
console.error('Failed to get server:', error);
return of(null);
})
);
/** Remove duplicate servers (by `id`), keeping the first occurrence. */
private deduplicateById<T extends { id: string }>(items: T[]): T[] {
const seen = new Set<string>();
return items.filter((item) => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
}
// Register a new server (with optional pre-generated ID)
registerServer(server: Omit<ServerInfo, 'createdAt'> & { id?: string }): Observable<ServerInfo> {
return this.http.post<ServerInfo>(`${this.baseUrl}/servers`, server).pipe(
catchError((error) => {
console.error('Failed to register server:', error);
return throwError(() => error);
})
);
/** Load endpoints from localStorage, migrating protocol if needed. */
private loadEndpoints(): void {
const stored = localStorage.getItem(ENDPOINTS_STORAGE_KEY);
if (!stored) {
this.initialiseDefaultEndpoint();
return;
}
// Update server info
updateServer(serverId: string, updates: Partial<ServerInfo>): Observable<ServerInfo> {
return this.http.patch<ServerInfo>(`${this.baseUrl}/servers/${serverId}`, updates).pipe(
catchError((error) => {
console.error('Failed to update server:', error);
return throwError(() => error);
})
);
try {
let endpoints = JSON.parse(stored) as ServerEndpoint[];
// Ensure at least one endpoint is active
if (endpoints.length > 0 && !endpoints.some((ep) => ep.isActive)) {
endpoints[0].isActive = true;
}
// Remove server from directory
unregisterServer(serverId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/servers/${serverId}`).pipe(
catchError((error) => {
console.error('Failed to unregister server:', error);
return throwError(() => error);
})
);
// Migrate localhost entries to match the current page protocol
const expectedProtocol =
typeof window !== 'undefined' && window.location?.protocol === 'https:'
? 'https'
: 'http';
endpoints = endpoints.map((endpoint) => {
if (endpoint.isDefault && /^https?:\/\/localhost:\d+$/.test(endpoint.url)) {
return { ...endpoint, url: endpoint.url.replace(/^https?/, expectedProtocol) };
}
return endpoint;
});
this._servers.set(endpoints);
this.saveEndpoints();
} catch {
this.initialiseDefaultEndpoint();
}
}
// Get users in a server
getServerUsers(serverId: string): Observable<User[]> {
return this.http.get<User[]>(`${this.baseUrl}/servers/${serverId}/users`).pipe(
catchError((error) => {
console.error('Failed to get server users:', error);
return of([]);
})
);
/** Create and persist the built-in default endpoint. */
private initialiseDefaultEndpoint(): void {
const defaultEndpoint: ServerEndpoint = { ...DEFAULT_ENDPOINT, id: uuidv4() };
this._servers.set([defaultEndpoint]);
this.saveEndpoints();
}
// Send join request
requestJoin(request: JoinRequest): Observable<{ success: boolean; signalingUrl?: string }> {
return this.http
.post<{ success: boolean; signalingUrl?: string }>(
`${this.baseUrl}/servers/${request.roomId}/join`,
request
)
.pipe(
catchError((error) => {
console.error('Failed to send join request:', error);
return throwError(() => error);
})
);
}
// Notify server of user leaving
notifyLeave(serverId: string, userId: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/servers/${serverId}/leave`, { userId }).pipe(
catchError((error) => {
console.error('Failed to notify leave:', error);
return of(undefined);
})
);
}
// Update user count for a server
updateUserCount(serverId: string, count: number): Observable<void> {
return this.http.patch<void>(`${this.baseUrl}/servers/${serverId}/user-count`, { count }).pipe(
catchError((error) => {
console.error('Failed to update user count:', error);
return of(undefined);
})
);
}
// Heartbeat to keep server active in directory
sendHeartbeat(serverId: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/servers/${serverId}/heartbeat`, {}).pipe(
catchError((error) => {
console.error('Failed to send heartbeat:', error);
return of(undefined);
})
);
}
// Get the WebSocket URL for the active server
getWebSocketUrl(): string {
const active = this.activeServer();
if (!active) {
const proto = (typeof window !== 'undefined' && window.location?.protocol === 'https:') ? 'wss' : 'ws';
return `${proto}://localhost:3001`;
}
// Convert http(s) to ws(s)
return active.url.replace(/^http/, 'ws');
/** Persist the current endpoint list to localStorage. */
private saveEndpoints(): void {
localStorage.setItem(ENDPOINTS_STORAGE_KEY, JSON.stringify(this._servers()));
}
}

View File

@@ -1,45 +1,94 @@
import { Injectable, signal, computed } from '@angular/core';
/** Default timeout (ms) for the NTP-style HTTP sync request. */
const DEFAULT_SYNC_TIMEOUT_MS = 5000;
/**
* Maintains a clock-offset between the local system time and the
* remote signaling server.
*
* The offset is estimated using a simple NTP-style round-trip
* measurement and is stored as a reactive Angular signal so that
* any dependent computed value auto-updates when a new sync occurs.
*/
@Injectable({ providedIn: 'root' })
export class TimeSyncService {
// serverTime - clientTime offset (milliseconds)
/**
* Internal offset signal:
* `serverTime = Date.now() + offset`.
*/
private readonly _offset = signal<number>(0);
private _lastSyncAt = 0;
/** Epoch timestamp of the most recent successful sync. */
private lastSyncTimestamp = 0;
/** Reactive read-only offset (milliseconds). */
readonly offset = computed(() => this._offset());
// Returns a server-adjusted now() using the current offset
/**
* Return a server-adjusted "now" timestamp.
*
* @returns Epoch milliseconds aligned to the server clock.
*/
now(): number {
return Date.now() + this._offset();
}
// Set offset based on a serverTime observed at approximately receiveAt
setFromServerTime(serverTime: number, receiveAt?: number): void {
const observedAt = receiveAt ?? Date.now();
const offset = serverTime - observedAt;
this._offset.set(offset);
this._lastSyncAt = Date.now();
/**
* Set the offset from a known server timestamp.
*
* @param serverTime - Epoch timestamp reported by the server.
* @param receiveTimestamp - Local epoch timestamp when the server time was
* observed. Defaults to `Date.now()` if omitted.
*/
setFromServerTime(serverTime: number, receiveTimestamp?: number): void {
const observedAt = receiveTimestamp ?? Date.now();
this._offset.set(serverTime - observedAt);
this.lastSyncTimestamp = Date.now();
}
// Perform an HTTP-based sync using a simple NTP-style roundtrip
async syncWithEndpoint(baseApiUrl: string, timeoutMs = 5000): Promise<void> {
/**
* Perform an HTTP-based clock synchronisation using a simple
* NTP-style round-trip.
*
* 1. Record `clientSendTime` (`t0`).
* 2. Fetch `GET {baseApiUrl}/time`.
* 3. Record `clientReceiveTime` (`t2`).
* 4. Estimate one-way latency as `(t2 t0) / 2`.
* 5. Compute offset: `serverNow midpoint(t0, t2)`.
*
* Any network or parsing error is silently ignored so that the
* last known offset (or zero) is retained.
*
* @param baseApiUrl - API base URL (e.g. `http://host:3001/api`).
* @param timeoutMs - Maximum time to wait for the response.
*/
async syncWithEndpoint(
baseApiUrl: string,
timeoutMs: number = DEFAULT_SYNC_TIMEOUT_MS,
): Promise<void> {
try {
const controller = new AbortController();
const t0 = Date.now();
const clientSendTime = Date.now();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const resp = await fetch(`${baseApiUrl}/time`, { signal: controller.signal });
const t2 = Date.now();
const response = await fetch(`${baseApiUrl}/time`, {
signal: controller.signal,
});
const clientReceiveTime = Date.now();
clearTimeout(timer);
if (!resp.ok) return;
const data = await resp.json();
// Estimate one-way latency and offset
if (!response.ok) return;
const data = await response.json();
const serverNow = Number(data?.now) || Date.now();
const midpoint = (t0 + t2) / 2;
const offset = serverNow - midpoint;
this._offset.set(offset);
this._lastSyncAt = Date.now();
const midpoint = (clientSendTime + clientReceiveTime) / 2;
this._offset.set(serverNow - midpoint);
this.lastSyncTimestamp = Date.now();
} catch {
// ignore sync failures; retain last offset
// Sync failure is non-fatal; retain the previous offset.
}
}
}

View File

@@ -1,59 +1,78 @@
import { Injectable, signal, computed, inject } from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import * as RoomsActions from '../../store/rooms/rooms.actions';
import { RoomsActions } from '../../store/rooms/rooms.actions';
/**
* Snapshot of an active voice session, retained so that floating
* voice controls can display the connection details when the user
* navigates away from the server view.
*/
export interface VoiceSessionInfo {
/** Unique server identifier. */
serverId: string;
/** Display name of the server. */
serverName: string;
/** Room/channel ID within the server. */
roomId: string;
/** Display name of the room/channel. */
roomName: string;
/** Optional server icon (data-URL or remote URL). */
serverIcon?: string;
/** Optional server description. */
serverDescription?: string;
/** The route path to navigate back to the server */
/** Angular route path to navigate back to the server. */
serverRoute: string;
}
/**
* Service to track the current voice session across navigation.
* When a user is connected to voice in a server and navigates away,
* this service maintains the session info for the floating controls.
* Tracks the user's current voice session across client-side
* navigation so that floating voice controls remain visible when
* the user is browsing a different server or view.
*
* This service is purely a UI-state tracker — actual WebRTC
* voice management lives in {@link WebRTCService} and its managers.
*/
@Injectable({
providedIn: 'root',
})
@Injectable({ providedIn: 'root' })
export class VoiceSessionService {
private router = inject(Router);
private store = inject(Store);
private readonly router = inject(Router);
private readonly store = inject(Store);
// The voice session info when connected
/** Current voice session metadata, or `null` when disconnected. */
private readonly _voiceSession = signal<VoiceSessionInfo | null>(null);
// Whether the user is currently viewing the voice-connected server
private readonly _isViewingVoiceServer = signal(true);
/** Whether the user is currently viewing the voice-connected server. */
private readonly _isViewingVoiceServer = signal<boolean>(true);
// Public computed signals
/** Reactive read-only voice session. */
readonly voiceSession = computed(() => this._voiceSession());
/** Reactive flag: is the user's current view the voice server? */
readonly isViewingVoiceServer = computed(() => this._isViewingVoiceServer());
/**
* Whether to show floating voice controls:
* True when connected to voice AND not viewing the voice-connected server
* Whether the floating voice-controls overlay should be visible.
* `true` when a voice session is active AND the user is viewing
* a different server.
*/
readonly showFloatingControls = computed(() => {
return this._voiceSession() !== null && !this._isViewingVoiceServer();
});
readonly showFloatingControls = computed(
() => this._voiceSession() !== null && !this._isViewingVoiceServer(),
);
/**
* Start a voice session - called when user joins voice in a server
* Begin tracking a voice session.
* Called when the user joins a voice channel.
*
* @param sessionInfo - Metadata describing the voice-connected server/channel.
*/
startSession(info: VoiceSessionInfo): void {
this._voiceSession.set(info);
startSession(sessionInfo: VoiceSessionInfo): void {
this._voiceSession.set(sessionInfo);
this._isViewingVoiceServer.set(true);
}
/**
* End the voice session - called when user disconnects from voice
* Stop tracking the voice session.
* Called when the user disconnects from voice.
*/
endSession(): void {
this._voiceSession.set(null);
@@ -61,14 +80,20 @@ export class VoiceSessionService {
}
/**
* Update whether user is viewing the voice-connected server
* Manually flag whether the user is currently viewing the
* voice-connected server.
*
* @param isViewing - `true` if the user's current view is the voice server.
*/
setViewingVoiceServer(isViewing: boolean): void {
this._isViewingVoiceServer.set(isViewing);
}
/**
* Check if the current route matches the voice session server
* Compare the given server ID to the voice session's server and
* update the {@link isViewingVoiceServer} flag accordingly.
*
* @param currentServerId - ID of the server the user is currently viewing.
*/
checkCurrentRoute(currentServerId: string | null): void {
const session = this._voiceSession();
@@ -80,13 +105,15 @@ export class VoiceSessionService {
}
/**
* Navigate back to the voice-connected server
* Navigate the user back to the voice-connected server by
* dispatching a `viewServer` action.
*/
navigateToVoiceServer(): void {
const session = this._voiceSession();
if (session) {
// Use viewServer to switch view without leaving current server
this.store.dispatch(RoomsActions.viewServer({
if (!session) return;
this.store.dispatch(
RoomsActions.viewServer({
room: {
id: session.serverId,
name: session.serverName,
@@ -98,13 +125,14 @@ export class VoiceSessionService {
maxUsers: 50,
icon: session.serverIcon,
} as any,
}));
}),
);
this._isViewingVoiceServer.set(true);
}
}
/**
* Get the current server ID from the voice session
* Return the server ID of the active voice session, or `null`
* if the user is not in a voice channel.
*/
getVoiceServerId(): string | null {
return this._voiceSession()?.serverId ?? null;

View File

@@ -52,17 +52,14 @@ import {
export class WebRTCService implements OnDestroy {
private readonly timeSync = inject(TimeSyncService);
// ─── Logger ────────────────────────────────────────────────────────
private readonly logger = new WebRTCLogger(/* debugEnabled */ true);
// ─── Identity & server membership ──────────────────────────────────
private lastIdentifyCredentials: IdentifyCredentials | null = null;
private lastJoinedServer: JoinedServerInfo | null = null;
private readonly memberServerIds = new Set<string>();
private activeServerId: string | null = null;
private readonly serviceDestroyed$ = new Subject<void>();
// ─── Angular signals (reactive state) ──────────────────────────────
private readonly _localPeerId = signal<string>(uuidv4());
private readonly _isSignalingConnected = signal(false);
private readonly _isVoiceConnected = signal(false);
@@ -73,10 +70,12 @@ export class WebRTCService implements OnDestroy {
private readonly _screenStreamSignal = signal<MediaStream | null>(null);
private readonly _hasConnectionError = signal(false);
private readonly _connectionErrorMessage = signal<string | null>(null);
private readonly _hasEverConnected = signal(false);
// Public computed signals (unchanged external API)
readonly peerId = computed(() => this._localPeerId());
readonly isConnected = computed(() => this._isSignalingConnected());
readonly hasEverConnected = computed(() => this._hasEverConnected());
readonly isVoiceConnected = computed(() => this._isVoiceConnected());
readonly connectedPeers = computed(() => this._connectedPeers());
readonly isMuted = computed(() => this._isMuted());
@@ -91,7 +90,6 @@ export class WebRTCService implements OnDestroy {
return true;
});
// ─── Public observables (unchanged external API) ───────────────────
private readonly signalingMessage$ = new Subject<SignalingMessage>();
readonly onSignalingMessage = this.signalingMessage$.asObservable();
@@ -102,8 +100,6 @@ export class WebRTCService implements OnDestroy {
get onRemoteStream(): Observable<{ peerId: string; stream: MediaStream }> { return this.peerManager.remoteStream$.asObservable(); }
get onVoiceConnected(): Observable<void> { return this.mediaManager.voiceConnected$.asObservable(); }
// ─── Sub-managers ──────────────────────────────────────────────────
private readonly signalingManager: SignalingManager;
private readonly peerManager: PeerConnectionManager;
private readonly mediaManager: MediaManager;
@@ -162,12 +158,11 @@ export class WebRTCService implements OnDestroy {
this.wireManagerEvents();
}
// ─── Event wiring ──────────────────────────────────────────────────
private wireManagerEvents(): void {
// Signaling → connection status
this.signalingManager.connectionStatus$.subscribe(({ connected, errorMessage }) => {
this._isSignalingConnected.set(connected);
if (connected) this._hasEverConnected.set(true);
this._hasConnectionError.set(!connected);
this._connectionErrorMessage.set(connected ? null : (errorMessage ?? null));
});
@@ -187,8 +182,6 @@ export class WebRTCService implements OnDestroy {
});
}
// ─── Signaling message routing ─────────────────────────────────────
private handleSignalingMessage(message: any): void {
this.signalingMessage$.next(message);
this.logger.info('Signaling message', { type: message.type });
@@ -242,8 +235,6 @@ export class WebRTCService implements OnDestroy {
}
}
// ─── Voice state snapshot ──────────────────────────────────────────
private getCurrentVoiceState(): VoiceStateSnapshot {
return {
isConnected: this._isVoiceConnected(),
@@ -259,41 +250,85 @@ export class WebRTCService implements OnDestroy {
// PUBLIC API matches the old monolithic service's interface
// ═══════════════════════════════════════════════════════════════════
// ─── Signaling ─────────────────────────────────────────────────────
/**
* Connect to a signaling server via WebSocket.
*
* @param serverUrl - The WebSocket URL of the signaling server.
* @returns An observable that emits `true` once connected.
*/
connectToSignalingServer(serverUrl: string): Observable<boolean> {
return this.signalingManager.connect(serverUrl);
}
/**
* Ensure the signaling WebSocket is connected, reconnecting if needed.
*
* @param timeoutMs - Maximum time (ms) to wait for the connection.
* @returns `true` if connected within the timeout.
*/
async ensureSignalingConnected(timeoutMs?: number): Promise<boolean> {
return this.signalingManager.ensureConnected(timeoutMs);
}
/**
* Send a signaling-level message (with `from` and `timestamp` auto-populated).
*
* @param message - The signaling message payload (excluding `from` / `timestamp`).
*/
sendSignalingMessage(message: Omit<SignalingMessage, 'from' | 'timestamp'>): void {
this.signalingManager.sendSignalingMessage(message, this._localPeerId());
}
/**
* Send a raw JSON payload through the signaling WebSocket.
*
* @param message - Arbitrary JSON message.
*/
sendRawMessage(message: Record<string, unknown>): void {
this.signalingManager.sendRawMessage(message);
}
// ─── Server membership ─────────────────────────────────────────────
/**
* Track the currently-active server ID (for server-scoped operations).
*
* @param serverId - The server to mark as active.
*/
setCurrentServer(serverId: string): void {
this.activeServerId = serverId;
}
/**
* Send an identify message to the signaling server.
*
* The credentials are cached so they can be replayed after a reconnect.
*
* @param oderId - The user's unique order/peer ID.
* @param displayName - The user's display name.
*/
identify(oderId: string, displayName: string): void {
this.lastIdentifyCredentials = { oderId, displayName };
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, oderId, displayName });
}
/**
* Join a server (room) on the signaling server.
*
* @param roomId - The server / room ID to join.
* @param userId - The local user ID.
*/
joinRoom(roomId: string, userId: string): void {
this.lastJoinedServer = { serverId: roomId, userId };
this.memberServerIds.add(roomId);
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId: roomId });
}
/**
* Switch to a different server. If already a member, sends a view event;
* otherwise joins the server.
*
* @param serverId - The target server ID.
* @param userId - The local user ID.
*/
switchServer(serverId: string, userId: string): void {
this.lastJoinedServer = { serverId, userId };
@@ -307,6 +342,14 @@ export class WebRTCService implements OnDestroy {
}
}
/**
* Leave one or all servers.
*
* If `serverId` is provided, leaves only that server.
* Otherwise leaves every joined server and performs a full cleanup.
*
* @param serverId - Optional server to leave; omit to leave all.
*/
leaveRoom(serverId?: string): void {
if (serverId) {
this.memberServerIds.delete(serverId);
@@ -323,86 +366,159 @@ export class WebRTCService implements OnDestroy {
this.fullCleanup();
}
/**
* Check whether the local client has joined a given server.
*
* @param serverId - The server to check.
*/
hasJoinedServer(serverId: string): boolean {
return this.memberServerIds.has(serverId);
}
/** Returns a read-only set of all currently-joined server IDs. */
getJoinedServerIds(): ReadonlySet<string> {
return this.memberServerIds;
}
// ─── Peer messaging ────────────────────────────────────────────────
/**
* Broadcast a {@link ChatEvent} to every connected peer.
*
* @param event - The chat event to send.
*/
broadcastMessage(event: ChatEvent): void {
this.peerManager.broadcastMessage(event);
}
/**
* Send a {@link ChatEvent} to a specific peer.
*
* @param peerId - The target peer ID.
* @param event - The chat event to send.
*/
sendToPeer(peerId: string, event: ChatEvent): void {
this.peerManager.sendToPeer(peerId, event);
}
/**
* Send a {@link ChatEvent} to a peer with back-pressure awareness.
*
* @param peerId - The target peer ID.
* @param event - The chat event to send.
*/
async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise<void> {
return this.peerManager.sendToPeerBuffered(peerId, event);
}
/** Returns an array of currently-connected peer IDs. */
getConnectedPeers(): string[] {
return this.peerManager.getConnectedPeerIds();
}
/**
* Get the composite remote {@link MediaStream} for a connected peer.
*
* @param peerId - The remote peer whose stream to retrieve.
* @returns The stream, or `null` if the peer has no active stream.
*/
getRemoteStream(peerId: string): MediaStream | null {
return this.peerManager.remotePeerStreams.get(peerId) ?? null;
}
// ─── Voice / Media ─────────────────────────────────────────────────
/**
* Request microphone access and start sending audio to all peers.
*
* @returns The captured local {@link MediaStream}.
*/
async enableVoice(): Promise<MediaStream> {
const stream = await this.mediaManager.enableVoice();
this.syncMediaSignals();
return stream;
}
/** Stop local voice capture and remove audio senders from peers. */
disableVoice(): void {
this.mediaManager.disableVoice();
this._isVoiceConnected.set(false);
}
/**
* Inject an externally-obtained media stream as the local voice source.
*
* @param stream - The media stream to use.
*/
setLocalStream(stream: MediaStream): void {
this.mediaManager.setLocalStream(stream);
this.syncMediaSignals();
}
/**
* Toggle the local microphone mute state.
*
* @param muted - Explicit state; if omitted, the current state is toggled.
*/
toggleMute(muted?: boolean): void {
this.mediaManager.toggleMute(muted);
this._isMuted.set(this.mediaManager.getIsMicMuted());
}
/**
* Toggle self-deafen (suppress incoming audio playback).
*
* @param deafened - Explicit state; if omitted, the current state is toggled.
*/
toggleDeafen(deafened?: boolean): void {
this.mediaManager.toggleDeafen(deafened);
this._isDeafened.set(this.mediaManager.getIsSelfDeafened());
}
/**
* Set the output volume for remote audio playback.
*
* @param volume - Normalised volume (01).
*/
setOutputVolume(volume: number): void {
this.mediaManager.setOutputVolume(volume);
}
/**
* Set the maximum audio bitrate for all peer connections.
*
* @param kbps - Target bitrate in kilobits per second.
*/
async setAudioBitrate(kbps: number): Promise<void> {
return this.mediaManager.setAudioBitrate(kbps);
}
/**
* Apply a predefined latency profile that maps to a specific bitrate.
*
* @param profile - One of `'low'`, `'balanced'`, or `'high'`.
*/
async setLatencyProfile(profile: LatencyProfile): Promise<void> {
return this.mediaManager.setLatencyProfile(profile);
}
/**
* Start broadcasting voice-presence heartbeats to all peers.
*
* @param roomId - The voice channel room ID.
* @param serverId - The voice channel server ID.
*/
startVoiceHeartbeat(roomId?: string, serverId?: string): void {
this.mediaManager.startVoiceHeartbeat(roomId, serverId);
}
/** Stop the voice-presence heartbeat. */
stopVoiceHeartbeat(): void {
this.mediaManager.stopVoiceHeartbeat();
}
// ─── Screen share ──────────────────────────────────────────────────
/**
* Start sharing the screen (or a window) with all connected peers.
*
* @param includeAudio - Whether to capture and mix system audio.
* @returns The screen-capture {@link MediaStream}.
*/
async startScreenShare(includeAudio: boolean = false): Promise<MediaStream> {
const stream = await this.screenShareManager.startScreenShare(includeAudio);
this._isScreenSharing.set(true);
@@ -410,24 +526,26 @@ export class WebRTCService implements OnDestroy {
return stream;
}
/** Stop screen sharing and restore microphone audio on all peers. */
stopScreenShare(): void {
this.screenShareManager.stopScreenShare();
this._isScreenSharing.set(false);
this._screenStreamSignal.set(null);
}
// ─── Disconnect / cleanup ─────────────────────────────────────────
/** Disconnect from the signaling server and clean up all state. */
disconnect(): void {
this.leaveRoom();
this.mediaManager.stopVoiceHeartbeat();
this.signalingManager.close();
this._isSignalingConnected.set(false);
this._hasEverConnected.set(false);
this._hasConnectionError.set(false);
this._connectionErrorMessage.set(null);
this.serviceDestroyed$.next();
}
/** Alias for {@link disconnect}. */
disconnectAll(): void {
this.disconnect();
}
@@ -442,8 +560,6 @@ export class WebRTCService implements OnDestroy {
this._screenStreamSignal.set(null);
}
// ─── Helpers ───────────────────────────────────────────────────────
/** Synchronise Angular signals from the MediaManager's internal state. */
private syncMediaSignals(): void {
this._isVoiceConnected.set(this.mediaManager.getIsVoiceActive());
@@ -451,8 +567,6 @@ export class WebRTCService implements OnDestroy {
this._isDeafened.set(this.mediaManager.getIsSelfDeafened());
}
// ─── Lifecycle ─────────────────────────────────────────────────────
ngOnDestroy(): void {
this.disconnect();
this.serviceDestroyed$.complete();

View File

@@ -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 01). */
getRemoteAudioVolume(): number { return this.remoteAudioVolume; }
/** The voice channel room ID, if currently in voice. */
getCurrentVoiceRoomId(): string | undefined { return this.currentVoiceRoomId; }
/** The voice channel server ID, if currently in voice. */
getCurrentVoiceServerId(): string | undefined { return this.currentVoiceServerId; }
// ─── Enable / Disable voice ────────────────────────────────────────
/**
* Request microphone access via `getUserMedia` and bind the resulting
* audio track to every active peer connection.
*
* If a local stream already exists it is stopped first.
*
* @returns The captured {@link MediaStream}.
* @throws If `getUserMedia` is unavailable (non-secure context) or the user denies access.
*/
async enableVoice(): Promise<MediaStream> {
try {
// Stop any existing stream first
@@ -125,6 +143,10 @@ export class MediaManager {
}
}
/**
* Stop all local media tracks and remove audio senders from peers.
* The peer connections themselves are kept alive.
*/
disableVoice(): void {
if (this.localMediaStream) {
this.localMediaStream.getTracks().forEach((track) => track.stop());
@@ -154,8 +176,11 @@ export class MediaManager {
this.voiceConnected$.next();
}
// ─── Mute / Deafen ────────────────────────────────────────────────
/**
* Toggle the local microphone mute state.
*
* @param muted - Explicit state; if omitted, the current state is toggled.
*/
toggleMute(muted?: boolean): void {
if (this.localMediaStream) {
const audioTracks = this.localMediaStream.getAudioTracks();
@@ -165,18 +190,32 @@ export class MediaManager {
}
}
/**
* Toggle self-deafen (suppress all incoming audio playback).
*
* @param deafened - Explicit state; if omitted, the current state is toggled.
*/
toggleDeafen(deafened?: boolean): void {
this.isSelfDeafened = deafened !== undefined ? deafened : !this.isSelfDeafened;
}
// ─── Volume ────────────────────────────────────────────────────────
/**
* Set the output volume for remote audio.
*
* @param volume - A value between {@link VOLUME_MIN} (0) and {@link VOLUME_MAX} (1).
*/
setOutputVolume(volume: number): void {
this.remoteAudioVolume = Math.max(VOLUME_MIN, Math.min(VOLUME_MAX, volume));
}
// ─── Audio bitrate ────────────────────────────────────────────────
/**
* Set the maximum audio bitrate on every active peer's audio sender.
*
* The value is clamped between {@link AUDIO_BITRATE_MIN_BPS} and
* {@link AUDIO_BITRATE_MAX_BPS}.
*
* @param kbps - Target bitrate in kilobits per second.
*/
async setAudioBitrate(kbps: number): Promise<void> {
const targetBps = Math.max(AUDIO_BITRATE_MIN_BPS, Math.min(AUDIO_BITRATE_MAX_BPS, Math.floor(kbps * KBPS_TO_BPS)));
@@ -186,25 +225,36 @@ export class MediaManager {
if (peerData.connection.signalingState !== 'stable') return;
let params: RTCRtpSendParameters;
try { params = sender.getParameters(); } catch (e) { console.warn('getParameters failed; skipping bitrate apply', e); return; }
try { params = sender.getParameters(); } catch (error) { this.logger.warn('getParameters failed; skipping bitrate apply', error as any); return; }
params.encodings = params.encodings || [{}];
params.encodings[0].maxBitrate = targetBps;
try {
await sender.setParameters(params);
console.log('Applied audio bitrate:', targetBps);
} catch (e) {
console.warn('Failed to set audio bitrate', e);
this.logger.info('Applied audio bitrate', { targetBps });
} catch (error) {
this.logger.warn('Failed to set audio bitrate', error as any);
}
});
}
/**
* Apply a named latency profile that maps to a predefined bitrate.
*
* @param profile - One of `'low'`, `'balanced'`, or `'high'`.
*/
async setLatencyProfile(profile: LatencyProfile): Promise<void> {
await this.setAudioBitrate(LATENCY_PROFILE_BITRATES[profile]);
}
// ─── Voice-presence heartbeat ─────────────────────────────────────
/**
* Start periodically broadcasting voice presence to all peers.
*
* Optionally records the voice room/server so heartbeats include them.
*
* @param roomId - The voice channel room ID.
* @param serverId - The voice channel server ID.
*/
startVoiceHeartbeat(roomId?: string, serverId?: string): void {
this.stopVoiceHeartbeat();
@@ -224,6 +274,7 @@ export class MediaManager {
}
}
/** Stop the voice-presence heartbeat timer. */
stopVoiceHeartbeat(): void {
if (this.voicePresenceTimer) {
clearInterval(this.voicePresenceTimer);
@@ -231,8 +282,6 @@ export class MediaManager {
}
}
// ─── Internal helpers ──────────────────────────────────────────────
/**
* Bind local audio/video tracks to all existing peer transceivers.
* Restores transceiver direction to sendrecv if previously set to recvonly
@@ -285,6 +334,7 @@ export class MediaManager {
});
}
/** Broadcast a voice-presence state event to all connected peers. */
private broadcastVoicePresence(): void {
const oderId = this.callbacks.getIdentifyOderId();
const displayName = this.callbacks.getIdentifyDisplayName();

View File

@@ -64,7 +64,6 @@ export class PeerConnectionManager {
private disconnectedPeerTracker = new Map<string, DisconnectedPeerEntry>();
private peerReconnectTimers = new Map<string, ReturnType<typeof setInterval>>();
// ─── Public event subjects ─────────────────────────────────────────
readonly peerConnected$ = new Subject<string>();
readonly peerDisconnected$ = new Subject<string>();
readonly remoteStream$ = new Subject<{ peerId: string; stream: MediaStream }>();
@@ -77,13 +76,27 @@ export class PeerConnectionManager {
private callbacks: PeerConnectionCallbacks,
) {}
/** Allow hot-swapping callbacks (e.g. after service wiring). */
/**
* Replace the callback set at runtime.
* Needed because of circular initialisation between managers.
*
* @param cb - The new callback interface to wire into this manager.
*/
setCallbacks(cb: PeerConnectionCallbacks): void {
this.callbacks = cb;
}
// ─── Peer connection lifecycle ─────────────────────────────────────
/**
* Create a new RTCPeerConnection to a remote peer.
*
* Sets up ICE candidate forwarding, connection-state monitoring,
* data-channel creation (initiator) or listening (answerer),
* transceiver pre-creation, and local-track attachment.
*
* @param remotePeerId - Unique identifier of the remote peer.
* @param isInitiator - `true` if this side should create the data channel and send the offer.
* @returns The newly-created {@link PeerData} record.
*/
createPeerConnection(remotePeerId: string, isInitiator: boolean): PeerData {
this.logger.info('Creating peer connection', { remotePeerId, isInitiator });
@@ -201,8 +214,11 @@ export class PeerConnectionManager {
return peerData;
}
// ─── Offer / Answer / ICE ──────────────────────────────────────────
/**
* Create an SDP offer and send it to the remote peer via the signaling server.
*
* @param remotePeerId - The peer to send the offer to.
*/
async createAndSendOffer(remotePeerId: string): Promise<void> {
const peerData = this.activePeerConnections.get(remotePeerId);
if (!peerData) return;
@@ -221,6 +237,16 @@ export class PeerConnectionManager {
}
}
/**
* Handle an incoming SDP offer from a remote peer.
*
* Creates the peer connection if it doesn't exist, sets the remote
* description, discovers browser-created transceivers, attaches local
* tracks, flushes queued ICE candidates, and sends back an answer.
*
* @param fromUserId - The peer ID that sent the offer.
* @param sdp - The remote session description.
*/
async handleOffer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise<void> {
this.logger.info('Handling offer', { fromUserId });
@@ -277,6 +303,15 @@ export class PeerConnectionManager {
}
}
/**
* Handle an incoming SDP answer from a remote peer.
*
* Sets the remote description and flushes any queued ICE candidates.
* Ignored if the connection is not in the `have-local-offer` state.
*
* @param fromUserId - The peer ID that sent the answer.
* @param sdp - The remote session description.
*/
async handleAnswer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise<void> {
this.logger.info('Handling answer', { fromUserId });
const peerData = this.activePeerConnections.get(fromUserId);
@@ -300,6 +335,15 @@ export class PeerConnectionManager {
}
}
/**
* Process an incoming ICE candidate from a remote peer.
*
* If the remote description has already been set the candidate is added
* immediately; otherwise it is queued until the description arrives.
*
* @param fromUserId - The peer ID that sent the candidate.
* @param candidate - The ICE candidate to add.
*/
async handleIceCandidate(fromUserId: string, candidate: RTCIceCandidateInit): Promise<void> {
let peerData = this.activePeerConnections.get(fromUserId);
if (!peerData) {
@@ -338,21 +382,28 @@ export class PeerConnectionManager {
}
}
// ─── Data channel ──────────────────────────────────────────────────
/**
* Wire open/close/error/message handlers onto a data channel.
*
* On open, current voice and screen states are sent to the remote peer
* and a state-request is emitted so the remote peer does the same.
*
* @param channel - The RTCDataChannel to configure.
* @param remotePeerId - The remote peer this channel belongs to.
*/
private setupDataChannel(channel: RTCDataChannel, remotePeerId: string): void {
channel.onopen = () => {
console.log(`Data channel open with ${remotePeerId}`);
this.logger.info('Data channel open', { remotePeerId });
this.sendCurrentStatesToChannel(channel, remotePeerId);
try { channel.send(JSON.stringify({ type: P2P_TYPE_STATE_REQUEST })); } catch { /* ignore */ }
};
channel.onclose = () => {
console.log(`Data channel closed with ${remotePeerId}`);
this.logger.info('Data channel closed', { remotePeerId });
};
channel.onerror = (error) => {
console.error(`Data channel error with ${remotePeerId}:`, error);
this.logger.error('Data channel error', error, { remotePeerId });
};
channel.onmessage = (event) => {
@@ -365,8 +416,18 @@ export class PeerConnectionManager {
};
}
/**
* Route an incoming peer-to-peer message.
*
* State-request messages trigger an immediate state broadcast back.
* All other messages are enriched with `fromPeerId` and forwarded
* to the {@link messageReceived$} subject.
*
* @param peerId - The remote peer that sent the message.
* @param message - The parsed JSON payload.
*/
private handlePeerMessage(peerId: string, message: any): void {
console.log('Received P2P message from', peerId, ':', message);
this.logger.info('Received P2P message', { peerId, type: message?.type });
if (message.type === P2P_TYPE_STATE_REQUEST || message.type === P2P_TYPE_VOICE_STATE_REQUEST) {
this.sendCurrentStatesToPeer(peerId);
@@ -377,8 +438,6 @@ export class PeerConnectionManager {
this.messageReceived$.next(enriched);
}
// ─── Messaging helpers ─────────────────────────────────────────────
/** Broadcast a ChatEvent to every peer with an open data channel. */
broadcastMessage(event: ChatEvent): void {
const data = JSON.stringify(event);
@@ -386,33 +445,48 @@ export class PeerConnectionManager {
try {
if (peerData.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) {
peerData.dataChannel.send(data);
console.log('Sent message via P2P to:', peerId);
this.logger.info('Sent message via P2P', { peerId });
}
} catch (error) {
console.error(`Failed to send to peer ${peerId}:`, error);
this.logger.error('Failed to send to peer', error, { peerId });
}
});
}
/** Send a ChatEvent to a single peer. */
/**
* Send a {@link ChatEvent} to a specific peer's data channel.
*
* Silently returns if the peer is not connected or the channel is not open.
*
* @param peerId - The target peer.
* @param event - The chat event to send.
*/
sendToPeer(peerId: string, event: ChatEvent): void {
const peerData = this.activePeerConnections.get(peerId);
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
console.error(`Peer ${peerId} not connected`);
this.logger.warn('Peer not connected cannot send', { peerId });
return;
}
try {
peerData.dataChannel.send(JSON.stringify(event));
} catch (error) {
console.error(`Failed to send to peer ${peerId}:`, error);
this.logger.error('Failed to send to peer', error, { peerId });
}
}
/** Send with back-pressure awareness (for large payloads). */
/**
* Send a {@link ChatEvent} with back-pressure awareness.
*
* If the data channel's buffer exceeds {@link DATA_CHANNEL_HIGH_WATER_BYTES}
* the call awaits until the buffer drains below {@link DATA_CHANNEL_LOW_WATER_BYTES}.
*
* @param peerId - The target peer.
* @param event - The chat event to send.
*/
async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise<void> {
const peerData = this.activePeerConnections.get(peerId);
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
console.error(`Peer ${peerId} not connected`);
this.logger.warn('Peer not connected cannot send buffered', { peerId });
return;
}
@@ -435,11 +509,14 @@ export class PeerConnectionManager {
});
}
try { channel.send(data); } catch (error) { console.error(`Failed to send to peer ${peerId}:`, error); }
try { channel.send(data); } catch (error) { this.logger.error('Failed to send buffered message', error, { peerId }); }
}
// ─── State broadcasts ─────────────────────────────────────────────
/**
* Send the current voice and screen-share states to a single peer.
*
* @param peerId - The peer to notify.
*/
sendCurrentStatesToPeer(peerId: string): void {
const credentials = this.callbacks.getIdentifyCredentials();
const oderId = credentials?.oderId || this.callbacks.getLocalPeerId();
@@ -469,6 +546,7 @@ export class PeerConnectionManager {
}
}
/** Broadcast the current voice and screen-share states to all connected peers. */
broadcastCurrentStates(): void {
const credentials = this.callbacks.getIdentifyCredentials();
const oderId = credentials?.oderId || this.callbacks.getLocalPeerId();
@@ -479,8 +557,6 @@ export class PeerConnectionManager {
this.broadcastMessage({ type: P2P_TYPE_SCREEN_STATE, oderId, displayName, isScreenSharing: this.callbacks.isScreenSharingActive() } as any);
}
// ─── Remote tracks ─────────────────────────────────────────────────
private handleRemoteTrack(event: RTCTrackEvent, remotePeerId: string): void {
const track = event.track;
const settings = typeof track.getSettings === 'function' ? track.getSettings() : {} as MediaTrackSettings;
@@ -495,7 +571,7 @@ export class PeerConnectionManager {
// Merge into composite stream per peer
let compositeStream = this.remotePeerStreams.get(remotePeerId) || new MediaStream();
const trackAlreadyAdded = compositeStream.getTracks().some(t => t.id === track.id);
const trackAlreadyAdded = compositeStream.getTracks().some(existingTrack => existingTrack.id === track.id);
if (!trackAlreadyAdded) {
try { compositeStream.addTrack(track); } catch (e) { this.logger.warn('Failed to add track to composite stream', e as any); }
}
@@ -503,8 +579,11 @@ export class PeerConnectionManager {
this.remoteStream$.next({ peerId: remotePeerId, stream: compositeStream });
}
// ─── Peer removal / cleanup ────────────────────────────────────────
/**
* Close and remove a peer connection, data channel, and emit a disconnect event.
*
* @param peerId - The peer to remove.
*/
removePeer(peerId: string): void {
const peerData = this.activePeerConnections.get(peerId);
if (peerData) {
@@ -516,6 +595,7 @@ export class PeerConnectionManager {
}
}
/** Close every active peer connection and clear internal state. */
closeAllPeers(): void {
this.clearAllPeerReconnectTimers();
this.activePeerConnections.forEach((peerData) => {
@@ -526,8 +606,6 @@ export class PeerConnectionManager {
this.connectedPeersChanged$.next([]);
}
// ─── P2P reconnection ─────────────────────────────────────────────
private trackDisconnectedPeer(peerId: string): void {
this.disconnectedPeerTracker.set(peerId, { lastSeenTimestamp: Date.now(), reconnectAttempts: 0 });
}
@@ -537,6 +615,7 @@ export class PeerConnectionManager {
if (timer) { clearInterval(timer); this.peerReconnectTimers.delete(peerId); }
}
/** Cancel all pending peer reconnect timers and clear the tracker. */
clearAllPeerReconnectTimers(): void {
this.peerReconnectTimers.forEach((timer) => clearInterval(timer));
this.peerReconnectTimers.clear();
@@ -586,10 +665,9 @@ export class PeerConnectionManager {
}
}
// ─── Connected-peer helpers ────────────────────────────────────────
private connectedPeersList: string[] = [];
/** Return a snapshot copy of the currently-connected peer IDs. */
getConnectedPeerIds(): string[] {
return [...this.connectedPeersList];
}
@@ -601,11 +679,17 @@ export class PeerConnectionManager {
}
}
/**
* Remove a peer from the connected list and notify subscribers.
*
* @param peerId - The peer to remove.
*/
private removeFromConnectedPeers(peerId: string): void {
this.connectedPeersList = this.connectedPeersList.filter(p => p !== peerId);
this.connectedPeersList = this.connectedPeersList.filter(connectedId => connectedId !== peerId);
this.connectedPeersChanged$.next(this.connectedPeersList);
}
/** Reset the connected peers list to empty and notify subscribers. */
resetConnectedPeers(): void {
this.connectedPeersList = [];
this.connectedPeersChanged$.next([]);

View File

@@ -43,17 +43,32 @@ export class ScreenShareManager {
private callbacks: ScreenShareCallbacks,
) {}
/**
* Replace the callback set at runtime.
* Needed because of circular initialisation between managers.
*
* @param cb - The new callback interface to wire into this manager.
*/
setCallbacks(cb: ScreenShareCallbacks): void {
this.callbacks = cb;
}
// ─── Accessors ─────────────────────────────────────────────────────
/** Returns the current screen-capture stream, or `null` if inactive. */
getScreenStream(): MediaStream | null { return this.activeScreenStream; }
/** Whether screen sharing is currently active. */
getIsScreenActive(): boolean { return this.isScreenActive; }
// ─── Start / Stop ──────────────────────────────────────────────────
/**
* Begin screen sharing.
*
* Tries the Electron desktop capturer API first (for Electron builds),
* then falls back to standard `getDisplayMedia`.
* Optionally includes system audio mixed with the microphone.
*
* @param includeSystemAudio - Whether to capture system / tab audio alongside the video.
* @returns The captured screen {@link MediaStream}.
* @throws If both Electron and browser screen capture fail.
*/
async startScreenShare(includeSystemAudio: boolean = false): Promise<MediaStream> {
try {
this.logger.info('startScreenShare invoked', { includeSystemAudio });
@@ -125,6 +140,12 @@ export class ScreenShareManager {
}
}
/**
* Stop screen sharing and restore the microphone audio track on all peers.
*
* Stops all screen-capture tracks, tears down mixed audio,
* resets video transceivers to receive-only, and triggers renegotiation.
*/
stopScreenShare(): void {
if (this.activeScreenStream) {
this.activeScreenStream.getTracks().forEach((track) => track.stop());
@@ -135,14 +156,14 @@ export class ScreenShareManager {
// Clean up mixed audio
if (this.combinedAudioStream) {
try { this.combinedAudioStream.getTracks().forEach(t => t.stop()); } catch { /* ignore */ }
try { this.combinedAudioStream.getTracks().forEach(track => track.stop()); } catch { /* ignore */ }
this.combinedAudioStream = null;
}
// Remove video track and restore mic on all peers
this.callbacks.getActivePeers().forEach((peerData, peerId) => {
const transceivers = peerData.connection.getTransceivers();
const videoTransceiver = transceivers.find(t => t.sender === peerData.videoSender || t.sender === peerData.screenVideoSender);
const videoTransceiver = transceivers.find(transceiver => transceiver.sender === peerData.videoSender || transceiver.sender === peerData.screenVideoSender);
if (videoTransceiver) {
videoTransceiver.sender.replaceTrack(null).catch(() => {});
if (videoTransceiver.direction === TRANSCEIVER_SEND_RECV) {
@@ -161,15 +182,20 @@ export class ScreenShareManager {
audioSender = transceiver.sender;
}
peerData.audioSender = audioSender;
audioSender.replaceTrack(micTrack).catch((e) => console.error('restore mic replaceTrack failed:', e));
audioSender.replaceTrack(micTrack).catch((error) => this.logger.error('Restore mic replaceTrack failed', error));
}
this.callbacks.renegotiate(peerId);
});
}
// ─── Internal helpers ──────────────────────────────────────────────
/** Create a mixed audio stream from screen audio + mic audio. */
/**
* Create a mixed audio stream from screen audio + microphone audio
* using the Web Audio API ({@link AudioContext}).
*
* Falls back to screen-audio-only if mixing fails.
*
* @param includeSystemAudio - Whether system audio should be mixed in.
*/
private prepareMixedAudio(includeSystemAudio: boolean): void {
const screenAudioTrack = includeSystemAudio ? (this.activeScreenStream?.getAudioTracks()[0] || null) : null;
const micAudioTrack = this.callbacks.getLocalMediaStream()?.getAudioTracks()[0] || null;
@@ -204,7 +230,12 @@ export class ScreenShareManager {
}
}
/** Attach screen video + audio tracks to all active peers. */
/**
* Attach screen video and (optionally) combined audio tracks to all
* active peer connections, then trigger SDP renegotiation.
*
* @param includeSystemAudio - Whether the combined audio track should replace the mic sender.
*/
private attachScreenTracksToPeers(includeSystemAudio: boolean): void {
this.callbacks.getActivePeers().forEach((peerData, peerId) => {
if (!this.activeScreenStream) return;

View File

@@ -39,8 +39,6 @@ export class SignalingManager {
private readonly getMemberServerIds: () => ReadonlySet<string>,
) {}
// ─── Public API ────────────────────────────────────────────────────
/** Open (or re-open) a WebSocket to the signaling server. */
connect(serverUrl: string): Observable<boolean> {
this.lastSignalingUrl = serverUrl;
@@ -146,8 +144,6 @@ export class SignalingManager {
return this.lastSignalingUrl;
}
// ─── Internals ─────────────────────────────────────────────────────
/** Re-identify and rejoin servers after a reconnect. */
private reIdentifyAndRejoin(): void {
const credentials = this.getLastIdentify();
@@ -172,6 +168,12 @@ export class SignalingManager {
}
}
/**
* Schedule a reconnect attempt using exponential backoff.
*
* The delay doubles with each attempt up to {@link SIGNALING_RECONNECT_MAX_DELAY_MS}.
* No-ops if a timer is already pending or no URL is stored.
*/
private scheduleReconnect(): void {
if (this.signalingReconnectTimer || !this.lastSignalingUrl) return;
const delay = Math.min(
@@ -189,6 +191,7 @@ export class SignalingManager {
}, delay);
}
/** Cancel any pending reconnect timer and reset the attempt counter. */
private clearReconnect(): void {
if (this.signalingReconnectTimer) {
clearTimeout(this.signalingReconnectTimer);
@@ -197,11 +200,13 @@ export class SignalingManager {
this.signalingReconnectAttempts = 0;
}
/** Start the heartbeat interval that drives periodic state broadcasts. */
private startHeartbeat(): void {
this.stopHeartbeat();
this.stateHeartbeatTimer = setInterval(() => this.heartbeatTick$.next(), STATE_HEARTBEAT_INTERVAL_MS);
}
/** Stop the heartbeat interval. */
private stopHeartbeat(): void {
if (this.stateHeartbeatTimer) {
clearInterval(this.stateHeartbeatTimer);

View File

@@ -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}`));
}
}

View File

@@ -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) */

View File

@@ -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;
}

View File

@@ -147,9 +147,7 @@
} @else {
@for (user of membersFiltered(); track user.id) {
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold text-sm">
{{ user.displayName ? user.displayName.charAt(0).toUpperCase() : '?' }}
</div>
<app-user-avatar [name]="user.displayName || '?'" size="sm" />
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<p class="text-sm font-medium text-foreground truncate">{{ user.displayName }}</p>
@@ -302,7 +300,11 @@
<p class="text-sm font-medium text-foreground">Admins Can Manage Rooms</p>
<p class="text-xs text-muted-foreground">Allow admins to create/modify chat & voice rooms</p>
</div>
<input type="checkbox" [(ngModel)]="adminsManageRooms" class="w-4 h-4 accent-primary" />
<input
type="checkbox"
[(ngModel)]="adminsManageRooms"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
@@ -310,7 +312,11 @@
<p class="text-sm font-medium text-foreground">Moderators Can Manage Rooms</p>
<p class="text-xs text-muted-foreground">Allow moderators to create/modify chat & voice rooms</p>
</div>
<input type="checkbox" [(ngModel)]="moderatorsManageRooms" class="w-4 h-4 accent-primary" />
<input
type="checkbox"
[(ngModel)]="moderatorsManageRooms"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
@@ -318,7 +324,11 @@
<p class="text-sm font-medium text-foreground">Admins Can Change Server Icon</p>
<p class="text-xs text-muted-foreground">Grant icon management to admins</p>
</div>
<input type="checkbox" [(ngModel)]="adminsManageIcon" class="w-4 h-4 accent-primary" />
<input
type="checkbox"
[(ngModel)]="adminsManageIcon"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
@@ -326,7 +336,11 @@
<p class="text-sm font-medium text-foreground">Moderators Can Change Server Icon</p>
<p class="text-xs text-muted-foreground">Grant icon management to moderators</p>
</div>
<input type="checkbox" [(ngModel)]="moderatorsManageIcon" class="w-4 h-4 accent-primary" />
<input
type="checkbox"
[(ngModel)]="moderatorsManageIcon"
class="w-4 h-4 accent-primary"
/>
</div>
</div>
@@ -346,28 +360,16 @@
<!-- Delete Confirmation Modal -->
@if (showDeleteConfirm()) {
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="showDeleteConfirm.set(false)">
<div class="bg-card border border-border rounded-lg p-6 w-96 max-w-[90vw]" (click)="$event.stopPropagation()">
<h3 class="text-lg font-semibold text-foreground mb-2">Delete Room</h3>
<p class="text-sm text-muted-foreground mb-4">
Are you sure you want to delete this room? This action cannot be undone.
</p>
<div class="flex gap-2 justify-end">
<button
(click)="showDeleteConfirm.set(false)"
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors"
<app-confirm-dialog
title="Delete Room"
confirmLabel="Delete Room"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="deleteRoom()"
(cancelled)="showDeleteConfirm.set(false)"
>
Cancel
</button>
<button
(click)="deleteRoom()"
class="px-4 py-2 bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
>
Delete Room
</button>
</div>
</div>
</div>
<p>Are you sure you want to delete this room? This action cannot be undone.</p>
</app-confirm-dialog>
}
} @else {
<div class="h-full flex items-center justify-center text-muted-foreground">

View File

@@ -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({

View File

@@ -8,20 +8,40 @@
<div class="space-y-3">
<div>
<label class="block text-xs text-muted-foreground mb-1">Username</label>
<input [(ngModel)]="username" type="text" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
<input
[(ngModel)]="username"
type="text"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
/>
</div>
<div>
<label class="block text-xs text-muted-foreground mb-1">Password</label>
<input [(ngModel)]="password" type="password" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
<input
[(ngModel)]="password"
type="password"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
/>
</div>
<div>
<label class="block text-xs text-muted-foreground mb-1">Server App</label>
<select [(ngModel)]="serverId" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground">
<option *ngFor="let s of servers(); trackBy: trackById" [value]="s.id">{{ s.name }}</option>
<select
[(ngModel)]="serverId"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
>
@for (s of servers(); track s.id) {
<option [value]="s.id">{{ s.name }}</option>
}
</select>
</div>
<p *ngIf="error()" class="text-xs text-destructive">{{ error() }}</p>
<button (click)="submit()" class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90">Login</button>
@if (error()) {
<p class="text-xs text-destructive">{{ error() }}</p>
}
<button
(click)="submit()"
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
>
Login
</button>
<div class="text-xs text-muted-foreground text-center mt-2">
No account? <a class="text-primary hover:underline" (click)="goRegister()">Register</a>
</div>

View File

@@ -8,8 +8,9 @@ import { lucideLogIn } from '@ng-icons/lucide';
import { AuthService } from '../../../core/services/auth.service';
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
import * as UsersActions from '../../../store/users/users.actions';
import { UsersActions } from '../../../store/users/users.actions';
import { User } from '../../../core/models';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
@Component({
selector: 'app-login',
@@ -18,6 +19,9 @@ import { User } from '../../../core/models';
viewProviders: [provideIcons({ lucideLogIn })],
templateUrl: './login.component.html',
})
/**
* Login form allowing existing users to authenticate against a selected server.
*/
export class LoginComponent {
private auth = inject(AuthService);
private serversSvc = inject(ServerDirectoryService);
@@ -30,8 +34,10 @@ export class LoginComponent {
serverId: string | undefined = this.serversSvc.activeServer()?.id;
error = signal<string | null>(null);
/** TrackBy function for server list rendering. */
trackById(_index: number, item: { id: string }) { return item.id; }
/** Validate and submit the login form, then navigate to search on success. */
submit() {
this.error.set(null);
const sid = this.serverId || this.serversSvc.activeServer()?.id;
@@ -47,7 +53,7 @@ export class LoginComponent {
role: 'member',
joinedAt: Date.now(),
};
try { localStorage.setItem('metoyou_currentUserId', resp.id); } catch {}
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
this.store.dispatch(UsersActions.setCurrentUser({ user }));
this.router.navigate(['/search']);
},
@@ -57,6 +63,7 @@ export class LoginComponent {
});
}
/** Navigate to the registration page. */
goRegister() {
this.router.navigate(['/register']);
}

View File

@@ -8,24 +8,48 @@
<div class="space-y-3">
<div>
<label class="block text-xs text-muted-foreground mb-1">Username</label>
<input [(ngModel)]="username" type="text" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
<input
[(ngModel)]="username"
type="text"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
/>
</div>
<div>
<label class="block text-xs text-muted-foreground mb-1">Display Name</label>
<input [(ngModel)]="displayName" type="text" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
<input
[(ngModel)]="displayName"
type="text"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
/>
</div>
<div>
<label class="block text-xs text-muted-foreground mb-1">Password</label>
<input [(ngModel)]="password" type="password" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
<input
[(ngModel)]="password"
type="password"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
/>
</div>
<div>
<label class="block text-xs text-muted-foreground mb-1">Server App</label>
<select [(ngModel)]="serverId" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground">
<option *ngFor="let s of servers(); trackBy: trackById" [value]="s.id">{{ s.name }}</option>
<select
[(ngModel)]="serverId"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
>
@for (s of servers(); track s.id) {
<option [value]="s.id">{{ s.name }}</option>
}
</select>
</div>
<p *ngIf="error()" class="text-xs text-destructive">{{ error() }}</p>
<button (click)="submit()" class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90">Create Account</button>
@if (error()) {
<p class="text-xs text-destructive">{{ error() }}</p>
}
<button
(click)="submit()"
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
>
Create Account
</button>
<div class="text-xs text-muted-foreground text-center mt-2">
Have an account? <a class="text-primary hover:underline" (click)="goLogin()">Login</a>
</div>

View File

@@ -8,8 +8,9 @@ import { lucideUserPlus } from '@ng-icons/lucide';
import { AuthService } from '../../../core/services/auth.service';
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
import * as UsersActions from '../../../store/users/users.actions';
import { UsersActions } from '../../../store/users/users.actions';
import { User } from '../../../core/models';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
@Component({
selector: 'app-register',
@@ -18,6 +19,9 @@ import { User } from '../../../core/models';
viewProviders: [provideIcons({ lucideUserPlus })],
templateUrl: './register.component.html',
})
/**
* Registration form allowing new users to create an account on a selected server.
*/
export class RegisterComponent {
private auth = inject(AuthService);
private serversSvc = inject(ServerDirectoryService);
@@ -31,8 +35,10 @@ export class RegisterComponent {
serverId: string | undefined = this.serversSvc.activeServer()?.id;
error = signal<string | null>(null);
/** TrackBy function for server list rendering. */
trackById(_index: number, item: { id: string }) { return item.id; }
/** Validate and submit the registration form, then navigate to search on success. */
submit() {
this.error.set(null);
const sid = this.serverId || this.serversSvc.activeServer()?.id;
@@ -48,7 +54,7 @@ export class RegisterComponent {
role: 'member',
joinedAt: Date.now(),
};
try { localStorage.setItem('metoyou_currentUserId', resp.id); } catch {}
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
this.store.dispatch(UsersActions.setCurrentUser({ user }));
this.router.navigate(['/search']);
},
@@ -58,6 +64,7 @@ export class RegisterComponent {
});
}
/** Navigate to the login page. */
goLogin() {
this.router.navigate(['/login']);
}

View File

@@ -13,11 +13,15 @@ import { selectCurrentUser } from '../../../store/users/users.selectors';
viewProviders: [provideIcons({ lucideUser, lucideLogIn, lucideUserPlus })],
templateUrl: './user-bar.component.html',
})
/**
* Compact user status bar showing the current user with login/register navigation links.
*/
export class UserBarComponent {
private store = inject(Store);
private router = inject(Router);
user = this.store.selectSignal(selectCurrentUser);
/** Navigate to the specified authentication page. */
goto(path: 'login' | 'register') {
this.router.navigate([`/${path}`]);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,462 @@
<div class="chat-layout relative h-full">
<!-- Messages List -->
<div #messagesContainer class="chat-messages-scroll absolute inset-0 overflow-y-auto p-4 space-y-4" (scroll)="onScroll()">
<!-- Syncing indicator -->
@if (syncing() && !loading()) {
<div class="flex items-center justify-center gap-2 py-1.5 text-xs text-muted-foreground">
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-primary"></div>
<span>Syncing messages…</span>
</div>
}
@if (loading()) {
<div class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
} @else if (messages().length === 0) {
<div class="flex flex-col items-center justify-center h-full text-muted-foreground">
<p class="text-lg">No messages yet</p>
<p class="text-sm">Be the first to say something!</p>
</div>
} @else {
<!-- Infinite scroll: load-more sentinel at top -->
@if (hasMoreMessages()) {
<div class="flex items-center justify-center py-3">
@if (loadingMore()) {
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-primary"></div>
} @else {
<button (click)="loadMore()" class="text-xs text-muted-foreground hover:text-foreground transition-colors px-3 py-1 rounded-md hover:bg-secondary">
Load older messages
</button>
}
</div>
}
@for (message of messages(); track message.id) {
<div
[attr.data-message-id]="message.id"
class="group relative flex gap-3 p-2 rounded-lg hover:bg-secondary/30 transition-colors"
[class.opacity-50]="message.isDeleted"
>
<!-- Avatar -->
<app-user-avatar [name]="message.senderName" size="md" class="flex-shrink-0" />
<!-- Message Content -->
<div class="flex-1 min-w-0">
<!-- Reply indicator -->
@if (message.replyToId) {
@let repliedMsg = getRepliedMessage(message.replyToId);
<div class="flex items-center gap-1.5 mb-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground transition-colors" (click)="scrollToMessage(message.replyToId)">
<div class="w-4 h-3 border-l-2 border-t-2 border-muted-foreground/50 rounded-tl-md"></div>
<ng-icon name="lucideReply" class="w-3 h-3" />
@if (repliedMsg) {
<span class="font-medium">{{ repliedMsg.senderName }}</span>
<span class="truncate max-w-[200px]">{{ repliedMsg.content }}</span>
} @else {
<span class="italic">Original message not found</span>
}
</div>
}
<div class="flex items-baseline gap-2">
<span class="font-semibold text-foreground">{{ message.senderName }}</span>
<span class="text-xs text-muted-foreground">
{{ formatTimestamp(message.timestamp) }}
</span>
@if (message.editedAt) {
<span class="text-xs text-muted-foreground">(edited)</span>
}
</div>
@if (editingMessageId() === message.id) {
<!-- Edit Mode -->
<div class="mt-1 flex gap-2">
<input
type="text"
[(ngModel)]="editContent"
(keydown.enter)="saveEdit(message.id)"
(keydown.escape)="cancelEdit()"
class="flex-1 px-3 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<button
(click)="saveEdit(message.id)"
class="p-1 text-primary hover:bg-primary/10 rounded"
>
<ng-icon name="lucideCheck" class="w-4 h-4" />
</button>
<button
(click)="cancelEdit()"
class="p-1 text-muted-foreground hover:bg-secondary rounded"
>
<ng-icon name="lucideX" class="w-4 h-4" />
</button>
</div>
} @else {
<div class="chat-markdown mt-1 break-words">
<remark [markdown]="message.content" [processor]="remarkProcessor">
<ng-template [remarkTemplate]="'code'" let-node>
@if (node.lang === 'mermaid') {
<remark-mermaid [code]="node.value"/>
} @else {
<pre><code>{{ node.value }}</code></pre>
}
</ng-template>
</remark>
</div>
@if (getAttachments(message.id).length > 0) {
<div class="mt-2 space-y-2">
@for (att of getAttachments(message.id); track att.id) {
@if (att.isImage) {
@if (att.available && att.objectUrl) {
<!-- Available image with hover overlay -->
<div class="relative group/img inline-block" (contextmenu)="openImageContextMenu($event, att)">
<img
[src]="att.objectUrl"
[alt]="att.filename"
class="rounded-md max-h-80 w-auto cursor-pointer"
(click)="openLightbox(att)"
/>
<div class="absolute inset-0 bg-black/0 group-hover/img:bg-black/20 transition-colors rounded-md pointer-events-none"></div>
<div class="absolute top-2 right-2 opacity-0 group-hover/img:opacity-100 transition-opacity flex gap-1">
<button
(click)="openLightbox(att); $event.stopPropagation()"
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md backdrop-blur-sm transition-colors"
title="View full size"
>
<ng-icon name="lucideExpand" class="w-4 h-4" />
</button>
<button
(click)="downloadAttachment(att); $event.stopPropagation()"
class="p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md backdrop-blur-sm transition-colors"
title="Download"
>
<ng-icon name="lucideDownload" class="w-4 h-4" />
</button>
</div>
</div>
} @else if ((att.receivedBytes || 0) > 0) {
<!-- Downloading in progress -->
<div class="border border-border rounded-md p-3 bg-secondary/40 max-w-xs">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center">
<ng-icon name="lucideImage" class="w-5 h-5 text-primary" />
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
</div>
<div class="text-xs font-medium text-primary">{{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}%</div>
</div>
<div class="mt-2 h-1.5 rounded-full bg-muted overflow-hidden">
<div class="h-full rounded-full bg-primary transition-all duration-300" [style.width.%]="(att.receivedBytes || 0) * 100 / att.size"></div>
</div>
</div>
} @else {
<!-- Unavailable — waiting for source -->
<div class="border border-dashed border-border rounded-md p-4 bg-secondary/20 max-w-xs">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-md bg-muted flex items-center justify-center">
<ng-icon name="lucideImage" class="w-5 h-5 text-muted-foreground" />
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate text-foreground">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
<div class="text-xs text-muted-foreground/70 mt-0.5 italic">Waiting for image source…</div>
</div>
</div>
<button
(click)="retryImageRequest(att, message.id)"
class="mt-2 w-full px-3 py-1.5 text-xs bg-secondary hover:bg-secondary/80 text-foreground rounded-md transition-colors"
>
Retry
</button>
</div>
}
} @else {
<div class="border border-border rounded-md p-2 bg-secondary/40">
<div class="flex items-center justify-between">
<div class="min-w-0">
<div class="text-sm font-medium truncate">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
</div>
<div class="flex items-center gap-2">
@if (!isUploader(att)) {
@if (!att.available) {
<div class="w-24 h-1.5 rounded bg-muted">
<div class="h-1.5 rounded bg-primary" [style.width.%]="(att.receivedBytes || 0) * 100 / att.size"></div>
</div>
<div class="text-xs text-muted-foreground flex items-center gap-2">
<span>{{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}%</span>
@if (att.speedBps) {
<span>• {{ formatSpeed(att.speedBps) }}</span>
}
</div>
@if (!(att.receivedBytes || 0)) {
<button
class="px-2 py-1 text-xs bg-secondary text-foreground rounded"
(click)="requestAttachment(att, message.id)"
>Request</button>
} @else {
<button
class="px-2 py-1 text-xs bg-destructive text-destructive-foreground rounded"
(click)="cancelAttachment(att, message.id)"
>Cancel</button>
}
} @else {
<button
class="px-2 py-1 text-xs bg-primary text-primary-foreground rounded"
(click)="downloadAttachment(att)"
>Download</button>
}
} @else {
<div class="text-xs text-muted-foreground">Shared from your device</div>
}
</div>
</div>
</div>
}
}
</div>
}
}
<!-- Reactions -->
@if (message.reactions.length > 0) {
<div class="flex flex-wrap gap-1 mt-2">
@for (reaction of getGroupedReactions(message); track reaction.emoji) {
<button
(click)="toggleReaction(message.id, reaction.emoji)"
class="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-secondary hover:bg-secondary/80 transition-colors"
[class.ring-1]="reaction.hasCurrentUser"
[class.ring-primary]="reaction.hasCurrentUser"
>
<span>{{ reaction.emoji }}</span>
<span class="text-muted-foreground">{{ reaction.count }}</span>
</button>
}
</div>
}
</div>
<!-- Message Actions (visible on hover) -->
@if (!message.isDeleted) {
<div class="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 bg-card border border-border rounded-lg shadow-lg">
<!-- Emoji Picker Toggle -->
<div class="relative">
<button
(click)="toggleEmojiPicker(message.id)"
class="p-1.5 hover:bg-secondary rounded-l-lg transition-colors"
>
<ng-icon name="lucideSmile" class="w-4 h-4 text-muted-foreground" />
</button>
@if (showEmojiPicker() === message.id) {
<div class="absolute bottom-full right-0 mb-2 p-2 bg-card border border-border rounded-lg shadow-lg flex gap-1 z-10">
@for (emoji of commonEmojis; track emoji) {
<button
(click)="addReaction(message.id, emoji)"
class="p-1 hover:bg-secondary rounded transition-colors text-lg"
>
{{ emoji }}
</button>
}
</div>
}
</div>
<!-- Reply -->
<button
(click)="setReplyTo(message)"
class="p-1.5 hover:bg-secondary transition-colors"
>
<ng-icon name="lucideReply" class="w-4 h-4 text-muted-foreground" />
</button>
<!-- Edit (own messages only) -->
@if (isOwnMessage(message)) {
<button
(click)="startEdit(message)"
class="p-1.5 hover:bg-secondary transition-colors"
>
<ng-icon name="lucideEdit" class="w-4 h-4 text-muted-foreground" />
</button>
}
<!-- Delete (own messages or admin) -->
@if (isOwnMessage(message) || isAdmin()) {
<button
(click)="deleteMessage(message)"
class="p-1.5 hover:bg-destructive/10 rounded-r-lg transition-colors"
>
<ng-icon name="lucideTrash2" class="w-4 h-4 text-destructive" />
</button>
}
</div>
}
</div>
}
}
<!-- New messages snackbar (center bottom inside container) -->
@if (showNewMessagesBar()) {
<div class="sticky bottom-4 flex justify-center pointer-events-none">
<div class="px-3 py-2 bg-card border border-border rounded-lg shadow flex items-center gap-3 pointer-events-auto">
<span class="text-sm text-muted-foreground">New messages</span>
<button (click)="readLatest()" class="px-2 py-1 bg-primary text-primary-foreground rounded hover:bg-primary/90 text-sm">Read latest</button>
</div>
</div>
}
</div>
<!-- Bottom bar: floats over messages -->
<div #bottomBar class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
<!-- Reply Preview -->
@if (replyTo()) {
<div class="px-4 py-2 bg-secondary/50 flex items-center gap-2 pointer-events-auto">
<ng-icon name="lucideReply" class="w-4 h-4 text-muted-foreground" />
<span class="text-sm text-muted-foreground flex-1">
Replying to <span class="font-semibold">{{ replyTo()?.senderName }}</span>
</span>
<button (click)="clearReply()" class="p-1 hover:bg-secondary rounded">
<ng-icon name="lucideX" class="w-4 h-4 text-muted-foreground" />
</button>
</div>
}
<!-- Typing Indicator -->
<app-typing-indicator />
<!-- Markdown Toolbar -->
@if (toolbarVisible()) {
<div class="pointer-events-auto" (mousedown)="$event.preventDefault()" (mouseenter)="onToolbarMouseEnter()" (mouseleave)="onToolbarMouseLeave()">
<div class="mx-4 -mb-2 flex flex-wrap gap-2 justify-start items-center bg-card/70 backdrop-blur border border-border rounded-lg px-2 py-1 shadow-sm">
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline('**')"><b>B</b></button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline('*')"><i>I</i></button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline('~~')"><s>S</s></button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline(inlineCodeToken)">&#96;</button>
<span class="mx-1 text-muted-foreground">|</span>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHeading(1)">H1</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHeading(2)">H2</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHeading(3)">H3</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyPrefix('> ')">Quote</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyPrefix('- ')">• List</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyOrderedList()">1. List</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyCodeBlock()">Code</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyLink()">Link</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyImage()">Image</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHorizontalRule()">HR</button>
</div>
</div>
}
<!-- Message Input -->
<div class="p-4 border-border">
<div
class="chat-input-wrapper relative"
(mouseenter)="inputHovered.set(true)"
(mouseleave)="inputHovered.set(false)"
>
<textarea
#messageInputRef
rows="1"
[(ngModel)]="messageContent"
(focus)="onInputFocus()"
(blur)="onInputBlur()"
(keydown.enter)="onEnter($event)"
(input)="onInputChange(); autoResizeTextarea()"
(dragenter)="onDragEnter($event)"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
placeholder="Type a message..."
class="chat-textarea w-full pl-3 pr-12 py-2 rounded-2xl border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
[class.border-primary]="dragActive()"
[class.border-dashed]="dragActive()"
[class.ctrl-resize]="ctrlHeld()"
></textarea>
<button
(click)="sendMessage()"
[disabled]="!messageContent.trim() && pendingFiles.length === 0"
class="send-btn absolute right-2 bottom-[15px] w-8 h-8 rounded-full bg-primary text-primary-foreground grid place-items-center hover:bg-primary/90 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
[class.visible]="inputHovered() || messageContent.trim().length > 0"
>
<ng-icon name="lucideSend" class="w-4 h-4" />
</button>
@if (dragActive()) {
<div class="pointer-events-none absolute inset-0 rounded-2xl border-2 border-primary border-dashed bg-primary/5 flex items-center justify-center">
<div class="text-sm text-muted-foreground">Drop files to attach</div>
</div>
}
@if (pendingFiles.length > 0) {
<div class="mt-2 flex flex-wrap gap-2">
@for (file of pendingFiles; track file.name) {
<div class="group flex items-center gap-2 px-2 py-1 rounded bg-secondary/60 border border-border">
<div class="text-xs font-medium truncate max-w-[14rem]">{{ file.name }}</div>
<div class="text-[10px] text-muted-foreground">{{ formatBytes(file.size) }}</div>
<button (click)="removePendingFile(file)" class="opacity-70 group-hover:opacity-100 text-[10px] bg-destructive/20 text-destructive rounded px-1 py-0.5">Remove</button>
</div>
}
</div>
}
</div>
</div>
</div>
<!-- Image Lightbox Modal -->
@if (lightboxAttachment()) {
<div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
(click)="closeLightbox()"
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!)"
(keydown.escape)="closeLightbox()"
tabindex="0"
#lightboxBackdrop
>
<div class="relative max-w-[90vw] max-h-[90vh]" (click)="$event.stopPropagation()">
<img
[src]="lightboxAttachment()!.objectUrl"
[alt]="lightboxAttachment()!.filename"
class="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!); $event.stopPropagation()"
/>
<!-- Top-right action bar -->
<div class="absolute top-3 right-3 flex gap-2">
<button
(click)="downloadAttachment(lightboxAttachment()!)"
class="p-2 bg-black/60 hover:bg-black/80 text-white rounded-lg backdrop-blur-sm transition-colors"
title="Download"
>
<ng-icon name="lucideDownload" class="w-5 h-5" />
</button>
<button
(click)="closeLightbox()"
class="p-2 bg-black/60 hover:bg-black/80 text-white rounded-lg backdrop-blur-sm transition-colors"
title="Close"
>
<ng-icon name="lucideX" class="w-5 h-5" />
</button>
</div>
<!-- Bottom info bar -->
<div class="absolute bottom-3 left-3 right-3 flex items-center justify-between">
<div class="px-3 py-1.5 bg-black/60 backdrop-blur-sm rounded-lg">
<span class="text-white text-sm">{{ lightboxAttachment()!.filename }}</span>
<span class="text-white/60 text-xs ml-2">{{ formatBytes(lightboxAttachment()!.size) }}</span>
</div>
</div>
</div>
</div>
}
<!-- Image Context Menu -->
@if (imageContextMenu()) {
<app-context-menu [x]="imageContextMenu()!.x" [y]="imageContextMenu()!.y" (closed)="closeImageContextMenu()">
<button (click)="copyImageToClipboard(imageContextMenu()!.attachment)" class="context-menu-item-icon">
<ng-icon name="lucideCopy" class="w-4 h-4 text-muted-foreground" />
Copy Image
</button>
<button (click)="downloadAttachment(imageContextMenu()!.attachment); closeImageContextMenu()" class="context-menu-item-icon">
<ng-icon name="lucideDownload" class="w-4 h-4 text-muted-foreground" />
Save Image
</button>
</app-context-menu>
}

View File

@@ -0,0 +1,223 @@
/* ── Chat layout: messages scroll behind input ──── */
.chat-layout {
display: flex;
flex-direction: column;
}
.chat-messages-scroll {
/* Fallback; dynamically overridden by updateScrollPadding() */
padding-bottom: 120px;
}
.chat-bottom-bar {
pointer-events: auto;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
background: hsl(var(--background) / 0.85);
/* Inset from right so the scrollbar track stays visible */
right: 8px;
}
/* Gradient fade-in above the bottom bar — blur only, no darkening */
.chat-bottom-fade {
height: 20px;
background: transparent;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
mask-image: linear-gradient(to bottom, transparent 0%, black 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 100%);
}
/* ── Chat textarea redesign ────────────────────── */
.chat-textarea {
--textarea-bg: hsl(40deg 3.7% 15.9% / 87%);
background: var(--textarea-bg);
/* Auto-resize: start at 62px, grow upward */
height: 62px;
min-height: 62px;
max-height: 520px;
overflow-y: hidden;
resize: none;
transition: height 0.12s ease;
/* Show manual resize handle only while Ctrl is held */
&.ctrl-resize {
resize: vertical;
}
}
/* Send button: hidden by default, fades in on wrapper hover */
.send-btn {
opacity: 0;
pointer-events: none;
transform: scale(0.85);
transition:
opacity 0.2s ease,
transform 0.2s ease;
&.visible {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
}
/* ── ngx-remark markdown styles ──────────────── */
.chat-markdown {
max-width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
font-size: 0.9375rem;
line-height: 1.5;
color: hsl(var(--foreground));
::ng-deep {
remark {
display: contents;
}
p {
margin: 0.25em 0;
}
strong {
font-weight: 700;
color: hsl(var(--foreground));
}
em {
font-style: italic;
}
del {
text-decoration: line-through;
opacity: 0.7;
}
a {
color: hsl(var(--primary));
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
h1, h2, h3, h4, h5, h6 {
font-weight: 700;
margin: 0.5em 0 0.25em;
color: hsl(var(--foreground));
}
h1 { font-size: 1.5em; }
h2 { font-size: 1.3em; }
h3 { font-size: 1.15em; }
ul, ol {
margin: 0.25em 0;
padding-left: 1.5em;
}
ul { list-style-type: disc; }
ol { list-style-type: decimal; }
li {
margin: 0.125em 0;
}
blockquote {
border-left: 3px solid hsl(var(--primary) / 0.5);
margin: 0.5em 0;
padding: 0.25em 0.75em;
color: hsl(var(--muted-foreground));
background: hsl(var(--secondary) / 0.3);
border-radius: 0 var(--radius) var(--radius) 0;
}
code {
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
font-size: 0.875em;
background: hsl(var(--secondary));
padding: 0.15em 0.35em;
border-radius: 4px;
white-space: pre-wrap;
word-break: break-word;
}
pre {
overflow-x: auto;
max-width: 100%;
margin: 0.5em 0;
padding: 0.75em 1em;
background: hsl(var(--secondary));
border-radius: var(--radius);
border: 1px solid hsl(var(--border));
code {
background: transparent;
padding: 0;
border-radius: 0;
white-space: pre;
word-break: normal;
}
}
hr {
border: none;
border-top: 1px solid hsl(var(--border));
margin: 0.75em 0;
}
table {
border-collapse: collapse;
margin: 0.5em 0;
font-size: 0.875em;
width: auto;
max-width: 100%;
overflow-x: auto;
display: block;
}
th, td {
border: 1px solid hsl(var(--border));
padding: 0.35em 0.75em;
text-align: left;
}
th {
background: hsl(var(--secondary));
font-weight: 600;
}
img {
max-width: 100%;
height: auto;
max-height: 320px;
border-radius: var(--radius);
display: block;
}
// Ensure consecutive paragraphs have minimal spacing for chat feel
p + p {
margin-top: 0.25em;
}
// Mermaid diagrams: prevent SVG from blocking clicks on the rest of the app
remark-mermaid {
display: block;
overflow-x: auto;
max-width: 100%;
svg {
pointer-events: none;
max-width: 100%;
height: auto;
}
}
}
}

View File

@@ -0,0 +1,996 @@
import { Component, inject, signal, computed, effect, ElementRef, ViewChild, AfterViewChecked, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AttachmentService, Attachment } from '../../../core/services/attachment.service';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideSend,
lucideSmile,
lucideEdit,
lucideTrash2,
lucideReply,
lucideMoreVertical,
lucideCheck,
lucideX,
lucideDownload,
lucideExpand,
lucideImage,
lucideCopy,
} from '@ng-icons/lucide';
import { MessagesActions } from '../../../store/messages/messages.actions';
import { selectAllMessages, selectMessagesLoading, selectMessagesSyncing } from '../../../store/messages/messages.selectors';
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors';
import { Message } from '../../../core/models';
import { WebRTCService } from '../../../core/services/webrtc.service';
import { Subscription } from 'rxjs';
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
import { ContextMenuComponent, UserAvatarComponent } from '../../../shared';
import { TypingIndicatorComponent } from '../typing-indicator/typing-indicator.component';
import { RemarkModule, MermaidComponent } from 'ngx-remark';
import remarkGfm from 'remark-gfm';
import remarkBreaks from 'remark-breaks';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥', '👀'];
@Component({
selector: 'app-chat-messages',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon, ContextMenuComponent, UserAvatarComponent, TypingIndicatorComponent, RemarkModule, MermaidComponent],
viewProviders: [
provideIcons({
lucideSend,
lucideSmile,
lucideEdit,
lucideTrash2,
lucideReply,
lucideMoreVertical,
lucideCheck,
lucideX,
lucideDownload,
lucideExpand,
lucideImage,
lucideCopy,
}),
],
templateUrl: './chat-messages.component.html',
styleUrls: ['./chat-messages.component.scss'],
// eslint-disable-next-line @angular-eslint/no-host-metadata-property
host: {
'(document:keydown)': 'onDocKeydown($event)',
'(document:keyup)': 'onDocKeyup($event)',
},
})
/**
* Real-time chat messages view with infinite scroll, markdown rendering,
* emoji reactions, file attachments, and image lightbox support.
*/
export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestroy {
@ViewChild('messagesContainer') messagesContainer!: ElementRef;
@ViewChild('messageInputRef') messageInputRef!: ElementRef<HTMLTextAreaElement>;
@ViewChild('bottomBar') bottomBar!: ElementRef;
private store = inject(Store);
private webrtc = inject(WebRTCService);
private serverDirectory = inject(ServerDirectoryService);
private attachmentsSvc = inject(AttachmentService);
private cdr = inject(ChangeDetectorRef);
/** Remark processor with GFM (tables, strikethrough, etc.) and line-break support */
remarkProcessor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkBreaks) as any;
private allMessages = this.store.selectSignal(selectAllMessages);
private activeChannelId = this.store.selectSignal(selectActiveChannelId);
// --- Infinite scroll (upwards) pagination ---
private readonly PAGE_SIZE = 50;
displayLimit = signal(this.PAGE_SIZE);
loadingMore = signal(false);
/** All messages for the current channel (full list, unsliced) */
private allChannelMessages = computed(() => {
const channelId = this.activeChannelId();
const roomId = this.currentRoom()?.id;
return this.allMessages().filter(message =>
message.roomId === roomId && (message.channelId || 'general') === channelId
);
});
/** Paginated view — only the most recent `displayLimit` messages */
messages = computed(() => {
const all = this.allChannelMessages();
const limit = this.displayLimit();
if (all.length <= limit) return all;
return all.slice(all.length - limit);
});
/** Whether there are older messages that can be loaded */
hasMoreMessages = computed(() => this.allChannelMessages().length > this.displayLimit());
loading = this.store.selectSignal(selectMessagesLoading);
syncing = this.store.selectSignal(selectMessagesSyncing);
currentUser = this.store.selectSignal(selectCurrentUser);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
private currentRoom = this.store.selectSignal(selectCurrentRoom);
messageContent = '';
editContent = '';
editingMessageId = signal<string | null>(null);
replyTo = signal<Message | null>(null);
showEmojiPicker = signal<string | null>(null);
readonly commonEmojis = COMMON_EMOJIS;
private shouldScrollToBottom = true;
/** Keeps us pinned to bottom while images/attachments load after initial open */
private initialScrollObserver: MutationObserver | null = null;
private initialScrollTimer: any = null;
private boundOnImageLoad: (() => void) | null = null;
/** True while a programmatic scroll-to-bottom is in progress (suppresses onScroll). */
private isAutoScrolling = false;
private lastTypingSentAt = 0;
private lastMessageCount = 0;
private initialScrollPending = true;
pendingFiles: File[] = [];
// New messages snackbar state
showNewMessagesBar = signal(false);
// Plain (non-reactive) reference time used only by formatTimestamp.
// Updated periodically but NOT a signal, so it won't re-render every message.
private nowRef = Date.now();
private nowTimer: any;
toolbarVisible = signal(false);
private toolbarHovering = false;
inlineCodeToken = '`';
dragActive = signal(false);
inputHovered = signal(false);
ctrlHeld = signal(false);
private boundCtrlDown: ((e: KeyboardEvent) => void) | null = null;
private boundCtrlUp: ((e: KeyboardEvent) => void) | null = null;
// Image lightbox modal state
lightboxAttachment = signal<Attachment | null>(null);
// Image right-click context menu state
imageContextMenu = signal<{ x: number; y: number; attachment: Attachment } | null>(null);
private boundOnKeydown: ((event: KeyboardEvent) => void) | null = null;
// Reset scroll state when room/server changes (handles reuse of component on navigation)
private onRoomChanged = effect(() => {
void this.currentRoom(); // track room signal
this.initialScrollPending = true;
this.stopInitialScrollWatch();
this.showNewMessagesBar.set(false);
this.lastMessageCount = 0;
this.displayLimit.set(this.PAGE_SIZE);
});
// Reset pagination when switching channels within the same room
private onChannelChanged = effect(() => {
void this.activeChannelId(); // track channel signal
this.displayLimit.set(this.PAGE_SIZE);
this.initialScrollPending = true;
this.showNewMessagesBar.set(false);
this.lastMessageCount = 0;
});
// Re-render when attachments update (e.g. download progress from WebRTC callbacks)
private attachmentsUpdatedEffect = effect(() => {
void this.attachmentsSvc.updated();
this.cdr.markForCheck();
});
// Track total channel messages (not paginated) for new-message detection
private totalChannelMessagesLength = computed(() => this.allChannelMessages().length);
messagesLength = computed(() => this.messages().length);
private onMessagesChanged = effect(() => {
const currentCount = this.totalChannelMessagesLength();
const el = this.messagesContainer?.nativeElement;
if (!el) {
this.lastMessageCount = currentCount;
return;
}
// Skip during initial scroll setup
if (this.initialScrollPending) {
this.lastMessageCount = currentCount;
return;
}
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
const newMessages = currentCount > this.lastMessageCount;
if (newMessages) {
if (distanceFromBottom <= 300) {
// Smooth auto-scroll only when near bottom; schedule after render
this.scheduleScrollToBottomSmooth();
this.showNewMessagesBar.set(false);
} else {
// Schedule snackbar update to avoid blocking change detection
queueMicrotask(() => this.showNewMessagesBar.set(true));
}
}
this.lastMessageCount = currentCount;
});
ngAfterViewChecked(): void {
const el = this.messagesContainer?.nativeElement;
if (!el) return;
// First render after connect: scroll to bottom instantly (no animation)
// Only proceed once messages are actually rendered in the DOM
if (this.initialScrollPending) {
if (this.messages().length > 0) {
this.initialScrollPending = false;
// Snap to bottom immediately, then keep watching for late layout changes
this.isAutoScrolling = true;
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => { this.isAutoScrolling = false; });
this.startInitialScrollWatch();
this.showNewMessagesBar.set(false);
this.lastMessageCount = this.messages().length;
} else if (!this.loading()) {
// Room has no messages and loading is done
this.initialScrollPending = false;
this.lastMessageCount = 0;
}
return;
}
this.updateScrollPadding();
}
ngOnInit(): void {
// Initialize message count for snackbar trigger
this.lastMessageCount = this.messages().length;
// Update reference time silently (non-reactive) so formatTimestamp
// uses a reasonably fresh "now" without re-rendering every message.
this.nowTimer = setInterval(() => {
this.nowRef = Date.now();
}, 60000);
// Global Escape key listener for lightbox & context menu
this.boundOnKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (this.imageContextMenu()) { this.closeImageContextMenu(); return; }
if (this.lightboxAttachment()) { this.closeLightbox(); return; }
}
};
document.addEventListener('keydown', this.boundOnKeydown);
}
ngOnDestroy(): void {
this.stopInitialScrollWatch();
if (this.nowTimer) {
clearInterval(this.nowTimer);
this.nowTimer = null;
}
if (this.boundOnKeydown) {
document.removeEventListener('keydown', this.boundOnKeydown);
}
}
/** Send the current message content (with optional attachments) and reset the input. */
sendMessage(): void {
const raw = this.messageContent.trim();
if (!raw && this.pendingFiles.length === 0) return;
const content = this.appendImageMarkdown(raw);
this.store.dispatch(
MessagesActions.sendMessage({
content,
replyToId: this.replyTo()?.id,
channelId: this.activeChannelId(),
})
);
this.messageContent = '';
this.clearReply();
this.shouldScrollToBottom = true;
// Reset textarea height after sending
requestAnimationFrame(() => this.autoResizeTextarea());
this.showNewMessagesBar.set(false);
if (this.pendingFiles.length > 0) {
// Wait briefly for the message to appear in the list, then attach
setTimeout(() => this.attachFilesToLastOwnMessage(content), 100);
}
}
/** Throttle and broadcast a typing indicator when the user types. */
onInputChange(): void {
const now = Date.now();
if (now - this.lastTypingSentAt > 1000) { // throttle typing events
try {
this.webrtc.sendRawMessage({ type: 'typing' });
this.lastTypingSentAt = now;
} catch {}
}
}
/** Begin editing an existing message, populating the edit input. */
startEdit(message: Message): void {
this.editingMessageId.set(message.id);
this.editContent = message.content;
}
/** Save the edited message content and exit edit mode. */
saveEdit(messageId: string): void {
if (!this.editContent.trim()) return;
this.store.dispatch(
MessagesActions.editMessage({
messageId,
content: this.editContent.trim(),
})
);
this.cancelEdit();
}
/** Cancel the current edit and clear the edit state. */
cancelEdit(): void {
this.editingMessageId.set(null);
this.editContent = '';
}
/** Delete a message (own or admin-delete if the user has admin privileges). */
deleteMessage(message: Message): void {
if (this.isOwnMessage(message)) {
this.store.dispatch(MessagesActions.deleteMessage({ messageId: message.id }));
} else if (this.isAdmin()) {
this.store.dispatch(MessagesActions.adminDeleteMessage({ messageId: message.id }));
}
}
/** Set the message to reply to. */
setReplyTo(message: Message): void {
this.replyTo.set(message);
}
/** Clear the current reply-to reference. */
clearReply(): void {
this.replyTo.set(null);
}
/** Find the original message that a reply references. */
getRepliedMessage(messageId: string): Message | undefined {
return this.allMessages().find(message => message.id === messageId);
}
/** Smooth-scroll to a specific message element and briefly highlight it. */
scrollToMessage(messageId: string): void {
const container = this.messagesContainer?.nativeElement;
if (!container) return;
const el = container.querySelector(`[data-message-id="${messageId}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('bg-primary/10');
setTimeout(() => el.classList.remove('bg-primary/10'), 2000);
}
}
/** Toggle the emoji picker for a message. */
toggleEmojiPicker(messageId: string): void {
this.showEmojiPicker.update((current) =>
current === messageId ? null : messageId
);
}
/** Add a reaction emoji to a message. */
addReaction(messageId: string, emoji: string): void {
this.store.dispatch(MessagesActions.addReaction({ messageId, emoji }));
this.showEmojiPicker.set(null);
}
/** Toggle the reaction for the current user on a message. */
toggleReaction(messageId: string, emoji: string): void {
const message = this.messages().find((msg) => msg.id === messageId);
const currentUserId = this.currentUser()?.id;
if (!message || !currentUserId) return;
const hasReacted = message.reactions.some(
(reaction) => reaction.emoji === emoji && reaction.userId === currentUserId
);
if (hasReacted) {
this.store.dispatch(MessagesActions.removeReaction({ messageId, emoji }));
} else {
this.store.dispatch(MessagesActions.addReaction({ messageId, emoji }));
}
}
/** Check whether a message was sent by the current user. */
isOwnMessage(message: Message): boolean {
return message.senderId === this.currentUser()?.id;
}
/** Aggregate reactions by emoji, returning counts and whether the current user reacted. */
getGroupedReactions(message: Message): { emoji: string; count: number; hasCurrentUser: boolean }[] {
const groups = new Map<string, { count: number; hasCurrentUser: boolean }>();
const currentUserId = this.currentUser()?.id;
message.reactions.forEach((reaction) => {
const existing = groups.get(reaction.emoji) || { count: 0, hasCurrentUser: false };
groups.set(reaction.emoji, {
count: existing.count + 1,
hasCurrentUser: existing.hasCurrentUser || reaction.userId === currentUserId,
});
});
return Array.from(groups.entries()).map(([emoji, data]) => ({
emoji,
...data,
}));
}
/** Format a timestamp as a relative or absolute time string. */
formatTimestamp(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date(this.nowRef);
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
// Compare calendar days (midnight-aligned) to avoid NG0100 flicker
const toDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
if (dayDiff === 0) {
return time;
} else if (dayDiff === 1) {
return 'Yesterday ' + time;
} else if (dayDiff < 7) {
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time;
}
}
private scrollToBottom(): void {
if (this.messagesContainer) {
const el = this.messagesContainer.nativeElement;
el.scrollTop = el.scrollHeight;
this.shouldScrollToBottom = false;
}
}
/**
* Start observing the messages container for DOM mutations
* and image load events. Every time the container's content
* changes size (new nodes, images finishing load) we instantly
* snap to the bottom. Automatically stops after a timeout or
* when the user scrolls up.
*/
private startInitialScrollWatch(): void {
this.stopInitialScrollWatch(); // clean up any prior watcher
const el = this.messagesContainer?.nativeElement;
if (!el) return;
const snap = () => {
if (this.messagesContainer) {
const e = this.messagesContainer.nativeElement;
this.isAutoScrolling = true;
e.scrollTop = e.scrollHeight;
// Clear flag after browser fires the synchronous scroll event
requestAnimationFrame(() => { this.isAutoScrolling = false; });
}
};
// 1. MutationObserver catches new DOM nodes (attachments rendered, etc.)
this.initialScrollObserver = new MutationObserver(() => {
requestAnimationFrame(snap);
});
this.initialScrollObserver.observe(el, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src'], // img src swaps
});
// 2. Capture-phase 'load' listener catches images finishing load
this.boundOnImageLoad = () => requestAnimationFrame(snap);
el.addEventListener('load', this.boundOnImageLoad, true);
// 3. Auto-stop after 5s so we don't fight user scrolling
this.initialScrollTimer = setTimeout(() => this.stopInitialScrollWatch(), 5000);
}
private stopInitialScrollWatch(): void {
if (this.initialScrollObserver) {
this.initialScrollObserver.disconnect();
this.initialScrollObserver = null;
}
if (this.boundOnImageLoad && this.messagesContainer) {
this.messagesContainer.nativeElement.removeEventListener('load', this.boundOnImageLoad, true);
this.boundOnImageLoad = null;
}
if (this.initialScrollTimer) {
clearTimeout(this.initialScrollTimer);
this.initialScrollTimer = null;
}
}
private scrollToBottomSmooth(): void {
if (this.messagesContainer) {
const el = this.messagesContainer.nativeElement;
try {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
} catch {
// Fallback if smooth not supported
el.scrollTop = el.scrollHeight;
}
this.shouldScrollToBottom = false;
}
}
private scheduleScrollToBottomSmooth(): void {
// Use double rAF to ensure DOM updated and layout computed before scrolling
requestAnimationFrame(() => {
requestAnimationFrame(() => this.scrollToBottomSmooth());
});
}
/** Handle scroll events: toggle auto-scroll, dismiss snackbar, and trigger infinite scroll. */
onScroll(): void {
if (!this.messagesContainer) return;
// Ignore scroll events caused by programmatic snap-to-bottom
if (this.isAutoScrolling) return;
const el = this.messagesContainer.nativeElement;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
this.shouldScrollToBottom = distanceFromBottom <= 300;
if (this.shouldScrollToBottom) {
this.showNewMessagesBar.set(false);
}
// Any user-initiated scroll during the initial load period
// immediately hands control back to the user
if (this.initialScrollObserver) {
this.stopInitialScrollWatch();
}
// Infinite scroll upwards — load older messages when near the top
if (el.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
this.loadMore();
}
}
/** Load older messages by expanding the display window, preserving scroll position */
loadMore(): void {
if (this.loadingMore() || !this.hasMoreMessages()) return;
this.loadingMore.set(true);
const el = this.messagesContainer?.nativeElement;
const prevScrollHeight = el?.scrollHeight ?? 0;
this.displayLimit.update(limit => limit + this.PAGE_SIZE);
// After Angular renders the new messages, restore scroll position
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (el) {
const newScrollHeight = el.scrollHeight;
el.scrollTop += newScrollHeight - prevScrollHeight;
}
this.loadingMore.set(false);
});
});
}
// Markdown toolbar actions
/** Handle keyboard events in the message input (Enter to send, Shift+Enter for newline). */
onEnter(evt: Event): void {
const keyEvent = evt as KeyboardEvent;
if (keyEvent.shiftKey) {
// allow newline
return;
}
keyEvent.preventDefault();
this.sendMessage();
}
private getSelection(): { start: number; end: number } {
const el = this.messageInputRef?.nativeElement;
return { start: el?.selectionStart ?? this.messageContent.length, end: el?.selectionEnd ?? this.messageContent.length };
}
private setSelection(start: number, end: number): void {
const el = this.messageInputRef?.nativeElement;
if (el) {
el.selectionStart = start;
el.selectionEnd = end;
el.focus();
}
}
/** Wrap selected text in an inline markdown token (bold, italic, etc.). */
applyInline(token: string): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'text';
const after = this.messageContent.slice(end);
const newText = `${before}${token}${selected}${token}${after}`;
this.messageContent = newText;
const cursor = before.length + token.length + selected.length + token.length;
this.setSelection(cursor, cursor);
}
/** Prepend each selected line with a markdown prefix (e.g. `- ` for lists). */
applyPrefix(prefix: string): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'text';
const after = this.messageContent.slice(end);
const lines = selected.split('\n').map(line => `${prefix}${line}`);
const newSelected = lines.join('\n');
const newText = `${before}${newSelected}${after}`;
this.messageContent = newText;
const cursor = before.length + newSelected.length;
this.setSelection(cursor, cursor);
}
/** Insert a markdown heading at the given level around the current selection. */
applyHeading(level: number): void {
const hashes = '#'.repeat(Math.max(1, Math.min(6, level)));
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'Heading';
const after = this.messageContent.slice(end);
const needsLeadingNewline = before.length > 0 && !before.endsWith('\n');
const needsTrailingNewline = after.length > 0 && !after.startsWith('\n');
const block = `${needsLeadingNewline ? '\n' : ''}${hashes} ${selected}${needsTrailingNewline ? '\n' : ''}`;
const newText = `${before}${block}${after}`;
this.messageContent = newText;
const cursor = before.length + block.length;
this.setSelection(cursor, cursor);
}
/** Convert selected lines into a numbered markdown list. */
applyOrderedList(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'item\nitem';
const after = this.messageContent.slice(end);
const lines = selected.split('\n').map((line, index) => `${index + 1}. ${line}`);
const newSelected = lines.join('\n');
const newText = `${before}${newSelected}${after}`;
this.messageContent = newText;
const cursor = before.length + newSelected.length;
this.setSelection(cursor, cursor);
}
/** Wrap the selection in a fenced markdown code block. */
applyCodeBlock(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'code';
const after = this.messageContent.slice(end);
const fenced = `\n\n\`\`\`\n${selected}\n\`\`\`\n\n`;
const newText = `${before}${fenced}${after}`;
this.messageContent = newText;
const cursor = before.length + fenced.length;
this.setSelection(cursor, cursor);
}
/** Insert a markdown link around the current selection. */
applyLink(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'link';
const after = this.messageContent.slice(end);
const link = `[${selected}](https://)`;
const newText = `${before}${link}${after}`;
this.messageContent = newText;
const cursorStart = before.length + link.length - 1; // position inside url
this.setSelection(cursorStart - 8, cursorStart - 1);
}
/** Insert a markdown image embed around the current selection. */
applyImage(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'alt';
const after = this.messageContent.slice(end);
const img = `![${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<void> {
this.closeImageContextMenu();
if (!att.objectUrl) return;
try {
const resp = await fetch(att.objectUrl);
const blob = await resp.blob();
// Convert to PNG for clipboard compatibility
const pngBlob = await this.convertToPng(blob);
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': pngBlob }),
]);
} catch (_error) {
// Failed to copy image to clipboard
}
}
private convertToPng(blob: Blob): Promise<Blob> {
return new Promise((resolve, reject) => {
if (blob.type === 'image/png') {
resolve(blob);
return;
}
const img = new Image();
const url = URL.createObjectURL(blob);
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
if (!ctx) { reject(new Error('Canvas not supported')); return; }
ctx.drawImage(img, 0, 0);
canvas.toBlob((pngBlob) => {
URL.revokeObjectURL(url);
if (pngBlob) resolve(pngBlob);
else reject(new Error('PNG conversion failed'));
}, 'image/png');
};
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Image load failed')); };
img.src = url;
});
}
/** Retry fetching an image from any available peer. */
retryImageRequest(att: Attachment, messageId: string): void {
this.attachmentsSvc.requestImageFromAnyPeer(messageId, att);
}
private attachFilesToLastOwnMessage(content: string): void {
const me = this.currentUser()?.id;
if (!me) return;
const msg = [...this.messages()].reverse().find((message) => message.senderId === me && message.content === content && !message.isDeleted);
if (!msg) {
// Retry shortly until message appears
setTimeout(() => this.attachFilesToLastOwnMessage(content), 150);
return;
}
const uploaderPeerId = this.currentUser()?.id || undefined;
this.attachmentsSvc.publishAttachments(msg.id, this.pendingFiles, uploaderPeerId);
this.pendingFiles = [];
}
// Detect image URLs and append Markdown embeds at the end
private appendImageMarkdown(content: string): string {
const imageUrlRegex = /(https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg|bmp|tiff)(?:\?[^\s)]*)?)/ig;
const urls = new Set<string>();
let match: RegExpExecArray | null;
const text = content;
while ((match = imageUrlRegex.exec(text)) !== null) {
urls.add(match[1]);
}
if (urls.size === 0) return content;
let append = '';
for (const url of urls) {
// Skip if already embedded as a Markdown image
const alreadyEmbedded = new RegExp(`!\\[[^\\]]*\\\\]\\(\s*${this.escapeRegex(url)}\s*\\)`, 'i').test(text);
if (!alreadyEmbedded) {
append += `\n![](${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);
}
}

View File

@@ -0,0 +1,12 @@
@if (typingDisplay().length > 0) {
<div class="px-4 py-2 backdrop-blur-sm bg-background/60">
<span class="inline-block px-3 py-1 rounded-full text-sm text-muted-foreground">
{{ typingDisplay().join(', ') }}
@if (typingOthersCount() > 0) {
and {{ typingOthersCount() }} others are typing...
} @else {
{{ typingDisplay().length === 1 ? 'is' : 'are' }} typing...
}
</span>
</div>
}

View File

@@ -0,0 +1,67 @@
import { Component, inject, signal, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { WebRTCService } from '../../../core/services/webrtc.service';
import { merge, interval, filter, map, tap } from 'rxjs';
const TYPING_TTL = 3_000;
const PURGE_INTERVAL = 1_000;
const MAX_SHOWN = 4;
@Component({
selector: 'app-typing-indicator',
standalone: true,
templateUrl: './typing-indicator.component.html',
host: {
'class': 'block',
'style': 'background: linear-gradient(to bottom, transparent, hsl(var(--background)));',
},
})
export class TypingIndicatorComponent {
private readonly typingMap = new Map<string, { name: string; expiresAt: number }>();
typingDisplay = signal<string[]>([]);
typingOthersCount = signal<number>(0);
constructor() {
const webrtc = inject(WebRTCService);
const destroyRef = inject(DestroyRef);
const typing$ = webrtc.onSignalingMessage.pipe(
filter((msg: any) => msg?.type === 'user_typing' && msg.displayName && msg.oderId),
tap((msg: any) => {
const now = Date.now();
this.typingMap.set(String(msg.oderId), {
name: String(msg.displayName),
expiresAt: now + TYPING_TTL,
});
}),
);
const purge$ = interval(PURGE_INTERVAL).pipe(
map(() => Date.now()),
filter((now) => {
let changed = false;
for (const [key, entry] of this.typingMap) {
if (entry.expiresAt <= now) {
this.typingMap.delete(key);
changed = true;
}
}
return changed;
}),
);
merge(typing$, purge$)
.pipe(takeUntilDestroyed(destroyRef))
.subscribe(() => this.recomputeDisplay());
}
private recomputeDisplay(): void {
const now = Date.now();
const names = Array.from(this.typingMap.values())
.filter((e) => e.expiresAt > now)
.map((e) => e.name);
this.typingDisplay.set(names.slice(0, MAX_SHOWN));
this.typingOthersCount.set(Math.max(0, names.length - MAX_SHOWN));
}
}

View File

@@ -1,276 +0,0 @@
import { Component, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideMic,
lucideMicOff,
lucideMonitor,
lucideShield,
lucideCrown,
lucideMoreVertical,
lucideBan,
lucideUserX,
lucideVolume2,
lucideVolumeX,
} from '@ng-icons/lucide';
import * as UsersActions from '../../store/users/users.actions';
import {
selectOnlineUsers,
selectCurrentUser,
selectIsCurrentUserAdmin,
} from '../../store/users/users.selectors';
import { User } from '../../core/models';
@Component({
selector: 'app-user-list',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon],
viewProviders: [
provideIcons({
lucideMic,
lucideMicOff,
lucideMonitor,
lucideShield,
lucideCrown,
lucideMoreVertical,
lucideBan,
lucideUserX,
lucideVolume2,
lucideVolumeX,
}),
],
template: `
<div class="h-full flex flex-col bg-card border-l border-border">
<!-- Header -->
<div class="p-4 border-b border-border">
<h3 class="font-semibold text-foreground">Members</h3>
<p class="text-xs text-muted-foreground">{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice</p>
@if (voiceUsers().length > 0) {
<div class="mt-2 flex flex-wrap gap-2">
@for (v of voiceUsers(); track v.id) {
<span class="px-2 py-1 text-xs rounded bg-secondary text-foreground flex items-center gap-1">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span>
{{ v.displayName }}
</span>
}
</div>
}
</div>
<!-- User List -->
<div class="flex-1 overflow-y-auto p-2 space-y-1">
@for (user of onlineUsers(); track user.id) {
<div
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
(click)="toggleUserMenu(user.id)"
>
<!-- Avatar with online indicator -->
<div class="relative">
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold text-sm">
{{ user.displayName.charAt(0).toUpperCase() }}
</div>
<span class="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-card"
[class.bg-green-500]="user.isOnline !== false && user.status !== 'offline'"
[class.bg-gray-500]="user.isOnline === false || user.status === 'offline'"
></span>
</div>
<!-- User Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1">
<span class="font-medium text-sm text-foreground truncate">
{{ user.displayName }}
</span>
@if (user.isAdmin) {
<ng-icon name="lucideShield" class="w-3 h-3 text-primary" />
}
@if (user.isRoomOwner) {
<ng-icon name="lucideCrown" class="w-3 h-3 text-yellow-500" />
}
</div>
</div>
<!-- Voice/Screen Status -->
<div class="flex items-center gap-1">
@if (user.voiceState?.isSpeaking) {
<ng-icon name="lucideMic" class="w-4 h-4 text-green-500 animate-pulse" />
} @else if (user.voiceState?.isMuted) {
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
} @else if (user.voiceState?.isConnected) {
<ng-icon name="lucideMic" class="w-4 h-4 text-muted-foreground" />
}
@if (user.screenShareState?.isSharing) {
<ng-icon name="lucideMonitor" class="w-4 h-4 text-primary" />
}
</div>
<!-- User Menu -->
@if (showUserMenu() === user.id && isAdmin() && !isCurrentUser(user)) {
<div
class="absolute right-0 top-full mt-1 z-10 w-48 bg-card border border-border rounded-lg shadow-lg py-1"
(click)="$event.stopPropagation()"
>
@if (user.voiceState?.isConnected) {
<button
(click)="muteUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2"
>
@if (user.voiceState?.isMutedByAdmin) {
<ng-icon name="lucideVolume2" class="w-4 h-4" />
<span>Unmute</span>
} @else {
<ng-icon name="lucideVolumeX" class="w-4 h-4" />
<span>Mute</span>
}
</button>
}
<button
(click)="kickUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2 text-yellow-500"
>
<ng-icon name="lucideUserX" class="w-4 h-4" />
<span>Kick</span>
</button>
<button
(click)="banUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-destructive/10 flex items-center gap-2 text-destructive"
>
<ng-icon name="lucideBan" class="w-4 h-4" />
<span>Ban</span>
</button>
</div>
}
</div>
}
@if (onlineUsers().length === 0) {
<div class="text-center py-8 text-muted-foreground text-sm">
No users online
</div>
}
</div>
</div>
<!-- Ban Dialog -->
@if (showBanDialog()) {
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="closeBanDialog()">
<div class="bg-card border border-border rounded-lg p-6 w-96 max-w-[90vw]" (click)="$event.stopPropagation()">
<h3 class="text-lg font-semibold text-foreground mb-4">Ban User</h3>
<p class="text-sm text-muted-foreground mb-4">
Are you sure you want to ban <span class="font-semibold text-foreground">{{ userToBan()?.displayName }}</span>?
</p>
<div class="mb-4">
<label class="block text-sm font-medium text-foreground mb-1">Reason (optional)</label>
<input
type="text"
[(ngModel)]="banReason"
placeholder="Enter ban reason..."
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-foreground mb-1">Duration</label>
<select
[(ngModel)]="banDuration"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="3600000">1 hour</option>
<option value="86400000">1 day</option>
<option value="604800000">1 week</option>
<option value="2592000000">30 days</option>
<option value="0">Permanent</option>
</select>
</div>
<div class="flex gap-2 justify-end">
<button
(click)="closeBanDialog()"
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors"
>
Cancel
</button>
<button
(click)="confirmBan()"
class="px-4 py-2 bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
>
Ban User
</button>
</div>
</div>
</div>
}
`,
})
export class UserListComponent {
private store = inject(Store);
onlineUsers = this.store.selectSignal(selectOnlineUsers);
voiceUsers = computed(() => this.onlineUsers().filter(u => !!u.voiceState?.isConnected));
currentUser = this.store.selectSignal(selectCurrentUser);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
showUserMenu = signal<string | null>(null);
showBanDialog = signal(false);
userToBan = signal<User | null>(null);
banReason = '';
banDuration = '86400000'; // Default 1 day
toggleUserMenu(userId: string): void {
this.showUserMenu.update((current) => (current === userId ? null : userId));
}
isCurrentUser(user: User): boolean {
return user.id === this.currentUser()?.id;
}
muteUser(user: User): void {
if (user.voiceState?.isMutedByAdmin) {
this.store.dispatch(UsersActions.adminUnmuteUser({ userId: user.id }));
} else {
this.store.dispatch(UsersActions.adminMuteUser({ userId: user.id }));
}
this.showUserMenu.set(null);
}
kickUser(user: User): void {
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
this.showUserMenu.set(null);
}
banUser(user: User): void {
this.userToBan.set(user);
this.showBanDialog.set(true);
this.showUserMenu.set(null);
}
closeBanDialog(): void {
this.showBanDialog.set(false);
this.userToBan.set(null);
this.banReason = '';
this.banDuration = '86400000';
}
confirmBan(): void {
const user = this.userToBan();
if (!user) return;
const duration = parseInt(this.banDuration, 10);
const expiresAt = duration === 0 ? undefined : Date.now() + duration;
this.store.dispatch(
UsersActions.banUser({
userId: user.id,
reason: this.banReason || undefined,
expiresAt,
})
);
this.closeBanDialog();
}
}

View File

@@ -0,0 +1,147 @@
<!-- Header -->
<div class="p-4 border-b border-border">
<h3 class="font-semibold text-foreground">Members</h3>
<p class="text-xs text-muted-foreground">{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice</p>
@if (voiceUsers().length > 0) {
<div class="mt-2 flex flex-wrap gap-2">
@for (v of voiceUsers(); track v.id) {
<span class="px-2 py-1 text-xs rounded bg-secondary text-foreground flex items-center gap-1">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span>
{{ v.displayName }}
</span>
}
</div>
}
</div>
<!-- User List -->
<div class="flex-1 overflow-y-auto p-2 space-y-1">
@for (user of onlineUsers(); track user.id) {
<div
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
(click)="toggleUserMenu(user.id)"
>
<!-- Avatar with online indicator -->
<div class="relative">
<app-user-avatar [name]="user.displayName" size="sm" />
<span class="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-card"
[class.bg-green-500]="user.isOnline !== false && user.status !== 'offline'"
[class.bg-gray-500]="user.isOnline === false || user.status === 'offline'"
></span>
</div>
<!-- User Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1">
<span class="font-medium text-sm text-foreground truncate">
{{ user.displayName }}
</span>
@if (user.isAdmin) {
<ng-icon name="lucideShield" class="w-3 h-3 text-primary" />
}
@if (user.isRoomOwner) {
<ng-icon name="lucideCrown" class="w-3 h-3 text-yellow-500" />
}
</div>
</div>
<!-- Voice/Screen Status -->
<div class="flex items-center gap-1">
@if (user.voiceState?.isSpeaking) {
<ng-icon name="lucideMic" class="w-4 h-4 text-green-500 animate-pulse" />
} @else if (user.voiceState?.isMuted) {
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
} @else if (user.voiceState?.isConnected) {
<ng-icon name="lucideMic" class="w-4 h-4 text-muted-foreground" />
}
@if (user.screenShareState?.isSharing) {
<ng-icon name="lucideMonitor" class="w-4 h-4 text-primary" />
}
</div>
<!-- User Menu -->
@if (showUserMenu() === user.id && isAdmin() && !isCurrentUser(user)) {
<div
class="absolute right-0 top-full mt-1 z-10 w-48 bg-card border border-border rounded-lg shadow-lg py-1"
(click)="$event.stopPropagation()"
>
@if (user.voiceState?.isConnected) {
<button
(click)="muteUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2"
>
@if (user.voiceState?.isMutedByAdmin) {
<ng-icon name="lucideVolume2" class="w-4 h-4" />
<span>Unmute</span>
} @else {
<ng-icon name="lucideVolumeX" class="w-4 h-4" />
<span>Mute</span>
}
</button>
}
<button
(click)="kickUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2 text-yellow-500"
>
<ng-icon name="lucideUserX" class="w-4 h-4" />
<span>Kick</span>
</button>
<button
(click)="banUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-destructive/10 flex items-center gap-2 text-destructive"
>
<ng-icon name="lucideBan" class="w-4 h-4" />
<span>Ban</span>
</button>
</div>
}
</div>
}
@if (onlineUsers().length === 0) {
<div class="text-center py-8 text-muted-foreground text-sm">
No users online
</div>
}
</div>
<!-- Ban Dialog -->
@if (showBanDialog()) {
<app-confirm-dialog
title="Ban User"
confirmLabel="Ban User"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="confirmBan()"
(cancelled)="closeBanDialog()"
>
<p class="mb-4">
Are you sure you want to ban <span class="font-semibold text-foreground">{{ userToBan()?.displayName }}</span>?
</p>
<div class="mb-4">
<label class="block text-sm font-medium text-foreground mb-1">Reason (optional)</label>
<input
type="text"
[(ngModel)]="banReason"
placeholder="Enter ban reason..."
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-1">Duration</label>
<select
[(ngModel)]="banDuration"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="3600000">1 hour</option>
<option value="86400000">1 day</option>
<option value="604800000">1 week</option>
<option value="2592000000">30 days</option>
<option value="0">Permanent</option>
</select>
</div>
</app-confirm-dialog>
}

View File

@@ -0,0 +1,124 @@
import { Component, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideMic,
lucideMicOff,
lucideMonitor,
lucideShield,
lucideCrown,
lucideMoreVertical,
lucideBan,
lucideUserX,
lucideVolume2,
lucideVolumeX,
} from '@ng-icons/lucide';
import { UsersActions } from '../../../store/users/users.actions';
import {
selectOnlineUsers,
selectCurrentUser,
selectIsCurrentUserAdmin,
} from '../../../store/users/users.selectors';
import { User } from '../../../core/models';
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
@Component({
selector: 'app-user-list',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent, ConfirmDialogComponent],
viewProviders: [
provideIcons({
lucideMic,
lucideMicOff,
lucideMonitor,
lucideShield,
lucideCrown,
lucideMoreVertical,
lucideBan,
lucideUserX,
lucideVolume2,
lucideVolumeX,
}),
],
templateUrl: './user-list.component.html',
})
/**
* Displays the list of online users with voice state indicators and admin actions.
*/
export class UserListComponent {
private store = inject(Store);
onlineUsers = this.store.selectSignal(selectOnlineUsers) as import('@angular/core').Signal<User[]>;
voiceUsers = computed(() => this.onlineUsers().filter((user: User) => !!user.voiceState?.isConnected));
currentUser = this.store.selectSignal(selectCurrentUser) as import('@angular/core').Signal<User | undefined | null>;
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
showUserMenu = signal<string | null>(null);
showBanDialog = signal(false);
userToBan = signal<User | null>(null);
banReason = '';
banDuration = '86400000'; // Default 1 day
/** Toggle the context menu for a specific user. */
toggleUserMenu(userId: string): void {
this.showUserMenu.update((current) => (current === userId ? null : userId));
}
/** Check whether the given user is the currently authenticated user. */
isCurrentUser(user: User): boolean {
return user.id === this.currentUser()?.id;
}
/** Toggle server-side mute on a user (admin action). */
muteUser(user: User): void {
if (user.voiceState?.isMutedByAdmin) {
this.store.dispatch(UsersActions.adminUnmuteUser({ userId: user.id }));
} else {
this.store.dispatch(UsersActions.adminMuteUser({ userId: user.id }));
}
this.showUserMenu.set(null);
}
/** Kick a user from the server (admin action). */
kickUser(user: User): void {
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
this.showUserMenu.set(null);
}
/** Open the ban confirmation dialog for a user (admin action). */
banUser(user: User): void {
this.userToBan.set(user);
this.showBanDialog.set(true);
this.showUserMenu.set(null);
}
/** Close the ban dialog and reset its form fields. */
closeBanDialog(): void {
this.showBanDialog.set(false);
this.userToBan.set(null);
this.banReason = '';
this.banDuration = '86400000';
}
/** Confirm the ban, dispatch the action with duration, and close the dialog. */
confirmBan(): void {
const user = this.userToBan();
if (!user) return;
const duration = parseInt(this.banDuration, 10);
const expiresAt = duration === 0 ? undefined : Date.now() + duration;
this.store.dispatch(
UsersActions.banUser({
userId: user.id,
reason: this.banReason || undefined,
expiresAt,
})
);
this.closeBanDialog();
}
}

View File

@@ -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);
}
}

View File

@@ -126,25 +126,12 @@
<div class="ml-5 mt-1 space-y-1">
@for (u of voiceUsersInRoom(ch.id); track u.id) {
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
@if (u.avatarUrl) {
<img
[src]="u.avatarUrl"
alt=""
class="w-7 h-7 rounded-full ring-2 object-cover"
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-red-500]="u.voiceState?.isDeafened"
<app-user-avatar
[name]="u.displayName"
[avatarUrl]="u.avatarUrl"
size="xs"
[ringClass]="u.voiceState?.isDeafened ? 'ring-2 ring-red-500' : u.voiceState?.isMuted ? 'ring-2 ring-yellow-500' : 'ring-2 ring-green-500'"
/>
} @else {
<div
class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-medium ring-2"
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-red-500]="u.voiceState?.isDeafened"
>
{{ u.displayName.charAt(0).toUpperCase() }}
</div>
}
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
<button
@@ -177,13 +164,7 @@
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">You</h4>
<div class="flex items-center gap-2 px-2 py-1.5 rounded bg-secondary/30">
<div class="relative">
@if (currentUser()?.avatarUrl) {
<img [src]="currentUser()?.avatarUrl" alt="" class="w-8 h-8 rounded-full object-cover" />
} @else {
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary text-sm font-medium">
{{ currentUser()?.displayName?.charAt(0)?.toUpperCase() || '?' }}
</div>
}
<app-user-avatar [name]="currentUser()?.displayName || '?'" [avatarUrl]="currentUser()?.avatarUrl" size="sm" />
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
</div>
<div class="flex-1 min-w-0">
@@ -220,13 +201,7 @@
(contextmenu)="openUserContextMenu($event, user)"
>
<div class="relative">
@if (user.avatarUrl) {
<img [src]="user.avatarUrl" alt="" class="w-8 h-8 rounded-full object-cover" />
} @else {
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary text-sm font-medium">
{{ user.displayName.charAt(0).toUpperCase() }}
</div>
}
<app-user-avatar [name]="user.displayName" [avatarUrl]="user.avatarUrl" size="sm" />
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
</div>
<div class="flex-1 min-w-0">
@@ -283,66 +258,47 @@
<!-- Channel context menu -->
@if (showChannelMenu()) {
<div class="fixed inset-0 z-40" (click)="closeChannelMenu()"></div>
<div class="fixed z-50 bg-card border border-border rounded-lg shadow-lg w-44 py-1" [style.left.px]="channelMenuX()" [style.top.px]="channelMenuY()">
<button (click)="resyncMessages()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Resync Messages
</button>
<app-context-menu [x]="channelMenuX()" [y]="channelMenuY()" (closed)="closeChannelMenu()" [width]="'w-44'">
<button (click)="resyncMessages()" class="context-menu-item">Resync Messages</button>
@if (canManageChannels()) {
<div class="border-t border-border my-1"></div>
<button (click)="startRename()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Rename Channel
</button>
<button (click)="deleteChannel()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-destructive">
Delete Channel
</button>
<div class="context-menu-divider"></div>
<button (click)="startRename()" class="context-menu-item">Rename Channel</button>
<button (click)="deleteChannel()" class="context-menu-item-danger">Delete Channel</button>
}
</div>
</app-context-menu>
}
<!-- User context menu (kick / role management) -->
@if (showUserMenu()) {
<div class="fixed inset-0 z-40" (click)="closeUserMenu()"></div>
<div class="fixed z-50 bg-card border border-border rounded-lg shadow-lg w-48 py-1" [style.left.px]="userMenuX()" [style.top.px]="userMenuY()">
<app-context-menu [x]="userMenuX()" [y]="userMenuY()" (closed)="closeUserMenu()">
@if (isAdmin()) {
<!-- Role management -->
@if (contextMenuUser()?.role === 'member') {
<button (click)="changeUserRole('moderator')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Promote to Moderator
</button>
<button (click)="changeUserRole('admin')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Promote to Admin
</button>
<button (click)="changeUserRole('moderator')" class="context-menu-item">Promote to Moderator</button>
<button (click)="changeUserRole('admin')" class="context-menu-item">Promote to Admin</button>
}
@if (contextMenuUser()?.role === 'moderator') {
<button (click)="changeUserRole('admin')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Promote to Admin
</button>
<button (click)="changeUserRole('member')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Demote to Member
</button>
<button (click)="changeUserRole('admin')" class="context-menu-item">Promote to Admin</button>
<button (click)="changeUserRole('member')" class="context-menu-item">Demote to Member</button>
}
@if (contextMenuUser()?.role === 'admin') {
<button (click)="changeUserRole('member')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Demote to Member
</button>
<button (click)="changeUserRole('member')" class="context-menu-item">Demote to Member</button>
}
<div class="border-t border-border my-1"></div>
<button (click)="kickUserAction()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-destructive">
Kick User
</button>
<div class="context-menu-divider"></div>
<button (click)="kickUserAction()" class="context-menu-item-danger">Kick User</button>
} @else {
<div class="px-3 py-1.5 text-sm text-muted-foreground">No actions available</div>
<div class="context-menu-empty">No actions available</div>
}
</div>
</app-context-menu>
}
<!-- Create channel dialog -->
@if (showCreateChannelDialog()) {
<div class="fixed inset-0 z-40 bg-black/30" (click)="cancelCreateChannel()"></div>
<div class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg w-[320px]">
<div class="p-4">
<h4 class="font-semibold text-foreground mb-3">Create {{ createChannelType() === 'text' ? 'Text' : 'Voice' }} Channel</h4>
<app-confirm-dialog
[title]="'Create ' + (createChannelType() === 'text' ? 'Text' : 'Voice') + ' Channel'"
confirmLabel="Create"
(confirmed)="confirmCreateChannel()"
(cancelled)="cancelCreateChannel()"
>
<input
type="text"
[(ngModel)]="newChannelName"
@@ -350,10 +306,5 @@
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary text-sm"
(keydown.enter)="confirmCreateChannel()"
/>
</div>
<div class="flex gap-2 p-3 border-t border-border">
<button (click)="cancelCreateChannel()" class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm">Cancel</button>
<button (click)="confirmCreateChannel()" class="flex-1 px-3 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors text-sm">Create</button>
</div>
</div>
</app-confirm-dialog>
}

View File

@@ -6,12 +6,13 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers, lucidePlus } from '@ng-icons/lucide';
import { selectOnlineUsers, selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
import { selectCurrentRoom, selectActiveChannelId, selectTextChannels, selectVoiceChannels } from '../../../store/rooms/rooms.selectors';
import * as UsersActions from '../../../store/users/users.actions';
import * as RoomsActions from '../../../store/rooms/rooms.actions';
import * as MessagesActions from '../../../store/messages/messages.actions';
import { UsersActions } from '../../../store/users/users.actions';
import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { MessagesActions } from '../../../store/messages/messages.actions';
import { WebRTCService } from '../../../core/services/webrtc.service';
import { VoiceSessionService } from '../../../core/services/voice-session.service';
import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component';
import { ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
import { Channel, User } from '../../../core/models';
import { v4 as uuidv4 } from 'uuid';
@@ -20,12 +21,15 @@ type TabView = 'channels' | 'users';
@Component({
selector: 'app-rooms-side-panel',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon, VoiceControlsComponent],
imports: [CommonModule, FormsModule, NgIcon, VoiceControlsComponent, ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent],
viewProviders: [
provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers, lucidePlus })
],
templateUrl: './rooms-side-panel.component.html',
})
/**
* Side panel listing text and voice channels, online users, and channel management actions.
*/
export class RoomsSidePanelComponent {
private store = inject(Store);
private webrtc = inject(WebRTCService);
@@ -61,14 +65,16 @@ export class RoomsSidePanelComponent {
userMenuY = signal(0);
contextMenuUser = signal<User | null>(null);
/** Return online users excluding the current user. */
// Filter out current user from online users list
onlineUsersFiltered() {
const current = this.currentUser();
const currentId = current?.id;
const currentOderId = current?.oderId;
return this.onlineUsers().filter(u => u.id !== currentId && u.oderId !== currentOderId);
return this.onlineUsers().filter(user => user.id !== currentId && user.oderId !== currentOderId);
}
/** Check whether the current user has permission to manage channels. */
canManageChannels(): boolean {
const room = this.currentRoom();
const user = this.currentUser();
@@ -81,12 +87,14 @@ export class RoomsSidePanelComponent {
return false;
}
/** Select a text channel (no-op if currently renaming). */
// ---- Text channel selection ----
selectTextChannel(channelId: string) {
if (this.renamingChannelId()) return; // don't switch while renaming
this.store.dispatch(RoomsActions.selectChannel({ channelId }));
}
/** Open the context menu for a channel at the cursor position. */
// ---- Channel context menu ----
openChannelContextMenu(evt: MouseEvent, channel: Channel) {
evt.preventDefault();
@@ -96,10 +104,12 @@ export class RoomsSidePanelComponent {
this.showChannelMenu.set(true);
}
/** Close the channel context menu. */
closeChannelMenu() {
this.showChannelMenu.set(false);
}
/** Begin inline renaming of the context-menu channel. */
startRename() {
const ch = this.contextChannel();
this.closeChannelMenu();
@@ -108,6 +118,7 @@ export class RoomsSidePanelComponent {
}
}
/** Commit the channel rename from the inline input value. */
confirmRename(event: Event) {
const input = event.target as HTMLInputElement;
const name = input.value.trim();
@@ -118,10 +129,12 @@ export class RoomsSidePanelComponent {
this.renamingChannelId.set(null);
}
/** Cancel the inline rename operation. */
cancelRename() {
this.renamingChannelId.set(null);
}
/** Delete the context-menu channel. */
deleteChannel() {
const ch = this.contextChannel();
this.closeChannelMenu();
@@ -130,11 +143,11 @@ export class RoomsSidePanelComponent {
}
}
/** Trigger a message inventory re-sync from all connected peers. */
resyncMessages() {
this.closeChannelMenu();
const room = this.currentRoom();
if (!room) {
console.warn('[Resync] No current room');
return;
}
@@ -143,19 +156,19 @@ export class RoomsSidePanelComponent {
// Request inventory from all connected peers
const peers = this.webrtc.getConnectedPeers();
console.log(`[Resync] Requesting inventory from ${peers.length} peer(s) for room ${room.id}`);
if (peers.length === 0) {
console.warn('[Resync] No connected peers — sync will time out');
// No connected peers — sync will time out
}
peers.forEach((pid) => {
try {
this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: room.id } as any);
} catch (e) {
console.error(`[Resync] Failed to send to peer ${pid}:`, e);
} catch (_error) {
// Failed to send inventory request to this peer
}
});
}
/** Open the create-channel dialog for the given channel type. */
// ---- Create channel ----
createChannel(type: 'text' | 'voice') {
this.createChannelType.set(type);
@@ -163,6 +176,7 @@ export class RoomsSidePanelComponent {
this.showCreateChannelDialog.set(true);
}
/** Confirm channel creation and dispatch the add-channel action. */
confirmCreateChannel() {
const name = this.newChannelName.trim();
if (!name) return;
@@ -178,10 +192,12 @@ export class RoomsSidePanelComponent {
this.showCreateChannelDialog.set(false);
}
/** Cancel channel creation and close the dialog. */
cancelCreateChannel() {
this.showCreateChannelDialog.set(false);
}
/** Open the user context menu for admin actions (kick/role change). */
// ---- User context menu (kick/role) ----
openUserContextMenu(evt: MouseEvent, user: User) {
evt.preventDefault();
@@ -192,10 +208,12 @@ export class RoomsSidePanelComponent {
this.showUserMenu.set(true);
}
/** Close the user context menu. */
closeUserMenu() {
this.showUserMenu.set(false);
}
/** Change a user's role and broadcast the update to connected peers. */
changeUserRole(role: 'admin' | 'moderator' | 'member') {
const user = this.contextMenuUser();
this.closeUserMenu();
@@ -210,6 +228,7 @@ export class RoomsSidePanelComponent {
}
}
/** Kick a user and broadcast the action to peers. */
kickUserAction() {
const user = this.contextMenuUser();
this.closeUserMenu();
@@ -224,12 +243,13 @@ export class RoomsSidePanelComponent {
}
}
/** Join a voice channel, managing permissions and existing voice connections. */
// ---- Voice ----
joinVoice(roomId: string) {
// Gate by room permissions
const room = this.currentRoom();
if (room && room.permissions && room.permissions.allowVoice === false) {
console.warn('Voice is disabled by room permissions');
// Voice is disabled by room permissions
return;
}
@@ -248,7 +268,7 @@ export class RoomsSidePanelComponent {
}));
}
} else {
console.warn('Already connected to voice in another server. Disconnect first before joining.');
// Already connected to voice in another server; must disconnect first
return;
}
}
@@ -280,8 +300,8 @@ export class RoomsSidePanelComponent {
// Update voice session for floating controls
if (room) {
// Find label from channel list
const vc = this.voiceChannels().find(c => c.id === roomId);
const voiceRoomName = vc ? `🔊 ${vc.name}` : roomId;
const voiceChannel = this.voiceChannels().find(channel => channel.id === roomId);
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
this.voiceSessionService.startSession({
serverId: room.id,
serverName: room.name,
@@ -292,9 +312,12 @@ export class RoomsSidePanelComponent {
serverRoute: `/room/${room.id}`,
});
}
}).catch((e) => console.error('Failed to join voice room', roomId, e));
}).catch((_error) => {
// Failed to join voice room
});
}
/** Leave a voice channel and broadcast the disconnect state. */
leaveVoice(roomId: string) {
const current = this.currentUser();
// Only leave if currently in this room
@@ -326,32 +349,36 @@ export class RoomsSidePanelComponent {
this.voiceSessionService.endSession();
}
/** Count the number of users connected to a voice channel in the current room. */
voiceOccupancy(roomId: string): number {
const users = this.onlineUsers();
const room = this.currentRoom();
return users.filter(u =>
!!u.voiceState?.isConnected &&
u.voiceState?.roomId === roomId &&
u.voiceState?.serverId === room?.id
return users.filter(user =>
!!user.voiceState?.isConnected &&
user.voiceState?.roomId === roomId &&
user.voiceState?.serverId === room?.id
).length;
}
/** Dispatch a viewer:focus event to display a remote user's screen share. */
viewShare(userId: string) {
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
window.dispatchEvent(evt);
}
/** Dispatch a viewer:focus event to display a remote user's stream. */
viewStream(userId: string) {
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
window.dispatchEvent(evt);
}
/** Check whether a user is currently sharing their screen. */
isUserSharing(userId: string): boolean {
const me = this.currentUser();
if (me?.id === userId) {
return this.webrtc.isScreenSharing();
}
const user = this.onlineUsers().find(u => u.id === userId || u.oderId === userId);
const user = this.onlineUsers().find(onlineUser => onlineUser.id === userId || onlineUser.oderId === userId);
if (user?.screenShareState?.isSharing === false) {
return false;
}
@@ -359,15 +386,17 @@ export class RoomsSidePanelComponent {
return !!stream && stream.getVideoTracks().length > 0;
}
/** Return all users currently connected to a specific voice channel. */
voiceUsersInRoom(roomId: string) {
const room = this.currentRoom();
return this.onlineUsers().filter(u =>
!!u.voiceState?.isConnected &&
u.voiceState?.roomId === roomId &&
u.voiceState?.serverId === room?.id
return this.onlineUsers().filter(user =>
!!user.voiceState?.isConnected &&
user.voiceState?.roomId === roomId &&
user.voiceState?.serverId === room?.id
);
}
/** Check whether the current user is connected to the specified voice channel. */
isCurrentRoom(roomId: string): boolean {
const me = this.currentUser();
const room = this.currentRoom();
@@ -378,6 +407,7 @@ export class RoomsSidePanelComponent {
);
}
/** Check whether voice is enabled by the current room's permissions. */
voiceEnabled(): boolean {
const room = this.currentRoom();
return room?.permissions?.allowVoice !== false;

View File

@@ -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,

View File

@@ -10,48 +10,44 @@
<!-- Saved servers icons -->
<div class="flex-1 w-full overflow-y-auto flex flex-col items-center gap-2 mt-2">
<ng-container *ngFor="let room of savedRooms(); trackBy: trackRoomId">
@for (room of savedRooms(); track room.id) {
<button
class="w-10 h-10 flex-shrink-0 rounded-2xl overflow-hidden border border-border hover:border-primary/60 hover:shadow-sm transition-all"
[title]="room.name"
(click)="joinSavedRoom(room)"
(contextmenu)="openContextMenu($event, room)"
>
<ng-container *ngIf="room.icon; else noIcon">
@if (room.icon) {
<img [src]="room.icon" [alt]="room.name" class="w-full h-full object-cover" />
</ng-container>
<ng-template #noIcon>
} @else {
<div class="w-full h-full flex items-center justify-center bg-secondary">
<span class="text-sm font-semibold text-muted-foreground">{{ initial(room.name) }}</span>
</div>
</ng-template>
}
</button>
</ng-container>
}
</div>
</nav>
<!-- Context menu -->
<div *ngIf="showMenu()" class="">
<div class="fixed inset-0 z-40" (click)="closeMenu()"></div>
<div class="fixed z-50 bg-card border border-border rounded-lg shadow-md w-44" [style.left.px]="menuX()" [style.top.px]="menuY()">
<button *ngIf="isCurrentContextRoom()" (click)="leaveServer()" class="w-full text-left px-3 py-2 hover:bg-secondary transition-colors text-foreground">Leave Server</button>
<button (click)="openForgetConfirm()" class="w-full text-left px-3 py-2 hover:bg-secondary transition-colors text-foreground">Forget Server</button>
</div>
</div>
@if (showMenu()) {
<app-context-menu [x]="menuX()" [y]="menuY()" (closed)="closeMenu()" [width]="'w-44'">
@if (isCurrentContextRoom()) {
<button (click)="leaveServer()" class="context-menu-item">Leave Server</button>
}
<button (click)="openForgetConfirm()" class="context-menu-item">Forget Server</button>
</app-context-menu>
}
<!-- Forget confirmation dialog -->
<div *ngIf="showConfirm()">
<div class="fixed inset-0 z-40 bg-black/30" (click)="cancelForget()"></div>
<div class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg w-[280px]">
<div class="p-4">
<h4 class="font-semibold text-foreground mb-2">Forget Server?</h4>
<p class="text-sm text-muted-foreground">
Remove <span class="font-medium text-foreground">{{ contextRoom()?.name }}</span> from your My Servers list.
</p>
</div>
<div class="flex gap-2 p-3 border-t border-border">
<button (click)="cancelForget()" class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors">Cancel</button>
<button (click)="confirmForget()" class="flex-1 px-3 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors">Forget</button>
</div>
</div>
</div>
@if (showConfirm()) {
<app-confirm-dialog
title="Forget Server?"
confirmLabel="Forget"
(confirmed)="confirmForget()"
(cancelled)="cancelForget()"
[widthClass]="'w-[280px]'"
>
<p>Remove <span class="font-medium text-foreground">{{ contextRoom()?.name }}</span> from your My Servers list.</p>
</app-confirm-dialog>
}

View File

@@ -9,15 +9,19 @@ import { Room } from '../../core/models';
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import { VoiceSessionService } from '../../core/services/voice-session.service';
import { WebRTCService } from '../../core/services/webrtc.service';
import * as RoomsActions from '../../store/rooms/rooms.actions';
import { RoomsActions } from '../../store/rooms/rooms.actions';
import { ContextMenuComponent, ConfirmDialogComponent } from '../../shared';
@Component({
selector: 'app-servers-rail',
standalone: true,
imports: [CommonModule, NgIcon],
imports: [CommonModule, NgIcon, ContextMenuComponent, ConfirmDialogComponent],
viewProviders: [provideIcons({ lucidePlus })],
templateUrl: './servers-rail.component.html',
})
/**
* Vertical rail of saved server icons with context-menu actions for leaving/forgetting.
*/
export class ServersRailComponent {
private store = inject(Store);
private router = inject(Router);
@@ -28,12 +32,13 @@ export class ServersRailComponent {
// Context menu state
showMenu = signal(false);
menuX = signal(72); // rail width (~64px) + padding, position menu to the right
menuY = signal(100);
menuX = signal(72); // default X: rail width (~64px) + padding
menuY = signal(100); // default Y: arbitrary initial offset
contextRoom = signal<Room | null>(null);
// Confirmation dialog state
showConfirm = signal(false);
/** Return the first character of a server name as its icon initial. */
initial(name?: string): string {
if (!name) return '?';
const ch = name.trim()[0]?.toUpperCase();
@@ -42,6 +47,7 @@ export class ServersRailComponent {
trackRoomId = (index: number, room: Room) => room.id;
/** Navigate to the server search view. Updates voice session state if applicable. */
createServer(): void {
// Navigate to server list (has create button)
// Update voice session state if connected to voice
@@ -52,6 +58,7 @@ export class ServersRailComponent {
this.router.navigate(['/search']);
}
/** Join or switch to a saved room. Manages voice session and authentication state. */
joinSavedRoom(room: Room): void {
// Require auth: if no current user, go to login
const currentUserId = localStorage.getItem('metoyou_currentUserId');
@@ -88,37 +95,43 @@ export class ServersRailComponent {
}
}
/** Open the context menu positioned near the cursor for a given room. */
openContextMenu(evt: MouseEvent, room: Room): void {
evt.preventDefault();
this.contextRoom.set(room);
// Position menu slightly to the right of cursor to avoid overlapping the rail
// Offset 8px right to avoid overlapping the rail; floor at rail width (72px)
this.menuX.set(Math.max((evt.clientX + 8), 72));
this.menuY.set(evt.clientY);
this.showMenu.set(true);
}
/** Close the context menu (keeps contextRoom for potential confirmation). */
closeMenu(): void {
this.showMenu.set(false);
// keep contextRoom for potential confirmation dialog
}
/** Check whether the context-menu room is the currently active room. */
isCurrentContextRoom(): boolean {
const ctx = this.contextRoom();
const cur = this.currentRoom();
return !!ctx && !!cur && ctx.id === cur.id;
}
/** Leave the current server and navigate to the servers list. */
leaveServer(): void {
this.closeMenu();
this.store.dispatch(RoomsActions.leaveRoom());
window.dispatchEvent(new CustomEvent('navigate:servers'));
}
/** Show the forget-server confirmation dialog. */
openForgetConfirm(): void {
this.showConfirm.set(true);
this.closeMenu();
}
/** Forget (remove) a server from the saved list, leaving if it is the current room. */
confirmForget(): void {
const ctx = this.contextRoom();
if (!ctx) return;
@@ -131,6 +144,7 @@ export class ServersRailComponent {
this.contextRoom.set(null);
}
/** Cancel the forget-server confirmation dialog. */
cancelForget(): void {
this.showConfirm.set(false);
}

View File

@@ -16,6 +16,7 @@ import {
} from '@ng-icons/lucide';
import { ServerDirectoryService } from '../../core/services/server-directory.service';
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../core/constants';
@Component({
selector: 'app-settings',
@@ -36,6 +37,9 @@ import { ServerDirectoryService } from '../../core/services/server-directory.ser
],
templateUrl: './settings.component.html',
})
/**
* Settings page for managing signaling servers and connection preferences.
*/
export class SettingsComponent implements OnInit {
private serverDirectory = inject(ServerDirectoryService);
private router = inject(Router);
@@ -49,10 +53,12 @@ export class SettingsComponent implements OnInit {
autoReconnect = true;
searchAllServers = true;
/** Load persisted connection settings on component init. */
ngOnInit(): void {
this.loadConnectionSettings();
}
/** Add a new signaling server after URL validation and duplicate checking. */
addServer(): void {
this.addError.set(null);
@@ -65,7 +71,7 @@ export class SettingsComponent implements OnInit {
}
// Check for duplicates
if (this.servers().some((s) => s.url === this.newServerUrl)) {
if (this.servers().some((server) => server.url === this.newServerUrl)) {
this.addError.set('This server URL already exists');
return;
}
@@ -87,22 +93,26 @@ export class SettingsComponent implements OnInit {
}
}
/** Remove a signaling server by its ID. */
removeServer(id: string): void {
this.serverDirectory.removeServer(id);
}
/** Set the active signaling server used for connections. */
setActiveServer(id: string): void {
this.serverDirectory.setActiveServer(id);
}
/** Test connectivity to all configured servers. */
async testAllServers(): Promise<void> {
this.isTesting.set(true);
await this.serverDirectory.testAllServers();
this.isTesting.set(false);
}
/** Load connection settings (auto-reconnect, search scope) from localStorage. */
loadConnectionSettings(): void {
const settings = localStorage.getItem('metoyou_connection_settings');
const settings = localStorage.getItem(STORAGE_KEY_CONNECTION_SETTINGS);
if (settings) {
const parsed = JSON.parse(settings);
this.autoReconnect = parsed.autoReconnect ?? true;
@@ -111,9 +121,10 @@ export class SettingsComponent implements OnInit {
}
}
/** Persist current connection settings to localStorage. */
saveConnectionSettings(): void {
localStorage.setItem(
'metoyou_connection_settings',
STORAGE_KEY_CONNECTION_SETTINGS,
JSON.stringify({
autoReconnect: this.autoReconnect,
searchAllServers: this.searchAllServers,
@@ -122,6 +133,7 @@ export class SettingsComponent implements OnInit {
this.serverDirectory.setSearchAllServers(this.searchAllServers);
}
/** Navigate back to the main page. */
goBack(): void {
this.router.navigate(['/']);
}

View File

@@ -1,31 +1,62 @@
<div class="fixed top-0 left-16 right-0 h-10 bg-card border-b border-border flex items-center justify-between px-4 z-50 select-none" style="-webkit-app-region: drag;">
<div class="flex items-center gap-2 min-w-0 relative" style="-webkit-app-region: no-drag;">
<button *ngIf="inRoom()" (click)="onBack()" class="p-2 hover:bg-secondary rounded" title="Back">
<div
class="fixed top-0 left-16 right-0 h-10 bg-card border-b border-border flex items-center justify-between px-4 z-50 select-none"
style="-webkit-app-region: drag;"
>
<div class="flex items-center gap-2 min-w-0 relative" style="-webkit-app-region: no-drag;">
@if (inRoom()) {
<button (click)="onBack()" class="p-2 hover:bg-secondary rounded" title="Back">
<ng-icon name="lucideChevronLeft" class="w-5 h-5 text-muted-foreground" />
</button>
<ng-container *ngIf="inRoom(); else userServer">
}
@if (inRoom()) {
<ng-icon name="lucideHash" class="w-5 h-5 text-muted-foreground" />
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
<span *ngIf="roomDescription()" class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">{{ roomDescription() }}</span>
@if (roomDescription()) {
<span class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">
{{ roomDescription() }}
</span>
}
<button (click)="toggleMenu()" class="ml-2 p-2 hover:bg-secondary rounded" title="Menu">
<ng-icon name="lucideMenu" class="w-5 h-5 text-muted-foreground" />
</button>
<!-- Anchored dropdown under the menu button -->
<div *ngIf="showMenu()" class="absolute right-0 top-full mt-1 z-50 bg-card border border-border rounded-lg shadow-lg w-48">
<button (click)="leaveServer()" class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground">Leave Server</button>
@if (showMenu()) {
<div class="absolute right-0 top-full mt-1 z-50 bg-card border border-border rounded-lg shadow-lg w-48">
<button
(click)="leaveServer()"
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground"
>
Leave Server
</button>
<div class="border-t border-border"></div>
<button (click)="logout()" class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground">Logout</button>
<button
(click)="logout()"
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground"
>
Logout
</button>
</div>
</ng-container>
<ng-template #userServer>
}
} @else {
<div class="flex items-center gap-2 min-w-0">
<span class="text-sm text-muted-foreground truncate">{{ username() }} | {{ serverName() }}</span>
<span *ngIf="!isConnected()" class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive">Reconnecting…</span>
@if (isReconnecting()) {
<span class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive">Reconnecting…</span>
}
</div>
</ng-template>
</div>
<div class="flex items-center gap-2" style="-webkit-app-region: no-drag;">
<button *ngIf="!isAuthed()" class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground" (click)="goLogin()" title="Login">Login</button>
}
</div>
<div class="flex items-center gap-2" style="-webkit-app-region: no-drag;">
@if (!isAuthed()) {
<button
class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground"
(click)="goLogin()"
title="Login"
>
Login
</button>
}
@if (isElectron()) {
<button class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Minimize" (click)="minimize()">
<ng-icon name="lucideMinus" class="w-4 h-4" />
</button>
@@ -35,7 +66,10 @@
<button class="w-8 h-8 grid place-items-center hover:bg-destructive/10 rounded" title="Close" (click)="close()">
<ng-icon name="lucideX" class="w-4 h-4 text-destructive" />
</button>
</div>
}
</div>
</div>
<!-- Click-away overlay to close dropdown -->
<div *ngIf="showMenu()" class="fixed inset-0 z-40" (click)="closeMenu()" style="-webkit-app-region: no-drag;"></div>
@if (showMenu()) {
<div class="fixed inset-0 z-40" (click)="closeMenu()" style="-webkit-app-region: no-drag;"></div>
}

View File

@@ -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']);
}

View File

@@ -15,7 +15,7 @@ import {
import { WebRTCService } from '../../../core/services/webrtc.service';
import { VoiceSessionService } from '../../../core/services/voice-session.service';
import * as UsersActions from '../../../store/users/users.actions';
import { UsersActions } from '../../../store/users/users.actions';
import { selectCurrentUser } from '../../../store/users/users.selectors';
@Component({
@@ -34,6 +34,10 @@ import { selectCurrentUser } from '../../../store/users/users.selectors';
],
templateUrl: './floating-voice-controls.component.html'
})
/**
* Floating voice controls displayed when the user navigates away from the voice-connected server.
* Provides mute, deafen, screen-share, and disconnect actions in a compact overlay.
*/
export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
private webrtcService = inject(WebRTCService);
private voiceSessionService = inject(VoiceSessionService);
@@ -52,6 +56,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
private stateSubscription: Subscription | null = null;
/** Sync local mute/deafen/screen-share state from the WebRTC service on init. */
ngOnInit(): void {
// Sync mute/deafen state from webrtc service
this.isMuted.set(this.webrtcService.isMuted());
@@ -63,12 +68,14 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
this.stateSubscription?.unsubscribe();
}
/** Navigate back to the voice-connected server. */
navigateToServer(): void {
this.voiceSessionService.navigateToVoiceServer();
}
/** Toggle microphone mute and broadcast the updated voice state. */
toggleMute(): void {
this.isMuted.update(v => !v);
this.isMuted.update((current) => !current);
this.webrtcService.toggleMute(this.isMuted());
// Broadcast mute state change
@@ -84,8 +91,9 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
});
}
/** Toggle deafen state (muting audio output) and broadcast the updated voice state. */
toggleDeafen(): void {
this.isDeafened.update(v => !v);
this.isDeafened.update((current) => !current);
this.webrtcService.toggleDeafen(this.isDeafened());
// When deafening, also mute
@@ -107,6 +115,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
});
}
/** Toggle screen sharing on or off. */
async toggleScreenShare(): Promise<void> {
if (this.isScreenSharing()) {
this.webrtcService.stopScreenShare();
@@ -115,12 +124,13 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
try {
await this.webrtcService.startScreenShare(false);
this.isScreenSharing.set(true);
} catch (error) {
console.error('Failed to start screen share:', error);
} catch (_error) {
// Screen share request was denied or failed
}
}
}
/** Disconnect from the voice session entirely, cleaning up all voice state. */
disconnect(): void {
// Stop voice heartbeat
this.webrtcService.stopVoiceHeartbeat();
@@ -163,6 +173,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
this.isDeafened.set(false);
}
/** Return the CSS classes for the compact control button based on active state. */
getCompactButtonClass(isActive: boolean): string {
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
if (isActive) {
@@ -171,6 +182,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
}
/** Return the CSS classes for the compact screen-share button. */
getCompactScreenShareClass(): string {
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
if (this.isScreenSharing()) {
@@ -179,6 +191,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
}
/** Return the CSS classes for the mute toggle button. */
getMuteButtonClass(): string {
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
if (this.isMuted()) {
@@ -187,6 +200,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
}
/** Return the CSS classes for the deafen toggle button. */
getDeafenButtonClass(): string {
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
if (this.isDeafened()) {
@@ -195,6 +209,7 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
}
/** Return the CSS classes for the screen-share toggle button. */
getScreenShareButtonClass(): string {
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
if (this.isScreenSharing()) {

View File

@@ -19,12 +19,13 @@
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-white">
<ng-icon name="lucideMonitor" class="w-4 h-4" />
<span class="text-sm font-medium" *ngIf="activeScreenSharer(); else sharingUnknown">
@if (activeScreenSharer()) {
<span class="text-sm font-medium">
{{ activeScreenSharer()?.displayName }} is sharing their screen
</span>
<ng-template #sharingUnknown>
} @else {
<span class="text-sm font-medium">Someone is sharing their screen</span>
</ng-template>
}
</div>
<div class="flex items-center gap-3">
<!-- Viewer volume -->
@@ -71,10 +72,12 @@
</div>
<!-- No Stream Placeholder -->
<div *ngIf="!hasStream()" class="absolute inset-0 flex items-center justify-center bg-secondary">
@if (!hasStream()) {
<div class="absolute inset-0 flex items-center justify-center bg-secondary">
<div class="text-center text-muted-foreground">
<ng-icon name="lucideMonitor" class="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Waiting for screen share...</p>
</div>
</div>
}
</div>

View File

@@ -13,6 +13,7 @@ import {
import { WebRTCService } from '../../../core/services/webrtc.service';
import { selectOnlineUsers } from '../../../store/users/users.selectors';
import { User } from '../../../core/models';
import { DEFAULT_VOLUME } from '../../../core/constants';
@Component({
selector: 'app-screen-share-viewer',
@@ -28,6 +29,10 @@ import { User } from '../../../core/models';
],
templateUrl: './screen-share-viewer.component.html',
})
/**
* Displays a local or remote screen-share stream in a video player.
* Supports fullscreen toggling, volume control, and viewer focus events.
*/
export class ScreenShareViewerComponent implements OnDestroy {
@ViewChild('screenVideo') videoRef!: ElementRef<HTMLVideoElement>;
@@ -43,7 +48,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
isFullscreen = signal(false);
hasStream = signal(false);
isLocalShare = signal(false);
screenVolume = signal(100);
screenVolume = signal(DEFAULT_VOLUME);
private streamSubscription: (() => void) | null = null;
private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => {
@@ -51,7 +56,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
const userId = evt.detail?.userId;
if (!userId) return;
const stream = this.webrtcService.getRemoteStream(userId);
const user = this.onlineUsers().find((u) => u.id === userId || u.oderId === userId) || null;
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) || null;
if (stream && stream.getVideoTracks().length > 0) {
if (user) {
this.setRemoteStream(stream, user);
@@ -63,8 +68,8 @@ export class ScreenShareViewerComponent implements OnDestroy {
this.isLocalShare.set(false);
}
}
} catch (e) {
console.error('Failed to focus viewer on user stream:', e);
} catch (_error) {
// Failed to focus viewer on user stream
}
};
@@ -95,7 +100,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
if (!watchingId || !isWatchingRemote) return;
const users = this.onlineUsers();
const watchedUser = users.find(u => u.id === watchingId || u.oderId === watchingId);
const watchedUser = users.find(user => user.id === watchingId || user.oderId === watchingId);
// If the user is no longer sharing (screenShareState.isSharing is false), stop watching
if (watchedUser && watchedUser.screenShareState?.isSharing === false) {
@@ -105,7 +110,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
// Also check if the stream's video tracks are still available
const stream = this.webrtcService.getRemoteStream(watchingId);
const hasActiveVideo = stream?.getVideoTracks().some(t => t.readyState === 'live');
const hasActiveVideo = stream?.getVideoTracks().some(track => track.readyState === 'live');
if (!hasActiveVideo) {
// Stream or video tracks are gone - stop watching
this.stopWatching();
@@ -136,6 +141,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
window.removeEventListener('viewer:focus', this.viewerFocusHandler as EventListener);
}
/** Toggle between fullscreen and windowed display. */
toggleFullscreen(): void {
if (this.isFullscreen()) {
this.exitFullscreen();
@@ -144,6 +150,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
}
}
/** Enter fullscreen mode, requesting browser fullscreen if available. */
enterFullscreen(): void {
this.isFullscreen.set(true);
// Request browser fullscreen if available
@@ -154,6 +161,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
}
}
/** Exit fullscreen mode. */
exitFullscreen(): void {
this.isFullscreen.set(false);
if (document.fullscreenElement) {
@@ -161,6 +169,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
}
}
/** Stop the local screen share and reset viewer state. */
stopSharing(): void {
this.webrtcService.stopScreenShare();
this.activeScreenSharer.set(null);
@@ -168,6 +177,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
this.isLocalShare.set(false);
}
/** Stop watching a remote stream and reset the viewer. */
// Stop watching a remote stream (for viewers)
stopWatching(): void {
if (this.videoRef) {
@@ -182,6 +192,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
}
}
/** Attach and play a remote peer's screen-share stream. */
// Called by parent when a remote peer starts sharing
setRemoteStream(stream: MediaStream, user: User): void {
this.activeScreenSharer.set(user);
@@ -212,6 +223,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
}
}
/** Attach and play the local user's screen-share stream (always muted). */
// Called when local user starts sharing
setLocalStream(stream: MediaStream, user: User): void {
this.activeScreenSharer.set(user);
@@ -225,6 +237,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
}
}
/** Handle volume slider changes, applying only to remote streams. */
onScreenVolumeChange(event: Event): void {
const input = event.target as HTMLInputElement;
const val = Math.max(0, Math.min(100, parseInt(input.value, 10)));

View File

@@ -1,4 +1,4 @@
<div class="bg-card border-t border-border p-4">
<div class="bg-card border-border p-4">
<!-- Connection Error Banner -->
@if (showConnectionError()) {
<div class="mb-3 p-2 bg-destructive/20 border border-destructive/30 rounded-lg flex items-center gap-2">
@@ -10,9 +10,7 @@
<!-- User Info -->
<div class="flex items-center gap-3 mb-4">
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold text-sm">
{{ currentUser()?.displayName?.charAt(0)?.toUpperCase() || '?' }}
</div>
<app-user-avatar [name]="currentUser()?.displayName || '?'" size="sm" />
<div class="flex-1 min-w-0">
<p class="font-medium text-sm text-foreground truncate">
{{ currentUser()?.displayName || 'Unknown' }}
@@ -145,7 +143,10 @@
<div>
<label class="block text-sm font-medium text-foreground mb-1">Latency</label>
<select (change)="onLatencyProfileChange($event)" class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm">
<select
(change)="onLatencyProfileChange($event)"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm"
>
<option value="low">Low (fast)</option>
<option value="balanced" selected>Balanced</option>
<option value="high">High (quality)</option>
@@ -154,7 +155,12 @@
<div>
<label class="block text-sm font-medium text-foreground mb-1">Include system audio when sharing screen</label>
<input type="checkbox" [checked]="includeSystemAudio()" (change)="onIncludeSystemAudioChange($event)" class="accent-primary" />
<input
type="checkbox"
[checked]="includeSystemAudio()"
(change)="onIncludeSystemAudioChange($event)"
class="accent-primary"
/>
<p class="text-xs text-muted-foreground">Off by default; viewers will still hear your mic.</p>
</div>
@@ -162,7 +168,15 @@
<label class="block text-sm font-medium text-foreground mb-1">
Audio Bitrate: {{ audioBitrate() }} kbps
</label>
<input type="range" [value]="audioBitrate()" (input)="onAudioBitrateChange($event)" min="32" max="256" step="8" class="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" />
<input
type="range"
[value]="audioBitrate()"
(input)="onAudioBitrateChange($event)"
min="32"
max="256"
step="8"
class="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
</div>

View File

@@ -17,9 +17,11 @@ import {
import { WebRTCService } from '../../../core/services/webrtc.service';
import { VoiceSessionService } from '../../../core/services/voice-session.service';
import * as UsersActions from '../../../store/users/users.actions';
import { UsersActions } from '../../../store/users/users.actions';
import { selectCurrentUser } from '../../../store/users/users.selectors';
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../core/constants';
import { UserAvatarComponent } from '../../../shared';
interface AudioDevice {
deviceId: string;
@@ -29,7 +31,7 @@ interface AudioDevice {
@Component({
selector: 'app-voice-controls',
standalone: true,
imports: [CommonModule, NgIcon],
imports: [CommonModule, NgIcon, UserAvatarComponent],
viewProviders: [
provideIcons({
lucideMic,
@@ -74,7 +76,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
latencyProfile = signal<'low'|'balanced'|'high'>('balanced');
includeSystemAudio = signal(false);
private SETTINGS_KEY = 'metoyou_voice_settings';
private voiceConnectedSubscription: Subscription | null = null;
async ngOnInit(): Promise<void> {
@@ -87,14 +88,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// Subscribe to remote streams to play audio from peers
this.remoteStreamSubscription = this.webrtcService.onRemoteStream.subscribe(
({ peerId, stream }) => {
console.log('Received remote stream from:', peerId, 'tracks:', stream.getTracks().map(t => t.kind));
this.playRemoteAudio(peerId, stream);
}
);
// Subscribe to voice connected event to play pending streams and ensure all remote audio is set up
this.voiceConnectedSubscription = this.webrtcService.onVoiceConnected.subscribe(() => {
console.log('Voice connected, playing pending streams:', this.pendingRemoteStreams.size);
this.playPendingStreams();
// Also ensure all remote streams from connected peers are playing
// This handles the case where streams were received while voice was "connected"
@@ -130,7 +129,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
*/
private playPendingStreams(): void {
this.pendingRemoteStreams.forEach((stream, peerId) => {
console.log('Playing pending stream from:', peerId, 'tracks:', stream.getTracks().map(t => t.kind));
this.playRemoteAudio(peerId, stream);
});
this.pendingRemoteStreams.clear();
@@ -143,7 +141,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
*/
private ensureAllRemoteStreamsPlaying(): void {
const connectedPeers = this.webrtcService.getConnectedPeers();
console.log('Ensuring audio for connected peers:', connectedPeers.length);
for (const peerId of connectedPeers) {
const stream = this.webrtcService.getRemoteStream(peerId);
@@ -151,7 +148,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// Check if we already have an active audio element for this peer
const existingAudio = this.remoteAudioElements.get(peerId);
if (!existingAudio || existingAudio.srcObject !== stream) {
console.log('Setting up remote audio for peer:', peerId);
this.playRemoteAudio(peerId, stream);
}
}
@@ -168,14 +164,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
audio.srcObject = null;
audio.remove();
this.remoteAudioElements.delete(peerId);
console.log('Removed remote audio for:', peerId);
}
}
private playRemoteAudio(peerId: string, stream: MediaStream): void {
// Only play remote audio if we have joined voice
if (!this.isConnected()) {
console.log('Not connected to voice, storing pending stream from:', peerId);
// Store the stream to play later when we connect
this.pendingRemoteStreams.set(peerId, stream);
return;
@@ -184,21 +178,18 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// Check if stream has audio tracks
const audioTracks = stream.getAudioTracks();
if (audioTracks.length === 0) {
console.log('No audio tracks in stream from:', peerId);
return;
}
// Check if audio track is live
const audioTrack = audioTracks[0];
if (audioTrack.readyState !== 'live') {
console.warn('Audio track not live from:', peerId, 'state:', audioTrack.readyState);
// Still try to play it - it might become live later
}
// Remove existing audio element for this peer if any
const existingAudio = this.remoteAudioElements.get(peerId);
if (existingAudio) {
console.log('Removing existing audio element for:', peerId);
existingAudio.srcObject = null;
existingAudio.remove();
}
@@ -216,9 +207,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// Play the audio
audio.play().then(() => {
console.log('Playing remote audio from:', peerId, 'track state:', audioTrack.readyState, 'enabled:', audioTrack.enabled);
}).catch((error) => {
console.error('Failed to play remote audio from:', peerId, error);
});
this.remoteAudioElements.set(peerId, audio);
@@ -227,22 +216,20 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
async loadAudioDevices(): Promise<void> {
try {
if (!navigator.mediaDevices?.enumerateDevices) {
console.warn('navigator.mediaDevices not available (requires HTTPS or localhost)');
return;
}
const devices = await navigator.mediaDevices.enumerateDevices();
this.inputDevices.set(
devices
.filter((d) => d.kind === 'audioinput')
.map((d) => ({ deviceId: d.deviceId, label: d.label }))
.filter((device) => device.kind === 'audioinput')
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
);
this.outputDevices.set(
devices
.filter((d) => d.kind === 'audiooutput')
.map((d) => ({ deviceId: d.deviceId, label: d.label }))
.filter((device) => device.kind === 'audiooutput')
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
);
} catch (error) {
console.error('Failed to enumerate devices:', error);
}
}
@@ -251,12 +238,10 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// Require signaling connectivity first
const ok = await this.webrtcService.ensureSignalingConnected();
if (!ok) {
console.error('Cannot join call: signaling server unreachable');
return;
}
if (!navigator.mediaDevices?.getUserMedia) {
console.error('Cannot join call: navigator.mediaDevices not available (requires HTTPS or localhost)');
return;
}
@@ -289,7 +274,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// Play any pending remote streams now that we're connected
this.pendingRemoteStreams.forEach((pendingStream, peerId) => {
console.log('Playing pending stream from:', peerId);
this.playRemoteAudio(peerId, pendingStream);
});
this.pendingRemoteStreams.clear();
@@ -297,7 +281,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// Persist settings after successful connection
this.saveSettings();
} catch (error) {
console.error('Failed to get user media:', error);
}
}
@@ -305,8 +288,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
async retryConnection(): Promise<void> {
try {
await this.webrtcService.ensureSignalingConnected(10000);
} catch (e) {
console.error('Retry connection failed:', e);
} catch (_error) {
}
}
@@ -359,7 +341,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
}
toggleMute(): void {
this.isMuted.update((v) => !v);
this.isMuted.update((current) => !current);
this.webrtcService.toggleMute(this.isMuted());
// Broadcast mute state change
@@ -376,7 +358,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
}
toggleDeafen(): void {
this.isDeafened.update((v) => !v);
this.isDeafened.update((current) => !current);
this.webrtcService.toggleDeafen(this.isDeafened());
// Mute/unmute all remote audio elements
@@ -412,13 +394,12 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
await this.webrtcService.startScreenShare(this.includeSystemAudio());
this.isScreenSharing.set(true);
} catch (error) {
console.error('Failed to start screen share:', error);
}
}
}
toggleSettings(): void {
this.showSettings.update((v) => !v);
this.showSettings.update((current) => !current);
}
closeSettings(): void {
@@ -485,9 +466,9 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
private loadSettings(): void {
try {
const raw = localStorage.getItem(this.SETTINGS_KEY);
const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
if (!raw) return;
const s = JSON.parse(raw) as {
const settings = JSON.parse(raw) as {
inputDevice?: string;
outputDevice?: string;
inputVolume?: number;
@@ -496,19 +477,19 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
latencyProfile?: 'low'|'balanced'|'high';
includeSystemAudio?: boolean;
};
if (s.inputDevice) this.selectedInputDevice.set(s.inputDevice);
if (s.outputDevice) this.selectedOutputDevice.set(s.outputDevice);
if (typeof s.inputVolume === 'number') this.inputVolume.set(s.inputVolume);
if (typeof s.outputVolume === 'number') this.outputVolume.set(s.outputVolume);
if (typeof s.audioBitrate === 'number') this.audioBitrate.set(s.audioBitrate);
if (s.latencyProfile) this.latencyProfile.set(s.latencyProfile);
if (typeof s.includeSystemAudio === 'boolean') this.includeSystemAudio.set(s.includeSystemAudio);
if (settings.inputDevice) this.selectedInputDevice.set(settings.inputDevice);
if (settings.outputDevice) this.selectedOutputDevice.set(settings.outputDevice);
if (typeof settings.inputVolume === 'number') this.inputVolume.set(settings.inputVolume);
if (typeof settings.outputVolume === 'number') this.outputVolume.set(settings.outputVolume);
if (typeof settings.audioBitrate === 'number') this.audioBitrate.set(settings.audioBitrate);
if (settings.latencyProfile) this.latencyProfile.set(settings.latencyProfile);
if (typeof settings.includeSystemAudio === 'boolean') this.includeSystemAudio.set(settings.includeSystemAudio);
} catch {}
}
private saveSettings(): void {
try {
const s = {
const voiceSettings = {
inputDevice: this.selectedInputDevice(),
outputDevice: this.selectedOutputDevice(),
inputVolume: this.inputVolume(),
@@ -517,7 +498,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
latencyProfile: this.latencyProfile(),
includeSystemAudio: this.includeSystemAudio(),
};
localStorage.setItem(this.SETTINGS_KEY, JSON.stringify(s));
localStorage.setItem(STORAGE_KEY_VOICE_SETTINGS, JSON.stringify(voiceSettings));
} catch {}
}
@@ -536,7 +517,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.remoteAudioElements.forEach((audio) => {
const anyAudio = audio as any;
if (typeof anyAudio.setSinkId === 'function') {
anyAudio.setSinkId(deviceId).catch((e: any) => console.warn('Failed to setSinkId', e));
anyAudio.setSinkId(deviceId).catch(() => {});
}
});
}

View File

@@ -0,0 +1,80 @@
import { Component, input, output, HostListener } from '@angular/core';
/**
* Reusable confirmation dialog modal.
*
* Usage:
* ```html
* @if (showConfirm()) {
* <app-confirm-dialog
* title="Delete Room?"
* confirmLabel="Delete"
* variant="danger"
* (confirmed)="onDelete()"
* (cancelled)="showConfirm.set(false)"
* >
* <p>This will permanently delete the room.</p>
* </app-confirm-dialog>
* }
* ```
*/
@Component({
selector: 'app-confirm-dialog',
standalone: true,
template: `
<!-- Backdrop -->
<div class="fixed inset-0 z-40 bg-black/30" (click)="cancelled.emit()"></div>
<!-- Dialog -->
<div class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg"
[class]="widthClass()">
<div class="p-4">
<h4 class="font-semibold text-foreground mb-2">{{ title() }}</h4>
<div class="text-sm text-muted-foreground">
<ng-content />
</div>
</div>
<div class="flex gap-2 p-3 border-t border-border">
<button
(click)="cancelled.emit()"
class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm"
>
{{ cancelLabel() }}
</button>
<button
(click)="confirmed.emit()"
class="flex-1 px-3 py-2 rounded-lg transition-colors text-sm"
[class.bg-primary]="variant() === 'primary'"
[class.text-primary-foreground]="variant() === 'primary'"
[class.hover:bg-primary/90]="variant() === 'primary'"
[class.bg-destructive]="variant() === 'danger'"
[class.text-destructive-foreground]="variant() === 'danger'"
[class.hover:bg-destructive/90]="variant() === 'danger'"
>
{{ confirmLabel() }}
</button>
</div>
</div>
`,
styles: [`:host { display: contents; }`],
})
export class ConfirmDialogComponent {
/** Dialog title. */
title = input.required<string>();
/** Label for the confirm button. */
confirmLabel = input<string>('Confirm');
/** Label for the cancel button. */
cancelLabel = input<string>('Cancel');
/** Visual style of the confirm button. */
variant = input<'primary' | 'danger'>('primary');
/** Tailwind width class for the dialog. */
widthClass = input<string>('w-[320px]');
/** Emitted when the user confirms. */
confirmed = output<void>();
/** Emitted when the user cancels (backdrop click, Cancel button, or Escape). */
cancelled = output<void>();
@HostListener('document:keydown.escape')
onEscape(): void {
this.cancelled.emit();
}
}

View File

@@ -0,0 +1,77 @@
import { Component, input, output, HostListener } from '@angular/core';
/**
* Generic positioned context-menu overlay.
*
* Usage:
* ```html
* @if (showMenu()) {
* <app-context-menu [x]="menuX()" [y]="menuY()" (closed)="closeMenu()" [width]="'w-48'">
* <button (click)="doSomething()" class="context-menu-item">Action</button>
* </app-context-menu>
* }
* ```
*
* Built-in item classes are available via the host styles:
* - `.context-menu-item` — normal item
* - `.context-menu-item-danger` — destructive (red) item
* - `.context-menu-divider` — horizontal separator
*/
@Component({
selector: 'app-context-menu',
standalone: true,
template: `
<!-- Invisible backdrop that captures clicks outside -->
<div class="fixed inset-0 z-40" (click)="closed.emit()"></div>
<!-- Positioned menu panel -->
<div
class="fixed z-50 bg-card border border-border rounded-lg shadow-lg py-1"
[class]="width()"
[style.left.px]="x()"
[style.top.px]="y()"
>
<ng-content />
</div>
`,
styles: [
`
:host {
display: contents;
}
/* Convenience classes consumers can use on projected buttons */
:host ::ng-deep .context-menu-item {
@apply w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground;
}
:host ::ng-deep .context-menu-item-danger {
@apply w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-destructive;
}
:host ::ng-deep .context-menu-item-icon {
@apply w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground flex items-center gap-2;
}
:host ::ng-deep .context-menu-item-icon-danger {
@apply w-full text-left px-3 py-2 text-sm hover:bg-destructive/10 transition-colors text-destructive flex items-center gap-2;
}
:host ::ng-deep .context-menu-divider {
@apply border-t border-border my-1;
}
:host ::ng-deep .context-menu-empty {
@apply px-3 py-1.5 text-sm text-muted-foreground;
}
`,
],
})
export class ContextMenuComponent {
/** Horizontal position (px from left). */
x = input.required<number>();
/** Vertical position (px from top). */
y = input.required<number>();
/** Tailwind width class for the panel (default `w-48`). */
width = input<string>('w-48');
/** Emitted when the menu should close (backdrop click or Escape). */
closed = output<void>();
@HostListener('document:keydown.escape')
onEscape(): void {
this.closed.emit();
}
}

View File

@@ -0,0 +1,73 @@
import { Component, input } from '@angular/core';
/**
* Reusable user avatar circle.
*
* Displays the user's image when `avatarUrl` is provided, otherwise
* falls back to a colored circle with the first letter of `name`.
*
* Optional rings (e.g. voice-state colours) can be applied via `ringClass`.
*
* Usage:
* ```html
* <app-user-avatar [name]="user.displayName" [avatarUrl]="user.avatarUrl" size="md" />
* <app-user-avatar [name]="user.displayName" size="sm" ringClass="ring-2 ring-green-500" />
* ```
*/
@Component({
selector: 'app-user-avatar',
standalone: true,
template: `
@if (avatarUrl()) {
<img
[src]="avatarUrl()"
alt=""
class="rounded-full object-cover"
[class]="sizeClasses() + ' ' + ringClass()"
/>
} @else {
<div
class="rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
[class]="sizeClasses() + ' ' + textClass() + ' ' + ringClass()"
>
{{ initial() }}
</div>
}
`,
styles: [`:host { display: contents; }`],
})
export class UserAvatarComponent {
/** Display name — first character is used as fallback initial. */
name = input.required<string>();
/** Optional avatar image URL. */
avatarUrl = input<string | undefined | null>();
/** Predefined size: `xs` (28px), `sm` (32px), `md` (40px), `lg` (48px). */
size = input<'xs' | 'sm' | 'md' | 'lg'>('sm');
/** Extra ring classes, e.g. `'ring-2 ring-green-500'`. */
ringClass = input<string>('');
/** Compute the first-letter initial. */
initial(): string {
return this.name()?.charAt(0)?.toUpperCase() ?? '?';
}
/** Map size token to Tailwind dimension classes. */
sizeClasses(): string {
switch (this.size()) {
case 'xs': return 'w-7 h-7';
case 'sm': return 'w-8 h-8';
case 'md': return 'w-10 h-10';
case 'lg': return 'w-12 h-12';
}
}
/** Map size token to text size for initials. */
textClass(): string {
switch (this.size()) {
case 'xs': return 'text-xs';
case 'sm': return 'text-sm';
case 'md': return 'text-base font-semibold';
case 'lg': return 'text-lg font-semibold';
}
}
}

6
src/app/shared/index.ts Normal file
View File

@@ -0,0 +1,6 @@
/**
* Shared reusable UI components barrel.
*/
export { ContextMenuComponent } from './components/context-menu/context-menu.component';
export { UserAvatarComponent } from './components/user-avatar/user-avatar.component';
export { ConfirmDialogComponent } from './components/confirm-dialog/confirm-dialog.component';

View File

@@ -1,27 +1,41 @@
/**
* Root state definition and barrel exports for the NgRx store.
*
* Three feature slices:
* - **messages** chat messages, reactions, sync state
* - **users** online users, bans, roles, voice state
* - **rooms** servers / rooms, channels, search results
*/
import { isDevMode } from '@angular/core';
import { ActionReducerMap, MetaReducer } from '@ngrx/store';
import { messagesReducer, MessagesState } from './messages/messages.reducer';
import { usersReducer, UsersState } from './users/users.reducer';
import { roomsReducer, RoomsState } from './rooms/rooms.reducer';
/** Combined root state of the application. */
export interface AppState {
/** Chat messages feature slice. */
messages: MessagesState;
/** Users / presence feature slice. */
users: UsersState;
/** Rooms / servers feature slice. */
rooms: RoomsState;
}
/** Top-level reducer map registered with `StoreModule.forRoot()`. */
export const reducers: ActionReducerMap<AppState> = {
messages: messagesReducer,
users: usersReducer,
rooms: roomsReducer,
};
/** Meta-reducers (e.g. logging) enabled only in development builds. */
export const metaReducers: MetaReducer<AppState>[] = isDevMode() ? [] : [];
// Re-export actions
export * as MessagesActions from './messages/messages.actions';
export * as UsersActions from './users/users.actions';
export * as RoomsActions from './rooms/rooms.actions';
export { MessagesActions } from './messages/messages.actions';
export { UsersActions } from './users/users.actions';
export { RoomsActions } from './rooms/rooms.actions';
// Re-export selectors explicitly to avoid conflicts
export {
@@ -57,6 +71,7 @@ export {
// Re-export effects
export { MessagesEffects } from './messages/messages.effects';
export { MessagesSyncEffects } from './messages/messages-sync.effects';
export { UsersEffects } from './users/users.effects';
export { RoomsEffects } from './rooms/rooms.effects';

View File

@@ -0,0 +1,445 @@
/**
* Handler functions for incoming P2P messages dispatched via WebRTC.
*
* Each handler is a pure function that receives an event and a context
* object containing the required services. Handlers return an
* `Observable<Action>` or `EMPTY` when no store action needs dispatching.
*
* The handler registry at the bottom maps event `type` strings to their
* handlers, and `dispatchIncomingMessage()` is the single entry point
* consumed by the `incomingMessages$` effect.
*/
import { Observable, of, from, EMPTY } from 'rxjs';
import { mergeMap } from 'rxjs/operators';
import { Action } from '@ngrx/store';
import { Message } from '../../core/models';
import { DatabaseService } from '../../core/services/database.service';
import { WebRTCService } from '../../core/services/webrtc.service';
import { AttachmentService } from '../../core/services/attachment.service';
import { MessagesActions } from './messages.actions';
import {
INVENTORY_LIMIT,
CHUNK_SIZE,
FULL_SYNC_LIMIT,
chunkArray,
buildInventoryItem,
buildLocalInventoryMap,
findMissingIds,
hydrateMessage,
mergeIncomingMessage,
} from './messages.helpers';
/** Shared context injected into each handler function. */
export interface IncomingMessageContext {
db: DatabaseService;
webrtc: WebRTCService;
attachments: AttachmentService;
currentUser: any;
currentRoom: any;
}
/** Signature for an incoming-message handler function. */
type MessageHandler = (
event: any,
ctx: IncomingMessageContext,
) => Observable<Action>;
/**
* Responds to a peer's inventory request by building and sending
* our local message inventory in chunks.
*/
function handleInventoryRequest(
event: any,
{ db, webrtc }: IncomingMessageContext,
): Observable<Action> {
const { roomId, fromPeerId } = event;
if (!roomId || !fromPeerId) return EMPTY;
return from(
(async () => {
const messages = await db.getMessages(roomId, INVENTORY_LIMIT, 0);
const items = await Promise.all(
messages.map((msg) => buildInventoryItem(msg, db)),
);
items.sort((a, b) => a.ts - b.ts);
for (const chunk of chunkArray(items, CHUNK_SIZE)) {
webrtc.sendToPeer(fromPeerId, {
type: 'chat-inventory',
roomId,
items: chunk,
total: items.length,
index: 0,
} as any);
}
})(),
).pipe(mergeMap(() => EMPTY));
}
/**
* Compares a peer's inventory against local state
* and requests any missing or stale messages.
*/
function handleInventory(
event: any,
{ db, webrtc }: IncomingMessageContext,
): Observable<Action> {
const { roomId, fromPeerId, items } = event;
if (!roomId || !Array.isArray(items) || !fromPeerId) return EMPTY;
return from(
(async () => {
const local = await db.getMessages(roomId, INVENTORY_LIMIT, 0);
const localMap = await buildLocalInventoryMap(local, db);
const missing = findMissingIds(items, localMap);
for (const chunk of chunkArray(missing, CHUNK_SIZE)) {
webrtc.sendToPeer(fromPeerId, {
type: 'chat-sync-request-ids',
roomId,
ids: chunk,
} as any);
}
})(),
).pipe(mergeMap(() => EMPTY));
}
/**
* Responds to a peer's request for specific message IDs by sending
* hydrated messages along with their attachment metadata.
*/
function handleSyncRequestIds(
event: any,
{ db, webrtc, attachments }: IncomingMessageContext,
): Observable<Action> {
const { roomId, ids, fromPeerId } = event;
if (!Array.isArray(ids) || !fromPeerId) return EMPTY;
return from(
(async () => {
const maybeMessages = await Promise.all(
(ids as string[]).map((id) => db.getMessageById(id)),
);
const messages = maybeMessages.filter(
(msg): msg is Message => !!msg,
);
const hydrated = await Promise.all(
messages.map((msg) => hydrateMessage(msg, db)),
);
const msgIds = hydrated.map((msg) => msg.id);
const attachmentMetas =
attachments.getAttachmentMetasForMessages(msgIds);
for (const chunk of chunkArray(hydrated, CHUNK_SIZE)) {
const chunkAttachments: Record<string, any> = {};
for (const m of chunk) {
if (attachmentMetas[m.id])
chunkAttachments[m.id] = attachmentMetas[m.id];
}
webrtc.sendToPeer(fromPeerId, {
type: 'chat-sync-batch',
roomId: roomId || '',
messages: chunk,
attachments:
Object.keys(chunkAttachments).length > 0
? chunkAttachments
: undefined,
} as any);
}
})(),
).pipe(mergeMap(() => EMPTY));
}
/**
* Processes a batch of synced messages from a peer: merges each into
* the local DB, registers attachment metadata, and auto-requests any
* missing image attachments.
*/
function handleSyncBatch(
event: any,
{ db, attachments }: IncomingMessageContext,
): Observable<Action> {
if (!Array.isArray(event.messages)) return EMPTY;
if (event.attachments && typeof event.attachments === 'object') {
attachments.registerSyncedAttachments(event.attachments);
}
return from(processSyncBatch(event, db, attachments)).pipe(
mergeMap((toUpsert) =>
toUpsert.length > 0
? of(MessagesActions.syncMessages({ messages: toUpsert }))
: EMPTY,
),
);
}
/** Merges each incoming message and collects those that changed. */
async function processSyncBatch(
event: any,
db: DatabaseService,
attachments: AttachmentService,
): Promise<Message[]> {
const toUpsert: Message[] = [];
for (const incoming of event.messages as Message[]) {
const { message, changed } = await mergeIncomingMessage(incoming, db);
if (changed) toUpsert.push(message);
}
if (event.attachments && event.fromPeerId) {
requestMissingImages(event.attachments, attachments);
}
return toUpsert;
}
/** Auto-requests any unavailable image attachments from any connected peer. */
function requestMissingImages(
attachmentMap: Record<string, any[]>,
attachments: AttachmentService,
): void {
for (const [msgId, metas] of Object.entries(attachmentMap)) {
for (const meta of metas) {
if (!meta.isImage) continue;
const atts = attachments.getForMessage(msgId);
const att = atts.find((a: any) => a.id === meta.id);
if (
att &&
!att.available &&
!(att.receivedBytes && att.receivedBytes > 0)
) {
attachments.requestImageFromAnyPeer(msgId, att);
}
}
}
}
/** Saves an incoming chat message to DB and dispatches receiveMessage. */
function handleChatMessage(
event: any,
{ db, currentUser }: IncomingMessageContext,
): Observable<Action> {
const msg = event.message;
if (!msg) return EMPTY;
// Skip our own messages (reflected via server relay)
const isOwnMessage =
msg.senderId === currentUser?.id ||
msg.senderId === currentUser?.oderId;
if (isOwnMessage) return EMPTY;
db.saveMessage(msg);
return of(MessagesActions.receiveMessage({ message: msg }));
}
/** Applies a remote message edit to the local DB and store. */
function handleMessageEdited(
event: any,
{ db }: IncomingMessageContext,
): Observable<Action> {
if (!event.messageId || !event.content) return EMPTY;
db.updateMessage(event.messageId, {
content: event.content,
editedAt: event.editedAt,
});
return of(
MessagesActions.editMessageSuccess({
messageId: event.messageId,
content: event.content,
editedAt: event.editedAt,
}),
);
}
/** Applies a remote message deletion to the local DB and store. */
function handleMessageDeleted(
event: any,
{ db }: IncomingMessageContext,
): Observable<Action> {
if (!event.messageId) return EMPTY;
db.deleteMessage(event.messageId);
return of(
MessagesActions.deleteMessageSuccess({ messageId: event.messageId }),
);
}
/** Saves an incoming reaction to DB and updates the store. */
function handleReactionAdded(
event: any,
{ db }: IncomingMessageContext,
): Observable<Action> {
if (!event.messageId || !event.reaction) return EMPTY;
db.saveReaction(event.reaction);
return of(MessagesActions.addReactionSuccess({ reaction: event.reaction }));
}
/** Removes a reaction from DB and updates the store. */
function handleReactionRemoved(
event: any,
{ db }: IncomingMessageContext,
): Observable<Action> {
if (!event.messageId || !event.oderId || !event.emoji) return EMPTY;
db.removeReaction(event.messageId, event.oderId, event.emoji);
return of(
MessagesActions.removeReactionSuccess({
messageId: event.messageId,
oderId: event.oderId,
emoji: event.emoji,
}),
);
}
function handleFileAnnounce(
event: any,
{ attachments }: IncomingMessageContext,
): Observable<Action> {
attachments.handleFileAnnounce(event);
return EMPTY;
}
function handleFileChunk(
event: any,
{ attachments }: IncomingMessageContext,
): Observable<Action> {
attachments.handleFileChunk(event);
return EMPTY;
}
function handleFileRequest(
event: any,
{ attachments }: IncomingMessageContext,
): Observable<Action> {
attachments.handleFileRequest(event);
return EMPTY;
}
function handleFileCancel(
event: any,
{ attachments }: IncomingMessageContext,
): Observable<Action> {
attachments.handleFileCancel(event);
return EMPTY;
}
function handleFileNotFound(
event: any,
{ attachments }: IncomingMessageContext,
): Observable<Action> {
attachments.handleFileNotFound(event);
return EMPTY;
}
/**
* Compares a peer's dataset summary and requests full sync
* if the peer has newer or more data.
*/
function handleSyncSummary(
event: any,
{ db, webrtc, currentRoom }: IncomingMessageContext,
): Observable<Action> {
if (!currentRoom) return EMPTY;
return from(
(async () => {
const local = await db.getMessages(currentRoom.id, FULL_SYNC_LIMIT, 0);
const localCount = local.length;
const localLastUpdated = local.reduce(
(max, m) => Math.max(max, m.editedAt || m.timestamp || 0),
0,
);
const remoteLastUpdated = event.lastUpdated || 0;
const remoteCount = event.count || 0;
const identical =
localLastUpdated === remoteLastUpdated && localCount === remoteCount;
const needsSync =
remoteLastUpdated > localLastUpdated ||
(remoteLastUpdated === localLastUpdated && remoteCount > localCount);
if (!identical && needsSync && event.fromPeerId) {
webrtc.sendToPeer(event.fromPeerId, {
type: 'chat-sync-request',
roomId: currentRoom.id,
} as any);
}
})(),
).pipe(mergeMap(() => EMPTY));
}
/** Responds to a peer's full sync request by sending all local messages. */
function handleSyncRequest(
event: any,
{ db, webrtc, currentRoom }: IncomingMessageContext,
): Observable<Action> {
if (!currentRoom || !event.fromPeerId) return EMPTY;
return from(
(async () => {
const all = await db.getMessages(currentRoom.id, FULL_SYNC_LIMIT, 0);
webrtc.sendToPeer(event.fromPeerId, {
type: 'chat-sync-full',
roomId: currentRoom.id,
messages: all,
} as any);
})(),
).pipe(mergeMap(() => EMPTY));
}
/** Merges a full message dump from a peer into the local DB and store. */
function handleSyncFull(
event: any,
{ db }: IncomingMessageContext,
): Observable<Action> {
if (!event.messages || !Array.isArray(event.messages)) return EMPTY;
event.messages.forEach((msg: Message) => db.saveMessage(msg));
return of(MessagesActions.syncMessages({ messages: event.messages }));
}
/** Map of event types to their handler functions. */
const HANDLER_MAP: Readonly<Record<string, MessageHandler>> = {
// Inventory-based sync protocol
'chat-inventory-request': handleInventoryRequest,
'chat-inventory': handleInventory,
'chat-sync-request-ids': handleSyncRequestIds,
'chat-sync-batch': handleSyncBatch,
// Chat messages
'chat-message': handleChatMessage,
'message-edited': handleMessageEdited,
'message-deleted': handleMessageDeleted,
// Reactions
'reaction-added': handleReactionAdded,
'reaction-removed': handleReactionRemoved,
// Attachments
'file-announce': handleFileAnnounce,
'file-chunk': handleFileChunk,
'file-request': handleFileRequest,
'file-cancel': handleFileCancel,
'file-not-found': handleFileNotFound,
// Legacy sync handshake
'chat-sync-summary': handleSyncSummary,
'chat-sync-request': handleSyncRequest,
'chat-sync-full': handleSyncFull,
};
/**
* Routes an incoming P2P message to the appropriate handler.
* Returns `EMPTY` if the event type is unknown or has no relevant handler.
*/
export function dispatchIncomingMessage(
event: any,
ctx: IncomingMessageContext,
): Observable<Action> {
const handler = HANDLER_MAP[event.type];
return handler ? handler(event, ctx) : EMPTY;
}

View File

@@ -0,0 +1,211 @@
/**
* Sync-lifecycle effects for the messages store slice.
*
* These effects manage the periodic sync polling, peer-connect
* handshakes, and join-room kickoff that keep message databases
* in sync across peers.
*
* Extracted from the monolithic MessagesEffects to keep each
* class focused on a single concern.
*/
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { of, from, timer, Subject, EMPTY } from 'rxjs';
import {
map,
mergeMap,
catchError,
withLatestFrom,
tap,
filter,
exhaustMap,
switchMap,
repeat,
takeUntil,
} from 'rxjs/operators';
import { MessagesActions } from './messages.actions';
import { RoomsActions } from '../rooms/rooms.actions';
import { selectMessagesSyncing } from './messages.selectors';
import { selectCurrentRoom } from '../rooms/rooms.selectors';
import { DatabaseService } from '../../core/services/database.service';
import { WebRTCService } from '../../core/services/webrtc.service';
import {
INVENTORY_LIMIT,
FULL_SYNC_LIMIT,
SYNC_POLL_FAST_MS,
SYNC_POLL_SLOW_MS,
SYNC_TIMEOUT_MS,
getLatestTimestamp,
} from './messages.helpers';
@Injectable()
export class MessagesSyncEffects {
private readonly actions$ = inject(Actions);
private readonly store = inject(Store);
private readonly db = inject(DatabaseService);
private readonly webrtc = inject(WebRTCService);
/** Tracks whether the last sync cycle found no new messages. */
private lastSyncClean = false;
/** Subject to reset the periodic sync timer. */
private readonly syncReset$ = new Subject<void>();
/**
* When a new peer connects, sends our dataset summary and an
* inventory request so both sides can reconcile.
*/
peerConnectedSync$ = createEffect(
() =>
this.webrtc.onPeerConnected.pipe(
withLatestFrom(this.store.select(selectCurrentRoom)),
mergeMap(([peerId, room]) => {
if (!room) return EMPTY;
return from(
this.db.getMessages(room.id, FULL_SYNC_LIMIT, 0),
).pipe(
tap((messages) => {
const count = messages.length;
const lastUpdated = getLatestTimestamp(messages);
this.webrtc.sendToPeer(peerId, {
type: 'chat-sync-summary',
roomId: room.id,
count,
lastUpdated,
} as any);
this.webrtc.sendToPeer(peerId, {
type: 'chat-inventory-request',
roomId: room.id,
} as any);
}),
);
}),
),
{ dispatch: false },
);
/**
* When the user joins a room, sends a summary and inventory
* request to every already-connected peer.
*/
joinRoomSyncKickoff$ = createEffect(
() =>
this.actions$.pipe(
ofType(RoomsActions.joinRoomSuccess),
withLatestFrom(this.store.select(selectCurrentRoom)),
mergeMap(([{ room }, currentRoom]) => {
const activeRoom = currentRoom || room;
if (!activeRoom) return EMPTY;
return from(
this.db.getMessages(activeRoom.id, FULL_SYNC_LIMIT, 0),
).pipe(
tap((messages) => {
const count = messages.length;
const lastUpdated = getLatestTimestamp(messages);
for (const pid of this.webrtc.getConnectedPeers()) {
try {
this.webrtc.sendToPeer(pid, {
type: 'chat-sync-summary',
roomId: activeRoom.id,
count,
lastUpdated,
} as any);
this.webrtc.sendToPeer(pid, {
type: 'chat-inventory-request',
roomId: activeRoom.id,
} as any);
} catch {
/* peer may have disconnected */
}
}
}),
);
}),
),
{ dispatch: false },
);
/**
* Alternates between fast (10 s) and slow (15 min) sync intervals.
* Sends inventory requests to all connected peers.
*/
periodicSyncPoll$ = createEffect(() =>
timer(SYNC_POLL_FAST_MS).pipe(
repeat({
delay: () =>
timer(
this.lastSyncClean ? SYNC_POLL_SLOW_MS : SYNC_POLL_FAST_MS,
),
}),
takeUntil(this.syncReset$),
withLatestFrom(this.store.select(selectCurrentRoom)),
filter(
([, room]) =>
!!room && this.webrtc.getConnectedPeers().length > 0,
),
exhaustMap(([, room]) => {
const peers = this.webrtc.getConnectedPeers();
if (!room || peers.length === 0) {
return of(MessagesActions.syncComplete());
}
return from(
this.db.getMessages(room.id, INVENTORY_LIMIT, 0),
).pipe(
map(() => {
for (const pid of peers) {
try {
this.webrtc.sendToPeer(pid, {
type: 'chat-inventory-request',
roomId: room.id,
} as any);
} catch {
/* peer may have disconnected */
}
}
return MessagesActions.startSync();
}),
catchError(() => {
this.lastSyncClean = false;
return of(MessagesActions.syncComplete());
}),
);
}),
),
);
/**
* Auto-completes a sync cycle after a timeout if no messages arrive.
* Switches to slow polling when the cycle is clean.
*/
syncTimeout$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.startSync),
switchMap(() => from(
new Promise<void>((resolve) => setTimeout(resolve, SYNC_TIMEOUT_MS)),
)),
withLatestFrom(this.store.select(selectMessagesSyncing)),
filter(([, syncing]) => syncing),
map(() => {
this.lastSyncClean = true;
return MessagesActions.syncComplete();
}),
),
);
/**
* When a peer (re)connects, revert to aggressive polling in case
* we missed messages while disconnected.
*/
syncReceivedMessages$ = createEffect(
() =>
this.webrtc.onPeerConnected.pipe(
tap(() => {
this.lastSyncClean = false;
}),
),
{ dispatch: false },
);
}

View File

@@ -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(),
},
});

View File

@@ -1,11 +1,20 @@
/**
* Core message CRUD effects (load, send, edit, delete, react)
* and the central incoming-message dispatcher.
*
* Sync-lifecycle effects (polling, peer-connect handshakes) live in
* `messages-sync.effects.ts` to keep this file focused.
*
* The giant `incomingMessages$` switch-case has been replaced by a
* handler registry in `messages-incoming.handlers.ts`.
*/
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { of, from, timer, Subject } from 'rxjs';
import { map, mergeMap, catchError, withLatestFrom, tap, switchMap, filter, exhaustMap, repeat, takeUntil } from 'rxjs/operators';
import { of, from, EMPTY } from 'rxjs';
import { mergeMap, catchError, withLatestFrom, switchMap } from 'rxjs/operators';
import { v4 as uuidv4 } from 'uuid';
import * as MessagesActions from './messages.actions';
import { selectMessagesSyncing } from './messages.selectors';
import { MessagesActions } from './messages.actions';
import { selectCurrentUser } from '../users/users.selectors';
import { selectCurrentRoom } from '../rooms/rooms.selectors';
import { DatabaseService } from '../../core/services/database.service';
@@ -13,55 +22,46 @@ import { WebRTCService } from '../../core/services/webrtc.service';
import { TimeSyncService } from '../../core/services/time-sync.service';
import { AttachmentService } from '../../core/services/attachment.service';
import { Message, Reaction } from '../../core/models';
import * as UsersActions from '../users/users.actions';
import * as RoomsActions from '../rooms/rooms.actions';
import { hydrateMessages } from './messages.helpers';
import {
dispatchIncomingMessage,
IncomingMessageContext,
} from './messages-incoming.handlers';
@Injectable()
export class MessagesEffects {
private actions$ = inject(Actions);
private store = inject(Store);
private db = inject(DatabaseService);
private webrtc = inject(WebRTCService);
private timeSync = inject(TimeSyncService);
private attachments = inject(AttachmentService);
private readonly actions$ = inject(Actions);
private readonly store = inject(Store);
private readonly db = inject(DatabaseService);
private readonly webrtc = inject(WebRTCService);
private readonly timeSync = inject(TimeSyncService);
private readonly attachments = inject(AttachmentService);
private readonly INVENTORY_LIMIT = 1000; // number of recent messages to consider
private readonly CHUNK_SIZE = 200; // chunk size for inventory/batch transfers
private readonly SYNC_POLL_FAST_MS = 10_000; // 10s — aggressive poll
private readonly SYNC_POLL_SLOW_MS = 900_000; // 15min — idle poll after clean sync
private lastSyncClean = false; // true after a sync cycle with no new messages
// Load messages from local database (hydrate reactions from separate table)
/** Loads messages for a room from the local database, hydrating reactions. */
loadMessages$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.loadMessages),
switchMap(({ roomId }) =>
from(this.db.getMessages(roomId)).pipe(
mergeMap(async (messages) => {
// Hydrate each message with its reactions from the reactions table
const hydrated = await Promise.all(
messages.map(async (m) => {
const reactions = await this.db.getReactionsForMessage(m.id);
return reactions.length > 0 ? { ...m, reactions } : m;
})
);
const hydrated = await hydrateMessages(messages, this.db);
return MessagesActions.loadMessagesSuccess({ messages: hydrated });
}),
catchError((error) =>
of(MessagesActions.loadMessagesFailure({ error: error.message }))
)
)
)
)
of(MessagesActions.loadMessagesFailure({ error: error.message })),
),
),
),
),
);
// Send message
/** Constructs a new message, persists it locally, and broadcasts to all peers. */
sendMessage$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.sendMessage),
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom)
this.store.select(selectCurrentRoom),
),
mergeMap(([{ content, replyToId, channelId }, currentUser, currentRoom]) => {
if (!currentUser || !currentRoom) {
@@ -81,24 +81,18 @@ export class MessagesEffects {
replyToId,
};
// Save to local DB
this.db.saveMessage(message);
// Broadcast to all peers
this.webrtc.broadcastMessage({
type: 'chat-message',
message,
});
this.webrtc.broadcastMessage({ type: 'chat-message', message });
return of(MessagesActions.sendMessageSuccess({ message }));
}),
catchError((error) =>
of(MessagesActions.sendMessageFailure({ error: error.message }))
)
)
of(MessagesActions.sendMessageFailure({ error: error.message })),
),
),
);
// Edit message
/** Edits an existing message (author-only), updates DB, and broadcasts the change. */
editMessage$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.editMessage),
@@ -109,40 +103,29 @@ export class MessagesEffects {
}
return from(this.db.getMessageById(messageId)).pipe(
mergeMap((existingMessage) => {
if (!existingMessage) {
mergeMap((existing) => {
if (!existing) {
return of(MessagesActions.editMessageFailure({ error: 'Message not found' }));
}
// Check if user owns the message
if (existingMessage.senderId !== currentUser.id) {
if (existing.senderId !== currentUser.id) {
return of(MessagesActions.editMessageFailure({ error: 'Cannot edit others messages' }));
}
const editedAt = this.timeSync.now();
// Update in DB
this.db.updateMessage(messageId, { content, editedAt });
// Broadcast to peers
this.webrtc.broadcastMessage({
type: 'message-edited',
messageId,
content,
editedAt,
});
this.webrtc.broadcastMessage({ type: 'message-edited', messageId, content, editedAt });
return of(MessagesActions.editMessageSuccess({ messageId, content, editedAt }));
}),
catchError((error) =>
of(MessagesActions.editMessageFailure({ error: error.message }))
)
of(MessagesActions.editMessageFailure({ error: error.message })),
),
);
})
)
}),
),
);
// Delete message (user's own)
/** Soft-deletes a message (author-only), marks it deleted in DB, and broadcasts. */
deleteMessage$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.deleteMessage),
@@ -153,36 +136,28 @@ export class MessagesEffects {
}
return from(this.db.getMessageById(messageId)).pipe(
mergeMap((existingMessage) => {
if (!existingMessage) {
mergeMap((existing) => {
if (!existing) {
return of(MessagesActions.deleteMessageFailure({ error: 'Message not found' }));
}
// Check if user owns the message
if (existingMessage.senderId !== currentUser.id) {
if (existing.senderId !== currentUser.id) {
return of(MessagesActions.deleteMessageFailure({ error: 'Cannot delete others messages' }));
}
// Soft delete - mark as deleted
this.db.updateMessage(messageId, { isDeleted: true });
// Broadcast to peers
this.webrtc.broadcastMessage({
type: 'message-deleted',
messageId,
});
this.webrtc.broadcastMessage({ type: 'message-deleted', messageId });
return of(MessagesActions.deleteMessageSuccess({ messageId }));
}),
catchError((error) =>
of(MessagesActions.deleteMessageFailure({ error: error.message }))
)
of(MessagesActions.deleteMessageFailure({ error: error.message })),
),
);
})
)
}),
),
);
// Admin delete message
/** Soft-deletes any message (admin+ only). */
adminDeleteMessage$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.adminDeleteMessage),
@@ -192,38 +167,33 @@ export class MessagesEffects {
return of(MessagesActions.deleteMessageFailure({ error: 'Not logged in' }));
}
// Check admin permission
if (currentUser.role !== 'host' && currentUser.role !== 'admin' && currentUser.role !== 'moderator') {
const hasPermission =
currentUser.role === 'host' ||
currentUser.role === 'admin' ||
currentUser.role === 'moderator';
if (!hasPermission) {
return of(MessagesActions.deleteMessageFailure({ error: 'Permission denied' }));
}
// Soft delete
this.db.updateMessage(messageId, { isDeleted: true });
// Broadcast to peers
this.webrtc.broadcastMessage({
type: 'message-deleted',
messageId,
deletedBy: currentUser.id,
});
this.webrtc.broadcastMessage({ type: 'message-deleted', messageId, deletedBy: currentUser.id });
return of(MessagesActions.deleteMessageSuccess({ messageId }));
}),
catchError((error) =>
of(MessagesActions.deleteMessageFailure({ error: error.message }))
)
)
of(MessagesActions.deleteMessageFailure({ error: error.message })),
),
),
);
// Add reaction
/** Adds an emoji reaction to a message, persists it, and broadcasts to peers. */
addReaction$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.addReaction),
withLatestFrom(this.store.select(selectCurrentUser)),
mergeMap(([{ messageId, emoji }, currentUser]) => {
if (!currentUser) {
return of({ type: 'NO_OP' });
}
if (!currentUser) return EMPTY;
const reaction: Reaction = {
id: uuidv4(),
@@ -234,35 +204,23 @@ export class MessagesEffects {
timestamp: this.timeSync.now(),
};
// Save to DB
this.db.saveReaction(reaction);
// Broadcast to peers
this.webrtc.broadcastMessage({
type: 'reaction-added',
messageId,
reaction,
});
this.webrtc.broadcastMessage({ type: 'reaction-added', messageId, reaction });
return of(MessagesActions.addReactionSuccess({ reaction }));
})
)
}),
),
);
// Remove reaction
/** Removes the current user's reaction from a message, deletes from DB, and broadcasts. */
removeReaction$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.removeReaction),
withLatestFrom(this.store.select(selectCurrentUser)),
mergeMap(([{ messageId, emoji }, currentUser]) => {
if (!currentUser) {
return of({ type: 'NO_OP' });
}
if (!currentUser) return EMPTY;
// Remove from DB
this.db.removeReaction(messageId, currentUser.id, emoji);
// Broadcast to peers
this.webrtc.broadcastMessage({
type: 'reaction-removed',
messageId,
@@ -270,412 +228,37 @@ export class MessagesEffects {
emoji,
});
return of(MessagesActions.removeReactionSuccess({ messageId, oderId: currentUser.id, emoji }));
})
)
return of(
MessagesActions.removeReactionSuccess({
messageId,
oderId: currentUser.id,
emoji,
}),
);
}),
),
);
// Listen to incoming messages from WebRTC peers
/**
* Central dispatcher for all incoming P2P messages.
* Delegates to handler functions in `messages-incoming.handlers.ts`.
*/
incomingMessages$ = createEffect(() =>
this.webrtc.onMessageReceived.pipe(
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom)
this.store.select(selectCurrentRoom),
),
mergeMap(([event, currentUser, currentRoom]: [any, any, any]) => {
console.log('Received peer message:', event.type, event);
switch (event.type) {
// Precise sync via ID inventory and targeted requests
case 'chat-inventory-request': {
const reqRoomId = event.roomId;
if (!reqRoomId || !event.fromPeerId) return of({ type: 'NO_OP' });
return from(this.db.getMessages(reqRoomId, this.INVENTORY_LIMIT, 0)).pipe(
mergeMap(async (messages) => {
const items = await Promise.all(
messages.map(async (m) => {
const reactions = await this.db.getReactionsForMessage(m.id);
return { id: m.id, ts: m.editedAt || m.timestamp || 0, rc: reactions.length };
})
);
items.sort((a, b) => a.ts - b.ts);
console.log(`[Sync] Sending inventory of ${items.length} items for room ${reqRoomId}`);
for (let i = 0; i < items.length; i += this.CHUNK_SIZE) {
const chunk = items.slice(i, i + this.CHUNK_SIZE);
this.webrtc.sendToPeer(event.fromPeerId, {
type: 'chat-inventory',
roomId: reqRoomId,
items: chunk,
total: items.length,
index: i,
} as any);
}
const ctx: IncomingMessageContext = {
db: this.db,
webrtc: this.webrtc,
attachments: this.attachments,
currentUser,
currentRoom,
};
return dispatchIncomingMessage(event, ctx);
}),
map(() => ({ type: 'NO_OP' }))
);
}
case 'chat-inventory': {
const invRoomId = event.roomId;
if (!invRoomId || !Array.isArray(event.items) || !event.fromPeerId) return of({ type: 'NO_OP' });
// Determine which IDs we are missing or have older versions of
return from(this.db.getMessages(invRoomId, this.INVENTORY_LIMIT, 0)).pipe(
mergeMap(async (local) => {
// Build local map with timestamps and reaction counts
const localMap = new Map<string, { ts: number; rc: number }>();
await Promise.all(
local.map(async (m) => {
const reactions = await this.db.getReactionsForMessage(m.id);
localMap.set(m.id, { ts: m.editedAt || m.timestamp || 0, rc: reactions.length });
})
);
const missing: string[] = [];
for (const item of event.items as Array<{ id: string; ts: number; rc?: number }>) {
const localEntry = localMap.get(item.id);
if (!localEntry) {
missing.push(item.id);
} else if (item.ts > localEntry.ts) {
missing.push(item.id);
} else if (item.rc !== undefined && item.rc !== localEntry.rc) {
missing.push(item.id);
}
}
console.log(`[Sync] Inventory received: ${event.items.length} remote, ${missing.length} missing/stale`);
// Request in chunks from the sender
for (let i = 0; i < missing.length; i += this.CHUNK_SIZE) {
const chunk = missing.slice(i, i + this.CHUNK_SIZE);
this.webrtc.sendToPeer(event.fromPeerId, {
type: 'chat-sync-request-ids',
roomId: invRoomId,
ids: chunk,
} as any);
}
return { type: 'NO_OP' } as any;
})
);
}
case 'chat-sync-request-ids': {
const syncReqRoomId = event.roomId;
if (!Array.isArray(event.ids) || !event.fromPeerId) return of({ type: 'NO_OP' });
const ids: string[] = event.ids;
return from(Promise.all(ids.map((id: string) => this.db.getMessageById(id)))).pipe(
mergeMap(async (maybeMessages) => {
const messages = maybeMessages.filter((m): m is Message => !!m);
// Hydrate reactions from the separate reactions table
const hydrated = await Promise.all(
messages.map(async (m) => {
const reactions = await this.db.getReactionsForMessage(m.id);
return { ...m, reactions };
})
);
// Collect attachment metadata for synced messages
const msgIds = hydrated.map(m => m.id);
const attachmentMetas = this.attachments.getAttachmentMetasForMessages(msgIds);
console.log(`[Sync] Sending ${hydrated.length} messages for ${ids.length} requested IDs`);
// Send in chunks to avoid large payloads
for (let i = 0; i < hydrated.length; i += this.CHUNK_SIZE) {
const chunk = hydrated.slice(i, i + this.CHUNK_SIZE);
// Include only attachments for this chunk
const chunkAttachments: Record<string, any> = {};
for (const m of chunk) {
if (attachmentMetas[m.id]) chunkAttachments[m.id] = attachmentMetas[m.id];
}
this.webrtc.sendToPeer(event.fromPeerId, {
type: 'chat-sync-batch',
roomId: syncReqRoomId || '',
messages: chunk,
attachments: Object.keys(chunkAttachments).length > 0 ? chunkAttachments : undefined,
} as any);
}
}),
map(() => ({ type: 'NO_OP' }))
);
}
case 'chat-sync-batch': {
if (!Array.isArray(event.messages)) return of({ type: 'NO_OP' });
// Register synced attachment metadata so the UI knows about them
if (event.attachments && typeof event.attachments === 'object') {
this.attachments.registerSyncedAttachments(event.attachments);
}
return from((async () => {
const toUpsert: Message[] = [];
for (const m of event.messages as Message[]) {
const existing = await this.db.getMessageById(m.id);
const ets = existing ? (existing.editedAt || existing.timestamp || 0) : -1;
const its = m.editedAt || m.timestamp || 0;
const isNewer = !existing || its > ets;
if (isNewer) {
await this.db.saveMessage(m);
}
// Persist incoming reactions to the reactions table (deduped)
const incomingReactions = m.reactions ?? [];
for (const r of incomingReactions) {
await this.db.saveReaction(r);
}
// Hydrate merged reactions from DB and upsert if anything changed
if (isNewer || incomingReactions.length > 0) {
const reactions = await this.db.getReactionsForMessage(m.id);
toUpsert.push({ ...(isNewer ? m : existing!), reactions });
}
}
// Auto-request unavailable images from the sender
if (event.attachments && event.fromPeerId) {
for (const [msgId, metas] of Object.entries(event.attachments) as [string, any[]][]) {
for (const meta of metas) {
if (meta.isImage) {
const atts = this.attachments.getForMessage(msgId);
const att = atts.find((a: any) => a.id === meta.id);
if (att && !att.available && !(att.receivedBytes && att.receivedBytes > 0)) {
this.attachments.requestImageFromAnyPeer(msgId, att);
}
}
}
}
}
return toUpsert;
})()).pipe(
mergeMap((toUpsert) => toUpsert.length ? of(MessagesActions.syncMessages({ messages: toUpsert })) : of({ type: 'NO_OP' }))
);
}
case 'voice-state':
// Update voice state for the sender
if (event.oderId && event.voiceState) {
const userId = event.oderId;
return of(UsersActions.updateVoiceState({ userId, voiceState: event.voiceState }));
}
break;
case 'chat-message':
// Save to local DB and dispatch receive action
// Skip if this is our own message (sent via server relay)
if (event.message && event.message.senderId !== currentUser?.id && event.message.senderId !== currentUser?.oderId) {
this.db.saveMessage(event.message);
return of(MessagesActions.receiveMessage({ message: event.message }));
}
break;
case 'file-announce':
this.attachments.handleFileAnnounce(event);
return of({ type: 'NO_OP' });
case 'file-chunk':
this.attachments.handleFileChunk(event);
return of({ type: 'NO_OP' });
case 'file-request':
// Uploader can fulfill request directly via AttachmentService
this.attachments.handleFileRequest(event);
return of({ type: 'NO_OP' });
case 'file-cancel':
// Stop any in-progress upload to the requester
this.attachments.handleFileCancel(event);
return of({ type: 'NO_OP' });
case 'file-not-found':
// Peer couldn't serve the file try another peer automatically
this.attachments.handleFileNotFound(event);
return of({ type: 'NO_OP' });
case 'message-edited':
if (event.messageId && event.content) {
this.db.updateMessage(event.messageId, { content: event.content, editedAt: event.editedAt });
return of(MessagesActions.editMessageSuccess({
messageId: event.messageId,
content: event.content,
editedAt: event.editedAt,
}));
}
break;
case 'message-deleted':
if (event.messageId) {
this.db.deleteMessage(event.messageId);
return of(MessagesActions.deleteMessageSuccess({ messageId: event.messageId }));
}
break;
case 'reaction-added':
if (event.messageId && event.reaction) {
this.db.saveReaction(event.reaction);
return of(MessagesActions.addReactionSuccess({
reaction: event.reaction,
}));
}
break;
case 'reaction-removed':
if (event.messageId && event.oderId && event.emoji) {
this.db.removeReaction(event.messageId, event.oderId, event.emoji);
return of(MessagesActions.removeReactionSuccess({
messageId: event.messageId,
oderId: event.oderId,
emoji: event.emoji,
}));
}
break;
// Chat sync handshake: summary -> request -> full
case 'chat-sync-summary':
// Compare summaries and request sync if the peer has newer data
if (!currentRoom) return of({ type: 'NO_OP' });
return from(this.db.getMessages(currentRoom.id, 10000, 0)).pipe(
tap((local) => {
const localCount = local.length;
const localLastUpdated = local.reduce((max, m) => Math.max(max, m.editedAt || m.timestamp || 0), 0);
const remoteLastUpdated = event.lastUpdated || 0;
const remoteCount = event.count || 0;
const identical = localLastUpdated === remoteLastUpdated && localCount === remoteCount;
const needsSync = remoteLastUpdated > localLastUpdated || (remoteLastUpdated === localLastUpdated && remoteCount > localCount);
if (!identical && needsSync && event.fromPeerId) {
this.webrtc.sendToPeer(event.fromPeerId, { type: 'chat-sync-request', roomId: currentRoom.id } as any);
}
}),
map(() => ({ type: 'NO_OP' }))
);
case 'chat-sync-request':
if (!currentRoom || !event.fromPeerId) return of({ type: 'NO_OP' });
return from(this.db.getMessages(currentRoom.id, 10000, 0)).pipe(
tap((all) => {
this.webrtc.sendToPeer(event.fromPeerId, { type: 'chat-sync-full', roomId: currentRoom.id, messages: all } as any);
}),
map(() => ({ type: 'NO_OP' }))
);
case 'chat-sync-full':
if (event.messages && Array.isArray(event.messages)) {
// Merge into local DB and update store
event.messages.forEach((m: Message) => this.db.saveMessage(m));
return of(MessagesActions.syncMessages({ messages: event.messages }));
}
break;
}
return of({ type: 'NO_OP' });
})
)
);
// On peer connect, broadcast local dataset summary
peerConnectedSync$ = createEffect(
() =>
this.webrtc.onPeerConnected.pipe(
withLatestFrom(this.store.select(selectCurrentRoom)),
mergeMap(([peerId, room]) => {
if (!room) return of({ type: 'NO_OP' });
return from(this.db.getMessages(room.id, 10000, 0)).pipe(
map((messages) => {
const count = messages.length;
const lastUpdated = messages.reduce((max, m) => Math.max(max, m.editedAt || m.timestamp || 0), 0);
// Send summary specifically to the newly connected peer
this.webrtc.sendToPeer(peerId, { type: 'chat-sync-summary', roomId: room.id, count, lastUpdated } as any);
// Also request their inventory for precise reconciliation
this.webrtc.sendToPeer(peerId, { type: 'chat-inventory-request', roomId: room.id } as any);
return { type: 'NO_OP' };
})
);
})
),
{ dispatch: false }
);
// Kick off sync to all currently connected peers shortly after joining a room
joinRoomSyncKickoff$ = createEffect(
() =>
this.actions$.pipe(
ofType(RoomsActions.joinRoomSuccess),
withLatestFrom(this.store.select(selectCurrentRoom)),
mergeMap(([{ room }, currentRoom]) => {
const activeRoom = currentRoom || room;
if (!activeRoom) return of({ type: 'NO_OP' });
return from(this.db.getMessages(activeRoom.id, 10000, 0)).pipe(
tap((messages) => {
const count = messages.length;
const lastUpdated = messages.reduce((max, m) => Math.max(max, m.editedAt || m.timestamp || 0), 0);
const peers = this.webrtc.getConnectedPeers();
peers.forEach((pid) => {
try {
this.webrtc.sendToPeer(pid, { type: 'chat-sync-summary', roomId: activeRoom.id, count, lastUpdated } as any);
this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: activeRoom.id } as any);
} catch {}
});
}),
map(() => ({ type: 'NO_OP' }))
);
})
),
{ dispatch: false }
);
// Periodic sync poll 10s when catching up, 15min after a clean sync
private syncReset$ = new Subject<void>();
periodicSyncPoll$ = createEffect(() =>
timer(this.SYNC_POLL_FAST_MS).pipe(
// After each emission, decide the next delay based on last result
repeat({ delay: () => timer(this.lastSyncClean ? this.SYNC_POLL_SLOW_MS : this.SYNC_POLL_FAST_MS) }),
takeUntil(this.syncReset$), // restart via syncReset$ is handled externally if needed
withLatestFrom(
this.store.select(selectCurrentRoom)
),
filter(([, room]) => !!room && this.webrtc.getConnectedPeers().length > 0),
exhaustMap(([, room]) => {
const peers = this.webrtc.getConnectedPeers();
if (!room || peers.length === 0) return of(MessagesActions.syncComplete());
return from(this.db.getMessages(room.id, this.INVENTORY_LIMIT, 0)).pipe(
map((messages) => {
peers.forEach((pid) => {
try {
this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: room.id } as any);
} catch {}
});
return MessagesActions.startSync();
}),
catchError(() => {
this.lastSyncClean = false;
return of(MessagesActions.syncComplete());
})
);
})
)
);
// Auto-complete sync after a timeout if no sync messages arrive
syncTimeout$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.startSync),
switchMap(() => {
// If no syncMessages or syncComplete within 5s, auto-complete
return new Promise<void>((resolve) => setTimeout(resolve, 5000));
}),
withLatestFrom(this.store.select(selectMessagesSyncing)),
filter(([, syncing]) => syncing),
map(() => {
// No new messages arrived during this cycle → clean sync, slow down
this.lastSyncClean = true;
return MessagesActions.syncComplete();
})
)
);
// When new messages actually arrive via sync, switch back to fast polling
syncReceivedMessages$ = createEffect(
() =>
this.webrtc.onPeerConnected.pipe(
// A peer (re)connecting means we may have been offline — revert to aggressive polling
tap(() => { this.lastSyncClean = false; })
),
{ dispatch: false }
);
}

View File

@@ -0,0 +1,156 @@
/**
* Pure utility functions and constants for the messages store slice.
*
* Extracted from messages.effects.ts to improve readability, testability,
* and reuse across effects and handler files.
*/
import { Message } from '../../core/models';
import { DatabaseService } from '../../core/services/database.service';
/** Maximum number of recent messages to include in sync inventories. */
export const INVENTORY_LIMIT = 1000;
/** Number of messages per chunk for inventory / batch transfers. */
export const CHUNK_SIZE = 200;
/** Aggressive sync poll interval (10 seconds). */
export const SYNC_POLL_FAST_MS = 10_000;
/** Idle sync poll interval after a clean (no-new-messages) cycle (15 minutes). */
export const SYNC_POLL_SLOW_MS = 900_000;
/** Sync timeout duration before auto-completing a cycle (5 seconds). */
export const SYNC_TIMEOUT_MS = 5_000;
/** Large limit used for legacy full-sync operations. */
export const FULL_SYNC_LIMIT = 10_000;
/** Extracts the effective timestamp from a message (editedAt takes priority). */
export function getMessageTimestamp(msg: Message): number {
return msg.editedAt || msg.timestamp || 0;
}
/** Computes the most recent timestamp across a batch of messages. */
export function getLatestTimestamp(messages: Message[]): number {
return messages.reduce(
(max, msg) => Math.max(max, getMessageTimestamp(msg)),
0,
);
}
/** Splits an array into chunks of the given size. */
export function chunkArray<T>(items: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < items.length; i += size) {
chunks.push(items.slice(i, i + size));
}
return chunks;
}
/** Hydrates a single message with its reactions from the database. */
export async function hydrateMessage(
msg: Message,
db: DatabaseService,
): Promise<Message> {
const reactions = await db.getReactionsForMessage(msg.id);
return reactions.length > 0 ? { ...msg, reactions } : msg;
}
/** Hydrates an array of messages with their reactions. */
export async function hydrateMessages(
messages: Message[],
db: DatabaseService,
): Promise<Message[]> {
return Promise.all(messages.map((msg) => hydrateMessage(msg, db)));
}
/** Inventory item representing a message's sync state. */
export interface InventoryItem {
id: string;
ts: number;
rc: number;
}
/** Builds a sync inventory item from a message and its reaction count. */
export async function buildInventoryItem(
msg: Message,
db: DatabaseService,
): Promise<InventoryItem> {
const reactions = await db.getReactionsForMessage(msg.id);
return { id: msg.id, ts: getMessageTimestamp(msg), rc: reactions.length };
}
/** Builds a local map of `{timestamp, reactionCount}` keyed by message ID. */
export async function buildLocalInventoryMap(
messages: Message[],
db: DatabaseService,
): Promise<Map<string, { ts: number; rc: number }>> {
const map = new Map<string, { ts: number; rc: number }>();
await Promise.all(
messages.map(async (msg) => {
const reactions = await db.getReactionsForMessage(msg.id);
map.set(msg.id, { ts: getMessageTimestamp(msg), rc: reactions.length });
}),
);
return map;
}
/** Identifies missing or stale message IDs by comparing remote items against a local map. */
export function findMissingIds(
remoteItems: ReadonlyArray<{ id: string; ts: number; rc?: number }>,
localMap: ReadonlyMap<string, { ts: number; rc: number }>,
): string[] {
const missing: string[] = [];
for (const item of remoteItems) {
const local = localMap.get(item.id);
if (
!local ||
item.ts > local.ts ||
(item.rc !== undefined && item.rc !== local.rc)
) {
missing.push(item.id);
}
}
return missing;
}
/** Result of merging an incoming message into the local database. */
export interface MergeResult {
message: Message;
changed: boolean;
}
/**
* Merges an incoming message into the local database.
* Handles message upsert and reaction deduplication, then returns
* the fully hydrated message alongside a `changed` flag.
*/
export async function mergeIncomingMessage(
incoming: Message,
db: DatabaseService,
): Promise<MergeResult> {
const existing = await db.getMessageById(incoming.id);
const existingTs = existing ? getMessageTimestamp(existing) : -1;
const incomingTs = getMessageTimestamp(incoming);
const isNewer = !existing || incomingTs > existingTs;
if (isNewer) {
await db.saveMessage(incoming);
}
// Persist incoming reactions (deduped by the DB layer)
const incomingReactions = incoming.reactions ?? [];
for (const reaction of incomingReactions) {
await db.saveReaction(reaction);
}
const changed = isNewer || incomingReactions.length > 0;
if (changed) {
const reactions = await db.getReactionsForMessage(incoming.id);
return {
message: { ...(isNewer ? incoming : existing!), reactions },
changed,
};
}
return { message: existing!, changed: false };
}

View File

@@ -1,12 +1,17 @@
import { createReducer, on } from '@ngrx/store';
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
import { Message } from '../../core/models';
import * as MessagesActions from './messages.actions';
import { MessagesActions } from './messages.actions';
/** State shape for the messages feature slice, extending NgRx EntityState. */
export interface MessagesState extends EntityState<Message> {
/** Whether messages are being loaded from the database. */
loading: boolean;
/** Whether a peer-to-peer sync cycle is in progress. */
syncing: boolean;
/** Most recent error message from message operations. */
error: string | null;
/** ID of the room whose messages are currently loaded. */
currentRoomId: string | null;
}
@@ -108,7 +113,7 @@ export const messagesReducer = createReducer(
if (!message) return state;
const existingReaction = message.reactions.find(
(r) => r.emoji === reaction.emoji && r.userId === reaction.userId
(existing) => existing.emoji === reaction.emoji && existing.userId === reaction.userId
);
if (existingReaction) return state;
@@ -134,7 +139,7 @@ export const messagesReducer = createReducer(
id: messageId,
changes: {
reactions: message.reactions.filter(
(r) => !(r.emoji === emoji && r.userId === oderId)
(existingReaction) => !(existingReaction.emoji === emoji && existingReaction.userId === oderId)
),
},
},
@@ -155,18 +160,18 @@ export const messagesReducer = createReducer(
// Sync messages from peer (merge reactions to avoid losing local-only reactions)
on(MessagesActions.syncMessages, (state, { messages }) => {
const merged = messages.map(m => {
const existing = state.entities[m.id];
const merged = messages.map(message => {
const existing = state.entities[message.id];
if (existing?.reactions?.length) {
const combined = [...(m.reactions ?? [])];
for (const r of existing.reactions) {
if (!combined.some(c => c.userId === r.userId && c.emoji === r.emoji && c.messageId === r.messageId)) {
combined.push(r);
const combined = [...(message.reactions ?? [])];
for (const existingReaction of existing.reactions) {
if (!combined.some(combinedReaction => combinedReaction.userId === existingReaction.userId && combinedReaction.emoji === existingReaction.emoji && combinedReaction.messageId === existingReaction.messageId)) {
combined.push(existingReaction);
}
}
return { ...m, reactions: combined };
return { ...message, reactions: combined };
}
return m;
return message;
});
return messagesAdapter.upsertMany(merged, {
...state,

View File

@@ -1,45 +1,55 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { MessagesState, messagesAdapter } from './messages.reducer';
/** Selects the top-level messages feature state. */
export const selectMessagesState = createFeatureSelector<MessagesState>('messages');
const { selectIds, selectEntities, selectAll, selectTotal } = messagesAdapter.getSelectors();
/** Selects all message entities as a flat array. */
export const selectAllMessages = createSelector(selectMessagesState, selectAll);
/** Selects the message entity dictionary keyed by ID. */
export const selectMessagesEntities = createSelector(selectMessagesState, selectEntities);
/** Selects all message IDs. */
export const selectMessagesIds = createSelector(selectMessagesState, selectIds);
/** Selects the total count of messages. */
export const selectMessagesTotal = createSelector(selectMessagesState, selectTotal);
/** Whether messages are currently being loaded from the database. */
export const selectMessagesLoading = createSelector(
selectMessagesState,
(state) => state.loading
);
/** Selects the most recent messages-related error message. */
export const selectMessagesError = createSelector(
selectMessagesState,
(state) => state.error
);
/** Whether a peer-to-peer message sync cycle is in progress. */
export const selectMessagesSyncing = createSelector(
selectMessagesState,
(state) => state.syncing
);
/** Selects the ID of the room whose messages are currently loaded. */
export const selectCurrentRoomId = createSelector(
selectMessagesState,
(state) => state.currentRoomId
);
/** Selects all messages belonging to the currently active room. */
export const selectCurrentRoomMessages = createSelector(
selectAllMessages,
selectCurrentRoomId,
(messages, roomId) => roomId ? messages.filter((m) => m.roomId === roomId) : []
(messages, roomId) => roomId ? messages.filter((message) => message.roomId === roomId) : []
);
/** Select messages for the currently active text channel */
/** Creates a selector that returns messages for a specific text channel within the current room. */
export const selectChannelMessages = (channelId: string) =>
createSelector(
selectAllMessages,
@@ -47,25 +57,29 @@ export const selectChannelMessages = (channelId: string) =>
(messages, roomId) => {
if (!roomId) return [];
return messages.filter(
(m) => m.roomId === roomId && (m.channelId || 'general') === channelId
(message) => message.roomId === roomId && (message.channelId || 'general') === channelId
);
}
);
/** Creates a selector that returns a single message by its ID. */
export const selectMessageById = (id: string) =>
createSelector(selectMessagesEntities, (entities) => entities[id]);
/** Creates a selector that returns all messages for a specific room. */
export const selectMessagesByRoomId = (roomId: string) =>
createSelector(selectAllMessages, (messages) =>
messages.filter((m) => m.roomId === roomId)
messages.filter((message) => message.roomId === roomId)
);
/** Creates a selector that returns the N most recent messages. */
export const selectRecentMessages = (limit: number) =>
createSelector(selectAllMessages, (messages) =>
messages.slice(-limit)
);
/** Selects only messages that have at least one reaction. */
export const selectMessagesWithReactions = createSelector(
selectAllMessages,
(messages) => messages.filter((m) => m.reactions.length > 0)
(messages) => messages.filter((message) => message.reactions.length > 0)
);

View File

@@ -1,190 +1,62 @@
import { createAction, props } from '@ngrx/store';
/**
* Rooms store actions using `createActionGroup`.
*/
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { Room, RoomSettings, ServerInfo, RoomPermissions, Channel } from '../../core/models';
// Load rooms from storage
export const loadRooms = createAction('[Rooms] Load Rooms');
export const RoomsActions = createActionGroup({
source: 'Rooms',
events: {
'Load Rooms': emptyProps(),
'Load Rooms Success': props<{ rooms: Room[] }>(),
'Load Rooms Failure': props<{ error: string }>(),
export const loadRoomsSuccess = createAction(
'[Rooms] Load Rooms Success',
props<{ rooms: Room[] }>()
);
'Search Servers': props<{ query: string }>(),
'Search Servers Success': props<{ servers: ServerInfo[] }>(),
'Search Servers Failure': props<{ error: string }>(),
export const loadRoomsFailure = createAction(
'[Rooms] Load Rooms Failure',
props<{ error: string }>()
);
'Create Room': props<{ name: string; description?: string; topic?: string; isPrivate?: boolean; password?: string }>(),
'Create Room Success': props<{ room: Room }>(),
'Create Room Failure': props<{ error: string }>(),
// Search servers
export const searchServers = createAction(
'[Rooms] Search Servers',
props<{ query: string }>()
);
'Join Room': props<{ roomId: string; password?: string; serverInfo?: { name: string; description?: string; hostName?: string } }>(),
'Join Room Success': props<{ room: Room }>(),
'Join Room Failure': props<{ error: string }>(),
export const searchServersSuccess = createAction(
'[Rooms] Search Servers Success',
props<{ servers: ServerInfo[] }>()
);
'Leave Room': emptyProps(),
'Leave Room Success': emptyProps(),
export const searchServersFailure = createAction(
'[Rooms] Search Servers Failure',
props<{ error: string }>()
);
'View Server': props<{ room: Room }>(),
'View Server Success': props<{ room: Room }>(),
// Create room
export const createRoom = createAction(
'[Rooms] Create Room',
props<{ name: string; description?: string; topic?: string; isPrivate?: boolean; password?: string }>()
);
'Delete Room': props<{ roomId: string }>(),
'Delete Room Success': props<{ roomId: string }>(),
export const createRoomSuccess = createAction(
'[Rooms] Create Room Success',
props<{ room: Room }>()
);
'Forget Room': props<{ roomId: string }>(),
'Forget Room Success': props<{ roomId: string }>(),
export const createRoomFailure = createAction(
'[Rooms] Create Room Failure',
props<{ error: string }>()
);
'Update Room Settings': props<{ settings: Partial<RoomSettings> }>(),
'Update Room Settings Success': props<{ settings: RoomSettings }>(),
'Update Room Settings Failure': props<{ error: string }>(),
// Join room
export const joinRoom = createAction(
'[Rooms] Join Room',
props<{ roomId: string; password?: string; serverInfo?: { name: string; description?: string; hostName?: string } }>()
);
'Update Room Permissions': props<{ roomId: string; permissions: Partial<RoomPermissions> }>(),
export const joinRoomSuccess = createAction(
'[Rooms] Join Room Success',
props<{ room: Room }>()
);
'Update Server Icon': props<{ roomId: string; icon: string }>(),
'Update Server Icon Success': props<{ roomId: string; icon: string; iconUpdatedAt: number }>(),
'Update Server Icon Failure': props<{ error: string }>(),
export const joinRoomFailure = createAction(
'[Rooms] Join Room Failure',
props<{ error: string }>()
);
'Set Current Room': props<{ room: Room }>(),
'Clear Current Room': emptyProps(),
// Leave room
export const leaveRoom = createAction('[Rooms] Leave Room');
'Update Room': props<{ roomId: string; changes: Partial<Room> }>(),
'Receive Room Update': props<{ room: Partial<Room> }>(),
export const leaveRoomSuccess = createAction('[Rooms] Leave Room Success');
'Select Channel': props<{ channelId: string }>(),
'Add Channel': props<{ channel: Channel }>(),
'Remove Channel': props<{ channelId: string }>(),
'Rename Channel': props<{ channelId: string; name: string }>(),
// View server (switch view without disconnecting)
export const viewServer = createAction(
'[Rooms] View Server',
props<{ room: Room }>()
);
export const viewServerSuccess = createAction(
'[Rooms] View Server Success',
props<{ room: Room }>()
);
// Delete room
export const deleteRoom = createAction(
'[Rooms] Delete Room',
props<{ roomId: string }>()
);
export const deleteRoomSuccess = createAction(
'[Rooms] Delete Room Success',
props<{ roomId: string }>()
);
// Forget room locally
export const forgetRoom = createAction(
'[Rooms] Forget Room',
props<{ roomId: string }>()
);
export const forgetRoomSuccess = createAction(
'[Rooms] Forget Room Success',
props<{ roomId: string }>()
);
// Update room settings
export const updateRoomSettings = createAction(
'[Rooms] Update Room Settings',
props<{ settings: Partial<RoomSettings> }>()
);
export const updateRoomSettingsSuccess = createAction(
'[Rooms] Update Room Settings Success',
props<{ settings: RoomSettings }>()
);
export const updateRoomSettingsFailure = createAction(
'[Rooms] Update Room Settings Failure',
props<{ error: string }>()
);
// Update room permissions
export const updateRoomPermissions = createAction(
'[Rooms] Update Room Permissions',
props<{ roomId: string; permissions: Partial<RoomPermissions> }>()
);
// Update server icon (permission enforced)
export const updateServerIcon = createAction(
'[Rooms] Update Server Icon',
props<{ roomId: string; icon: string }>()
);
export const updateServerIconSuccess = createAction(
'[Rooms] Update Server Icon Success',
props<{ roomId: string; icon: string; iconUpdatedAt: number }>()
);
export const updateServerIconFailure = createAction(
'[Rooms] Update Server Icon Failure',
props<{ error: string }>()
);
// Set current room
export const setCurrentRoom = createAction(
'[Rooms] Set Current Room',
props<{ room: Room }>()
);
// Clear current room
export const clearCurrentRoom = createAction('[Rooms] Clear Current Room');
// Update room
export const updateRoom = createAction(
'[Rooms] Update Room',
props<{ roomId: string; changes: Partial<Room> }>()
);
// Receive room update from peer
export const receiveRoomUpdate = createAction(
'[Rooms] Receive Room Update',
props<{ room: Partial<Room> }>()
);
// Channel management
export const selectChannel = createAction(
'[Rooms] Select Channel',
props<{ channelId: string }>()
);
export const addChannel = createAction(
'[Rooms] Add Channel',
props<{ channel: Channel }>()
);
export const removeChannel = createAction(
'[Rooms] Remove Channel',
props<{ channelId: string }>()
);
export const renameChannel = createAction(
'[Rooms] Rename Channel',
props<{ channelId: string; name: string }>()
);
// Clear search results
export const clearSearchResults = createAction('[Rooms] Clear Search Results');
// Set connection status
export const setConnecting = createAction(
'[Rooms] Set Connecting',
props<{ isConnecting: boolean }>()
);
'Clear Search Results': emptyProps(),
'Set Connecting': props<{ isConnecting: boolean }>(),
},
});

View File

@@ -2,7 +2,7 @@ import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { of, from } from 'rxjs';
import { of, from, EMPTY } from 'rxjs';
import {
map,
mergeMap,
@@ -11,18 +11,41 @@ import {
tap,
debounceTime,
switchMap,
filter,
} from 'rxjs/operators';
import { v4 as uuidv4 } from 'uuid';
import * as RoomsActions from './rooms.actions';
import * as UsersActions from '../users/users.actions';
import * as MessagesActions from '../messages/messages.actions';
import { selectCurrentUser } from '../users/users.selectors';
import { RoomsActions } from './rooms.actions';
import { UsersActions } from '../users/users.actions';
import { MessagesActions } from '../messages/messages.actions';
import { selectCurrentUser, selectAllUsers } from '../users/users.selectors';
import { selectCurrentRoom } from './rooms.selectors';
import { DatabaseService } from '../../core/services/database.service';
import { WebRTCService } from '../../core/services/webrtc.service';
import { ServerDirectoryService } from '../../core/services/server-directory.service';
import { Room, RoomSettings, RoomPermissions } from '../../core/models';
import { selectAllUsers } from '../users/users.selectors';
import { Room, RoomSettings, RoomPermissions, VoiceState } from '../../core/models';
/** Build a minimal User object from signaling payload. */
function buildSignalingUser(
data: { oderId: string; displayName: string },
extras: Record<string, unknown> = {},
) {
return {
oderId: data.oderId,
id: data.oderId,
username: data.displayName.toLowerCase().replace(/\s+/g, '_'),
displayName: data.displayName,
status: 'online' as const,
isOnline: true,
role: 'member' as const,
joinedAt: Date.now(),
...extras,
};
}
/** Returns true when the message's server ID does not match the viewed server. */
function isWrongServer(msgServerId: string | undefined, viewedServerId: string | undefined): boolean {
return !!(msgServerId && viewedServerId && msgServerId !== viewedServerId);
}
@Injectable()
export class RoomsEffects {
@@ -33,7 +56,7 @@ export class RoomsEffects {
private webrtc = inject(WebRTCService);
private serverDirectory = inject(ServerDirectoryService);
// Load rooms from database
/** Loads all saved rooms from the local database. */
loadRooms$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.loadRooms),
@@ -48,7 +71,7 @@ export class RoomsEffects {
)
);
// Search servers with debounce
/** Searches the server directory with debounced input. */
searchServers$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.searchServers),
@@ -64,7 +87,7 @@ export class RoomsEffects {
)
);
// Create room
/** Creates a new room, saves it locally, and registers it with the server directory. */
createRoom$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.createRoom),
@@ -104,10 +127,7 @@ export class RoomsEffects {
maxUsers: room.maxUsers || 50,
tags: [],
})
.subscribe({
next: () => console.log('Room registered with directory, ID:', room.id),
error: (err) => console.warn('Failed to register room:', err),
});
.subscribe();
return of(RoomsActions.createRoomSuccess({ room }));
}),
@@ -117,7 +137,7 @@ export class RoomsEffects {
)
);
// Join room
/** Joins an existing room by ID, resolving room data from local DB or server directory. */
joinRoom$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.joinRoom),
@@ -183,7 +203,7 @@ export class RoomsEffects {
)
);
// Navigate to room after successful create or join
/** Navigates to the room view and establishes or reuses a signaling connection. */
navigateToRoom$ = createEffect(
() =>
this.actions$.pipe(
@@ -196,23 +216,18 @@ export class RoomsEffects {
// Check if already connected to signaling server
if (this.webrtc.isConnected()) {
// Already connected - join the new server (additive, multi-server)
console.log('Already connected to signaling, joining room:', room.id);
this.webrtc.setCurrentServer(room.id);
this.webrtc.switchServer(room.id, oderId);
} else {
// Not connected - establish new connection
console.log('Connecting to signaling server:', wsUrl);
this.webrtc.connectToSignalingServer(wsUrl).subscribe({
next: (connected) => {
if (connected) {
console.log('Connected to signaling, identifying user and joining room');
this.webrtc.setCurrentServer(room.id);
this.webrtc.identify(oderId, displayName);
this.webrtc.joinRoom(room.id, oderId);
}
},
error: (err) => console.error('Failed to connect to signaling server:', err),
error: () => {},
});
}
@@ -222,7 +237,7 @@ export class RoomsEffects {
{ dispatch: false }
);
// View server switch view to an already-joined server without leaving others
/** Switches the UI view to an already-joined server without leaving others. */
viewServer$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.viewServer),
@@ -241,7 +256,7 @@ export class RoomsEffects {
)
);
// When viewing a different server, reload messages and users for that server
/** Reloads messages and users when the viewed server changes. */
onViewServerSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.viewServerSuccess),
@@ -253,20 +268,15 @@ export class RoomsEffects {
)
);
// Leave room
/** Handles leave-room dispatches (navigation only, peers stay connected). */
leaveRoom$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.leaveRoom),
withLatestFrom(this.store.select(selectCurrentRoom)),
mergeMap(([, currentRoom]) => {
// Do not disconnect peers or voice on simple room exit
// Navigation away from room should not kill voice or P2P.
return of(RoomsActions.leaveRoomSuccess());
})
)
map(() => RoomsActions.leaveRoomSuccess()),
),
);
// Delete room
/** Deletes a room (host-only): removes from DB, notifies peers, and disconnects. */
deleteRoom$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.deleteRoom),
@@ -274,34 +284,19 @@ export class RoomsEffects {
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom)
),
switchMap(([{ roomId }, currentUser, currentRoom]) => {
if (!currentUser) {
return of({ type: 'NO_OP' });
}
// Only host can delete the room
if (currentRoom?.hostId !== currentUser.id) {
return of({ type: 'NO_OP' });
}
// Delete from local DB
filter(([, currentUser, currentRoom]) =>
!!currentUser && currentRoom?.hostId === currentUser.id,
),
switchMap(([{ roomId }]) => {
this.db.deleteRoom(roomId);
// Notify all connected peers
this.webrtc.broadcastMessage({
type: 'room-deleted',
roomId,
});
// Disconnect everyone
this.webrtc.broadcastMessage({ type: 'room-deleted', roomId });
this.webrtc.disconnectAll();
return of(RoomsActions.deleteRoomSuccess({ roomId }));
})
)
}),
),
);
// Forget room locally (remove from savedRooms and local DB)
/** Forgets a room locally: removes from DB and leaves the signaling server for that room. */
forgetRoom$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.forgetRoom),
@@ -318,7 +313,7 @@ export class RoomsEffects {
)
);
// Update room settings
/** Updates room settings (host/admin-only) and broadcasts changes to all peers. */
updateRoomSettings$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.updateRoomSettings),
@@ -373,7 +368,7 @@ export class RoomsEffects {
)
);
// Update room
/** Persists room field changes to the local database. */
updateRoom$ = createEffect(
() =>
this.actions$.pipe(
@@ -388,21 +383,17 @@ export class RoomsEffects {
{ dispatch: false }
);
// Update room permissions (host only)
/** Updates room permission grants (host-only) and broadcasts to peers. */
updateRoomPermissions$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.updateRoomPermissions),
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
mergeMap(([{ roomId, permissions }, currentUser, currentRoom]) => {
if (!currentUser || !currentRoom || currentRoom.id !== roomId) {
return of({ type: 'NO_OP' });
}
// Only host can change permission grant settings
if (currentRoom.hostId !== currentUser.id) {
return of({ type: 'NO_OP' });
}
filter(([{ roomId }, currentUser, currentRoom]) =>
!!currentUser && !!currentRoom && currentRoom.id === roomId && currentRoom.hostId === currentUser.id,
),
mergeMap(([{ roomId, permissions }, , currentRoom]) => {
const updated: Partial<Room> = {
permissions: { ...(currentRoom.permissions || {}), ...permissions } as RoomPermissions,
permissions: { ...(currentRoom!.permissions || {}), ...permissions } as RoomPermissions,
};
this.db.updateRoom(roomId, updated);
// Broadcast to peers
@@ -412,7 +403,7 @@ export class RoomsEffects {
)
);
// Update server icon (host or permitted roles)
/** Updates the server icon (permission-enforced) and broadcasts to peers. */
updateServerIcon$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.updateServerIcon),
@@ -440,7 +431,7 @@ export class RoomsEffects {
)
);
// Persist room creation to database
/** Persists newly created room to the local database. */
persistRoomCreation$ = createEffect(
() =>
this.actions$.pipe(
@@ -452,7 +443,7 @@ export class RoomsEffects {
{ dispatch: false }
);
// When joining a room, also load messages and users
/** Loads messages and bans when joining a room. */
onJoinRoomSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.joinRoomSuccess),
@@ -465,7 +456,7 @@ export class RoomsEffects {
)
);
// When leaving a room, clear messages and users
/** Clears messages and users from the store when leaving a room. */
onLeaveRoom$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.leaveRoomSuccess),
@@ -476,7 +467,7 @@ export class RoomsEffects {
)
);
// Listen to WebRTC signaling messages for user presence
/** Handles WebRTC signaling events for user presence (join, leave, server_users). */
signalingMessages$ = createEffect(() =>
this.webrtc.onSignalingMessage.pipe(
withLatestFrom(
@@ -484,108 +475,82 @@ export class RoomsEffects {
this.store.select(selectCurrentRoom),
),
mergeMap(([message, currentUser, currentRoom]: [any, any, any]) => {
const actions: any[] = [];
const myId = currentUser?.oderId || currentUser?.id;
const viewedServerId = currentRoom?.id;
if (message.type === 'server_users' && message.users) {
// Only populate for the currently viewed server
const msgServerId = message.serverId;
if (msgServerId && viewedServerId && msgServerId !== viewedServerId) {
return [{ type: 'NO_OP' }];
switch (message.type) {
case 'server_users': {
if (!message.users || isWrongServer(message.serverId, viewedServerId)) return EMPTY;
const joinActions = (message.users as { oderId: string; displayName: string }[])
.filter((u) => u.oderId !== myId)
.map((u) => UsersActions.userJoined({ user: buildSignalingUser(u) }));
return [UsersActions.clearUsers(), ...joinActions];
}
// Clear existing users first, then add the new set
actions.push(UsersActions.clearUsers());
// Add all existing users to the store (excluding ourselves)
message.users.forEach((user: { oderId: string; displayName: string }) => {
// Don't add ourselves to the list
if (user.oderId !== myId) {
actions.push(
UsersActions.userJoined({
user: {
oderId: user.oderId,
id: user.oderId,
username: user.displayName.toLowerCase().replace(/\s+/g, '_'),
displayName: user.displayName,
status: 'online',
isOnline: true,
role: 'member',
joinedAt: Date.now(),
},
})
);
case 'user_joined': {
if (isWrongServer(message.serverId, viewedServerId) || message.oderId === myId) return EMPTY;
return [UsersActions.userJoined({ user: buildSignalingUser(message) })];
}
});
} else if (message.type === 'user_joined') {
// Only add to user list if this event is for the currently viewed server
const msgServerId = message.serverId;
if (msgServerId && viewedServerId && msgServerId !== viewedServerId) {
return [{ type: 'NO_OP' }];
case 'user_left': {
if (isWrongServer(message.serverId, viewedServerId)) return EMPTY;
return [UsersActions.userLeft({ userId: message.oderId })];
}
// Don't add ourselves to the list
if (message.oderId !== myId) {
actions.push(
UsersActions.userJoined({
user: {
oderId: message.oderId,
id: message.oderId,
username: message.displayName.toLowerCase().replace(/\s+/g, '_'),
displayName: message.displayName,
status: 'online',
isOnline: true,
role: 'member',
joinedAt: Date.now(),
},
})
);
default:
return EMPTY;
}
} else if (message.type === 'user_left') {
// Only remove from user list if this event is for the currently viewed server
const msgServerId = message.serverId;
if (msgServerId && viewedServerId && msgServerId !== viewedServerId) {
return [{ type: 'NO_OP' }];
}
actions.push(UsersActions.userLeft({ userId: message.oderId }));
}
return actions.length > 0 ? actions : [{ type: 'NO_OP' }];
})
)
}),
),
);
// Incoming P2P room/icon events
/** Processes incoming P2P room and icon-sync events. */
incomingRoomEvents$ = createEffect(() =>
this.webrtc.onMessageReceived.pipe(
withLatestFrom(
this.store.select(selectCurrentRoom),
this.store.select(selectAllUsers)
this.store.select(selectAllUsers),
),
mergeMap(([event, currentRoom, allUsers]: [any, Room | null, any[]]) => {
if (!currentRoom) return of({ type: 'NO_OP' });
filter(([, room]) => !!room),
mergeMap(([event, currentRoom, allUsers]: [any, any, any[]]) => {
const room = currentRoom as Room;
switch (event.type) {
case 'voice-state': {
const userId = (event.fromPeerId as string) || (event.oderId as string);
const vs = event.voiceState as Partial<import('../../core/models').VoiceState> | undefined;
if (!userId || !vs) return of({ type: 'NO_OP' });
case 'voice-state':
return this.handleVoiceOrScreenState(event, allUsers, 'voice');
case 'screen-state':
return this.handleVoiceOrScreenState(event, allUsers, 'screen');
case 'room-settings-update':
return this.handleRoomSettingsUpdate(event, room);
case 'server-icon-summary':
return this.handleIconSummary(event, room);
case 'server-icon-request':
return this.handleIconRequest(event, room);
case 'server-icon-full':
case 'server-icon-update':
return this.handleIconData(event, room);
default:
return EMPTY;
}
}),
),
);
// Check if user exists in the store
const userExists = allUsers.some(u => u.id === userId || u.oderId === userId);
private handleVoiceOrScreenState(
event: any,
allUsers: any[],
kind: 'voice' | 'screen',
) {
const userId: string | undefined = event.fromPeerId ?? event.oderId;
if (!userId) return EMPTY;
const userExists = allUsers.some((u) => u.id === userId || u.oderId === userId);
if (kind === 'voice') {
const vs = event.voiceState as Partial<VoiceState> | undefined;
if (!vs) return EMPTY;
if (!userExists) {
// User doesn't exist yet - create them with the voice state
// This handles the race condition where voice-state arrives before server_users
const displayName = event.displayName || 'User';
return of(UsersActions.userJoined({
user: {
oderId: userId,
id: userId,
username: displayName.toLowerCase().replace(/\s+/g, '_'),
displayName: displayName,
status: 'online',
isOnline: true,
role: 'member',
joinedAt: Date.now(),
user: buildSignalingUser(
{ oderId: userId, displayName: event.displayName || 'User' },
{
voiceState: {
isConnected: vs.isConnected ?? false,
isMuted: vs.isMuted ?? false,
@@ -597,110 +562,87 @@ export class RoomsEffects {
serverId: vs.serverId,
},
},
),
}));
}
return of(UsersActions.updateVoiceState({ userId, voiceState: vs }));
}
case 'screen-state': {
const userId = (event.fromPeerId as string) || (event.oderId as string);
const isSharing = event.isScreenSharing as boolean | undefined;
if (!userId || isSharing === undefined) return of({ type: 'NO_OP' });
// Check if user exists in the store
const userExists = allUsers.some(u => u.id === userId || u.oderId === userId);
// screen-state
const isSharing = event.isScreenSharing as boolean | undefined;
if (isSharing === undefined) return EMPTY;
if (!userExists) {
// User doesn't exist yet - create them with the screen share state
const displayName = event.displayName || 'User';
return of(UsersActions.userJoined({
user: {
oderId: userId,
id: userId,
username: displayName.toLowerCase().replace(/\s+/g, '_'),
displayName: displayName,
status: 'online',
isOnline: true,
role: 'member',
joinedAt: Date.now(),
screenShareState: {
isSharing,
},
},
user: buildSignalingUser(
{ oderId: userId, displayName: event.displayName || 'User' },
{ screenShareState: { isSharing } },
),
}));
}
return of(UsersActions.updateScreenShareState({
userId,
screenShareState: { isSharing },
}));
}
case 'room-settings-update': {
private handleRoomSettingsUpdate(event: any, room: Room) {
const settings: RoomSettings | undefined = event.settings;
if (!settings) return of({ type: 'NO_OP' });
this.db.updateRoom(currentRoom.id, settings);
return of(
RoomsActions.receiveRoomUpdate({ room: { ...settings } as Partial<Room> })
);
if (!settings) return EMPTY;
this.db.updateRoom(room.id, settings);
return of(RoomsActions.receiveRoomUpdate({ room: { ...settings } as Partial<Room> }));
}
// Server icon sync handshake
case 'server-icon-summary': {
private handleIconSummary(event: any, room: Room) {
const remoteUpdated = event.iconUpdatedAt || 0;
const localUpdated = currentRoom.iconUpdatedAt || 0;
const needsSync = remoteUpdated > localUpdated;
if (needsSync && event.fromPeerId) {
this.webrtc.sendToPeer(event.fromPeerId, { type: 'server-icon-request', roomId: currentRoom.id } as any);
const localUpdated = room.iconUpdatedAt || 0;
if (remoteUpdated > localUpdated && event.fromPeerId) {
this.webrtc.sendToPeer(event.fromPeerId, {
type: 'server-icon-request',
roomId: room.id,
} as any);
}
return of({ type: 'NO_OP' });
return EMPTY;
}
case 'server-icon-request': {
private handleIconRequest(event: any, room: Room) {
if (event.fromPeerId) {
this.webrtc.sendToPeer(event.fromPeerId, {
type: 'server-icon-full',
roomId: currentRoom.id,
icon: currentRoom.icon,
iconUpdatedAt: currentRoom.iconUpdatedAt || 0,
roomId: room.id,
icon: room.icon,
iconUpdatedAt: room.iconUpdatedAt || 0,
} as any);
}
return of({ type: 'NO_OP' });
return EMPTY;
}
case 'server-icon-full':
case 'server-icon-update': {
if (typeof event.icon !== 'string') return of({ type: 'NO_OP' });
// Enforce that only owner or permitted roles can update
private handleIconData(event: any, room: Room) {
const senderId = event.fromPeerId as string | undefined;
if (!senderId) return of({ type: 'NO_OP' });
if (typeof event.icon !== 'string' || !senderId) return EMPTY;
return this.store.select(selectAllUsers).pipe(
map((users) => users.find((u) => u.id === senderId)),
mergeMap((sender) => {
if (!sender) return of({ type: 'NO_OP' });
const role = sender.role;
const perms = currentRoom.permissions || {};
const isOwner = currentRoom.hostId === sender.id;
const canByRole = (role === 'admin' && perms.adminsManageIcon) || (role === 'moderator' && perms.moderatorsManageIcon);
if (!isOwner && !canByRole) {
return of({ type: 'NO_OP' });
}
if (!sender) return EMPTY;
const perms = room.permissions || {};
const isOwner = room.hostId === sender.id;
const canByRole =
(sender.role === 'admin' && perms.adminsManageIcon) ||
(sender.role === 'moderator' && perms.moderatorsManageIcon);
if (!isOwner && !canByRole) return EMPTY;
const updates: Partial<Room> = {
icon: event.icon,
iconUpdatedAt: event.iconUpdatedAt || Date.now(),
};
this.db.updateRoom(currentRoom.id, updates);
return of(RoomsActions.updateRoom({ roomId: currentRoom.id, changes: updates }));
})
this.db.updateRoom(room.id, updates);
return of(RoomsActions.updateRoom({ roomId: room.id, changes: updates }));
}),
);
}
}
return of({ type: 'NO_OP' });
})
)
);
// On peer connect, broadcast local server icon summary (sync upon join/connect)
/** Broadcasts the local server icon summary to peers when a new peer connects. */
peerConnectedIconSync$ = createEffect(
() =>
this.webrtc.onPeerConnected.pipe(

View File

@@ -1,6 +1,6 @@
import { createReducer, on } from '@ngrx/store';
import { Room, ServerInfo, RoomSettings, Channel } from '../../core/models';
import * as RoomsActions from './rooms.actions';
import { RoomsActions } from './rooms.actions';
/** Default channels for a new server */
export function defaultChannels(): Channel[] {
@@ -15,15 +15,15 @@ export function defaultChannels(): Channel[] {
/** Deduplicate rooms by id, keeping the last occurrence */
function deduplicateRooms(rooms: Room[]): Room[] {
const seen = new Map<string, Room>();
for (const r of rooms) {
seen.set(r.id, r);
for (const room of rooms) {
seen.set(room.id, room);
}
return Array.from(seen.values());
}
/** Upsert a room into a saved-rooms list (add or replace by id) */
function upsertRoom(savedRooms: Room[], room: Room): Room[] {
const idx = savedRooms.findIndex(r => r.id === room.id);
const idx = savedRooms.findIndex(existingRoom => existingRoom.id === room.id);
if (idx >= 0) {
const updated = [...savedRooms];
updated[idx] = room;
@@ -32,17 +32,28 @@ function upsertRoom(savedRooms: Room[], room: Room): Room[] {
return [...savedRooms, room];
}
/** State shape for the rooms feature slice. */
export interface RoomsState {
/** The room the user is currently viewing. */
currentRoom: Room | null;
/** All rooms persisted locally (joined or created). */
savedRooms: Room[];
/** Editable settings for the current room. */
roomSettings: RoomSettings | null;
/** Results returned from the server directory search. */
searchResults: ServerInfo[];
/** Whether a server directory search is in progress. */
isSearching: boolean;
/** Whether a connection to a room is being established. */
isConnecting: boolean;
/** Whether the user is connected to a room. */
isConnected: boolean;
/** Whether rooms are being loaded from local storage. */
loading: boolean;
/** Most recent error message, if any. */
error: string | null;
activeChannelId: string; // currently selected text channel
/** ID of the currently selected text channel. */
activeChannelId: string;
}
export const initialState: RoomsState = {
@@ -212,14 +223,14 @@ export const roomsReducer = createReducer(
// Delete room
on(RoomsActions.deleteRoomSuccess, (state, { roomId }) => ({
...state,
savedRooms: state.savedRooms.filter((r) => r.id !== roomId),
savedRooms: state.savedRooms.filter((room) => room.id !== roomId),
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom,
})),
// Forget room (local only)
on(RoomsActions.forgetRoomSuccess, (state, { roomId }) => ({
...state,
savedRooms: state.savedRooms.filter((r) => r.id !== roomId),
savedRooms: state.savedRooms.filter((room) => room.id !== roomId),
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom,
})),
@@ -295,7 +306,7 @@ export const roomsReducer = createReducer(
on(RoomsActions.removeChannel, (state, { channelId }) => {
if (!state.currentRoom) return state;
const existing = state.currentRoom.channels || defaultChannels();
const updatedChannels = existing.filter(c => c.id !== channelId);
const updatedChannels = existing.filter(channel => channel.id !== channelId);
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
return {
...state,
@@ -308,7 +319,7 @@ export const roomsReducer = createReducer(
on(RoomsActions.renameChannel, (state, { channelId, name }) => {
if (!state.currentRoom) return state;
const existing = state.currentRoom.channels || defaultChannels();
const updatedChannels = existing.map(c => c.id === channelId ? { ...c, name } : c);
const updatedChannels = existing.map(channel => channel.id === channelId ? { ...channel, name } : channel);
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
return {
...state,

View File

@@ -1,84 +1,101 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { RoomsState } from './rooms.reducer';
/** Selects the top-level rooms feature state. */
export const selectRoomsState = createFeatureSelector<RoomsState>('rooms');
/** Selects the room the user is currently viewing. */
export const selectCurrentRoom = createSelector(
selectRoomsState,
(state) => state.currentRoom
);
/** Selects the current room's settings (name, topic, privacy, etc.). */
export const selectRoomSettings = createSelector(
selectRoomsState,
(state) => state.roomSettings
);
/** Selects server search results from the directory. */
export const selectSearchResults = createSelector(
selectRoomsState,
(state) => state.searchResults
);
/** Whether a server directory search is currently in progress. */
export const selectIsSearching = createSelector(
selectRoomsState,
(state) => state.isSearching
);
/** Whether a room connection is being established. */
export const selectIsConnecting = createSelector(
selectRoomsState,
(state) => state.isConnecting
);
/** Whether the user is currently connected to a room. */
export const selectIsConnected = createSelector(
selectRoomsState,
(state) => state.isConnected
);
/** Selects the most recent rooms-related error message. */
export const selectRoomsError = createSelector(
selectRoomsState,
(state) => state.error
);
/** Selects the ID of the current room, or null. */
export const selectCurrentRoomId = createSelector(
selectCurrentRoom,
(room) => room?.id ?? null
);
/** Selects the display name of the current room. */
export const selectCurrentRoomName = createSelector(
selectCurrentRoom,
(room) => room?.name ?? ''
);
/** Selects the host ID of the current room (for ownership checks). */
export const selectIsCurrentUserHost = createSelector(
selectCurrentRoom,
(room) => room?.hostId // Will be compared with current user ID in component
(room) => room?.hostId
);
/** Selects all locally-saved rooms. */
export const selectSavedRooms = createSelector(
selectRoomsState,
(state) => state.savedRooms
);
/** Whether rooms are currently being loaded from local storage. */
export const selectRoomsLoading = createSelector(
selectRoomsState,
(state) => state.loading
);
/** Selects the ID of the currently active text channel. */
export const selectActiveChannelId = createSelector(
selectRoomsState,
(state) => state.activeChannelId
);
/** Selects all channels defined on the current room. */
export const selectCurrentRoomChannels = createSelector(
selectCurrentRoom,
(room) => room?.channels ?? []
);
/** Selects only text channels, sorted by position. */
export const selectTextChannels = createSelector(
selectCurrentRoomChannels,
(channels) => channels.filter(c => c.type === 'text').sort((a, b) => a.position - b.position)
(channels) => channels.filter(channel => channel.type === 'text').sort((a, b) => a.position - b.position)
);
/** Selects only voice channels, sorted by position. */
export const selectVoiceChannels = createSelector(
selectCurrentRoomChannels,
(channels) => channels.filter(c => c.type === 'voice').sort((a, b) => a.position - b.position)
(channels) => channels.filter(channel => channel.type === 'voice').sort((a, b) => a.position - b.position)
);

View File

@@ -1,146 +1,48 @@
import { createAction, props } from '@ngrx/store';
/**
* Users store actions using `createActionGroup`.
*/
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { User, BanEntry, VoiceState, ScreenShareState } from '../../core/models';
// Load current user from storage
export const loadCurrentUser = createAction('[Users] Load Current User');
export const UsersActions = createActionGroup({
source: 'Users',
events: {
'Load Current User': emptyProps(),
'Load Current User Success': props<{ user: User }>(),
'Load Current User Failure': props<{ error: string }>(),
export const loadCurrentUserSuccess = createAction(
'[Users] Load Current User Success',
props<{ user: User }>()
);
'Set Current User': props<{ user: User }>(),
'Update Current User': props<{ updates: Partial<User> }>(),
export const loadCurrentUserFailure = createAction(
'[Users] Load Current User Failure',
props<{ error: string }>()
);
'Load Room Users': props<{ roomId: string }>(),
'Load Room Users Success': props<{ users: User[] }>(),
'Load Room Users Failure': props<{ error: string }>(),
// Set current user
export const setCurrentUser = createAction(
'[Users] Set Current User',
props<{ user: User }>()
);
'User Joined': props<{ user: User }>(),
'User Left': props<{ userId: string }>(),
// Update current user
export const updateCurrentUser = createAction(
'[Users] Update Current User',
props<{ updates: Partial<User> }>()
);
'Update User': props<{ userId: string; updates: Partial<User> }>(),
'Update User Role': props<{ userId: string; role: User['role'] }>(),
// Load users in room
export const loadRoomUsers = createAction(
'[Users] Load Room Users',
props<{ roomId: string }>()
);
'Kick User': props<{ userId: string }>(),
'Kick User Success': props<{ userId: string }>(),
export const loadRoomUsersSuccess = createAction(
'[Users] Load Room Users Success',
props<{ users: User[] }>()
);
'Ban User': props<{ userId: string; reason?: string; expiresAt?: number }>(),
'Ban User Success': props<{ userId: string; ban: BanEntry }>(),
'Unban User': props<{ oderId: string }>(),
'Unban User Success': props<{ oderId: string }>(),
export const loadRoomUsersFailure = createAction(
'[Users] Load Room Users Failure',
props<{ error: string }>()
);
'Load Bans': emptyProps(),
'Load Bans Success': props<{ bans: BanEntry[] }>(),
// User joined
export const userJoined = createAction(
'[Users] User Joined',
props<{ user: User }>()
);
'Admin Mute User': props<{ userId: string }>(),
'Admin Unmute User': props<{ userId: string }>(),
// User left
export const userLeft = createAction(
'[Users] User Left',
props<{ userId: string }>()
);
'Sync Users': props<{ users: User[] }>(),
'Clear Users': emptyProps(),
'Update Host': props<{ userId: string }>(),
// Update user
export const updateUser = createAction(
'[Users] Update User',
props<{ userId: string; updates: Partial<User> }>()
);
// Update user role
export const updateUserRole = createAction(
'[Users] Update User Role',
props<{ userId: string; role: User['role'] }>()
);
// Kick user
export const kickUser = createAction(
'[Users] Kick User',
props<{ userId: string }>()
);
export const kickUserSuccess = createAction(
'[Users] Kick User Success',
props<{ userId: string }>()
);
// Ban user
export const banUser = createAction(
'[Users] Ban User',
props<{ userId: string; reason?: string; expiresAt?: number }>()
);
export const banUserSuccess = createAction(
'[Users] Ban User Success',
props<{ userId: string; ban: BanEntry }>()
);
// Unban user
export const unbanUser = createAction(
'[Users] Unban User',
props<{ oderId: string }>()
);
export const unbanUserSuccess = createAction(
'[Users] Unban User Success',
props<{ oderId: string }>()
);
// Load bans
export const loadBans = createAction('[Users] Load Bans');
export const loadBansSuccess = createAction(
'[Users] Load Bans Success',
props<{ bans: BanEntry[] }>()
);
// Admin mute/unmute
export const adminMuteUser = createAction(
'[Users] Admin Mute User',
props<{ userId: string }>()
);
export const adminUnmuteUser = createAction(
'[Users] Admin Unmute User',
props<{ userId: string }>()
);
// Sync users from peer
export const syncUsers = createAction(
'[Users] Sync Users',
props<{ users: User[] }>()
);
// Clear users
export const clearUsers = createAction('[Users] Clear Users');
// Update host
export const updateHost = createAction(
'[Users] Update Host',
props<{ userId: string }>()
);
// Update voice state for a user
export const updateVoiceState = createAction(
'[Users] Update Voice State',
props<{ userId: string; voiceState: Partial<VoiceState> }>()
);
// Update screen share state for a user
export const updateScreenShareState = createAction(
'[Users] Update Screen Share State',
props<{ userId: string; screenShareState: Partial<ScreenShareState> }>()
);
'Update Voice State': props<{ userId: string; voiceState: Partial<VoiceState> }>(),
'Update Screen Share State': props<{ userId: string; screenShareState: Partial<ScreenShareState> }>(),
},
});

View File

@@ -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(

View File

@@ -1,13 +1,19 @@
import { createReducer, on } from '@ngrx/store';
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
import { User, BanEntry } from '../../core/models';
import * as UsersActions from './users.actions';
import { UsersActions } from './users.actions';
/** State shape for the users feature slice, extending NgRx EntityState. */
export interface UsersState extends EntityState<User> {
/** ID of the locally authenticated user. */
currentUserId: string | null;
/** ID of the room host (owner). */
hostId: string | null;
/** Whether a user-loading operation is in progress. */
loading: boolean;
/** Most recent error message from user operations. */
error: string | null;
/** List of active bans for the current room. */
bans: BanEntry[];
}
@@ -137,7 +143,7 @@ export const usersReducer = createReducer(
// Unban user
on(UsersActions.unbanUserSuccess, (state, { oderId }) => ({
...state,
bans: state.bans.filter((b) => b.oderId !== oderId),
bans: state.bans.filter((ban) => ban.oderId !== oderId),
})),
// Load bans

View File

@@ -1,84 +1,103 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { UsersState, usersAdapter } from './users.reducer';
/** Selects the top-level users feature state. */
export const selectUsersState = createFeatureSelector<UsersState>('users');
const { selectIds, selectEntities, selectAll, selectTotal } = usersAdapter.getSelectors();
/** Selects all user entities as a flat array. */
export const selectAllUsers = createSelector(selectUsersState, selectAll);
/** Selects the user entity dictionary keyed by ID. */
export const selectUsersEntities = createSelector(selectUsersState, selectEntities);
/** Selects all user IDs. */
export const selectUsersIds = createSelector(selectUsersState, selectIds);
/** Selects the total count of users. */
export const selectUsersTotal = createSelector(selectUsersState, selectTotal);
/** Whether a user-loading operation is in progress. */
export const selectUsersLoading = createSelector(
selectUsersState,
(state) => state.loading
);
/** Selects the most recent users-related error message. */
export const selectUsersError = createSelector(
selectUsersState,
(state) => state.error
);
/** Selects the current (local) user's ID, or null. */
export const selectCurrentUserId = createSelector(
selectUsersState,
(state) => state.currentUserId
);
/** Selects the host's user ID. */
export const selectHostId = createSelector(
selectUsersState,
(state) => state.hostId
);
/** Selects all active ban entries for the current room. */
export const selectBannedUsers = createSelector(
selectUsersState,
(state) => state.bans
);
/** Selects the full User entity for the current (local) user. */
export const selectCurrentUser = createSelector(
selectUsersEntities,
selectCurrentUserId,
(entities, currentUserId) => (currentUserId ? entities[currentUserId] : null)
);
/** Selects the full User entity for the room host. */
export const selectHost = createSelector(
selectUsersEntities,
selectHostId,
(entities, hostId) => (hostId ? entities[hostId] : null)
);
/** Creates a selector that returns a single user by their ID. */
export const selectUserById = (id: string) =>
createSelector(selectUsersEntities, (entities) => entities[id]);
/** Whether the current user is the room host. */
export const selectIsCurrentUserHost = createSelector(
selectCurrentUserId,
selectHostId,
(currentUserId, hostId) => currentUserId === hostId
);
/** Whether the current user holds an elevated role (host, admin, or moderator). */
export const selectIsCurrentUserAdmin = createSelector(
selectCurrentUser,
(user) => user?.role === 'host' || user?.role === 'admin' || user?.role === 'moderator'
);
/** Selects users who are currently online (not offline). */
export const selectOnlineUsers = createSelector(
selectAllUsers,
(users) => users.filter((u) => u.status !== 'offline' || u.isOnline === true)
(users) => users.filter((user) => user.status !== 'offline' || user.isOnline === true)
);
/** Creates a selector that returns users with a specific role. */
export const selectUsersByRole = (role: string) =>
createSelector(selectAllUsers, (users) =>
users.filter((u) => u.role === role)
users.filter((user) => user.role === role)
);
/** Selects all users with an elevated role (host, admin, or moderator). */
export const selectAdmins = createSelector(
selectAllUsers,
(users) => users.filter((u) => u.role === 'host' || u.role === 'admin' || u.role === 'moderator')
(users) => users.filter((user) => user.role === 'host' || user.role === 'admin' || user.role === 'moderator')
);
/** Whether the current user is the room owner (host role). */
export const selectIsCurrentUserOwner = createSelector(
selectCurrentUser,
(user) => user?.role === 'host'

View File

@@ -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));

View File

@@ -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';