Big commit

This commit is contained in:
2026-03-02 00:13:34 +01:00
parent d146138fca
commit 6d7465ff18
54 changed files with 5999 additions and 2291 deletions

View File

@@ -17,9 +17,17 @@ export interface User {
screenShareState?: ScreenShareState;
}
export interface Channel {
id: string;
name: string;
type: 'text' | 'voice';
position: number; // ordering within its type group
}
export interface Message {
id: string;
roomId: string;
channelId?: string; // which text channel the message belongs to (default: 'general')
senderId: string;
senderName: string;
content: string;
@@ -55,6 +63,8 @@ export interface Room {
iconUpdatedAt?: number; // last update timestamp for conflict resolution
// Role-based management permissions
permissions?: RoomPermissions;
// Channels within the server
channels?: Channel[];
}
export interface RoomSettings {
@@ -129,7 +139,7 @@ export interface SignalingMessage {
}
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';
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';
messageId?: string;
message?: Message;
reaction?: Reaction;
@@ -149,6 +159,8 @@ export interface ChatEvent {
settings?: RoomSettings;
voiceState?: Partial<VoiceState>;
isScreenSharing?: boolean;
role?: 'host' | 'admin' | 'moderator' | 'member';
channels?: Channel[];
}
export interface ServerInfo {

View File

@@ -1,9 +1,9 @@
import { Injectable, inject, signal } from '@angular/core';
import { Subject } from 'rxjs';
import { Injectable, inject, signal, effect } from '@angular/core';
import { v4 as uuidv4 } from 'uuid';
import { WebRTCService } from './webrtc.service';
import { Store } from '@ngrx/store';
import { selectCurrentRoomName } from '../../store/rooms/rooms.selectors';
import { DatabaseService } from './database.service';
export interface AttachmentMeta {
id: string;
@@ -13,7 +13,8 @@ export interface AttachmentMeta {
mime: string;
isImage: boolean;
uploaderPeerId?: string;
filePath?: string; // Electron-only: absolute path to original file
filePath?: string; // Electron-only: absolute path to original file
savedPath?: string; // Electron-only: disk cache path where image was saved
}
export interface Attachment extends AttachmentMeta {
@@ -29,19 +30,15 @@ export interface Attachment extends AttachmentMeta {
@Injectable({ providedIn: 'root' })
export class AttachmentService {
private readonly webrtc = inject(WebRTCService);
// Injected NgRx store
private readonly ngrxStore = inject(Store);
private readonly STORAGE_KEY = 'metoyou_attachments';
private readonly db = inject(DatabaseService);
// messageId -> attachments
private attachmentsByMessage = new Map<string, Attachment[]>();
// expose updates if needed
updated = signal<number>(0);
// Keep original files for uploaders to fulfill requests
private originals = new Map<string, File>(); // key: messageId:fileId
// Notify UI when original is missing and uploader needs to reselect
readonly onMissingOriginal = new Subject<{ messageId: string; fileId: string; fromPeerId: string }>();
// Track cancelled transfers (uploader side) keyed by messageId:fileId:peerId
private cancelledTransfers = new Set<string>();
private makeKey(messageId: string, fileId: string, peerId: string): string { return `${messageId}:${fileId}:${peerId}`; }
@@ -49,14 +46,197 @@ export class AttachmentService {
return this.cancelledTransfers.has(this.makeKey(messageId, fileId, targetPeerId));
}
/** Check whether a file is an image or video. */
private isMedia(att: { mime: string }): boolean {
return att.mime.startsWith('image/') || att.mime.startsWith('video/');
}
private dbInitDone = false;
constructor() {
this.loadPersisted();
effect(() => {
if (this.db.isReady() && !this.dbInitDone) {
this.dbInitDone = true;
this.initFromDb();
}
});
}
private async initFromDb(): Promise<void> {
await this.loadFromDb();
await this.migrateFromLocalStorage();
await this.tryLoadSavedFiles();
}
getForMessage(messageId: string): Attachment[] {
return this.attachmentsByMessage.get(messageId) || [];
}
/** Return minimal attachment metadata for a set of message IDs (for sync). */
getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> {
const result: Record<string, AttachmentMeta[]> = {};
for (const mid of messageIds) {
const list = this.attachmentsByMessage.get(mid);
if (list && list.length > 0) {
result[mid] = list.map(a => ({
id: a.id,
messageId: a.messageId,
filename: a.filename,
size: a.size,
mime: a.mime,
isImage: a.isImage,
uploaderPeerId: a.uploaderPeerId,
filePath: undefined, // never share local paths
savedPath: undefined, // never share local paths
}));
}
}
return result;
}
/** Register attachments received via message sync (metadata only). */
registerSyncedAttachments(attachmentMap: Record<string, AttachmentMeta[]>): void {
const newAtts: Attachment[] = [];
for (const [messageId, metas] of Object.entries(attachmentMap)) {
const existing = this.attachmentsByMessage.get(messageId) || [];
for (const meta of metas) {
if (!existing.find(e => e.id === meta.id)) {
const att: Attachment = { ...meta, available: false, receivedBytes: 0 };
existing.push(att);
newAtts.push(att);
}
}
if (existing.length > 0) {
this.attachmentsByMessage.set(messageId, existing);
}
}
if (newAtts.length > 0) {
this.updated.set(this.updated() + 1);
for (const att of newAtts) {
void this.persistAttachmentMeta(att);
}
}
}
// Track pending requests so we can retry with other peers
// key: messageId:fileId -> Set of peer IDs already tried
private pendingRequests = new Map<string, Set<string>>();
/** Request a file from any connected peer that might have it. */
requestFromAnyPeer(messageId: string, att: Attachment): void {
const connected = this.webrtc.getConnectedPeers();
if (connected.length === 0) {
console.warn('[Attachments] No connected peers to request file from');
return;
}
const reqKey = `${messageId}:${att.id}`;
// Reset tried-peers for a fresh request
this.pendingRequests.set(reqKey, new Set());
this.sendFileRequestToNextPeer(messageId, att.id, att.uploaderPeerId);
}
/** Send file-request to the next untried peer. Returns true if a request was sent. */
private sendFileRequestToNextPeer(messageId: string, fileId: string, preferredPeerId?: string): boolean {
const connected = this.webrtc.getConnectedPeers();
const reqKey = `${messageId}:${fileId}`;
const tried = this.pendingRequests.get(reqKey) || new Set();
// Pick the best untried peer: preferred first, then any
let target: string | undefined;
if (preferredPeerId && connected.includes(preferredPeerId) && !tried.has(preferredPeerId)) {
target = preferredPeerId;
} else {
target = connected.find(p => !tried.has(p));
}
if (!target) {
console.warn(`[Attachments] All ${tried.size} peers tried for ${reqKey}, none could serve`);
this.pendingRequests.delete(reqKey);
return false;
}
tried.add(target);
this.pendingRequests.set(reqKey, tried);
console.log(`[Attachments] Requesting ${fileId} from peer ${target} (tried ${tried.size}/${connected.length})`);
this.webrtc.sendToPeer(target, {
type: 'file-request',
messageId,
fileId,
} as any);
return true;
}
/** Handle a file-not-found response try the next peer. */
handleFileNotFound(payload: any): void {
const { messageId, fileId } = payload;
if (!messageId || !fileId) return;
const list = this.attachmentsByMessage.get(messageId) || [];
const att = list.find(a => a.id === fileId);
this.sendFileRequestToNextPeer(messageId, fileId, att?.uploaderPeerId);
}
/** @deprecated Use requestFromAnyPeer instead */
requestImageFromAnyPeer(messageId: string, att: Attachment): void {
this.requestFromAnyPeer(messageId, att);
}
/** On startup, try loading previously saved files from disk (Electron). */
private async tryLoadSavedFiles(): Promise<void> {
const w: any = window as any;
if (!w?.electronAPI?.fileExists || !w?.electronAPI?.readFile) return;
try {
let changed = false;
for (const [, attachments] of this.attachmentsByMessage) {
for (const att of attachments) {
if (att.available) continue;
// 1. Try savedPath (disk cache — all file types)
if (att.savedPath) {
try {
const exists = await w.electronAPI.fileExists(att.savedPath);
if (exists) {
const base64 = await w.electronAPI.readFile(att.savedPath);
const bytes = this.base64ToUint8Array(base64);
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: att.mime });
att.objectUrl = URL.createObjectURL(blob);
att.available = true;
// Re-populate originals so handleFileRequest step 1 works after restart
const file = new File([blob], att.filename, { type: att.mime });
this.originals.set(`${att.messageId}:${att.id}`, file);
changed = true;
continue;
}
} catch {}
}
// 2. Try filePath (uploader's original)
if (att.filePath) {
try {
const exists = await w.electronAPI.fileExists(att.filePath);
if (exists) {
const base64 = await w.electronAPI.readFile(att.filePath);
const bytes = this.base64ToUint8Array(base64);
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: att.mime });
att.objectUrl = URL.createObjectURL(blob);
att.available = true;
// Re-populate originals so handleFileRequest step 1 works after restart
const file = new File([blob], att.filename, { type: att.mime });
this.originals.set(`${att.messageId}:${att.id}`, file);
changed = true;
// Save to disk cache for future use
if (att.size <= 10 * 1024 * 1024) {
void this.saveFileToDisk(att, blob);
}
continue;
}
} catch {}
}
}
}
if (changed) {
this.updated.set(this.updated() + 1);
}
} catch {}
}
// Publish attachments for a sent message and stream images <=10MB
async publishAttachments(messageId: string, files: File[], uploaderPeerId?: string): Promise<void> {
const attachments: Attachment[] = [];
@@ -77,18 +257,18 @@ export class AttachmentService {
// Save original for request-based transfer
this.originals.set(`${messageId}:${id}`, file);
console.log(`[Attachments] publishAttachments: stored original key="${messageId}:${id}" (${file.name}, ${file.size} bytes)`);
// Ensure uploader sees their own image immediately
if (meta.isImage) {
try {
const url = URL.createObjectURL(file);
meta.objectUrl = url;
meta.available = true;
// Auto-save only for images ≤10MB
if (meta.size <= 10 * 1024 * 1024) {
void this.saveImageToDisk(meta, file);
}
} catch {}
// Ensure uploader sees their own files immediately (all types, not just images)
try {
const url = URL.createObjectURL(file);
meta.objectUrl = url;
meta.available = true;
} catch {}
// Save ALL files ≤10MB to disk (Electron) for persistence across restarts
if (meta.size <= 10 * 1024 * 1024) {
void this.saveFileToDisk(meta, file);
}
// Announce to peers
@@ -113,7 +293,9 @@ export class AttachmentService {
this.attachmentsByMessage.set(messageId, [ ...(this.attachmentsByMessage.get(messageId) || []), ...attachments ]);
this.updated.set(this.updated() + 1);
this.persist();
for (const att of attachments) {
void this.persistAttachmentMeta(att);
}
}
private async streamFileToPeers(messageId: string, fileId: string, file: File): Promise<void> {
@@ -146,7 +328,7 @@ export class AttachmentService {
const list = this.attachmentsByMessage.get(messageId) || [];
const exists = list.find((a: Attachment) => a.id === file.id);
if (!exists) {
list.push({
const att: Attachment = {
id: file.id,
messageId,
filename: file.filename,
@@ -156,10 +338,11 @@ export class AttachmentService {
uploaderPeerId: file.uploaderPeerId,
available: false,
receivedBytes: 0,
});
};
list.push(att);
this.attachmentsByMessage.set(messageId, list);
this.updated.set(this.updated() + 1);
this.persist();
void this.persistAttachmentMeta(att);
}
}
@@ -205,20 +388,20 @@ export class AttachmentService {
const blob = new Blob(finalParts, { type: att.mime });
att.available = true;
att.objectUrl = URL.createObjectURL(blob);
// Auto-save small images to disk under app data: server/<room>/image
if (att.isImage && att.size <= 10 * 1024 * 1024) {
void this.saveImageToDisk(att, blob);
// Auto-save ALL received files to disk under app data (Electron)
if (att.size <= 10 * 1024 * 1024) {
void this.saveFileToDisk(att, blob);
}
// Final update
delete (this as any)[partsKey];
delete (this as any)[countKey];
this.updated.set(this.updated() + 1);
this.persist();
void this.persistAttachmentMeta(att);
}
}
}
private async saveImageToDisk(att: Attachment, blob: Blob): Promise<void> {
private async saveFileToDisk(att: Attachment, blob: Blob): Promise<void> {
try {
const w: any = window as any;
const appData: string | undefined = await w?.electronAPI?.getAppDataPath?.();
@@ -228,28 +411,20 @@ export class AttachmentService {
const sub = this.ngrxStore.select(selectCurrentRoomName).subscribe((n) => { name = n || ''; resolve(name); sub.unsubscribe(); });
});
const safeRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room';
const dir = `${appData}/server/${safeRoom}/image`;
const subDir = att.mime.startsWith('video/') ? 'video' : att.mime.startsWith('image/') ? 'image' : 'files';
const dir = `${appData}/server/${safeRoom}/${subDir}`;
await w.electronAPI.ensureDir(dir);
const arrayBuffer = await blob.arrayBuffer();
const base64 = this.arrayBufferToBase64(arrayBuffer);
const path = `${dir}/${att.filename}`;
await w.electronAPI.writeFile(path, base64);
const diskPath = `${dir}/${att.filename}`;
await w.electronAPI.writeFile(diskPath, base64);
att.savedPath = diskPath;
void this.persistAttachmentMeta(att);
} catch {}
}
requestFile(messageId: string, att: Attachment): void {
const target = att.uploaderPeerId;
if (!target) return;
const connected = this.webrtc.getConnectedPeers();
if (!connected.includes(target)) {
console.warn('Uploader peer not connected:', target);
return;
}
this.webrtc.sendToPeer(target, {
type: 'file-request',
messageId,
fileId: att.id,
} as any);
this.requestFromAnyPeer(messageId, att);
}
// Cancel an in-progress request from the requester side
@@ -281,47 +456,105 @@ export class AttachmentService {
} catch {}
}
// When we receive a request and we are the uploader, stream the original file if available
// When we receive a request, stream the file if we have it (uploader or any peer with cached copy)
async handleFileRequest(payload: any): Promise<void> {
const { messageId, fileId, fromPeerId } = payload;
if (!messageId || !fileId || !fromPeerId) return;
const original = this.originals.get(`${messageId}:${fileId}`);
if (!messageId || !fileId || !fromPeerId) {
console.warn('[Attachments] handleFileRequest: missing fields', { messageId, fileId, fromPeerId });
return;
}
console.log(`[Attachments] handleFileRequest for ${fileId} (msg=${messageId}) from peer ${fromPeerId}`);
console.log(`[Attachments] originals map has ${this.originals.size} entries: [${[...this.originals.keys()].join(', ')}]`);
// 1. Check in-memory originals (uploader case)
const exactKey = `${messageId}:${fileId}`;
let original = this.originals.get(exactKey);
// 1b. Fallback: search originals by fileId alone (handles rare messageId drift)
if (!original) {
for (const [key, file] of this.originals) {
if (key.endsWith(`:${fileId}`)) {
console.warn(`[Attachments] Exact key "${exactKey}" not found, but matched by fileId via key "${key}"`);
original = file;
break;
}
}
}
if (original) {
console.log(`[Attachments] Serving ${fileId} from in-memory original (${original.size} bytes)`);
await this.streamFileToPeer(fromPeerId, messageId, fileId, original);
return;
}
// Try Electron file path fallback
const list = this.attachmentsByMessage.get(messageId) || [];
const att = list.find((a: Attachment) => a.id === fileId);
const w: any = window as any;
// 2. Check Electron file-path fallback (uploader's original path)
if (att?.filePath && w?.electronAPI?.fileExists && w?.electronAPI?.readFile) {
try {
const exists = await w.electronAPI.fileExists(att.filePath);
if (exists) {
const base64 = await w.electronAPI.readFile(att.filePath);
const bytes = this.base64ToUint8Array(base64);
const chunkSize = 64 * 1024;
const totalChunks = Math.ceil(bytes.byteLength / chunkSize);
for (let i = 0; i < totalChunks; i++) {
if (this.isCancelled(fromPeerId, messageId, fileId)) break;
const slice = bytes.subarray(i * chunkSize, Math.min(bytes.byteLength, (i + 1) * chunkSize));
const slicedBuffer = (slice.buffer as ArrayBuffer).slice(slice.byteOffset, slice.byteOffset + slice.byteLength);
const b64 = this.arrayBufferToBase64(slicedBuffer);
this.webrtc.sendToPeer(fromPeerId, {
type: 'file-chunk',
messageId,
fileId,
index: i,
total: totalChunks,
data: b64,
} as any);
}
console.log(`[Attachments] Serving ${fileId} from original filePath: ${att.filePath}`);
await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, att.filePath);
return;
}
} catch {}
}
// Fallback: prompt reselect
this.onMissingOriginal.next({ messageId, fileId, fromPeerId });
// 3. Check savedPath (disk cache recorded path)
if (att?.savedPath && w?.electronAPI?.fileExists && w?.electronAPI?.readFile) {
try {
const exists = await w.electronAPI.fileExists(att.savedPath);
if (exists) {
console.log(`[Attachments] Serving ${fileId} from savedPath: ${att.savedPath}`);
await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, att.savedPath);
return;
}
} catch {}
}
// 3b. Fallback: Check Electron disk cache by room name (backward compat)
if (att?.isImage && w?.electronAPI?.getAppDataPath && w?.electronAPI?.fileExists && w?.electronAPI?.readFile) {
try {
const appData = await w.electronAPI.getAppDataPath();
if (appData) {
const roomName = await new Promise<string>((resolve) => {
const sub = this.ngrxStore.select(selectCurrentRoomName).subscribe((n) => { resolve(n || ''); sub.unsubscribe(); });
});
const safeRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room';
const path = `${appData}/server/${safeRoom}/image/${att.filename}`;
const exists = await w.electronAPI.fileExists(path);
if (exists) {
console.log(`[Attachments] Serving ${fileId} from disk cache: ${path}`);
await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, path);
return;
}
}
} catch {}
}
// 4. Check in-memory blob (received this session but not saved to disk, e.g. browser mode)
if (att?.available && att.objectUrl) {
try {
const resp = await fetch(att.objectUrl);
const blob = await resp.blob();
const file = new File([blob], att.filename, { type: att.mime });
console.log(`[Attachments] Serving ${fileId} from in-memory blob (${blob.size} bytes)`);
await this.streamFileToPeer(fromPeerId, messageId, fileId, file);
return;
} catch {}
}
// 5. Cannot serve notify requester so they can try another peer
console.warn(`[Attachments] Cannot fulfill file-request for ${fileId} (msg=${messageId}) no source found. ` +
`originals=${this.originals.size}, att=${att ? `available=${att.available},savedPath=${att.savedPath},filePath=${att.filePath}` : 'not in map'}`);
this.webrtc.sendToPeer(fromPeerId, {
type: 'file-not-found',
messageId,
fileId,
} as any);
}
private async streamFileToPeer(targetPeerId: string, messageId: string, fileId: string, file: File): Promise<void> {
@@ -355,37 +588,57 @@ export class AttachmentService {
// Optionally clear original if desired (keep for re-request)
}
/** Stream a file from Electron disk to a peer. */
private async streamFileFromDiskToPeer(targetPeerId: string, messageId: string, fileId: string, filePath: string): Promise<void> {
const w: any = window as any;
const base64 = await w.electronAPI.readFile(filePath);
const bytes = this.base64ToUint8Array(base64);
const chunkSize = 64 * 1024;
const totalChunks = Math.ceil(bytes.byteLength / chunkSize);
for (let i = 0; i < totalChunks; i++) {
if (this.isCancelled(targetPeerId, messageId, fileId)) break;
const slice = bytes.subarray(i * chunkSize, Math.min(bytes.byteLength, (i + 1) * chunkSize));
const slicedBuffer = (slice.buffer as ArrayBuffer).slice(slice.byteOffset, slice.byteOffset + slice.byteLength);
const b64 = this.arrayBufferToBase64(slicedBuffer);
this.webrtc.sendToPeer(targetPeerId, {
type: 'file-chunk',
messageId,
fileId,
index: i,
total: totalChunks,
data: b64,
} as any);
}
}
// Fulfill a pending request with a user-provided file (uploader side)
async fulfillRequestWithFile(messageId: string, fileId: string, targetPeerId: string, file: File): Promise<void> {
this.originals.set(`${messageId}:${fileId}`, file);
await this.streamFileToPeer(targetPeerId, messageId, fileId, file);
}
private persist(): void {
private async persistAttachmentMeta(att: Attachment): Promise<void> {
if (!this.db.isReady()) return;
try {
const all: Attachment[] = Array.from(this.attachmentsByMessage.values()).flat();
const minimal = all.map((a: Attachment) => ({
id: a.id,
messageId: a.messageId,
filename: a.filename,
size: a.size,
mime: a.mime,
isImage: a.isImage,
uploaderPeerId: a.uploaderPeerId,
filePath: a.filePath,
available: false,
}));
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(minimal));
await this.db.saveAttachment({
id: att.id,
messageId: att.messageId,
filename: att.filename,
size: att.size,
mime: att.mime,
isImage: att.isImage,
uploaderPeerId: att.uploaderPeerId,
filePath: att.filePath,
savedPath: att.savedPath,
});
} catch {}
}
private loadPersisted(): void {
private async loadFromDb(): Promise<void> {
try {
const raw = localStorage.getItem(this.STORAGE_KEY);
if (!raw) return;
const list: AttachmentMeta[] = JSON.parse(raw);
const all: AttachmentMeta[] = await this.db.getAllAttachments();
const grouped = new Map<string, Attachment[]>();
for (const a of list) {
for (const a of all) {
const att: Attachment = { ...a, available: false };
const arr = grouped.get(a.messageId) || [];
arr.push(att);
@@ -396,6 +649,26 @@ export class AttachmentService {
} catch {}
}
/** One-time migration from localStorage to database. */
private async migrateFromLocalStorage(): Promise<void> {
try {
const raw = localStorage.getItem('metoyou_attachments');
if (!raw) return;
const list: AttachmentMeta[] = JSON.parse(raw);
for (const meta of list) {
const existing = this.attachmentsByMessage.get(meta.messageId) || [];
if (!existing.find(e => e.id === meta.id)) {
const att: Attachment = { ...meta, available: false };
existing.push(att);
this.attachmentsByMessage.set(meta.messageId, existing);
void this.persistAttachmentMeta(att);
}
}
localStorage.removeItem('metoyou_attachments');
this.updated.set(this.updated() + 1);
} catch {}
}
private arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = '';
const bytes = new Uint8Array(buffer);

View File

@@ -0,0 +1,302 @@
import { Injectable } from '@angular/core';
import { Message, User, Room, Reaction, BanEntry } from '../models';
const DB_NAME = 'metoyou';
const DB_VERSION = 2;
/**
* 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
* facade can delegate transparently.
*/
@Injectable({ providedIn: 'root' })
export class BrowserDatabaseService {
private db: IDBDatabase | null = null;
/* ------------------------------------------------------------------ */
/* Lifecycle */
/* ------------------------------------------------------------------ */
async initialize(): Promise<void> {
if (this.db) return;
this.db = await this.openDatabase();
}
/* ------------------------------------------------------------------ */
/* Messages */
/* ------------------------------------------------------------------ */
async saveMessage(message: Message): Promise<void> {
await this.put('messages', message);
}
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)
.slice(offset, offset + limit);
}
async deleteMessage(messageId: string): Promise<void> {
await this.delete('messages', messageId);
}
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 });
}
async getMessageById(messageId: string): Promise<Message | null> {
return (await this.get<Message>('messages', messageId)) ?? null;
}
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,
);
if (!dup) await this.put('reactions', reaction);
}
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);
}
async getReactionsForMessage(messageId: string): Promise<Reaction[]> {
return this.getAllFromIndex<Reaction>('reactions', 'messageId', messageId);
}
/* ------------------------------------------------------------------ */
/* Users */
/* ------------------------------------------------------------------ */
async saveUser(user: User): Promise<void> {
await this.put('users', user);
}
async getUser(userId: string): Promise<User | null> {
return (await this.get<User>('users', userId)) ?? null;
}
async getCurrentUser(): Promise<User | null> {
const meta = await this.get<{ id: string; value: string }>('meta', 'currentUserId');
if (!meta) return null;
return this.getUser(meta.value);
}
async setCurrentUserId(userId: string): Promise<void> {
await this.put('meta', { id: 'currentUserId', value: userId });
}
async getUsersByRoom(_roomId: string): Promise<User[]> {
return this.getAll<User>('users');
}
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 });
}
/* ------------------------------------------------------------------ */
/* Rooms */
/* ------------------------------------------------------------------ */
async saveRoom(room: Room): Promise<void> {
await this.put('rooms', room);
}
async getRoom(roomId: string): Promise<Room | null> {
return (await this.get<Room>('rooms', roomId)) ?? null;
}
async getAllRooms(): Promise<Room[]> {
return this.getAll<Room>('rooms');
}
async deleteRoom(roomId: string): Promise<void> {
await this.delete('rooms', roomId);
await this.clearRoomMessages(roomId);
}
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 });
}
/* ------------------------------------------------------------------ */
/* Bans */
/* ------------------------------------------------------------------ */
async saveBan(ban: BanEntry): Promise<void> {
await this.put('bans', ban);
}
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);
}
async getBansForRoom(roomId: string): Promise<BanEntry[]> {
const all = await this.getAllFromIndex<BanEntry>('bans', 'roomId', roomId);
const now = Date.now();
return all.filter((b) => !b.expiresAt || b.expiresAt > now);
}
async isUserBanned(userId: string, roomId: string): Promise<boolean> {
const bans = await this.getBansForRoom(roomId);
return bans.some((b) => b.oderId === userId);
}
/* ------------------------------------------------------------------ */
/* Attachments */
/* ------------------------------------------------------------------ */
async saveAttachment(attachment: any): Promise<void> {
await this.put('attachments', attachment);
}
async getAttachmentsForMessage(messageId: string): Promise<any[]> {
return this.getAllFromIndex<any>('attachments', 'messageId', messageId);
}
async getAllAttachments(): Promise<any[]> {
return this.getAll<any>('attachments');
}
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);
}
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);
}
/* ================================================================== */
/* Private helpers thin wrappers around IndexedDB */
/* ================================================================== */
private openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_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 });
}
if (!db.objectStoreNames.contains('users')) {
db.createObjectStore('users', { keyPath: 'id' });
}
if (!db.objectStoreNames.contains('rooms')) {
db.createObjectStore('rooms', { keyPath: 'id' });
}
if (!db.objectStoreNames.contains('reactions')) {
const rxns = db.createObjectStore('reactions', { keyPath: 'id' });
rxns.createIndex('messageId', 'messageId', { unique: false });
}
if (!db.objectStoreNames.contains('bans')) {
const bans = db.createObjectStore('bans', { keyPath: 'oderId' });
bans.createIndex('roomId', 'roomId', { unique: false });
}
if (!db.objectStoreNames.contains('meta')) {
db.createObjectStore('meta', { keyPath: 'id' });
}
if (!db.objectStoreNames.contains('attachments')) {
const atts = db.createObjectStore('attachments', { keyPath: 'id' });
atts.createIndex('messageId', 'messageId', { unique: false });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
private transaction(
stores: string | string[],
mode: IDBTransactionMode = 'readonly',
): IDBTransaction {
return this.db!.transaction(stores, mode);
}
private complete(tx: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
private get<T>(store: 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);
});
}
private getAll<T>(store: 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);
});
}
private getAllFromIndex<T>(
store: 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);
});
}
private put(store: 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);
});
}
private delete(store: 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);
});
}
}

