Remote connection
This commit is contained in:
@@ -1,8 +1,15 @@
|
|||||||
const { app, BrowserWindow, ipcMain, desktopCapturer } = require('electron');
|
const { app, BrowserWindow, ipcMain, desktopCapturer } = require('electron');
|
||||||
|
const fs = require('fs');
|
||||||
|
const fsp = fs.promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
let mainWindow;
|
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() {
|
function createWindow() {
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1400,
|
width: 1400,
|
||||||
@@ -23,7 +30,9 @@ function createWindow() {
|
|||||||
// In development, load from Angular dev server
|
// In development, load from Angular dev server
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
mainWindow.loadURL('http://localhost:4200');
|
mainWindow.loadURL('http://localhost:4200');
|
||||||
|
if (process.env.DEBUG_DEVTOOLS === '1') {
|
||||||
mainWindow.webContents.openDevTools();
|
mainWindow.webContents.openDevTools();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// In production, load the built Angular app
|
// In production, load the built Angular app
|
||||||
// The dist folder is at the project root, not in electron folder
|
// 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', () => {
|
ipcMain.handle('get-app-data-path', () => {
|
||||||
return app.getPath('userData');
|
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),
|
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
||||||
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
||||||
fileExists: (filePath) => ipcRenderer.invoke('file-exists', filePath),
|
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",
|
"server:dev": "cd server && npm run dev",
|
||||||
"electron": "ng build && electron .",
|
"electron": "ng build && electron .",
|
||||||
"electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && cross-env NODE_ENV=development 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: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": "npm run build:prod && electron-builder",
|
||||||
"electron:build:win": "npm run build:prod && electron-builder --win",
|
"electron:build:win": "npm run build:prod && electron-builder --win",
|
||||||
@@ -63,7 +63,10 @@
|
|||||||
"simple-peer": "^9.11.1",
|
"simple-peer": "^9.11.1",
|
||||||
"sql.js": "^1.13.0",
|
"sql.js": "^1.13.0",
|
||||||
"tslib": "^2.3.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": {
|
"devDependencies": {
|
||||||
"@angular/build": "^21.0.4",
|
"@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": [],
|
"tags": [],
|
||||||
"createdAt": 1766902260144,
|
"createdAt": 1766902260144,
|
||||||
"lastSeen": 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/cors": "^2.8.14",
|
||||||
"@types/express": "^4.17.18",
|
"@types/express": "^4.17.18",
|
||||||
"@types/node": "^20.8.0",
|
"@types/node": "^20.8.0",
|
||||||
|
"@types/sql.js": "^1.4.9",
|
||||||
"@types/uuid": "^9.0.4",
|
"@types/uuid": "^9.0.4",
|
||||||
"@types/ws": "^8.5.8",
|
"@types/ws": "^8.5.8",
|
||||||
"ts-node-dev": "^2.0.0",
|
"ts-node-dev": "^2.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
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
|
// Simple SQLite via sql.js persisted to a single file
|
||||||
const DATA_DIR = path.join(process.cwd(), 'data');
|
const DATA_DIR = path.join(process.cwd(), 'data');
|
||||||
@@ -11,7 +11,7 @@ function ensureDataDir() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let SQL: any = null;
|
let SQL: any = null;
|
||||||
let db: Database | null = null;
|
let db: any | null = null;
|
||||||
|
|
||||||
export async function initDB(): Promise<void> {
|
export async function initDB(): Promise<void> {
|
||||||
if (db) return;
|
if (db) return;
|
||||||
@@ -56,7 +56,7 @@ export interface AuthUser {
|
|||||||
|
|
||||||
export async function getUserByUsername(username: string): Promise<AuthUser | null> {
|
export async function getUserByUsername(username: string): Promise<AuthUser | null> {
|
||||||
if (!db) await initDB();
|
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]);
|
stmt.bind([username]);
|
||||||
let row: AuthUser | null = null;
|
let row: AuthUser | null = null;
|
||||||
if (stmt.step()) {
|
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> {
|
export async function getUserById(id: string): Promise<AuthUser | null> {
|
||||||
if (!db) await initDB();
|
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]);
|
stmt.bind([id]);
|
||||||
let row: AuthUser | null = null;
|
let row: AuthUser | null = null;
|
||||||
if (stmt.step()) {
|
if (stmt.step()) {
|
||||||
|
|||||||
@@ -88,6 +88,46 @@ app.get('/api/time', (req, res) => {
|
|||||||
res.json({ now: Date.now() });
|
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)
|
// Basic auth (demo - file-based)
|
||||||
interface AuthUser { id: string; username: string; passwordHash: string; displayName: string; createdAt: number; }
|
interface AuthUser { id: string; username: string; passwordHash: string; displayName: string; createdAt: number; }
|
||||||
let authUsers: AuthUser[] = [];
|
let authUsers: AuthUser[] = [];
|
||||||
@@ -320,33 +360,33 @@ const server = createServer(app);
|
|||||||
const wss = new WebSocketServer({ server });
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
wss.on('connection', (ws: WebSocket) => {
|
wss.on('connection', (ws: WebSocket) => {
|
||||||
const oderId = uuidv4();
|
const connectionId = uuidv4();
|
||||||
connectedUsers.set(oderId, { oderId, ws });
|
connectedUsers.set(connectionId, { oderId: connectionId, ws });
|
||||||
|
|
||||||
ws.on('message', (data) => {
|
ws.on('message', (data) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(data.toString());
|
const message = JSON.parse(data.toString());
|
||||||
handleWebSocketMessage(oderId, message);
|
handleWebSocketMessage(connectionId, message);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Invalid WebSocket message:', err);
|
console.error('Invalid WebSocket message:', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
const user = connectedUsers.get(oderId);
|
const user = connectedUsers.get(connectionId);
|
||||||
if (user?.serverId) {
|
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, {
|
broadcastToServer(user.serverId, {
|
||||||
type: 'user_left',
|
type: 'user_left',
|
||||||
oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
}, oderId);
|
}, user.oderId);
|
||||||
}
|
}
|
||||||
connectedUsers.delete(oderId);
|
connectedUsers.delete(connectionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send connection acknowledgment
|
// Send connection acknowledgment with the connectionId (client will identify with their actual oderId)
|
||||||
ws.send(JSON.stringify({ type: 'connected', oderId, serverTime: Date.now() }));
|
ws.send(JSON.stringify({ type: 'connected', connectionId, serverTime: Date.now() }));
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleWebSocketMessage(connectionId: string, message: any): void {
|
function handleWebSocketMessage(connectionId: string, message: any): void {
|
||||||
@@ -366,14 +406,15 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
|
|||||||
case 'join_server':
|
case 'join_server':
|
||||||
user.serverId = message.serverId;
|
user.serverId = message.serverId;
|
||||||
connectedUsers.set(connectionId, user);
|
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)
|
// 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())
|
const usersInServer = Array.from(connectedUsers.values())
|
||||||
.filter(u => u.serverId === message.serverId && u.oderId !== user.oderId)
|
.filter(u => u.serverId === message.serverId && u.oderId !== user.oderId && u.displayName)
|
||||||
.map(u => ({ oderId: u.oderId, displayName: 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({
|
user.ws.send(JSON.stringify({
|
||||||
type: 'server_users',
|
type: 'server_users',
|
||||||
users: usersInServer,
|
users: usersInServer,
|
||||||
@@ -383,7 +424,7 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
|
|||||||
broadcastToServer(message.serverId, {
|
broadcastToServer(message.serverId, {
|
||||||
type: 'user_joined',
|
type: 'user_joined',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName,
|
displayName: user.displayName || 'Anonymous',
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -396,7 +437,7 @@ function handleWebSocketMessage(connectionId: string, message: any): void {
|
|||||||
broadcastToServer(oldServerId, {
|
broadcastToServer(oldServerId, {
|
||||||
type: 'user_left',
|
type: 'user_left',
|
||||||
oderId: user.oderId,
|
oderId: user.oderId,
|
||||||
displayName: user.displayName,
|
displayName: user.displayName || 'Anonymous',
|
||||||
}, user.oderId);
|
}, user.oderId);
|
||||||
}
|
}
|
||||||
break;
|
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' {
|
declare module 'sql.js' {
|
||||||
export default function initSqlJs(config?: { locateFile?: (file: string) => string }): Promise<any>;
|
export default function initSqlJs(config?: { locateFile?: (file: string) => string }): Promise<any>;
|
||||||
export type Database = any;
|
export type Database = any;
|
||||||
|
|||||||
@@ -11,4 +11,7 @@
|
|||||||
<router-outlet />
|
<router-outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Floating voice controls - shown when connected to voice and navigated away from server -->
|
||||||
|
<app-floating-voice-controls />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ import { Store } from '@ngrx/store';
|
|||||||
import { DatabaseService } from './core/services/database.service';
|
import { DatabaseService } from './core/services/database.service';
|
||||||
import { ServerDirectoryService } from './core/services/server-directory.service';
|
import { ServerDirectoryService } from './core/services/server-directory.service';
|
||||||
import { TimeSyncService } from './core/services/time-sync.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 { ServersRailComponent } from './features/servers/servers-rail.component';
|
||||||
import { TitleBarComponent } from './features/shell/title-bar.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 UsersActions from './store/users/users.actions';
|
||||||
import * as RoomsActions from './store/rooms/rooms.actions';
|
import * as RoomsActions from './store/rooms/rooms.actions';
|
||||||
|
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [CommonModule, RouterOutlet, ServersRailComponent, TitleBarComponent],
|
imports: [CommonModule, RouterOutlet, ServersRailComponent, TitleBarComponent, FloatingVoiceControlsComponent],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss',
|
styleUrl: './app.scss',
|
||||||
})
|
})
|
||||||
@@ -23,6 +26,9 @@ export class App implements OnInit {
|
|||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private servers = inject(ServerDirectoryService);
|
private servers = inject(ServerDirectoryService);
|
||||||
private timeSync = inject(TimeSyncService);
|
private timeSync = inject(TimeSyncService);
|
||||||
|
private voiceSession = inject(VoiceSessionService);
|
||||||
|
|
||||||
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
// Initialize database
|
// 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) => {
|
this.router.events.subscribe((evt) => {
|
||||||
if (evt instanceof NavigationEnd) {
|
if (evt instanceof NavigationEnd) {
|
||||||
const url = evt.urlAfterRedirects || evt.url;
|
const url = evt.urlAfterRedirects || evt.url;
|
||||||
// Store room route or search
|
// Store room route or search
|
||||||
localStorage.setItem('metoyou_lastVisitedRoute', url);
|
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;
|
isSpeaking: boolean;
|
||||||
isMutedByAdmin?: boolean;
|
isMutedByAdmin?: boolean;
|
||||||
volume?: number;
|
volume?: number;
|
||||||
|
/** The voice channel/room ID within a server (e.g., 'general', 'afk') */
|
||||||
roomId?: string;
|
roomId?: string;
|
||||||
|
/** The server ID the user is connected to voice in */
|
||||||
|
serverId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScreenShareState {
|
export interface ScreenShareState {
|
||||||
@@ -126,7 +129,7 @@ export interface SignalingMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatEvent {
|
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;
|
messageId?: string;
|
||||||
message?: Message;
|
message?: Message;
|
||||||
reaction?: Reaction;
|
reaction?: Reaction;
|
||||||
@@ -140,10 +143,12 @@ export interface ChatEvent {
|
|||||||
editedAt?: number;
|
editedAt?: number;
|
||||||
deletedBy?: string;
|
deletedBy?: string;
|
||||||
oderId?: string;
|
oderId?: string;
|
||||||
|
displayName?: string;
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
settings?: RoomSettings;
|
settings?: RoomSettings;
|
||||||
voiceState?: Partial<VoiceState>;
|
voiceState?: Partial<VoiceState>;
|
||||||
|
isScreenSharing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerInfo {
|
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 './database.service';
|
||||||
export * from './webrtc.service';
|
export * from './webrtc.service';
|
||||||
export * from './server-directory.service';
|
export * from './server-directory.service';
|
||||||
|
export * from './voice-session.service';
|
||||||
|
|||||||
@@ -70,7 +70,13 @@ export class ServerDirectoryService {
|
|||||||
|
|
||||||
private get baseUrl(): string {
|
private get baseUrl(): string {
|
||||||
const active = this.activeServer();
|
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
|
// Expose API base URL for consumers that need to call server endpoints
|
||||||
@@ -83,7 +89,13 @@ export class ServerDirectoryService {
|
|||||||
const newServer: ServerEndpoint = {
|
const newServer: ServerEndpoint = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
name: server.name,
|
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,
|
isActive: false,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
status: 'unknown',
|
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 { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
|
import { AttachmentService, Attachment } from '../../core/services/attachment.service';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import {
|
import {
|
||||||
lucideSend,
|
lucideSend,
|
||||||
@@ -20,6 +21,10 @@ import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../store/users/u
|
|||||||
import { Message } from '../../core/models';
|
import { Message } from '../../core/models';
|
||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
import { Subscription } from 'rxjs';
|
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 = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥', '👀'];
|
const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥', '👀'];
|
||||||
|
|
||||||
@@ -99,9 +104,73 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<p class="text-foreground break-words whitespace-pre-wrap mt-1">
|
<div class="prose prose-invert mt-1 break-words" (click)="onContentClick($event)" [innerHTML]="renderMarkdown(message.content)"></div>
|
||||||
{{ message.content }}
|
@if (getAttachments(message.id).length > 0) {
|
||||||
</p>
|
<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 -->
|
<!-- Reactions -->
|
||||||
@@ -216,20 +285,71 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
|
|||||||
</div>
|
</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 -->
|
<!-- Message Input -->
|
||||||
<div class="p-4 border-t border-border">
|
<div class="p-4 border-t border-border">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2 items-start">
|
||||||
<input
|
<div class="relative flex-1">
|
||||||
type="text"
|
<textarea
|
||||||
|
#messageInputRef
|
||||||
|
rows="2"
|
||||||
[(ngModel)]="messageContent"
|
[(ngModel)]="messageContent"
|
||||||
(keydown.enter)="sendMessage()"
|
(focus)="onInputFocus()"
|
||||||
|
(blur)="onInputBlur()"
|
||||||
|
(keydown.enter)="onEnter($event)"
|
||||||
(input)="onInputChange()"
|
(input)="onInputChange()"
|
||||||
placeholder="Type a message..."
|
(dragenter)="onDragEnter($event)"
|
||||||
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"
|
(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
|
<button
|
||||||
(click)="sendMessage()"
|
(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"
|
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" />
|
<ng-icon name="lucideSend" class="w-4 h-4" />
|
||||||
@@ -241,9 +361,13 @@ const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥',
|
|||||||
})
|
})
|
||||||
export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestroy {
|
export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestroy {
|
||||||
@ViewChild('messagesContainer') messagesContainer!: ElementRef;
|
@ViewChild('messagesContainer') messagesContainer!: ElementRef;
|
||||||
|
@ViewChild('messageInputRef') messageInputRef!: ElementRef<HTMLTextAreaElement>;
|
||||||
|
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private webrtc = inject(WebRTCService);
|
private webrtc = inject(WebRTCService);
|
||||||
|
private sanitizer = inject(DomSanitizer);
|
||||||
|
private serverDirectory = inject(ServerDirectoryService);
|
||||||
|
private attachmentsSvc = inject(AttachmentService);
|
||||||
|
|
||||||
messages = this.store.selectSignal(selectAllMessages);
|
messages = this.store.selectSignal(selectAllMessages);
|
||||||
loading = this.store.selectSignal(selectMessagesLoading);
|
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 readonly typingTTL = 3000; // ms to keep a user as typing
|
||||||
private lastMessageCount = 0;
|
private lastMessageCount = 0;
|
||||||
private initialScrollPending = true;
|
private initialScrollPending = true;
|
||||||
|
pendingFiles: File[] = [];
|
||||||
|
|
||||||
// Track typing users by name and expire them
|
// Track typing users by name and expire them
|
||||||
private typingMap = new Map<string, { name: string; expiresAt: number }>();
|
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)
|
// Stable reference time to avoid ExpressionChanged errors (updated every minute)
|
||||||
nowRef = signal<number>(Date.now());
|
nowRef = signal<number>(Date.now());
|
||||||
private nowTimer: any;
|
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
|
// Messages length signal and effect to detect new messages without blocking change detection
|
||||||
messagesLength = computed(() => this.messages().length);
|
messagesLength = computed(() => this.messages().length);
|
||||||
@@ -316,8 +452,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
this.scrollToBottom();
|
this.scrollToBottom();
|
||||||
this.showNewMessagesBar.set(false);
|
this.showNewMessagesBar.set(false);
|
||||||
this.lastMessageCount = this.messages().length;
|
this.lastMessageCount = this.messages().length;
|
||||||
|
this.loadCspImages();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Attempt to resolve any deferred images after each check
|
||||||
|
this.loadCspImages();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
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
|
// Periodically purge expired typing entries
|
||||||
const purge = () => {
|
const purge = () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -363,11 +517,14 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
}
|
}
|
||||||
|
|
||||||
sendMessage(): void {
|
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(
|
this.store.dispatch(
|
||||||
MessagesActions.sendMessage({
|
MessagesActions.sendMessage({
|
||||||
content: this.messageContent.trim(),
|
content,
|
||||||
replyToId: this.replyTo()?.id,
|
replyToId: this.replyTo()?.id,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -376,6 +533,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
this.clearReply();
|
this.clearReply();
|
||||||
this.shouldScrollToBottom = true;
|
this.shouldScrollToBottom = true;
|
||||||
this.showNewMessagesBar.set(false);
|
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 {
|
onInputChange(): void {
|
||||||
@@ -545,6 +707,378 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
this.typingOthersCount.set(others);
|
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
|
// Snackbar: scroll to latest
|
||||||
readLatest(): void {
|
readLatest(): void {
|
||||||
this.shouldScrollToBottom = true;
|
this.shouldScrollToBottom = true;
|
||||||
|
|||||||
@@ -2,160 +2,294 @@ import { Component, inject, signal } from '@angular/core';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
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 { selectOnlineUsers, selectCurrentUser } from '../../store/users/users.selectors';
|
||||||
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||||
import * as UsersActions from '../../store/users/users.actions';
|
import * as UsersActions from '../../store/users/users.actions';
|
||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
|
import { VoiceSessionService } from '../../core/services/voice-session.service';
|
||||||
import { VoiceControlsComponent } from '../voice/voice-controls.component';
|
import { VoiceControlsComponent } from '../voice/voice-controls.component';
|
||||||
|
|
||||||
|
type TabView = 'channels' | 'users';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-rooms-side-panel',
|
selector: 'app-rooms-side-panel',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, NgIcon, VoiceControlsComponent],
|
imports: [CommonModule, NgIcon, VoiceControlsComponent],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor })
|
provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers })
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<aside class="w-80 bg-card h-full flex flex-col">
|
<aside class="w-80 bg-card h-full flex flex-col">
|
||||||
<div class="p-4 border-b border-border flex items-center justify-between">
|
<!-- Minimalistic header with tabs -->
|
||||||
<h3 class="font-semibold text-foreground">Rooms</h3>
|
<div class="border-b border-border">
|
||||||
<button class="p-2 hover:bg-secondary rounded" (click)="backToServers()">
|
<div class="flex items-center">
|
||||||
<ng-icon name="lucideChevronLeft" class="w-4 h-4" />
|
<!-- 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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 flex-1 overflow-auto">
|
|
||||||
<h4 class="text-xs text-muted-foreground mb-1">Chat Rooms</h4>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<button class="w-full px-3 py-2 text-sm rounded bg-secondary hover:bg-secondary/80"># general</button>
|
|
||||||
<button class="w-full px-3 py-2 text-sm rounded bg-secondary hover:bg-secondary/80"># random</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Channels View -->
|
||||||
|
@if (activeTab() === 'channels') {
|
||||||
|
<div class="flex-1 overflow-auto">
|
||||||
|
<!-- Text Channels -->
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<h4 class="text-xs text-muted-foreground mb-1">Voice Rooms</h4>
|
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Text Channels</h4>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<button class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center gap-2 text-left text-foreground/80 hover:text-foreground">
|
||||||
|
<span class="text-muted-foreground">#</span> general
|
||||||
|
</button>
|
||||||
|
<button class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center gap-2 text-left text-foreground/80 hover:text-foreground">
|
||||||
|
<span class="text-muted-foreground">#</span> random
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Voice Channels -->
|
||||||
|
<div class="p-3 pt-0">
|
||||||
|
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Voice Channels</h4>
|
||||||
@if (!voiceEnabled()) {
|
@if (!voiceEnabled()) {
|
||||||
<p class="text-xs text-muted-foreground mb-2">Voice is disabled by host</p>
|
<p class="text-sm text-muted-foreground px-2 py-2">Voice is disabled by host</p>
|
||||||
}
|
}
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
|
<!-- General Voice -->
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
class="w-full px-3 py-2 text-sm rounded-md hover:bg-secondary/60 flex items-center justify-between"
|
class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center justify-between text-left"
|
||||||
(click)="joinVoice('general')"
|
(click)="joinVoice('general')"
|
||||||
[class.bg-secondary/30]="isCurrentRoom('general')"
|
[class.bg-secondary/40]="isCurrentRoom('general')"
|
||||||
[class.border-l-2]="isCurrentRoom('general')"
|
|
||||||
[class.border-primary]="isCurrentRoom('general')"
|
|
||||||
[disabled]="!voiceEnabled()"
|
[disabled]="!voiceEnabled()"
|
||||||
>
|
>
|
||||||
<span class="text-foreground/90">🔊 General</span>
|
<span class="flex items-center gap-2 text-foreground/80">
|
||||||
<span class="text-xs px-2 py-0.5 rounded bg-primary/15 text-primary">{{ voiceOccupancy('general') }}</span>
|
<span>🔊</span> General
|
||||||
|
</span>
|
||||||
|
@if (voiceOccupancy('general') > 0) {
|
||||||
|
<span class="text-sm text-muted-foreground">{{ voiceOccupancy('general') }}</span>
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
@if (voiceUsersInRoom('general').length > 0) {
|
@if (voiceUsersInRoom('general').length > 0) {
|
||||||
<div class="mt-1 ml-6 space-y-1">
|
<div class="ml-5 mt-1 space-y-1">
|
||||||
@for (u of voiceUsersInRoom('general'); track u.id) {
|
@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">
|
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
|
||||||
<!-- Avatar with status-colored border -->
|
|
||||||
@if (u.avatarUrl) {
|
@if (u.avatarUrl) {
|
||||||
<img
|
<img
|
||||||
[src]="u.avatarUrl"
|
[src]="u.avatarUrl"
|
||||||
alt="avatar"
|
alt=""
|
||||||
class="w-7 h-7 rounded-full ring-2 object-cover"
|
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-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||||
[class.ring-yellow-500]="u.voiceState?.isConnected && u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||||
[class.ring-red-500]="u.voiceState?.isConnected && u.voiceState?.isDeafened"
|
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||||
[class.animate-pulse]="u.voiceState?.isSpeaking && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
|
||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<div
|
<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="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?.isConnected && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||||
[class.ring-yellow-500]="u.voiceState?.isConnected && u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||||
[class.ring-red-500]="u.voiceState?.isConnected && u.voiceState?.isDeafened"
|
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||||
[class.animate-pulse]="u.voiceState?.isSpeaking && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
|
||||||
>
|
>
|
||||||
{{ u.displayName.charAt(0).toUpperCase() }}
|
{{ u.displayName.charAt(0).toUpperCase() }}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<span class="text-sm truncate text-foreground/90">{{ u.displayName }}</span>
|
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||||
|
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
|
||||||
|
<button
|
||||||
|
(click)="viewStream(u.id); $event.stopPropagation()"
|
||||||
|
class="px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
LIVE
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (u.voiceState?.isMuted) {
|
||||||
|
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- AFK Voice -->
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
class="w-full px-3 py-2 text-sm rounded-md hover:bg-secondary/60 flex items-center justify-between"
|
class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center justify-between text-left"
|
||||||
(click)="joinVoice('afk')"
|
(click)="joinVoice('afk')"
|
||||||
[class.bg-secondary/30]="isCurrentRoom('afk')"
|
[class.bg-secondary/40]="isCurrentRoom('afk')"
|
||||||
[class.border-l-2]="isCurrentRoom('afk')"
|
|
||||||
[class.border-primary]="isCurrentRoom('afk')"
|
|
||||||
[disabled]="!voiceEnabled()"
|
[disabled]="!voiceEnabled()"
|
||||||
>
|
>
|
||||||
<span class="text-foreground/90">🔕 AFK</span>
|
<span class="flex items-center gap-2 text-foreground/80">
|
||||||
<span class="text-xs px-2 py-0.5 rounded bg-primary/15 text-primary">{{ voiceOccupancy('afk') }}</span>
|
<span>🔕</span> AFK
|
||||||
|
</span>
|
||||||
|
@if (voiceOccupancy('afk') > 0) {
|
||||||
|
<span class="text-sm text-muted-foreground">{{ voiceOccupancy('afk') }}</span>
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
@if (voiceUsersInRoom('afk').length > 0) {
|
@if (voiceUsersInRoom('afk').length > 0) {
|
||||||
<div class="mt-1 ml-6 space-y-1">
|
<div class="ml-5 mt-1 space-y-1">
|
||||||
@for (u of voiceUsersInRoom('afk'); track u.id) {
|
@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">
|
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
|
||||||
<!-- Avatar with status-colored border -->
|
|
||||||
@if (u.avatarUrl) {
|
@if (u.avatarUrl) {
|
||||||
<img
|
<img
|
||||||
[src]="u.avatarUrl"
|
[src]="u.avatarUrl"
|
||||||
alt="avatar"
|
alt=""
|
||||||
class="w-7 h-7 rounded-full ring-2 object-cover"
|
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-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||||
[class.ring-yellow-500]="u.voiceState?.isConnected && u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||||
[class.ring-red-500]="u.voiceState?.isConnected && u.voiceState?.isDeafened"
|
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||||
[class.animate-pulse]="u.voiceState?.isSpeaking && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
|
||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<div
|
<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="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?.isConnected && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||||
[class.ring-yellow-500]="u.voiceState?.isConnected && u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||||
[class.ring-red-500]="u.voiceState?.isConnected && u.voiceState?.isDeafened"
|
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||||
[class.animate-pulse]="u.voiceState?.isSpeaking && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
|
||||||
>
|
>
|
||||||
{{ u.displayName.charAt(0).toUpperCase() }}
|
{{ u.displayName.charAt(0).toUpperCase() }}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<span class="text-sm truncate text-foreground/90">{{ u.displayName }}</span>
|
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||||
</div>
|
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
|
||||||
|
<button
|
||||||
|
(click)="viewStream(u.id); $event.stopPropagation()"
|
||||||
|
class="px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
LIVE
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="p-3 border-t border-border">
|
|
||||||
<h4 class="text-xs text-muted-foreground mb-1">In Voice</h4>
|
|
||||||
<div class="space-y-1">
|
|
||||||
@for (u of onlineUsers(); track u.id) {
|
|
||||||
@if (u.voiceState?.isConnected) {
|
|
||||||
<div class="px-3 py-2 text-sm rounded-lg flex items-center gap-2 hover:bg-secondary/60">
|
|
||||||
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
|
||||||
<span class="truncate">{{ u.displayName }}</span>
|
|
||||||
<span class="flex-1"></span>
|
|
||||||
@if (u.voiceState?.isMuted) {
|
@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>
|
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
|
||||||
} @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)) {
|
</div>
|
||||||
<button class="ml-2 inline-flex items-center justify-center w-8 h-8 rounded hover:bg-secondary" (click)="viewShare(u.id)">
|
}
|
||||||
<ng-icon name="lucideMonitor" class="w-4 h-4 text-red-500" />
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- 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>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Voice controls pinned to sidebar bottom -->
|
}
|
||||||
|
|
||||||
|
<!-- No other users message -->
|
||||||
|
@if (onlineUsersFiltered().length === 0) {
|
||||||
|
<div class="text-center py-4 text-muted-foreground">
|
||||||
|
<p class="text-sm">No other users in this server</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Voice controls pinned to sidebar bottom (hidden when floating controls visible) -->
|
||||||
@if (voiceEnabled()) {
|
@if (voiceEnabled()) {
|
||||||
|
<div [class.invisible]="showFloatingControls()">
|
||||||
<app-voice-controls />
|
<app-voice-controls />
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</aside>
|
</aside>
|
||||||
`,
|
`,
|
||||||
@@ -163,14 +297,20 @@ import { VoiceControlsComponent } from '../voice/voice-controls.component';
|
|||||||
export class RoomsSidePanelComponent {
|
export class RoomsSidePanelComponent {
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private webrtc = inject(WebRTCService);
|
private webrtc = inject(WebRTCService);
|
||||||
|
private voiceSessionService = inject(VoiceSessionService);
|
||||||
|
|
||||||
|
activeTab = signal<TabView>('channels');
|
||||||
|
showFloatingControls = this.voiceSessionService.showFloatingControls;
|
||||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
// room selection is stored in voiceState.roomId in the store; no local tracking needed
|
|
||||||
|
|
||||||
backToServers() {
|
// Filter out current user from online users list
|
||||||
// Simple navigation: emit a custom event; wire to router/navigation in parent
|
onlineUsersFiltered() {
|
||||||
window.dispatchEvent(new CustomEvent('navigate:servers'));
|
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) {
|
joinVoice(roomId: string) {
|
||||||
@@ -180,20 +320,53 @@ export class RoomsSidePanelComponent {
|
|||||||
console.warn('Voice is disabled by room permissions');
|
console.warn('Voice is disabled by room permissions');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Enable microphone and broadcast voice-state
|
|
||||||
this.webrtc.enableVoice().then(() => {
|
|
||||||
const current = this.currentUser();
|
const current = this.currentUser();
|
||||||
if (current?.id) {
|
|
||||||
|
// Check if already connected to voice in a DIFFERENT server - must disconnect first
|
||||||
|
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
|
||||||
|
// Connected to voice in a different server - user must disconnect first
|
||||||
|
console.warn('Already connected to voice in another server. Disconnect first before joining.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If switching channels within the same server, just update the room
|
||||||
|
const isSwitchingChannels = current?.voiceState?.isConnected &&
|
||||||
|
current.voiceState.serverId === room?.id &&
|
||||||
|
current.voiceState.roomId !== roomId;
|
||||||
|
|
||||||
|
// Enable microphone and broadcast voice-state
|
||||||
|
const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice();
|
||||||
|
|
||||||
|
enableVoicePromise.then(() => {
|
||||||
|
if (current?.id && room) {
|
||||||
this.store.dispatch(UsersActions.updateVoiceState({
|
this.store.dispatch(UsersActions.updateVoiceState({
|
||||||
userId: current.id,
|
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({
|
this.webrtc.broadcastMessage({
|
||||||
type: 'voice-state',
|
type: 'voice-state',
|
||||||
oderId: current?.oderId || current?.id,
|
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));
|
}).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
|
// Only leave if currently in this room
|
||||||
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId)) return;
|
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId)) return;
|
||||||
|
|
||||||
|
// Stop voice heartbeat
|
||||||
|
this.webrtc.stopVoiceHeartbeat();
|
||||||
|
|
||||||
// Disable voice locally
|
// Disable voice locally
|
||||||
this.webrtc.disableVoice();
|
this.webrtc.disableVoice();
|
||||||
|
|
||||||
@@ -209,7 +385,7 @@ export class RoomsSidePanelComponent {
|
|||||||
if (current?.id) {
|
if (current?.id) {
|
||||||
this.store.dispatch(UsersActions.updateVoiceState({
|
this.store.dispatch(UsersActions.updateVoiceState({
|
||||||
userId: current.id,
|
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({
|
this.webrtc.broadcastMessage({
|
||||||
type: 'voice-state',
|
type: 'voice-state',
|
||||||
oderId: current?.oderId || current?.id,
|
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 {
|
voiceOccupancy(roomId: string): number {
|
||||||
const users = this.onlineUsers();
|
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) {
|
viewShare(userId: string) {
|
||||||
@@ -233,23 +419,50 @@ export class RoomsSidePanelComponent {
|
|||||||
window.dispatchEvent(evt);
|
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 {
|
isUserSharing(userId: string): boolean {
|
||||||
const me = this.currentUser();
|
const me = this.currentUser();
|
||||||
if (me?.id === userId) {
|
if (me?.id === userId) {
|
||||||
// Local user: use signal
|
// Local user: use signal
|
||||||
return this.webrtc.isScreenSharing();
|
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);
|
const stream = this.webrtc.getRemoteStream(userId);
|
||||||
return !!stream && stream.getVideoTracks().length > 0;
|
return !!stream && stream.getVideoTracks().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
voiceUsersInRoom(roomId: string) {
|
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 {
|
isCurrentRoom(roomId: string): boolean {
|
||||||
const me = this.currentUser();
|
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 {
|
voiceEnabled(): boolean {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { lucidePlus } from '@ng-icons/lucide';
|
|||||||
|
|
||||||
import { Room } from '../../core/models';
|
import { Room } from '../../core/models';
|
||||||
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||||
|
import { VoiceSessionService } from '../../core/services/voice-session.service';
|
||||||
import * as RoomsActions from '../../store/rooms/rooms.actions';
|
import * as RoomsActions from '../../store/rooms/rooms.actions';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -77,6 +78,7 @@ import * as RoomsActions from '../../store/rooms/rooms.actions';
|
|||||||
export class ServersRailComponent {
|
export class ServersRailComponent {
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
private voiceSession = inject(VoiceSessionService);
|
||||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
@@ -98,6 +100,11 @@ export class ServersRailComponent {
|
|||||||
|
|
||||||
createServer(): void {
|
createServer(): void {
|
||||||
// Navigate to server list (has create button)
|
// 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']);
|
this.router.navigate(['/search']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +117,18 @@ export class ServersRailComponent {
|
|||||||
this.router.navigate(['/login']);
|
this.router.navigate(['/login']);
|
||||||
return;
|
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({
|
this.store.dispatch(RoomsActions.joinRoom({
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
serverInfo: {
|
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>
|
<span class="text-sm font-medium">Someone is sharing their screen</span>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</div>
|
</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
|
<button
|
||||||
(click)="toggleFullscreen()"
|
(click)="toggleFullscreen()"
|
||||||
class="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors"
|
class="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors"
|
||||||
@@ -70,6 +82,15 @@ import { User } from '../../core/models';
|
|||||||
<button
|
<button
|
||||||
(click)="stopSharing()"
|
(click)="stopSharing()"
|
||||||
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
|
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" />
|
<ng-icon name="lucideX" class="w-4 h-4 text-white" />
|
||||||
</button>
|
</button>
|
||||||
@@ -98,9 +119,12 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
|||||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||||
|
|
||||||
activeScreenSharer = signal<User | null>(null);
|
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);
|
isFullscreen = signal(false);
|
||||||
hasStream = signal(false);
|
hasStream = signal(false);
|
||||||
isLocalShare = signal(false);
|
isLocalShare = signal(false);
|
||||||
|
screenVolume = signal(100);
|
||||||
|
|
||||||
private streamSubscription: (() => void) | null = null;
|
private streamSubscription: (() => void) | null = null;
|
||||||
private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => {
|
private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => {
|
||||||
@@ -110,11 +134,13 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
|||||||
const stream = this.webrtcService.getRemoteStream(userId);
|
const stream = this.webrtcService.getRemoteStream(userId);
|
||||||
const user = this.onlineUsers().find((u) => u.id === userId || u.oderId === userId) || null;
|
const user = this.onlineUsers().find((u) => u.id === userId || u.oderId === userId) || null;
|
||||||
if (stream && stream.getVideoTracks().length > 0) {
|
if (stream && stream.getVideoTracks().length > 0) {
|
||||||
if (user) this.setRemoteStream(stream, user);
|
if (user) {
|
||||||
else if (this.videoRef) {
|
this.setRemoteStream(stream, user);
|
||||||
|
} else if (this.videoRef) {
|
||||||
this.videoRef.nativeElement.srcObject = stream;
|
this.videoRef.nativeElement.srcObject = stream;
|
||||||
this.hasStream.set(true);
|
this.hasStream.set(true);
|
||||||
this.activeScreenSharer.set(null);
|
this.activeScreenSharer.set(null);
|
||||||
|
this.watchingUserId.set(userId);
|
||||||
this.isLocalShare.set(false);
|
this.isLocalShare.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,37 +154,51 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
|||||||
effect(() => {
|
effect(() => {
|
||||||
const screenStream = this.webrtcService.screenStream();
|
const screenStream = this.webrtcService.screenStream();
|
||||||
if (screenStream && this.videoRef) {
|
if (screenStream && this.videoRef) {
|
||||||
|
// Local share: always mute to avoid audio feedback
|
||||||
this.videoRef.nativeElement.srcObject = screenStream;
|
this.videoRef.nativeElement.srcObject = screenStream;
|
||||||
|
this.videoRef.nativeElement.volume = 0;
|
||||||
|
this.videoRef.nativeElement.muted = true;
|
||||||
|
this.isLocalShare.set(true);
|
||||||
this.hasStream.set(true);
|
this.hasStream.set(true);
|
||||||
} else if (this.videoRef) {
|
} else if (this.videoRef) {
|
||||||
this.videoRef.nativeElement.srcObject = null;
|
this.videoRef.nativeElement.srcObject = null;
|
||||||
|
this.isLocalShare.set(false);
|
||||||
this.hasStream.set(false);
|
this.hasStream.set(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Watch for when the user we're watching stops sharing
|
||||||
|
effect(() => {
|
||||||
|
const watchingId = this.watchingUserId();
|
||||||
|
const isWatchingRemote = this.hasStream() && !this.isLocalShare();
|
||||||
|
|
||||||
|
// Only check if we're actually watching a remote stream
|
||||||
|
if (!watchingId || !isWatchingRemote) return;
|
||||||
|
|
||||||
|
const users = this.onlineUsers();
|
||||||
|
const watchedUser = users.find(u => u.id === watchingId || u.oderId === watchingId);
|
||||||
|
|
||||||
|
// If the user is no longer sharing (screenShareState.isSharing is false), stop watching
|
||||||
|
if (watchedUser && watchedUser.screenShareState?.isSharing === false) {
|
||||||
|
this.stopWatching();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check if the stream's video tracks are still available
|
||||||
|
const stream = this.webrtcService.getRemoteStream(watchingId);
|
||||||
|
const hasActiveVideo = stream?.getVideoTracks().some(t => t.readyState === 'live');
|
||||||
|
if (!hasActiveVideo) {
|
||||||
|
// Stream or video tracks are gone - stop watching
|
||||||
|
this.stopWatching();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Subscribe to remote streams with video (screen shares)
|
// 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 }) => {
|
this.remoteStreamSub = this.webrtcService.onRemoteStream.subscribe(({ peerId, stream }) => {
|
||||||
try {
|
// Do nothing on remote stream - user must explicitly click "Live" to view
|
||||||
const hasVideo = stream.getVideoTracks().length > 0;
|
// The stream is still stored in webrtcService.remoteStreams and can be accessed via getRemoteStream()
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for focus events dispatched by other components
|
// Listen for focus events dispatched by other components
|
||||||
@@ -209,12 +249,46 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
|||||||
this.isLocalShare.set(false);
|
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
|
// Called by parent when a remote peer starts sharing
|
||||||
setRemoteStream(stream: MediaStream, user: User): void {
|
setRemoteStream(stream: MediaStream, user: User): void {
|
||||||
this.activeScreenSharer.set(user);
|
this.activeScreenSharer.set(user);
|
||||||
|
this.watchingUserId.set(user.id || user.oderId || null);
|
||||||
this.isLocalShare.set(false);
|
this.isLocalShare.set(false);
|
||||||
if (this.videoRef) {
|
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);
|
this.hasStream.set(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,7 +299,22 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
|||||||
this.isLocalShare.set(true);
|
this.isLocalShare.set(true);
|
||||||
if (this.videoRef) {
|
if (this.videoRef) {
|
||||||
this.videoRef.nativeElement.srcObject = stream;
|
this.videoRef.nativeElement.srcObject = stream;
|
||||||
|
// Always mute local share playback
|
||||||
|
this.videoRef.nativeElement.volume = 0;
|
||||||
|
this.videoRef.nativeElement.muted = true;
|
||||||
this.hasStream.set(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';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
|
import { VoiceSessionService } from '../../core/services/voice-session.service';
|
||||||
import * as UsersActions from '../../store/users/users.actions';
|
import * as UsersActions from '../../store/users/users.actions';
|
||||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||||
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||||
@@ -44,6 +45,15 @@ interface AudioDevice {
|
|||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<div class="bg-card border-t border-border p-4">
|
<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 -->
|
<!-- User Info -->
|
||||||
<div class="flex items-center gap-3 mb-4">
|
<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">
|
<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' }}
|
{{ currentUser()?.displayName || 'Unknown' }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-muted-foreground">
|
<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>
|
<span class="text-green-500">● Connected</span>
|
||||||
} @else {
|
} @else {
|
||||||
<span class="text-muted-foreground">● Disconnected</span>
|
<span class="text-muted-foreground">● Disconnected</span>
|
||||||
@@ -186,6 +198,12 @@ interface AudioDevice {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label class="block text-sm font-medium text-foreground mb-1">
|
<label class="block text-sm font-medium text-foreground mb-1">
|
||||||
Audio Bitrate: {{ audioBitrate() }} kbps
|
Audio Bitrate: {{ audioBitrate() }} kbps
|
||||||
@@ -210,6 +228,7 @@ interface AudioDevice {
|
|||||||
})
|
})
|
||||||
export class VoiceControlsComponent implements OnInit, OnDestroy {
|
export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||||
private webrtcService = inject(WebRTCService);
|
private webrtcService = inject(WebRTCService);
|
||||||
|
private voiceSessionService = inject(VoiceSessionService);
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private remoteStreamSubscription: Subscription | null = null;
|
private remoteStreamSubscription: Subscription | null = null;
|
||||||
private remoteAudioElements = new Map<string, HTMLAudioElement>();
|
private remoteAudioElements = new Map<string, HTMLAudioElement>();
|
||||||
@@ -219,6 +238,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
isConnected = computed(() => this.webrtcService.isVoiceConnected());
|
isConnected = computed(() => this.webrtcService.isVoiceConnected());
|
||||||
|
showConnectionError = computed(() => this.webrtcService.shouldShowConnectionError());
|
||||||
|
connectionErrorMessage = computed(() => this.webrtcService.connectionErrorMessage());
|
||||||
isMuted = signal(false);
|
isMuted = signal(false);
|
||||||
isDeafened = signal(false);
|
isDeafened = signal(false);
|
||||||
isScreenSharing = signal(false);
|
isScreenSharing = signal(false);
|
||||||
@@ -232,10 +253,18 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
outputVolume = signal(100);
|
outputVolume = signal(100);
|
||||||
audioBitrate = signal(96);
|
audioBitrate = signal(96);
|
||||||
latencyProfile = signal<'low'|'balanced'|'high'>('balanced');
|
latencyProfile = signal<'low'|'balanced'|'high'>('balanced');
|
||||||
|
includeSystemAudio = signal(false);
|
||||||
|
|
||||||
|
private SETTINGS_KEY = 'metoyou_voice_settings';
|
||||||
|
private voiceConnectedSubscription: Subscription | null = null;
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
await this.loadAudioDevices();
|
await this.loadAudioDevices();
|
||||||
|
|
||||||
|
// Load persisted voice settings and apply
|
||||||
|
this.loadSettings();
|
||||||
|
this.applySettingsToWebRTC();
|
||||||
|
|
||||||
// Subscribe to remote streams to play audio from peers
|
// Subscribe to remote streams to play audio from peers
|
||||||
this.remoteStreamSubscription = this.webrtcService.onRemoteStream.subscribe(
|
this.remoteStreamSubscription = this.webrtcService.onRemoteStream.subscribe(
|
||||||
({ peerId, stream }) => {
|
({ 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
|
// Clean up audio when peer disconnects
|
||||||
this.webrtcService.onPeerDisconnected.subscribe((peerId) => {
|
this.webrtcService.onPeerDisconnected.subscribe((peerId) => {
|
||||||
this.removeRemoteAudio(peerId);
|
this.removeRemoteAudio(peerId);
|
||||||
@@ -263,6 +302,41 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
this.remoteAudioElements.clear();
|
this.remoteAudioElements.clear();
|
||||||
|
|
||||||
this.remoteStreamSubscription?.unsubscribe();
|
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 {
|
private removeRemoteAudio(peerId: string): void {
|
||||||
@@ -295,9 +369,17 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
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
|
// Remove existing audio element for this peer if any
|
||||||
const existingAudio = this.remoteAudioElements.get(peerId);
|
const existingAudio = this.remoteAudioElements.get(peerId);
|
||||||
if (existingAudio) {
|
if (existingAudio) {
|
||||||
|
console.log('Removing existing audio element for:', peerId);
|
||||||
existingAudio.srcObject = null;
|
existingAudio.srcObject = null;
|
||||||
existingAudio.remove();
|
existingAudio.remove();
|
||||||
}
|
}
|
||||||
@@ -315,7 +397,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// Play the audio
|
// Play the audio
|
||||||
audio.play().then(() => {
|
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) => {
|
}).catch((error) => {
|
||||||
console.error('Failed to play remote audio from:', peerId, 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> {
|
async connect(): Promise<void> {
|
||||||
try {
|
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({
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: {
|
audio: {
|
||||||
deviceId: this.selectedInputDevice() || undefined,
|
deviceId: this.selectedInputDevice() || undefined,
|
||||||
@@ -353,14 +442,20 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.webrtcService.setLocalStream(stream);
|
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
|
// Broadcast voice state to other users
|
||||||
this.webrtcService.broadcastMessage({
|
this.webrtcService.broadcastMessage({
|
||||||
type: 'voice-state',
|
type: 'voice-state',
|
||||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||||
|
displayName: this.currentUser()?.displayName || 'User',
|
||||||
voiceState: {
|
voiceState: {
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
isMuted: this.isMuted(),
|
isMuted: this.isMuted(),
|
||||||
isDeafened: this.isDeafened(),
|
isDeafened: this.isDeafened(),
|
||||||
|
roomId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -370,16 +465,32 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
this.playRemoteAudio(peerId, pendingStream);
|
this.playRemoteAudio(peerId, pendingStream);
|
||||||
});
|
});
|
||||||
this.pendingRemoteStreams.clear();
|
this.pendingRemoteStreams.clear();
|
||||||
|
|
||||||
|
// Persist settings after successful connection
|
||||||
|
this.saveSettings();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get user media:', 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 {
|
disconnect(): void {
|
||||||
|
// Stop voice heartbeat
|
||||||
|
this.webrtcService.stopVoiceHeartbeat();
|
||||||
|
|
||||||
// Broadcast voice disconnect to other users
|
// Broadcast voice disconnect to other users
|
||||||
this.webrtcService.broadcastMessage({
|
this.webrtcService.broadcastMessage({
|
||||||
type: 'voice-state',
|
type: 'voice-state',
|
||||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||||
|
displayName: this.currentUser()?.displayName || 'User',
|
||||||
voiceState: {
|
voiceState: {
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
isMuted: false,
|
isMuted: false,
|
||||||
@@ -407,9 +518,13 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
if (user?.id) {
|
if (user?.id) {
|
||||||
this.store.dispatch(UsersActions.updateVoiceState({
|
this.store.dispatch(UsersActions.updateVoiceState({
|
||||||
userId: user.id,
|
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.isScreenSharing.set(false);
|
||||||
this.isMuted.set(false);
|
this.isMuted.set(false);
|
||||||
this.isDeafened.set(false);
|
this.isDeafened.set(false);
|
||||||
@@ -423,6 +538,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
this.webrtcService.broadcastMessage({
|
this.webrtcService.broadcastMessage({
|
||||||
type: 'voice-state',
|
type: 'voice-state',
|
||||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||||
|
displayName: this.currentUser()?.displayName || 'User',
|
||||||
voiceState: {
|
voiceState: {
|
||||||
isConnected: this.isConnected(),
|
isConnected: this.isConnected(),
|
||||||
isMuted: this.isMuted(),
|
isMuted: this.isMuted(),
|
||||||
@@ -450,6 +566,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
this.webrtcService.broadcastMessage({
|
this.webrtcService.broadcastMessage({
|
||||||
type: 'voice-state',
|
type: 'voice-state',
|
||||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||||
|
displayName: this.currentUser()?.displayName || 'User',
|
||||||
voiceState: {
|
voiceState: {
|
||||||
isConnected: this.isConnected(),
|
isConnected: this.isConnected(),
|
||||||
isMuted: this.isMuted(),
|
isMuted: this.isMuted(),
|
||||||
@@ -464,7 +581,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
this.isScreenSharing.set(false);
|
this.isScreenSharing.set(false);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await this.webrtcService.startScreenShare();
|
await this.webrtcService.startScreenShare(this.includeSystemAudio());
|
||||||
this.isScreenSharing.set(true);
|
this.isScreenSharing.set(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to start screen share:', error);
|
console.error('Failed to start screen share:', error);
|
||||||
@@ -488,16 +605,20 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
this.disconnect();
|
this.disconnect();
|
||||||
this.connect();
|
this.connect();
|
||||||
}
|
}
|
||||||
|
this.saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
onOutputDeviceChange(event: Event): void {
|
onOutputDeviceChange(event: Event): void {
|
||||||
const select = event.target as HTMLSelectElement;
|
const select = event.target as HTMLSelectElement;
|
||||||
this.selectedOutputDevice.set(select.value);
|
this.selectedOutputDevice.set(select.value);
|
||||||
|
this.applyOutputDevice();
|
||||||
|
this.saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
onInputVolumeChange(event: Event): void {
|
onInputVolumeChange(event: Event): void {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
this.inputVolume.set(parseInt(input.value, 10));
|
this.inputVolume.set(parseInt(input.value, 10));
|
||||||
|
this.saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
onOutputVolumeChange(event: Event): void {
|
onOutputVolumeChange(event: Event): void {
|
||||||
@@ -509,6 +630,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
this.remoteAudioElements.forEach((audio) => {
|
this.remoteAudioElements.forEach((audio) => {
|
||||||
audio.volume = this.outputVolume() / 100;
|
audio.volume = this.outputVolume() / 100;
|
||||||
});
|
});
|
||||||
|
this.saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
onLatencyProfileChange(event: Event): void {
|
onLatencyProfileChange(event: Event): void {
|
||||||
@@ -516,6 +638,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
const profile = select.value as 'low'|'balanced'|'high';
|
const profile = select.value as 'low'|'balanced'|'high';
|
||||||
this.latencyProfile.set(profile);
|
this.latencyProfile.set(profile);
|
||||||
this.webrtcService.setLatencyProfile(profile);
|
this.webrtcService.setLatencyProfile(profile);
|
||||||
|
this.saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
onAudioBitrateChange(event: Event): void {
|
onAudioBitrateChange(event: Event): void {
|
||||||
@@ -523,6 +646,71 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
const kbps = parseInt(input.value, 10);
|
const kbps = parseInt(input.value, 10);
|
||||||
this.audioBitrate.set(kbps);
|
this.audioBitrate.set(kbps);
|
||||||
this.webrtcService.setAudioBitrate(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 {
|
getMuteButtonClass(): string {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
|||||||
import { DatabaseService } from '../../core/services/database.service';
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
import { TimeSyncService } from '../../core/services/time-sync.service';
|
import { TimeSyncService } from '../../core/services/time-sync.service';
|
||||||
|
import { AttachmentService } from '../../core/services/attachment.service';
|
||||||
import { Message, Reaction } from '../../core/models';
|
import { Message, Reaction } from '../../core/models';
|
||||||
import * as UsersActions from '../users/users.actions';
|
import * as UsersActions from '../users/users.actions';
|
||||||
import * as RoomsActions from '../rooms/rooms.actions';
|
import * as RoomsActions from '../rooms/rooms.actions';
|
||||||
@@ -21,6 +22,7 @@ export class MessagesEffects {
|
|||||||
private db = inject(DatabaseService);
|
private db = inject(DatabaseService);
|
||||||
private webrtc = inject(WebRTCService);
|
private webrtc = inject(WebRTCService);
|
||||||
private timeSync = inject(TimeSyncService);
|
private timeSync = inject(TimeSyncService);
|
||||||
|
private attachments = inject(AttachmentService);
|
||||||
|
|
||||||
private readonly INVENTORY_LIMIT = 1000; // number of recent messages to consider
|
private readonly INVENTORY_LIMIT = 1000; // number of recent messages to consider
|
||||||
private readonly CHUNK_SIZE = 200; // chunk size for inventory/batch transfers
|
private readonly CHUNK_SIZE = 200; // chunk size for inventory/batch transfers
|
||||||
@@ -374,6 +376,24 @@ export class MessagesEffects {
|
|||||||
}
|
}
|
||||||
break;
|
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':
|
case 'message-edited':
|
||||||
if (event.messageId && event.content) {
|
if (event.messageId && event.content) {
|
||||||
this.db.updateMessage(event.messageId, { content: event.content, editedAt: event.editedAt });
|
this.db.updateMessage(event.messageId, { content: event.content, editedAt: event.editedAt });
|
||||||
|
|||||||
@@ -190,23 +190,32 @@ export class RoomsEffects {
|
|||||||
ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess),
|
ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess),
|
||||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||||
tap(([{ room }, user]) => {
|
tap(([{ room }, user]) => {
|
||||||
// Connect to signaling server
|
|
||||||
const wsUrl = this.serverDirectory.getWebSocketUrl();
|
const wsUrl = this.serverDirectory.getWebSocketUrl();
|
||||||
console.log('Connecting to signaling server:', wsUrl);
|
const oderId = user?.oderId || this.webrtc.peerId();
|
||||||
|
const displayName = user?.displayName || 'Anonymous';
|
||||||
|
|
||||||
|
// Check if already connected to signaling server
|
||||||
|
if (this.webrtc.isConnected()) {
|
||||||
|
// Already connected - just switch to the new server without reconnecting WebSocket
|
||||||
|
// This preserves voice connections and peer state
|
||||||
|
console.log('Already connected to signaling, switching to room:', room.id);
|
||||||
|
this.webrtc.setCurrentServer(room.id);
|
||||||
|
this.webrtc.switchServer(room.id, oderId);
|
||||||
|
} else {
|
||||||
|
// Not connected - establish new connection
|
||||||
|
console.log('Connecting to signaling server:', wsUrl);
|
||||||
this.webrtc.connectToSignalingServer(wsUrl).subscribe({
|
this.webrtc.connectToSignalingServer(wsUrl).subscribe({
|
||||||
next: (connected) => {
|
next: (connected) => {
|
||||||
if (connected) {
|
if (connected) {
|
||||||
console.log('Connected to signaling, identifying user and joining room');
|
console.log('Connected to signaling, identifying user and joining room');
|
||||||
this.webrtc.setCurrentServer(room.id);
|
this.webrtc.setCurrentServer(room.id);
|
||||||
const oderId = user?.oderId || this.webrtc.peerId();
|
|
||||||
const displayName = user?.displayName || 'Anonymous';
|
|
||||||
this.webrtc.identify(oderId, displayName);
|
this.webrtc.identify(oderId, displayName);
|
||||||
this.webrtc.joinRoom(room.id, oderId);
|
this.webrtc.joinRoom(room.id, oderId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (err) => console.error('Failed to connect to signaling server:', err),
|
error: (err) => console.error('Failed to connect to signaling server:', err),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.router.navigate(['/room', room.id]);
|
this.router.navigate(['/room', room.id]);
|
||||||
})
|
})
|
||||||
@@ -498,8 +507,11 @@ export class RoomsEffects {
|
|||||||
// Incoming P2P room/icon events
|
// Incoming P2P room/icon events
|
||||||
incomingRoomEvents$ = createEffect(() =>
|
incomingRoomEvents$ = createEffect(() =>
|
||||||
this.webrtc.onMessageReceived.pipe(
|
this.webrtc.onMessageReceived.pipe(
|
||||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
withLatestFrom(
|
||||||
mergeMap(([event, currentRoom]: [any, Room | null]) => {
|
this.store.select(selectCurrentRoom),
|
||||||
|
this.store.select(selectAllUsers)
|
||||||
|
),
|
||||||
|
mergeMap(([event, currentRoom, allUsers]: [any, Room | null, any[]]) => {
|
||||||
if (!currentRoom) return of({ type: 'NO_OP' });
|
if (!currentRoom) return of({ type: 'NO_OP' });
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
@@ -507,8 +519,73 @@ export class RoomsEffects {
|
|||||||
const userId = (event.fromPeerId as string) || (event.oderId as string);
|
const userId = (event.fromPeerId as string) || (event.oderId as string);
|
||||||
const vs = event.voiceState as Partial<import('../../core/models').VoiceState> | undefined;
|
const vs = event.voiceState as Partial<import('../../core/models').VoiceState> | undefined;
|
||||||
if (!userId || !vs) return of({ type: 'NO_OP' });
|
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 }));
|
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': {
|
case 'room-settings-update': {
|
||||||
const settings: RoomSettings | undefined = event.settings;
|
const settings: RoomSettings | undefined = event.settings;
|
||||||
if (!settings) return of({ type: 'NO_OP' });
|
if (!settings) return of({ type: 'NO_OP' });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createAction, props } from '@ngrx/store';
|
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
|
// Load current user from storage
|
||||||
export const loadCurrentUser = createAction('[Users] Load Current User');
|
export const loadCurrentUser = createAction('[Users] Load Current User');
|
||||||
@@ -138,3 +138,9 @@ export const updateVoiceState = createAction(
|
|||||||
'[Users] Update Voice State',
|
'[Users] Update Voice State',
|
||||||
props<{ userId: string; voiceState: Partial<VoiceState> }>()
|
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,
|
isSpeaking: voiceState.isSpeaking ?? prev.isSpeaking,
|
||||||
isMutedByAdmin: voiceState.isMutedByAdmin ?? prev.isMutedByAdmin,
|
isMutedByAdmin: voiceState.isMutedByAdmin ?? prev.isMutedByAdmin,
|
||||||
volume: voiceState.volume ?? prev.volume,
|
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;
|
-ms-overflow-style: none;
|
||||||
scrollbar-width: 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