Remote connection

This commit is contained in:
2026-01-09 19:49:38 +01:00
parent 87c722b5ae
commit 8c551a90f4
28 changed files with 3134 additions and 327 deletions

View File

@@ -1,8 +1,15 @@
const { app, BrowserWindow, ipcMain, desktopCapturer } = require('electron');
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
let mainWindow;
// Suppress Autofill devtools errors by disabling related features
app.commandLine.appendSwitch('disable-features', 'Autofill,AutofillAssistant,AutofillServerCommunication');
// Allow media autoplay without user gesture (bypasses Chromium autoplay policy)
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
function createWindow() {
mainWindow = new BrowserWindow({
width: 1400,
@@ -23,7 +30,9 @@ function createWindow() {
// In development, load from Angular dev server
if (process.env.NODE_ENV === 'development') {
mainWindow.loadURL('http://localhost:4200');
if (process.env.DEBUG_DEVTOOLS === '1') {
mainWindow.webContents.openDevTools();
}
} else {
// In production, load the built Angular app
// The dist folder is at the project root, not in electron folder
@@ -83,3 +92,29 @@ ipcMain.handle('get-sources', async () => {
ipcMain.handle('get-app-data-path', () => {
return app.getPath('userData');
});
// IPC for basic file operations used by renderer
ipcMain.handle('file-exists', async (_event, filePath) => {
try {
await fsp.access(filePath, fs.constants.F_OK);
return true;
} catch {
return false;
}
});
ipcMain.handle('read-file', async (_event, filePath) => {
const data = await fsp.readFile(filePath);
return data.toString('base64');
});
ipcMain.handle('write-file', async (_event, filePath, base64Data) => {
const buffer = Buffer.from(base64Data, 'base64');
await fsp.writeFile(filePath, buffer);
return true;
});
ipcMain.handle('ensure-dir', async (_event, dirPath) => {
await fsp.mkdir(dirPath, { recursive: true });
return true;
});

View File

@@ -16,4 +16,5 @@ contextBridge.exposeInMainWorld('electronAPI', {
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
fileExists: (filePath) => ipcRenderer.invoke('file-exists', filePath),
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
});

View File

@@ -16,7 +16,7 @@
"server:dev": "cd server && npm run dev",
"electron": "ng build && electron .",
"electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && cross-env NODE_ENV=development electron .\"",
"electron:full": "concurrently --kill-others \"cd server && npm start\" \"ng serve\" \"wait-on http://localhost:4200 http://localhost:3001/api/health && cross-env NODE_ENV=development electron .\"",
"electron:full": "concurrently --kill-others \"cd server && npm run dev\" \"ng serve\" \"wait-on http://localhost:4200 http://localhost:3001/api/health && cross-env NODE_ENV=development electron .\"",
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production electron .\"",
"electron:build": "npm run build:prod && electron-builder",
"electron:build:win": "npm run build:prod && electron-builder --win",
@@ -63,7 +63,10 @@
"simple-peer": "^9.11.1",
"sql.js": "^1.13.0",
"tslib": "^2.3.0",
"uuid": "^13.0.0"
"uuid": "^13.0.0",
"marked": "^12.0.2",
"dompurify": "^3.0.6",
"highlight.js": "^11.9.0"
},
"devDependencies": {
"@angular/build": "^21.0.4",

BIN
server/data/metoyou.sqlite Normal file

Binary file not shown.

View File

@@ -23,5 +23,17 @@
"tags": [],
"createdAt": 1766902260144,
"lastSeen": 1766902260144
},
{
"id": "337ad599-736e-49c6-bf01-fb94c1b82a6d",
"name": "ASDASD",
"ownerId": "54c0953a-1e54-4c07-8da9-06c143d9354f",
"ownerPublicKey": "54c0953a-1e54-4c07-8da9-06c143d9354f",
"isPrivate": false,
"maxUsers": 50,
"currentUsers": 0,
"tags": [],
"createdAt": 1767240654523,
"lastSeen": 1767240654523
}
]

View File

@@ -19,6 +19,7 @@
"@types/cors": "^2.8.14",
"@types/express": "^4.17.18",
"@types/node": "^20.8.0",
"@types/sql.js": "^1.4.9",
"@types/uuid": "^9.0.4",
"@types/ws": "^8.5.8",
"ts-node-dev": "^2.0.0",

View File

@@ -1,6 +1,6 @@
import fs from 'fs';
import path from 'path';
import initSqlJs, { Database, Statement } from 'sql.js';
import initSqlJs from 'sql.js';
// Simple SQLite via sql.js persisted to a single file
const DATA_DIR = path.join(process.cwd(), 'data');
@@ -11,7 +11,7 @@ function ensureDataDir() {
}
let SQL: any = null;
let db: Database | null = null;
let db: any | null = null;
export async function initDB(): Promise<void> {
if (db) return;
@@ -56,7 +56,7 @@ export interface AuthUser {
export async function getUserByUsername(username: string): Promise<AuthUser | null> {
if (!db) await initDB();
const stmt: Statement = db!.prepare('SELECT id, username, passwordHash, displayName, createdAt FROM users WHERE username = ? LIMIT 1');
const stmt: any = db!.prepare('SELECT id, username, passwordHash, displayName, createdAt FROM users WHERE username = ? LIMIT 1');
stmt.bind([username]);
let row: AuthUser | null = null;
if (stmt.step()) {
@@ -75,7 +75,7 @@ export async function getUserByUsername(username: string): Promise<AuthUser | nu
export async function getUserById(id: string): Promise<AuthUser | null> {
if (!db) await initDB();
const stmt: Statement = db!.prepare('SELECT id, username, passwordHash, displayName, createdAt FROM users WHERE id = ? LIMIT 1');
const stmt: any = db!.prepare('SELECT id, username, passwordHash, displayName, createdAt FROM users WHERE id = ? LIMIT 1');
stmt.bind([id]);
let row: AuthUser | null = null;
if (stmt.step()) {

View File

@@ -88,6 +88,46 @@ app.get('/api/time', (req, res) => {
res.json({ now: Date.now() });
});
// Image proxy to allow rendering external images within CSP (img-src 'self' data: blob:)
app.get('/api/image-proxy', async (req, res) => {
try {
const url = String(req.query.url || '');
if (!/^https?:\/\//i.test(url)) {
return res.status(400).json({ error: 'Invalid URL' });
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
const response = await fetch(url, { redirect: 'follow', signal: controller.signal });
clearTimeout(timeout);
if (!response.ok) {
return res.status(response.status).end();
}
const contentType = response.headers.get('content-type') || '';
if (!contentType.toLowerCase().startsWith('image/')) {
return res.status(415).json({ error: 'Unsupported content type' });
}
const arrayBuffer = await response.arrayBuffer();
const MAX_BYTES = 8 * 1024 * 1024; // 8MB limit
if (arrayBuffer.byteLength > MAX_BYTES) {
return res.status(413).json({ error: 'Image too large' });
}
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'public, max-age=3600');
res.send(Buffer.from(arrayBuffer));
} catch (err) {
if ((err as any)?.name === 'AbortError') {
return res.status(504).json({ error: 'Timeout fetching image' });
}
console.error('Image proxy error:', err);
res.status(502).json({ error: 'Failed to fetch image' });
}
});
// Basic auth (demo - file-based)
interface AuthUser { id: string; username: string; passwordHash: string; displayName: string; createdAt: number; }
let authUsers: AuthUser[] = [];
@@ -320,33 +360,33 @@ const server = createServer(app);
const wss = new WebSocketServer({ server });
wss.on('connection', (ws: WebSocket) => {
const oderId = uuidv4();
connectedUsers.set(oderId, { oderId, ws });
const connectionId = uuidv4();
connectedUsers.set(connectionId, { oderId: connectionId, ws });
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
handleWebSocketMessage(oderId, message);
handleWebSocketMessage(connectionId, message);
} catch (err) {
console.error('Invalid WebSocket message:', err);
}
});
ws.on('close', () => {
const user = connectedUsers.get(oderId);
const user = connectedUsers.get(connectionId);
if (user?.serverId) {
// Notify others in the room
// Notify others in the room - use user.oderId (the actual user ID), not connectionId
broadcastToServer(user.serverId, {
type: 'user_left',
oderId,
oderId: user.oderId,
displayName: user.displayName,
}, oderId);
}, user.oderId);
}
connectedUsers.delete(oderId);
connectedUsers.delete(connectionId);
});
// Send connection acknowledgment
ws.send(JSON.stringify({ type: 'connected', oderId, serverTime: Date.now() }));
// Send connection acknowledgment with the connectionId (client will identify with their actual oderId)
ws.send(JSON.stringify({ type: 'connected', connectionId, serverTime: Date.now() }));
});
function handleWebSocketMessage(connectionId: string, message: any): void {
@@ -366,14 +406,15 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
case 'join_server':
user.serverId = message.serverId;
connectedUsers.set(connectionId, user);
console.log(`User ${user.displayName} (${user.oderId}) joined server ${message.serverId}`);
console.log(`User ${user.displayName || 'Anonymous'} (${user.oderId}) joined server ${message.serverId}`);
// Get list of current users in server (exclude this user by oderId)
// Only include users that have been identified (have displayName)
const usersInServer = Array.from(connectedUsers.values())
.filter(u => u.serverId === message.serverId && u.oderId !== user.oderId)
.map(u => ({ oderId: u.oderId, displayName: u.displayName }));
.filter(u => u.serverId === message.serverId && u.oderId !== user.oderId && u.displayName)
.map(u => ({ oderId: u.oderId, displayName: u.displayName || 'Anonymous' }));
console.log(`Sending server_users to ${user.displayName}:`, usersInServer);
console.log(`Sending server_users to ${user.displayName || 'Anonymous'}:`, usersInServer);
user.ws.send(JSON.stringify({
type: 'server_users',
users: usersInServer,
@@ -383,7 +424,7 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
broadcastToServer(message.serverId, {
type: 'user_joined',
oderId: user.oderId,
displayName: user.displayName,
displayName: user.displayName || 'Anonymous',
}, user.oderId);
break;
@@ -396,7 +437,7 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
broadcastToServer(oldServerId, {
type: 'user_left',
oderId: user.oderId,
displayName: user.displayName,
displayName: user.displayName || 'Anonymous',
}, user.oderId);
}
break;

View File

@@ -1,3 +1,4 @@
declare module 'sql.js';
declare module 'sql.js' {
export default function initSqlJs(config?: { locateFile?: (file: string) => string }): Promise<any>;
export type Database = any;

View File

@@ -11,4 +11,7 @@
<router-outlet />
</div>
</main>
<!-- Floating voice controls - shown when connected to voice and navigated away from server -->
<app-floating-voice-controls />
</div>

View File

@@ -6,14 +6,17 @@ import { Store } from '@ngrx/store';
import { DatabaseService } from './core/services/database.service';
import { ServerDirectoryService } from './core/services/server-directory.service';
import { TimeSyncService } from './core/services/time-sync.service';
import { VoiceSessionService } from './core/services/voice-session.service';
import { ServersRailComponent } from './features/servers/servers-rail.component';
import { TitleBarComponent } from './features/shell/title-bar.component';
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls.component';
import * as UsersActions from './store/users/users.actions';
import * as RoomsActions from './store/rooms/rooms.actions';
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
@Component({
selector: 'app-root',
imports: [CommonModule, RouterOutlet, ServersRailComponent, TitleBarComponent],
imports: [CommonModule, RouterOutlet, ServersRailComponent, TitleBarComponent, FloatingVoiceControlsComponent],
templateUrl: './app.html',
styleUrl: './app.scss',
})
@@ -23,6 +26,9 @@ export class App implements OnInit {
private router = inject(Router);
private servers = inject(ServerDirectoryService);
private timeSync = inject(TimeSyncService);
private voiceSession = inject(VoiceSessionService);
currentRoom = this.store.selectSignal(selectCurrentRoom);
async ngOnInit(): Promise<void> {
// Initialize database
@@ -56,12 +62,20 @@ export class App implements OnInit {
}
}
// Persist last visited on navigation
// Persist last visited on navigation and track voice session navigation
this.router.events.subscribe((evt) => {
if (evt instanceof NavigationEnd) {
const url = evt.urlAfterRedirects || evt.url;
// Store room route or search
localStorage.setItem('metoyou_lastVisitedRoute', url);
// Check if user navigated away from voice-connected server
// Extract roomId from URL if on a room route
const roomMatch = url.match(/\/room\/([^/]+)/);
const currentRoomId = roomMatch ? roomMatch[1] : null;
// Update voice session service with current server context
this.voiceSession.checkCurrentRoute(currentRoomId);
}
});
}

View File

@@ -107,7 +107,10 @@ export interface VoiceState {
isSpeaking: boolean;
isMutedByAdmin?: boolean;
volume?: number;
/** The voice channel/room ID within a server (e.g., 'general', 'afk') */
roomId?: string;
/** The server ID the user is connected to voice in */
serverId?: string;
}
export interface ScreenShareState {
@@ -126,7 +129,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';
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';
messageId?: string;
message?: Message;
reaction?: Reaction;
@@ -140,10 +143,12 @@ export interface ChatEvent {
editedAt?: number;
deletedBy?: string;
oderId?: string;
displayName?: string;
emoji?: string;
reason?: string;
settings?: RoomSettings;
voiceState?: Partial<VoiceState>;
isScreenSharing?: boolean;
}
export interface ServerInfo {

View File

@@ -0,0 +1,418 @@
import { Injectable, inject, signal } from '@angular/core';
import { Subject } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { WebRTCService } from './webrtc.service';
import { Store } from '@ngrx/store';
import { selectCurrentRoomName } from '../../store/rooms/rooms.selectors';
export interface AttachmentMeta {
id: string;
messageId: string;
filename: string;
size: number;
mime: string;
isImage: boolean;
uploaderPeerId?: string;
filePath?: string; // Electron-only: absolute path to original file
}
export interface Attachment extends AttachmentMeta {
available: boolean;
objectUrl?: string;
receivedBytes?: number;
// Runtime-only stats
speedBps?: number;
startedAtMs?: number;
lastUpdateMs?: number;
}
@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';
// 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}`; }
private isCancelled(targetPeerId: string, messageId: string, fileId: string): boolean {
return this.cancelledTransfers.has(this.makeKey(messageId, fileId, targetPeerId));
}
constructor() {
this.loadPersisted();
}
getForMessage(messageId: string): Attachment[] {
return this.attachmentsByMessage.get(messageId) || [];
}
// Publish attachments for a sent message and stream images <=10MB
async publishAttachments(messageId: string, files: File[], uploaderPeerId?: string): Promise<void> {
const attachments: Attachment[] = [];
for (const file of files) {
const id = uuidv4();
const meta: Attachment = {
id,
messageId,
filename: file.name,
size: file.size,
mime: file.type || 'application/octet-stream',
isImage: file.type.startsWith('image/'),
uploaderPeerId,
filePath: (file as any)?.path,
available: false,
};
attachments.push(meta);
// Save original for request-based transfer
this.originals.set(`${messageId}:${id}`, file);
// 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 {}
}
// Announce to peers
this.webrtc.broadcastMessage({
type: 'file-announce',
messageId,
file: {
id,
filename: meta.filename,
size: meta.size,
mime: meta.mime,
isImage: meta.isImage,
uploaderPeerId,
},
} as any);
// Stream image content if small enough (<= 10MB)
if (meta.isImage && meta.size <= 10 * 1024 * 1024) {
await this.streamFileToPeers(messageId, id, file);
}
}
this.attachmentsByMessage.set(messageId, [ ...(this.attachmentsByMessage.get(messageId) || []), ...attachments ]);
this.updated.set(this.updated() + 1);
this.persist();
}
private async streamFileToPeers(messageId: string, fileId: string, file: File): Promise<void> {
const chunkSize = 64 * 1024; // 64KB
const totalChunks = Math.ceil(file.size / chunkSize);
let offset = 0;
let index = 0;
while (offset < file.size) {
const slice = file.slice(offset, offset + chunkSize);
const arrayBuffer = await slice.arrayBuffer();
// Convert to base64 for JSON transport
const base64 = this.arrayBufferToBase64(arrayBuffer);
this.webrtc.broadcastMessage({
type: 'file-chunk',
messageId,
fileId,
index,
total: totalChunks,
data: base64,
} as any);
offset += chunkSize;
index++;
}
}
// Incoming events from peers
handleFileAnnounce(payload: any): void {
const { messageId, file } = payload;
if (!messageId || !file) return;
const list = this.attachmentsByMessage.get(messageId) || [];
const exists = list.find((a: Attachment) => a.id === file.id);
if (!exists) {
list.push({
id: file.id,
messageId,
filename: file.filename,
size: file.size,
mime: file.mime,
isImage: !!file.isImage,
uploaderPeerId: file.uploaderPeerId,
available: false,
receivedBytes: 0,
});
this.attachmentsByMessage.set(messageId, list);
this.updated.set(this.updated() + 1);
this.persist();
}
}
handleFileChunk(payload: any): void {
const { messageId, fileId, index, total, data } = payload;
if (!messageId || !fileId || typeof index !== 'number' || typeof total !== 'number' || !data) return;
const list = this.attachmentsByMessage.get(messageId) || [];
const att = list.find((a: Attachment) => a.id === fileId);
if (!att) return;
// Decode base64 and append to Blob parts
const bytes = this.base64ToUint8Array(data);
const partsKey = `${messageId}:${fileId}:parts`;
const countKey = `${messageId}:${fileId}:count`;
let parts = (this as any)[partsKey] as ArrayBuffer[] | undefined;
if (!parts) {
parts = new Array(total);
(this as any)[partsKey] = parts;
(this as any)[countKey] = 0;
}
if (!parts[index]) {
parts[index] = bytes.buffer as ArrayBuffer;
(this as any)[countKey] = ((this as any)[countKey] as number) + 1;
}
const now = Date.now();
const prevReceived = att.receivedBytes || 0;
att.receivedBytes = prevReceived + bytes.byteLength;
if (!att.startedAtMs) att.startedAtMs = now;
if (!att.lastUpdateMs) att.lastUpdateMs = now;
const deltaMs = Math.max(1, now - att.lastUpdateMs);
const instBps = (bytes.byteLength / deltaMs) * 1000;
const prevSpeed = att.speedBps || instBps;
// EWMA smoothing
att.speedBps = 0.7 * prevSpeed + 0.3 * instBps;
att.lastUpdateMs = now;
// Trigger UI update for real-time progress
this.updated.set(this.updated() + 1);
const receivedCount = (this as any)[countKey] as number;
if (receivedCount === total || (att.receivedBytes || 0) >= att.size) {
const finalParts = (this as any)[partsKey] as ArrayBuffer[];
if (finalParts.every((p) => p instanceof ArrayBuffer)) {
const blob = new Blob(finalParts, { type: att.mime });
att.available = true;
att.objectUrl = URL.createObjectURL(blob);
// Auto-save small images to disk under app data: server/<room>/image
if (att.isImage && att.size <= 10 * 1024 * 1024) {
void this.saveImageToDisk(att, blob);
}
// Final update
delete (this as any)[partsKey];
delete (this as any)[countKey];
this.updated.set(this.updated() + 1);
this.persist();
}
}
}
private async saveImageToDisk(att: Attachment, blob: Blob): Promise<void> {
try {
const w: any = window as any;
const appData: string | undefined = await w?.electronAPI?.getAppDataPath?.();
if (!appData) return;
const roomName = await new Promise<string>((resolve) => {
let name = '';
const sub = this.ngrxStore.select(selectCurrentRoomName).subscribe((n) => { name = n || ''; resolve(name); sub.unsubscribe(); });
});
const safeRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room';
const dir = `${appData}/server/${safeRoom}/image`;
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);
} 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);
}
// Cancel an in-progress request from the requester side
cancelRequest(messageId: string, att: Attachment): void {
const target = att.uploaderPeerId;
if (!target) return;
try {
// Reset local assembly state
const partsKey = `${messageId}:${att.id}:parts`;
const countKey = `${messageId}:${att.id}:count`;
delete (this as any)[partsKey];
delete (this as any)[countKey];
att.receivedBytes = 0;
att.speedBps = 0;
att.startedAtMs = undefined;
att.lastUpdateMs = undefined;
if (att.objectUrl) {
try { URL.revokeObjectURL(att.objectUrl); } catch {}
att.objectUrl = undefined;
}
att.available = false;
this.updated.set(this.updated() + 1);
// Notify uploader to stop streaming
this.webrtc.sendToPeer(target, {
type: 'file-cancel',
messageId,
fileId: att.id,
} as any);
} catch {}
}
// When we receive a request and we are the uploader, stream the original file if available
async handleFileRequest(payload: any): Promise<void> {
const { messageId, fileId, fromPeerId } = payload;
if (!messageId || !fileId || !fromPeerId) return;
const original = this.originals.get(`${messageId}:${fileId}`);
if (original) {
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;
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);
}
return;
}
} catch {}
}
// Fallback: prompt reselect
this.onMissingOriginal.next({ messageId, fileId, fromPeerId });
}
private async streamFileToPeer(targetPeerId: string, messageId: string, fileId: string, file: File): Promise<void> {
const chunkSize = 64 * 1024; // 64KB
const totalChunks = Math.ceil(file.size / chunkSize);
let offset = 0;
let index = 0;
while (offset < file.size) {
if (this.isCancelled(targetPeerId, messageId, fileId)) break;
const slice = file.slice(offset, offset + chunkSize);
const arrayBuffer = await slice.arrayBuffer();
const base64 = this.arrayBufferToBase64(arrayBuffer);
await this.webrtc.sendToPeerBuffered(targetPeerId, {
type: 'file-chunk',
messageId,
fileId,
index,
total: totalChunks,
data: base64,
} as any);
offset += chunkSize;
index++;
}
}
// Handle cancellation message (uploader side): stop any in-progress stream to requester
handleFileCancel(payload: any): void {
const { messageId, fileId, fromPeerId } = payload;
if (!messageId || !fileId || !fromPeerId) return;
this.cancelledTransfers.add(this.makeKey(messageId, fileId, fromPeerId));
// Optionally clear original if desired (keep for re-request)
}
// 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 {
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));
} catch {}
}
private loadPersisted(): void {
try {
const raw = localStorage.getItem(this.STORAGE_KEY);
if (!raw) return;
const list: AttachmentMeta[] = JSON.parse(raw);
const grouped = new Map<string, Attachment[]>();
for (const a of list) {
const att: Attachment = { ...a, available: false };
const arr = grouped.get(a.messageId) || [];
arr.push(att);
grouped.set(a.messageId, arr);
}
this.attachmentsByMessage = grouped;
this.updated.set(this.updated() + 1);
} catch {}
}
private arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
private base64ToUint8Array(base64: string): Uint8Array {
const binary = atob(base64);
const len = binary.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
}

View File

@@ -1,3 +1,4 @@
export * from './database.service';
export * from './webrtc.service';
export * from './server-directory.service';
export * from './voice-session.service';

View File

@@ -70,7 +70,13 @@ export class ServerDirectoryService {
private get baseUrl(): string {
const active = this.activeServer();
return active ? `${active.url}/api` : 'http://localhost:3001/api';
const raw = active ? active.url : 'http://localhost:3001';
// Strip trailing slashes and any accidental '/api'
let base = raw.replace(/\/+$/,'');
if (base.toLowerCase().endsWith('/api')) {
base = base.slice(0, -4);
}
return `${base}/api`;
}
// Expose API base URL for consumers that need to call server endpoints
@@ -83,7 +89,13 @@ export class ServerDirectoryService {
const newServer: ServerEndpoint = {
id: crypto.randomUUID(),
name: server.name,
url: server.url.replace(/\/$/, ''), // Remove trailing slash
// Sanitize: remove trailing slashes and any '/api'
url: (() => {
let u = server.url.trim();
u = u.replace(/\/+$/,'');
if (u.toLowerCase().endsWith('/api')) u = u.slice(0, -4);
return u;
})(),
isActive: false,
isDefault: false,
status: 'unknown',

View File

@@ -0,0 +1,107 @@
import { Injectable, signal, computed, inject } from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import * as RoomsActions from '../../store/rooms/rooms.actions';
export interface VoiceSessionInfo {
serverId: string;
serverName: string;
roomId: string;
roomName: string;
serverIcon?: string;
serverDescription?: string;
/** The route path to navigate back to the server */
serverRoute: string;
}
/**
* Service to track the current voice session across navigation.
* When a user is connected to voice in a server and navigates away,
* this service maintains the session info for the floating controls.
*/
@Injectable({
providedIn: 'root',
})
export class VoiceSessionService {
private router = inject(Router);
private store = inject(Store);
// The voice session info when connected
private readonly _voiceSession = signal<VoiceSessionInfo | null>(null);
// Whether the user is currently viewing the voice-connected server
private readonly _isViewingVoiceServer = signal(true);
// Public computed signals
readonly voiceSession = computed(() => this._voiceSession());
readonly isViewingVoiceServer = computed(() => this._isViewingVoiceServer());
/**
* Whether to show floating voice controls:
* True when connected to voice AND not viewing the voice-connected server
*/
readonly showFloatingControls = computed(() => {
return this._voiceSession() !== null && !this._isViewingVoiceServer();
});
/**
* Start a voice session - called when user joins voice in a server
*/
startSession(info: VoiceSessionInfo): void {
this._voiceSession.set(info);
this._isViewingVoiceServer.set(true);
}
/**
* End the voice session - called when user disconnects from voice
*/
endSession(): void {
this._voiceSession.set(null);
this._isViewingVoiceServer.set(true);
}
/**
* Update whether user is viewing the voice-connected server
*/
setViewingVoiceServer(isViewing: boolean): void {
this._isViewingVoiceServer.set(isViewing);
}
/**
* Check if the current route matches the voice session server
*/
checkCurrentRoute(currentServerId: string | null): void {
const session = this._voiceSession();
if (!session) {
this._isViewingVoiceServer.set(true);
return;
}
this._isViewingVoiceServer.set(currentServerId === session.serverId);
}
/**
* Navigate back to the voice-connected server
*/
navigateToVoiceServer(): void {
const session = this._voiceSession();
if (session) {
// Dispatch joinRoom action to update the store state
this.store.dispatch(RoomsActions.joinRoom({
roomId: session.serverId,
serverInfo: {
name: session.serverName,
description: session.serverDescription,
hostName: 'Unknown',
},
}));
this._isViewingVoiceServer.set(true);
}
}
/**
* Get the current server ID from the voice session
*/
getVoiceServerId(): string | null {
return this._voiceSession()?.serverId ?? null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import { Component, inject, signal, computed, effect, ElementRef, ViewChild, Aft
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AttachmentService, Attachment } from '../../core/services/attachment.service';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideSend,
@@ -20,6 +21,10 @@ import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../store/users/u
import { Message } from '../../core/models';
import { WebRTCService } from '../../core/services/webrtc.service';
import { Subscription } from 'rxjs';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { ServerDirectoryService } from '../../core/services/server-directory.service';
const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥', '👀'];
@@ -99,9 +104,73 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
</button>
</div>
} @else {
<p class="text-foreground break-words whitespace-pre-wrap mt-1">
{{ message.content }}
</p>
<div class="prose prose-invert mt-1 break-words" (click)="onContentClick($event)" [innerHTML]="renderMarkdown(message.content)"></div>
@if (getAttachments(message.id).length > 0) {
<div class="mt-2 space-y-2">
@for (att of getAttachments(message.id); track att.id) {
@if (att.isImage) {
@if (att.available && att.objectUrl) {
<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>
</div>
<div class="text-xs text-muted-foreground">{{ ((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>
</div>
}
} @else {
<div class="border border-border rounded-md p-2 bg-secondary/40">
<div class="flex items-center justify-between">
<div class="min-w-0">
<div class="text-sm font-medium truncate">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
</div>
<div class="flex items-center gap-2">
@if (!isUploader(att)) {
@if (!att.available) {
<div class="w-24 h-1.5 rounded bg-muted">
<div class="h-1.5 rounded bg-primary" [style.width.%]="(att.receivedBytes || 0) * 100 / att.size"></div>
</div>
<div class="text-xs text-muted-foreground flex items-center gap-2">
<span>{{ ((att.receivedBytes || 0) * 100 / att.size) | number:'1.0-0' }}%</span>
@if (att.speedBps) {
<span>• {{ formatSpeed(att.speedBps) }}</span>
}
</div>
@if (!(att.receivedBytes || 0)) {
<button
class="px-2 py-1 text-xs bg-secondary text-foreground rounded"
(click)="requestAttachment(att, message.id)"
>Request</button>
} @else {
<button
class="px-2 py-1 text-xs bg-destructive text-destructive-foreground rounded"
(click)="cancelAttachment(att, message.id)"
>Cancel</button>
}
} @else {
<button
class="px-2 py-1 text-xs bg-primary text-primary-foreground rounded"
(click)="downloadAttachment(att)"
>Download</button>
}
} @else {
<div class="text-xs text-muted-foreground">Shared from your device</div>
}
</div>
</div>
</div>
}
}
</div>
}
}
<!-- Reactions -->
@@ -216,20 +285,71 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
</div>
}
<!-- Markdown Toolbar -->
@if (toolbarVisible()) {
<div class="pointer-events-auto" (mousedown)="$event.preventDefault()" (mouseenter)="onToolbarMouseEnter()" (mouseleave)="onToolbarMouseLeave()">
<div class="mx-4 -mb-2 flex flex-wrap gap-2 justify-start items-center bg-card/70 backdrop-blur border border-border rounded-lg px-2 py-1 shadow-sm">
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline('**')"><b>B</b></button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline('*')"><i>I</i></button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline('~~')"><s>S</s></button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyInline(inlineCodeToken)">&#96;</button>
<span class="mx-1 text-muted-foreground">|</span>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHeading(1)">H1</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHeading(2)">H2</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHeading(3)">H3</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyPrefix('> ')">Quote</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyPrefix('- ')">• List</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyOrderedList()">1. List</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyCodeBlock()">Code</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyLink()">Link</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyImage()">Image</button>
<button class="px-2 py-1 text-xs hover:bg-secondary rounded" (click)="applyHorizontalRule()">HR</button>
</div>
</div>
}
<!-- Message Input -->
<div class="p-4 border-t border-border">
<div class="flex gap-2">
<input
type="text"
<div class="flex gap-2 items-start">
<div class="relative flex-1">
<textarea
#messageInputRef
rows="2"
[(ngModel)]="messageContent"
(keydown.enter)="sendMessage()"
(focus)="onInputFocus()"
(blur)="onInputBlur()"
(keydown.enter)="onEnter($event)"
(input)="onInputChange()"
placeholder="Type a message..."
class="flex-1 px-4 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
(dragenter)="onDragEnter($event)"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
placeholder="Type a message... (Markdown supported)"
class="w-full px-3 py-1 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-y"
[class.border-primary]="dragActive()"
[class.border-dashed]="dragActive()"
></textarea>
@if (dragActive()) {
<div class="pointer-events-none absolute inset-0 rounded-lg border-2 border-primary border-dashed bg-primary/5 flex items-center justify-center">
<div class="text-sm text-muted-foreground">Drop files to attach</div>
</div>
}
@if (pendingFiles.length > 0) {
<div class="mt-2 flex flex-wrap gap-2">
@for (file of pendingFiles; track file.name) {
<div class="group flex items-center gap-2 px-2 py-1 rounded bg-secondary/60 border border-border">
<div class="text-xs font-medium truncate max-w-[14rem]">{{ file.name }}</div>
<div class="text-[10px] text-muted-foreground">{{ formatBytes(file.size) }}</div>
<button (click)="removePendingFile(file)" class="opacity-70 group-hover:opacity-100 text-[10px] bg-destructive/20 text-destructive rounded px-1 py-0.5">Remove</button>
</div>
}
</div>
}
</div>
<button
(click)="sendMessage()"
[disabled]="!messageContent.trim()"
[disabled]="!messageContent.trim() && pendingFiles.length === 0"
class="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<ng-icon name="lucideSend" class="w-4 h-4" />
@@ -241,9 +361,13 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
})
export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestroy {
@ViewChild('messagesContainer') messagesContainer!: ElementRef;
@ViewChild('messageInputRef') messageInputRef!: ElementRef<HTMLTextAreaElement>;
private store = inject(Store);
private webrtc = inject(WebRTCService);
private sanitizer = inject(DomSanitizer);
private serverDirectory = inject(ServerDirectoryService);
private attachmentsSvc = inject(AttachmentService);
messages = this.store.selectSignal(selectAllMessages);
loading = this.store.selectSignal(selectMessagesLoading);
@@ -264,6 +388,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
private readonly typingTTL = 3000; // ms to keep a user as typing
private lastMessageCount = 0;
private initialScrollPending = true;
pendingFiles: File[] = [];
// Track typing users by name and expire them
private typingMap = new Map<string, { name: string; expiresAt: number }>();
@@ -274,6 +399,17 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
// Stable reference time to avoid ExpressionChanged errors (updated every minute)
nowRef = signal<number>(Date.now());
private nowTimer: any;
toolbarVisible = signal(false);
private toolbarHovering = false;
inlineCodeToken = '`';
dragActive = signal(false);
// Cache blob URLs for proxied images to prevent repeated network fetches on re-render
private imageBlobCache = new Map<string, string>();
// Re-render when attachments update
private attachmentsUpdatedEffect = effect(() => {
// Subscribe to updates; no-op body
void this.attachmentsSvc.updated();
});
// Messages length signal and effect to detect new messages without blocking change detection
messagesLength = computed(() => this.messages().length);
@@ -316,8 +452,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.scrollToBottom();
this.showNewMessagesBar.set(false);
this.lastMessageCount = this.messages().length;
this.loadCspImages();
return;
}
// Attempt to resolve any deferred images after each check
this.loadCspImages();
}
ngOnInit(): void {
@@ -329,6 +468,21 @@ 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();
@@ -363,11 +517,14 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
}
sendMessage(): void {
if (!this.messageContent.trim()) return;
const raw = this.messageContent.trim();
if (!raw && this.pendingFiles.length === 0) return;
const content = this.appendImageMarkdown(raw);
this.store.dispatch(
MessagesActions.sendMessage({
content: this.messageContent.trim(),
content,
replyToId: this.replyTo()?.id,
})
);
@@ -376,6 +533,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.clearReply();
this.shouldScrollToBottom = true;
this.showNewMessagesBar.set(false);
if (this.pendingFiles.length > 0) {
// Wait briefly for the message to appear in the list, then attach
setTimeout(() => this.attachFilesToLastOwnMessage(content), 100);
}
}
onInputChange(): void {
@@ -545,6 +707,378 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.typingOthersCount.set(others);
}
// Markdown rendering
renderMarkdown(content: string): SafeHtml {
marked.setOptions({ breaks: true });
const html = marked.parse(content ?? '') as string;
// Sanitize to a DOM fragment so we can post-process disallowed images
const frag = DOMPurify.sanitize(html, { RETURN_DOM_FRAGMENT: true }) as DocumentFragment;
const container = document.createElement('div');
container.appendChild(frag);
const imgs = Array.from(container.querySelectorAll('img'));
for (const img of imgs) {
const src = img.getAttribute('src') || '';
const isData = src.startsWith('data:');
const isBlob = src.startsWith('blob:');
let isSameOrigin = false;
try {
const resolved = new URL(src, window.location.href);
isSameOrigin = resolved.origin === window.location.origin;
} catch {
// non-URL values are treated as not allowed
isSameOrigin = false;
}
// Rewrite external images to deferred proxy and load as blob to satisfy CSP
if (!(isData || isBlob || isSameOrigin)) {
const apiBase = this.serverDirectory.getApiBaseUrl();
// Robust join to avoid relative paths and double slashes
const baseWithSlash = apiBase.endsWith('/') ? apiBase : apiBase + '/';
const proxied = new URL(`image-proxy?url=${encodeURIComponent(src)}`, baseWithSlash).toString();
img.setAttribute('data-src', proxied);
img.classList.add('csp-img');
// Tiny transparent placeholder to avoid empty src fetch
img.setAttribute('src', 'data:image/gif;base64,R0lGODlhAQABAAAAACw=');
}
// Apply reasonable sizing and lazy loading
img.setAttribute('loading', 'lazy');
img.classList.add('rounded-md');
img.style.maxWidth = '100%';
img.style.height = 'auto';
img.style.maxHeight = '320px';
}
const safeHtml = DOMPurify.sanitize(container.innerHTML);
return this.sanitizer.bypassSecurityTrustHtml(safeHtml);
}
// Resolve images marked for CSP-safe loading by converting to blob URLs
private async loadCspImages(): Promise<void> {
const root = this.messagesContainer?.nativeElement;
if (!root) return;
const imgs = Array.from(root.querySelectorAll('img.csp-img[data-src]')) as HTMLImageElement[];
if (imgs.length === 0) return;
for (const img of imgs) {
const url = img.getAttribute('data-src');
if (!url) continue;
try {
// Use cached blob URL if available to avoid refetching
const cached = this.imageBlobCache.get(url);
if (cached) {
img.src = cached;
} else {
const res = await fetch(url, { mode: 'cors' });
if (!res.ok) continue;
const blob = await res.blob();
const obj = URL.createObjectURL(blob);
this.imageBlobCache.set(url, obj);
img.src = obj;
}
img.removeAttribute('data-src');
img.classList.remove('csp-img');
} catch {}
}
}
// Markdown toolbar actions
onEnter(evt: Event): void {
const e = evt as KeyboardEvent;
if (e.shiftKey) {
// allow newline
return;
}
e.preventDefault();
this.sendMessage();
}
private getSelection(): { start: number; end: number } {
const el = this.messageInputRef?.nativeElement;
return { start: el?.selectionStart ?? this.messageContent.length, end: el?.selectionEnd ?? this.messageContent.length };
}
private setSelection(start: number, end: number): void {
const el = this.messageInputRef?.nativeElement;
if (el) {
el.selectionStart = start;
el.selectionEnd = end;
el.focus();
}
}
applyInline(token: string): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'text';
const after = this.messageContent.slice(end);
const newText = `${before}${token}${selected}${token}${after}`;
this.messageContent = newText;
const cursor = before.length + token.length + selected.length + token.length;
this.setSelection(cursor, cursor);
}
applyPrefix(prefix: string): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'text';
const after = this.messageContent.slice(end);
const lines = selected.split('\n').map(line => `${prefix}${line}`);
const newSelected = lines.join('\n');
const newText = `${before}${newSelected}${after}`;
this.messageContent = newText;
const cursor = before.length + newSelected.length;
this.setSelection(cursor, cursor);
}
applyHeading(level: number): void {
const hashes = '#'.repeat(Math.max(1, Math.min(6, level)));
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'Heading';
const after = this.messageContent.slice(end);
const needsLeadingNewline = before.length > 0 && !before.endsWith('\n');
const needsTrailingNewline = after.length > 0 && !after.startsWith('\n');
const block = `${needsLeadingNewline ? '\n' : ''}${hashes} ${selected}${needsTrailingNewline ? '\n' : ''}`;
const newText = `${before}${block}${after}`;
this.messageContent = newText;
const cursor = before.length + block.length;
this.setSelection(cursor, cursor);
}
applyOrderedList(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'item\nitem';
const after = this.messageContent.slice(end);
const lines = selected.split('\n').map((line, i) => `${i + 1}. ${line}`);
const newSelected = lines.join('\n');
const newText = `${before}${newSelected}${after}`;
this.messageContent = newText;
const cursor = before.length + newSelected.length;
this.setSelection(cursor, cursor);
}
applyCodeBlock(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'code';
const after = this.messageContent.slice(end);
const fenced = `\n\n\`\`\`\n${selected}\n\`\`\`\n\n`;
const newText = `${before}${fenced}${after}`;
this.messageContent = newText;
const cursor = before.length + fenced.length;
this.setSelection(cursor, cursor);
}
applyLink(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'link';
const after = this.messageContent.slice(end);
const link = `[${selected}](https://)`;
const newText = `${before}${link}${after}`;
this.messageContent = newText;
const cursorStart = before.length + link.length - 1; // position inside url
this.setSelection(cursorStart - 8, cursorStart - 1);
}
applyImage(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const selected = this.messageContent.slice(start, end) || 'alt';
const after = this.messageContent.slice(end);
const img = `![${selected}](https://)`;
const newText = `${before}${img}${after}`;
this.messageContent = newText;
const cursorStart = before.length + img.length - 1;
this.setSelection(cursorStart - 8, cursorStart - 1);
}
applyHorizontalRule(): void {
const { start, end } = this.getSelection();
const before = this.messageContent.slice(0, start);
const after = this.messageContent.slice(end);
const hr = `\n\n---\n\n`;
const newText = `${before}${hr}${after}`;
this.messageContent = newText;
const cursor = before.length + hr.length;
this.setSelection(cursor, cursor);
}
// Attachments: drag/drop and rendering
onDragEnter(evt: DragEvent): void {
evt.preventDefault();
this.dragActive.set(true);
}
onDragOver(evt: DragEvent): void {
evt.preventDefault();
this.dragActive.set(true);
}
onDragLeave(evt: DragEvent): void {
evt.preventDefault();
this.dragActive.set(false);
}
onDrop(evt: DragEvent): void {
evt.preventDefault();
const files: File[] = [];
const items = evt.dataTransfer?.items;
if (items && items.length) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) files.push(file);
}
}
} else if (evt.dataTransfer?.files?.length) {
for (let i = 0; i < evt.dataTransfer.files.length; i++) {
files.push(evt.dataTransfer.files[i]);
}
}
files.forEach((f) => this.pendingFiles.push(f));
// Keep toolbar visible so user sees options
this.toolbarVisible.set(true);
this.dragActive.set(false);
}
getAttachments(messageId: string): Attachment[] {
return this.attachmentsSvc.getForMessage(messageId);
}
formatBytes(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let i = 0;
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
return `${size.toFixed(1)} ${units[i]}`;
}
formatSpeed(bps?: number): string {
if (!bps || bps <= 0) return '0 B/s';
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
let speed = bps;
let i = 0;
while (speed >= 1024 && i < units.length - 1) { speed /= 1024; i++; }
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[i]}`;
}
removePendingFile(file: File): void {
const idx = this.pendingFiles.findIndex((f) => f === file);
if (idx >= 0) {
this.pendingFiles.splice(idx, 1);
}
}
downloadAttachment(att: Attachment): void {
if (!att.available || !att.objectUrl) return;
const a = document.createElement('a');
a.href = att.objectUrl;
a.download = att.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
requestAttachment(att: Attachment, messageId: string): void {
this.attachmentsSvc.requestFile(messageId, att);
}
cancelAttachment(att: Attachment, messageId: string): void {
this.attachmentsSvc.cancelRequest(messageId, att);
}
isUploader(att: Attachment): boolean {
const myUserId = this.currentUser()?.id;
return !!att.uploaderPeerId && !!myUserId && att.uploaderPeerId === myUserId;
}
private attachFilesToLastOwnMessage(content: string): void {
const me = this.currentUser()?.id;
if (!me) return;
const msg = [...this.messages()].reverse().find((m) => m.senderId === me && m.content === content && !m.isDeleted);
if (!msg) {
// Retry shortly until message appears
setTimeout(() => this.attachFilesToLastOwnMessage(content), 150);
return;
}
const uploaderPeerId = this.currentUser()?.id || undefined;
this.attachmentsSvc.publishAttachments(msg.id, this.pendingFiles, uploaderPeerId);
this.pendingFiles = [];
}
// Detect image URLs and append Markdown embeds at the end
private appendImageMarkdown(content: string): string {
const imageUrlRegex = /(https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg|bmp|tiff)(?:\?[^\s)]*)?)/ig;
const urls = new Set<string>();
let match: RegExpExecArray | null;
const text = content;
while ((match = imageUrlRegex.exec(text)) !== null) {
urls.add(match[1]);
}
if (urls.size === 0) return content;
let append = '';
for (const url of urls) {
// Skip if already embedded as a Markdown image
const alreadyEmbedded = new RegExp(`!\\[[^\\]]*\\\\]\\(\s*${this.escapeRegex(url)}\s*\\)`, 'i').test(text);
if (!alreadyEmbedded) {
append += `\n![](${url})`;
}
}
return append ? content + append : content;
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
onContentClick(evt: Event): void {
const target = evt.target as HTMLElement;
if (target && target.tagName.toLowerCase() === 'a') {
evt.preventDefault();
const href = (target as HTMLAnchorElement).href;
try {
const w: any = window as any;
if (w?.process?.type === 'renderer' && typeof w.require === 'function') {
const { shell } = w.require('electron');
shell.openExternal(href);
} else {
window.open(href, '_blank', 'noopener,noreferrer');
}
} catch {
window.open(href, '_blank', 'noopener,noreferrer');
}
}
}
onInputFocus(): void {
this.toolbarVisible.set(true);
}
onInputBlur(): void {
setTimeout(() => {
if (!this.toolbarHovering) {
this.toolbarVisible.set(false);
}
}, 150);
}
onToolbarMouseEnter(): void {
this.toolbarHovering = true;
}
onToolbarMouseLeave(): void {
this.toolbarHovering = false;
if (document.activeElement !== this.messageInputRef?.nativeElement) {
this.toolbarVisible.set(false);
}
}
// Snackbar: scroll to latest
readLatest(): void {
this.shouldScrollToBottom = true;

View File

@@ -2,160 +2,294 @@ import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor } from '@ng-icons/lucide';
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 * as UsersActions from '../../store/users/users.actions';
import { WebRTCService } from '../../core/services/webrtc.service';
import { VoiceSessionService } from '../../core/services/voice-session.service';
import { VoiceControlsComponent } from '../voice/voice-controls.component';
type TabView = 'channels' | 'users';
@Component({
selector: 'app-rooms-side-panel',
standalone: true,
imports: [CommonModule, NgIcon, VoiceControlsComponent],
viewProviders: [
provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor })
provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers })
],
template: `
<aside class="w-80 bg-card h-full flex flex-col">
<div class="p-4 border-b border-border flex items-center justify-between">
<h3 class="font-semibold text-foreground">Rooms</h3>
<button class="p-2 hover:bg-secondary rounded" (click)="backToServers()">
<ng-icon name="lucideChevronLeft" class="w-4 h-4" />
<!-- Minimalistic header with tabs -->
<div class="border-b border-border">
<div class="flex items-center">
<!-- Tab buttons -->
<button
(click)="activeTab.set('channels')"
class="flex-1 flex items-center justify-center gap-1.5 px-3 py-3 text-sm transition-colors border-b-2"
[class.border-primary]="activeTab() === 'channels'"
[class.text-foreground]="activeTab() === 'channels'"
[class.border-transparent]="activeTab() !== 'channels'"
[class.text-muted-foreground]="activeTab() !== 'channels'"
[class.hover:text-foreground]="activeTab() !== 'channels'"
>
<ng-icon name="lucideHash" class="w-4 h-4" />
<span>Channels</span>
</button>
<button
(click)="activeTab.set('users')"
class="flex-1 flex items-center justify-center gap-1.5 px-3 py-3 text-sm transition-colors border-b-2"
[class.border-primary]="activeTab() === 'users'"
[class.text-foreground]="activeTab() === 'users'"
[class.border-transparent]="activeTab() !== 'users'"
[class.text-muted-foreground]="activeTab() !== 'users'"
[class.hover:text-foreground]="activeTab() !== 'users'"
>
<ng-icon name="lucideUsers" class="w-4 h-4" />
<span>Users</span>
<span class="text-xs px-1.5 py-0.5 rounded-full bg-primary/15 text-primary">{{ onlineUsers().length }}</span>
</button>
</div>
<div class="p-3 flex-1 overflow-auto">
<h4 class="text-xs text-muted-foreground mb-1">Chat Rooms</h4>
<div class="space-y-1">
<button class="w-full px-3 py-2 text-sm rounded bg-secondary hover:bg-secondary/80"># general</button>
<button class="w-full px-3 py-2 text-sm rounded bg-secondary hover:bg-secondary/80"># random</button>
</div>
</div>
<!-- Channels View -->
@if (activeTab() === 'channels') {
<div class="flex-1 overflow-auto">
<!-- Text Channels -->
<div class="p-3">
<h4 class="text-xs text-muted-foreground mb-1">Voice Rooms</h4>
<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>
</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>
@if (!voiceEnabled()) {
<p class="text-xs text-muted-foreground mb-2">Voice is disabled by host</p>
<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-3 py-2 text-sm rounded-md hover:bg-secondary/60 flex items-center justify-between"
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/30]="isCurrentRoom('general')"
[class.border-l-2]="isCurrentRoom('general')"
[class.border-primary]="isCurrentRoom('general')"
[class.bg-secondary/40]="isCurrentRoom('general')"
[disabled]="!voiceEnabled()"
>
<span class="text-foreground/90">🔊 General</span>
<span class="text-xs px-2 py-0.5 rounded bg-primary/15 text-primary">{{ voiceOccupancy('general') }}</span>
<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="mt-1 ml-6 space-y-1">
<div class="ml-5 mt-1 space-y-1">
@for (u of voiceUsersInRoom('general'); track u.id) {
<div class="flex items-center gap-2 p-2 rounded-md hover:bg-secondary/60 transition-colors">
<!-- Avatar with status-colored border -->
<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="avatar"
alt=""
class="w-7 h-7 rounded-full ring-2 object-cover"
[class.ring-green-500]="u.voiceState?.isConnected && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-yellow-500]="u.voiceState?.isConnected && u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-red-500]="u.voiceState?.isConnected && u.voiceState?.isDeafened"
[class.animate-pulse]="u.voiceState?.isSpeaking && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[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 font-semibold text-xs ring-2"
[class.ring-green-500]="u.voiceState?.isConnected && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-yellow-500]="u.voiceState?.isConnected && u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-red-500]="u.voiceState?.isConnected && u.voiceState?.isDeafened"
[class.animate-pulse]="u.voiceState?.isSpeaking && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
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 truncate text-foreground/90">{{ u.displayName }}</span>
<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>
<!-- AFK Voice -->
<div>
<button
class="w-full px-3 py-2 text-sm rounded-md hover:bg-secondary/60 flex items-center justify-between"
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/30]="isCurrentRoom('afk')"
[class.border-l-2]="isCurrentRoom('afk')"
[class.border-primary]="isCurrentRoom('afk')"
[class.bg-secondary/40]="isCurrentRoom('afk')"
[disabled]="!voiceEnabled()"
>
<span class="text-foreground/90">🔕 AFK</span>
<span class="text-xs px-2 py-0.5 rounded bg-primary/15 text-primary">{{ voiceOccupancy('afk') }}</span>
<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>
@if (voiceUsersInRoom('afk').length > 0) {
<div class="mt-1 ml-6 space-y-1">
<div class="ml-5 mt-1 space-y-1">
@for (u of voiceUsersInRoom('afk'); track u.id) {
<div class="flex items-center gap-2 p-2 rounded-md hover:bg-secondary/60 transition-colors">
<!-- Avatar with status-colored border -->
<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="avatar"
alt=""
class="w-7 h-7 rounded-full ring-2 object-cover"
[class.ring-green-500]="u.voiceState?.isConnected && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-yellow-500]="u.voiceState?.isConnected && u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-red-500]="u.voiceState?.isConnected && u.voiceState?.isDeafened"
[class.animate-pulse]="u.voiceState?.isSpeaking && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[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 font-semibold text-xs ring-2"
[class.ring-green-500]="u.voiceState?.isConnected && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-yellow-500]="u.voiceState?.isConnected && u.voiceState?.isMuted && !u.voiceState?.isDeafened"
[class.ring-red-500]="u.voiceState?.isConnected && u.voiceState?.isDeafened"
[class.animate-pulse]="u.voiceState?.isSpeaking && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
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 truncate text-foreground/90">{{ u.displayName }}</span>
</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>
}
</div>
}
</div>
</div>
</div>
<div class="p-3 border-t border-border">
<h4 class="text-xs text-muted-foreground mb-1">In Voice</h4>
<div class="space-y-1">
@for (u of onlineUsers(); track u.id) {
@if (u.voiceState?.isConnected) {
<div class="px-3 py-2 text-sm rounded-lg flex items-center gap-2 hover:bg-secondary/60">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span>
<span class="truncate">{{ u.displayName }}</span>
<span class="flex-1"></span>
@if (u.voiceState?.isMuted) {
<span class="inline-flex items-center justify-center w-8 h-8"><ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" /></span>
} @else if (u.voiceState?.isSpeaking) {
<span class="inline-flex items-center justify-center w-8 h-8"><ng-icon name="lucideMic" class="w-4 h-4 text-green-500 animate-pulse" /></span>
} @else if (u.voiceState?.isConnected) {
<span class="inline-flex items-center justify-center w-8 h-8"><ng-icon name="lucideMic" class="w-4 h-4 text-muted-foreground" /></span>
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
}
@if (isUserSharing(u.id)) {
<button class="ml-2 inline-flex items-center justify-center w-8 h-8 rounded hover:bg-secondary" (click)="viewShare(u.id)">
<ng-icon name="lucideMonitor" class="w-4 h-4 text-red-500" />
</div>
}
</div>
}
</div>
</div>
</div>
</div>
}
<!-- Users View -->
@if (activeTab() === 'users') {
<div class="flex-1 overflow-auto p-3">
<!-- Current User (You) -->
@if (currentUser()) {
<div class="mb-4">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">You</h4>
<div class="flex items-center gap-2 px-2 py-1.5 rounded bg-secondary/30">
<div class="relative">
@if (currentUser()?.avatarUrl) {
<img [src]="currentUser()?.avatarUrl" alt="" class="w-8 h-8 rounded-full object-cover" />
} @else {
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary text-sm font-medium">
{{ currentUser()?.displayName?.charAt(0)?.toUpperCase() || '?' }}
</div>
}
<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">{{ currentUser()?.displayName }}</p>
<div class="flex items-center gap-2">
@if (currentUser()?.voiceState?.isConnected) {
<p class="text-[10px] text-muted-foreground flex items-center gap-1">
<ng-icon name="lucideMic" class="w-2.5 h-2.5" />
In voice
</p>
}
@if (currentUser()?.screenShareState?.isSharing || (currentUser()?.id && isUserSharing(currentUser()!.id))) {
<span class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium flex items-center gap-1 animate-pulse">
<ng-icon name="lucideMonitor" class="w-2.5 h-2.5" />
LIVE
</span>
}
</div>
</div>
</div>
</div>
}
<!-- Other Online Users -->
@if (onlineUsersFiltered().length > 0) {
<div class="mb-4">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">
Online — {{ onlineUsersFiltered().length }}
</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="relative">
@if (user.avatarUrl) {
<img [src]="user.avatarUrl" alt="" class="w-8 h-8 rounded-full object-cover" />
} @else {
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary text-sm font-medium">
{{ user.displayName.charAt(0).toUpperCase() }}
</div>
}
<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-2">
@if (user.voiceState?.isConnected) {
<p class="text-[10px] text-muted-foreground flex items-center gap-1">
<ng-icon name="lucideMic" class="w-2.5 h-2.5" />
In voice
</p>
}
@if (user.screenShareState?.isSharing || isUserSharing(user.id)) {
<button
(click)="viewStream(user.id); $event.stopPropagation()"
class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium hover:bg-red-600 transition-colors flex items-center gap-1 animate-pulse"
>
<ng-icon name="lucideMonitor" class="w-2.5 h-2.5" />
LIVE
</button>
}
</div>
}
</div>
</div>
}
</div>
</div>
<!-- Voice controls pinned to sidebar bottom -->
}
<!-- No other users message -->
@if (onlineUsersFiltered().length === 0) {
<div class="text-center py-4 text-muted-foreground">
<p class="text-sm">No other users in this server</p>
</div>
}
</div>
}
<!-- Voice controls pinned to sidebar bottom (hidden when floating controls visible) -->
@if (voiceEnabled()) {
<div [class.invisible]="showFloatingControls()">
<app-voice-controls />
</div>
}
</aside>
`,
@@ -163,14 +297,20 @@ import { VoiceControlsComponent } from '../voice/voice-controls.component';
export class RoomsSidePanelComponent {
private store = inject(Store);
private webrtc = inject(WebRTCService);
private voiceSessionService = inject(VoiceSessionService);
activeTab = signal<TabView>('channels');
showFloatingControls = this.voiceSessionService.showFloatingControls;
onlineUsers = this.store.selectSignal(selectOnlineUsers);
currentUser = this.store.selectSignal(selectCurrentUser);
currentRoom = this.store.selectSignal(selectCurrentRoom);
// room selection is stored in voiceState.roomId in the store; no local tracking needed
backToServers() {
// Simple navigation: emit a custom event; wire to router/navigation in parent
window.dispatchEvent(new CustomEvent('navigate:servers'));
// Filter out current user from online users list
onlineUsersFiltered() {
const current = this.currentUser();
const currentId = current?.id;
const currentOderId = current?.oderId;
return this.onlineUsers().filter(u => u.id !== currentId && u.oderId !== currentOderId);
}
joinVoice(roomId: string) {
@@ -180,20 +320,53 @@ export class RoomsSidePanelComponent {
console.warn('Voice is disabled by room permissions');
return;
}
// Enable microphone and broadcast voice-state
this.webrtc.enableVoice().then(() => {
const current = this.currentUser();
if (current?.id) {
// Check if already connected to voice in a DIFFERENT server - must disconnect first
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 switching channels within the same server, just update the room
const isSwitchingChannels = current?.voiceState?.isConnected &&
current.voiceState.serverId === room?.id &&
current.voiceState.roomId !== roomId;
// Enable microphone and broadcast voice-state
const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice();
enableVoicePromise.then(() => {
if (current?.id && room) {
this.store.dispatch(UsersActions.updateVoiceState({
userId: current.id,
voiceState: { isConnected: true, isMuted: false, isDeafened: false, roomId: roomId }
voiceState: { isConnected: true, isMuted: current.voiceState?.isMuted ?? false, isDeafened: current.voiceState?.isDeafened ?? false, roomId: roomId, serverId: room.id }
}));
}
// Start voice heartbeat to broadcast presence every 5 seconds
this.webrtc.startVoiceHeartbeat(roomId);
this.webrtc.broadcastMessage({
type: 'voice-state',
oderId: current?.oderId || current?.id,
voiceState: { isConnected: true, isMuted: false, isDeafened: false, roomId: roomId }
displayName: current?.displayName || 'User',
voiceState: { isConnected: true, isMuted: current?.voiceState?.isMuted ?? false, isDeafened: current?.voiceState?.isDeafened ?? false, roomId: roomId, serverId: room?.id }
});
// Update voice session for floating controls
if (room) {
const voiceRoomName = roomId === 'general' ? '🔊 General' : roomId === 'afk' ? '🔕 AFK' : roomId;
this.voiceSessionService.startSession({
serverId: room.id,
serverName: room.name,
roomId: roomId,
roomName: voiceRoomName,
serverIcon: room.icon,
serverDescription: room.description,
serverRoute: `/room/${room.id}`,
});
}
}).catch((e) => console.error('Failed to join voice room', roomId, e));
}
@@ -202,6 +375,9 @@ export class RoomsSidePanelComponent {
// Only leave if currently in this room
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId)) return;
// Stop voice heartbeat
this.webrtc.stopVoiceHeartbeat();
// Disable voice locally
this.webrtc.disableVoice();
@@ -209,7 +385,7 @@ export class RoomsSidePanelComponent {
if (current?.id) {
this.store.dispatch(UsersActions.updateVoiceState({
userId: current.id,
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined }
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined }
}));
}
@@ -217,13 +393,23 @@ export class RoomsSidePanelComponent {
this.webrtc.broadcastMessage({
type: 'voice-state',
oderId: current?.oderId || current?.id,
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined }
displayName: current?.displayName || 'User',
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined }
});
// End voice session
this.voiceSessionService.endSession();
}
voiceOccupancy(roomId: string): number {
const users = this.onlineUsers();
return users.filter(u => !!u.voiceState?.isConnected && u.voiceState?.roomId === roomId).length;
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 &&
u.voiceState?.serverId === room?.id
).length;
}
viewShare(userId: string) {
@@ -233,23 +419,50 @@ export class RoomsSidePanelComponent {
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);
}
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) {
return this.onlineUsers().filter(u => !!u.voiceState?.isConnected && u.voiceState?.roomId === roomId);
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 &&
u.voiceState?.serverId === room?.id
);
}
isCurrentRoom(roomId: string): boolean {
const me = this.currentUser();
return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId);
const room = this.currentRoom();
// Check that voice is connected AND both the server AND room match
return !!(
me?.voiceState?.isConnected &&
me.voiceState?.roomId === roomId &&
me.voiceState?.serverId === room?.id
);
}
voiceEnabled(): boolean {

View File

@@ -7,6 +7,7 @@ import { lucidePlus } from '@ng-icons/lucide';
import { Room } from '../../core/models';
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import { VoiceSessionService } from '../../core/services/voice-session.service';
import * as RoomsActions from '../../store/rooms/rooms.actions';
@Component({
@@ -77,6 +78,7 @@ import * as RoomsActions from '../../store/rooms/rooms.actions';
export class ServersRailComponent {
private store = inject(Store);
private router = inject(Router);
private voiceSession = inject(VoiceSessionService);
savedRooms = this.store.selectSignal(selectSavedRooms);
currentRoom = this.store.selectSignal(selectCurrentRoom);
@@ -98,6 +100,11 @@ export class ServersRailComponent {
createServer(): void {
// Navigate to server list (has create button)
// Update voice session state if connected to voice
const voiceServerId = this.voiceSession.getVoiceServerId();
if (voiceServerId) {
this.voiceSession.setViewingVoiceServer(false);
}
this.router.navigate(['/search']);
}
@@ -110,6 +117,18 @@ export class ServersRailComponent {
this.router.navigate(['/login']);
return;
}
// Check if we're navigating to a different server while in voice
const voiceServerId = this.voiceSession.getVoiceServerId();
if (voiceServerId && voiceServerId !== room.id) {
// User is switching to a different server while connected to voice
// Update voice session to show floating controls
this.voiceSession.setViewingVoiceServer(false);
} else if (voiceServerId === room.id) {
// Navigating back to the voice-connected server
this.voiceSession.setViewingVoiceServer(true);
}
this.store.dispatch(RoomsActions.joinRoom({
roomId: room.id,
serverInfo: {

View File

@@ -0,0 +1,277 @@
import { Component, inject, signal, computed, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { Subscription } from 'rxjs';
import {
lucideMic,
lucideMicOff,
lucideMonitor,
lucideMonitorOff,
lucidePhoneOff,
lucideHeadphones,
lucideArrowLeft,
} from '@ng-icons/lucide';
import { WebRTCService } from '../../core/services/webrtc.service';
import { VoiceSessionService } from '../../core/services/voice-session.service';
import * as UsersActions from '../../store/users/users.actions';
import { selectCurrentUser } from '../../store/users/users.selectors';
@Component({
selector: 'app-floating-voice-controls',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [
provideIcons({
lucideMic,
lucideMicOff,
lucideMonitor,
lucideMonitorOff,
lucidePhoneOff,
lucideHeadphones,
lucideArrowLeft,
}),
],
template: `
@if (showFloatingControls()) {
<!-- Centered relative to rooms-side-panel (w-80 = 320px, so right-40 = 160px from right edge = center) -->
<div class="fixed bottom-4 right-40 translate-x-1/2 z-50 bg-card border border-border rounded-xl shadow-lg">
<div class="p-2 flex items-center gap-2">
<!-- Back to server button -->
<button
(click)="navigateToServer()"
class="flex items-center gap-1.5 px-2 py-1 bg-primary/10 hover:bg-primary/20 text-primary rounded-lg transition-colors"
title="Back to {{ voiceSession()?.serverName }}"
>
<ng-icon name="lucideArrowLeft" class="w-3.5 h-3.5" />
@if (voiceSession()?.serverIcon) {
<img
[src]="voiceSession()?.serverIcon"
class="w-5 h-5 rounded object-cover"
alt=""
/>
} @else {
<div class="w-5 h-5 rounded bg-primary/20 flex items-center justify-center text-[10px] font-semibold">
{{ voiceSession()?.serverName?.charAt(0)?.toUpperCase() || '?' }}
</div>
}
</button>
<!-- Voice status indicator -->
<div class="flex items-center gap-1 px-1">
<span class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span>
<span class="text-xs text-muted-foreground max-w-20 truncate">{{ voiceSession()?.roomName || 'Voice' }}</span>
</div>
<!-- Divider -->
<div class="w-px h-6 bg-border"></div>
<!-- Voice controls -->
<div class="flex items-center gap-1">
<button
(click)="toggleMute()"
[class]="getCompactButtonClass(isMuted())"
title="Toggle Mute"
>
<ng-icon [name]="isMuted() ? 'lucideMicOff' : 'lucideMic'" class="w-4 h-4" />
</button>
<button
(click)="toggleDeafen()"
[class]="getCompactButtonClass(isDeafened())"
title="Toggle Deafen"
>
<ng-icon name="lucideHeadphones" class="w-4 h-4" />
</button>
<button
(click)="toggleScreenShare()"
[class]="getCompactScreenShareClass()"
title="Toggle Screen Share"
>
<ng-icon [name]="isScreenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'" class="w-4 h-4" />
</button>
<button
(click)="disconnect()"
class="w-7 h-7 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
title="Disconnect"
>
<ng-icon name="lucidePhoneOff" class="w-4 h-4" />
</button>
</div>
</div>
</div>
}
`,
})
export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
private webrtcService = inject(WebRTCService);
private voiceSessionService = inject(VoiceSessionService);
private store = inject(Store);
currentUser = this.store.selectSignal(selectCurrentUser);
// Voice state from services
showFloatingControls = this.voiceSessionService.showFloatingControls;
voiceSession = this.voiceSessionService.voiceSession;
isConnected = computed(() => this.webrtcService.isVoiceConnected());
isMuted = signal(false);
isDeafened = signal(false);
isScreenSharing = signal(false);
private stateSubscription: Subscription | null = null;
ngOnInit(): void {
// Sync mute/deafen state from webrtc service
this.isMuted.set(this.webrtcService.isMuted());
this.isDeafened.set(this.webrtcService.isDeafened());
this.isScreenSharing.set(this.webrtcService.isScreenSharing());
}
ngOnDestroy(): void {
this.stateSubscription?.unsubscribe();
}
navigateToServer(): void {
this.voiceSessionService.navigateToVoiceServer();
}
toggleMute(): void {
this.isMuted.update(v => !v);
this.webrtcService.toggleMute(this.isMuted());
// Broadcast mute state change
this.webrtcService.broadcastMessage({
type: 'voice-state',
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
displayName: this.currentUser()?.displayName || 'User',
voiceState: {
isConnected: this.isConnected(),
isMuted: this.isMuted(),
isDeafened: this.isDeafened(),
},
});
}
toggleDeafen(): void {
this.isDeafened.update(v => !v);
this.webrtcService.toggleDeafen(this.isDeafened());
// When deafening, also mute
if (this.isDeafened() && !this.isMuted()) {
this.isMuted.set(true);
this.webrtcService.toggleMute(true);
}
// Broadcast deafen state change
this.webrtcService.broadcastMessage({
type: 'voice-state',
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
displayName: this.currentUser()?.displayName || 'User',
voiceState: {
isConnected: this.isConnected(),
isMuted: this.isMuted(),
isDeafened: this.isDeafened(),
},
});
}
async toggleScreenShare(): Promise<void> {
if (this.isScreenSharing()) {
this.webrtcService.stopScreenShare();
this.isScreenSharing.set(false);
} else {
try {
await this.webrtcService.startScreenShare(false);
this.isScreenSharing.set(true);
} catch (error) {
console.error('Failed to start screen share:', error);
}
}
}
disconnect(): void {
// Stop voice heartbeat
this.webrtcService.stopVoiceHeartbeat();
// Broadcast voice disconnect
this.webrtcService.broadcastMessage({
type: 'voice-state',
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
displayName: this.currentUser()?.displayName || 'User',
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
},
});
// Stop screen sharing if active
if (this.isScreenSharing()) {
this.webrtcService.stopScreenShare();
}
// Disable voice
this.webrtcService.disableVoice();
// Update user voice state in store
const user = this.currentUser();
if (user?.id) {
this.store.dispatch(UsersActions.updateVoiceState({
userId: user.id,
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined }
}));
}
// End voice session
this.voiceSessionService.endSession();
// Reset local state
this.isScreenSharing.set(false);
this.isMuted.set(false);
this.isDeafened.set(false);
}
getCompactButtonClass(isActive: boolean): string {
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
if (isActive) {
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
}
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
}
getCompactScreenShareClass(): string {
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
if (this.isScreenSharing()) {
return base + ' bg-primary/20 text-primary hover:bg-primary/30';
}
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
}
getMuteButtonClass(): string {
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
if (this.isMuted()) {
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
}
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
}
getDeafenButtonClass(): string {
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
if (this.isDeafened()) {
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
}
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
}
getScreenShareButtonClass(): string {
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
if (this.isScreenSharing()) {
return base + ' bg-primary/20 text-primary hover:bg-primary/30';
}
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
}
}

View File

@@ -55,7 +55,19 @@ import { User } from '../../core/models';
<span class="text-sm font-medium">Someone is sharing their screen</span>
</ng-template>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-3">
<!-- Viewer volume -->
<div class="flex items-center gap-2 text-white">
<span class="text-xs opacity-80">Volume: {{ screenVolume() }}%</span>
<input
type="range"
min="0"
max="100"
[value]="screenVolume()"
(input)="onScreenVolumeChange($event)"
class="w-32 accent-white"
/>
</div>
<button
(click)="toggleFullscreen()"
class="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors"
@@ -70,6 +82,15 @@ import { User } from '../../core/models';
<button
(click)="stopSharing()"
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
title="Stop sharing"
>
<ng-icon name="lucideX" class="w-4 h-4 text-white" />
</button>
} @else {
<button
(click)="stopWatching()"
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
title="Stop watching"
>
<ng-icon name="lucideX" class="w-4 h-4 text-white" />
</button>
@@ -98,9 +119,12 @@ export class ScreenShareViewerComponent implements OnDestroy {
onlineUsers = this.store.selectSignal(selectOnlineUsers);
activeScreenSharer = signal<User | null>(null);
// Track the userId we're currently watching (for detecting when they stop sharing)
private watchingUserId = signal<string | null>(null);
isFullscreen = signal(false);
hasStream = signal(false);
isLocalShare = signal(false);
screenVolume = signal(100);
private streamSubscription: (() => void) | null = null;
private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => {
@@ -110,11 +134,13 @@ export class ScreenShareViewerComponent implements OnDestroy {
const stream = this.webrtcService.getRemoteStream(userId);
const user = this.onlineUsers().find((u) => u.id === userId || u.oderId === userId) || null;
if (stream && stream.getVideoTracks().length > 0) {
if (user) this.setRemoteStream(stream, user);
else if (this.videoRef) {
if (user) {
this.setRemoteStream(stream, user);
} else if (this.videoRef) {
this.videoRef.nativeElement.srcObject = stream;
this.hasStream.set(true);
this.activeScreenSharer.set(null);
this.watchingUserId.set(userId);
this.isLocalShare.set(false);
}
}
@@ -128,37 +154,51 @@ export class ScreenShareViewerComponent implements OnDestroy {
effect(() => {
const screenStream = this.webrtcService.screenStream();
if (screenStream && this.videoRef) {
// Local share: always mute to avoid audio feedback
this.videoRef.nativeElement.srcObject = screenStream;
this.videoRef.nativeElement.volume = 0;
this.videoRef.nativeElement.muted = true;
this.isLocalShare.set(true);
this.hasStream.set(true);
} else if (this.videoRef) {
this.videoRef.nativeElement.srcObject = null;
this.isLocalShare.set(false);
this.hasStream.set(false);
}
});
// Watch for when the user we're watching stops sharing
effect(() => {
const watchingId = this.watchingUserId();
const isWatchingRemote = this.hasStream() && !this.isLocalShare();
// Only check if we're actually watching a remote stream
if (!watchingId || !isWatchingRemote) return;
const users = this.onlineUsers();
const watchedUser = users.find(u => u.id === watchingId || u.oderId === watchingId);
// If the user is no longer sharing (screenShareState.isSharing is false), stop watching
if (watchedUser && watchedUser.screenShareState?.isSharing === false) {
this.stopWatching();
return;
}
// Also check if the stream's video tracks are still available
const stream = this.webrtcService.getRemoteStream(watchingId);
const hasActiveVideo = stream?.getVideoTracks().some(t => t.readyState === 'live');
if (!hasActiveVideo) {
// Stream or video tracks are gone - stop watching
this.stopWatching();
}
});
// Subscribe to remote streams with video (screen shares)
// NOTE: We no longer auto-display remote streams. Users must click "Live" to view.
// This subscription is kept for potential future use (e.g., tracking available streams)
this.remoteStreamSub = this.webrtcService.onRemoteStream.subscribe(({ peerId, stream }) => {
try {
const hasVideo = stream.getVideoTracks().length > 0;
if (!hasVideo) return;
// Find the user by peerId (oderId)
const user = this.onlineUsers().find((u) => u.id === peerId || u.oderId === peerId) || null;
// If we have a video stream, show it in the viewer
if (user) {
this.setRemoteStream(stream, user);
} else {
// Fallback: still show the stream without user details
this.activeScreenSharer.set(null);
if (this.videoRef) {
this.videoRef.nativeElement.srcObject = stream;
this.hasStream.set(true);
}
}
} catch (e) {
console.error('Failed to display remote screen share:', e);
}
// Do nothing on remote stream - user must explicitly click "Live" to view
// The stream is still stored in webrtcService.remoteStreams and can be accessed via getRemoteStream()
});
// Listen for focus events dispatched by other components
@@ -209,12 +249,46 @@ export class ScreenShareViewerComponent implements OnDestroy {
this.isLocalShare.set(false);
}
// Stop watching a remote stream (for viewers)
stopWatching(): void {
if (this.videoRef) {
this.videoRef.nativeElement.srcObject = null;
}
this.activeScreenSharer.set(null);
this.watchingUserId.set(null);
this.hasStream.set(false);
this.isLocalShare.set(false);
if (this.isFullscreen()) {
this.exitFullscreen();
}
}
// Called by parent when a remote peer starts sharing
setRemoteStream(stream: MediaStream, user: User): void {
this.activeScreenSharer.set(user);
this.watchingUserId.set(user.id || user.oderId || null);
this.isLocalShare.set(false);
if (this.videoRef) {
this.videoRef.nativeElement.srcObject = stream;
const el = this.videoRef.nativeElement;
el.srcObject = stream;
// For autoplay policies, try muted first, then unmute per volume setting
el.muted = true;
el.volume = 0;
el.play().then(() => {
// After playback starts, apply viewer volume settings
el.volume = this.screenVolume() / 100;
el.muted = this.screenVolume() === 0;
}).catch(() => {
// If autoplay fails, keep muted to allow play, then apply volume
try {
el.muted = true;
el.volume = 0;
el.play().then(() => {
el.volume = this.screenVolume() / 100;
el.muted = this.screenVolume() === 0;
}).catch(() => {});
} catch {}
});
this.hasStream.set(true);
}
}
@@ -225,7 +299,22 @@ export class ScreenShareViewerComponent implements OnDestroy {
this.isLocalShare.set(true);
if (this.videoRef) {
this.videoRef.nativeElement.srcObject = stream;
// Always mute local share playback
this.videoRef.nativeElement.volume = 0;
this.videoRef.nativeElement.muted = true;
this.hasStream.set(true);
}
}
onScreenVolumeChange(event: Event): void {
const input = event.target as HTMLInputElement;
const val = Math.max(0, Math.min(100, parseInt(input.value, 10)));
this.screenVolume.set(val);
if (this.videoRef?.nativeElement) {
// Volume applies only to remote streams; keep local share muted
const isLocal = this.isLocalShare();
this.videoRef.nativeElement.volume = isLocal ? 0 : val / 100;
this.videoRef.nativeElement.muted = isLocal ? true : val === 0;
}
}
}

View File

@@ -16,6 +16,7 @@ import {
} from '@ng-icons/lucide';
import { WebRTCService } from '../../core/services/webrtc.service';
import { VoiceSessionService } from '../../core/services/voice-session.service';
import * as UsersActions from '../../store/users/users.actions';
import { selectCurrentUser } from '../../store/users/users.selectors';
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
@@ -44,6 +45,15 @@ interface AudioDevice {
],
template: `
<div class="bg-card border-t border-border p-4">
<!-- Connection Error Banner -->
@if (showConnectionError()) {
<div class="mb-3 p-2 bg-destructive/20 border border-destructive/30 rounded-lg flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-destructive animate-pulse"></span>
<span class="text-xs text-destructive">{{ connectionErrorMessage() || 'Connection error' }}</span>
<button (click)="retryConnection()" class="ml-auto text-xs text-destructive hover:underline">Retry</button>
</div>
}
<!-- User Info -->
<div class="flex items-center gap-3 mb-4">
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold text-sm">
@@ -54,7 +64,9 @@ interface AudioDevice {
{{ currentUser()?.displayName || 'Unknown' }}
</p>
<p class="text-xs text-muted-foreground">
@if (isConnected()) {
@if (showConnectionError()) {
<span class="text-destructive">● Connection Error</span>
} @else if (isConnected()) {
<span class="text-green-500">● Connected</span>
} @else {
<span class="text-muted-foreground">● Disconnected</span>
@@ -186,6 +198,12 @@ interface AudioDevice {
</select>
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-1">Include system audio when sharing screen</label>
<input type="checkbox" [checked]="includeSystemAudio()" (change)="onIncludeSystemAudioChange($event)" class="accent-primary" />
<p class="text-xs text-muted-foreground">Off by default; viewers will still hear your mic.</p>
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-1">
Audio Bitrate: {{ audioBitrate() }} kbps
@@ -210,6 +228,7 @@ interface AudioDevice {
})
export class VoiceControlsComponent implements OnInit, OnDestroy {
private webrtcService = inject(WebRTCService);
private voiceSessionService = inject(VoiceSessionService);
private store = inject(Store);
private remoteStreamSubscription: Subscription | null = null;
private remoteAudioElements = new Map<string, HTMLAudioElement>();
@@ -219,6 +238,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
currentRoom = this.store.selectSignal(selectCurrentRoom);
isConnected = computed(() => this.webrtcService.isVoiceConnected());
showConnectionError = computed(() => this.webrtcService.shouldShowConnectionError());
connectionErrorMessage = computed(() => this.webrtcService.connectionErrorMessage());
isMuted = signal(false);
isDeafened = signal(false);
isScreenSharing = signal(false);
@@ -232,10 +253,18 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
outputVolume = signal(100);
audioBitrate = signal(96);
latencyProfile = signal<'low'|'balanced'|'high'>('balanced');
includeSystemAudio = signal(false);
private SETTINGS_KEY = 'metoyou_voice_settings';
private voiceConnectedSubscription: Subscription | null = null;
async ngOnInit(): Promise<void> {
await this.loadAudioDevices();
// Load persisted voice settings and apply
this.loadSettings();
this.applySettingsToWebRTC();
// Subscribe to remote streams to play audio from peers
this.remoteStreamSubscription = this.webrtcService.onRemoteStream.subscribe(
({ peerId, stream }) => {
@@ -244,6 +273,16 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
}
);
// Subscribe to voice connected event to play pending streams and ensure all remote audio is set up
this.voiceConnectedSubscription = this.webrtcService.onVoiceConnected.subscribe(() => {
console.log('Voice connected, playing pending streams:', this.pendingRemoteStreams.size);
this.playPendingStreams();
// Also ensure all remote streams from connected peers are playing
// This handles the case where streams were received while voice was "connected"
// from a previous session but audio elements weren't set up
this.ensureAllRemoteStreamsPlaying();
});
// Clean up audio when peer disconnects
this.webrtcService.onPeerDisconnected.subscribe((peerId) => {
this.removeRemoteAudio(peerId);
@@ -263,6 +302,41 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.remoteAudioElements.clear();
this.remoteStreamSubscription?.unsubscribe();
this.voiceConnectedSubscription?.unsubscribe();
}
/**
* Play any pending remote streams that were received before we joined voice.
* This is called when voice is connected to ensure audio works on first join.
*/
private playPendingStreams(): void {
this.pendingRemoteStreams.forEach((stream, peerId) => {
console.log('Playing pending stream from:', peerId, 'tracks:', stream.getTracks().map(t => t.kind));
this.playRemoteAudio(peerId, stream);
});
this.pendingRemoteStreams.clear();
}
/**
* Ensure all remote streams from connected peers are playing.
* This handles cases where voice was reconnected and streams were received
* while the previous voice session was still "connected".
*/
private ensureAllRemoteStreamsPlaying(): void {
const connectedPeers = this.webrtcService.getConnectedPeers();
console.log('Ensuring audio for connected peers:', connectedPeers.length);
for (const peerId of connectedPeers) {
const stream = this.webrtcService.getRemoteStream(peerId);
if (stream && stream.getAudioTracks().length > 0) {
// Check if we already have an active audio element for this peer
const existingAudio = this.remoteAudioElements.get(peerId);
if (!existingAudio || existingAudio.srcObject !== stream) {
console.log('Setting up remote audio for peer:', peerId);
this.playRemoteAudio(peerId, stream);
}
}
}
}
private removeRemoteAudio(peerId: string): void {
@@ -295,9 +369,17 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
return;
}
// Check if audio track is live
const audioTrack = audioTracks[0];
if (audioTrack.readyState !== 'live') {
console.warn('Audio track not live from:', peerId, 'state:', audioTrack.readyState);
// Still try to play it - it might become live later
}
// Remove existing audio element for this peer if any
const existingAudio = this.remoteAudioElements.get(peerId);
if (existingAudio) {
console.log('Removing existing audio element for:', peerId);
existingAudio.srcObject = null;
existingAudio.remove();
}
@@ -315,7 +397,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// Play the audio
audio.play().then(() => {
console.log('Playing remote audio from:', peerId);
console.log('Playing remote audio from:', peerId, 'track state:', audioTrack.readyState, 'enabled:', audioTrack.enabled);
}).catch((error) => {
console.error('Failed to play remote audio from:', peerId, error);
});
@@ -343,6 +425,13 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
async connect(): Promise<void> {
try {
// Require signaling connectivity first
const ok = await this.webrtcService.ensureSignalingConnected();
if (!ok) {
console.error('Cannot join call: signaling server unreachable');
return;
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: this.selectedInputDevice() || undefined,
@@ -353,14 +442,20 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.webrtcService.setLocalStream(stream);
// Start voice heartbeat to broadcast presence every 5 seconds
const roomId = this.currentUser()?.voiceState?.roomId;
this.webrtcService.startVoiceHeartbeat(roomId);
// Broadcast voice state to other users
this.webrtcService.broadcastMessage({
type: 'voice-state',
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
displayName: this.currentUser()?.displayName || 'User',
voiceState: {
isConnected: true,
isMuted: this.isMuted(),
isDeafened: this.isDeafened(),
roomId,
},
});
@@ -370,16 +465,32 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.playRemoteAudio(peerId, pendingStream);
});
this.pendingRemoteStreams.clear();
// Persist settings after successful connection
this.saveSettings();
} catch (error) {
console.error('Failed to get user media:', error);
}
}
// Retry connection when there's a connection error
async retryConnection(): Promise<void> {
try {
await this.webrtcService.ensureSignalingConnected(10000);
} catch (e) {
console.error('Retry connection failed:', e);
}
}
disconnect(): void {
// Stop voice heartbeat
this.webrtcService.stopVoiceHeartbeat();
// Broadcast voice disconnect to other users
this.webrtcService.broadcastMessage({
type: 'voice-state',
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
displayName: this.currentUser()?.displayName || 'User',
voiceState: {
isConnected: false,
isMuted: false,
@@ -407,9 +518,13 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
if (user?.id) {
this.store.dispatch(UsersActions.updateVoiceState({
userId: user.id,
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined }
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined }
}));
}
// End voice session for floating controls
this.voiceSessionService.endSession();
this.isScreenSharing.set(false);
this.isMuted.set(false);
this.isDeafened.set(false);
@@ -423,6 +538,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.webrtcService.broadcastMessage({
type: 'voice-state',
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
displayName: this.currentUser()?.displayName || 'User',
voiceState: {
isConnected: this.isConnected(),
isMuted: this.isMuted(),
@@ -450,6 +566,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.webrtcService.broadcastMessage({
type: 'voice-state',
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
displayName: this.currentUser()?.displayName || 'User',
voiceState: {
isConnected: this.isConnected(),
isMuted: this.isMuted(),
@@ -464,7 +581,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.isScreenSharing.set(false);
} else {
try {
await this.webrtcService.startScreenShare();
await this.webrtcService.startScreenShare(this.includeSystemAudio());
this.isScreenSharing.set(true);
} catch (error) {
console.error('Failed to start screen share:', error);
@@ -488,16 +605,20 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.disconnect();
this.connect();
}
this.saveSettings();
}
onOutputDeviceChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.selectedOutputDevice.set(select.value);
this.applyOutputDevice();
this.saveSettings();
}
onInputVolumeChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.inputVolume.set(parseInt(input.value, 10));
this.saveSettings();
}
onOutputVolumeChange(event: Event): void {
@@ -509,6 +630,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.remoteAudioElements.forEach((audio) => {
audio.volume = this.outputVolume() / 100;
});
this.saveSettings();
}
onLatencyProfileChange(event: Event): void {
@@ -516,6 +638,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
const profile = select.value as 'low'|'balanced'|'high';
this.latencyProfile.set(profile);
this.webrtcService.setLatencyProfile(profile);
this.saveSettings();
}
onAudioBitrateChange(event: Event): void {
@@ -523,6 +646,71 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
const kbps = parseInt(input.value, 10);
this.audioBitrate.set(kbps);
this.webrtcService.setAudioBitrate(kbps);
this.saveSettings();
}
onIncludeSystemAudioChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.includeSystemAudio.set(!!input.checked);
this.saveSettings();
}
private loadSettings(): void {
try {
const raw = localStorage.getItem(this.SETTINGS_KEY);
if (!raw) return;
const s = JSON.parse(raw) as {
inputDevice?: string;
outputDevice?: string;
inputVolume?: number;
outputVolume?: number;
audioBitrate?: number;
latencyProfile?: 'low'|'balanced'|'high';
includeSystemAudio?: boolean;
};
if (s.inputDevice) this.selectedInputDevice.set(s.inputDevice);
if (s.outputDevice) this.selectedOutputDevice.set(s.outputDevice);
if (typeof s.inputVolume === 'number') this.inputVolume.set(s.inputVolume);
if (typeof s.outputVolume === 'number') this.outputVolume.set(s.outputVolume);
if (typeof s.audioBitrate === 'number') this.audioBitrate.set(s.audioBitrate);
if (s.latencyProfile) this.latencyProfile.set(s.latencyProfile);
if (typeof s.includeSystemAudio === 'boolean') this.includeSystemAudio.set(s.includeSystemAudio);
} catch {}
}
private saveSettings(): void {
try {
const s = {
inputDevice: this.selectedInputDevice(),
outputDevice: this.selectedOutputDevice(),
inputVolume: this.inputVolume(),
outputVolume: this.outputVolume(),
audioBitrate: this.audioBitrate(),
latencyProfile: this.latencyProfile(),
includeSystemAudio: this.includeSystemAudio(),
};
localStorage.setItem(this.SETTINGS_KEY, JSON.stringify(s));
} catch {}
}
private applySettingsToWebRTC(): void {
try {
this.webrtcService.setOutputVolume(this.outputVolume() / 100);
this.webrtcService.setAudioBitrate(this.audioBitrate());
this.webrtcService.setLatencyProfile(this.latencyProfile());
this.applyOutputDevice();
} catch {}
}
private async applyOutputDevice(): Promise<void> {
const deviceId = this.selectedOutputDevice();
if (!deviceId) return;
this.remoteAudioElements.forEach((audio) => {
const anyAudio = audio as any;
if (typeof anyAudio.setSinkId === 'function') {
anyAudio.setSinkId(deviceId).catch((e: any) => console.warn('Failed to setSinkId', e));
}
});
}
getMuteButtonClass(): string {

View File

@@ -10,6 +10,7 @@ import { selectCurrentRoom } from '../rooms/rooms.selectors';
import { DatabaseService } from '../../core/services/database.service';
import { WebRTCService } from '../../core/services/webrtc.service';
import { TimeSyncService } from '../../core/services/time-sync.service';
import { AttachmentService } from '../../core/services/attachment.service';
import { Message, Reaction } from '../../core/models';
import * as UsersActions from '../users/users.actions';
import * as RoomsActions from '../rooms/rooms.actions';
@@ -21,6 +22,7 @@ export class MessagesEffects {
private db = inject(DatabaseService);
private webrtc = inject(WebRTCService);
private timeSync = inject(TimeSyncService);
private attachments = inject(AttachmentService);
private readonly INVENTORY_LIMIT = 1000; // number of recent messages to consider
private readonly CHUNK_SIZE = 200; // chunk size for inventory/batch transfers
@@ -374,6 +376,24 @@ export class MessagesEffects {
}
break;
case 'file-announce':
this.attachments.handleFileAnnounce(event);
return of({ type: 'NO_OP' });
case 'file-chunk':
this.attachments.handleFileChunk(event);
return of({ type: 'NO_OP' });
case 'file-request':
// Uploader can fulfill request directly via AttachmentService
this.attachments.handleFileRequest(event);
return of({ type: 'NO_OP' });
case 'file-cancel':
// Stop any in-progress upload to the requester
this.attachments.handleFileCancel(event);
return of({ type: 'NO_OP' });
case 'message-edited':
if (event.messageId && event.content) {
this.db.updateMessage(event.messageId, { content: event.content, editedAt: event.editedAt });

View File

@@ -190,23 +190,32 @@ export class RoomsEffects {
ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess),
withLatestFrom(this.store.select(selectCurrentUser)),
tap(([{ room }, user]) => {
// Connect to signaling server
const wsUrl = this.serverDirectory.getWebSocketUrl();
console.log('Connecting to signaling server:', wsUrl);
const oderId = user?.oderId || this.webrtc.peerId();
const displayName = user?.displayName || 'Anonymous';
// Check if already connected to signaling server
if (this.webrtc.isConnected()) {
// Already connected - just switch to the new server without reconnecting WebSocket
// This preserves voice connections and peer state
console.log('Already connected to signaling, switching to room:', room.id);
this.webrtc.setCurrentServer(room.id);
this.webrtc.switchServer(room.id, oderId);
} else {
// Not connected - establish new connection
console.log('Connecting to signaling server:', wsUrl);
this.webrtc.connectToSignalingServer(wsUrl).subscribe({
next: (connected) => {
if (connected) {
console.log('Connected to signaling, identifying user and joining room');
this.webrtc.setCurrentServer(room.id);
const oderId = user?.oderId || this.webrtc.peerId();
const displayName = user?.displayName || 'Anonymous';
this.webrtc.identify(oderId, displayName);
this.webrtc.joinRoom(room.id, oderId);
}
},
error: (err) => console.error('Failed to connect to signaling server:', err),
});
}
this.router.navigate(['/room', room.id]);
})
@@ -498,8 +507,11 @@ export class RoomsEffects {
// Incoming P2P room/icon events
incomingRoomEvents$ = createEffect(() =>
this.webrtc.onMessageReceived.pipe(
withLatestFrom(this.store.select(selectCurrentRoom)),
mergeMap(([event, currentRoom]: [any, Room | null]) => {
withLatestFrom(
this.store.select(selectCurrentRoom),
this.store.select(selectAllUsers)
),
mergeMap(([event, currentRoom, allUsers]: [any, Room | null, any[]]) => {
if (!currentRoom) return of({ type: 'NO_OP' });
switch (event.type) {
@@ -507,8 +519,73 @@ export class RoomsEffects {
const userId = (event.fromPeerId as string) || (event.oderId as string);
const vs = event.voiceState as Partial<import('../../core/models').VoiceState> | undefined;
if (!userId || !vs) return of({ type: 'NO_OP' });
// Check if user exists in the store
const userExists = allUsers.some(u => u.id === userId || u.oderId === userId);
if (!userExists) {
// User doesn't exist yet - create them with the voice state
// This handles the race condition where voice-state arrives before server_users
const displayName = event.displayName || 'User';
return of(UsersActions.userJoined({
user: {
oderId: userId,
id: userId,
username: displayName.toLowerCase().replace(/\s+/g, '_'),
displayName: displayName,
status: 'online',
isOnline: true,
role: 'member',
joinedAt: Date.now(),
voiceState: {
isConnected: vs.isConnected ?? false,
isMuted: vs.isMuted ?? false,
isDeafened: vs.isDeafened ?? false,
isSpeaking: vs.isSpeaking ?? false,
isMutedByAdmin: vs.isMutedByAdmin,
volume: vs.volume,
roomId: vs.roomId,
serverId: vs.serverId,
},
},
}));
}
return of(UsersActions.updateVoiceState({ userId, voiceState: vs }));
}
case 'screen-state': {
const userId = (event.fromPeerId as string) || (event.oderId as string);
const isSharing = event.isScreenSharing as boolean | undefined;
if (!userId || isSharing === undefined) return of({ type: 'NO_OP' });
// Check if user exists in the store
const userExists = allUsers.some(u => u.id === userId || u.oderId === userId);
if (!userExists) {
// User doesn't exist yet - create them with the screen share state
const displayName = event.displayName || 'User';
return of(UsersActions.userJoined({
user: {
oderId: userId,
id: userId,
username: displayName.toLowerCase().replace(/\s+/g, '_'),
displayName: displayName,
status: 'online',
isOnline: true,
role: 'member',
joinedAt: Date.now(),
screenShareState: {
isSharing,
},
},
}));
}
return of(UsersActions.updateScreenShareState({
userId,
screenShareState: { isSharing },
}));
}
case 'room-settings-update': {
const settings: RoomSettings | undefined = event.settings;
if (!settings) return of({ type: 'NO_OP' });

View File

@@ -1,5 +1,5 @@
import { createAction, props } from '@ngrx/store';
import { User, BanEntry, VoiceState } from '../../core/models';
import { User, BanEntry, VoiceState, ScreenShareState } from '../../core/models';
// Load current user from storage
export const loadCurrentUser = createAction('[Users] Load Current User');
@@ -138,3 +138,9 @@ export const updateVoiceState = createAction(
'[Users] Update Voice State',
props<{ userId: string; voiceState: Partial<VoiceState> }>()
);
// Update screen share state for a user
export const updateScreenShareState = createAction(
'[Users] Update Screen Share State',
props<{ userId: string; screenShareState: Partial<ScreenShareState> }>()
);

View File

@@ -205,7 +205,30 @@ export const usersReducer = createReducer(
isSpeaking: voiceState.isSpeaking ?? prev.isSpeaking,
isMutedByAdmin: voiceState.isMutedByAdmin ?? prev.isMutedByAdmin,
volume: voiceState.volume ?? prev.volume,
roomId: voiceState.roomId ?? prev.roomId,
// Use explicit undefined check - if undefined is passed, clear the value
roomId: voiceState.roomId !== undefined ? voiceState.roomId : prev.roomId,
serverId: voiceState.serverId !== undefined ? voiceState.serverId : prev.serverId,
},
},
},
state
);
}),
// Update screen share state
on(UsersActions.updateScreenShareState, (state, { userId, screenShareState }) => {
const prev = state.entities[userId]?.screenShareState || {
isSharing: false,
};
return usersAdapter.updateOne(
{
id: userId,
changes: {
screenShareState: {
isSharing: screenShareState.isSharing ?? prev.isSharing,
streamId: screenShareState.streamId ?? prev.streamId,
sourceId: screenShareState.sourceId ?? prev.sourceId,
sourceName: screenShareState.sourceName ?? prev.sourceName,
},
},
},

View File

@@ -64,3 +64,27 @@
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Markdown & code block tweaks */
.prose pre {
overflow-x: auto;
max-width: 100%;
}
.prose code {
white-space: pre-wrap;
word-break: break-word;
}
.prose {
max-width: 100%;
}
.prose img {
max-width: 100%;
height: auto;
max-height: 320px;
border-radius: var(--radius);
display: block;
}
/* Highlight.js theme */
@import 'highlight.js/styles/github-dark.css';