View File

@@ -1,230 +1,105 @@
import { Injectable, signal } from '@angular/core';
import { inject, Injectable, signal } from '@angular/core';
import { Message, User, Room, Reaction, BanEntry } from '../models';
import { PlatformService } from './platform.service';
import { BrowserDatabaseService } from './browser-database.service';
import { ElectronDatabaseService } from './electron-database.service';
/**
* Database service using localStorage for persistence.
* In a production Electron app, this would use sql.js with file system access.
* Facade database service.
*
* - **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.
*
* All consumers keep injecting `DatabaseService` the underlying storage
* engine is selected automatically at startup.
*/
@Injectable({
providedIn: 'root',
})
export class DatabaseService {
private readonly STORAGE_PREFIX = 'metoyou_';
private readonly platform = inject(PlatformService);
private readonly browserDb = inject(BrowserDatabaseService);
private readonly electronDb = inject(ElectronDatabaseService);
isReady = signal(false);
/** The active backend for the current platform. */
private get backend() {
return this.platform.isBrowser ? this.browserDb : this.electronDb;
}
/* ------------------------------------------------------------------ */
/* Lifecycle */
/* ------------------------------------------------------------------ */
async initialize(): Promise<void> {
// Initialize storage structure if needed
if (!localStorage.getItem(this.key('initialized'))) {
this.initializeStorage();
}
await this.backend.initialize();
this.isReady.set(true);
}
private initializeStorage(): void {
localStorage.setItem(this.key('messages'), JSON.stringify([]));
localStorage.setItem(this.key('users'), JSON.stringify([]));
localStorage.setItem(this.key('rooms'), JSON.stringify([]));
localStorage.setItem(this.key('reactions'), JSON.stringify([]));
localStorage.setItem(this.key('bans'), JSON.stringify([]));
localStorage.setItem(this.key('initialized'), 'true');
}
/* ------------------------------------------------------------------ */
/* Messages */
/* ------------------------------------------------------------------ */
private key(name: string): string {
return this.STORAGE_PREFIX + name;
}
saveMessage(message: Message) { return this.backend.saveMessage(message); }
getMessages(roomId: string, limit = 100, offset = 0) { return this.backend.getMessages(roomId, limit, offset); }
deleteMessage(messageId: string) { return this.backend.deleteMessage(messageId); }
updateMessage(messageId: string, updates: Partial<Message>) { return this.backend.updateMessage(messageId, updates); }
getMessageById(messageId: string) { return this.backend.getMessageById(messageId); }
clearRoomMessages(roomId: string) { return this.backend.clearRoomMessages(roomId); }
private getArray<T>(key: string): T[] {
const data = localStorage.getItem(this.key(key));
return data ? JSON.parse(data) : [];
}
/* ------------------------------------------------------------------ */
/* Reactions */
/* ------------------------------------------------------------------ */
private setArray<T>(key: string, data: T[]): void {
localStorage.setItem(this.key(key), JSON.stringify(data));
}
saveReaction(reaction: Reaction) { return this.backend.saveReaction(reaction); }
removeReaction(messageId: string, userId: string, emoji: string) { return this.backend.removeReaction(messageId, userId, emoji); }
getReactionsForMessage(messageId: string) { return this.backend.getReactionsForMessage(messageId); }
// Messages
async saveMessage(message: Message): Promise<void> {
const messages = this.getArray<Message>('messages');
const index = messages.findIndex((m) => m.id === message.id);
if (index >= 0) {
messages[index] = message;
} else {
messages.push(message);
}
this.setArray('messages', messages);
}
/* ------------------------------------------------------------------ */
/* Users */
/* ------------------------------------------------------------------ */
async getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> {
const messages = this.getArray<Message>('messages');
return messages
.filter((m) => m.roomId === roomId)
.sort((a, b) => a.timestamp - b.timestamp)
.slice(offset, offset + limit);
}
saveUser(user: User) { return this.backend.saveUser(user); }
getUser(userId: string) { return this.backend.getUser(userId); }
getCurrentUser() { return this.backend.getCurrentUser(); }
setCurrentUserId(userId: string) { return this.backend.setCurrentUserId(userId); }
getUsersByRoom(roomId: string) { return this.backend.getUsersByRoom(roomId); }
updateUser(userId: string, updates: Partial<User>) { return this.backend.updateUser(userId, updates); }
async deleteMessage(messageId: string): Promise<void> {
const messages = this.getArray<Message>('messages');
const filtered = messages.filter((m) => m.id !== messageId);
this.setArray('messages', filtered);
}
/* ------------------------------------------------------------------ */
/* Rooms */
/* ------------------------------------------------------------------ */
async updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
const messages = this.getArray<Message>('messages');
const index = messages.findIndex((m) => m.id === messageId);
if (index >= 0) {
messages[index] = { ...messages[index], ...updates };
this.setArray('messages', messages);
}
}
saveRoom(room: Room) { return this.backend.saveRoom(room); }
getRoom(roomId: string) { return this.backend.getRoom(roomId); }
getAllRooms() { return this.backend.getAllRooms(); }
deleteRoom(roomId: string) { return this.backend.deleteRoom(roomId); }
updateRoom(roomId: string, updates: Partial<Room>) { return this.backend.updateRoom(roomId, updates); }
async getMessageById(messageId: string): Promise<Message | null> {
const messages = this.getArray<Message>('messages');
return messages.find((m) => m.id === messageId) || null;
}
/* ------------------------------------------------------------------ */
/* Bans */
/* ------------------------------------------------------------------ */
async clearRoomMessages(roomId: string): Promise<void> {
const messages = this.getArray<Message>('messages');
const filtered = messages.filter((m) => m.roomId !== roomId);
this.setArray('messages', filtered);
}
saveBan(ban: BanEntry) { return this.backend.saveBan(ban); }
removeBan(oderId: string) { return this.backend.removeBan(oderId); }
getBansForRoom(roomId: string) { return this.backend.getBansForRoom(roomId); }
isUserBanned(userId: string, roomId: string) { return this.backend.isUserBanned(userId, roomId); }
// Reactions
async saveReaction(reaction: Reaction): Promise<void> {
const reactions = this.getArray<Reaction>('reactions');
const exists = reactions.some(
(r) =>
r.messageId === reaction.messageId &&
r.userId === reaction.userId &&
r.emoji === reaction.emoji
);
if (!exists) {
reactions.push(reaction);
this.setArray('reactions', reactions);
}
}
/* ------------------------------------------------------------------ */
/* Attachments */
/* ------------------------------------------------------------------ */
async removeReaction(messageId: string, userId: string, emoji: string): Promise<void> {
const reactions = this.getArray<Reaction>('reactions');
const filtered = reactions.filter(
(r) => !(r.messageId === messageId && r.userId === userId && r.emoji === emoji)
);
this.setArray('reactions', filtered);
}
saveAttachment(attachment: any) { return this.backend.saveAttachment(attachment); }
getAttachmentsForMessage(messageId: string) { return this.backend.getAttachmentsForMessage(messageId); }
getAllAttachments() { return this.backend.getAllAttachments(); }
deleteAttachmentsForMessage(messageId: string) { return this.backend.deleteAttachmentsForMessage(messageId); }
async getReactionsForMessage(messageId: string): Promise<Reaction[]> {
const reactions = this.getArray<Reaction>('reactions');
return reactions.filter((r) => r.messageId === messageId);
}
/* ------------------------------------------------------------------ */
/* Utilities */
/* ------------------------------------------------------------------ */
// Users
async saveUser(user: User): Promise<void> {
const users = this.getArray<User>('users');
const index = users.findIndex((u) => u.id === user.id);
if (index >= 0) {
users[index] = user;
} else {
users.push(user);
}
this.setArray('users', users);
}
async getUser(userId: string): Promise<User | null> {
const users = this.getArray<User>('users');
return users.find((u) => u.id === userId) || null;
}
async getCurrentUser(): Promise<User | null> {
const currentUserId = localStorage.getItem(this.key('currentUserId'));
if (!currentUserId) return null;
return this.getUser(currentUserId);
}
async setCurrentUserId(userId: string): Promise<void> {
localStorage.setItem(this.key('currentUserId'), userId);
}
async getUsersByRoom(roomId: string): Promise<User[]> {
return this.getArray<User>('users');
}
async updateUser(userId: string, updates: Partial<User>): Promise<void> {
const users = this.getArray<User>('users');
const index = users.findIndex((u) => u.id === userId);
if (index >= 0) {
users[index] = { ...users[index], ...updates };
this.setArray('users', users);
}
}
// Rooms
async saveRoom(room: Room): Promise<void> {
const rooms = this.getArray<Room>('rooms');
const index = rooms.findIndex((r) => r.id === room.id);
if (index >= 0) {
rooms[index] = room;
} else {
rooms.push(room);
}
this.setArray('rooms', rooms);
}
async getRoom(roomId: string): Promise<Room | null> {
const rooms = this.getArray<Room>('rooms');
return rooms.find((r) => r.id === roomId) || null;
}
async getAllRooms(): Promise<Room[]> {
return this.getArray<Room>('rooms');
}
async deleteRoom(roomId: string): Promise<void> {
const rooms = this.getArray<Room>('rooms');
const filtered = rooms.filter((r) => r.id !== roomId);
this.setArray('rooms', filtered);
await this.clearRoomMessages(roomId);
}
async updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
const rooms = this.getArray<Room>('rooms');
const index = rooms.findIndex((r) => r.id === roomId);
if (index >= 0) {
rooms[index] = { ...rooms[index], ...updates };
this.setArray('rooms', rooms);
}
}
// Bans
async saveBan(ban: BanEntry): Promise<void> {
const bans = this.getArray<BanEntry>('bans');
bans.push(ban);
this.setArray('bans', bans);
}
async removeBan(oderId: string): Promise<void> {
const bans = this.getArray<BanEntry>('bans');
const filtered = bans.filter((b) => b.oderId !== oderId);
this.setArray('bans', filtered);
}
async getBansForRoom(roomId: string): Promise<BanEntry[]> {
const bans = this.getArray<BanEntry>('bans');
const now = Date.now();
return bans.filter(
(b) => b.roomId === roomId && (!b.expiresAt || b.expiresAt > now)
);
}
async isUserBanned(userId: string, roomId: string): Promise<boolean> {
const bans = await this.getBansForRoom(roomId);
return bans.some((b) => b.oderId === userId);
}
async clearAllData(): Promise<void> {
const keys = Object.keys(localStorage).filter((k) =>
k.startsWith(this.STORAGE_PREFIX)
);
keys.forEach((k) => localStorage.removeItem(k));
this.initializeStorage();
}
clearAllData() { return this.backend.clearAllData(); }
}

