Remote connection
This commit is contained in:
@@ -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');
|
||||
mainWindow.webContents.openDevTools();
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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
BIN
server/data/metoyou.sqlite
Normal file
Binary file not shown.
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
1
server/src/types/sqljs.d.ts
vendored
1
server/src/types/sqljs.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
418
src/app/core/services/attachment.service.ts
Normal file
418
src/app/core/services/attachment.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './database.service';
|
||||
export * from './webrtc.service';
|
||||
export * from './server-directory.service';
|
||||
export * from './voice-session.service';
|
||||
|
||||
@@ -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',
|
||||
|
||||
107
src/app/core/services/voice-session.service.ts
Normal file
107
src/app/core/services/voice-session.service.ts
Normal 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
@@ -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)">`</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"
|
||||
[(ngModel)]="messageContent"
|
||||
(keydown.enter)="sendMessage()"
|
||||
(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"
|
||||
/>
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="relative flex-1">
|
||||
<textarea
|
||||
#messageInputRef
|
||||
rows="2"
|
||||
[(ngModel)]="messageContent"
|
||||
(focus)="onInputFocus()"
|
||||
(blur)="onInputBlur()"
|
||||
(keydown.enter)="onEnter($event)"
|
||||
(input)="onInputChange()"
|
||||
(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 = ``;
|
||||
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`;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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" />
|
||||
</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>
|
||||
<!-- 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>
|
||||
<div class="p-3">
|
||||
<h4 class="text-xs text-muted-foreground mb-1">Voice Rooms</h4>
|
||||
@if (!voiceEnabled()) {
|
||||
<p class="text-xs text-muted-foreground mb-2">Voice is disabled by host</p>
|
||||
}
|
||||
<div class="space-y-1">
|
||||
<div>
|
||||
<button
|
||||
class="w-full px-3 py-2 text-sm rounded-md hover:bg-secondary/60 flex items-center justify-between"
|
||||
(click)="joinVoice('general')"
|
||||
[class.bg-secondary/30]="isCurrentRoom('general')"
|
||||
[class.border-l-2]="isCurrentRoom('general')"
|
||||
[class.border-primary]="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>
|
||||
</button>
|
||||
@if (voiceUsersInRoom('general').length > 0) {
|
||||
<div class="mt-1 ml-6 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 -->
|
||||
@if (u.avatarUrl) {
|
||||
<img
|
||||
[src]="u.avatarUrl"
|
||||
alt="avatar"
|
||||
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"
|
||||
/>
|
||||
} @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"
|
||||
>
|
||||
{{ u.displayName.charAt(0).toUpperCase() }}
|
||||
|
||||
<!-- Channels View -->
|
||||
@if (activeTab() === 'channels') {
|
||||
<div class="flex-1 overflow-auto">
|
||||
<!-- Text Channels -->
|
||||
<div class="p-3">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Text Channels</h4>
|
||||
<div class="space-y-1">
|
||||
<button class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center gap-2 text-left text-foreground/80 hover:text-foreground">
|
||||
<span class="text-muted-foreground">#</span> general
|
||||
</button>
|
||||
<button class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center gap-2 text-left text-foreground/80 hover:text-foreground">
|
||||
<span class="text-muted-foreground">#</span> random
|
||||
</button>
|
||||
</div>
|
||||
</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-sm text-muted-foreground px-2 py-2">Voice is disabled by host</p>
|
||||
}
|
||||
<div class="space-y-1">
|
||||
<!-- General Voice -->
|
||||
<div>
|
||||
<button
|
||||
class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center justify-between text-left"
|
||||
(click)="joinVoice('general')"
|
||||
[class.bg-secondary/40]="isCurrentRoom('general')"
|
||||
[disabled]="!voiceEnabled()"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-foreground/80">
|
||||
<span>🔊</span> General
|
||||
</span>
|
||||
@if (voiceOccupancy('general') > 0) {
|
||||
<span class="text-sm text-muted-foreground">{{ voiceOccupancy('general') }}</span>
|
||||
}
|
||||
</button>
|
||||
@if (voiceUsersInRoom('general').length > 0) {
|
||||
<div class="ml-5 mt-1 space-y-1">
|
||||
@for (u of voiceUsersInRoom('general'); track u.id) {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
|
||||
@if (u.avatarUrl) {
|
||||
<img
|
||||
[src]="u.avatarUrl"
|
||||
alt=""
|
||||
class="w-7 h-7 rounded-full ring-2 object-cover"
|
||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||
/>
|
||||
} @else {
|
||||
<div
|
||||
class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-medium ring-2"
|
||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||
>
|
||||
{{ u.displayName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
|
||||
<button
|
||||
(click)="viewStream(u.id); $event.stopPropagation()"
|
||||
class="px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
|
||||
>
|
||||
LIVE
|
||||
</button>
|
||||
}
|
||||
@if (u.voiceState?.isMuted) {
|
||||
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<span class="text-sm truncate text-foreground/90">{{ u.displayName }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="w-full px-3 py-2 text-sm rounded-md hover:bg-secondary/60 flex items-center justify-between"
|
||||
(click)="joinVoice('afk')"
|
||||
[class.bg-secondary/30]="isCurrentRoom('afk')"
|
||||
[class.border-l-2]="isCurrentRoom('afk')"
|
||||
[class.border-primary]="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>
|
||||
</button>
|
||||
@if (voiceUsersInRoom('afk').length > 0) {
|
||||
<div class="mt-1 ml-6 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 -->
|
||||
@if (u.avatarUrl) {
|
||||
<img
|
||||
[src]="u.avatarUrl"
|
||||
alt="avatar"
|
||||
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"
|
||||
/>
|
||||
} @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"
|
||||
>
|
||||
{{ u.displayName.charAt(0).toUpperCase() }}
|
||||
|
||||
<!-- AFK Voice -->
|
||||
<div>
|
||||
<button
|
||||
class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center justify-between text-left"
|
||||
(click)="joinVoice('afk')"
|
||||
[class.bg-secondary/40]="isCurrentRoom('afk')"
|
||||
[disabled]="!voiceEnabled()"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-foreground/80">
|
||||
<span>🔕</span> AFK
|
||||
</span>
|
||||
@if (voiceOccupancy('afk') > 0) {
|
||||
<span class="text-sm text-muted-foreground">{{ voiceOccupancy('afk') }}</span>
|
||||
}
|
||||
</button>
|
||||
@if (voiceUsersInRoom('afk').length > 0) {
|
||||
<div class="ml-5 mt-1 space-y-1">
|
||||
@for (u of voiceUsersInRoom('afk'); track u.id) {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
|
||||
@if (u.avatarUrl) {
|
||||
<img
|
||||
[src]="u.avatarUrl"
|
||||
alt=""
|
||||
class="w-7 h-7 rounded-full ring-2 object-cover"
|
||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||
/>
|
||||
} @else {
|
||||
<div
|
||||
class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-medium ring-2"
|
||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||
>
|
||||
{{ u.displayName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
|
||||
<button
|
||||
(click)="viewStream(u.id); $event.stopPropagation()"
|
||||
class="px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
|
||||
>
|
||||
LIVE
|
||||
</button>
|
||||
}
|
||||
@if (u.voiceState?.isMuted) {
|
||||
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<span class="text-sm truncate text-foreground/90">{{ u.displayName }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</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>
|
||||
}
|
||||
@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" />
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- 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>
|
||||
}
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
<!-- Voice controls pinned to sidebar bottom -->
|
||||
}
|
||||
|
||||
<!-- Voice controls pinned to sidebar bottom (hidden when floating controls visible) -->
|
||||
@if (voiceEnabled()) {
|
||||
<app-voice-controls />
|
||||
<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;
|
||||
}
|
||||
|
||||
const current = this.currentUser();
|
||||
|
||||
// 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
|
||||
this.webrtc.enableVoice().then(() => {
|
||||
const current = this.currentUser();
|
||||
if (current?.id) {
|
||||
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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
277
src/app/features/voice/floating-voice-controls.component.ts
Normal file
277
src/app/features/voice/floating-voice-controls.component.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to remote streams with video (screen shares)
|
||||
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);
|
||||
// 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 }) => {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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';
|
||||
|
||||
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),
|
||||
});
|
||||
// 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);
|
||||
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' });
|
||||
|
||||
@@ -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> }>()
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user