View File

@@ -0,0 +1,173 @@
import { Injectable } from '@angular/core';
import { Message, User, Room, Reaction, BanEntry } from '../models';
/**
* Database service for the Electron (desktop) runtime.
*
* All SQLite queries run in the Electron **main process**
* (`electron/database.js`). This service is a thin IPC client that
* delegates every operation to `window.electronAPI.db.*`.
*/
@Injectable({ providedIn: 'root' })
export class ElectronDatabaseService {
private initialized = false;
/** Shorthand for the preload-exposed database API. */
private get api() {
return (window as any).electronAPI.db;
}
/* ------------------------------------------------------------------ */
/* Lifecycle */
/* ------------------------------------------------------------------ */
async initialize(): Promise<void> {
if (this.initialized) return;
await this.api.initialize();
this.initialized = true;
}
/* ------------------------------------------------------------------ */
/* Messages */
/* ------------------------------------------------------------------ */
saveMessage(message: Message): Promise<void> {
return this.api.saveMessage(message);
}
getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> {
return this.api.getMessages(roomId, limit, offset);
}
deleteMessage(messageId: string): Promise<void> {
return this.api.deleteMessage(messageId);
}
updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
return this.api.updateMessage(messageId, updates);
}
getMessageById(messageId: string): Promise<Message | null> {
return this.api.getMessageById(messageId);
}
clearRoomMessages(roomId: string): Promise<void> {
return this.api.clearRoomMessages(roomId);
}
/* ------------------------------------------------------------------ */
/* Reactions */
/* ------------------------------------------------------------------ */
saveReaction(reaction: Reaction): Promise<void> {
return this.api.saveReaction(reaction);
}
removeReaction(messageId: string, userId: string, emoji: string): Promise<void> {
return this.api.removeReaction(messageId, userId, emoji);
}
getReactionsForMessage(messageId: string): Promise<Reaction[]> {
return this.api.getReactionsForMessage(messageId);
}
/* ------------------------------------------------------------------ */
/* Users */
/* ------------------------------------------------------------------ */
saveUser(user: User): Promise<void> {
return this.api.saveUser(user);
}
getUser(userId: string): Promise<User | null> {
return this.api.getUser(userId);
}
getCurrentUser(): Promise<User | null> {
return this.api.getCurrentUser();
}
setCurrentUserId(userId: string): Promise<void> {
return this.api.setCurrentUserId(userId);
}
getUsersByRoom(roomId: string): Promise<User[]> {
return this.api.getUsersByRoom(roomId);
}
updateUser(userId: string, updates: Partial<User>): Promise<void> {
return this.api.updateUser(userId, updates);
}
/* ------------------------------------------------------------------ */
/* Rooms */
/* ------------------------------------------------------------------ */
saveRoom(room: Room): Promise<void> {
return this.api.saveRoom(room);
}
getRoom(roomId: string): Promise<Room | null> {
return this.api.getRoom(roomId);
}
getAllRooms(): Promise<Room[]> {
return this.api.getAllRooms();
}
deleteRoom(roomId: string): Promise<void> {
return this.api.deleteRoom(roomId);
}
updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
return this.api.updateRoom(roomId, updates);
}
/* ------------------------------------------------------------------ */
/* Bans */
/* ------------------------------------------------------------------ */
saveBan(ban: BanEntry): Promise<void> {
return this.api.saveBan(ban);
}
removeBan(oderId: string): Promise<void> {
return this.api.removeBan(oderId);
}
getBansForRoom(roomId: string): Promise<BanEntry[]> {
return this.api.getBansForRoom(roomId);
}
isUserBanned(userId: string, roomId: string): Promise<boolean> {
return this.api.isUserBanned(userId, roomId);
}
/* ------------------------------------------------------------------ */
/* Attachments */
/* ------------------------------------------------------------------ */
saveAttachment(attachment: any): Promise<void> {
return this.api.saveAttachment(attachment);
}
getAttachmentsForMessage(messageId: string): Promise<any[]> {
return this.api.getAttachmentsForMessage(messageId);
}
getAllAttachments(): Promise<any[]> {
return this.api.getAllAttachments();
}
deleteAttachmentsForMessage(messageId: string): Promise<void> {
return this.api.deleteAttachmentsForMessage(messageId);
}
/* ------------------------------------------------------------------ */
/* Utilities */
/* ------------------------------------------------------------------ */
clearAllData(): Promise<void> {
return this.api.clearAllData();
}
}

View File

@@ -1,3 +1,6 @@
export * from './platform.service';
export * from './browser-database.service';
export * from './electron-database.service';
export * from './database.service';
export * from './webrtc.service';
export * from './server-directory.service';

View File

@@ -0,0 +1,20 @@
import { Injectable } from '@angular/core';
/**
* Detects the runtime platform so other services can branch behaviour
* between Electron (desktop) and a plain browser tab.
*/
@Injectable({ providedIn: 'root' })
export class PlatformService {
/** True when the app is hosted inside an Electron renderer process. */
readonly isElectron: boolean;
/** True when the app is running in an ordinary browser (no Electron shell). */
readonly isBrowser: boolean;
constructor() {
this.isElectron =
typeof window !== 'undefined' && !!(window as any).electronAPI;
this.isBrowser = !this.isElectron;
}
}

View File

@@ -3,6 +3,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, of, throwError, forkJoin } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ServerInfo, JoinRequest, User } from '../models';
import { v4 as uuidv4 } from 'uuid';
export interface ServerEndpoint {
id: string;
@@ -15,9 +16,19 @@ export interface ServerEndpoint {
}
const STORAGE_KEY = 'metoyou_server_endpoints';
/** Derive default server URL from current page protocol (handles SSL toggle). */
function getDefaultServerUrl(): string {
if (typeof window !== 'undefined' && window.location) {
const proto = window.location.protocol === 'https:' ? 'https' : 'http';
return `${proto}://localhost:3001`;
}
return 'http://localhost:3001';
}
const DEFAULT_SERVER: Omit<ServerEndpoint, 'id'> = {
name: 'Local Server',
url: 'http://localhost:3001',
url: getDefaultServerUrl(),
isActive: true,
isDefault: true,
status: 'unknown',
@@ -41,12 +52,21 @@ export class ServerDirectoryService {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
const servers = JSON.parse(stored) as ServerEndpoint[];
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();
}
@@ -58,7 +78,7 @@ export class ServerDirectoryService {
private initializeDefaultServer(): void {
const defaultServer: ServerEndpoint = {
...DEFAULT_SERVER,
id: crypto.randomUUID(),
id: uuidv4(),
};
this._servers.set([defaultServer]);
this.saveServers();
@@ -70,7 +90,7 @@ export class ServerDirectoryService {
private get baseUrl(): string {
const active = this.activeServer();
const raw = active ? active.url : 'http://localhost:3001';
const raw = active ? active.url : getDefaultServerUrl();
// Strip trailing slashes and any accidental '/api'
let base = raw.replace(/\/+$/,'');
if (base.toLowerCase().endsWith('/api')) {
@@ -87,7 +107,7 @@ export class ServerDirectoryService {
// Server management methods
addServer(server: { name: string; url: string }): void {
const newServer: ServerEndpoint = {
id: crypto.randomUUID(),
id: uuidv4(),
name: server.name,
// Sanitize: remove trailing slashes and any '/api'
url: (() => {
@@ -396,7 +416,10 @@ export class ServerDirectoryService {
// Get the WebSocket URL for the active server
getWebSocketUrl(): string {
const active = this.activeServer();
if (!active) return 'ws://localhost:3001';
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');

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
/**
* Barrel export for the WebRTC sub-module.
*
* Other modules should import from here:
* import { ... } from './webrtc';
*/
export * from './webrtc.constants';
export * from './webrtc.types';
export * from './webrtc-logger';
export * from './signaling.manager';
export * from './peer-connection.manager';
export * from './media.manager';
export * from './screen-share.manager';

View File

@@ -0,0 +1,311 @@
/**
* Manages local voice media: getUserMedia, mute, deafen,
* attaching/detaching audio tracks to peer connections, and bitrate tuning.
*/
import { Subject } from 'rxjs';
import { WebRTCLogger } from './webrtc-logger';
import { PeerData } from './webrtc.types';
import {
TRACK_KIND_AUDIO,
TRACK_KIND_VIDEO,
TRANSCEIVER_SEND_RECV,
TRANSCEIVER_RECV_ONLY,
TRANSCEIVER_INACTIVE,
AUDIO_BITRATE_MIN_BPS,
AUDIO_BITRATE_MAX_BPS,
KBPS_TO_BPS,
LATENCY_PROFILE_BITRATES,
VOLUME_MIN,
VOLUME_MAX,
VOICE_HEARTBEAT_INTERVAL_MS,
DEFAULT_DISPLAY_NAME,
P2P_TYPE_VOICE_STATE,
LatencyProfile,
} from './webrtc.constants';
/**
* Callbacks the MediaManager needs from the owning service / peer manager.
*/
export interface MediaManagerCallbacks {
/** All active peer connections (for attaching tracks). */
getActivePeers(): Map<string, PeerData>;
/** Trigger SDP renegotiation for a specific peer. */
renegotiate(peerId: string): Promise<void>;
/** Broadcast a message to all peers. */
broadcastMessage(event: any): void;
/** Get identify credentials (for broadcasting). */
getIdentifyOderId(): string;
getIdentifyDisplayName(): string;
}
export class MediaManager {
/** The current local media stream (mic audio). */
private localMediaStream: MediaStream | null = null;
/** Remote audio output volume (0-1). */
private remoteAudioVolume = VOLUME_MAX;
/** Voice-presence heartbeat timer. */
private voicePresenceTimer: ReturnType<typeof setInterval> | null = null;
/** Emitted when voice is successfully connected. */
readonly voiceConnected$ = new Subject<void>();
// State tracked locally (the service exposes these via signals)
private isVoiceActive = false;
private isMicMuted = false;
private isSelfDeafened = false;
/** Current voice channel room ID (set when joining voice). */
private currentVoiceRoomId: string | undefined;
/** Current voice channel server ID (set when joining voice). */
private currentVoiceServerId: string | undefined;
constructor(
private readonly logger: WebRTCLogger,
private callbacks: MediaManagerCallbacks,
) {}
setCallbacks(cb: MediaManagerCallbacks): void {
this.callbacks = cb;
}
// ─── Accessors ─────────────────────────────────────────────────────
getLocalStream(): MediaStream | null { return this.localMediaStream; }
getIsVoiceActive(): boolean { return this.isVoiceActive; }
getIsMicMuted(): boolean { return this.isMicMuted; }
getIsSelfDeafened(): boolean { return this.isSelfDeafened; }
getRemoteAudioVolume(): number { return this.remoteAudioVolume; }
getCurrentVoiceRoomId(): string | undefined { return this.currentVoiceRoomId; }
getCurrentVoiceServerId(): string | undefined { return this.currentVoiceServerId; }
// ─── Enable / Disable voice ────────────────────────────────────────
async enableVoice(): Promise<MediaStream> {
try {
// Stop any existing stream first
if (this.localMediaStream) {
this.logger.info('Stopping existing local stream before enabling voice');
this.localMediaStream.getTracks().forEach((track) => track.stop());
this.localMediaStream = null;
}
const mediaConstraints: MediaStreamConstraints = {
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
video: false,
};
this.logger.info('getUserMedia constraints', mediaConstraints);
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error(
'navigator.mediaDevices is not available. ' +
'This requires a secure context (HTTPS or localhost). ' +
'If accessing from an external device, use HTTPS.'
);
}
const stream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
this.localMediaStream = stream;
this.logger.logStream('localVoice', stream);
this.bindLocalTracksToAllPeers();
this.isVoiceActive = true;
this.voiceConnected$.next();
return this.localMediaStream;
} catch (error) {
this.logger.error('Failed to getUserMedia', error);
throw error;
}
}
disableVoice(): void {
if (this.localMediaStream) {
this.localMediaStream.getTracks().forEach((track) => track.stop());
this.localMediaStream = null;
}
// Remove audio senders but keep connections alive
this.callbacks.getActivePeers().forEach((peerData) => {
const senders = peerData.connection.getSenders();
senders.forEach(sender => {
if (sender.track?.kind === TRACK_KIND_AUDIO) {
peerData.connection.removeTrack(sender);
}
});
});
this.isVoiceActive = false;
this.currentVoiceRoomId = undefined;
this.currentVoiceServerId = undefined;
}
/** Set the local stream from an external source (e.g. voice-controls component). */
setLocalStream(stream: MediaStream): void {
this.localMediaStream = stream;
this.bindLocalTracksToAllPeers();
this.isVoiceActive = true;
this.voiceConnected$.next();
}
// ─── Mute / Deafen ────────────────────────────────────────────────
toggleMute(muted?: boolean): void {
if (this.localMediaStream) {
const audioTracks = this.localMediaStream.getAudioTracks();
const newMutedState = muted !== undefined ? muted : !this.isMicMuted;
audioTracks.forEach((track) => { track.enabled = !newMutedState; });
this.isMicMuted = newMutedState;
}
}
toggleDeafen(deafened?: boolean): void {
this.isSelfDeafened = deafened !== undefined ? deafened : !this.isSelfDeafened;
}
// ─── Volume ────────────────────────────────────────────────────────
setOutputVolume(volume: number): void {
this.remoteAudioVolume = Math.max(VOLUME_MIN, Math.min(VOLUME_MAX, volume));
}
// ─── Audio bitrate ────────────────────────────────────────────────
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)));
this.callbacks.getActivePeers().forEach(async (peerData) => {
const sender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO);
if (!sender?.track) return;
if (peerData.connection.signalingState !== 'stable') return;
let params: RTCRtpSendParameters;
try { params = sender.getParameters(); } catch (e) { console.warn('getParameters failed; skipping bitrate apply', e); 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);
}
});
}
async setLatencyProfile(profile: LatencyProfile): Promise<void> {
await this.setAudioBitrate(LATENCY_PROFILE_BITRATES[profile]);
}
// ─── Voice-presence heartbeat ─────────────────────────────────────
startVoiceHeartbeat(roomId?: string, serverId?: string): void {
this.stopVoiceHeartbeat();
// Persist voice channel context so heartbeats and state snapshots include it
if (roomId !== undefined) this.currentVoiceRoomId = roomId;
if (serverId !== undefined) this.currentVoiceServerId = serverId;
this.voicePresenceTimer = setInterval(() => {
if (this.isVoiceActive) {
this.broadcastVoicePresence();
}
}, VOICE_HEARTBEAT_INTERVAL_MS);
// Also send an immediate heartbeat
if (this.isVoiceActive) {
this.broadcastVoicePresence();
}
}
stopVoiceHeartbeat(): void {
if (this.voicePresenceTimer) {
clearInterval(this.voicePresenceTimer);
this.voicePresenceTimer = null;
}
}
// ─── Internal helpers ──────────────────────────────────────────────
/**
* Bind local audio/video tracks to all existing peer transceivers.
* Restores transceiver direction to sendrecv if previously set to recvonly
* (which happens when disableVoice calls removeTrack).
*/
private bindLocalTracksToAllPeers(): void {
const peers = this.callbacks.getActivePeers();
if (!this.localMediaStream) return;
const localAudioTrack = this.localMediaStream.getAudioTracks()[0] || null;
const localVideoTrack = this.localMediaStream.getVideoTracks()[0] || null;
peers.forEach((peerData, peerId) => {
if (localAudioTrack) {
let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO);
if (!audioSender) {
audioSender = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV }).sender;
}
peerData.audioSender = audioSender;
// Restore direction after removeTrack (which sets it to recvonly)
const audioTransceiver = peerData.connection.getTransceivers().find(t => t.sender === audioSender);
if (audioTransceiver && (audioTransceiver.direction === TRANSCEIVER_RECV_ONLY || audioTransceiver.direction === TRANSCEIVER_INACTIVE)) {
audioTransceiver.direction = TRANSCEIVER_SEND_RECV;
}
audioSender.replaceTrack(localAudioTrack)
.then(() => this.logger.info('audio replaceTrack ok', { peerId }))
.catch((e) => this.logger.error('audio replaceTrack failed', e));
}
if (localVideoTrack) {
let videoSender = peerData.videoSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_VIDEO);
if (!videoSender) {
videoSender = peerData.connection.addTransceiver(TRACK_KIND_VIDEO, { direction: TRANSCEIVER_SEND_RECV }).sender;
}
peerData.videoSender = videoSender;
const videoTransceiver = peerData.connection.getTransceivers().find(t => t.sender === videoSender);
if (videoTransceiver && (videoTransceiver.direction === TRANSCEIVER_RECV_ONLY || videoTransceiver.direction === TRANSCEIVER_INACTIVE)) {
videoTransceiver.direction = TRANSCEIVER_SEND_RECV;
}
videoSender.replaceTrack(localVideoTrack)
.then(() => this.logger.info('video replaceTrack ok', { peerId }))
.catch((e) => this.logger.error('video replaceTrack failed', e));
}
this.callbacks.renegotiate(peerId);
});
}
private broadcastVoicePresence(): void {
const oderId = this.callbacks.getIdentifyOderId();
const displayName = this.callbacks.getIdentifyDisplayName();
this.callbacks.broadcastMessage({
type: P2P_TYPE_VOICE_STATE,
oderId,
displayName,
voiceState: {
isConnected: this.isVoiceActive,
isMuted: this.isMicMuted,
isDeafened: this.isSelfDeafened,
roomId: this.currentVoiceRoomId,
serverId: this.currentVoiceServerId,
},
});
}
/** Clean up all resources. */
destroy(): void {
this.disableVoice();
this.stopVoiceHeartbeat();
this.voiceConnected$.complete();
}
}

View File

@@ -0,0 +1,623 @@
/**
* Creates and manages RTCPeerConnections, data channels,
* offer/answer negotiation, ICE candidates, and P2P reconnection.
*/
import { Subject } from 'rxjs';
import { ChatEvent } from '../../models';
import { WebRTCLogger } from './webrtc-logger';
import { PeerData, DisconnectedPeerEntry, VoiceStateSnapshot, IdentifyCredentials } from './webrtc.types';
import {
ICE_SERVERS,
DATA_CHANNEL_LABEL,
DATA_CHANNEL_HIGH_WATER_BYTES,
DATA_CHANNEL_LOW_WATER_BYTES,
DATA_CHANNEL_STATE_OPEN,
CONNECTION_STATE_CONNECTED,
CONNECTION_STATE_DISCONNECTED,
CONNECTION_STATE_FAILED,
CONNECTION_STATE_CLOSED,
TRACK_KIND_AUDIO,
TRACK_KIND_VIDEO,
TRANSCEIVER_SEND_RECV,
TRANSCEIVER_RECV_ONLY,
PEER_RECONNECT_MAX_ATTEMPTS,
PEER_RECONNECT_INTERVAL_MS,
P2P_TYPE_STATE_REQUEST,
P2P_TYPE_VOICE_STATE_REQUEST,
P2P_TYPE_VOICE_STATE,
P2P_TYPE_SCREEN_STATE,
SIGNALING_TYPE_OFFER,
SIGNALING_TYPE_ANSWER,
SIGNALING_TYPE_ICE_CANDIDATE,
DEFAULT_DISPLAY_NAME,
} from './webrtc.constants';
/**
* Callbacks the PeerConnectionManager needs from the owning service.
* This keeps the manager decoupled from Angular DI / signals.
*/
export interface PeerConnectionCallbacks {
/** Send a raw JSON message via the signaling server. */
sendRawMessage(msg: Record<string, unknown>): void;
/** Get the current local media stream (mic audio). */
getLocalMediaStream(): MediaStream | null;
/** Whether signaling is currently connected. */
isSignalingConnected(): boolean;
/** Returns the current voice/screen state snapshot for broadcasting. */
getVoiceStateSnapshot(): VoiceStateSnapshot;
/** Returns the identify credentials (oderId + displayName). */
getIdentifyCredentials(): IdentifyCredentials | null;
/** Returns the local peer ID. */
getLocalPeerId(): string;
/** Whether screen sharing is active. */
isScreenSharingActive(): boolean;
}
export class PeerConnectionManager {
/** Active peer connections keyed by remote peer ID. */
readonly activePeerConnections = new Map<string, PeerData>();
/** Remote composite streams keyed by remote peer ID. */
readonly remotePeerStreams = new Map<string, MediaStream>();
/** Tracks disconnected peers for P2P reconnection scheduling. */
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 }>();
readonly messageReceived$ = new Subject<ChatEvent>();
/** Emitted whenever the connected peer list changes. */
readonly connectedPeersChanged$ = new Subject<string[]>();
constructor(
private readonly logger: WebRTCLogger,
private callbacks: PeerConnectionCallbacks,
) {}
/** Allow hot-swapping callbacks (e.g. after service wiring). */
setCallbacks(cb: PeerConnectionCallbacks): void {
this.callbacks = cb;
}
// ─── Peer connection lifecycle ─────────────────────────────────────
createPeerConnection(remotePeerId: string, isInitiator: boolean): PeerData {
this.logger.info('Creating peer connection', { remotePeerId, isInitiator });
const connection = new RTCPeerConnection({ iceServers: ICE_SERVERS });
let dataChannel: RTCDataChannel | null = null;
// ICE candidates → signaling
connection.onicecandidate = (event) => {
if (event.candidate) {
this.logger.info('ICE candidate gathered', { remotePeerId, candidateType: (event.candidate as any)?.type });
this.callbacks.sendRawMessage({
type: SIGNALING_TYPE_ICE_CANDIDATE,
targetUserId: remotePeerId,
payload: { candidate: event.candidate },
});
}
};
// Connection state
connection.onconnectionstatechange = () => {
this.logger.info('connectionstatechange', { remotePeerId, state: connection.connectionState });
switch (connection.connectionState) {
case CONNECTION_STATE_CONNECTED:
this.addToConnectedPeers(remotePeerId);
this.peerConnected$.next(remotePeerId);
this.clearPeerReconnectTimer(remotePeerId);
this.disconnectedPeerTracker.delete(remotePeerId);
this.requestVoiceStateFromPeer(remotePeerId);
break;
case CONNECTION_STATE_DISCONNECTED:
case CONNECTION_STATE_FAILED:
this.trackDisconnectedPeer(remotePeerId);
this.removePeer(remotePeerId);
this.schedulePeerReconnect(remotePeerId);
break;
case CONNECTION_STATE_CLOSED:
this.removePeer(remotePeerId);
break;
}
};
// Additional state logs
connection.oniceconnectionstatechange = () => {
this.logger.info('iceconnectionstatechange', { remotePeerId, state: connection.iceConnectionState });
};
connection.onsignalingstatechange = () => {
this.logger.info('signalingstatechange', { remotePeerId, state: connection.signalingState });
};
connection.onnegotiationneeded = () => {
this.logger.info('negotiationneeded', { remotePeerId });
};
// Incoming remote tracks
connection.ontrack = (event) => {
this.handleRemoteTrack(event, remotePeerId);
};
// Data channel
if (isInitiator) {
dataChannel = connection.createDataChannel(DATA_CHANNEL_LABEL, { ordered: true });
this.setupDataChannel(dataChannel, remotePeerId);
} else {
connection.ondatachannel = (event) => {
this.logger.info('Received data channel', { remotePeerId });
dataChannel = event.channel;
const existing = this.activePeerConnections.get(remotePeerId);
if (existing) { existing.dataChannel = dataChannel; }
this.setupDataChannel(dataChannel, remotePeerId);
};
}
const peerData: PeerData = {
connection,
dataChannel,
isInitiator,
pendingIceCandidates: [],
audioSender: undefined,
videoSender: undefined,
};
// Pre-create transceivers only for the initiator (offerer).
if (isInitiator) {
const audioTransceiver = connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV });
const videoTransceiver = connection.addTransceiver(TRACK_KIND_VIDEO, { direction: TRANSCEIVER_RECV_ONLY });
peerData.audioSender = audioTransceiver.sender;
peerData.videoSender = videoTransceiver.sender;
}
this.activePeerConnections.set(remotePeerId, peerData);
// Attach local stream to initiator
const localStream = this.callbacks.getLocalMediaStream();
if (localStream && isInitiator) {
this.logger.logStream(`localStream->${remotePeerId}`, localStream);
localStream.getTracks().forEach((track) => {
if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) {
peerData.audioSender.replaceTrack(track)
.then(() => this.logger.info('audio replaceTrack (init) ok', { remotePeerId }))
.catch((e) => this.logger.error('audio replaceTrack failed at createPeerConnection', e));
} else if (track.kind === TRACK_KIND_VIDEO && peerData.videoSender) {
peerData.videoSender.replaceTrack(track)
.then(() => this.logger.info('video replaceTrack (init) ok', { remotePeerId }))
.catch((e) => this.logger.error('video replaceTrack failed at createPeerConnection', e));
} else {
const sender = connection.addTrack(track, localStream);
if (track.kind === TRACK_KIND_AUDIO) peerData.audioSender = sender;
if (track.kind === TRACK_KIND_VIDEO) peerData.videoSender = sender;
}
});
}
return peerData;
}
// ─── Offer / Answer / ICE ──────────────────────────────────────────
async createAndSendOffer(remotePeerId: string): Promise<void> {
const peerData = this.activePeerConnections.get(remotePeerId);
if (!peerData) return;
try {
const offer = await peerData.connection.createOffer();
await peerData.connection.setLocalDescription(offer);
this.logger.info('Sending offer', { remotePeerId, type: offer.type, sdpLength: offer.sdp?.length });
this.callbacks.sendRawMessage({
type: SIGNALING_TYPE_OFFER,
targetUserId: remotePeerId,
payload: { sdp: offer },
});
} catch (error) {
this.logger.error('Failed to create offer', error);
}
}
async handleOffer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise<void> {
this.logger.info('Handling offer', { fromUserId });
let peerData = this.activePeerConnections.get(fromUserId);
if (!peerData) {
peerData = this.createPeerConnection(fromUserId, false);
}
try {
await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp));
// Discover transceivers the browser created on the answerer side
const transceivers = peerData.connection.getTransceivers();
for (const transceiver of transceivers) {
if (transceiver.receiver.track?.kind === TRACK_KIND_AUDIO && !peerData.audioSender) {
peerData.audioSender = transceiver.sender;
} else if (transceiver.receiver.track?.kind === TRACK_KIND_VIDEO && !peerData.videoSender) {
peerData.videoSender = transceiver.sender;
}
}
// Attach local tracks (answerer side)
const localStream = this.callbacks.getLocalMediaStream();
if (localStream) {
this.logger.logStream(`localStream->${fromUserId} (answerer)`, localStream);
for (const track of localStream.getTracks()) {
if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) {
await peerData.audioSender.replaceTrack(track);
this.logger.info('audio replaceTrack (answerer) ok', { fromUserId });
} else if (track.kind === TRACK_KIND_VIDEO && peerData.videoSender) {
await peerData.videoSender.replaceTrack(track);
this.logger.info('video replaceTrack (answerer) ok', { fromUserId });
}
}
}
// Flush queued ICE candidates
for (const candidate of peerData.pendingIceCandidates) {
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
}
peerData.pendingIceCandidates = [];
const answer = await peerData.connection.createAnswer();
await peerData.connection.setLocalDescription(answer);
this.logger.info('Sending answer', { to: fromUserId, type: answer.type, sdpLength: answer.sdp?.length });
this.callbacks.sendRawMessage({
type: SIGNALING_TYPE_ANSWER,
targetUserId: fromUserId,
payload: { sdp: answer },
});
} catch (error) {
this.logger.error('Failed to handle offer', error);
}
}
async handleAnswer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise<void> {
this.logger.info('Handling answer', { fromUserId });
const peerData = this.activePeerConnections.get(fromUserId);
if (!peerData) {
this.logger.error('No peer for answer', new Error('Missing peer'), { fromUserId });
return;
}
try {
if (peerData.connection.signalingState === 'have-local-offer') {
await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp));
for (const candidate of peerData.pendingIceCandidates) {
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
}
peerData.pendingIceCandidates = [];
} else {
this.logger.warn('Ignoring answer wrong signaling state', { state: peerData.connection.signalingState });
}
} catch (error) {
this.logger.error('Failed to handle answer', error);
}
}
async handleIceCandidate(fromUserId: string, candidate: RTCIceCandidateInit): Promise<void> {
let peerData = this.activePeerConnections.get(fromUserId);
if (!peerData) {
this.logger.info('Creating peer for early ICE', { fromUserId });
peerData = this.createPeerConnection(fromUserId, false);
}
try {
if (peerData.connection.remoteDescription) {
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
} else {
this.logger.info('Queuing ICE candidate', { fromUserId });
peerData.pendingIceCandidates.push(candidate);
}
} catch (error) {
this.logger.error('Failed to add ICE candidate', error);
}
}
/** Re-negotiate (create offer) to push track changes to remote. */
async renegotiate(peerId: string): Promise<void> {
const peerData = this.activePeerConnections.get(peerId);
if (!peerData) return;
try {
const offer = await peerData.connection.createOffer();
await peerData.connection.setLocalDescription(offer);
this.logger.info('Renegotiate offer', { peerId, type: offer.type, sdpLength: offer.sdp?.length });
this.callbacks.sendRawMessage({
type: SIGNALING_TYPE_OFFER,
targetUserId: peerId,
payload: { sdp: offer },
});
} catch (error) {
this.logger.error('Failed to renegotiate', error);
}
}
// ─── Data channel ──────────────────────────────────────────────────
private setupDataChannel(channel: RTCDataChannel, remotePeerId: string): void {
channel.onopen = () => {
console.log(`Data channel open with ${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}`);
};
channel.onerror = (error) => {
console.error(`Data channel error with ${remotePeerId}:`, error);
};
channel.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
this.handlePeerMessage(remotePeerId, message);
} catch (error) {
this.logger.error('Failed to parse peer message', error);
}
};
}
private handlePeerMessage(peerId: string, message: any): void {
console.log('Received P2P message from', peerId, ':', message);
if (message.type === P2P_TYPE_STATE_REQUEST || message.type === P2P_TYPE_VOICE_STATE_REQUEST) {
this.sendCurrentStatesToPeer(peerId);
return;
}
const enriched = { ...message, fromPeerId: peerId };
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);
this.activePeerConnections.forEach((peerData, peerId) => {
try {
if (peerData.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) {
peerData.dataChannel.send(data);
console.log('Sent message via P2P to:', peerId);
}
} catch (error) {
console.error(`Failed to send to peer ${peerId}:`, error);
}
});
}
/** Send a ChatEvent to a single peer. */
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`);
return;
}
try {
peerData.dataChannel.send(JSON.stringify(event));
} catch (error) {
console.error(`Failed to send to peer ${peerId}:`, error);
}
}
/** Send with back-pressure awareness (for large payloads). */
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`);
return;
}
const channel = peerData.dataChannel;
const data = JSON.stringify(event);
if (typeof channel.bufferedAmountLowThreshold === 'number') {
channel.bufferedAmountLowThreshold = DATA_CHANNEL_LOW_WATER_BYTES;
}
if (channel.bufferedAmount > DATA_CHANNEL_HIGH_WATER_BYTES) {
await new Promise<void>((resolve) => {
const handler = () => {
if (channel.bufferedAmount <= DATA_CHANNEL_LOW_WATER_BYTES) {
channel.removeEventListener('bufferedamountlow', handler as any);
resolve();
}
};
channel.addEventListener('bufferedamountlow', handler as any, { once: true } as any);
});
}
try { channel.send(data); } catch (error) { console.error(`Failed to send to peer ${peerId}:`, error); }
}
// ─── State broadcasts ─────────────────────────────────────────────
sendCurrentStatesToPeer(peerId: string): void {
const credentials = this.callbacks.getIdentifyCredentials();
const oderId = credentials?.oderId || this.callbacks.getLocalPeerId();
const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
const voiceState = this.callbacks.getVoiceStateSnapshot();
this.sendToPeer(peerId, { type: P2P_TYPE_VOICE_STATE, oderId, displayName, voiceState } as any);
this.sendToPeer(peerId, { type: P2P_TYPE_SCREEN_STATE, oderId, displayName, isScreenSharing: this.callbacks.isScreenSharingActive() } as any);
}
private sendCurrentStatesToChannel(channel: RTCDataChannel, remotePeerId: string): void {
if (channel.readyState !== DATA_CHANNEL_STATE_OPEN) {
this.logger.warn('Cannot send states channel not open', { remotePeerId, state: channel.readyState });
return;
}
const credentials = this.callbacks.getIdentifyCredentials();
const oderId = credentials?.oderId || this.callbacks.getLocalPeerId();
const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
const voiceState = this.callbacks.getVoiceStateSnapshot();
try {
channel.send(JSON.stringify({ type: P2P_TYPE_VOICE_STATE, oderId, displayName, voiceState }));
channel.send(JSON.stringify({ type: P2P_TYPE_SCREEN_STATE, oderId, displayName, isScreenSharing: this.callbacks.isScreenSharingActive() }));
this.logger.info('Sent initial states to channel', { remotePeerId, voiceState });
} catch (e) {
this.logger.error('Failed to send initial states to channel', e);
}
}
broadcastCurrentStates(): void {
const credentials = this.callbacks.getIdentifyCredentials();
const oderId = credentials?.oderId || this.callbacks.getLocalPeerId();
const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
const voiceState = this.callbacks.getVoiceStateSnapshot();
this.broadcastMessage({ type: P2P_TYPE_VOICE_STATE, oderId, displayName, voiceState } as any);
this.broadcastMessage({ type: P2P_TYPE_SCREEN_STATE, oderId, displayName, isScreenSharing: this.callbacks.isScreenSharingActive() } as any);
}
// ─── Remote tracks ─────────────────────────────────────────────────
private handleRemoteTrack(event: RTCTrackEvent, remotePeerId: string): void {
const track = event.track;
const settings = typeof track.getSettings === 'function' ? track.getSettings() : {} as MediaTrackSettings;
this.logger.info('Remote track', { remotePeerId, kind: track.kind, id: track.id, enabled: track.enabled, readyState: track.readyState, settings });
this.logger.attachTrackDiagnostics(track, `remote:${remotePeerId}:${track.kind}`);
// Skip inactive video placeholder tracks
if (track.kind === TRACK_KIND_VIDEO && (!track.enabled || track.readyState !== 'live')) {
this.logger.info('Skipping inactive video track', { remotePeerId, enabled: track.enabled, readyState: track.readyState });
return;
}
// Merge into composite stream per peer
let compositeStream = this.remotePeerStreams.get(remotePeerId) || new MediaStream();
const trackAlreadyAdded = compositeStream.getTracks().some(t => t.id === track.id);
if (!trackAlreadyAdded) {
try { compositeStream.addTrack(track); } catch (e) { this.logger.warn('Failed to add track to composite stream', e as any); }
}
this.remotePeerStreams.set(remotePeerId, compositeStream);
this.remoteStream$.next({ peerId: remotePeerId, stream: compositeStream });
}
// ─── Peer removal / cleanup ────────────────────────────────────────
removePeer(peerId: string): void {
const peerData = this.activePeerConnections.get(peerId);
if (peerData) {
if (peerData.dataChannel) peerData.dataChannel.close();
peerData.connection.close();
this.activePeerConnections.delete(peerId);
this.removeFromConnectedPeers(peerId);
this.peerDisconnected$.next(peerId);
}
}
closeAllPeers(): void {
this.clearAllPeerReconnectTimers();
this.activePeerConnections.forEach((peerData) => {
if (peerData.dataChannel) peerData.dataChannel.close();
peerData.connection.close();
});
this.activePeerConnections.clear();
this.connectedPeersChanged$.next([]);
}
// ─── P2P reconnection ─────────────────────────────────────────────
private trackDisconnectedPeer(peerId: string): void {
this.disconnectedPeerTracker.set(peerId, { lastSeenTimestamp: Date.now(), reconnectAttempts: 0 });
}
private clearPeerReconnectTimer(peerId: string): void {
const timer = this.peerReconnectTimers.get(peerId);
if (timer) { clearInterval(timer); this.peerReconnectTimers.delete(peerId); }
}
clearAllPeerReconnectTimers(): void {
this.peerReconnectTimers.forEach((timer) => clearInterval(timer));
this.peerReconnectTimers.clear();
this.disconnectedPeerTracker.clear();
}
private schedulePeerReconnect(peerId: string): void {
if (this.peerReconnectTimers.has(peerId)) return;
this.logger.info('Scheduling P2P reconnect', { peerId });
const timer = setInterval(() => {
const info = this.disconnectedPeerTracker.get(peerId);
if (!info) { this.clearPeerReconnectTimer(peerId); return; }
info.reconnectAttempts++;
this.logger.info('P2P reconnect attempt', { peerId, attempt: info.reconnectAttempts });
if (info.reconnectAttempts >= PEER_RECONNECT_MAX_ATTEMPTS) {
this.logger.info('P2P reconnect max attempts reached', { peerId });
this.clearPeerReconnectTimer(peerId);
this.disconnectedPeerTracker.delete(peerId);
return;
}
if (!this.callbacks.isSignalingConnected()) {
this.logger.info('Skipping P2P reconnect no signaling connection', { peerId });
return;
}
this.attemptPeerReconnect(peerId);
}, PEER_RECONNECT_INTERVAL_MS);
this.peerReconnectTimers.set(peerId, timer);
}
private attemptPeerReconnect(peerId: string): void {
const existing = this.activePeerConnections.get(peerId);
if (existing) { try { existing.connection.close(); } catch { /* ignore */ } this.activePeerConnections.delete(peerId); }
this.createPeerConnection(peerId, true);
this.createAndSendOffer(peerId);
}
private requestVoiceStateFromPeer(peerId: string): void {
const peerData = this.activePeerConnections.get(peerId);
if (peerData?.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) {
try { peerData.dataChannel.send(JSON.stringify({ type: P2P_TYPE_VOICE_STATE_REQUEST })); } catch (e) { this.logger.warn('Failed to request voice state', e as any); }
}
}
// ─── Connected-peer helpers ────────────────────────────────────────
private connectedPeersList: string[] = [];
getConnectedPeerIds(): string[] {
return [...this.connectedPeersList];
}
private addToConnectedPeers(peerId: string): void {
if (!this.connectedPeersList.includes(peerId)) {
this.connectedPeersList = [...this.connectedPeersList, peerId];
this.connectedPeersChanged$.next(this.connectedPeersList);
}
}
private removeFromConnectedPeers(peerId: string): void {
this.connectedPeersList = this.connectedPeersList.filter(p => p !== peerId);
this.connectedPeersChanged$.next(this.connectedPeersList);
}
resetConnectedPeers(): void {
this.connectedPeersList = [];
this.connectedPeersChanged$.next([]);
}
/** Clean up all resources. */
destroy(): void {
this.closeAllPeers();
this.peerConnected$.complete();
this.peerDisconnected$.complete();
this.remoteStream$.complete();
this.messageReceived$.complete();
this.connectedPeersChanged$.complete();
}
}

View File

@@ -0,0 +1,275 @@
/**
* Manages screen sharing: getDisplayMedia / Electron desktop capturer,
* mixed audio (screen + mic), and attaching screen tracks to peers.
*/
import { WebRTCLogger } from './webrtc-logger';
import { PeerData } from './webrtc.types';
import {
TRACK_KIND_AUDIO,
TRACK_KIND_VIDEO,
TRANSCEIVER_SEND_RECV,
TRANSCEIVER_RECV_ONLY,
SCREEN_SHARE_IDEAL_WIDTH,
SCREEN_SHARE_IDEAL_HEIGHT,
SCREEN_SHARE_IDEAL_FRAME_RATE,
ELECTRON_ENTIRE_SCREEN_SOURCE_NAME,
} from './webrtc.constants';
/**
* Callbacks the ScreenShareManager needs from the owning service.
*/
export interface ScreenShareCallbacks {
getActivePeers(): Map<string, PeerData>;
getLocalMediaStream(): MediaStream | null;
renegotiate(peerId: string): Promise<void>;
broadcastCurrentStates(): void;
}
export class ScreenShareManager {
/** The active screen-capture stream. */
private activeScreenStream: MediaStream | null = null;
/** Mixed audio stream (screen audio + mic). */
private combinedAudioStream: MediaStream | null = null;
/** AudioContext used to mix screen + mic audio. */
private audioMixingContext: AudioContext | null = null;
/** Whether screen sharing is currently active. */
private isScreenActive = false;
constructor(
private readonly logger: WebRTCLogger,
private callbacks: ScreenShareCallbacks,
) {}
setCallbacks(cb: ScreenShareCallbacks): void {
this.callbacks = cb;
}
// ─── Accessors ─────────────────────────────────────────────────────
getScreenStream(): MediaStream | null { return this.activeScreenStream; }
getIsScreenActive(): boolean { return this.isScreenActive; }
// ─── Start / Stop ──────────────────────────────────────────────────
async startScreenShare(includeSystemAudio: boolean = false): Promise<MediaStream> {
try {
this.logger.info('startScreenShare invoked', { includeSystemAudio });
// Try Electron desktop capturer first
if (typeof window !== 'undefined' && (window as any).electronAPI?.getSources) {
try {
const sources = await (window as any).electronAPI.getSources();
const screenSource = sources.find((s: any) => s.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME) || sources[0];
const electronConstraints: any = {
video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id } },
};
if (includeSystemAudio) {
electronConstraints.audio = { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id } };
} else {
electronConstraints.audio = false;
}
this.logger.info('desktopCapturer constraints', electronConstraints);
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error('navigator.mediaDevices is not available (requires HTTPS or localhost).');
}
this.activeScreenStream = await navigator.mediaDevices.getUserMedia(electronConstraints);
} catch (e) {
this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', e as any);
}
}
// Fallback to standard getDisplayMedia
if (!this.activeScreenStream) {
const displayConstraints: DisplayMediaStreamOptions = {
video: {
width: { ideal: SCREEN_SHARE_IDEAL_WIDTH },
height: { ideal: SCREEN_SHARE_IDEAL_HEIGHT },
frameRate: { ideal: SCREEN_SHARE_IDEAL_FRAME_RATE },
},
audio: includeSystemAudio ? { echoCancellation: false, noiseSuppression: false, autoGainControl: false } : false,
} as any;
this.logger.info('getDisplayMedia constraints', displayConstraints);
if (!navigator.mediaDevices) {
throw new Error('navigator.mediaDevices is not available (requires HTTPS or localhost).');
}
this.activeScreenStream = await (navigator.mediaDevices as any).getDisplayMedia(displayConstraints);
}
this.logger.logStream('screen', this.activeScreenStream);
// Prepare mixed audio if system audio is included
this.prepareMixedAudio(includeSystemAudio);
// Attach tracks to peers
this.attachScreenTracksToPeers(includeSystemAudio);
this.isScreenActive = true;
// Auto-stop when user ends share via browser UI
const screenVideoTrack = this.activeScreenStream!.getVideoTracks()[0];
if (screenVideoTrack) {
screenVideoTrack.onended = () => {
this.logger.warn('Screen video track ended');
this.stopScreenShare();
};
}
return this.activeScreenStream!;
} catch (error) {
this.logger.error('Failed to start screen share', error);
throw error;
}
}
stopScreenShare(): void {
if (this.activeScreenStream) {
this.activeScreenStream.getTracks().forEach((track) => track.stop());
this.activeScreenStream = null;
this.isScreenActive = false;
this.callbacks.broadcastCurrentStates();
}
// Clean up mixed audio
if (this.combinedAudioStream) {
try { this.combinedAudioStream.getTracks().forEach(t => t.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);
if (videoTransceiver) {
videoTransceiver.sender.replaceTrack(null).catch(() => {});
if (videoTransceiver.direction === TRANSCEIVER_SEND_RECV) {
videoTransceiver.direction = TRANSCEIVER_RECV_ONLY;
}
}
peerData.screenVideoSender = undefined;
peerData.screenAudioSender = undefined;
// Restore mic track
const micTrack = this.callbacks.getLocalMediaStream()?.getAudioTracks()[0] || null;
if (micTrack) {
let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO);
if (!audioSender) {
const transceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV });
audioSender = transceiver.sender;
}
peerData.audioSender = audioSender;
audioSender.replaceTrack(micTrack).catch((e) => console.error('restore mic replaceTrack failed:', e));
}
this.callbacks.renegotiate(peerId);
});
}
// ─── Internal helpers ──────────────────────────────────────────────
/** Create a mixed audio stream from screen audio + mic audio. */
private prepareMixedAudio(includeSystemAudio: boolean): void {
const screenAudioTrack = includeSystemAudio ? (this.activeScreenStream?.getAudioTracks()[0] || null) : null;
const micAudioTrack = this.callbacks.getLocalMediaStream()?.getAudioTracks()[0] || null;
if (includeSystemAudio && screenAudioTrack) {
try {
if (!this.audioMixingContext && (window as any).AudioContext) {
this.audioMixingContext = new (window as any).AudioContext();
}
if (!this.audioMixingContext) throw new Error('AudioContext not available');
const destination = this.audioMixingContext.createMediaStreamDestination();
const screenAudioSource = this.audioMixingContext.createMediaStreamSource(new MediaStream([screenAudioTrack]));
screenAudioSource.connect(destination);
if (micAudioTrack) {
const micAudioSource = this.audioMixingContext.createMediaStreamSource(new MediaStream([micAudioTrack]));
micAudioSource.connect(destination);
this.logger.info('Mixed mic + screen audio together');
}
this.combinedAudioStream = destination.stream;
this.logger.logStream('combinedAudio(screen+mic)', this.combinedAudioStream);
} catch (e) {
this.logger.warn('Mixed audio creation failed; fallback to screen audio only', e as any);
this.combinedAudioStream = screenAudioTrack ? new MediaStream([screenAudioTrack]) : null;
this.logger.logStream('combinedAudio(fallback)', this.combinedAudioStream);
}
} else {
this.combinedAudioStream = null;
}
}
/** Attach screen video + audio tracks to all active peers. */
private attachScreenTracksToPeers(includeSystemAudio: boolean): void {
this.callbacks.getActivePeers().forEach((peerData, peerId) => {
if (!this.activeScreenStream) return;
const screenVideoTrack = this.activeScreenStream.getVideoTracks()[0];
if (!screenVideoTrack) return;
this.logger.attachTrackDiagnostics(screenVideoTrack, `screenVideo:${peerId}`);
// Use primary video sender/transceiver
let videoSender = peerData.videoSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_VIDEO);
if (!videoSender) {
const videoTransceiver = peerData.connection.addTransceiver(TRACK_KIND_VIDEO, { direction: TRANSCEIVER_SEND_RECV });
videoSender = videoTransceiver.sender;
peerData.videoSender = videoSender;
} else {
const transceivers = peerData.connection.getTransceivers();
const videoTransceiver = transceivers.find(t => t.sender === videoSender);
if (videoTransceiver?.direction === TRANSCEIVER_RECV_ONLY) {
videoTransceiver.direction = TRANSCEIVER_SEND_RECV;
}
}
peerData.screenVideoSender = videoSender;
videoSender.replaceTrack(screenVideoTrack)
.then(() => this.logger.info('screen video replaceTrack ok', { peerId }))
.catch((e) => this.logger.error('screen video replaceTrack failed', e));
// Audio handling
const micTrack = this.callbacks.getLocalMediaStream()?.getAudioTracks()[0] || null;
if (includeSystemAudio) {
const combinedTrack = this.combinedAudioStream?.getAudioTracks()[0] || null;
if (combinedTrack) {
this.logger.attachTrackDiagnostics(combinedTrack, `combinedAudio:${peerId}`);
let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO);
if (!audioSender) {
const transceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV });
audioSender = transceiver.sender;
}
peerData.audioSender = audioSender;
audioSender.replaceTrack(combinedTrack)
.then(() => this.logger.info('screen audio(combined) replaceTrack ok', { peerId }))
.catch((e) => this.logger.error('audio replaceTrack (combined) failed', e));
}
} else if (micTrack) {
this.logger.attachTrackDiagnostics(micTrack, `micAudio:${peerId}`);
let audioSender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === TRACK_KIND_AUDIO);
if (!audioSender) {
const transceiver = peerData.connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV });
audioSender = transceiver.sender;
}
peerData.audioSender = audioSender;
audioSender.replaceTrack(micTrack)
.then(() => this.logger.info('screen audio(mic) replaceTrack ok', { peerId }))
.catch((e) => this.logger.error('audio replaceTrack (mic) failed', e));
}
this.callbacks.renegotiate(peerId);
});
}
/** Clean up all resources. */
destroy(): void {
this.stopScreenShare();
if (this.audioMixingContext) {
try { this.audioMixingContext.close(); } catch { /* ignore */ }
this.audioMixingContext = null;
}
}
}

View File

@@ -0,0 +1,219 @@
/**
* Manages the WebSocket connection to the signaling server,
* including automatic reconnection and heartbeats.
*/
import { Observable, Subject } from 'rxjs';
import { SignalingMessage } from '../../models';
import { WebRTCLogger } from './webrtc-logger';
import { IdentifyCredentials, JoinedServerInfo } from './webrtc.types';
import {
SIGNALING_RECONNECT_BASE_DELAY_MS,
SIGNALING_RECONNECT_MAX_DELAY_MS,
SIGNALING_CONNECT_TIMEOUT_MS,
STATE_HEARTBEAT_INTERVAL_MS,
SIGNALING_TYPE_IDENTIFY,
SIGNALING_TYPE_JOIN_SERVER,
SIGNALING_TYPE_VIEW_SERVER,
} from './webrtc.constants';
export class SignalingManager {
private signalingWebSocket: WebSocket | null = null;
private lastSignalingUrl: string | null = null;
private signalingReconnectAttempts = 0;
private signalingReconnectTimer: ReturnType<typeof setTimeout> | null = null;
private stateHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
/** Fires every heartbeat tick the main service hooks this to broadcast state. */
readonly heartbeatTick$ = new Subject<void>();
/** Fires whenever a raw signaling message arrives from the server. */
readonly messageReceived$ = new Subject<any>();
/** Fires when connection status changes (true = open, false = closed/error). */
readonly connectionStatus$ = new Subject<{ connected: boolean; errorMessage?: string }>();
constructor(
private readonly logger: WebRTCLogger,
private readonly getLastIdentify: () => IdentifyCredentials | null,
private readonly getLastJoinedServer: () => JoinedServerInfo | null,
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;
return new Observable<boolean>((observer) => {
try {
if (this.signalingWebSocket) {
this.signalingWebSocket.close();
}
this.lastSignalingUrl = serverUrl;
this.signalingWebSocket = new WebSocket(serverUrl);
this.signalingWebSocket.onopen = () => {
this.logger.info('Connected to signaling server');
this.clearReconnect();
this.startHeartbeat();
this.connectionStatus$.next({ connected: true });
this.reIdentifyAndRejoin();
observer.next(true);
};
this.signalingWebSocket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
this.messageReceived$.next(message);
} catch (error) {
this.logger.error('Failed to parse signaling message', error);
}
};
this.signalingWebSocket.onerror = (error) => {
this.logger.error('Signaling socket error', error);
this.connectionStatus$.next({ connected: false, errorMessage: 'Connection to signaling server failed' });
observer.error(error);
};
this.signalingWebSocket.onclose = () => {
this.logger.info('Disconnected from signaling server');
this.stopHeartbeat();
this.connectionStatus$.next({ connected: false, errorMessage: 'Disconnected from signaling server' });
this.scheduleReconnect();
};
} catch (error) {
observer.error(error);
}
});
}
/** Ensure signaling is connected; try reconnecting if not. */
async ensureConnected(timeoutMs: number = SIGNALING_CONNECT_TIMEOUT_MS): Promise<boolean> {
if (this.isSocketOpen()) return true;
if (!this.lastSignalingUrl) return false;
return new Promise<boolean>((resolve) => {
let settled = false;
const timeout = setTimeout(() => {
if (!settled) { settled = true; resolve(false); }
}, timeoutMs);
this.connect(this.lastSignalingUrl!).subscribe({
next: () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(true); } },
error: () => { if (!settled) { settled = true; clearTimeout(timeout); resolve(false); } },
});
});
}
/** Send a signaling message (with `from` / `timestamp` populated). */
sendSignalingMessage(message: Omit<SignalingMessage, 'from' | 'timestamp'>, localPeerId: string): void {
if (!this.isSocketOpen()) {
this.logger.error('Signaling socket not connected', new Error('Socket not open'));
return;
}
const fullMessage: SignalingMessage = { ...message, from: localPeerId, timestamp: Date.now() };
this.signalingWebSocket!.send(JSON.stringify(fullMessage));
}
/** Send a raw JSON payload (for identify, join_server, etc.). */
sendRawMessage(message: Record<string, unknown>): void {
if (!this.isSocketOpen()) {
this.logger.error('Signaling socket not connected', new Error('Socket not open'));
return;
}
this.signalingWebSocket!.send(JSON.stringify(message));
}
/** Gracefully close the WebSocket. */
close(): void {
this.stopHeartbeat();
this.clearReconnect();
if (this.signalingWebSocket) {
this.signalingWebSocket.close();
this.signalingWebSocket = null;
}
}
/** Whether the underlying WebSocket is currently open. */
isSocketOpen(): boolean {
return this.signalingWebSocket !== null && this.signalingWebSocket.readyState === WebSocket.OPEN;
}
/** The URL last used to connect (needed for reconnection). */
getLastUrl(): string | null {
return this.lastSignalingUrl;
}
// ─── Internals ─────────────────────────────────────────────────────
/** Re-identify and rejoin servers after a reconnect. */
private reIdentifyAndRejoin(): void {
const credentials = this.getLastIdentify();
if (credentials) {
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, oderId: credentials.oderId, displayName: credentials.displayName });
}
const memberIds = this.getMemberServerIds();
if (memberIds.size > 0) {
memberIds.forEach((serverId) => {
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId });
});
const lastJoined = this.getLastJoinedServer();
if (lastJoined) {
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER, serverId: lastJoined.serverId });
}
} else {
const lastJoined = this.getLastJoinedServer();
if (lastJoined) {
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId: lastJoined.serverId });
}
}
}
private scheduleReconnect(): void {
if (this.signalingReconnectTimer || !this.lastSignalingUrl) return;
const delay = Math.min(
SIGNALING_RECONNECT_MAX_DELAY_MS,
SIGNALING_RECONNECT_BASE_DELAY_MS * Math.pow(2, this.signalingReconnectAttempts),
);
this.signalingReconnectTimer = setTimeout(() => {
this.signalingReconnectTimer = null;
this.signalingReconnectAttempts++;
this.logger.info('Attempting to reconnect to signaling...');
this.connect(this.lastSignalingUrl!).subscribe({
next: () => { this.signalingReconnectAttempts = 0; },
error: () => { this.scheduleReconnect(); },
});
}, delay);
}
private clearReconnect(): void {
if (this.signalingReconnectTimer) {
clearTimeout(this.signalingReconnectTimer);
this.signalingReconnectTimer = null;
}
this.signalingReconnectAttempts = 0;
}
private startHeartbeat(): void {
this.stopHeartbeat();
this.stateHeartbeatTimer = setInterval(() => this.heartbeatTick$.next(), STATE_HEARTBEAT_INTERVAL_MS);
}
private stopHeartbeat(): void {
if (this.stateHeartbeatTimer) {
clearInterval(this.stateHeartbeatTimer);
this.stateHeartbeatTimer = null;
}
}
/** Clean up all resources. */
destroy(): void {
this.close();
this.heartbeatTick$.complete();
this.messageReceived$.complete();
this.connectionStatus$.complete();
}
}

View File

@@ -0,0 +1,66 @@
/**
* Lightweight logging utility for the WebRTC subsystem.
* All log lines are prefixed with `[WebRTC]`.
*/
export class WebRTCLogger {
constructor(private readonly isEnabled: boolean = true) {}
/** Informational log (only when debug is enabled). */
info(prefix: string, ...args: unknown[]): void {
if (!this.isEnabled) return;
try { console.log(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ }
}
/** Warning log (only when debug is enabled). */
warn(prefix: string, ...args: unknown[]): void {
if (!this.isEnabled) return;
try { console.warn(`[WebRTC] ${prefix}`, ...args); } catch { /* swallow */ }
}
/** Error log (always emitted regardless of debug flag). */
error(prefix: string, err: unknown, extra?: Record<string, unknown>): void {
const payload = {
name: (err as any)?.name,
message: (err as any)?.message,
stack: (err as any)?.stack,
...extra,
};
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;
this.info(`Track attached: ${label}`, {
id: track.id,
kind: track.kind,
readyState: track.readyState,
contentHint: track.contentHint,
settings,
});
track.addEventListener('ended', () => this.warn(`Track ended: ${label}`, { id: track.id, kind: track.kind }));
track.addEventListener('mute', () => this.warn(`Track muted: ${label}`, { id: track.id, kind: track.kind }));
track.addEventListener('unmute', () => this.info(`Track unmuted: ${label}`, { id: track.id, kind: track.kind }));
}
/** Log a MediaStream summary and attach diagnostics to every track. */
logStream(label: string, stream: MediaStream | null): void {
if (!stream) {
this.warn(`Stream missing: ${label}`);
return;
}
const audioTracks = stream.getAudioTracks();
const videoTracks = stream.getVideoTracks();
this.info(`Stream ready: ${label}`, {
id: (stream as any).id,
audioTrackCount: audioTracks.length,
videoTrackCount: videoTracks.length,
allTrackIds: stream.getTracks().map(t => ({ id: t.id, kind: t.kind })),
});
audioTracks.forEach((t, i) => this.attachTrackDiagnostics(t, `${label}:audio#${i}`));
videoTracks.forEach((t, i) => this.attachTrackDiagnostics(t, `${label}:video#${i}`));
}
}

View File

@@ -0,0 +1,106 @@
/**
* All magic numbers and strings used across the WebRTC subsystem.
* 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' },
{ urls: 'stun:stun2.l.google.com:19302' },
{ urls: 'stun:stun3.l.google.com:19302' },
{ 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 */
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 */
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) */
export const AUDIO_BITRATE_MAX_BPS = 256_000;
/** Multiplier to convert kbps → bps */
export const KBPS_TO_BPS = 1_000;
/** Pre-defined latency-to-bitrate mappings (bps) */
export const LATENCY_PROFILE_BITRATES = {
low: 64_000,
balanced: 96_000,
high: 128_000,
} 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';
export const SIGNALING_TYPE_LEAVE_SERVER = 'leave_server';
export const SIGNALING_TYPE_OFFER = 'offer';
export const SIGNALING_TYPE_ANSWER = 'answer';
export const SIGNALING_TYPE_ICE_CANDIDATE = 'ice_candidate';
export const SIGNALING_TYPE_CONNECTED = 'connected';
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) */
export const VOLUME_MIN = 0;
/** Maximum volume (normalised 0-1) */
export const VOLUME_MAX = 1;

View File

@@ -0,0 +1,43 @@
/**
* Shared type definitions for the WebRTC subsystem.
*/
/** Tracks a single peer's connection, data channel, and RTP senders. */
export interface PeerData {
connection: RTCPeerConnection;
dataChannel: RTCDataChannel | null;
isInitiator: boolean;
pendingIceCandidates: RTCIceCandidateInit[];
audioSender?: RTCRtpSender;
videoSender?: RTCRtpSender;
screenVideoSender?: RTCRtpSender;
screenAudioSender?: RTCRtpSender;
}
/** Credentials cached for automatic re-identification after reconnect. */
export interface IdentifyCredentials {
oderId: string;
displayName: string;
}
/** Last-joined server info, used for reconnection. */
export interface JoinedServerInfo {
serverId: string;
userId: string;
}
/** Entry in the disconnected-peer tracker for P2P reconnect scheduling. */
export interface DisconnectedPeerEntry {
lastSeenTimestamp: number;
reconnectAttempts: number;
}
/** Snapshot of current voice / screen state (broadcast to peers). */
export interface VoiceStateSnapshot {
isConnected: boolean;
isMuted: boolean;
isDeafened: boolean;
isScreenSharing: boolean;
roomId?: string;
serverId?: string;
}

View File

@@ -19,6 +19,17 @@
<ng-icon name="lucideSettings" class="w-4 h-4 inline mr-1" />
Settings
</button>
<button
(click)="activeTab.set('members')"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
[class.text-primary]="activeTab() === 'members'"
[class.border-b-2]="activeTab() === 'members'"
[class.border-primary]="activeTab() === 'members'"
[class.text-muted-foreground]="activeTab() !== 'members'"
>
<ng-icon name="lucideUsers" class="w-4 h-4 inline mr-1" />
Members
</button>
<button
(click)="activeTab.set('bans')"
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
@@ -38,8 +49,8 @@
[class.border-primary]="activeTab() === 'permissions'"
[class.text-muted-foreground]="activeTab() !== 'permissions'"
>
<ng-icon name="lucideUsers" class="w-4 h-4 inline mr-1" />
Permissions
<ng-icon name="lucideShield" class="w-4 h-4 inline mr-1" />
Perms
</button>
</div>
@@ -125,6 +136,65 @@
</div>
</div>
}
@case ('members') {
<div class="space-y-4">
<h3 class="text-sm font-medium text-foreground">Server Members</h3>
@if (membersFiltered().length === 0) {
<p class="text-sm text-muted-foreground text-center py-8">
No other members online
</p>
} @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>
<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>
@if (user.role === 'host') {
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded">Owner</span>
} @else if (user.role === 'admin') {
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span>
} @else if (user.role === 'moderator') {
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded">Mod</span>
}
</div>
</div>
<!-- Role actions (only for non-hosts) -->
@if (user.role !== 'host') {
<div class="flex items-center gap-1">
<select
[ngModel]="user.role"
(ngModelChange)="changeRole(user, $event)"
class="text-xs px-2 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="member">Member</option>
<option value="moderator">Moderator</option>
<option value="admin">Admin</option>
</select>
<button
(click)="kickMember(user)"
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Kick"
>
<ng-icon name="lucideUserX" class="w-4 h-4" />
</button>
<button
(click)="banMember(user)"
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Ban"
>
<ng-icon name="lucideBan" class="w-4 h-4" />
</button>
</div>
}
</div>
}
}
</div>
}
@case ('bans') {
<div class="space-y-4">
<h3 class="text-sm font-medium text-foreground">Banned Users</h3>

View File

@@ -23,10 +23,12 @@ import {
selectBannedUsers,
selectIsCurrentUserAdmin,
selectCurrentUser,
selectOnlineUsers,
} from '../../../store/users/users.selectors';
import { BanEntry, Room } from '../../../core/models';
import { BanEntry, Room, User } from '../../../core/models';
import { WebRTCService } from '../../../core/services/webrtc.service';
type AdminTab = 'settings' | 'bans' | 'permissions';
type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
@Component({
selector: 'app-admin-panel',
@@ -50,11 +52,13 @@ type AdminTab = 'settings' | 'bans' | 'permissions';
})
export class AdminPanelComponent {
private store = inject(Store);
private webrtc = inject(WebRTCService);
currentRoom = this.store.selectSignal(selectCurrentRoom);
currentUser = this.store.selectSignal(selectCurrentUser);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
bannedUsers = this.store.selectSignal(selectBannedUsers);
onlineUsers = this.store.selectSignal(selectOnlineUsers);
activeTab = signal<AdminTab>('settings');
showDeleteConfirm = signal(false);
@@ -157,4 +161,37 @@ export class AdminPanelComponent {
const date = new Date(timestamp);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// Members tab: get all users except self
membersFiltered(): User[] {
const me = this.currentUser();
return this.onlineUsers().filter(u => u.id !== me?.id && u.oderId !== me?.oderId);
}
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
this.webrtc.broadcastMessage({
type: 'role-change',
targetUserId: user.id,
role,
});
}
kickMember(user: User): void {
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
this.webrtc.broadcastMessage({
type: 'kick',
targetUserId: user.id,
kickedBy: this.currentUser()?.id,
});
}
banMember(user: User): void {
this.store.dispatch(UsersActions.banUser({ userId: user.id }));
this.webrtc.broadcastMessage({
type: 'ban',
targetUserId: user.id,
bannedBy: this.currentUser()?.id,
});
}
}

View File

@@ -1,4 +1,4 @@
import { Component, inject, signal, computed, effect, ElementRef, ViewChild, AfterViewChecked, OnInit, OnDestroy } from '@angular/core';
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';
@@ -13,11 +13,16 @@ import {
lucideMoreVertical,
lucideCheck,
lucideX,
lucideDownload,
lucideExpand,
lucideImage,
lucideCopy,
} from '@ng-icons/lucide';
import * as MessagesActions from '../../store/messages/messages.actions';
import { selectAllMessages, selectMessagesLoading } from '../../store/messages/messages.selectors';
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';
@@ -42,12 +47,23 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
lucideMoreVertical,
lucideCheck,
lucideX,
lucideDownload,
lucideExpand,
lucideImage,
lucideCopy,
}),
],
template: `
<div class="flex flex-col h-full">
<!-- Messages List -->
<div #messagesContainer class="flex-1 overflow-y-auto p-4 space-y-4 relative" (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>
@@ -58,8 +74,21 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
<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"
>
@@ -70,6 +99,20 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
<!-- 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">
@@ -110,20 +153,69 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
@for (att of getAttachments(message.id); track att.id) {
@if (att.isImage) {
@if (att.available && att.objectUrl) {
<img [src]="att.objectUrl" alt="image" class="rounded-md max-h-80 w-auto" />
} @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>
<!-- 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="text-xs text-muted-foreground">{{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}%</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 bg-muted">
<div class="h-1.5 rounded bg-primary" [style.width.%]="(att.receivedBytes || 0) * 100 / att.size"></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">
@@ -357,6 +449,76 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
</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()) {
<div class="fixed inset-0 z-[110]" (click)="closeImageContextMenu()"></div>
<div
class="fixed z-[120] bg-card border border-border rounded-lg shadow-lg w-48 py-1"
[style.left.px]="imageContextMenu()!.x"
[style.top.px]="imageContextMenu()!.y"
>
<button
(click)="copyImageToClipboard(imageContextMenu()!.attachment)"
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground flex items-center gap-2"
>
<ng-icon name="lucideCopy" class="w-4 h-4 text-muted-foreground" />
Copy Image
</button>
<button
(click)="downloadAttachment(imageContextMenu()!.attachment); closeImageContextMenu()"
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground flex items-center gap-2"
>
<ng-icon name="lucideDownload" class="w-4 h-4 text-muted-foreground" />
Save Image
</button>
</div>
}
`,
})
export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestroy {
@@ -368,11 +530,40 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
private sanitizer = inject(DomSanitizer);
private serverDirectory = inject(ServerDirectoryService);
private attachmentsSvc = inject(AttachmentService);
private cdr = inject(ChangeDetectorRef);
messages = this.store.selectSignal(selectAllMessages);
private allMessages = this.store.selectSignal(selectAllMessages);
private activeChannelId = this.store.selectSignal(selectActiveChannelId);
// --- Infinite scroll (upwards) pagination ---
private readonly PAGE_SIZE = 50;
displayLimit = signal(this.PAGE_SIZE);
loadingMore = signal(false);
/** All messages for the current channel (full list, unsliced) */
private allChannelMessages = computed(() => {
const channelId = this.activeChannelId();
const roomId = this.currentRoom()?.id;
return this.allMessages().filter(m =>
m.roomId === roomId && (m.channelId || 'general') === channelId
);
});
/** Paginated view — only the most recent `displayLimit` messages */
messages = computed(() => {
const all = this.allChannelMessages();
const limit = this.displayLimit();
if (all.length <= limit) return all;
return all.slice(all.length - limit);
});
/** Whether there are older messages that can be loaded */
hasMoreMessages = computed(() => this.allChannelMessages().length > this.displayLimit());
loading = this.store.selectSignal(selectMessagesLoading);
syncing = this.store.selectSignal(selectMessagesSyncing);
currentUser = this.store.selectSignal(selectCurrentUser);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
private currentRoom = this.store.selectSignal(selectCurrentRoom);
messageContent = '';
editContent = '';
@@ -383,6 +574,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
readonly commonEmojis = COMMON_EMOJIS;
private shouldScrollToBottom = true;
/** Keeps us pinned to bottom while images/attachments load after initial open */
private initialScrollObserver: MutationObserver | null = null;
private initialScrollTimer: any = null;
private boundOnImageLoad: (() => void) | null = null;
/** True while a programmatic scroll-to-bottom is in progress (suppresses onScroll). */
private isAutoScrolling = false;
private typingSub?: Subscription;
private lastTypingSentAt = 0;
private readonly typingTTL = 3000; // ms to keep a user as typing
@@ -396,8 +593,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
typingOthersCount = signal<number>(0);
// New messages snackbar state
showNewMessagesBar = signal(false);
// Stable reference time to avoid ExpressionChanged errors (updated every minute)
nowRef = signal<number>(Date.now());
// 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;
@@ -405,16 +603,46 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
dragActive = signal(false);
// Cache blob URLs for proxied images to prevent repeated network fetches on re-render
private imageBlobCache = new Map<string, string>();
// Re-render when attachments update
private attachmentsUpdatedEffect = effect(() => {
// Subscribe to updates; no-op body
void this.attachmentsSvc.updated();
// Cache rendered markdown to preserve text selection across re-renders
private markdownCache = new Map<string, SafeHtml>();
// 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: ((e: KeyboardEvent) => void) | null = null;
// Reset scroll state when room/server changes (handles reuse of component on navigation)
private onRoomChanged = effect(() => {
void this.currentRoom(); // track room signal
this.initialScrollPending = true;
this.stopInitialScrollWatch();
this.showNewMessagesBar.set(false);
this.lastMessageCount = 0;
this.displayLimit.set(this.PAGE_SIZE);
this.markdownCache.clear();
});
// Messages length signal and effect to detect new messages without blocking change detection
// Reset pagination when switching channels within the same room
private onChannelChanged = effect(() => {
void this.activeChannelId(); // track channel signal
this.displayLimit.set(this.PAGE_SIZE);
this.initialScrollPending = true;
this.showNewMessagesBar.set(false);
this.lastMessageCount = 0;
this.markdownCache.clear();
});
// Re-render when attachments update (e.g. download progress from WebRTC callbacks)
private attachmentsUpdatedEffect = effect(() => {
void this.attachmentsSvc.updated();
this.cdr.markForCheck();
});
// Track total channel messages (not paginated) for new-message detection
private totalChannelMessagesLength = computed(() => this.allChannelMessages().length);
messagesLength = computed(() => this.messages().length);
private onMessagesChanged = effect(() => {
const currentCount = this.messagesLength();
const currentCount = this.totalChannelMessagesLength();
const el = this.messagesContainer?.nativeElement;
if (!el) {
this.lastMessageCount = currentCount;
@@ -446,12 +674,23 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
const el = this.messagesContainer?.nativeElement;
if (!el) return;
// First render after connect: scroll to bottom by default (no animation)
// First render after connect: scroll to bottom instantly (no animation)
// Only proceed once messages are actually rendered in the DOM
if (this.initialScrollPending) {
this.initialScrollPending = false;
this.scrollToBottom();
this.showNewMessagesBar.set(false);
this.lastMessageCount = this.messages().length;
if (this.messages().length > 0) {
this.initialScrollPending = false;
// Snap to bottom immediately, then keep watching for late layout changes
this.isAutoScrolling = true;
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => { this.isAutoScrolling = false; });
this.startInitialScrollWatch();
this.showNewMessagesBar.set(false);
this.lastMessageCount = this.messages().length;
} else if (!this.loading()) {
// Room has no messages and loading is done
this.initialScrollPending = false;
this.lastMessageCount = 0;
}
this.loadCspImages();
return;
}
@@ -468,21 +707,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
}
});
// If we're the uploader and our original file was lost (e.g., after navigation), prompt reselect
this.attachmentsSvc.onMissingOriginal.subscribe(({ messageId, fileId, fromPeerId }) => {
try {
const input = document.createElement('input');
input.type = 'file';
input.onchange = async () => {
const file = input.files?.[0];
if (file) {
await this.attachmentsSvc.fulfillRequestWithFile(messageId, fileId, fromPeerId, file);
}
};
input.click();
} catch {}
});
// Periodically purge expired typing entries
const purge = () => {
const now = Date.now();
@@ -502,18 +726,32 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
// Initialize message count for snackbar trigger
this.lastMessageCount = this.messages().length;
// Update reference time periodically (minute granularity)
// Update reference time silently (non-reactive) so formatTimestamp
// uses a reasonably fresh "now" without re-rendering every message.
this.nowTimer = setInterval(() => {
this.nowRef.set(Date.now());
this.nowRef = Date.now();
}, 60000);
// Global Escape key listener for lightbox & context menu
this.boundOnKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (this.imageContextMenu()) { this.closeImageContextMenu(); return; }
if (this.lightboxAttachment()) { this.closeLightbox(); return; }
}
};
document.addEventListener('keydown', this.boundOnKeydown);
}
ngOnDestroy(): void {
this.typingSub?.unsubscribe();
this.stopInitialScrollWatch();
if (this.nowTimer) {
clearInterval(this.nowTimer);
this.nowTimer = null;
}
if (this.boundOnKeydown) {
document.removeEventListener('keydown', this.boundOnKeydown);
}
}
sendMessage(): void {
@@ -526,6 +764,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
MessagesActions.sendMessage({
content,
replyToId: this.replyTo()?.id,
channelId: this.activeChannelId(),
})
);
@@ -589,6 +828,21 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.replyTo.set(null);
}
getRepliedMessage(messageId: string): Message | undefined {
return this.allMessages().find(m => m.id === messageId);
}
scrollToMessage(messageId: string): void {
const container = this.messagesContainer?.nativeElement;
if (!container) return;
const el = container.querySelector(`[data-message-id="${messageId}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('bg-primary/10');
setTimeout(() => el.classList.remove('bg-primary/10'), 2000);
}
}
toggleEmojiPicker(messageId: string): void {
this.showEmojiPicker.update((current) =>
current === messageId ? null : messageId
@@ -641,20 +895,21 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
formatTimestamp(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date(this.nowRef());
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const now = new Date(this.nowRef);
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (days === 0) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (days === 1) {
return 'Yesterday ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (days < 7) {
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' +
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' }) + ' ' +
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time;
}
}
@@ -666,6 +921,63 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
}
}
/**
* 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;
@@ -688,12 +1000,46 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
onScroll(): void {
if (!this.messagesContainer) return;
// Ignore scroll events caused by programmatic snap-to-bottom
if (this.isAutoScrolling) return;
const el = this.messagesContainer.nativeElement;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
this.shouldScrollToBottom = distanceFromBottom <= 300;
if (this.shouldScrollToBottom) {
this.showNewMessagesBar.set(false);
}
// Any user-initiated scroll during the initial load period
// immediately hands control back to the user
if (this.initialScrollObserver) {
this.stopInitialScrollWatch();
}
// Infinite scroll upwards — load older messages when near the top
if (el.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
this.loadMore();
}
}
/** Load older messages by expanding the display window, preserving scroll position */
loadMore(): void {
if (this.loadingMore() || !this.hasMoreMessages()) return;
this.loadingMore.set(true);
const el = this.messagesContainer?.nativeElement;
const prevScrollHeight = el?.scrollHeight ?? 0;
this.displayLimit.update(limit => limit + this.PAGE_SIZE);
// After Angular renders the new messages, restore scroll position
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (el) {
const newScrollHeight = el.scrollHeight;
el.scrollTop += newScrollHeight - prevScrollHeight;
}
this.loadingMore.set(false);
});
});
}
private recomputeTypingDisplay(now: number): void {
@@ -707,8 +1053,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.typingOthersCount.set(others);
}
// Markdown rendering
// Markdown rendering (cached so re-renders don't replace innerHTML and kill text selection)
renderMarkdown(content: string): SafeHtml {
const cached = this.markdownCache.get(content);
if (cached) return cached;
marked.setOptions({ breaks: true });
const html = marked.parse(content ?? '') as string;
// Sanitize to a DOM fragment so we can post-process disallowed images
@@ -750,7 +1099,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
}
const safeHtml = DOMPurify.sanitize(container.innerHTML);
return this.sanitizer.bypassSecurityTrustHtml(safeHtml);
const result = this.sanitizer.bypassSecurityTrustHtml(safeHtml);
this.markdownCache.set(content, result);
return result;
}
// Resolve images marked for CSP-safe loading by converting to blob URLs
@@ -995,6 +1346,74 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
return !!att.uploaderPeerId && !!myUserId && att.uploaderPeerId === myUserId;
}
// ---- Image lightbox ----
openLightbox(att: Attachment): void {
if (att.available && att.objectUrl) {
this.lightboxAttachment.set(att);
}
}
closeLightbox(): void {
this.lightboxAttachment.set(null);
}
// ---- Image context menu ----
openImageContextMenu(event: MouseEvent, att: Attachment): void {
event.preventDefault();
event.stopPropagation();
this.imageContextMenu.set({ x: event.clientX, y: event.clientY, attachment: att });
}
closeImageContextMenu(): void {
this.imageContextMenu.set(null);
}
async copyImageToClipboard(att: Attachment): Promise<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 (err) {
console.error('Failed to copy image to clipboard:', err);
}
}
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;
});
}
retryImageRequest(att: Attachment, messageId: string): void {
this.attachmentsSvc.requestImageFromAnyPeer(messageId, att);
}
private attachFilesToLastOwnMessage(content: string): void {
const me = this.currentUser()?.id;
if (!me) return;

View File

@@ -1,9 +1,23 @@
<div class="h-full flex flex-col bg-background">
@if (currentRoom()) {
<!-- Channel header bar -->
<div class="h-12 flex items-center gap-2 px-4 border-b border-border bg-card flex-shrink-0">
<span class="text-muted-foreground text-lg">#</span>
<span class="font-medium text-foreground text-sm">{{ activeChannelName }}</span>
<div class="flex-1"></div>
@if (isAdmin()) {
<button
(click)="toggleAdminPanel()"
class="p-1.5 rounded hover:bg-secondary transition-colors text-muted-foreground hover:text-foreground"
title="Server Settings"
>
<ng-icon name="lucideSettings" class="w-4 h-4" />
</button>
}
</div>
<!-- Main Content -->
<div class="flex-1 flex overflow-hidden">
<!-- Left rail is global; chat area fills remaining space -->
<!-- Chat Area -->
<main class="flex-1 flex flex-col min-w-0">
<!-- Screen Share Viewer -->
@@ -15,15 +29,24 @@
</div>
</main>
<!-- Admin Panel (slide-over) -->
@if (showAdminPanel() && isAdmin()) {
<aside class="w-80 flex-shrink-0 border-l border-border overflow-y-auto">
<div class="flex items-center justify-between px-4 py-2 border-b border-border bg-card">
<span class="text-sm font-medium text-foreground">Server Settings</span>
<button (click)="toggleAdminPanel()" class="p-1 rounded hover:bg-secondary text-muted-foreground hover:text-foreground">
<ng-icon name="lucideX" class="w-4 h-4" />
</button>
</div>
<app-admin-panel />
</aside>
}
<!-- Sidebar always visible -->
<aside class="w-80 flex-shrink-0 border-l border-border">
<app-rooms-side-panel class="h-full" />
</aside>
</div>
<!-- Voice Controls moved to sidebar bottom -->
<!-- Mobile overlay removed; sidebar remains visible by default -->
} @else {
<!-- No Room Selected -->
<div class="flex-1 flex items-center justify-center">

View File

@@ -18,7 +18,7 @@ import { ScreenShareViewerComponent } from '../../voice/screen-share-viewer/scre
import { AdminPanelComponent } from '../../admin/admin-panel/admin-panel.component';
import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.component';
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { selectCurrentRoom, selectActiveChannelId, selectTextChannels } from '../../../store/rooms/rooms.selectors';
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
@@ -32,6 +32,7 @@ type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
ChatMessagesComponent,
ScreenShareViewerComponent,
RoomsSidePanelComponent,
AdminPanelComponent,
],
viewProviders: [
provideIcons({
@@ -49,11 +50,20 @@ export class ChatRoomComponent {
private store = inject(Store);
private router = inject(Router);
showMenu = signal(false);
showAdminPanel = signal(false);
currentRoom = this.store.selectSignal(selectCurrentRoom);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
activeChannelId = this.store.selectSignal(selectActiveChannelId);
textChannels = this.store.selectSignal(selectTextChannels);
// Sidebar always visible; panel toggles removed
get activeChannelName(): string {
const id = this.activeChannelId();
const ch = this.textChannels().find(c => c.id === id);
return ch ? ch.name : id;
}
// Header moved to TitleBar
toggleAdminPanel() {
this.showAdminPanel.update(v => !v);
}
}

View File

@@ -36,135 +36,133 @@
<div class="flex-1 overflow-auto">
<!-- Text Channels -->
<div class="p-3">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Text Channels</h4>
<div class="space-y-1">
<button class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center gap-2 text-left text-foreground/80 hover:text-foreground">
<span class="text-muted-foreground">#</span> general
</button>
<button class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center gap-2 text-left text-foreground/80 hover:text-foreground">
<span class="text-muted-foreground">#</span> random
</button>
<div class="flex items-center justify-between mb-2 px-1">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Text Channels</h4>
@if (canManageChannels()) {
<button (click)="createChannel('text')" class="text-muted-foreground hover:text-foreground transition-colors" title="Create Text Channel">
<ng-icon name="lucidePlus" class="w-3.5 h-3.5" />
</button>
}
</div>
<div class="space-y-0.5">
@for (ch of textChannels(); track ch.id) {
<button
class="w-full px-2 py-1.5 text-sm rounded flex items-center gap-2 text-left transition-colors"
[class.bg-secondary]="activeChannelId() === ch.id"
[class.text-foreground]="activeChannelId() === ch.id"
[class.font-medium]="activeChannelId() === ch.id"
[class.text-foreground/60]="activeChannelId() !== ch.id"
[class.hover:bg-secondary/60]="activeChannelId() !== ch.id"
[class.hover:text-foreground/80]="activeChannelId() !== ch.id"
(click)="selectTextChannel(ch.id)"
(contextmenu)="openChannelContextMenu($event, ch)"
>
<span class="text-muted-foreground text-base">#</span>
@if (renamingChannelId() === ch.id) {
<input
#renameInput
type="text"
[value]="ch.name"
(keydown.enter)="confirmRename($event)"
(keydown.escape)="cancelRename()"
(blur)="confirmRename($event)"
class="flex-1 bg-secondary border border-border rounded px-1 py-0.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
(click)="$event.stopPropagation()"
/>
} @else {
<span class="truncate">{{ ch.name }}</span>
}
</button>
}
</div>
</div>
<!-- Voice Channels -->
<div class="p-3 pt-0">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Voice Channels</h4>
<div class="flex items-center justify-between mb-2 px-1">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Voice Channels</h4>
@if (canManageChannels()) {
<button (click)="createChannel('voice')" class="text-muted-foreground hover:text-foreground transition-colors" title="Create Voice Channel">
<ng-icon name="lucidePlus" class="w-3.5 h-3.5" />
</button>
}
</div>
@if (!voiceEnabled()) {
<p class="text-sm text-muted-foreground px-2 py-2">Voice is disabled by host</p>
}
<div class="space-y-1">
<!-- General Voice -->
<div>
<button
class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center justify-between text-left"
(click)="joinVoice('general')"
[class.bg-secondary/40]="isCurrentRoom('general')"
[disabled]="!voiceEnabled()"
>
<span class="flex items-center gap-2 text-foreground/80">
<span>🔊</span> General
</span>
@if (voiceOccupancy('general') > 0) {
<span class="text-sm text-muted-foreground">{{ voiceOccupancy('general') }}</span>
}
</button>
@if (voiceUsersInRoom('general').length > 0) {
<div class="ml-5 mt-1 space-y-1">
@for (u of voiceUsersInRoom('general'); 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"
/>
} @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
(click)="viewStream(u.id); $event.stopPropagation()"
class="px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
>
LIVE
</button>
}
@if (u.voiceState?.isMuted) {
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
}
</div>
@for (ch of voiceChannels(); track ch.id) {
<div>
<button
class="w-full px-2 py-1.5 text-sm rounded hover:bg-secondary/60 flex items-center justify-between text-left transition-colors"
(click)="joinVoice(ch.id)"
(contextmenu)="openChannelContextMenu($event, ch)"
[class.bg-secondary/40]="isCurrentRoom(ch.id)"
[disabled]="!voiceEnabled()"
>
<span class="flex items-center gap-2 text-foreground/80">
<ng-icon name="lucideMic" class="w-4 h-4 text-muted-foreground" />
@if (renamingChannelId() === ch.id) {
<input
#renameInput
type="text"
[value]="ch.name"
(keydown.enter)="confirmRename($event)"
(keydown.escape)="cancelRename()"
(blur)="confirmRename($event)"
class="flex-1 bg-secondary border border-border rounded px-1 py-0.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
(click)="$event.stopPropagation()"
/>
} @else {
<span>{{ ch.name }}</span>
}
</span>
@if (voiceOccupancy(ch.id) > 0) {
<span class="text-xs text-muted-foreground">{{ voiceOccupancy(ch.id) }}</span>
}
</div>
}
</div>
<!-- AFK Voice -->
<div>
<button
class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center justify-between text-left"
(click)="joinVoice('afk')"
[class.bg-secondary/40]="isCurrentRoom('afk')"
[disabled]="!voiceEnabled()"
>
<span class="flex items-center gap-2 text-foreground/80">
<span>🔕</span> AFK
</span>
@if (voiceOccupancy('afk') > 0) {
<span class="text-sm text-muted-foreground">{{ voiceOccupancy('afk') }}</span>
</button>
<!-- Voice users connected to this channel -->
@if (voiceUsersInRoom(ch.id).length > 0) {
<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"
/>
} @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
(click)="viewStream(u.id); $event.stopPropagation()"
class="px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
>
LIVE
</button>
}
@if (u.voiceState?.isMuted) {
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
}
</div>
}
</div>
}
</button>
@if (voiceUsersInRoom('afk').length > 0) {
<div class="ml-5 mt-1 space-y-1">
@for (u of voiceUsersInRoom('afk'); 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"
/>
} @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
(click)="viewStream(u.id); $event.stopPropagation()"
class="px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
>
LIVE
</button>
}
@if (u.voiceState?.isMuted) {
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
}
</div>
}
</div>
}
</div>
</div>
}
</div>
</div>
</div>
@@ -217,7 +215,10 @@
</h4>
<div class="space-y-1">
@for (user of onlineUsersFiltered(); track user.id) {
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
<div
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40 group/user"
(contextmenu)="openUserContextMenu($event, user)"
>
<div class="relative">
@if (user.avatarUrl) {
<img [src]="user.avatarUrl" alt="" class="w-8 h-8 rounded-full object-cover" />
@@ -229,7 +230,16 @@
<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">
<p class="text-sm text-foreground truncate">{{ user.displayName }}</p>
<div class="flex items-center gap-1.5">
<p class="text-sm text-foreground truncate">{{ user.displayName }}</p>
@if (user.role === 'host') {
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium">Owner</span>
} @else if (user.role === 'admin') {
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium">Admin</span>
} @else if (user.role === 'moderator') {
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium">Mod</span>
}
</div>
<div class="flex items-center gap-2">
@if (user.voiceState?.isConnected) {
<p class="text-[10px] text-muted-foreground flex items-center gap-1">
@@ -270,3 +280,80 @@
</div>
}
</aside>
<!-- 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>
@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>
}
<!-- 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()">
@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>
}
@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>
}
@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>
}
<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>
} @else {
<div class="px-3 py-1.5 text-sm text-muted-foreground">No actions available</div>
}
</div>
}
<!-- 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>
<input
type="text"
[(ngModel)]="newChannelName"
placeholder="Channel name"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary text-sm"
(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>
}

View File

@@ -1,23 +1,28 @@
import { Component, inject, signal } 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 { lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers } from '@ng-icons/lucide';
import { selectOnlineUsers, selectCurrentUser } from '../../../store/users/users.selectors';
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
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 { WebRTCService } from '../../../core/services/webrtc.service';
import { VoiceSessionService } from '../../../core/services/voice-session.service';
import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component';
import { Channel, User } from '../../../core/models';
import { v4 as uuidv4 } from 'uuid';
type TabView = 'channels' | 'users';
@Component({
selector: 'app-rooms-side-panel',
standalone: true,
imports: [CommonModule, NgIcon, VoiceControlsComponent],
imports: [CommonModule, FormsModule, NgIcon, VoiceControlsComponent],
viewProviders: [
provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers })
provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers, lucidePlus })
],
templateUrl: './rooms-side-panel.component.html',
})
@@ -31,6 +36,30 @@ export class RoomsSidePanelComponent {
onlineUsers = this.store.selectSignal(selectOnlineUsers);
currentUser = this.store.selectSignal(selectCurrentUser);
currentRoom = this.store.selectSignal(selectCurrentRoom);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
activeChannelId = this.store.selectSignal(selectActiveChannelId);
textChannels = this.store.selectSignal(selectTextChannels);
voiceChannels = this.store.selectSignal(selectVoiceChannels);
// Channel context menu state
showChannelMenu = signal(false);
channelMenuX = signal(0);
channelMenuY = signal(0);
contextChannel = signal<Channel | null>(null);
// Rename state
renamingChannelId = signal<string | null>(null);
// Create channel dialog state
showCreateChannelDialog = signal(false);
createChannelType = signal<'text' | 'voice'>('text');
newChannelName = '';
// User context menu state
showUserMenu = signal(false);
userMenuX = signal(0);
userMenuY = signal(0);
contextMenuUser = signal<User | null>(null);
// Filter out current user from online users list
onlineUsersFiltered() {
@@ -40,6 +69,162 @@ export class RoomsSidePanelComponent {
return this.onlineUsers().filter(u => u.id !== currentId && u.oderId !== currentOderId);
}
canManageChannels(): boolean {
const room = this.currentRoom();
const user = this.currentUser();
if (!room || !user) return false;
// Owner always can
if (room.hostId === user.id) return true;
const perms = room.permissions || {};
if (user.role === 'admin' && perms.adminsManageRooms) return true;
if (user.role === 'moderator' && perms.moderatorsManageRooms) return true;
return false;
}
// ---- Text channel selection ----
selectTextChannel(channelId: string) {
if (this.renamingChannelId()) return; // don't switch while renaming
this.store.dispatch(RoomsActions.selectChannel({ channelId }));
}
// ---- Channel context menu ----
openChannelContextMenu(evt: MouseEvent, channel: Channel) {
evt.preventDefault();
this.contextChannel.set(channel);
this.channelMenuX.set(evt.clientX);
this.channelMenuY.set(evt.clientY);
this.showChannelMenu.set(true);
}
closeChannelMenu() {
this.showChannelMenu.set(false);
}
startRename() {
const ch = this.contextChannel();
this.closeChannelMenu();
if (ch) {
this.renamingChannelId.set(ch.id);
}
}
confirmRename(event: Event) {
const input = event.target as HTMLInputElement;
const name = input.value.trim();
const channelId = this.renamingChannelId();
if (channelId && name) {
this.store.dispatch(RoomsActions.renameChannel({ channelId, name }));
}
this.renamingChannelId.set(null);
}
cancelRename() {
this.renamingChannelId.set(null);
}
deleteChannel() {
const ch = this.contextChannel();
this.closeChannelMenu();
if (ch) {
this.store.dispatch(RoomsActions.removeChannel({ channelId: ch.id }));
}
}
resyncMessages() {
this.closeChannelMenu();
const room = this.currentRoom();
if (!room) {
console.warn('[Resync] No current room');
return;
}
// Dispatch startSync for UI spinner
this.store.dispatch(MessagesActions.startSync());
// 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');
}
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);
}
});
}
// ---- Create channel ----
createChannel(type: 'text' | 'voice') {
this.createChannelType.set(type);
this.newChannelName = '';
this.showCreateChannelDialog.set(true);
}
confirmCreateChannel() {
const name = this.newChannelName.trim();
if (!name) return;
const type = this.createChannelType();
const existing = type === 'text' ? this.textChannels() : this.voiceChannels();
const channel: Channel = {
id: type === 'voice' ? `vc-${uuidv4().slice(0, 8)}` : uuidv4().slice(0, 8),
name,
type,
position: existing.length,
};
this.store.dispatch(RoomsActions.addChannel({ channel }));
this.showCreateChannelDialog.set(false);
}
cancelCreateChannel() {
this.showCreateChannelDialog.set(false);
}
// ---- User context menu (kick/role) ----
openUserContextMenu(evt: MouseEvent, user: User) {
evt.preventDefault();
if (!this.isAdmin()) return;
this.contextMenuUser.set(user);
this.userMenuX.set(evt.clientX);
this.userMenuY.set(evt.clientY);
this.showUserMenu.set(true);
}
closeUserMenu() {
this.showUserMenu.set(false);
}
changeUserRole(role: 'admin' | 'moderator' | 'member') {
const user = this.contextMenuUser();
this.closeUserMenu();
if (user) {
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
// Broadcast role change to peers
this.webrtc.broadcastMessage({
type: 'role-change',
targetUserId: user.id,
role,
});
}
}
kickUserAction() {
const user = this.contextMenuUser();
this.closeUserMenu();
if (user) {
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
// Broadcast kick to peers
this.webrtc.broadcastMessage({
type: 'kick',
targetUserId: user.id,
kickedBy: this.currentUser()?.id,
});
}
}
// ---- Voice ----
joinVoice(roomId: string) {
// Gate by room permissions
const room = this.currentRoom();
@@ -51,10 +236,21 @@ export class RoomsSidePanelComponent {
const current = this.currentUser();
// Check if already connected to voice in a DIFFERENT server - must disconnect first
// Also handle stale voice state: if the store says connected but voice isn't actually active,
// clear it so the user can join.
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
// Connected to voice in a different server - user must disconnect first
console.warn('Already connected to voice in another server. Disconnect first before joining.');
return;
if (!this.webrtc.isVoiceConnected()) {
// Stale state clear it so the user can proceed
if (current.id) {
this.store.dispatch(UsersActions.updateVoiceState({
userId: current.id,
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined }
}));
}
} else {
console.warn('Already connected to voice in another server. Disconnect first before joining.');
return;
}
}
// If switching channels within the same server, just update the room
@@ -73,7 +269,7 @@ export class RoomsSidePanelComponent {
}));
}
// Start voice heartbeat to broadcast presence every 5 seconds
this.webrtc.startVoiceHeartbeat(roomId);
this.webrtc.startVoiceHeartbeat(roomId, room?.id);
this.webrtc.broadcastMessage({
type: 'voice-state',
oderId: current?.oderId || current?.id,
@@ -83,7 +279,9 @@ export class RoomsSidePanelComponent {
// Update voice session for floating controls
if (room) {
const voiceRoomName = roomId === 'general' ? '🔊 General' : roomId === 'afk' ? '🔕 AFK' : roomId;
// Find label from channel list
const vc = this.voiceChannels().find(c => c.id === roomId);
const voiceRoomName = vc ? `🔊 ${vc.name}` : roomId;
this.voiceSessionService.startSession({
serverId: room.id,
serverName: room.name,
@@ -131,7 +329,6 @@ export class RoomsSidePanelComponent {
voiceOccupancy(roomId: string): number {
const users = this.onlineUsers();
const room = this.currentRoom();
// Only count users connected to voice in this specific server and room
return users.filter(u =>
!!u.voiceState?.isConnected &&
u.voiceState?.roomId === roomId &&
@@ -140,14 +337,11 @@ export class RoomsSidePanelComponent {
}
viewShare(userId: string) {
// Focus viewer on a user's stream if present
// Requires WebRTCService to expose a remote streams registry.
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
window.dispatchEvent(evt);
}
viewStream(userId: string) {
// Focus viewer on a user's stream - dispatches event to screen-share-viewer
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
window.dispatchEvent(evt);
}
@@ -155,25 +349,18 @@ export class RoomsSidePanelComponent {
isUserSharing(userId: string): boolean {
const me = this.currentUser();
if (me?.id === userId) {
// Local user: use signal
return this.webrtc.isScreenSharing();
}
// For remote users, check the store state first (authoritative)
const user = this.onlineUsers().find(u => u.id === userId || u.oderId === userId);
if (user?.screenShareState?.isSharing === false) {
// Store says not sharing - trust this over stream presence
return false;
}
// Fall back to checking stream if store state is undefined
const stream = this.webrtc.getRemoteStream(userId);
return !!stream && stream.getVideoTracks().length > 0;
}
voiceUsersInRoom(roomId: string) {
const room = this.currentRoom();
// Only show users connected to voice in this specific server and room
return this.onlineUsers().filter(u =>
!!u.voiceState?.isConnected &&
u.voiceState?.roomId === roomId &&
@@ -184,7 +371,6 @@ export class RoomsSidePanelComponent {
isCurrentRoom(roomId: string): boolean {
const me = this.currentUser();
const room = this.currentRoom();
// Check that voice is connected AND both the server AND room match
return !!(
me?.voiceState?.isConnected &&
me.voiceState?.roomId === roomId &&

View File

@@ -77,6 +77,9 @@ export class TitleBarComponent {
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');
} catch {}

View File

@@ -226,6 +226,10 @@ 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
@@ -251,6 +255,11 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
return;
}
if (!navigator.mediaDevices?.getUserMedia) {
console.error('Cannot join call: navigator.mediaDevices not available (requires HTTPS or localhost)');
return;
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: this.selectedInputDevice() || undefined,

View File

@@ -20,7 +20,7 @@ export const loadMessagesFailure = createAction(
// Send message
export const sendMessage = createAction(
'[Messages] Send Message',
props<{ content: string; replyToId?: string }>()
props<{ content: string; replyToId?: string; channelId?: string }>()
);
export const sendMessageSuccess = createAction(
@@ -104,5 +104,9 @@ export const syncMessages = createAction(
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');

View File

@@ -1,10 +1,11 @@
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { of, from } from 'rxjs';
import { map, mergeMap, catchError, withLatestFrom, tap, switchMap } from 'rxjs/operators';
import { of, from, timer, Subject } from 'rxjs';
import { map, mergeMap, catchError, withLatestFrom, tap, switchMap, filter, exhaustMap, repeat, takeUntil } from 'rxjs/operators';
import { v4 as uuidv4 } from 'uuid';
import * as MessagesActions from './messages.actions';
import { selectMessagesSyncing } from './messages.selectors';
import { selectCurrentUser } from '../users/users.selectors';
import { selectCurrentRoom } from '../rooms/rooms.selectors';
import { DatabaseService } from '../../core/services/database.service';
@@ -26,14 +27,26 @@ export class MessagesEffects {
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
// Load messages from local database (hydrate reactions from separate table)
loadMessages$ = createEffect(() =>
this.actions$.pipe(
ofType(MessagesActions.loadMessages),
switchMap(({ roomId }) =>
from(this.db.getMessages(roomId)).pipe(
map((messages) => MessagesActions.loadMessagesSuccess({ messages })),
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;
})
);
return MessagesActions.loadMessagesSuccess({ messages: hydrated });
}),
catchError((error) =>
of(MessagesActions.loadMessagesFailure({ error: error.message }))
)
@@ -50,7 +63,7 @@ export class MessagesEffects {
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom)
),
mergeMap(([{ content, replyToId }, currentUser, currentRoom]) => {
mergeMap(([{ content, replyToId, channelId }, currentUser, currentRoom]) => {
if (!currentUser || !currentRoom) {
return of(MessagesActions.sendMessageFailure({ error: 'Not connected to a room' }));
}
@@ -58,6 +71,7 @@ export class MessagesEffects {
const message: Message = {
id: uuidv4(),
roomId: currentRoom.id,
channelId: channelId || 'general',
senderId: currentUser.id,
senderName: currentUser.displayName || currentUser.username,
content,
@@ -226,6 +240,7 @@ export class MessagesEffects {
// Broadcast to peers
this.webrtc.broadcastMessage({
type: 'reaction-added',
messageId,
reaction,
});
@@ -273,17 +288,23 @@ export class MessagesEffects {
switch (event.type) {
// Precise sync via ID inventory and targeted requests
case 'chat-inventory-request': {
if (!currentRoom || !event.fromPeerId) return of({ type: 'NO_OP' });
return from(this.db.getMessages(currentRoom.id, this.INVENTORY_LIMIT, 0)).pipe(
tap((messages) => {
const items = messages
.map((m) => ({ id: m.id, ts: m.editedAt || m.timestamp || 0 }))
.sort((a, b) => a.ts - b.ts);
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: currentRoom.id,
roomId: reqRoomId,
items: chunk,
total: items.length,
index: i,
@@ -295,24 +316,37 @@ export class MessagesEffects {
}
case 'chat-inventory': {
if (!currentRoom || !Array.isArray(event.items) || !event.fromPeerId) return of({ type: 'NO_OP' });
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(currentRoom.id, this.INVENTORY_LIMIT, 0)).pipe(
return from(this.db.getMessages(invRoomId, this.INVENTORY_LIMIT, 0)).pipe(
mergeMap(async (local) => {
const localMap = new Map(local.map((m) => [m.id, m.editedAt || m.timestamp || 0]));
// 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 { id, ts } of event.items as Array<{ id: string; ts: number }>) {
const lts = localMap.get(id);
if (lts === undefined || ts > lts) {
missing.push(id);
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: currentRoom.id,
roomId: invRoomId,
ids: chunk,
} as any);
}
@@ -322,18 +356,36 @@ export class MessagesEffects {
}
case 'chat-sync-request-ids': {
if (!currentRoom || !Array.isArray(event.ids) || !event.fromPeerId) return of({ type: 'NO_OP' });
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(
tap((maybeMessages) => {
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 < messages.length; i += this.CHUNK_SIZE) {
const chunk = messages.slice(i, i + this.CHUNK_SIZE);
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: currentRoom.id,
roomId: syncReqRoomId || '',
messages: chunk,
attachments: Object.keys(chunkAttachments).length > 0 ? chunkAttachments : undefined,
} as any);
}
}),
@@ -342,21 +394,54 @@ export class MessagesEffects {
}
case 'chat-sync-batch': {
if (!currentRoom || !Array.isArray(event.messages)) return of({ type: 'NO_OP' });
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 accepted: Message[] = [];
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;
if (!existing || its > ets) {
const isNewer = !existing || its > ets;
if (isNewer) {
await this.db.saveMessage(m);
accepted.push(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 });
}
}
return accepted;
// 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((accepted) => accepted.length ? of(MessagesActions.syncMessages({ messages: accepted })) : of({ type: 'NO_OP' }))
mergeMap((toUpsert) => toUpsert.length ? of(MessagesActions.syncMessages({ messages: toUpsert })) : of({ type: 'NO_OP' }))
);
}
case 'voice-state':
@@ -394,6 +479,11 @@ export class MessagesEffects {
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 });
@@ -526,4 +616,66 @@ export class MessagesEffects {
),
{ 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

@@ -5,6 +5,7 @@ import * as MessagesActions from './messages.actions';
export interface MessagesState extends EntityState<Message> {
loading: boolean;
syncing: boolean;
error: string | null;
currentRoomId: string | null;
}
@@ -16,6 +17,7 @@ export const messagesAdapter: EntityAdapter<Message> = createEntityAdapter<Messa
export const initialState: MessagesState = messagesAdapter.getInitialState({
loading: false,
syncing: false,
error: null,
currentRoomId: null,
});
@@ -23,13 +25,23 @@ export const initialState: MessagesState = messagesAdapter.getInitialState({
export const messagesReducer = createReducer(
initialState,
// Load messages
on(MessagesActions.loadMessages, (state, { roomId }) => ({
...state,
loading: true,
error: null,
currentRoomId: roomId,
})),
// Load messages — clear stale messages when switching to a different room
on(MessagesActions.loadMessages, (state, { roomId }) => {
if (state.currentRoomId && state.currentRoomId !== roomId) {
return messagesAdapter.removeAll({
...state,
loading: true,
error: null,
currentRoomId: roomId,
});
}
return {
...state,
loading: true,
error: null,
currentRoomId: roomId,
};
}),
on(MessagesActions.loadMessagesSuccess, (state, { messages }) =>
messagesAdapter.setAll(messages, {
@@ -130,10 +142,37 @@ export const messagesReducer = createReducer(
);
}),
// Sync messages from peer
on(MessagesActions.syncMessages, (state, { messages }) =>
messagesAdapter.upsertMany(messages, state)
),
// Sync lifecycle
on(MessagesActions.startSync, (state) => ({
...state,
syncing: true,
})),
on(MessagesActions.syncComplete, (state) => ({
...state,
syncing: false,
})),
// 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];
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);
}
}
return { ...m, reactions: combined };
}
return m;
});
return messagesAdapter.upsertMany(merged, {
...state,
syncing: false,
});
}),
// Clear messages
on(MessagesActions.clearMessages, (state) =>

View File

@@ -23,6 +23,11 @@ export const selectMessagesError = createSelector(
(state) => state.error
);
export const selectMessagesSyncing = createSelector(
selectMessagesState,
(state) => state.syncing
);
export const selectCurrentRoomId = createSelector(
selectMessagesState,
(state) => state.currentRoomId
@@ -34,6 +39,19 @@ export const selectCurrentRoomMessages = createSelector(
(messages, roomId) => roomId ? messages.filter((m) => m.roomId === roomId) : []
);
/** Select messages for the currently active text channel */
export const selectChannelMessages = (channelId: string) =>
createSelector(
selectAllMessages,
selectCurrentRoomId,
(messages, roomId) => {
if (!roomId) return [];
return messages.filter(
(m) => m.roomId === roomId && (m.channelId || 'general') === channelId
);
}
);
export const selectMessageById = (id: string) =>
createSelector(selectMessagesEntities, (entities) => entities[id]);

View File

@@ -1,5 +1,5 @@
import { createAction, props } from '@ngrx/store';
import { Room, RoomSettings, ServerInfo, RoomPermissions } from '../../core/models';
import { Room, RoomSettings, ServerInfo, RoomPermissions, Channel } from '../../core/models';
// Load rooms from storage
export const loadRooms = createAction('[Rooms] Load Rooms');
@@ -159,6 +159,27 @@ export const receiveRoomUpdate = createAction(
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');

View File

@@ -1,7 +1,37 @@
import { createReducer, on } from '@ngrx/store';
import { Room, ServerInfo, RoomSettings } from '../../core/models';
import { Room, ServerInfo, RoomSettings, Channel } from '../../core/models';
import * as RoomsActions from './rooms.actions';
/** Default channels for a new server */
export function defaultChannels(): Channel[] {
return [
{ id: 'general', name: 'general', type: 'text', position: 0 },
{ id: 'random', name: 'random', type: 'text', position: 1 },
{ id: 'vc-general', name: 'General', type: 'voice', position: 0 },
{ id: 'vc-afk', name: 'AFK', type: 'voice', position: 1 },
];
}
/** 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);
}
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);
if (idx >= 0) {
const updated = [...savedRooms];
updated[idx] = room;
return updated;
}
return [...savedRooms, room];
}
export interface RoomsState {
currentRoom: Room | null;
savedRooms: Room[];
@@ -12,6 +42,7 @@ export interface RoomsState {
isConnected: boolean;
loading: boolean;
error: string | null;
activeChannelId: string; // currently selected text channel
}
export const initialState: RoomsState = {
@@ -24,6 +55,7 @@ export const initialState: RoomsState = {
isConnected: false,
loading: false,
error: null,
activeChannelId: 'general',
};
export const roomsReducer = createReducer(
@@ -38,7 +70,7 @@ export const roomsReducer = createReducer(
on(RoomsActions.loadRoomsSuccess, (state, { rooms }) => ({
...state,
savedRooms: rooms,
savedRooms: deduplicateRooms(rooms),
loading: false,
})),
@@ -74,12 +106,17 @@ export const roomsReducer = createReducer(
error: null,
})),
on(RoomsActions.createRoomSuccess, (state, { room }) => ({
...state,
currentRoom: room,
isConnecting: false,
isConnected: true,
})),
on(RoomsActions.createRoomSuccess, (state, { room }) => {
const enriched = { ...room, channels: room.channels || defaultChannels() };
return {
...state,
currentRoom: enriched,
savedRooms: upsertRoom(state.savedRooms, enriched),
isConnecting: false,
isConnected: true,
activeChannelId: 'general',
};
}),
on(RoomsActions.createRoomFailure, (state, { error }) => ({
...state,
@@ -94,12 +131,17 @@ export const roomsReducer = createReducer(
error: null,
})),
on(RoomsActions.joinRoomSuccess, (state, { room }) => ({
...state,
currentRoom: room,
isConnecting: false,
isConnected: true,
})),
on(RoomsActions.joinRoomSuccess, (state, { room }) => {
const enriched = { ...room, channels: room.channels || defaultChannels() };
return {
...state,
currentRoom: enriched,
savedRooms: upsertRoom(state.savedRooms, enriched),
isConnecting: false,
isConnected: true,
activeChannelId: 'general',
};
}),
on(RoomsActions.joinRoomFailure, (state, { error }) => ({
...state,
@@ -128,12 +170,17 @@ export const roomsReducer = createReducer(
error: null,
})),
on(RoomsActions.viewServerSuccess, (state, { room }) => ({
...state,
currentRoom: room,
isConnecting: false,
isConnected: true,
})),
on(RoomsActions.viewServerSuccess, (state, { room }) => {
const enriched = { ...room, channels: room.channels || defaultChannels() };
return {
...state,
currentRoom: enriched,
savedRooms: upsertRoom(state.savedRooms, enriched),
isConnecting: false,
isConnected: true,
activeChannelId: 'general',
};
}),
// Update room settings
on(RoomsActions.updateRoomSettings, (state) => ({
@@ -225,5 +272,48 @@ export const roomsReducer = createReducer(
on(RoomsActions.setConnecting, (state, { isConnecting }) => ({
...state,
isConnecting,
}))
})),
// Channel management
on(RoomsActions.selectChannel, (state, { channelId }) => ({
...state,
activeChannelId: channelId,
})),
on(RoomsActions.addChannel, (state, { channel }) => {
if (!state.currentRoom) return state;
const existing = state.currentRoom.channels || defaultChannels();
const updatedChannels = [...existing, channel];
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
return {
...state,
currentRoom: updatedRoom,
savedRooms: upsertRoom(state.savedRooms, updatedRoom),
};
}),
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 updatedRoom = { ...state.currentRoom, channels: updatedChannels };
return {
...state,
currentRoom: updatedRoom,
savedRooms: upsertRoom(state.savedRooms, updatedRoom),
activeChannelId: state.activeChannelId === channelId ? 'general' : state.activeChannelId,
};
}),
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 updatedRoom = { ...state.currentRoom, channels: updatedChannels };
return {
...state,
currentRoom: updatedRoom,
savedRooms: upsertRoom(state.savedRooms, updatedRoom),
};
})
);

View File

@@ -62,3 +62,23 @@ export const selectRoomsLoading = createSelector(
selectRoomsState,
(state) => state.loading
);
export const selectActiveChannelId = createSelector(
selectRoomsState,
(state) => state.activeChannelId
);
export const selectCurrentRoomChannels = createSelector(
selectCurrentRoom,
(room) => room?.channels ?? []
);
export const selectTextChannels = createSelector(
selectCurrentRoomChannels,
(channels) => channels.filter(c => c.type === 'text').sort((a, b) => a.position - b.position)
);
export const selectVoiceChannels = createSelector(
selectCurrentRoomChannels,
(channels) => channels.filter(c => c.type === 'voice').sort((a, b) => a.position - b.position)
);

View File

@@ -78,3 +78,8 @@ export const selectAdmins = createSelector(
selectAllUsers,
(users) => users.filter((u) => u.role === 'host' || u.role === 'admin' || u.role === 'moderator')
);
export const selectIsCurrentUserOwner = createSelector(
selectCurrentUser,
(user) => user?.role === 'host'
);