init
This commit is contained in:
17
.editorconfig
Normal file
17
.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
47
.gitignore
vendored
Normal file
47
.gitignore
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
dist-electron
|
||||
node_modules/*
|
||||
*server/node_modules/*
|
||||
*package-lock.json
|
||||
.angular
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
__screenshots__/
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
4
.vscode/extensions.json
vendored
Normal file
4
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
20
.vscode/launch.json
vendored
Normal file
20
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
42
.vscode/tasks.json
vendored
Normal file
42
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
59
README.md
Normal file
59
README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Client
|
||||
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.0.4.
|
||||
|
||||
## Development server
|
||||
|
||||
To start a local development server, run:
|
||||
|
||||
```bash
|
||||
ng serve
|
||||
```
|
||||
|
||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
|
||||
```bash
|
||||
ng generate component component-name
|
||||
```
|
||||
|
||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||
|
||||
```bash
|
||||
ng generate --help
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To build the project run:
|
||||
|
||||
```bash
|
||||
ng build
|
||||
```
|
||||
|
||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
||||
|
||||
```bash
|
||||
ng test
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
For end-to-end (e2e) testing, run:
|
||||
|
||||
```bash
|
||||
ng e2e
|
||||
```
|
||||
|
||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
102
angular.json
Normal file
102
angular.json
Normal file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm",
|
||||
"analytics": false
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"client": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss",
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:class": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:guard": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:interceptor": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:resolver": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:service": {
|
||||
"skipTests": true
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"browser": "src/main.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"allowedCommonJsDependencies": [
|
||||
"simple-peer",
|
||||
"uuid"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "client:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "client:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
85
electron/main.js
Normal file
85
electron/main.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const { app, BrowserWindow, ipcMain, desktopCapturer } = require('electron');
|
||||
const path = require('path');
|
||||
|
||||
let mainWindow;
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
frame: false,
|
||||
titleBarStyle: 'hidden',
|
||||
backgroundColor: '#0a0a0f',
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
webSecurity: true,
|
||||
},
|
||||
});
|
||||
|
||||
// In development, load from Angular dev server
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
mainWindow.loadURL('http://localhost:4200');
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
// In production, load the built Angular app
|
||||
// The dist folder is at the project root, not in electron folder
|
||||
mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'client', 'browser', 'index.html'));
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(createWindow);
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
// IPC handlers for window controls
|
||||
ipcMain.on('window-minimize', () => {
|
||||
mainWindow?.minimize();
|
||||
});
|
||||
|
||||
ipcMain.on('window-maximize', () => {
|
||||
if (mainWindow?.isMaximized()) {
|
||||
mainWindow.unmaximize();
|
||||
} else {
|
||||
mainWindow?.maximize();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('window-close', () => {
|
||||
mainWindow?.close();
|
||||
});
|
||||
|
||||
// IPC handler for desktop capturer (screen sharing)
|
||||
ipcMain.handle('get-sources', async () => {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['window', 'screen'],
|
||||
thumbnailSize: { width: 150, height: 150 },
|
||||
});
|
||||
return sources.map((source) => ({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
thumbnail: source.thumbnail.toDataURL(),
|
||||
}));
|
||||
});
|
||||
|
||||
// IPC handler for app data path
|
||||
ipcMain.handle('get-app-data-path', () => {
|
||||
return app.getPath('userData');
|
||||
});
|
||||
19
electron/preload.js
Normal file
19
electron/preload.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// Window controls
|
||||
minimizeWindow: () => ipcRenderer.send('window-minimize'),
|
||||
maximizeWindow: () => ipcRenderer.send('window-maximize'),
|
||||
closeWindow: () => ipcRenderer.send('window-close'),
|
||||
|
||||
// Desktop capturer for screen sharing
|
||||
getSources: () => ipcRenderer.invoke('get-sources'),
|
||||
|
||||
// App data path for SQLite storage
|
||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||
|
||||
// File system operations for database persistence
|
||||
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
||||
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
||||
fileExists: (filePath) => ipcRenderer.invoke('file-exists', filePath),
|
||||
});
|
||||
335
instructions.md
Normal file
335
instructions.md
Normal file
@@ -0,0 +1,335 @@
|
||||
# User Stories for P2P Chat Application
|
||||
|
||||
> **Reference:** [webrtc.org](https://webrtc.org)
|
||||
|
||||
The following user stories describe a peer-to-peer chat and voice application (Discord-like) built with **Electron** and **Angular** (using RxJS and NgRx) with a local SQLite store (via SQL.js). A central server only provides a directory of active rooms and users; all message and audio/video data is exchanged directly between peers (no port forwarding needed). Stories are grouped by role and include acceptance criteria and technical notes.
|
||||
|
||||
---
|
||||
|
||||
## Regular User
|
||||
|
||||
### Search and Join Chat Server
|
||||
|
||||
**User Story:** As a Regular User, I want to search for available chat servers (rooms) by name or topic so that I can find and join active communities.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- A search input allows queries against the central directory of servers.
|
||||
- Matching servers are returned and listed with details (name, current user count, etc.).
|
||||
- I can select a server from the list and send a join request.
|
||||
- If the server exists and is active, I am connected to that server's peer network.
|
||||
- Error is shown if the server is not found or unavailable.
|
||||
|
||||
**Technical Notes:**
|
||||
- Use Angular's `HttpClient` to call the central REST API (e.g. `GET /servers?search={query}`), which returns an Observable.
|
||||
- Use RxJS operators (e.g. `debounceTime`) to handle user input.
|
||||
- Use Angular components/forms for the search UI and display results. Use `async` pipe in templates for Observables.
|
||||
- Store search results and selected server info in an NgRx store or Angular Signals to manage state and reactively update UI.
|
||||
|
||||
---
|
||||
|
||||
### Create Chatroom (Become Host)
|
||||
|
||||
**User Story:** As a Regular User, I want to create a new chatroom so that I can start my own voice/text server; I should automatically become the room's host.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- I can create a named room; the central server registers the new room.
|
||||
- I join the newly created room and see myself listed as the host/owner.
|
||||
- The host can set an optional password or topic for the room.
|
||||
- Other users searching for rooms see this new room available.
|
||||
- If I disconnect without others online, the room is removed from the server directory.
|
||||
|
||||
**Technical Notes:**
|
||||
- Use Angular forms to input room details and NgRx actions to handle creation. Dispatch an action that posts to the central server API (e.g. `POST /servers`) and upon success updates the local store.
|
||||
- In the NgRx state, mark the creator as `host: true`. Follow NgRx best practices (immutable state, action-driven updates).
|
||||
- No code style notes here beyond following lint rules (e.g. `prefer const`, `no var`, 2-space indent).
|
||||
- After creation, initiate the P2P mesh: the host opens listening sockets (WebRTC or TCP sockets) and waits for peers (see "P2P Connectivity" story).
|
||||
|
||||
---
|
||||
|
||||
### Join Existing Chatroom
|
||||
|
||||
**User Story:** As a Regular User, I want to join an existing chatroom so that I can participate in it.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- I can select an available room and request to join.
|
||||
- The host is notified of my request and (if approved) I connect to the room.
|
||||
- If no approval step, I immediately join and synchronize chat history and participants.
|
||||
- Upon joining, I receive the current chat history (text and possibly voice stream) from one or more peers.
|
||||
|
||||
**Technical Notes:**
|
||||
- Use Angular Router or services to handle navigation/joining logic.
|
||||
- When joining, use a WebRTC signaling channel (via the central server or direct peer signaling) to exchange ICE candidates (STUN/TURN).
|
||||
- Once connected, use WebRTC DataChannels for chat data and Streams for audio/video.
|
||||
- On the host side, use NgRx Effects to handle incoming join events and broadcast initial state (messages and user list) to new peer.
|
||||
|
||||
---
|
||||
|
||||
### Send, Edit, Delete, and React to Messages
|
||||
|
||||
**User Story:** As a Regular User, I want to send text messages, and edit or delete my own messages (and react to any message), so that I can communicate effectively and correct mistakes.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- I can type and send a chat message, which is immediately delivered to all peers.
|
||||
- I can edit or delete any of my own previously sent messages; these edits/deletions update on all peers in real time.
|
||||
- I can react (e.g. add emoji) to any message; reactions are displayed next to the message for all users.
|
||||
- All message operations (send/edit/delete/react) are performed instantly (optimistic UI) and confirmed or synchronized with peers.
|
||||
- Deleted messages are removed from the UI for all users.
|
||||
|
||||
**Technical Notes:**
|
||||
- Use WebRTC `RTCDataChannel` (via a library like `simple-peer` or native API) for sending JSON-encoded message events between peers. Data channels provide reliable, ordered delivery for text.
|
||||
- Maintain a local message store (NgRx store or Signals) that holds all chat messages and reactions. Update the store immutably and broadcast updates to other peers.
|
||||
- Persist messages locally using SQL.js: run SQLite in-memory and after significant changes call `db.export()` to save state (or write to a file via Electron). On join, import/export to synchronize history (SQL.js can load an existing `Uint8Array` DB).
|
||||
- Implement send/edit/delete/react actions and reducers/effects in NgRx. Ensure all reducers treat state immutably and use NgRx Effects for any asynchronous broadcasts.
|
||||
- Follow lint rules for event handlers and functions (`no-unused-vars`, consistent naming).
|
||||
|
||||
---
|
||||
|
||||
### Voice Chat (Push-to-Talk)
|
||||
|
||||
**User Story:** As a Regular User, I want to join a voice channel within the chatroom so that I can talk to other participants (e.g. using push-to-talk or a switch).
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- I can enable my microphone and connect to the voice channel.
|
||||
- Other users hear my voice with low latency, and I hear theirs.
|
||||
- Only one person needs to be host; no central server relaying audio.
|
||||
- If I mute or leave, others no longer hear me.
|
||||
- Voice quality adapts to network (e.g. uses Opus codec).
|
||||
|
||||
**Technical Notes:**
|
||||
- Use WebRTC MediaStreams for audio: call `navigator.mediaDevices.getUserMedia({ audio: true })` to capture microphone audio. Add this audio track to the peer connection.
|
||||
- On receiving side, use a `<audio>` element or Web Audio API to play incoming audio streams.
|
||||
- Use an ICE/STUN configuration to establish P2P UDP voice channels. This avoids needing port forwarding.
|
||||
- Manage audio tracks in NgRx or a service for mute/unmute state. No messages need SQLite storage.
|
||||
- Consider using Electron's `contextIsolation` and secure context for permissions.
|
||||
|
||||
---
|
||||
|
||||
### Screen Sharing (with Game Capture)
|
||||
|
||||
**User Story:** As a Regular User, I want to share my screen or a game window with others so that I can show my gameplay or desktop.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- I can start screen sharing (full screen, a window, or browser tab).
|
||||
- Other users see a video stream of my screen.
|
||||
- Shared video uses hardware-accelerated decoding (GPU) for performance.
|
||||
- Sharing a game window is possible (capturing fullscreen games).
|
||||
- I can stop sharing and return to voice-only.
|
||||
|
||||
**Technical Notes:**
|
||||
- Use `navigator.mediaDevices.getDisplayMedia()` to capture the screen or window as a MediaStream. For game capture, Electron's `desktopCapturer` or `getDisplayMedia()` with a game window source can be used.
|
||||
- Combine the screen stream with WebRTC peer connections so that peers receive video as part of the call.
|
||||
- Configure the WebRTC codec to a hardware-accelerated codec (e.g. VP8/VP9 with GPU decoding) if supported. Electron/Chromium typically handles GPU decode automatically for WebRTC.
|
||||
- Ensure to handle multiple video tracks (microphone and screen) in the peer connection.
|
||||
- Follow ESLint rules for function naming and spacing in all new components.
|
||||
|
||||
---
|
||||
|
||||
### Local Chat History (Offline Access)
|
||||
|
||||
**User Story:** As a Regular User, I want the chat history to be saved locally in the app's database so that I can scroll through recent messages even after restarting the app or going offline.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- All sent/received messages in each room are saved in a local SQLite (via SQL.js) database.
|
||||
- When I reopen the app or reconnect to a room, previous messages are loaded from the local DB.
|
||||
- The database updates dynamically as new messages arrive or are edited/deleted.
|
||||
- The DB file (or exported data) persists between app restarts (using Electron's filesystem).
|
||||
|
||||
**Technical Notes:**
|
||||
- Initialize sql.js on app startup: load the WASM file (e.g. `sql-wasm.wasm`) by configuring `locateFile` in `initSqlJs`.
|
||||
- Create a database (`new SQL.Database()`) to hold tables like `messages` and `users`. Use SQL for durable local storage.
|
||||
- On each chat event, insert or update records (`INSERT`/`UPDATE`/`DELETE`). Query the DB to load history.
|
||||
- After updates, call `db.export()` and save the byte array to a file in Electron's app data directory. On restart, load that file into SQL.js to restore state.
|
||||
- Keep SQL code tidy and run queries through a service or NgRx effect, in line with TypeScript ESLint rules (`no unused vars`, `semicolons`, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Admin
|
||||
|
||||
### Moderate Users
|
||||
|
||||
**User Story:** As an Admin (room moderator), I want to remove or ban disruptive users so that I can keep the chatroom safe.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- I see a list of all users in the room and can select a user to kick or ban.
|
||||
- If I kick a user, that user is immediately disconnected from the room and cannot rejoin unless invited.
|
||||
- If I ban a user, their address/user ID is added to a room-specific ban list; they cannot rejoin even if they try.
|
||||
- Kicked or banned users cannot send any messages or voice.
|
||||
- A record of the action (who was kicked/banned) is logged in the chat (for transparency).
|
||||
|
||||
**Technical Notes:**
|
||||
- On the Angular UI, display user list in an admin dashboard component. Use NgRx store for active users.
|
||||
- When "kick/ban" is invoked, dispatch an NgRx action. An NgRx Effect can broadcast a "force-disconnect" signal to that peer's client.
|
||||
- The target client, upon receiving this signal, shows a message and disconnects. The room's peers update their state to remove that user.
|
||||
- Maintain a ban list (in local state/SQL.js); on new join attempts check this list to reject banned users.
|
||||
- Implementation parallels Discord's model: admins can ban/remove members. Follow lint rules and ensure any admin actions update state immutably.
|
||||
|
||||
---
|
||||
|
||||
### Moderate Chat Content
|
||||
|
||||
**User Story:** As an Admin, I want to delete inappropriate messages (even if sent by others) so that harmful content is removed immediately.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- I see a delete option on any message in the chat history.
|
||||
- Deleting a message removes it from the view for all participants in real time.
|
||||
- Deleted message history is purged from local DB for all users.
|
||||
- A placeholder (e.g. "Message deleted") is shown so others know a message was removed (optional).
|
||||
|
||||
**Technical Notes:**
|
||||
- In the message list component, show a delete button for admins on every message.
|
||||
- On deletion, dispatch an action; use an NgRx Effect to broadcast a "delete-message" event on the data channel to all peers.
|
||||
- Each peer then removes that message from its NgRx store and local SQL DB.
|
||||
- Ensure the deletion logic updates the state immutably and follows ESLint rules (e.g. no empty interfaces, one statement per line).
|
||||
|
||||
---
|
||||
|
||||
### Manage Chatroom / Server Info
|
||||
|
||||
**User Story:** As an Admin, I want to update room settings (name, description, rules) so that I can keep the room information current.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- I can change the room's display name, topic, or description.
|
||||
- Changes propagate to all users immediately (they see the new name/topic).
|
||||
- If the room is private, I can update the password or invite list.
|
||||
- All users see an "updated by admin" notification.
|
||||
|
||||
**Technical Notes:**
|
||||
- Use an Angular form or settings dialog for editing room info. Update NgRx store and broadcast changes to peers.
|
||||
- Store room metadata in NgRx; use a dedicated slice for "room settings."
|
||||
- On update, send a "update-room-info" message over the data channel. Peers update their local UI accordingly.
|
||||
- Persist settings in SQL.js so that if the host restarts, the settings remain.
|
||||
|
||||
---
|
||||
|
||||
### Assign / Revoke Moderator Role (Optional)
|
||||
|
||||
**User Story:** As an Admin, I want to designate other users as moderators or revoke their privileges so that management can be delegated.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- I can promote a user to moderator (grant them admin rights) and demote them.
|
||||
- Moderators get the same capabilities as Admin for moderation tasks.
|
||||
- The UI reflects their moderator status (e.g., a badge).
|
||||
- Role changes are synced to all clients.
|
||||
|
||||
**Technical Notes:**
|
||||
- Add a "roles" field in user state (e.g. `user = { id, name, role }`).
|
||||
- Implement an Admin action to change a user's role; broadcast this over the P2P channel.
|
||||
- All clients update their NgRx state so that role-based UI (e.g. delete buttons) appear accordingly.
|
||||
- Use NgRx to enforce role logic in reducers, following immutability rules.
|
||||
|
||||
---
|
||||
|
||||
## System
|
||||
|
||||
### Automatic Host Reassignment
|
||||
|
||||
**User Story:** As a System, I need to automatically elect a new host if the current host leaves so that the room continues operating smoothly.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- If the host disconnects unexpectedly, the system selects another participant as the new host.
|
||||
- Selection criteria might be: longest connected, lowest ping, or first joined. (Define one policy.)
|
||||
- All clients update to mark the new host (e.g. show a host badge).
|
||||
- The new host gains any host privileges (can kick/ban, etc.).
|
||||
- The central directory is updated if needed (new host can keep room alive).
|
||||
|
||||
**Technical Notes:**
|
||||
- Implement a leader-election algorithm in the P2P mesh: e.g., use Lamport timestamps or simply choose the next-in-list user.
|
||||
- On host disconnect event (detected via data channel/peerconnection `onclose`), dispatch a "host-left" action. An NgRx Effect can determine new host ID and broadcast a "new-host" event.
|
||||
- Update NgRx state `hostId` and any UI accordingly. This follows NgRx unidirectional flow and immutability.
|
||||
- Ensure any pending room changes (like last chat state) transfer seamlessly. The local SQLite DB is already consistent across peers, so no data is lost.
|
||||
|
||||
---
|
||||
|
||||
### Peer-to-Peer Connectivity & NAT Traversal
|
||||
|
||||
**User Story:** As a System, I want all peers to connect directly (audio/data) without requiring users to configure port forwarding.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Peers automatically discover routes to each other (using STUN/TURN) and establish direct connections.
|
||||
- No manual network configuration is needed by users.
|
||||
- Voice and data streams work across typical home/office NATs.
|
||||
- If a direct path fails, relay via TURN.
|
||||
|
||||
**Technical Notes:**
|
||||
- Use WebRTC's ICE framework: supply STUN (and optionally TURN) servers in the `RTCPeerConnection` configuration.
|
||||
- Exchange ICE candidates via the central server signaling (or existing peer connections).
|
||||
- The WebRTC API inherently avoids port forwarding by NAT traversal.
|
||||
- If TURN is needed, use a public TURN (or self-hosted) relay. This ensures compliance with "no port forwarding" requirement.
|
||||
- Manage connections in NgRx/Services; create Effects to handle ICE candidate events and re-dispatch as needed.
|
||||
|
||||
---
|
||||
|
||||
### Central Server Directory Endpoints
|
||||
|
||||
**User Story:** As a System, I will provide REST endpoints so that clients can search for servers and get lists of active users.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- A `GET /servers` endpoint returns current active rooms with metadata (name, user count, host).
|
||||
- A `GET /users` endpoint returns currently online users for a room.
|
||||
- Clients can `POST /join-request` to signal intent to join (if such a flow is needed).
|
||||
- Data is updated in real time as rooms/users come and go (clients may poll or use WebSocket if implemented).
|
||||
|
||||
**Technical Notes:**
|
||||
- Implement server APIs (e.g., with Node.js/Express) that track room and user registrations in memory or a lightweight DB.
|
||||
- The Angular app uses `HttpClient` to call these endpoints.
|
||||
- Use RxJS on the frontend to poll or listen (via WebSocket or Server-Sent Events) for updates, updating the NgRx store on arrival.
|
||||
- Adhere to API error handling best practices in Angular (interceptors, error messages).
|
||||
|
||||
---
|
||||
|
||||
### Initiate P2P Signaling
|
||||
|
||||
**User Story:** As a System, I need to facilitate the initial WebRTC handshake between peers so that they can establish direct connections.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- The host and new peers exchange session descriptions (SDP) and ICE candidates via a signaling channel.
|
||||
- Signaling can be done over the existing P2P network or via the central server (just for metadata exchange, not media).
|
||||
- Once ICE handshake is complete, direct peer connections are established for chat.
|
||||
|
||||
**Technical Notes:**
|
||||
- Use the central server or a temporary WebSocket channel for exchanging SDP offers/answers (as per WebRTC spec).
|
||||
- Implement an NgRx Effect that listens for signaling messages (e.g. via a WebSocket service) and creates/sets local/remote descriptions on `RTCPeerConnection`.
|
||||
- All signaling messages (offer, answer, ICE candidates) should be sent as JSON over a lightweight channel.
|
||||
- Once WebRTC `onicecandidate` fires, send each candidate to the peer.
|
||||
|
||||
---
|
||||
|
||||
### Data Synchronization
|
||||
|
||||
**User Story:** As a System, I need to keep each peer's local database in sync so that all users see the same chat history and user list.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- When a new user joins, they receive the full current chat history and user list from an existing peer.
|
||||
- All peers agree on the same final state for edits/deletions; conflicts (rare) are resolved consistently (e.g. last-write-wins).
|
||||
- The local SQL databases converge to the same data for the same room.
|
||||
- After reconnections or partitioning, peers resynchronize missing events.
|
||||
|
||||
**Technical Notes:**
|
||||
- On join, the host (or longest-staying peer) can send a "state dump" (all messages and user list) to the newcomer via the data channel, then replay new events.
|
||||
- Use timestamps or message IDs to order events. Ensure RxJS streams or NgRx Effects handle out-of-order gracefully.
|
||||
- Persist full chat log and user list in SQLite; on conflict (e.g., duplicate message IDs) merge by timestamp.
|
||||
- Leverage NgRx selectors to produce sorted message lists, ensuring UI consistency across clients.
|
||||
|
||||
---
|
||||
|
||||
## Code Quality Guidelines
|
||||
|
||||
> **Note:** All code should follow the project's TypeScript and Angular style guidelines.
|
||||
|
||||
The ESLint flat config enforces rules like:
|
||||
- `no-var`
|
||||
- `prefer-const`
|
||||
- 2-space indentation
|
||||
- Angular ESLint for templates
|
||||
|
||||
Use these lint rules and Prettier formatting (as noted in config) to maintain code quality. Each component, service, and NgRx element should comply with the attached ESLint configuration.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- **WebRTC:** [webrtc.org](https://webrtc.org) - ICE/STUN/TURN, P2P connectivity
|
||||
- **Angular:** [angular.dev](https://angular.dev) - HttpClient, Observables, async pipe
|
||||
- **NgRx:** Immutable state management, action-driven updates, Effects
|
||||
- **SQL.js:** [github.com/sql-js/sql.js](https://github.com/sql-js/sql.js) - SQLite in browser/Electron
|
||||
- **Discord Moderation:** [discord.com](https://discord.com) - Moderation model reference
|
||||
121
package.json
Normal file
121
package.json
Normal file
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"name": "metoyou",
|
||||
"version": "1.0.0",
|
||||
"description": "P2P Discord-like chat application",
|
||||
"main": "electron/main.js",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"build:all": "npm run build && cd server && npm run build",
|
||||
"build:prod": "ng build --configuration production --base-href='./'",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"server:build": "cd server && npm run build",
|
||||
"server:start": "cd server && npm start",
|
||||
"server:dev": "cd server && npm run dev",
|
||||
"electron": "ng build && electron .",
|
||||
"electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && cross-env NODE_ENV=development electron .\"",
|
||||
"electron:full": "concurrently --kill-others \"cd server && npm start\" \"ng serve\" \"wait-on http://localhost:4200 http://localhost:3001/api/health && cross-env NODE_ENV=development electron .\"",
|
||||
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production electron .\"",
|
||||
"electron:build": "npm run build:prod && electron-builder",
|
||||
"electron:build:win": "npm run build:prod && electron-builder --win",
|
||||
"electron:build:mac": "npm run build:prod && electron-builder --mac",
|
||||
"electron:build:linux": "npm run build:prod && electron-builder --linux",
|
||||
"electron:build:all": "npm run build:prod && electron-builder --win --mac --linux",
|
||||
"build:prod:all": "npm run build:prod && cd server && npm run build",
|
||||
"build:prod:win": "npm run build:prod:all && electron-builder --win",
|
||||
"dev": "npm run electron:full"
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "npm@10.9.2",
|
||||
"dependencies": {
|
||||
"@angular/common": "^21.0.0",
|
||||
"@angular/compiler": "^21.0.0",
|
||||
"@angular/core": "^21.0.0",
|
||||
"@angular/forms": "^21.0.0",
|
||||
"@angular/platform-browser": "^21.0.0",
|
||||
"@angular/router": "^21.0.0",
|
||||
"@ng-icons/core": "^33.0.0",
|
||||
"@ng-icons/lucide": "^33.0.0",
|
||||
"@ngrx/effects": "^21.0.1",
|
||||
"@ngrx/entity": "^21.0.1",
|
||||
"@ngrx/store": "^21.0.1",
|
||||
"@ngrx/store-devtools": "^21.0.1",
|
||||
"@spartan-ng/brain": "^0.0.1-alpha.589",
|
||||
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
||||
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"rxjs": "~7.8.0",
|
||||
"simple-peer": "^9.11.1",
|
||||
"sql.js": "^1.13.0",
|
||||
"tslib": "^2.3.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^21.0.4",
|
||||
"@angular/cli": "^21.0.4",
|
||||
"@angular/compiler-cli": "^21.0.0",
|
||||
"@types/simple-peer": "^9.11.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^26.0.12",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.2",
|
||||
"wait-on": "^7.2.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.metoyou.app",
|
||||
"productName": "MetoYou",
|
||||
"directories": {
|
||||
"output": "dist-electron"
|
||||
},
|
||||
"files": [
|
||||
"dist/client/**/*",
|
||||
"electron/**/*",
|
||||
"node_modules/**/*",
|
||||
"!node_modules/**/test/**/*",
|
||||
"!node_modules/**/tests/**/*",
|
||||
"!node_modules/**/*.d.ts",
|
||||
"!node_modules/**/*.map"
|
||||
],
|
||||
"nodeGypRebuild": false,
|
||||
"buildDependenciesFromSource": false,
|
||||
"npmRebuild": false,
|
||||
"mac": {
|
||||
"category": "public.app-category.social-networking",
|
||||
"target": "dmg"
|
||||
},
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"artifactName": "${productName}-${version}-${arch}.${ext}"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
},
|
||||
"linux": {
|
||||
"target": ["AppImage", "deb"],
|
||||
"category": "Network;Chat",
|
||||
"executableName": "metoyou",
|
||||
"executableArgs": ["--no-sandbox"]
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
27
server/data/servers.json
Normal file
27
server/data/servers.json
Normal file
@@ -0,0 +1,27 @@
|
||||
[
|
||||
{
|
||||
"id": "5b9ee424-dcfc-4bcb-a0cd-175b0d4dc3d7",
|
||||
"name": "hello",
|
||||
"ownerId": "7c74c680-09ca-42ee-b5fb-650e8eaa1622",
|
||||
"ownerPublicKey": "5b870756-bdfd-47c0-9a27-90f5838b66ac",
|
||||
"isPrivate": false,
|
||||
"maxUsers": 50,
|
||||
"currentUsers": 0,
|
||||
"tags": [],
|
||||
"createdAt": 1766898986953,
|
||||
"lastSeen": 1766898986953
|
||||
},
|
||||
{
|
||||
"id": "39071c2e-6715-45a7-ac56-9e82ec4fae03",
|
||||
"name": "HeePassword",
|
||||
"description": "ME ME",
|
||||
"ownerId": "53b1172a-acff-4e19-9773-a2a23408b3c0",
|
||||
"ownerPublicKey": "53b1172a-acff-4e19-9773-a2a23408b3c0",
|
||||
"isPrivate": true,
|
||||
"maxUsers": 50,
|
||||
"currentUsers": 0,
|
||||
"tags": [],
|
||||
"createdAt": 1766902260144,
|
||||
"lastSeen": 1766902260144
|
||||
}
|
||||
]
|
||||
9
server/data/users.json
Normal file
9
server/data/users.json
Normal file
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"id": "54c0953a-1e54-4c07-8da9-06c143d9354f",
|
||||
"username": "azaaxin",
|
||||
"passwordHash": "9f3a38af5ddad28abc9a273e2481883b245e5a908266c2ce1f0e42c7fa175d6c",
|
||||
"displayName": "azaaxin",
|
||||
"createdAt": 1766902824975
|
||||
}
|
||||
]
|
||||
2
server/dist/index.d.ts
vendored
Normal file
2
server/dist/index.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export {};
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
1
server/dist/index.d.ts.map
vendored
Normal file
1
server/dist/index.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
|
||||
395
server/dist/index.js
vendored
Normal file
395
server/dist/index.js
vendored
Normal file
@@ -0,0 +1,395 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const express_1 = __importDefault(require("express"));
|
||||
const cors_1 = __importDefault(require("cors"));
|
||||
const http_1 = require("http");
|
||||
const ws_1 = require("ws");
|
||||
const uuid_1 = require("uuid");
|
||||
const app = (0, express_1.default)();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
app.use((0, cors_1.default)());
|
||||
app.use(express_1.default.json());
|
||||
const servers = new Map();
|
||||
const joinRequests = new Map();
|
||||
const connectedUsers = new Map();
|
||||
// Persistence
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const DATA_DIR = path_1.default.join(process.cwd(), 'data');
|
||||
const SERVERS_FILE = path_1.default.join(DATA_DIR, 'servers.json');
|
||||
const USERS_FILE = path_1.default.join(DATA_DIR, 'users.json');
|
||||
function ensureDataDir() {
|
||||
if (!fs_1.default.existsSync(DATA_DIR))
|
||||
fs_1.default.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
function saveServers() {
|
||||
ensureDataDir();
|
||||
fs_1.default.writeFileSync(SERVERS_FILE, JSON.stringify(Array.from(servers.values()), null, 2));
|
||||
}
|
||||
function loadServers() {
|
||||
ensureDataDir();
|
||||
if (fs_1.default.existsSync(SERVERS_FILE)) {
|
||||
const raw = fs_1.default.readFileSync(SERVERS_FILE, 'utf-8');
|
||||
const list = JSON.parse(raw);
|
||||
list.forEach(s => servers.set(s.id, s));
|
||||
}
|
||||
}
|
||||
// REST API Routes
|
||||
// Health check endpoint
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: Date.now(),
|
||||
serverCount: servers.size,
|
||||
connectedUsers: connectedUsers.size,
|
||||
});
|
||||
});
|
||||
let authUsers = [];
|
||||
function saveUsers() { ensureDataDir(); fs_1.default.writeFileSync(USERS_FILE, JSON.stringify(authUsers, null, 2)); }
|
||||
function loadUsers() { ensureDataDir(); if (fs_1.default.existsSync(USERS_FILE)) {
|
||||
authUsers = JSON.parse(fs_1.default.readFileSync(USERS_FILE, 'utf-8'));
|
||||
} }
|
||||
const crypto_1 = __importDefault(require("crypto"));
|
||||
function hashPassword(pw) { return crypto_1.default.createHash('sha256').update(pw).digest('hex'); }
|
||||
app.post('/api/users/register', (req, res) => {
|
||||
const { username, password, displayName } = req.body;
|
||||
if (!username || !password)
|
||||
return res.status(400).json({ error: 'Missing username/password' });
|
||||
if (authUsers.find(u => u.username === username))
|
||||
return res.status(409).json({ error: 'Username taken' });
|
||||
const user = { id: (0, uuid_1.v4)(), username, passwordHash: hashPassword(password), displayName: displayName || username, createdAt: Date.now() };
|
||||
authUsers.push(user);
|
||||
saveUsers();
|
||||
res.status(201).json({ id: user.id, username: user.username, displayName: user.displayName });
|
||||
});
|
||||
app.post('/api/users/login', (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
const user = authUsers.find(u => u.username === username && u.passwordHash === hashPassword(password));
|
||||
if (!user)
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
res.json({ id: user.id, username: user.username, displayName: user.displayName });
|
||||
});
|
||||
// Search servers
|
||||
app.get('/api/servers', (req, res) => {
|
||||
const { q, tags, limit = 20, offset = 0 } = req.query;
|
||||
let results = Array.from(servers.values())
|
||||
.filter(s => !s.isPrivate)
|
||||
.filter(s => {
|
||||
if (q) {
|
||||
const query = String(q).toLowerCase();
|
||||
return s.name.toLowerCase().includes(query) ||
|
||||
s.description?.toLowerCase().includes(query);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.filter(s => {
|
||||
if (tags) {
|
||||
const tagList = String(tags).split(',');
|
||||
return tagList.some(t => s.tags.includes(t));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// Keep servers visible permanently until deleted; do not filter by lastSeen
|
||||
const total = results.length;
|
||||
results = results.slice(Number(offset), Number(offset) + Number(limit));
|
||||
res.json({ servers: results, total, limit: Number(limit), offset: Number(offset) });
|
||||
});
|
||||
// Register a server
|
||||
app.post('/api/servers', (req, res) => {
|
||||
const { id: clientId, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, tags } = req.body;
|
||||
if (!name || !ownerId || !ownerPublicKey) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
// Use client-provided ID if available, otherwise generate one
|
||||
const id = clientId || (0, uuid_1.v4)();
|
||||
const server = {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
ownerId,
|
||||
ownerPublicKey,
|
||||
isPrivate: isPrivate ?? false,
|
||||
maxUsers: maxUsers ?? 0,
|
||||
currentUsers: 0,
|
||||
tags: tags ?? [],
|
||||
createdAt: Date.now(),
|
||||
lastSeen: Date.now(),
|
||||
};
|
||||
servers.set(id, server);
|
||||
saveServers();
|
||||
res.status(201).json(server);
|
||||
});
|
||||
// Update server
|
||||
app.put('/api/servers/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ownerId, ...updates } = req.body;
|
||||
const server = servers.get(id);
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
}
|
||||
if (server.ownerId !== ownerId) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
const updated = { ...server, ...updates, lastSeen: Date.now() };
|
||||
servers.set(id, updated);
|
||||
saveServers();
|
||||
res.json(updated);
|
||||
});
|
||||
// Heartbeat - keep server alive
|
||||
app.post('/api/servers/:id/heartbeat', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { currentUsers } = req.body;
|
||||
const server = servers.get(id);
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
}
|
||||
server.lastSeen = Date.now();
|
||||
if (typeof currentUsers === 'number') {
|
||||
server.currentUsers = currentUsers;
|
||||
}
|
||||
servers.set(id, server);
|
||||
saveServers();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
// Remove server
|
||||
app.delete('/api/servers/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ownerId } = req.body;
|
||||
const server = servers.get(id);
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
}
|
||||
if (server.ownerId !== ownerId) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
servers.delete(id);
|
||||
saveServers();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
// Request to join a server
|
||||
app.post('/api/servers/:id/join', (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { userId, userPublicKey, displayName } = req.body;
|
||||
const server = servers.get(serverId);
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
}
|
||||
const requestId = (0, uuid_1.v4)();
|
||||
const request = {
|
||||
id: requestId,
|
||||
serverId,
|
||||
userId,
|
||||
userPublicKey,
|
||||
displayName,
|
||||
status: server.isPrivate ? 'pending' : 'approved',
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
joinRequests.set(requestId, request);
|
||||
// Notify server owner via WebSocket
|
||||
if (server.isPrivate) {
|
||||
notifyServerOwner(server.ownerId, {
|
||||
type: 'join_request',
|
||||
request,
|
||||
});
|
||||
}
|
||||
res.status(201).json(request);
|
||||
});
|
||||
// Get join requests for a server
|
||||
app.get('/api/servers/:id/requests', (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { ownerId } = req.query;
|
||||
const server = servers.get(serverId);
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
}
|
||||
if (server.ownerId !== ownerId) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
const requests = Array.from(joinRequests.values())
|
||||
.filter(r => r.serverId === serverId && r.status === 'pending');
|
||||
res.json({ requests });
|
||||
});
|
||||
// Approve/reject join request
|
||||
app.put('/api/requests/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ownerId, status } = req.body;
|
||||
const request = joinRequests.get(id);
|
||||
if (!request) {
|
||||
return res.status(404).json({ error: 'Request not found' });
|
||||
}
|
||||
const server = servers.get(request.serverId);
|
||||
if (!server || server.ownerId !== ownerId) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
request.status = status;
|
||||
joinRequests.set(id, request);
|
||||
// Notify the requester
|
||||
notifyUser(request.userId, {
|
||||
type: 'request_update',
|
||||
request,
|
||||
});
|
||||
res.json(request);
|
||||
});
|
||||
// WebSocket Server for real-time signaling
|
||||
const server = (0, http_1.createServer)(app);
|
||||
const wss = new ws_1.WebSocketServer({ server });
|
||||
wss.on('connection', (ws) => {
|
||||
const oderId = (0, uuid_1.v4)();
|
||||
connectedUsers.set(oderId, { oderId, ws });
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
handleWebSocketMessage(oderId, message);
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Invalid WebSocket message:', err);
|
||||
}
|
||||
});
|
||||
ws.on('close', () => {
|
||||
const user = connectedUsers.get(oderId);
|
||||
if (user?.serverId) {
|
||||
// Notify others in the room
|
||||
broadcastToServer(user.serverId, {
|
||||
type: 'user_left',
|
||||
oderId,
|
||||
displayName: user.displayName,
|
||||
}, oderId);
|
||||
}
|
||||
connectedUsers.delete(oderId);
|
||||
});
|
||||
// Send connection acknowledgment
|
||||
ws.send(JSON.stringify({ type: 'connected', oderId }));
|
||||
});
|
||||
function handleWebSocketMessage(connectionId, message) {
|
||||
const user = connectedUsers.get(connectionId);
|
||||
if (!user)
|
||||
return;
|
||||
switch (message.type) {
|
||||
case 'identify':
|
||||
// User identifies themselves with their permanent ID
|
||||
// Store their actual oderId for peer-to-peer routing
|
||||
user.oderId = message.oderId || connectionId;
|
||||
user.displayName = message.displayName || 'Anonymous';
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||
break;
|
||||
case 'join_server':
|
||||
user.serverId = message.serverId;
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User ${user.displayName} (${user.oderId}) joined server ${message.serverId}`);
|
||||
// Get list of current users in server (exclude this user by oderId)
|
||||
const usersInServer = Array.from(connectedUsers.values())
|
||||
.filter(u => u.serverId === message.serverId && u.oderId !== user.oderId)
|
||||
.map(u => ({ oderId: u.oderId, displayName: u.displayName }));
|
||||
console.log(`Sending server_users to ${user.displayName}:`, usersInServer);
|
||||
user.ws.send(JSON.stringify({
|
||||
type: 'server_users',
|
||||
users: usersInServer,
|
||||
}));
|
||||
// Notify others (exclude by oderId, not connectionId)
|
||||
broadcastToServer(message.serverId, {
|
||||
type: 'user_joined',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName,
|
||||
}, user.oderId);
|
||||
break;
|
||||
case 'leave_server':
|
||||
const oldServerId = user.serverId;
|
||||
user.serverId = undefined;
|
||||
connectedUsers.set(connectionId, user);
|
||||
if (oldServerId) {
|
||||
broadcastToServer(oldServerId, {
|
||||
type: 'user_left',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName,
|
||||
}, user.oderId);
|
||||
}
|
||||
break;
|
||||
case 'offer':
|
||||
case 'answer':
|
||||
case 'ice_candidate':
|
||||
// Forward signaling messages to specific peer
|
||||
console.log(`Forwarding ${message.type} from ${user.oderId} to ${message.targetUserId}`);
|
||||
const targetUser = findUserByUserId(message.targetUserId);
|
||||
if (targetUser) {
|
||||
targetUser.ws.send(JSON.stringify({
|
||||
...message,
|
||||
fromUserId: user.oderId,
|
||||
}));
|
||||
console.log(`Successfully forwarded ${message.type} to ${message.targetUserId}`);
|
||||
}
|
||||
else {
|
||||
console.log(`Target user ${message.targetUserId} not found. Connected users:`, Array.from(connectedUsers.values()).map(u => ({ oderId: u.oderId, displayName: u.displayName })));
|
||||
}
|
||||
break;
|
||||
case 'chat_message':
|
||||
// Broadcast chat message to all users in the server
|
||||
if (user.serverId) {
|
||||
broadcastToServer(user.serverId, {
|
||||
type: 'chat_message',
|
||||
message: message.message,
|
||||
senderId: user.oderId,
|
||||
senderName: user.displayName,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'typing':
|
||||
// Broadcast typing indicator
|
||||
if (user.serverId) {
|
||||
broadcastToServer(user.serverId, {
|
||||
type: 'user_typing',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName,
|
||||
}, user.oderId);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.log('Unknown message type:', message.type);
|
||||
}
|
||||
}
|
||||
function broadcastToServer(serverId, message, excludeOderId) {
|
||||
console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type);
|
||||
connectedUsers.forEach((user) => {
|
||||
if (user.serverId === serverId && user.oderId !== excludeOderId) {
|
||||
console.log(` -> Sending to ${user.displayName} (${user.oderId})`);
|
||||
user.ws.send(JSON.stringify(message));
|
||||
}
|
||||
});
|
||||
}
|
||||
function notifyServerOwner(ownerId, message) {
|
||||
const owner = findUserByUserId(ownerId);
|
||||
if (owner) {
|
||||
owner.ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
function notifyUser(oderId, message) {
|
||||
const user = findUserByUserId(oderId);
|
||||
if (user) {
|
||||
user.ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
function findUserByUserId(oderId) {
|
||||
return Array.from(connectedUsers.values()).find(u => u.oderId === oderId);
|
||||
}
|
||||
// Cleanup old data periodically
|
||||
// Simple cleanup only for stale join requests (keep servers permanent)
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
joinRequests.forEach((request, id) => {
|
||||
if (now - request.createdAt > 24 * 60 * 60 * 1000) {
|
||||
joinRequests.delete(id);
|
||||
}
|
||||
});
|
||||
}, 60 * 1000);
|
||||
server.listen(PORT, () => {
|
||||
console.log(`🚀 MetoYou signaling server running on port ${PORT}`);
|
||||
console.log(` REST API: http://localhost:${PORT}/api`);
|
||||
console.log(` WebSocket: ws://localhost:${PORT}`);
|
||||
// Load servers on startup
|
||||
loadServers();
|
||||
});
|
||||
//# sourceMappingURL=index.js.map
|
||||
1
server/dist/index.js.map
vendored
Normal file
1
server/dist/index.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
27
server/package.json
Normal file
27
server/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "metoyou-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Signaling server for MetoYou P2P chat application",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node-dev --respawn src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"sql.js": "^1.9.0",
|
||||
"uuid": "^9.0.0",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.14",
|
||||
"@types/express": "^4.17.18",
|
||||
"@types/node": "^20.8.0",
|
||||
"@types/uuid": "^9.0.4",
|
||||
"@types/ws": "^8.5.8",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.2.2"
|
||||
}
|
||||
}
|
||||
102
server/src/db.ts
Normal file
102
server/src/db.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import initSqlJs, { Database, Statement } from 'sql.js';
|
||||
|
||||
// Simple SQLite via sql.js persisted to a single file
|
||||
const DATA_DIR = path.join(process.cwd(), 'data');
|
||||
const DB_FILE = path.join(DATA_DIR, 'metoyou.sqlite');
|
||||
|
||||
function ensureDataDir() {
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
let SQL: any = null;
|
||||
let db: Database | null = null;
|
||||
|
||||
export async function initDB(): Promise<void> {
|
||||
if (db) return;
|
||||
SQL = await initSqlJs({ locateFile: (file: string) => require.resolve('sql.js/dist/sql-wasm.wasm') });
|
||||
ensureDataDir();
|
||||
|
||||
if (fs.existsSync(DB_FILE)) {
|
||||
const fileBuffer = fs.readFileSync(DB_FILE);
|
||||
db = new SQL.Database(new Uint8Array(fileBuffer));
|
||||
} else {
|
||||
db = new SQL.Database();
|
||||
}
|
||||
|
||||
// Initialize schema
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
passwordHash TEXT NOT NULL,
|
||||
displayName TEXT NOT NULL,
|
||||
createdAt INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
persist();
|
||||
}
|
||||
|
||||
function persist(): void {
|
||||
if (!db) return;
|
||||
const data = db.export();
|
||||
const buffer = Buffer.from(data);
|
||||
fs.writeFileSync(DB_FILE, buffer);
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
displayName: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export async function getUserByUsername(username: string): Promise<AuthUser | null> {
|
||||
if (!db) await initDB();
|
||||
const stmt: Statement = db!.prepare('SELECT id, username, passwordHash, displayName, createdAt FROM users WHERE username = ? LIMIT 1');
|
||||
stmt.bind([username]);
|
||||
let row: AuthUser | null = null;
|
||||
if (stmt.step()) {
|
||||
const r = stmt.getAsObject() as any;
|
||||
row = {
|
||||
id: String(r.id),
|
||||
username: String(r.username),
|
||||
passwordHash: String(r.passwordHash),
|
||||
displayName: String(r.displayName),
|
||||
createdAt: Number(r.createdAt),
|
||||
};
|
||||
}
|
||||
stmt.free();
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function getUserById(id: string): Promise<AuthUser | null> {
|
||||
if (!db) await initDB();
|
||||
const stmt: Statement = db!.prepare('SELECT id, username, passwordHash, displayName, createdAt FROM users WHERE id = ? LIMIT 1');
|
||||
stmt.bind([id]);
|
||||
let row: AuthUser | null = null;
|
||||
if (stmt.step()) {
|
||||
const r = stmt.getAsObject() as any;
|
||||
row = {
|
||||
id: String(r.id),
|
||||
username: String(r.username),
|
||||
passwordHash: String(r.passwordHash),
|
||||
displayName: String(r.displayName),
|
||||
createdAt: Number(r.createdAt),
|
||||
};
|
||||
}
|
||||
stmt.free();
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function createUser(user: AuthUser): Promise<void> {
|
||||
if (!db) await initDB();
|
||||
const stmt = db!.prepare('INSERT INTO users (id, username, passwordHash, displayName, createdAt) VALUES (?, ?, ?, ?, ?)');
|
||||
stmt.bind([user.id, user.username, user.passwordHash, user.displayName, user.createdAt]);
|
||||
stmt.step();
|
||||
stmt.free();
|
||||
persist();
|
||||
}
|
||||
501
server/src/index.ts
Normal file
501
server/src/index.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { createServer } from 'http';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// In-memory storage for servers and users
|
||||
interface ServerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
ownerId: string;
|
||||
ownerPublicKey: string;
|
||||
isPrivate: boolean;
|
||||
maxUsers: number;
|
||||
currentUsers: number;
|
||||
tags: string[];
|
||||
createdAt: number;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
interface JoinRequest {
|
||||
id: string;
|
||||
serverId: string;
|
||||
userId: string;
|
||||
userPublicKey: string;
|
||||
displayName: string;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface ConnectedUser {
|
||||
oderId: string;
|
||||
ws: WebSocket;
|
||||
serverId?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
const servers = new Map<string, ServerInfo>();
|
||||
const joinRequests = new Map<string, JoinRequest>();
|
||||
const connectedUsers = new Map<string, ConnectedUser>();
|
||||
|
||||
// Persistence
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
const DATA_DIR = path.join(process.cwd(), 'data');
|
||||
const SERVERS_FILE = path.join(DATA_DIR, 'servers.json');
|
||||
const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
||||
|
||||
function ensureDataDir() {
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function saveServers() {
|
||||
ensureDataDir();
|
||||
fs.writeFileSync(SERVERS_FILE, JSON.stringify(Array.from(servers.values()), null, 2));
|
||||
}
|
||||
|
||||
function loadServers() {
|
||||
ensureDataDir();
|
||||
if (fs.existsSync(SERVERS_FILE)) {
|
||||
const raw = fs.readFileSync(SERVERS_FILE, 'utf-8');
|
||||
const list: ServerInfo[] = JSON.parse(raw);
|
||||
list.forEach(s => servers.set(s.id, s));
|
||||
}
|
||||
}
|
||||
|
||||
// REST API Routes
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: Date.now(),
|
||||
serverCount: servers.size,
|
||||
connectedUsers: connectedUsers.size,
|
||||
});
|
||||
});
|
||||
|
||||
// Time endpoint for clock synchronization
|
||||
app.get('/api/time', (req, res) => {
|
||||
res.json({ now: Date.now() });
|
||||
});
|
||||
|
||||
// Basic auth (demo - file-based)
|
||||
interface AuthUser { id: string; username: string; passwordHash: string; displayName: string; createdAt: number; }
|
||||
let authUsers: AuthUser[] = [];
|
||||
function saveUsers() { ensureDataDir(); fs.writeFileSync(USERS_FILE, JSON.stringify(authUsers, null, 2)); }
|
||||
function loadUsers() { ensureDataDir(); if (fs.existsSync(USERS_FILE)) { authUsers = JSON.parse(fs.readFileSync(USERS_FILE,'utf-8')); } }
|
||||
import crypto from 'crypto';
|
||||
import { initDB, getUserByUsername, createUser } from './db';
|
||||
function hashPassword(pw: string) { return crypto.createHash('sha256').update(pw).digest('hex'); }
|
||||
|
||||
app.post('/api/users/register', async (req, res) => {
|
||||
const { username, password, displayName } = req.body;
|
||||
if (!username || !password) return res.status(400).json({ error: 'Missing username/password' });
|
||||
await initDB();
|
||||
const exists = await getUserByUsername(username);
|
||||
if (exists) return res.status(409).json({ error: 'Username taken' });
|
||||
const user: AuthUser = { id: uuidv4(), username, passwordHash: hashPassword(password), displayName: displayName || username, createdAt: Date.now() };
|
||||
await createUser(user);
|
||||
res.status(201).json({ id: user.id, username: user.username, displayName: user.displayName });
|
||||
});
|
||||
|
||||
app.post('/api/users/login', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
await initDB();
|
||||
const user = await getUserByUsername(username);
|
||||
if (!user || user.passwordHash !== hashPassword(password)) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
res.json({ id: user.id, username: user.username, displayName: user.displayName });
|
||||
});
|
||||
|
||||
// Search servers
|
||||
app.get('/api/servers', (req, res) => {
|
||||
const { q, tags, limit = 20, offset = 0 } = req.query;
|
||||
|
||||
let results = Array.from(servers.values())
|
||||
.filter(s => !s.isPrivate)
|
||||
.filter(s => {
|
||||
if (q) {
|
||||
const query = String(q).toLowerCase();
|
||||
return s.name.toLowerCase().includes(query) ||
|
||||
s.description?.toLowerCase().includes(query);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.filter(s => {
|
||||
if (tags) {
|
||||
const tagList = String(tags).split(',');
|
||||
return tagList.some(t => s.tags.includes(t));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Keep servers visible permanently until deleted; do not filter by lastSeen
|
||||
|
||||
const total = results.length;
|
||||
results = results.slice(Number(offset), Number(offset) + Number(limit));
|
||||
|
||||
res.json({ servers: results, total, limit: Number(limit), offset: Number(offset) });
|
||||
});
|
||||
|
||||
// Register a server
|
||||
app.post('/api/servers', (req, res) => {
|
||||
const { id: clientId, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, tags } = req.body;
|
||||
|
||||
if (!name || !ownerId || !ownerPublicKey) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
// Use client-provided ID if available, otherwise generate one
|
||||
const id = clientId || uuidv4();
|
||||
const server: ServerInfo = {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
ownerId,
|
||||
ownerPublicKey,
|
||||
isPrivate: isPrivate ?? false,
|
||||
maxUsers: maxUsers ?? 0,
|
||||
currentUsers: 0,
|
||||
tags: tags ?? [],
|
||||
createdAt: Date.now(),
|
||||
lastSeen: Date.now(),
|
||||
};
|
||||
|
||||
servers.set(id, server);
|
||||
saveServers();
|
||||
res.status(201).json(server);
|
||||
});
|
||||
|
||||
// Update server
|
||||
app.put('/api/servers/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ownerId, ...updates } = req.body;
|
||||
|
||||
const server = servers.get(id);
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
}
|
||||
|
||||
if (server.ownerId !== ownerId) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
const updated = { ...server, ...updates, lastSeen: Date.now() };
|
||||
servers.set(id, updated);
|
||||
saveServers();
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
// Heartbeat - keep server alive
|
||||
app.post('/api/servers/:id/heartbeat', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { currentUsers } = req.body;
|
||||
|
||||
const server = servers.get(id);
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
}
|
||||
|
||||
server.lastSeen = Date.now();
|
||||
if (typeof currentUsers === 'number') {
|
||||
server.currentUsers = currentUsers;
|
||||
}
|
||||
servers.set(id, server);
|
||||
saveServers();
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Remove server
|
||||
app.delete('/api/servers/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ownerId } = req.body;
|
||||
|
||||
const server = servers.get(id);
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
}
|
||||
|
||||
if (server.ownerId !== ownerId) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
servers.delete(id);
|
||||
saveServers();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Request to join a server
|
||||
app.post('/api/servers/:id/join', (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { userId, userPublicKey, displayName } = req.body;
|
||||
|
||||
const server = servers.get(serverId);
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
}
|
||||
|
||||
const requestId = uuidv4();
|
||||
const request: JoinRequest = {
|
||||
id: requestId,
|
||||
serverId,
|
||||
userId,
|
||||
userPublicKey,
|
||||
displayName,
|
||||
status: server.isPrivate ? 'pending' : 'approved',
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
joinRequests.set(requestId, request);
|
||||
|
||||
// Notify server owner via WebSocket
|
||||
if (server.isPrivate) {
|
||||
notifyServerOwner(server.ownerId, {
|
||||
type: 'join_request',
|
||||
request,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json(request);
|
||||
});
|
||||
|
||||
// Get join requests for a server
|
||||
app.get('/api/servers/:id/requests', (req, res) => {
|
||||
const { id: serverId } = req.params;
|
||||
const { ownerId } = req.query;
|
||||
|
||||
const server = servers.get(serverId);
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
}
|
||||
|
||||
if (server.ownerId !== ownerId) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
const requests = Array.from(joinRequests.values())
|
||||
.filter(r => r.serverId === serverId && r.status === 'pending');
|
||||
|
||||
res.json({ requests });
|
||||
});
|
||||
|
||||
// Approve/reject join request
|
||||
app.put('/api/requests/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { ownerId, status } = req.body;
|
||||
|
||||
const request = joinRequests.get(id);
|
||||
if (!request) {
|
||||
return res.status(404).json({ error: 'Request not found' });
|
||||
}
|
||||
|
||||
const server = servers.get(request.serverId);
|
||||
if (!server || server.ownerId !== ownerId) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
request.status = status;
|
||||
joinRequests.set(id, request);
|
||||
|
||||
// Notify the requester
|
||||
notifyUser(request.userId, {
|
||||
type: 'request_update',
|
||||
request,
|
||||
});
|
||||
|
||||
res.json(request);
|
||||
});
|
||||
|
||||
// WebSocket Server for real-time signaling
|
||||
const server = createServer(app);
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
wss.on('connection', (ws: WebSocket) => {
|
||||
const oderId = uuidv4();
|
||||
connectedUsers.set(oderId, { oderId, ws });
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
handleWebSocketMessage(oderId, message);
|
||||
} catch (err) {
|
||||
console.error('Invalid WebSocket message:', err);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
const user = connectedUsers.get(oderId);
|
||||
if (user?.serverId) {
|
||||
// Notify others in the room
|
||||
broadcastToServer(user.serverId, {
|
||||
type: 'user_left',
|
||||
oderId,
|
||||
displayName: user.displayName,
|
||||
}, oderId);
|
||||
}
|
||||
connectedUsers.delete(oderId);
|
||||
});
|
||||
|
||||
// Send connection acknowledgment
|
||||
ws.send(JSON.stringify({ type: 'connected', oderId, serverTime: Date.now() }));
|
||||
});
|
||||
|
||||
function handleWebSocketMessage(connectionId: string, message: any): void {
|
||||
const user = connectedUsers.get(connectionId);
|
||||
if (!user) return;
|
||||
|
||||
switch (message.type) {
|
||||
case 'identify':
|
||||
// User identifies themselves with their permanent ID
|
||||
// Store their actual oderId for peer-to-peer routing
|
||||
user.oderId = message.oderId || connectionId;
|
||||
user.displayName = message.displayName || 'Anonymous';
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||
break;
|
||||
|
||||
case 'join_server':
|
||||
user.serverId = message.serverId;
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User ${user.displayName} (${user.oderId}) joined server ${message.serverId}`);
|
||||
|
||||
// Get list of current users in server (exclude this user by oderId)
|
||||
const usersInServer = Array.from(connectedUsers.values())
|
||||
.filter(u => u.serverId === message.serverId && u.oderId !== user.oderId)
|
||||
.map(u => ({ oderId: u.oderId, displayName: u.displayName }));
|
||||
|
||||
console.log(`Sending server_users to ${user.displayName}:`, usersInServer);
|
||||
user.ws.send(JSON.stringify({
|
||||
type: 'server_users',
|
||||
users: usersInServer,
|
||||
}));
|
||||
|
||||
// Notify others (exclude by oderId, not connectionId)
|
||||
broadcastToServer(message.serverId, {
|
||||
type: 'user_joined',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName,
|
||||
}, user.oderId);
|
||||
break;
|
||||
|
||||
case 'leave_server':
|
||||
const oldServerId = user.serverId;
|
||||
user.serverId = undefined;
|
||||
connectedUsers.set(connectionId, user);
|
||||
|
||||
if (oldServerId) {
|
||||
broadcastToServer(oldServerId, {
|
||||
type: 'user_left',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName,
|
||||
}, user.oderId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'offer':
|
||||
case 'answer':
|
||||
case 'ice_candidate':
|
||||
// Forward signaling messages to specific peer
|
||||
console.log(`Forwarding ${message.type} from ${user.oderId} to ${message.targetUserId}`);
|
||||
const targetUser = findUserByUserId(message.targetUserId);
|
||||
if (targetUser) {
|
||||
targetUser.ws.send(JSON.stringify({
|
||||
...message,
|
||||
fromUserId: user.oderId,
|
||||
}));
|
||||
console.log(`Successfully forwarded ${message.type} to ${message.targetUserId}`);
|
||||
} else {
|
||||
console.log(`Target user ${message.targetUserId} not found. Connected users:`,
|
||||
Array.from(connectedUsers.values()).map(u => ({ oderId: u.oderId, displayName: u.displayName })));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'chat_message':
|
||||
// Broadcast chat message to all users in the server
|
||||
if (user.serverId) {
|
||||
broadcastToServer(user.serverId, {
|
||||
type: 'chat_message',
|
||||
message: message.message,
|
||||
senderId: user.oderId,
|
||||
senderName: user.displayName,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'typing':
|
||||
// Broadcast typing indicator
|
||||
if (user.serverId) {
|
||||
broadcastToServer(user.serverId, {
|
||||
type: 'user_typing',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName,
|
||||
}, user.oderId);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('Unknown message type:', message.type);
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastToServer(serverId: string, message: any, excludeOderId?: string): void {
|
||||
console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type);
|
||||
connectedUsers.forEach((user) => {
|
||||
if (user.serverId === serverId && user.oderId !== excludeOderId) {
|
||||
console.log(` -> Sending to ${user.displayName} (${user.oderId})`);
|
||||
user.ws.send(JSON.stringify(message));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function notifyServerOwner(ownerId: string, message: any): void {
|
||||
const owner = findUserByUserId(ownerId);
|
||||
if (owner) {
|
||||
owner.ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
function notifyUser(oderId: string, message: any): void {
|
||||
const user = findUserByUserId(oderId);
|
||||
if (user) {
|
||||
user.ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
function findUserByUserId(oderId: string): ConnectedUser | undefined {
|
||||
return Array.from(connectedUsers.values()).find(u => u.oderId === oderId);
|
||||
}
|
||||
|
||||
// Cleanup old data periodically
|
||||
// Simple cleanup only for stale join requests (keep servers permanent)
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
joinRequests.forEach((request, id) => {
|
||||
if (now - request.createdAt > 24 * 60 * 60 * 1000) {
|
||||
joinRequests.delete(id);
|
||||
}
|
||||
});
|
||||
}, 60 * 1000);
|
||||
|
||||
initDB().then(() => {
|
||||
server.listen(PORT, () => {
|
||||
console.log(`🚀 MetoYou signaling server running on port ${PORT}`);
|
||||
console.log(` REST API: http://localhost:${PORT}/api`);
|
||||
console.log(` WebSocket: ws://localhost:${PORT}`);
|
||||
// Load servers on startup
|
||||
loadServers();
|
||||
});
|
||||
}).catch((err) => {
|
||||
console.error('Failed to initialize database:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
5
server/src/types/sqljs.d.ts
vendored
Normal file
5
server/src/types/sqljs.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare module 'sql.js' {
|
||||
export default function initSqlJs(config?: { locateFile?: (file: string) => string }): Promise<any>;
|
||||
export type Database = any;
|
||||
export type Statement = any;
|
||||
}
|
||||
19
server/tsconfig.json
Normal file
19
server/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
34
src/app/app.config.ts
Normal file
34
src/app/app.config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideStore } from '@ngrx/store';
|
||||
import { provideEffects } from '@ngrx/effects';
|
||||
import { provideStoreDevtools } from '@ngrx/store-devtools';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { messagesReducer } from './store/messages/messages.reducer';
|
||||
import { usersReducer } from './store/users/users.reducer';
|
||||
import { roomsReducer } from './store/rooms/rooms.reducer';
|
||||
import { MessagesEffects } from './store/messages/messages.effects';
|
||||
import { UsersEffects } from './store/users/users.effects';
|
||||
import { RoomsEffects } from './store/rooms/rooms.effects';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(),
|
||||
provideStore({
|
||||
messages: messagesReducer,
|
||||
users: usersReducer,
|
||||
rooms: roomsReducer,
|
||||
}),
|
||||
provideEffects([MessagesEffects, UsersEffects, RoomsEffects]),
|
||||
provideStoreDevtools({
|
||||
maxAge: 25,
|
||||
logOnly: !isDevMode(),
|
||||
autoPause: true,
|
||||
trace: false,
|
||||
}),
|
||||
],
|
||||
};
|
||||
14
src/app/app.html
Normal file
14
src/app/app.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<div class="h-screen bg-background text-foreground flex">
|
||||
<!-- Global left servers rail always visible -->
|
||||
<aside class="w-16 flex-shrink-0 border-r border-border bg-card">
|
||||
<app-servers-rail class="h-full" />
|
||||
</aside>
|
||||
<main class="flex-1 min-w-0 relative overflow-hidden">
|
||||
<!-- Custom draggable title bar -->
|
||||
<app-title-bar />
|
||||
<!-- Content area fills below the title bar without global scroll -->
|
||||
<div class="absolute inset-x-0 top-10 bottom-0 overflow-auto">
|
||||
<router-outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
36
src/app/app.routes.ts
Normal file
36
src/app/app.routes.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'search',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () =>
|
||||
import('./features/auth/login.component').then((m) => m.LoginComponent),
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
loadComponent: () =>
|
||||
import('./features/auth/register.component').then((m) => m.RegisterComponent),
|
||||
},
|
||||
{
|
||||
path: 'search',
|
||||
loadComponent: () =>
|
||||
import('./features/server-search/server-search.component').then(
|
||||
(m) => m.ServerSearchComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'room/:roomId',
|
||||
loadComponent: () =>
|
||||
import('./features/room/chat-room.component').then((m) => m.ChatRoomComponent),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
loadComponent: () =>
|
||||
import('./features/settings/settings.component').then((m) => m.SettingsComponent),
|
||||
},
|
||||
];
|
||||
0
src/app/app.scss
Normal file
0
src/app/app.scss
Normal file
68
src/app/app.ts
Normal file
68
src/app/app.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { Router, RouterOutlet, NavigationEnd } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
|
||||
import { DatabaseService } from './core/services/database.service';
|
||||
import { ServerDirectoryService } from './core/services/server-directory.service';
|
||||
import { TimeSyncService } from './core/services/time-sync.service';
|
||||
import { ServersRailComponent } from './features/servers/servers-rail.component';
|
||||
import { TitleBarComponent } from './features/shell/title-bar.component';
|
||||
import * as UsersActions from './store/users/users.actions';
|
||||
import * as RoomsActions from './store/rooms/rooms.actions';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [CommonModule, RouterOutlet, ServersRailComponent, TitleBarComponent],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
})
|
||||
export class App implements OnInit {
|
||||
private databaseService = inject(DatabaseService);
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
private servers = inject(ServerDirectoryService);
|
||||
private timeSync = inject(TimeSyncService);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
// Initialize database
|
||||
await this.databaseService.initialize();
|
||||
|
||||
// Initial time sync with active server
|
||||
try {
|
||||
const apiBase = this.servers.getApiBaseUrl();
|
||||
await this.timeSync.syncWithEndpoint(apiBase);
|
||||
} catch {}
|
||||
|
||||
// Load user data from local storage or create new user
|
||||
this.store.dispatch(UsersActions.loadCurrentUser());
|
||||
|
||||
// Load saved rooms
|
||||
this.store.dispatch(RoomsActions.loadRooms());
|
||||
|
||||
// If not authenticated, redirect to login; else restore last route
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
if (!currentUserId) {
|
||||
if (this.router.url !== '/login' && this.router.url !== '/register') {
|
||||
this.router.navigate(['/login']).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
const last = localStorage.getItem('metoyou_lastVisitedRoute');
|
||||
if (last && typeof last === 'string') {
|
||||
const current = this.router.url;
|
||||
if (current === '/' || current === '/search') {
|
||||
this.router.navigate([last], { replaceUrl: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Persist last visited on navigation
|
||||
this.router.events.subscribe((evt) => {
|
||||
if (evt instanceof NavigationEnd) {
|
||||
const url = evt.urlAfterRedirects || evt.url;
|
||||
// Store room route or search
|
||||
localStorage.setItem('metoyou_lastVisitedRoute', url);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
175
src/app/core/models/index.ts
Normal file
175
src/app/core/models/index.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
// Models for the P2P Chat Application
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
oderId: string; // Unique order ID for peer identification
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
status: 'online' | 'away' | 'busy' | 'offline';
|
||||
role: 'host' | 'admin' | 'moderator' | 'member';
|
||||
joinedAt: number;
|
||||
peerId?: string;
|
||||
isOnline?: boolean;
|
||||
isAdmin?: boolean;
|
||||
isRoomOwner?: boolean;
|
||||
voiceState?: VoiceState;
|
||||
screenShareState?: ScreenShareState;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
roomId: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
editedAt?: number;
|
||||
reactions: Reaction[];
|
||||
isDeleted: boolean;
|
||||
replyToId?: string;
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
id: string;
|
||||
messageId: string;
|
||||
oderId: string;
|
||||
userId: string; // Alias for backward compatibility
|
||||
emoji: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
topic?: string;
|
||||
hostId: string;
|
||||
password?: string;
|
||||
isPrivate: boolean;
|
||||
createdAt: number;
|
||||
userCount: number;
|
||||
maxUsers?: number;
|
||||
// Optional server icon synced P2P
|
||||
icon?: string; // data URL (e.g., base64 PNG) or remote URL
|
||||
iconUpdatedAt?: number; // last update timestamp for conflict resolution
|
||||
// Role-based management permissions
|
||||
permissions?: RoomPermissions;
|
||||
}
|
||||
|
||||
export interface RoomSettings {
|
||||
name: string;
|
||||
description?: string;
|
||||
topic?: string;
|
||||
isPrivate: boolean;
|
||||
password?: string;
|
||||
maxUsers?: number;
|
||||
rules?: string[];
|
||||
}
|
||||
|
||||
export interface RoomPermissions {
|
||||
// Whether admins can manage chat/voice rooms creation and modifications
|
||||
adminsManageRooms?: boolean;
|
||||
moderatorsManageRooms?: boolean;
|
||||
// Whether admins/moderators can change server icon
|
||||
adminsManageIcon?: boolean;
|
||||
moderatorsManageIcon?: boolean;
|
||||
// Existing capability toggles
|
||||
allowVoice?: boolean;
|
||||
allowScreenShare?: boolean;
|
||||
allowFileUploads?: boolean;
|
||||
slowModeInterval?: number;
|
||||
}
|
||||
|
||||
export interface BanEntry {
|
||||
oderId: string;
|
||||
userId: string;
|
||||
roomId: string;
|
||||
bannedBy: string;
|
||||
displayName?: string;
|
||||
reason?: string;
|
||||
expiresAt?: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface PeerConnection {
|
||||
peerId: string;
|
||||
userId: string;
|
||||
status: 'connecting' | 'connected' | 'disconnected' | 'failed';
|
||||
dataChannel?: RTCDataChannel;
|
||||
connection?: RTCPeerConnection;
|
||||
}
|
||||
|
||||
export interface VoiceState {
|
||||
isConnected: boolean;
|
||||
isMuted: boolean;
|
||||
isDeafened: boolean;
|
||||
isSpeaking: boolean;
|
||||
isMutedByAdmin?: boolean;
|
||||
volume?: number;
|
||||
roomId?: string;
|
||||
}
|
||||
|
||||
export interface ScreenShareState {
|
||||
isSharing: boolean;
|
||||
streamId?: string;
|
||||
sourceId?: string;
|
||||
sourceName?: string;
|
||||
}
|
||||
|
||||
export interface SignalingMessage {
|
||||
type: 'offer' | 'answer' | 'ice-candidate' | 'join' | 'leave' | 'chat' | 'state-sync' | 'kick' | 'ban' | 'host-change' | 'room-update';
|
||||
from: string;
|
||||
to?: string;
|
||||
payload: unknown;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
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';
|
||||
messageId?: string;
|
||||
message?: Message;
|
||||
reaction?: Reaction;
|
||||
data?: Partial<Message>;
|
||||
timestamp?: number;
|
||||
targetUserId?: string;
|
||||
roomId?: string;
|
||||
kickedBy?: string;
|
||||
bannedBy?: string;
|
||||
content?: string;
|
||||
editedAt?: number;
|
||||
deletedBy?: string;
|
||||
oderId?: string;
|
||||
emoji?: string;
|
||||
reason?: string;
|
||||
settings?: RoomSettings;
|
||||
voiceState?: Partial<VoiceState>;
|
||||
}
|
||||
|
||||
export interface ServerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
topic?: string;
|
||||
hostName: string;
|
||||
ownerId?: string;
|
||||
ownerPublicKey?: string;
|
||||
userCount: number;
|
||||
maxUsers: number;
|
||||
isPrivate: boolean;
|
||||
tags?: string[];
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface JoinRequest {
|
||||
roomId: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
currentUser: User | null;
|
||||
currentRoom: Room | null;
|
||||
isConnecting: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
43
src/app/core/services/auth.service.ts
Normal file
43
src/app/core/services/auth.service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { ServerDirectoryService, ServerEndpoint } from './server-directory.service';
|
||||
|
||||
export interface LoginResponse {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private http = inject(HttpClient);
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
|
||||
private endpointFor(serverId?: string): string {
|
||||
let base: ServerEndpoint | undefined;
|
||||
if (serverId) {
|
||||
base = this.serverDirectory.servers().find((s) => s.id === serverId);
|
||||
}
|
||||
const active = base || this.serverDirectory.activeServer();
|
||||
return active ? `${active.url}/api` : 'http://localhost:3001/api';
|
||||
}
|
||||
|
||||
register(params: { username: string; password: string; displayName?: string; serverId?: string }): Observable<LoginResponse> {
|
||||
const url = `${this.endpointFor(params.serverId)}/users/register`;
|
||||
return this.http.post<LoginResponse>(url, {
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
displayName: params.displayName,
|
||||
}).pipe(map((resp) => resp));
|
||||
}
|
||||
|
||||
login(params: { username: string; password: string; serverId?: string }): Observable<LoginResponse> {
|
||||
const url = `${this.endpointFor(params.serverId)}/users/login`;
|
||||
return this.http.post<LoginResponse>(url, {
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
}).pipe(map((resp) => resp));
|
||||
}
|
||||
}
|
||||
230
src/app/core/services/database.service.ts
Normal file
230
src/app/core/services/database.service.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { Message, User, Room, Reaction, BanEntry } from '../models';
|
||||
|
||||
/**
|
||||
* Database service using localStorage for persistence.
|
||||
* In a production Electron app, this would use sql.js with file system access.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DatabaseService {
|
||||
private readonly STORAGE_PREFIX = 'metoyou_';
|
||||
|
||||
isReady = signal(false);
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// Initialize storage structure if needed
|
||||
if (!localStorage.getItem(this.key('initialized'))) {
|
||||
this.initializeStorage();
|
||||
}
|
||||
this.isReady.set(true);
|
||||
}
|
||||
|
||||
private initializeStorage(): void {
|
||||
localStorage.setItem(this.key('messages'), JSON.stringify([]));
|
||||
localStorage.setItem(this.key('users'), JSON.stringify([]));
|
||||
localStorage.setItem(this.key('rooms'), JSON.stringify([]));
|
||||
localStorage.setItem(this.key('reactions'), JSON.stringify([]));
|
||||
localStorage.setItem(this.key('bans'), JSON.stringify([]));
|
||||
localStorage.setItem(this.key('initialized'), 'true');
|
||||
}
|
||||
|
||||
private key(name: string): string {
|
||||
return this.STORAGE_PREFIX + name;
|
||||
}
|
||||
|
||||
private getArray<T>(key: string): T[] {
|
||||
const data = localStorage.getItem(this.key(key));
|
||||
return data ? JSON.parse(data) : [];
|
||||
}
|
||||
|
||||
private setArray<T>(key: string, data: T[]): void {
|
||||
localStorage.setItem(this.key(key), JSON.stringify(data));
|
||||
}
|
||||
|
||||
// Messages
|
||||
async saveMessage(message: Message): Promise<void> {
|
||||
const messages = this.getArray<Message>('messages');
|
||||
const index = messages.findIndex((m) => m.id === message.id);
|
||||
if (index >= 0) {
|
||||
messages[index] = message;
|
||||
} else {
|
||||
messages.push(message);
|
||||
}
|
||||
this.setArray('messages', messages);
|
||||
}
|
||||
|
||||
async getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> {
|
||||
const messages = this.getArray<Message>('messages');
|
||||
return messages
|
||||
.filter((m) => m.roomId === roomId)
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
async deleteMessage(messageId: string): Promise<void> {
|
||||
const messages = this.getArray<Message>('messages');
|
||||
const filtered = messages.filter((m) => m.id !== messageId);
|
||||
this.setArray('messages', filtered);
|
||||
}
|
||||
|
||||
async updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
|
||||
const messages = this.getArray<Message>('messages');
|
||||
const index = messages.findIndex((m) => m.id === messageId);
|
||||
if (index >= 0) {
|
||||
messages[index] = { ...messages[index], ...updates };
|
||||
this.setArray('messages', messages);
|
||||
}
|
||||
}
|
||||
|
||||
async getMessageById(messageId: string): Promise<Message | null> {
|
||||
const messages = this.getArray<Message>('messages');
|
||||
return messages.find((m) => m.id === messageId) || null;
|
||||
}
|
||||
|
||||
async clearRoomMessages(roomId: string): Promise<void> {
|
||||
const messages = this.getArray<Message>('messages');
|
||||
const filtered = messages.filter((m) => m.roomId !== roomId);
|
||||
this.setArray('messages', filtered);
|
||||
}
|
||||
|
||||
// Reactions
|
||||
async saveReaction(reaction: Reaction): Promise<void> {
|
||||
const reactions = this.getArray<Reaction>('reactions');
|
||||
const exists = reactions.some(
|
||||
(r) =>
|
||||
r.messageId === reaction.messageId &&
|
||||
r.userId === reaction.userId &&
|
||||
r.emoji === reaction.emoji
|
||||
);
|
||||
if (!exists) {
|
||||
reactions.push(reaction);
|
||||
this.setArray('reactions', reactions);
|
||||
}
|
||||
}
|
||||
|
||||
async removeReaction(messageId: string, userId: string, emoji: string): Promise<void> {
|
||||
const reactions = this.getArray<Reaction>('reactions');
|
||||
const filtered = reactions.filter(
|
||||
(r) => !(r.messageId === messageId && r.userId === userId && r.emoji === emoji)
|
||||
);
|
||||
this.setArray('reactions', filtered);
|
||||
}
|
||||
|
||||
async getReactionsForMessage(messageId: string): Promise<Reaction[]> {
|
||||
const reactions = this.getArray<Reaction>('reactions');
|
||||
return reactions.filter((r) => r.messageId === messageId);
|
||||
}
|
||||
|
||||
// Users
|
||||
async saveUser(user: User): Promise<void> {
|
||||
const users = this.getArray<User>('users');
|
||||
const index = users.findIndex((u) => u.id === user.id);
|
||||
if (index >= 0) {
|
||||
users[index] = user;
|
||||
} else {
|
||||
users.push(user);
|
||||
}
|
||||
this.setArray('users', users);
|
||||
}
|
||||
|
||||
async getUser(userId: string): Promise<User | null> {
|
||||
const users = this.getArray<User>('users');
|
||||
return users.find((u) => u.id === userId) || null;
|
||||
}
|
||||
|
||||
async getCurrentUser(): Promise<User | null> {
|
||||
const currentUserId = localStorage.getItem(this.key('currentUserId'));
|
||||
if (!currentUserId) return null;
|
||||
return this.getUser(currentUserId);
|
||||
}
|
||||
|
||||
async setCurrentUserId(userId: string): Promise<void> {
|
||||
localStorage.setItem(this.key('currentUserId'), userId);
|
||||
}
|
||||
|
||||
async getUsersByRoom(roomId: string): Promise<User[]> {
|
||||
return this.getArray<User>('users');
|
||||
}
|
||||
|
||||
async updateUser(userId: string, updates: Partial<User>): Promise<void> {
|
||||
const users = this.getArray<User>('users');
|
||||
const index = users.findIndex((u) => u.id === userId);
|
||||
if (index >= 0) {
|
||||
users[index] = { ...users[index], ...updates };
|
||||
this.setArray('users', users);
|
||||
}
|
||||
}
|
||||
|
||||
// Rooms
|
||||
async saveRoom(room: Room): Promise<void> {
|
||||
const rooms = this.getArray<Room>('rooms');
|
||||
const index = rooms.findIndex((r) => r.id === room.id);
|
||||
if (index >= 0) {
|
||||
rooms[index] = room;
|
||||
} else {
|
||||
rooms.push(room);
|
||||
}
|
||||
this.setArray('rooms', rooms);
|
||||
}
|
||||
|
||||
async getRoom(roomId: string): Promise<Room | null> {
|
||||
const rooms = this.getArray<Room>('rooms');
|
||||
return rooms.find((r) => r.id === roomId) || null;
|
||||
}
|
||||
|
||||
async getAllRooms(): Promise<Room[]> {
|
||||
return this.getArray<Room>('rooms');
|
||||
}
|
||||
|
||||
async deleteRoom(roomId: string): Promise<void> {
|
||||
const rooms = this.getArray<Room>('rooms');
|
||||
const filtered = rooms.filter((r) => r.id !== roomId);
|
||||
this.setArray('rooms', filtered);
|
||||
await this.clearRoomMessages(roomId);
|
||||
}
|
||||
|
||||
async updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
|
||||
const rooms = this.getArray<Room>('rooms');
|
||||
const index = rooms.findIndex((r) => r.id === roomId);
|
||||
if (index >= 0) {
|
||||
rooms[index] = { ...rooms[index], ...updates };
|
||||
this.setArray('rooms', rooms);
|
||||
}
|
||||
}
|
||||
|
||||
// Bans
|
||||
async saveBan(ban: BanEntry): Promise<void> {
|
||||
const bans = this.getArray<BanEntry>('bans');
|
||||
bans.push(ban);
|
||||
this.setArray('bans', bans);
|
||||
}
|
||||
|
||||
async removeBan(oderId: string): Promise<void> {
|
||||
const bans = this.getArray<BanEntry>('bans');
|
||||
const filtered = bans.filter((b) => b.oderId !== oderId);
|
||||
this.setArray('bans', filtered);
|
||||
}
|
||||
|
||||
async getBansForRoom(roomId: string): Promise<BanEntry[]> {
|
||||
const bans = this.getArray<BanEntry>('bans');
|
||||
const now = Date.now();
|
||||
return bans.filter(
|
||||
(b) => b.roomId === roomId && (!b.expiresAt || b.expiresAt > now)
|
||||
);
|
||||
}
|
||||
|
||||
async isUserBanned(userId: string, roomId: string): Promise<boolean> {
|
||||
const bans = await this.getBansForRoom(roomId);
|
||||
return bans.some((b) => b.oderId === userId);
|
||||
}
|
||||
|
||||
async clearAllData(): Promise<void> {
|
||||
const keys = Object.keys(localStorage).filter((k) =>
|
||||
k.startsWith(this.STORAGE_PREFIX)
|
||||
);
|
||||
keys.forEach((k) => localStorage.removeItem(k));
|
||||
this.initializeStorage();
|
||||
}
|
||||
}
|
||||
3
src/app/core/services/index.ts
Normal file
3
src/app/core/services/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './database.service';
|
||||
export * from './webrtc.service';
|
||||
export * from './server-directory.service';
|
||||
392
src/app/core/services/server-directory.service.ts
Normal file
392
src/app/core/services/server-directory.service.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, of, throwError, forkJoin } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { ServerInfo, JoinRequest, User } from '../models';
|
||||
|
||||
export interface ServerEndpoint {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
status: 'online' | 'offline' | 'checking' | 'unknown';
|
||||
latency?: number;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'metoyou_server_endpoints';
|
||||
const DEFAULT_SERVER: Omit<ServerEndpoint, 'id'> = {
|
||||
name: 'Local Server',
|
||||
url: 'http://localhost:3001',
|
||||
isActive: true,
|
||||
isDefault: true,
|
||||
status: 'unknown',
|
||||
};
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ServerDirectoryService {
|
||||
private readonly _servers = signal<ServerEndpoint[]>([]);
|
||||
private _searchAllServers = false;
|
||||
|
||||
readonly servers = computed(() => this._servers());
|
||||
readonly activeServer = computed(() => this._servers().find((s) => s.isActive) || this._servers()[0]);
|
||||
|
||||
constructor(private http: HttpClient) {
|
||||
this.loadServers();
|
||||
}
|
||||
|
||||
private loadServers(): void {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
const servers = JSON.parse(stored) as ServerEndpoint[];
|
||||
// Ensure at least one is active
|
||||
if (!servers.some((s) => s.isActive) && servers.length > 0) {
|
||||
servers[0].isActive = true;
|
||||
}
|
||||
this._servers.set(servers);
|
||||
} catch {
|
||||
this.initializeDefaultServer();
|
||||
}
|
||||
} else {
|
||||
this.initializeDefaultServer();
|
||||
}
|
||||
}
|
||||
|
||||
private initializeDefaultServer(): void {
|
||||
const defaultServer: ServerEndpoint = {
|
||||
...DEFAULT_SERVER,
|
||||
id: crypto.randomUUID(),
|
||||
};
|
||||
this._servers.set([defaultServer]);
|
||||
this.saveServers();
|
||||
}
|
||||
|
||||
private saveServers(): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this._servers()));
|
||||
}
|
||||
|
||||
private get baseUrl(): string {
|
||||
const active = this.activeServer();
|
||||
return active ? `${active.url}/api` : 'http://localhost:3001/api';
|
||||
}
|
||||
|
||||
// Expose API base URL for consumers that need to call server endpoints
|
||||
getApiBaseUrl(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
// Server management methods
|
||||
addServer(server: { name: string; url: string }): void {
|
||||
const newServer: ServerEndpoint = {
|
||||
id: crypto.randomUUID(),
|
||||
name: server.name,
|
||||
url: server.url.replace(/\/$/, ''), // Remove trailing slash
|
||||
isActive: false,
|
||||
isDefault: false,
|
||||
status: 'unknown',
|
||||
};
|
||||
this._servers.update((servers) => [...servers, newServer]);
|
||||
this.saveServers();
|
||||
}
|
||||
|
||||
removeServer(id: string): void {
|
||||
const servers = this._servers();
|
||||
const server = servers.find((s) => s.id === id);
|
||||
if (server?.isDefault) return; // Can't remove default server
|
||||
|
||||
const wasActive = server?.isActive;
|
||||
this._servers.update((servers) => servers.filter((s) => s.id !== id));
|
||||
|
||||
// If removed server was active, activate the first server
|
||||
if (wasActive) {
|
||||
this._servers.update((servers) => {
|
||||
if (servers.length > 0) {
|
||||
servers[0].isActive = true;
|
||||
}
|
||||
return [...servers];
|
||||
});
|
||||
}
|
||||
this.saveServers();
|
||||
}
|
||||
|
||||
setActiveServer(id: string): void {
|
||||
this._servers.update((servers) =>
|
||||
servers.map((s) => ({
|
||||
...s,
|
||||
isActive: s.id === id,
|
||||
}))
|
||||
);
|
||||
this.saveServers();
|
||||
}
|
||||
|
||||
updateServerStatus(id: string, status: ServerEndpoint['status'], latency?: number): void {
|
||||
this._servers.update((servers) =>
|
||||
servers.map((s) => (s.id === id ? { ...s, status, latency } : s))
|
||||
);
|
||||
this.saveServers();
|
||||
}
|
||||
|
||||
setSearchAllServers(value: boolean): void {
|
||||
this._searchAllServers = value;
|
||||
}
|
||||
|
||||
async testServer(id: string): Promise<boolean> {
|
||||
const server = this._servers().find((s) => s.id === id);
|
||||
if (!server) return false;
|
||||
|
||||
this.updateServerStatus(id, 'checking');
|
||||
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const response = await fetch(`${server.url}/api/health`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const latency = Date.now() - startTime;
|
||||
|
||||
if (response.ok) {
|
||||
this.updateServerStatus(id, 'online', latency);
|
||||
return true;
|
||||
} else {
|
||||
this.updateServerStatus(id, 'offline');
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// Try alternative endpoint
|
||||
try {
|
||||
const response = await fetch(`${server.url}/api/servers`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const latency = Date.now() - startTime;
|
||||
|
||||
if (response.ok) {
|
||||
this.updateServerStatus(id, 'online', latency);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Server is offline
|
||||
}
|
||||
this.updateServerStatus(id, 'offline');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async testAllServers(): Promise<void> {
|
||||
const servers = this._servers();
|
||||
await Promise.all(servers.map((s) => this.testServer(s.id)));
|
||||
}
|
||||
|
||||
// Search for servers - optionally across all configured endpoints
|
||||
searchServers(query: string): Observable<ServerInfo[]> {
|
||||
if (this._searchAllServers) {
|
||||
return this.searchAllServerEndpoints(query);
|
||||
}
|
||||
return this.searchSingleServer(query, this.baseUrl);
|
||||
}
|
||||
|
||||
private searchSingleServer(query: string, baseUrl: string): Observable<ServerInfo[]> {
|
||||
const params = new HttpParams().set('q', query);
|
||||
|
||||
return this.http.get<{ servers: ServerInfo[]; total: number }>(`${baseUrl}/servers`, { params }).pipe(
|
||||
map((response) => {
|
||||
// Handle both wrapped response { servers: [...] } and direct array
|
||||
if (Array.isArray(response)) {
|
||||
return response;
|
||||
}
|
||||
return response.servers || [];
|
||||
}),
|
||||
catchError((error) => {
|
||||
console.error('Failed to search servers:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private searchAllServerEndpoints(query: string): Observable<ServerInfo[]> {
|
||||
const servers = this._servers().filter((s) => s.status !== 'offline');
|
||||
|
||||
if (servers.length === 0) {
|
||||
return this.searchSingleServer(query, this.baseUrl);
|
||||
}
|
||||
|
||||
const requests = servers.map((server) =>
|
||||
this.searchSingleServer(query, `${server.url}/api`).pipe(
|
||||
map((results) =>
|
||||
results.map((r) => ({
|
||||
...r,
|
||||
sourceId: server.id,
|
||||
sourceName: server.name,
|
||||
}))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return forkJoin(requests).pipe(
|
||||
map((results) => results.flat()),
|
||||
// Remove duplicates based on server ID
|
||||
map((servers) => {
|
||||
const seen = new Set<string>();
|
||||
return servers.filter((s) => {
|
||||
if (seen.has(s.id)) return false;
|
||||
seen.add(s.id);
|
||||
return true;
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Get all available servers
|
||||
getServers(): Observable<ServerInfo[]> {
|
||||
if (this._searchAllServers) {
|
||||
return this.getAllServersFromAllEndpoints();
|
||||
}
|
||||
|
||||
return this.http.get<{ servers: ServerInfo[]; total: number }>(`${this.baseUrl}/servers`).pipe(
|
||||
map((response) => {
|
||||
if (Array.isArray(response)) {
|
||||
return response;
|
||||
}
|
||||
return response.servers || [];
|
||||
}),
|
||||
catchError((error) => {
|
||||
console.error('Failed to get servers:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private getAllServersFromAllEndpoints(): Observable<ServerInfo[]> {
|
||||
const servers = this._servers().filter((s) => s.status !== 'offline');
|
||||
|
||||
if (servers.length === 0) {
|
||||
return this.http.get<{ servers: ServerInfo[]; total: number }>(`${this.baseUrl}/servers`).pipe(
|
||||
map((response) => (Array.isArray(response) ? response : response.servers || [])),
|
||||
catchError(() => of([]))
|
||||
);
|
||||
}
|
||||
|
||||
const requests = servers.map((server) =>
|
||||
this.http.get<{ servers: ServerInfo[]; total: number }>(`${server.url}/api/servers`).pipe(
|
||||
map((response) => {
|
||||
const results = Array.isArray(response) ? response : response.servers || [];
|
||||
return results.map((r) => ({
|
||||
...r,
|
||||
sourceId: server.id,
|
||||
sourceName: server.name,
|
||||
}));
|
||||
}),
|
||||
catchError(() => of([]))
|
||||
)
|
||||
);
|
||||
|
||||
return forkJoin(requests).pipe(map((results) => results.flat()));
|
||||
}
|
||||
|
||||
// Get server details
|
||||
getServer(serverId: string): Observable<ServerInfo | null> {
|
||||
return this.http.get<ServerInfo>(`${this.baseUrl}/servers/${serverId}`).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to get server:', error);
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Register a new server (with optional pre-generated ID)
|
||||
registerServer(server: Omit<ServerInfo, 'createdAt'> & { id?: string }): Observable<ServerInfo> {
|
||||
return this.http.post<ServerInfo>(`${this.baseUrl}/servers`, server).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to register server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Update server info
|
||||
updateServer(serverId: string, updates: Partial<ServerInfo>): Observable<ServerInfo> {
|
||||
return this.http.patch<ServerInfo>(`${this.baseUrl}/servers/${serverId}`, updates).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to update server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Remove server from directory
|
||||
unregisterServer(serverId: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/servers/${serverId}`).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to unregister server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Get users in a server
|
||||
getServerUsers(serverId: string): Observable<User[]> {
|
||||
return this.http.get<User[]>(`${this.baseUrl}/servers/${serverId}/users`).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to get server users:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Send join request
|
||||
requestJoin(request: JoinRequest): Observable<{ success: boolean; signalingUrl?: string }> {
|
||||
return this.http
|
||||
.post<{ success: boolean; signalingUrl?: string }>(
|
||||
`${this.baseUrl}/servers/${request.roomId}/join`,
|
||||
request
|
||||
)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to send join request:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Notify server of user leaving
|
||||
notifyLeave(serverId: string, userId: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/servers/${serverId}/leave`, { userId }).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to notify leave:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Update user count for a server
|
||||
updateUserCount(serverId: string, count: number): Observable<void> {
|
||||
return this.http.patch<void>(`${this.baseUrl}/servers/${serverId}/user-count`, { count }).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to update user count:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Heartbeat to keep server active in directory
|
||||
sendHeartbeat(serverId: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/servers/${serverId}/heartbeat`, {}).pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to send heartbeat:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Get the WebSocket URL for the active server
|
||||
getWebSocketUrl(): string {
|
||||
const active = this.activeServer();
|
||||
if (!active) return 'ws://localhost:3001';
|
||||
|
||||
// Convert http(s) to ws(s)
|
||||
return active.url.replace(/^http/, 'ws');
|
||||
}
|
||||
}
|
||||
45
src/app/core/services/time-sync.service.ts
Normal file
45
src/app/core/services/time-sync.service.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TimeSyncService {
|
||||
// serverTime - clientTime offset (milliseconds)
|
||||
private readonly _offset = signal<number>(0);
|
||||
private _lastSyncAt = 0;
|
||||
|
||||
readonly offset = computed(() => this._offset());
|
||||
|
||||
// Returns a server-adjusted now() using the current offset
|
||||
now(): number {
|
||||
return Date.now() + this._offset();
|
||||
}
|
||||
|
||||
// Set offset based on a serverTime observed at approximately receiveAt
|
||||
setFromServerTime(serverTime: number, receiveAt?: number): void {
|
||||
const observedAt = receiveAt ?? Date.now();
|
||||
const offset = serverTime - observedAt;
|
||||
this._offset.set(offset);
|
||||
this._lastSyncAt = Date.now();
|
||||
}
|
||||
|
||||
// Perform an HTTP-based sync using a simple NTP-style roundtrip
|
||||
async syncWithEndpoint(baseApiUrl: string, timeoutMs = 5000): Promise<void> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const t0 = Date.now();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const resp = await fetch(`${baseApiUrl}/time`, { signal: controller.signal });
|
||||
const t2 = Date.now();
|
||||
clearTimeout(timer);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
// Estimate one-way latency and offset
|
||||
const serverNow = Number(data?.now) || Date.now();
|
||||
const midpoint = (t0 + t2) / 2;
|
||||
const offset = serverNow - midpoint;
|
||||
this._offset.set(offset);
|
||||
this._lastSyncAt = Date.now();
|
||||
} catch {
|
||||
// ignore sync failures; retain last offset
|
||||
}
|
||||
}
|
||||
}
|
||||
813
src/app/core/services/webrtc.service.ts
Normal file
813
src/app/core/services/webrtc.service.ts
Normal file
@@ -0,0 +1,813 @@
|
||||
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { SignalingMessage, ChatEvent } from '../models';
|
||||
import { TimeSyncService } from './time-sync.service';
|
||||
|
||||
// ICE server configuration for NAT traversal
|
||||
const ICE_SERVERS: RTCIceServer[] = [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
{ urls: 'stun:stun2.l.google.com:19302' },
|
||||
{ urls: 'stun:stun3.l.google.com:19302' },
|
||||
{ urls: 'stun:stun4.l.google.com:19302' },
|
||||
];
|
||||
|
||||
interface PeerData {
|
||||
connection: RTCPeerConnection;
|
||||
dataChannel: RTCDataChannel | null;
|
||||
isInitiator: boolean;
|
||||
pendingCandidates: RTCIceCandidateInit[];
|
||||
audioSender?: RTCRtpSender;
|
||||
videoSender?: RTCRtpSender;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class WebRTCService {
|
||||
private timeSync = inject(TimeSyncService);
|
||||
private peers = new Map<string, PeerData>();
|
||||
private localStream: MediaStream | null = null;
|
||||
private _screenStream: MediaStream | null = null;
|
||||
private remoteStreams = new Map<string, MediaStream>();
|
||||
private signalingSocket: WebSocket | null = null;
|
||||
private lastWsUrl: string | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private reconnectTimer: any = null;
|
||||
private destroy$ = new Subject<void>();
|
||||
private outputVolume = 1;
|
||||
private currentServerId: string | null = null;
|
||||
private lastIdentify: { oderId: string; displayName: string } | null = null;
|
||||
private lastJoin: { serverId: string; userId: string } | null = null;
|
||||
|
||||
// Signals for reactive state
|
||||
private readonly _peerId = signal<string>(uuidv4());
|
||||
private readonly _isConnected = signal(false);
|
||||
private readonly _isVoiceConnected = signal(false);
|
||||
private readonly _connectedPeers = signal<string[]>([]);
|
||||
private readonly _isMuted = signal(false);
|
||||
private readonly _isDeafened = signal(false);
|
||||
private readonly _isScreenSharing = signal(false);
|
||||
private readonly _screenStreamSignal = signal<MediaStream | null>(null);
|
||||
|
||||
// Public computed signals
|
||||
readonly peerId = computed(() => this._peerId());
|
||||
readonly isConnected = computed(() => this._isConnected());
|
||||
readonly isVoiceConnected = computed(() => this._isVoiceConnected());
|
||||
readonly connectedPeers = computed(() => this._connectedPeers());
|
||||
readonly isMuted = computed(() => this._isMuted());
|
||||
readonly isDeafened = computed(() => this._isDeafened());
|
||||
readonly isScreenSharing = computed(() => this._isScreenSharing());
|
||||
readonly screenStream = computed(() => this._screenStreamSignal());
|
||||
|
||||
// Subjects for events
|
||||
private readonly messageReceived$ = new Subject<ChatEvent>();
|
||||
private readonly peerConnected$ = new Subject<string>();
|
||||
private readonly peerDisconnected$ = new Subject<string>();
|
||||
private readonly signalingMessage$ = new Subject<SignalingMessage>();
|
||||
private readonly remoteStream$ = new Subject<{ peerId: string; stream: MediaStream }>();
|
||||
|
||||
// Public observables
|
||||
readonly onMessageReceived = this.messageReceived$.asObservable();
|
||||
readonly onPeerConnected = this.peerConnected$.asObservable();
|
||||
readonly onPeerDisconnected = this.peerDisconnected$.asObservable();
|
||||
readonly onSignalingMessage = this.signalingMessage$.asObservable();
|
||||
readonly onRemoteStream = this.remoteStream$.asObservable();
|
||||
|
||||
// Accessor for remote screen/media streams by peer ID
|
||||
getRemoteStream(peerId: string): MediaStream | null {
|
||||
return this.remoteStreams.get(peerId) ?? null;
|
||||
}
|
||||
|
||||
// Connect to signaling server
|
||||
connectToSignalingServer(serverUrl: string): Observable<boolean> {
|
||||
return new Observable<boolean>((observer) => {
|
||||
try {
|
||||
// Close existing connection if any
|
||||
if (this.signalingSocket) {
|
||||
this.signalingSocket.close();
|
||||
}
|
||||
|
||||
this.lastWsUrl = serverUrl;
|
||||
this.signalingSocket = new WebSocket(serverUrl);
|
||||
|
||||
this.signalingSocket.onopen = () => {
|
||||
console.log('Connected to signaling server');
|
||||
this._isConnected.set(true);
|
||||
this.clearReconnect();
|
||||
// Re-identify and rejoin if we have prior context
|
||||
if (this.lastIdentify) {
|
||||
this.sendRawMessage({
|
||||
type: 'identify',
|
||||
oderId: this.lastIdentify.oderId,
|
||||
displayName: this.lastIdentify.displayName,
|
||||
});
|
||||
}
|
||||
if (this.lastJoin) {
|
||||
this.sendRawMessage({
|
||||
type: 'join_server',
|
||||
serverId: this.lastJoin.serverId,
|
||||
});
|
||||
}
|
||||
observer.next(true);
|
||||
};
|
||||
|
||||
this.signalingSocket.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
this.handleSignalingMessage(message);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse signaling message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.signalingSocket.onerror = (error) => {
|
||||
console.error('Signaling socket error:', error);
|
||||
observer.error(error);
|
||||
};
|
||||
|
||||
this.signalingSocket.onclose = () => {
|
||||
console.log('Disconnected from signaling server');
|
||||
this._isConnected.set(false);
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
} catch (error) {
|
||||
observer.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Send signaling message
|
||||
sendSignalingMessage(message: Omit<SignalingMessage, 'from' | 'timestamp'>): void {
|
||||
if (!this.signalingSocket || this.signalingSocket.readyState !== WebSocket.OPEN) {
|
||||
console.error('Signaling socket not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
const fullMessage: SignalingMessage = {
|
||||
...message,
|
||||
from: this._peerId(),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.signalingSocket.send(JSON.stringify(fullMessage));
|
||||
}
|
||||
|
||||
// Send raw message to server (for identify, join_server, etc.)
|
||||
sendRawMessage(message: Record<string, unknown>): void {
|
||||
if (!this.signalingSocket || this.signalingSocket.readyState !== WebSocket.OPEN) {
|
||||
console.error('Signaling socket not connected');
|
||||
return;
|
||||
}
|
||||
this.signalingSocket.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
// Create peer connection using native WebRTC
|
||||
private createPeerConnection(remotePeerId: string, isInitiator: boolean): PeerData {
|
||||
console.log(`Creating peer connection to ${remotePeerId}, initiator: ${isInitiator}`);
|
||||
|
||||
const connection = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
||||
let dataChannel: RTCDataChannel | null = null;
|
||||
|
||||
// Handle ICE candidates
|
||||
connection.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
console.log('Sending ICE candidate to:', remotePeerId);
|
||||
this.sendRawMessage({
|
||||
type: 'ice_candidate',
|
||||
targetUserId: remotePeerId,
|
||||
payload: { candidate: event.candidate },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle connection state changes
|
||||
connection.onconnectionstatechange = () => {
|
||||
console.log(`Connection state with ${remotePeerId}:`, connection.connectionState);
|
||||
if (connection.connectionState === 'connected') {
|
||||
this._connectedPeers.update((peers) =>
|
||||
peers.includes(remotePeerId) ? peers : [...peers, remotePeerId]
|
||||
);
|
||||
this.peerConnected$.next(remotePeerId);
|
||||
} else if (connection.connectionState === 'disconnected' ||
|
||||
connection.connectionState === 'failed' ||
|
||||
connection.connectionState === 'closed') {
|
||||
this.removePeer(remotePeerId);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle incoming tracks (audio/video)
|
||||
connection.ontrack = (event) => {
|
||||
console.log(`Received track from ${remotePeerId}:`, event.track.kind);
|
||||
if (event.streams[0]) {
|
||||
this.remoteStreams.set(remotePeerId, event.streams[0]);
|
||||
this.remoteStream$.next({ peerId: remotePeerId, stream: event.streams[0] });
|
||||
}
|
||||
};
|
||||
|
||||
// If initiator, create data channel
|
||||
if (isInitiator) {
|
||||
dataChannel = connection.createDataChannel('chat', { ordered: true });
|
||||
this.setupDataChannel(dataChannel, remotePeerId);
|
||||
} else {
|
||||
// If not initiator, wait for data channel from remote peer
|
||||
connection.ondatachannel = (event) => {
|
||||
console.log('Received data channel from:', remotePeerId);
|
||||
dataChannel = event.channel;
|
||||
this.setupDataChannel(dataChannel, remotePeerId);
|
||||
// Update the peer data with the new channel
|
||||
const existing = this.peers.get(remotePeerId);
|
||||
if (existing) {
|
||||
existing.dataChannel = dataChannel;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Create and register peer data before adding local tracks
|
||||
const peerData: PeerData = { connection, dataChannel, isInitiator, pendingCandidates: [] };
|
||||
this.peers.set(remotePeerId, peerData);
|
||||
|
||||
// Add local stream if available
|
||||
if (this.localStream) {
|
||||
this.localStream.getTracks().forEach((track) => {
|
||||
const sender = connection.addTrack(track, this.localStream!);
|
||||
if (track.kind === 'audio') peerData.audioSender = sender;
|
||||
if (track.kind === 'video') peerData.videoSender = sender;
|
||||
});
|
||||
}
|
||||
|
||||
return peerData;
|
||||
}
|
||||
|
||||
// Setup data channel event handlers
|
||||
private setupDataChannel(channel: RTCDataChannel, remotePeerId: string): void {
|
||||
channel.onopen = () => {
|
||||
console.log(`Data channel open with ${remotePeerId}`);
|
||||
};
|
||||
|
||||
channel.onclose = () => {
|
||||
console.log(`Data channel closed with ${remotePeerId}`);
|
||||
};
|
||||
|
||||
channel.onerror = (error) => {
|
||||
console.error(`Data channel error with ${remotePeerId}:`, error);
|
||||
};
|
||||
|
||||
channel.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
this.handlePeerMessage(remotePeerId, message);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse peer message:', error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Create and send offer
|
||||
private async createOffer(remotePeerId: string): Promise<void> {
|
||||
const peerData = this.peers.get(remotePeerId);
|
||||
if (!peerData) return;
|
||||
|
||||
try {
|
||||
const offer = await peerData.connection.createOffer();
|
||||
await peerData.connection.setLocalDescription(offer);
|
||||
|
||||
console.log('Sending offer to:', remotePeerId);
|
||||
this.sendRawMessage({
|
||||
type: 'offer',
|
||||
targetUserId: remotePeerId,
|
||||
payload: { sdp: offer },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create offer:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incoming offer
|
||||
private async handleOffer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise<void> {
|
||||
console.log('Handling offer from:', fromUserId);
|
||||
|
||||
let peerData = this.peers.get(fromUserId);
|
||||
if (!peerData) {
|
||||
peerData = this.createPeerConnection(fromUserId, false);
|
||||
}
|
||||
|
||||
try {
|
||||
await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp));
|
||||
|
||||
// Process any pending ICE candidates
|
||||
for (const candidate of peerData.pendingCandidates) {
|
||||
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
}
|
||||
peerData.pendingCandidates = [];
|
||||
|
||||
const answer = await peerData.connection.createAnswer();
|
||||
await peerData.connection.setLocalDescription(answer);
|
||||
|
||||
console.log('Sending answer to:', fromUserId);
|
||||
this.sendRawMessage({
|
||||
type: 'answer',
|
||||
targetUserId: fromUserId,
|
||||
payload: { sdp: answer },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to handle offer:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incoming answer
|
||||
private async handleAnswer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise<void> {
|
||||
console.log('Handling answer from:', fromUserId);
|
||||
|
||||
const peerData = this.peers.get(fromUserId);
|
||||
if (!peerData) {
|
||||
console.error('No peer connection for answer from:', fromUserId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Only set remote description if we're in the right state
|
||||
if (peerData.connection.signalingState === 'have-local-offer') {
|
||||
await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp));
|
||||
|
||||
// Process any pending ICE candidates
|
||||
for (const candidate of peerData.pendingCandidates) {
|
||||
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
}
|
||||
peerData.pendingCandidates = [];
|
||||
} else {
|
||||
console.warn('Ignoring answer - wrong signaling state:', peerData.connection.signalingState);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle answer:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incoming ICE candidate
|
||||
private async handleIceCandidate(fromUserId: string, candidate: RTCIceCandidateInit): Promise<void> {
|
||||
let peerData = this.peers.get(fromUserId);
|
||||
if (!peerData) {
|
||||
// Create peer connection if it doesn't exist yet (candidate arrived before offer)
|
||||
console.log('Creating peer connection for early ICE candidate from:', fromUserId);
|
||||
peerData = this.createPeerConnection(fromUserId, false);
|
||||
}
|
||||
|
||||
try {
|
||||
if (peerData.connection.remoteDescription) {
|
||||
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
} else {
|
||||
// Queue the candidate for later
|
||||
console.log('Queuing ICE candidate from:', fromUserId);
|
||||
peerData.pendingCandidates.push(candidate);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add ICE candidate:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incoming signaling messages
|
||||
private handleSignalingMessage(message: any): void {
|
||||
this.signalingMessage$.next(message);
|
||||
console.log('Received signaling message:', message.type, message);
|
||||
|
||||
switch (message.type) {
|
||||
case 'connected':
|
||||
console.log('Server connection acknowledged, oderId:', message.oderId);
|
||||
if (typeof message.serverTime === 'number') {
|
||||
this.timeSync.setFromServerTime(message.serverTime);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'server_users':
|
||||
console.log('Users in server:', message.users);
|
||||
if (message.users && Array.isArray(message.users)) {
|
||||
message.users.forEach((user: { oderId: string; displayName: string }) => {
|
||||
if (user.oderId && !this.peers.has(user.oderId)) {
|
||||
console.log('Creating peer connection to existing user:', user.oderId);
|
||||
this.createPeerConnection(user.oderId, true);
|
||||
// Create and send offer
|
||||
this.createOffer(user.oderId);
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'user_joined':
|
||||
console.log('User joined:', message.displayName, message.oderId);
|
||||
// Don't create connection here - the new user will initiate to us
|
||||
break;
|
||||
|
||||
case 'user_left':
|
||||
console.log('User left:', message.displayName, message.oderId);
|
||||
this.removePeer(message.oderId);
|
||||
break;
|
||||
|
||||
case 'offer':
|
||||
if (message.fromUserId && message.payload?.sdp) {
|
||||
this.handleOffer(message.fromUserId, message.payload.sdp);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'answer':
|
||||
if (message.fromUserId && message.payload?.sdp) {
|
||||
this.handleAnswer(message.fromUserId, message.payload.sdp);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ice_candidate':
|
||||
if (message.fromUserId && message.payload?.candidate) {
|
||||
this.handleIceCandidate(message.fromUserId, message.payload.candidate);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Set current server ID for message routing
|
||||
setCurrentServer(serverId: string): void {
|
||||
this.currentServerId = serverId;
|
||||
}
|
||||
|
||||
// Get a snapshot of currently connected peer IDs
|
||||
getConnectedPeers(): string[] {
|
||||
return this._connectedPeers();
|
||||
}
|
||||
|
||||
// Identify and remember credentials
|
||||
identify(oderId: string, displayName: string): void {
|
||||
this.lastIdentify = { oderId, displayName };
|
||||
this.sendRawMessage({ type: 'identify', oderId, displayName });
|
||||
}
|
||||
|
||||
// Handle messages from peers
|
||||
private handlePeerMessage(peerId: string, message: any): void {
|
||||
console.log('Received P2P message from', peerId, ':', message);
|
||||
const enriched = { ...message, fromPeerId: peerId };
|
||||
this.messageReceived$.next(enriched);
|
||||
}
|
||||
|
||||
// Send message to all connected peers via P2P only
|
||||
broadcastMessage(event: ChatEvent): void {
|
||||
const data = JSON.stringify(event);
|
||||
|
||||
this.peers.forEach((peerData, peerId) => {
|
||||
try {
|
||||
if (peerData.dataChannel && peerData.dataChannel.readyState === 'open') {
|
||||
peerData.dataChannel.send(data);
|
||||
console.log('Sent message via P2P to:', peerId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to send to peer ${peerId}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Send message to specific peer
|
||||
sendToPeer(peerId: string, event: ChatEvent): void {
|
||||
const peerData = this.peers.get(peerId);
|
||||
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== 'open') {
|
||||
console.error(`Peer ${peerId} not connected`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.stringify(event);
|
||||
peerData.dataChannel.send(data);
|
||||
} catch (error) {
|
||||
console.error(`Failed to send to peer ${peerId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove peer connection
|
||||
private removePeer(peerId: string): void {
|
||||
const peerData = this.peers.get(peerId);
|
||||
if (peerData) {
|
||||
if (peerData.dataChannel) {
|
||||
peerData.dataChannel.close();
|
||||
}
|
||||
peerData.connection.close();
|
||||
this.peers.delete(peerId);
|
||||
this._connectedPeers.update((peers) => peers.filter((p) => p !== peerId));
|
||||
this.peerDisconnected$.next(peerId);
|
||||
}
|
||||
}
|
||||
|
||||
// Voice chat - get user media
|
||||
async enableVoice(): Promise<MediaStream> {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
video: false,
|
||||
});
|
||||
|
||||
this.localStream = stream;
|
||||
|
||||
// Add stream to all existing peers and renegotiate
|
||||
this.peers.forEach((peerData, peerId) => {
|
||||
if (this.localStream) {
|
||||
this.localStream.getTracks().forEach((track) => {
|
||||
peerData.connection.addTrack(track, this.localStream!);
|
||||
});
|
||||
|
||||
// Renegotiate to send the new tracks (both sides need to renegotiate)
|
||||
this.renegotiate(peerId);
|
||||
}
|
||||
});
|
||||
|
||||
this._isVoiceConnected.set(true);
|
||||
return this.localStream;
|
||||
} catch (error) {
|
||||
console.error('Failed to get user media:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Disable voice (stop and remove audio tracks)
|
||||
disableVoice(): void {
|
||||
if (this.localStream) {
|
||||
this.localStream.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
});
|
||||
this.localStream = null;
|
||||
}
|
||||
|
||||
// Remove audio senders from peer connections but keep connections open
|
||||
this.peers.forEach((peerData) => {
|
||||
const senders = peerData.connection.getSenders();
|
||||
senders.forEach(sender => {
|
||||
if (sender.track?.kind === 'audio') {
|
||||
peerData.connection.removeTrack(sender);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Update voice connection state
|
||||
this._isVoiceConnected.set(false);
|
||||
}
|
||||
|
||||
// Screen sharing
|
||||
async startScreenShare(): Promise<MediaStream> {
|
||||
try {
|
||||
// Check if Electron API is available for desktop capturer
|
||||
if (typeof window !== 'undefined' && (window as any).electronAPI?.getSources) {
|
||||
const sources = await (window as any).electronAPI.getSources();
|
||||
const screenSource = sources.find((s: any) => s.name === 'Entire Screen') || sources[0];
|
||||
|
||||
this._screenStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
mandatory: {
|
||||
chromeMediaSource: 'desktop',
|
||||
chromeMediaSourceId: screenSource.id,
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
} else {
|
||||
// Fallback to standard getDisplayMedia (no system audio to preserve mic)
|
||||
this._screenStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: {
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 },
|
||||
frameRate: { ideal: 30 },
|
||||
},
|
||||
audio: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Add/replace screen video track to all peers and renegotiate
|
||||
this.peers.forEach((peerData, peerId) => {
|
||||
if (this._screenStream) {
|
||||
const videoTrack = this._screenStream.getVideoTracks()[0];
|
||||
if (!videoTrack) return;
|
||||
|
||||
const sender = peerData.connection.getSenders().find(s => s.track?.kind === 'video');
|
||||
if (sender) {
|
||||
sender.replaceTrack(videoTrack).catch((e) => console.error('replaceTrack failed:', e));
|
||||
} else {
|
||||
peerData.connection.addTrack(videoTrack, this._screenStream!);
|
||||
}
|
||||
|
||||
// Renegotiate to ensure remote receives video
|
||||
this.renegotiate(peerId);
|
||||
}
|
||||
});
|
||||
|
||||
this._isScreenSharing.set(true);
|
||||
this._screenStreamSignal.set(this._screenStream);
|
||||
|
||||
// Handle when user stops sharing via browser UI
|
||||
this._screenStream.getVideoTracks()[0].onended = () => {
|
||||
this.stopScreenShare();
|
||||
};
|
||||
|
||||
return this._screenStream;
|
||||
} catch (error) {
|
||||
console.error('Failed to start screen share:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Stop screen sharing
|
||||
stopScreenShare(): void {
|
||||
if (this._screenStream) {
|
||||
this._screenStream.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
});
|
||||
this._screenStream = null;
|
||||
this._screenStreamSignal.set(null);
|
||||
this._isScreenSharing.set(false);
|
||||
}
|
||||
|
||||
// Remove sent video tracks from peers and renegotiate back to audio-only
|
||||
this.peers.forEach((peerData, peerId) => {
|
||||
const senders = peerData.connection.getSenders();
|
||||
senders.forEach(sender => {
|
||||
if (sender.track?.kind === 'video') {
|
||||
peerData.connection.removeTrack(sender);
|
||||
}
|
||||
});
|
||||
this.renegotiate(peerId);
|
||||
});
|
||||
}
|
||||
|
||||
// Join a room
|
||||
joinRoom(roomId: string, userId: string): void {
|
||||
this.lastJoin = { serverId: roomId, userId };
|
||||
this.sendRawMessage({
|
||||
type: 'join_server',
|
||||
serverId: roomId,
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer || !this.lastWsUrl) return;
|
||||
const delay = Math.min(30000, 1000 * Math.pow(2, this.reconnectAttempts)); // 1s,2s,4s.. up to 30s
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.reconnectAttempts++;
|
||||
console.log('Attempting to reconnect to signaling...');
|
||||
this.connectToSignalingServer(this.lastWsUrl!).subscribe({
|
||||
next: () => {
|
||||
this.reconnectAttempts = 0;
|
||||
},
|
||||
error: () => {
|
||||
// schedule next attempt
|
||||
this.scheduleReconnect();
|
||||
},
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private clearReconnect(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this.reconnectAttempts = 0;
|
||||
}
|
||||
|
||||
// Leave room
|
||||
leaveRoom(): void {
|
||||
this.sendRawMessage({
|
||||
type: 'leave_server',
|
||||
});
|
||||
|
||||
// Close all peer connections
|
||||
this.peers.forEach((peerData, peerId) => {
|
||||
if (peerData.dataChannel) {
|
||||
peerData.dataChannel.close();
|
||||
}
|
||||
peerData.connection.close();
|
||||
});
|
||||
this.peers.clear();
|
||||
this._connectedPeers.set([]);
|
||||
|
||||
// Stop all media
|
||||
this.disableVoice();
|
||||
this.stopScreenShare();
|
||||
}
|
||||
|
||||
// Disconnect from signaling server
|
||||
disconnect(): void {
|
||||
this.leaveRoom();
|
||||
|
||||
if (this.signalingSocket) {
|
||||
this.signalingSocket.close();
|
||||
this.signalingSocket = null;
|
||||
}
|
||||
|
||||
this._isConnected.set(false);
|
||||
this.destroy$.next();
|
||||
}
|
||||
|
||||
// Alias for disconnect - used by components
|
||||
disconnectAll(): void {
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
// Set local media stream from external source
|
||||
setLocalStream(stream: MediaStream): void {
|
||||
this.localStream = stream;
|
||||
|
||||
// Add stream to all existing peers and renegotiate
|
||||
this.peers.forEach((peerData, peerId) => {
|
||||
if (this.localStream) {
|
||||
// Remove existing audio tracks first
|
||||
const senders = peerData.connection.getSenders();
|
||||
senders.forEach(sender => {
|
||||
if (sender.track?.kind === 'audio') {
|
||||
peerData.connection.removeTrack(sender);
|
||||
}
|
||||
});
|
||||
|
||||
// Add new tracks
|
||||
this.localStream.getTracks().forEach((track) => {
|
||||
const sender = peerData.connection.addTrack(track, this.localStream!);
|
||||
if (track.kind === 'audio') peerData.audioSender = sender;
|
||||
if (track.kind === 'video') peerData.videoSender = sender;
|
||||
});
|
||||
|
||||
// Renegotiate to send the new tracks (both sides need to renegotiate)
|
||||
this.renegotiate(peerId);
|
||||
}
|
||||
});
|
||||
|
||||
this._isVoiceConnected.set(true);
|
||||
}
|
||||
|
||||
// Renegotiate connection (for adding/removing tracks)
|
||||
private async renegotiate(peerId: string): Promise<void> {
|
||||
const peerData = this.peers.get(peerId);
|
||||
if (!peerData) return;
|
||||
|
||||
try {
|
||||
const offer = await peerData.connection.createOffer();
|
||||
await peerData.connection.setLocalDescription(offer);
|
||||
|
||||
console.log('Sending renegotiation offer to:', peerId);
|
||||
this.sendRawMessage({
|
||||
type: 'offer',
|
||||
targetUserId: peerId,
|
||||
payload: { sdp: offer },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to renegotiate:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle mute with explicit state
|
||||
toggleMute(muted?: boolean): void {
|
||||
if (this.localStream) {
|
||||
const audioTracks = this.localStream.getAudioTracks();
|
||||
const newMutedState = muted !== undefined ? muted : !this._isMuted();
|
||||
audioTracks.forEach((track) => {
|
||||
track.enabled = !newMutedState;
|
||||
});
|
||||
this._isMuted.set(newMutedState);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle deafen state
|
||||
toggleDeafen(deafened?: boolean): void {
|
||||
const newDeafenedState = deafened !== undefined ? deafened : !this._isDeafened();
|
||||
this._isDeafened.set(newDeafenedState);
|
||||
}
|
||||
|
||||
// Set output volume for remote streams
|
||||
setOutputVolume(volume: number): void {
|
||||
this.outputVolume = Math.max(0, Math.min(1, volume));
|
||||
}
|
||||
|
||||
// Latency/bitrate controls for audio
|
||||
async setAudioBitrate(kbps: number): Promise<void> {
|
||||
const bps = Math.max(16000, Math.min(256000, Math.floor(kbps * 1000)));
|
||||
this.peers.forEach(async (peerData) => {
|
||||
const sender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === 'audio');
|
||||
if (!sender) return;
|
||||
const params = sender.getParameters();
|
||||
params.encodings = params.encodings || [{}];
|
||||
params.encodings[0].maxBitrate = bps;
|
||||
try {
|
||||
await sender.setParameters(params);
|
||||
console.log('Applied audio bitrate:', bps);
|
||||
} catch (e) {
|
||||
console.warn('Failed to set audio bitrate', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async setLatencyProfile(profile: 'low' | 'balanced' | 'high'): Promise<void> {
|
||||
const map = { low: 64000, balanced: 96000, high: 128000 } as const;
|
||||
await this.setAudioBitrate(map[profile]);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
ngOnDestroy(): void {
|
||||
this.disconnect();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
467
src/app/features/admin/admin-panel.component.ts
Normal file
467
src/app/features/admin/admin-panel.component.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideShield,
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideTrash2,
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
lucideLock,
|
||||
lucideUnlock,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import * as UsersActions from '../../store/users/users.actions';
|
||||
import * as RoomsActions from '../../store/rooms/rooms.actions';
|
||||
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||
import {
|
||||
selectBannedUsers,
|
||||
selectIsCurrentUserAdmin,
|
||||
selectCurrentUser,
|
||||
} from '../../store/users/users.selectors';
|
||||
import { BanEntry, Room } from '../../core/models';
|
||||
|
||||
type AdminTab = 'settings' | 'bans' | 'permissions';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideShield,
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideTrash2,
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
lucideLock,
|
||||
lucideUnlock,
|
||||
}),
|
||||
],
|
||||
template: `
|
||||
@if (isAdmin()) {
|
||||
<div class="h-full flex flex-col bg-card">
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-border flex items-center gap-2">
|
||||
<ng-icon name="lucideShield" class="w-5 h-5 text-primary" />
|
||||
<h2 class="font-semibold text-foreground">Admin Panel</h2>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-border">
|
||||
<button
|
||||
(click)="activeTab.set('settings')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
[class.text-primary]="activeTab() === 'settings'"
|
||||
[class.border-b-2]="activeTab() === 'settings'"
|
||||
[class.border-primary]="activeTab() === 'settings'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'settings'"
|
||||
>
|
||||
<ng-icon name="lucideSettings" class="w-4 h-4 inline mr-1" />
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
(click)="activeTab.set('bans')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
[class.text-primary]="activeTab() === 'bans'"
|
||||
[class.border-b-2]="activeTab() === 'bans'"
|
||||
[class.border-primary]="activeTab() === 'bans'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'bans'"
|
||||
>
|
||||
<ng-icon name="lucideBan" class="w-4 h-4 inline mr-1" />
|
||||
Bans
|
||||
</button>
|
||||
<button
|
||||
(click)="activeTab.set('permissions')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
[class.text-primary]="activeTab() === 'permissions'"
|
||||
[class.border-b-2]="activeTab() === 'permissions'"
|
||||
[class.border-primary]="activeTab() === 'permissions'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'permissions'"
|
||||
>
|
||||
<ng-icon name="lucideUsers" class="w-4 h-4 inline mr-1" />
|
||||
Permissions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
@switch (activeTab()) {
|
||||
@case ('settings') {
|
||||
<div class="space-y-6">
|
||||
<h3 class="text-sm font-medium text-foreground">Room Settings</h3>
|
||||
|
||||
<!-- Room Name -->
|
||||
<div>
|
||||
<label class="block text-sm text-muted-foreground mb-1">Room Name</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="roomName"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Room Description -->
|
||||
<div>
|
||||
<label class="block text-sm text-muted-foreground mb-1">Description</label>
|
||||
<textarea
|
||||
[(ngModel)]="roomDescription"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Private Room Toggle -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Private Room</p>
|
||||
<p class="text-xs text-muted-foreground">Require approval to join</p>
|
||||
</div>
|
||||
<button
|
||||
(click)="togglePrivate()"
|
||||
class="p-2 rounded-lg transition-colors"
|
||||
[class.bg-primary]="isPrivate()"
|
||||
[class.text-primary-foreground]="isPrivate()"
|
||||
[class.bg-secondary]="!isPrivate()"
|
||||
[class.text-muted-foreground]="!isPrivate()"
|
||||
>
|
||||
@if (isPrivate()) {
|
||||
<ng-icon name="lucideLock" class="w-4 h-4" />
|
||||
} @else {
|
||||
<ng-icon name="lucideUnlock" class="w-4 h-4" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Max Users -->
|
||||
<div>
|
||||
<label class="block text-sm text-muted-foreground mb-1">Max Users (0 = unlimited)</label>
|
||||
<input
|
||||
type="number"
|
||||
[(ngModel)]="maxUsers"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<button
|
||||
(click)="saveSettings()"
|
||||
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<ng-icon name="lucideCheck" class="w-4 h-4" />
|
||||
Save Settings
|
||||
</button>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<div class="pt-4 border-t border-border">
|
||||
<h3 class="text-sm font-medium text-destructive mb-4">Danger Zone</h3>
|
||||
<button
|
||||
(click)="confirmDeleteRoom()"
|
||||
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<ng-icon name="lucideTrash2" class="w-4 h-4" />
|
||||
Delete Room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case ('bans') {
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-foreground">Banned Users</h3>
|
||||
|
||||
@if (bannedUsers().length === 0) {
|
||||
<p class="text-sm text-muted-foreground text-center py-8">
|
||||
No banned users
|
||||
</p>
|
||||
} @else {
|
||||
@for (ban of bannedUsers(); track ban.oderId) {
|
||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
||||
<div class="w-8 h-8 rounded-full bg-destructive/20 flex items-center justify-center text-destructive font-semibold text-sm">
|
||||
{{ ban.displayName?.charAt(0)?.toUpperCase() || '?' }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-foreground truncate">
|
||||
{{ ban.displayName || 'Unknown User' }}
|
||||
</p>
|
||||
@if (ban.reason) {
|
||||
<p class="text-xs text-muted-foreground truncate">
|
||||
Reason: {{ ban.reason }}
|
||||
</p>
|
||||
}
|
||||
@if (ban.expiresAt) {
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Expires: {{ formatExpiry(ban.expiresAt) }}
|
||||
</p>
|
||||
} @else {
|
||||
<p class="text-xs text-destructive">Permanent</p>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
(click)="unbanUser(ban)"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="lucideX" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('permissions') {
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-foreground">Room Permissions</h3>
|
||||
|
||||
<!-- Permission Toggles -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Allow Voice Chat</p>
|
||||
<p class="text-xs text-muted-foreground">Users can join voice channels</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="allowVoice"
|
||||
class="w-4 h-4 accent-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Allow Screen Share</p>
|
||||
<p class="text-xs text-muted-foreground">Users can share their screen</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="allowScreenShare"
|
||||
class="w-4 h-4 accent-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Allow File Uploads</p>
|
||||
<p class="text-xs text-muted-foreground">Users can upload files</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="allowFileUploads"
|
||||
class="w-4 h-4 accent-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Slow Mode</p>
|
||||
<p class="text-xs text-muted-foreground">Limit message frequency</p>
|
||||
</div>
|
||||
<select
|
||||
[(ngModel)]="slowModeInterval"
|
||||
class="px-3 py-1 bg-secondary rounded border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="0">Off</option>
|
||||
<option value="5">5 seconds</option>
|
||||
<option value="10">10 seconds</option>
|
||||
<option value="30">30 seconds</option>
|
||||
<option value="60">1 minute</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Management Permissions -->
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Admins Can Manage Rooms</p>
|
||||
<p class="text-xs text-muted-foreground">Allow admins to create/modify chat & voice rooms</p>
|
||||
</div>
|
||||
<input type="checkbox" [(ngModel)]="adminsManageRooms" class="w-4 h-4 accent-primary" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Moderators Can Manage Rooms</p>
|
||||
<p class="text-xs text-muted-foreground">Allow moderators to create/modify chat & voice rooms</p>
|
||||
</div>
|
||||
<input type="checkbox" [(ngModel)]="moderatorsManageRooms" class="w-4 h-4 accent-primary" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Admins Can Change Server Icon</p>
|
||||
<p class="text-xs text-muted-foreground">Grant icon management to admins</p>
|
||||
</div>
|
||||
<input type="checkbox" [(ngModel)]="adminsManageIcon" class="w-4 h-4 accent-primary" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Moderators Can Change Server Icon</p>
|
||||
<p class="text-xs text-muted-foreground">Grant icon management to moderators</p>
|
||||
</div>
|
||||
<input type="checkbox" [(ngModel)]="moderatorsManageIcon" class="w-4 h-4 accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Permissions -->
|
||||
<button
|
||||
(click)="savePermissions()"
|
||||
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<ng-icon name="lucideCheck" class="w-4 h-4" />
|
||||
Save Permissions
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
@if (showDeleteConfirm()) {
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="showDeleteConfirm.set(false)">
|
||||
<div class="bg-card border border-border rounded-lg p-6 w-96 max-w-[90vw]" (click)="$event.stopPropagation()">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-2">Delete Room</h3>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Are you sure you want to delete this room? This action cannot be undone.
|
||||
</p>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button
|
||||
(click)="showDeleteConfirm.set(false)"
|
||||
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
(click)="deleteRoom()"
|
||||
class="px-4 py-2 bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
|
||||
>
|
||||
Delete Room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="h-full flex items-center justify-center text-muted-foreground">
|
||||
<p>You don't have admin permissions</p>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class AdminPanelComponent {
|
||||
private store = inject(Store);
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
bannedUsers = this.store.selectSignal(selectBannedUsers);
|
||||
|
||||
activeTab = signal<AdminTab>('settings');
|
||||
showDeleteConfirm = signal(false);
|
||||
|
||||
// Settings
|
||||
roomName = '';
|
||||
roomDescription = '';
|
||||
isPrivate = signal(false);
|
||||
maxUsers = 0;
|
||||
|
||||
// Permissions
|
||||
allowVoice = true;
|
||||
allowScreenShare = true;
|
||||
allowFileUploads = true;
|
||||
slowModeInterval = '0';
|
||||
adminsManageRooms = false;
|
||||
moderatorsManageRooms = false;
|
||||
adminsManageIcon = false;
|
||||
moderatorsManageIcon = false;
|
||||
|
||||
constructor() {
|
||||
// Initialize from current room
|
||||
const room = this.currentRoom();
|
||||
if (room) {
|
||||
this.roomName = room.name;
|
||||
this.roomDescription = room.description || '';
|
||||
this.isPrivate.set(room.isPrivate);
|
||||
this.maxUsers = room.maxUsers || 0;
|
||||
const perms = room.permissions || {};
|
||||
this.allowVoice = perms.allowVoice !== false;
|
||||
this.allowScreenShare = perms.allowScreenShare !== false;
|
||||
this.allowFileUploads = perms.allowFileUploads !== false;
|
||||
this.slowModeInterval = String(perms.slowModeInterval ?? 0);
|
||||
this.adminsManageRooms = !!perms.adminsManageRooms;
|
||||
this.moderatorsManageRooms = !!perms.moderatorsManageRooms;
|
||||
this.adminsManageIcon = !!perms.adminsManageIcon;
|
||||
this.moderatorsManageIcon = !!perms.moderatorsManageIcon;
|
||||
}
|
||||
}
|
||||
|
||||
togglePrivate(): void {
|
||||
this.isPrivate.update((v) => !v);
|
||||
}
|
||||
|
||||
saveSettings(): void {
|
||||
const room = this.currentRoom();
|
||||
if (!room) return;
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.updateRoom({
|
||||
roomId: room.id,
|
||||
changes: {
|
||||
name: this.roomName,
|
||||
description: this.roomDescription,
|
||||
isPrivate: this.isPrivate(),
|
||||
maxUsers: this.maxUsers,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
savePermissions(): void {
|
||||
const room = this.currentRoom();
|
||||
if (!room) return;
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.updateRoomPermissions({
|
||||
roomId: room.id,
|
||||
permissions: {
|
||||
allowVoice: this.allowVoice,
|
||||
allowScreenShare: this.allowScreenShare,
|
||||
allowFileUploads: this.allowFileUploads,
|
||||
slowModeInterval: parseInt(this.slowModeInterval, 10),
|
||||
adminsManageRooms: this.adminsManageRooms,
|
||||
moderatorsManageRooms: this.moderatorsManageRooms,
|
||||
adminsManageIcon: this.adminsManageIcon,
|
||||
moderatorsManageIcon: this.moderatorsManageIcon,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
unbanUser(ban: BanEntry): void {
|
||||
this.store.dispatch(UsersActions.unbanUser({ oderId: ban.oderId }));
|
||||
}
|
||||
|
||||
confirmDeleteRoom(): void {
|
||||
this.showDeleteConfirm.set(true);
|
||||
}
|
||||
|
||||
deleteRoom(): void {
|
||||
const room = this.currentRoom();
|
||||
if (!room) return;
|
||||
|
||||
this.store.dispatch(RoomsActions.deleteRoom({ roomId: room.id }));
|
||||
this.showDeleteConfirm.set(false);
|
||||
}
|
||||
|
||||
formatExpiry(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
}
|
||||
94
src/app/features/auth/login.component.ts
Normal file
94
src/app/features/auth/login.component.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideLogIn } from '@ng-icons/lucide';
|
||||
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||
import * as UsersActions from '../../store/users/users.actions';
|
||||
import { User } from '../../core/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideLogIn })],
|
||||
template: `
|
||||
<div class="h-full grid place-items-center bg-background">
|
||||
<div class="w-[360px] bg-card border border-border rounded-xl p-6 shadow-sm">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<ng-icon name="lucideLogIn" class="w-5 h-5 text-primary" />
|
||||
<h1 class="text-lg font-semibold text-foreground">Login</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Username</label>
|
||||
<input [(ngModel)]="username" type="text" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Password</label>
|
||||
<input [(ngModel)]="password" type="password" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Server App</label>
|
||||
<select [(ngModel)]="serverId" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground">
|
||||
<option *ngFor="let s of servers(); trackBy: trackById" [value]="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<p *ngIf="error()" class="text-xs text-destructive">{{ error() }}</p>
|
||||
<button (click)="submit()" class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90">Login</button>
|
||||
<div class="text-xs text-muted-foreground text-center mt-2">
|
||||
No account? <a class="text-primary hover:underline" (click)="goRegister()">Register</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LoginComponent {
|
||||
private auth = inject(AuthService);
|
||||
private serversSvc = inject(ServerDirectoryService);
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
|
||||
servers = this.serversSvc.servers;
|
||||
username = '';
|
||||
password = '';
|
||||
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
||||
error = signal<string | null>(null);
|
||||
|
||||
trackById(_index: number, item: { id: string }) { return item.id; }
|
||||
|
||||
submit() {
|
||||
this.error.set(null);
|
||||
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
||||
this.auth.login({ username: this.username.trim(), password: this.password, serverId: sid }).subscribe({
|
||||
next: (resp) => {
|
||||
if (sid) this.serversSvc.setActiveServer(sid);
|
||||
const user: User = {
|
||||
id: resp.id,
|
||||
oderId: resp.id,
|
||||
username: resp.username,
|
||||
displayName: resp.displayName,
|
||||
status: 'online',
|
||||
role: 'member',
|
||||
joinedAt: Date.now(),
|
||||
};
|
||||
try { localStorage.setItem('metoyou_currentUserId', resp.id); } catch {}
|
||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
||||
this.router.navigate(['/search']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || 'Login failed');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
goRegister() {
|
||||
this.router.navigate(['/register']);
|
||||
}
|
||||
}
|
||||
99
src/app/features/auth/register.component.ts
Normal file
99
src/app/features/auth/register.component.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideUserPlus } from '@ng-icons/lucide';
|
||||
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||
import * as UsersActions from '../../store/users/users.actions';
|
||||
import { User } from '../../core/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideUserPlus })],
|
||||
template: `
|
||||
<div class="h-full grid place-items-center bg-background">
|
||||
<div class="w-[380px] bg-card border border-border rounded-xl p-6 shadow-sm">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<ng-icon name="lucideUserPlus" class="w-5 h-5 text-primary" />
|
||||
<h1 class="text-lg font-semibold text-foreground">Register</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Username</label>
|
||||
<input [(ngModel)]="username" type="text" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Display Name</label>
|
||||
<input [(ngModel)]="displayName" type="text" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Password</label>
|
||||
<input [(ngModel)]="password" type="password" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Server App</label>
|
||||
<select [(ngModel)]="serverId" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground">
|
||||
<option *ngFor="let s of servers(); trackBy: trackById" [value]="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<p *ngIf="error()" class="text-xs text-destructive">{{ error() }}</p>
|
||||
<button (click)="submit()" class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90">Create Account</button>
|
||||
<div class="text-xs text-muted-foreground text-center mt-2">
|
||||
Have an account? <a class="text-primary hover:underline" (click)="goLogin()">Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class RegisterComponent {
|
||||
private auth = inject(AuthService);
|
||||
private serversSvc = inject(ServerDirectoryService);
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
|
||||
servers = this.serversSvc.servers;
|
||||
username = '';
|
||||
displayName = '';
|
||||
password = '';
|
||||
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
||||
error = signal<string | null>(null);
|
||||
|
||||
trackById(_index: number, item: { id: string }) { return item.id; }
|
||||
|
||||
submit() {
|
||||
this.error.set(null);
|
||||
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
||||
this.auth.register({ username: this.username.trim(), password: this.password, displayName: this.displayName.trim(), serverId: sid }).subscribe({
|
||||
next: (resp) => {
|
||||
if (sid) this.serversSvc.setActiveServer(sid);
|
||||
const user: User = {
|
||||
id: resp.id,
|
||||
oderId: resp.id,
|
||||
username: resp.username,
|
||||
displayName: resp.displayName,
|
||||
status: 'online',
|
||||
role: 'member',
|
||||
joinedAt: Date.now(),
|
||||
};
|
||||
try { localStorage.setItem('metoyou_currentUserId', resp.id); } catch {}
|
||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
||||
this.router.navigate(['/search']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || 'Registration failed');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
goLogin() {
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
43
src/app/features/auth/user-bar.component.ts
Normal file
43
src/app/features/auth/user-bar.component.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideUser, lucideLogIn, lucideUserPlus } from '@ng-icons/lucide';
|
||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-bar',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideUser, lucideLogIn, lucideUserPlus })],
|
||||
template: `
|
||||
<div class="h-10 border-b border-border bg-card flex items-center justify-end px-3 gap-2">
|
||||
<div class="flex-1"></div>
|
||||
@if (user()) {
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<ng-icon name="lucideUser" class="w-4 h-4 text-muted-foreground" />
|
||||
<span class="text-foreground">{{ user()?.displayName }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<button (click)="goto('login')" class="px-2 py-1 text-sm rounded bg-secondary hover:bg-secondary/80 flex items-center gap-1">
|
||||
<ng-icon name="lucideLogIn" class="w-4 h-4" />
|
||||
Login
|
||||
</button>
|
||||
<button (click)="goto('register')" class="px-2 py-1 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1">
|
||||
<ng-icon name="lucideUserPlus" class="w-4 h-4" />
|
||||
Register
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class UserBarComponent {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
user = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
goto(path: 'login' | 'register') {
|
||||
this.router.navigate([`/${path}`]);
|
||||
}
|
||||
}
|
||||
554
src/app/features/chat/chat-messages.component.ts
Normal file
554
src/app/features/chat/chat-messages.component.ts
Normal file
@@ -0,0 +1,554 @@
|
||||
import { Component, inject, signal, computed, effect, ElementRef, ViewChild, AfterViewChecked, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideSend,
|
||||
lucideSmile,
|
||||
lucideEdit,
|
||||
lucideTrash2,
|
||||
lucideReply,
|
||||
lucideMoreVertical,
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import * as MessagesActions from '../../store/messages/messages.actions';
|
||||
import { selectAllMessages, selectMessagesLoading } from '../../store/messages/messages.selectors';
|
||||
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../store/users/users.selectors';
|
||||
import { Message } from '../../core/models';
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥', '👀'];
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-messages',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideSend,
|
||||
lucideSmile,
|
||||
lucideEdit,
|
||||
lucideTrash2,
|
||||
lucideReply,
|
||||
lucideMoreVertical,
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
}),
|
||||
],
|
||||
template: `
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Messages List -->
|
||||
<div #messagesContainer class="flex-1 overflow-y-auto p-4 space-y-4 relative" (scroll)="onScroll()">
|
||||
@if (loading()) {
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
} @else if (messages().length === 0) {
|
||||
<div class="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<p class="text-lg">No messages yet</p>
|
||||
<p class="text-sm">Be the first to say something!</p>
|
||||
</div>
|
||||
} @else {
|
||||
@for (message of messages(); track message.id) {
|
||||
<div
|
||||
class="group relative flex gap-3 p-2 rounded-lg hover:bg-secondary/30 transition-colors"
|
||||
[class.opacity-50]="message.isDeleted"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold">
|
||||
{{ message.senderName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
|
||||
<!-- Message Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="font-semibold text-foreground">{{ message.senderName }}</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ formatTimestamp(message.timestamp) }}
|
||||
</span>
|
||||
@if (message.editedAt) {
|
||||
<span class="text-xs text-muted-foreground">(edited)</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (editingMessageId() === message.id) {
|
||||
<!-- Edit Mode -->
|
||||
<div class="mt-1 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="editContent"
|
||||
(keydown.enter)="saveEdit(message.id)"
|
||||
(keydown.escape)="cancelEdit()"
|
||||
class="flex-1 px-3 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<button
|
||||
(click)="saveEdit(message.id)"
|
||||
class="p-1 text-primary hover:bg-primary/10 rounded"
|
||||
>
|
||||
<ng-icon name="lucideCheck" class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
(click)="cancelEdit()"
|
||||
class="p-1 text-muted-foreground hover:bg-secondary rounded"
|
||||
>
|
||||
<ng-icon name="lucideX" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="text-foreground break-words whitespace-pre-wrap mt-1">
|
||||
{{ message.content }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<!-- Reactions -->
|
||||
@if (message.reactions.length > 0) {
|
||||
<div class="flex flex-wrap gap-1 mt-2">
|
||||
@for (reaction of getGroupedReactions(message); track reaction.emoji) {
|
||||
<button
|
||||
(click)="toggleReaction(message.id, reaction.emoji)"
|
||||
class="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-secondary hover:bg-secondary/80 transition-colors"
|
||||
[class.ring-1]="reaction.hasCurrentUser"
|
||||
[class.ring-primary]="reaction.hasCurrentUser"
|
||||
>
|
||||
<span>{{ reaction.emoji }}</span>
|
||||
<span class="text-muted-foreground">{{ reaction.count }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Message Actions (visible on hover) -->
|
||||
@if (!message.isDeleted) {
|
||||
<div class="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 bg-card border border-border rounded-lg shadow-lg">
|
||||
<!-- Emoji Picker Toggle -->
|
||||
<div class="relative">
|
||||
<button
|
||||
(click)="toggleEmojiPicker(message.id)"
|
||||
class="p-1.5 hover:bg-secondary rounded-l-lg transition-colors"
|
||||
>
|
||||
<ng-icon name="lucideSmile" class="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
@if (showEmojiPicker() === message.id) {
|
||||
<div class="absolute bottom-full right-0 mb-2 p-2 bg-card border border-border rounded-lg shadow-lg flex gap-1 z-10">
|
||||
@for (emoji of commonEmojis; track emoji) {
|
||||
<button
|
||||
(click)="addReaction(message.id, emoji)"
|
||||
class="p-1 hover:bg-secondary rounded transition-colors text-lg"
|
||||
>
|
||||
{{ emoji }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Reply -->
|
||||
<button
|
||||
(click)="setReplyTo(message)"
|
||||
class="p-1.5 hover:bg-secondary transition-colors"
|
||||
>
|
||||
<ng-icon name="lucideReply" class="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
<!-- Edit (own messages only) -->
|
||||
@if (isOwnMessage(message)) {
|
||||
<button
|
||||
(click)="startEdit(message)"
|
||||
class="p-1.5 hover:bg-secondary transition-colors"
|
||||
>
|
||||
<ng-icon name="lucideEdit" class="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Delete (own messages or admin) -->
|
||||
@if (isOwnMessage(message) || isAdmin()) {
|
||||
<button
|
||||
(click)="deleteMessage(message)"
|
||||
class="p-1.5 hover:bg-destructive/10 rounded-r-lg transition-colors"
|
||||
>
|
||||
<ng-icon name="lucideTrash2" class="w-4 h-4 text-destructive" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<!-- New messages snackbar (center bottom inside container) -->
|
||||
@if (showNewMessagesBar()) {
|
||||
<div class="sticky bottom-4 flex justify-center pointer-events-none">
|
||||
<div class="px-3 py-2 bg-card border border-border rounded-lg shadow flex items-center gap-3 pointer-events-auto">
|
||||
<span class="text-sm text-muted-foreground">New messages</span>
|
||||
<button (click)="readLatest()" class="px-2 py-1 bg-primary text-primary-foreground rounded hover:bg-primary/90 text-sm">Read latest</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Reply Preview -->
|
||||
@if (replyTo()) {
|
||||
<div class="px-4 py-2 bg-secondary/50 border-t border-border flex items-center gap-2">
|
||||
<ng-icon name="lucideReply" class="w-4 h-4 text-muted-foreground" />
|
||||
<span class="text-sm text-muted-foreground flex-1">
|
||||
Replying to <span class="font-semibold">{{ replyTo()?.senderName }}</span>
|
||||
</span>
|
||||
<button (click)="clearReply()" class="p-1 hover:bg-secondary rounded">
|
||||
<ng-icon name="lucideX" class="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Typing Indicator -->
|
||||
@if (typingDisplay().length > 0) {
|
||||
<div class="px-4 py-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{{ typingDisplay().join(', ') }}
|
||||
@if (typingOthersCount() > 0) {
|
||||
and {{ typingOthersCount() }} others are typing...
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Message Input -->
|
||||
<div class="p-4 border-t border-border">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="messageContent"
|
||||
(keydown.enter)="sendMessage()"
|
||||
(input)="onInputChange()"
|
||||
placeholder="Type a message..."
|
||||
class="flex-1 px-4 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<button
|
||||
(click)="sendMessage()"
|
||||
[disabled]="!messageContent.trim()"
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestroy {
|
||||
@ViewChild('messagesContainer') messagesContainer!: ElementRef;
|
||||
|
||||
private store = inject(Store);
|
||||
private webrtc = inject(WebRTCService);
|
||||
|
||||
messages = this.store.selectSignal(selectAllMessages);
|
||||
loading = this.store.selectSignal(selectMessagesLoading);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
|
||||
messageContent = '';
|
||||
editContent = '';
|
||||
editingMessageId = signal<string | null>(null);
|
||||
replyTo = signal<Message | null>(null);
|
||||
showEmojiPicker = signal<string | null>(null);
|
||||
|
||||
readonly commonEmojis = COMMON_EMOJIS;
|
||||
|
||||
private shouldScrollToBottom = true;
|
||||
private typingSub?: Subscription;
|
||||
private lastTypingSentAt = 0;
|
||||
private readonly typingTTL = 3000; // ms to keep a user as typing
|
||||
private lastMessageCount = 0;
|
||||
private initialScrollPending = true;
|
||||
|
||||
// Track typing users by name and expire them
|
||||
private typingMap = new Map<string, { name: string; expiresAt: number }>();
|
||||
typingDisplay = signal<string[]>([]);
|
||||
typingOthersCount = signal<number>(0);
|
||||
// New messages snackbar state
|
||||
showNewMessagesBar = signal(false);
|
||||
// Stable reference time to avoid ExpressionChanged errors (updated every minute)
|
||||
nowRef = signal<number>(Date.now());
|
||||
private nowTimer: any;
|
||||
|
||||
// Messages length signal and effect to detect new messages without blocking change detection
|
||||
messagesLength = computed(() => this.messages().length);
|
||||
private onMessagesChanged = effect(() => {
|
||||
const currentCount = this.messagesLength();
|
||||
const el = this.messagesContainer?.nativeElement;
|
||||
if (!el) {
|
||||
this.lastMessageCount = currentCount;
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip during initial scroll setup
|
||||
if (this.initialScrollPending) {
|
||||
this.lastMessageCount = currentCount;
|
||||
return;
|
||||
}
|
||||
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
const newMessages = currentCount > this.lastMessageCount;
|
||||
if (newMessages) {
|
||||
if (distanceFromBottom <= 300) {
|
||||
// Smooth auto-scroll only when near bottom; schedule after render
|
||||
this.scheduleScrollToBottomSmooth();
|
||||
this.showNewMessagesBar.set(false);
|
||||
} else {
|
||||
// Schedule snackbar update to avoid blocking change detection
|
||||
queueMicrotask(() => this.showNewMessagesBar.set(true));
|
||||
}
|
||||
}
|
||||
this.lastMessageCount = currentCount;
|
||||
});
|
||||
|
||||
ngAfterViewChecked(): void {
|
||||
const el = this.messagesContainer?.nativeElement;
|
||||
if (!el) return;
|
||||
|
||||
// First render after connect: scroll to bottom by default (no animation)
|
||||
if (this.initialScrollPending) {
|
||||
this.initialScrollPending = false;
|
||||
this.scrollToBottom();
|
||||
this.showNewMessagesBar.set(false);
|
||||
this.lastMessageCount = this.messages().length;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.typingSub = this.webrtc.onSignalingMessage.subscribe((msg: any) => {
|
||||
if (msg?.type === 'user_typing' && msg.displayName && msg.oderId) {
|
||||
const now = Date.now();
|
||||
this.typingMap.set(String(msg.oderId), { name: String(msg.displayName), expiresAt: now + this.typingTTL });
|
||||
this.recomputeTypingDisplay(now);
|
||||
}
|
||||
});
|
||||
|
||||
// Periodically purge expired typing entries
|
||||
const purge = () => {
|
||||
const now = Date.now();
|
||||
let changed = false;
|
||||
for (const [key, entry] of Array.from(this.typingMap.entries())) {
|
||||
if (entry.expiresAt <= now) {
|
||||
this.typingMap.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) this.recomputeTypingDisplay(now);
|
||||
// schedule next purge
|
||||
setTimeout(purge, 1000);
|
||||
};
|
||||
setTimeout(purge, 1000);
|
||||
|
||||
// Initialize message count for snackbar trigger
|
||||
this.lastMessageCount = this.messages().length;
|
||||
|
||||
// Update reference time periodically (minute granularity)
|
||||
this.nowTimer = setInterval(() => {
|
||||
this.nowRef.set(Date.now());
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.typingSub?.unsubscribe();
|
||||
if (this.nowTimer) {
|
||||
clearInterval(this.nowTimer);
|
||||
this.nowTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(): void {
|
||||
if (!this.messageContent.trim()) return;
|
||||
|
||||
this.store.dispatch(
|
||||
MessagesActions.sendMessage({
|
||||
content: this.messageContent.trim(),
|
||||
replyToId: this.replyTo()?.id,
|
||||
})
|
||||
);
|
||||
|
||||
this.messageContent = '';
|
||||
this.clearReply();
|
||||
this.shouldScrollToBottom = true;
|
||||
this.showNewMessagesBar.set(false);
|
||||
}
|
||||
|
||||
onInputChange(): void {
|
||||
const now = Date.now();
|
||||
if (now - this.lastTypingSentAt > 1000) { // throttle typing events
|
||||
try {
|
||||
this.webrtc.sendRawMessage({ type: 'typing' });
|
||||
this.lastTypingSentAt = now;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
startEdit(message: Message): void {
|
||||
this.editingMessageId.set(message.id);
|
||||
this.editContent = message.content;
|
||||
}
|
||||
|
||||
saveEdit(messageId: string): void {
|
||||
if (!this.editContent.trim()) return;
|
||||
|
||||
this.store.dispatch(
|
||||
MessagesActions.editMessage({
|
||||
messageId,
|
||||
content: this.editContent.trim(),
|
||||
})
|
||||
);
|
||||
|
||||
this.cancelEdit();
|
||||
}
|
||||
|
||||
cancelEdit(): void {
|
||||
this.editingMessageId.set(null);
|
||||
this.editContent = '';
|
||||
}
|
||||
|
||||
deleteMessage(message: Message): void {
|
||||
if (this.isOwnMessage(message)) {
|
||||
this.store.dispatch(MessagesActions.deleteMessage({ messageId: message.id }));
|
||||
} else if (this.isAdmin()) {
|
||||
this.store.dispatch(MessagesActions.adminDeleteMessage({ messageId: message.id }));
|
||||
}
|
||||
}
|
||||
|
||||
setReplyTo(message: Message): void {
|
||||
this.replyTo.set(message);
|
||||
}
|
||||
|
||||
clearReply(): void {
|
||||
this.replyTo.set(null);
|
||||
}
|
||||
|
||||
toggleEmojiPicker(messageId: string): void {
|
||||
this.showEmojiPicker.update((current) =>
|
||||
current === messageId ? null : messageId
|
||||
);
|
||||
}
|
||||
|
||||
addReaction(messageId: string, emoji: string): void {
|
||||
this.store.dispatch(MessagesActions.addReaction({ messageId, emoji }));
|
||||
this.showEmojiPicker.set(null);
|
||||
}
|
||||
|
||||
toggleReaction(messageId: string, emoji: string): void {
|
||||
const message = this.messages().find((m) => m.id === messageId);
|
||||
const currentUserId = this.currentUser()?.id;
|
||||
|
||||
if (!message || !currentUserId) return;
|
||||
|
||||
const hasReacted = message.reactions.some(
|
||||
(r) => r.emoji === emoji && r.userId === currentUserId
|
||||
);
|
||||
|
||||
if (hasReacted) {
|
||||
this.store.dispatch(MessagesActions.removeReaction({ messageId, emoji }));
|
||||
} else {
|
||||
this.store.dispatch(MessagesActions.addReaction({ messageId, emoji }));
|
||||
}
|
||||
}
|
||||
|
||||
isOwnMessage(message: Message): boolean {
|
||||
return message.senderId === this.currentUser()?.id;
|
||||
}
|
||||
|
||||
getGroupedReactions(message: Message): { emoji: string; count: number; hasCurrentUser: boolean }[] {
|
||||
const groups = new Map<string, { count: number; hasCurrentUser: boolean }>();
|
||||
const currentUserId = this.currentUser()?.id;
|
||||
|
||||
message.reactions.forEach((reaction) => {
|
||||
const existing = groups.get(reaction.emoji) || { count: 0, hasCurrentUser: false };
|
||||
groups.set(reaction.emoji, {
|
||||
count: existing.count + 1,
|
||||
hasCurrentUser: existing.hasCurrentUser || reaction.userId === currentUserId,
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(groups.entries()).map(([emoji, data]) => ({
|
||||
emoji,
|
||||
...data,
|
||||
}));
|
||||
}
|
||||
|
||||
formatTimestamp(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date(this.nowRef());
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (days === 1) {
|
||||
return 'Yesterday ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (days < 7) {
|
||||
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' +
|
||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
|
||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
}
|
||||
|
||||
private scrollToBottom(): void {
|
||||
if (this.messagesContainer) {
|
||||
const el = this.messagesContainer.nativeElement;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
this.shouldScrollToBottom = false;
|
||||
}
|
||||
}
|
||||
|
||||
private scrollToBottomSmooth(): void {
|
||||
if (this.messagesContainer) {
|
||||
const el = this.messagesContainer.nativeElement;
|
||||
try {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
} catch {
|
||||
// Fallback if smooth not supported
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
this.shouldScrollToBottom = false;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleScrollToBottomSmooth(): void {
|
||||
// Use double rAF to ensure DOM updated and layout computed before scrolling
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => this.scrollToBottomSmooth());
|
||||
});
|
||||
}
|
||||
|
||||
onScroll(): void {
|
||||
if (!this.messagesContainer) return;
|
||||
const el = this.messagesContainer.nativeElement;
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
this.shouldScrollToBottom = distanceFromBottom <= 300;
|
||||
if (this.shouldScrollToBottom) {
|
||||
this.showNewMessagesBar.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private recomputeTypingDisplay(now: number): void {
|
||||
const entries = Array.from(this.typingMap.values())
|
||||
.filter(e => e.expiresAt > now)
|
||||
.map(e => e.name);
|
||||
const maxShow = 4;
|
||||
const shown = entries.slice(0, maxShow);
|
||||
const others = Math.max(0, entries.length - shown.length);
|
||||
this.typingDisplay.set(shown);
|
||||
this.typingOthersCount.set(others);
|
||||
}
|
||||
|
||||
// Snackbar: scroll to latest
|
||||
readLatest(): void {
|
||||
this.shouldScrollToBottom = true;
|
||||
this.scrollToBottomSmooth();
|
||||
this.showNewMessagesBar.set(false);
|
||||
}
|
||||
}
|
||||
276
src/app/features/chat/user-list.component.ts
Normal file
276
src/app/features/chat/user-list.component.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { Component, inject, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideMonitor,
|
||||
lucideShield,
|
||||
lucideCrown,
|
||||
lucideMoreVertical,
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideVolume2,
|
||||
lucideVolumeX,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import * as UsersActions from '../../store/users/users.actions';
|
||||
import {
|
||||
selectOnlineUsers,
|
||||
selectCurrentUser,
|
||||
selectIsCurrentUserAdmin,
|
||||
} from '../../store/users/users.selectors';
|
||||
import { User } from '../../core/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideMonitor,
|
||||
lucideShield,
|
||||
lucideCrown,
|
||||
lucideMoreVertical,
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideVolume2,
|
||||
lucideVolumeX,
|
||||
}),
|
||||
],
|
||||
template: `
|
||||
<div class="h-full flex flex-col bg-card border-l border-border">
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-border">
|
||||
<h3 class="font-semibold text-foreground">Members</h3>
|
||||
<p class="text-xs text-muted-foreground">{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice</p>
|
||||
@if (voiceUsers().length > 0) {
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@for (v of voiceUsers(); track v.id) {
|
||||
<span class="px-2 py-1 text-xs rounded bg-secondary text-foreground flex items-center gap-1">
|
||||
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
||||
{{ v.displayName }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- User List -->
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
@for (user of onlineUsers(); track user.id) {
|
||||
<div
|
||||
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
|
||||
(click)="toggleUserMenu(user.id)"
|
||||
>
|
||||
<!-- Avatar with online indicator -->
|
||||
<div class="relative">
|
||||
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold text-sm">
|
||||
{{ user.displayName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<span class="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-card"
|
||||
[class.bg-green-500]="user.isOnline !== false && user.status !== 'offline'"
|
||||
[class.bg-gray-500]="user.isOnline === false || user.status === 'offline'"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<!-- User Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="font-medium text-sm text-foreground truncate">
|
||||
{{ user.displayName }}
|
||||
</span>
|
||||
@if (user.isAdmin) {
|
||||
<ng-icon name="lucideShield" class="w-3 h-3 text-primary" />
|
||||
}
|
||||
@if (user.isRoomOwner) {
|
||||
<ng-icon name="lucideCrown" class="w-3 h-3 text-yellow-500" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Voice/Screen Status -->
|
||||
<div class="flex items-center gap-1">
|
||||
@if (user.voiceState?.isSpeaking) {
|
||||
<ng-icon name="lucideMic" class="w-4 h-4 text-green-500 animate-pulse" />
|
||||
} @else if (user.voiceState?.isMuted) {
|
||||
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
|
||||
} @else if (user.voiceState?.isConnected) {
|
||||
<ng-icon name="lucideMic" class="w-4 h-4 text-muted-foreground" />
|
||||
}
|
||||
|
||||
@if (user.screenShareState?.isSharing) {
|
||||
<ng-icon name="lucideMonitor" class="w-4 h-4 text-primary" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- User Menu -->
|
||||
@if (showUserMenu() === user.id && isAdmin() && !isCurrentUser(user)) {
|
||||
<div
|
||||
class="absolute right-0 top-full mt-1 z-10 w-48 bg-card border border-border rounded-lg shadow-lg py-1"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
@if (user.voiceState?.isConnected) {
|
||||
<button
|
||||
(click)="muteUser(user)"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2"
|
||||
>
|
||||
@if (user.voiceState?.isMutedByAdmin) {
|
||||
<ng-icon name="lucideVolume2" class="w-4 h-4" />
|
||||
<span>Unmute</span>
|
||||
} @else {
|
||||
<ng-icon name="lucideVolumeX" class="w-4 h-4" />
|
||||
<span>Mute</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
(click)="kickUser(user)"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2 text-yellow-500"
|
||||
>
|
||||
<ng-icon name="lucideUserX" class="w-4 h-4" />
|
||||
<span>Kick</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="banUser(user)"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-destructive/10 flex items-center gap-2 text-destructive"
|
||||
>
|
||||
<ng-icon name="lucideBan" class="w-4 h-4" />
|
||||
<span>Ban</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (onlineUsers().length === 0) {
|
||||
<div class="text-center py-8 text-muted-foreground text-sm">
|
||||
No users online
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ban Dialog -->
|
||||
@if (showBanDialog()) {
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="closeBanDialog()">
|
||||
<div class="bg-card border border-border rounded-lg p-6 w-96 max-w-[90vw]" (click)="$event.stopPropagation()">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Ban User</h3>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Are you sure you want to ban <span class="font-semibold text-foreground">{{ userToBan()?.displayName }}</span>?
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Reason (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="banReason"
|
||||
placeholder="Enter ban reason..."
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Duration</label>
|
||||
<select
|
||||
[(ngModel)]="banDuration"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="3600000">1 hour</option>
|
||||
<option value="86400000">1 day</option>
|
||||
<option value="604800000">1 week</option>
|
||||
<option value="2592000000">30 days</option>
|
||||
<option value="0">Permanent</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button
|
||||
(click)="closeBanDialog()"
|
||||
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
(click)="confirmBan()"
|
||||
class="px-4 py-2 bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
|
||||
>
|
||||
Ban User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class UserListComponent {
|
||||
private store = inject(Store);
|
||||
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
voiceUsers = computed(() => this.onlineUsers().filter(u => !!u.voiceState?.isConnected));
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
|
||||
showUserMenu = signal<string | null>(null);
|
||||
showBanDialog = signal(false);
|
||||
userToBan = signal<User | null>(null);
|
||||
banReason = '';
|
||||
banDuration = '86400000'; // Default 1 day
|
||||
|
||||
toggleUserMenu(userId: string): void {
|
||||
this.showUserMenu.update((current) => (current === userId ? null : userId));
|
||||
}
|
||||
|
||||
isCurrentUser(user: User): boolean {
|
||||
return user.id === this.currentUser()?.id;
|
||||
}
|
||||
|
||||
muteUser(user: User): void {
|
||||
if (user.voiceState?.isMutedByAdmin) {
|
||||
this.store.dispatch(UsersActions.adminUnmuteUser({ userId: user.id }));
|
||||
} else {
|
||||
this.store.dispatch(UsersActions.adminMuteUser({ userId: user.id }));
|
||||
}
|
||||
this.showUserMenu.set(null);
|
||||
}
|
||||
|
||||
kickUser(user: User): void {
|
||||
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
||||
this.showUserMenu.set(null);
|
||||
}
|
||||
|
||||
banUser(user: User): void {
|
||||
this.userToBan.set(user);
|
||||
this.showBanDialog.set(true);
|
||||
this.showUserMenu.set(null);
|
||||
}
|
||||
|
||||
closeBanDialog(): void {
|
||||
this.showBanDialog.set(false);
|
||||
this.userToBan.set(null);
|
||||
this.banReason = '';
|
||||
this.banDuration = '86400000';
|
||||
}
|
||||
|
||||
confirmBan(): void {
|
||||
const user = this.userToBan();
|
||||
if (!user) return;
|
||||
|
||||
const duration = parseInt(this.banDuration, 10);
|
||||
const expiresAt = duration === 0 ? undefined : Date.now() + duration;
|
||||
|
||||
this.store.dispatch(
|
||||
UsersActions.banUser({
|
||||
userId: user.id,
|
||||
reason: this.banReason || undefined,
|
||||
expiresAt,
|
||||
})
|
||||
);
|
||||
|
||||
this.closeBanDialog();
|
||||
}
|
||||
}
|
||||
97
src/app/features/room/chat-room.component.ts
Normal file
97
src/app/features/room/chat-room.component.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideHash,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideMenu,
|
||||
lucideX,
|
||||
lucideChevronLeft,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ChatMessagesComponent } from '../chat/chat-messages.component';
|
||||
import { UserListComponent } from '../chat/user-list.component';
|
||||
import { ScreenShareViewerComponent } from '../voice/screen-share-viewer.component';
|
||||
import { AdminPanelComponent } from '../admin/admin-panel.component';
|
||||
import { RoomsSidePanelComponent } from './rooms-side-panel.component';
|
||||
|
||||
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||
import { selectIsCurrentUserAdmin } from '../../store/users/users.selectors';
|
||||
|
||||
type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-room',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
ChatMessagesComponent,
|
||||
ScreenShareViewerComponent,
|
||||
RoomsSidePanelComponent,
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideHash,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideMenu,
|
||||
lucideX,
|
||||
lucideChevronLeft,
|
||||
}),
|
||||
],
|
||||
template: `
|
||||
<div class="h-full flex flex-col bg-background">
|
||||
@if (currentRoom()) {
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<!-- Left rail is global; chat area fills remaining space -->
|
||||
|
||||
<!-- Chat Area -->
|
||||
<main class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Screen Share Viewer -->
|
||||
<app-screen-share-viewer />
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<app-chat-messages />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Sidebar always visible -->
|
||||
<aside class="w-80 flex-shrink-0 border-l border-border">
|
||||
<app-rooms-side-panel class="h-full" />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Voice Controls moved to sidebar bottom -->
|
||||
|
||||
<!-- Mobile overlay removed; sidebar remains visible by default -->
|
||||
} @else {
|
||||
<!-- No Room Selected -->
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center text-muted-foreground">
|
||||
<ng-icon name="lucideHash" class="w-16 h-16 mx-auto mb-4 opacity-30" />
|
||||
<h2 class="text-xl font-medium mb-2">No room selected</h2>
|
||||
<p class="text-sm">Select or create a room to start chatting</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ChatRoomComponent {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
showMenu = signal(false);
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
|
||||
// Sidebar always visible; panel toggles removed
|
||||
|
||||
// Header moved to TitleBar
|
||||
}
|
||||
259
src/app/features/room/rooms-side-panel.component.ts
Normal file
259
src/app/features/room/rooms-side-panel.component.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor } from '@ng-icons/lucide';
|
||||
import { selectOnlineUsers, selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||
import * as UsersActions from '../../store/users/users.actions';
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
import { VoiceControlsComponent } from '../voice/voice-controls.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-rooms-side-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon, VoiceControlsComponent],
|
||||
viewProviders: [
|
||||
provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor })
|
||||
],
|
||||
template: `
|
||||
<aside class="w-80 bg-card h-full flex flex-col">
|
||||
<div class="p-4 border-b border-border flex items-center justify-between">
|
||||
<h3 class="font-semibold text-foreground">Rooms</h3>
|
||||
<button class="p-2 hover:bg-secondary rounded" (click)="backToServers()">
|
||||
<ng-icon name="lucideChevronLeft" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-3 flex-1 overflow-auto">
|
||||
<h4 class="text-xs text-muted-foreground mb-1">Chat Rooms</h4>
|
||||
<div class="space-y-1">
|
||||
<button class="w-full px-3 py-2 text-sm rounded bg-secondary hover:bg-secondary/80"># general</button>
|
||||
<button class="w-full px-3 py-2 text-sm rounded bg-secondary hover:bg-secondary/80"># random</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<h4 class="text-xs text-muted-foreground mb-1">Voice Rooms</h4>
|
||||
@if (!voiceEnabled()) {
|
||||
<p class="text-xs text-muted-foreground mb-2">Voice is disabled by host</p>
|
||||
}
|
||||
<div class="space-y-1">
|
||||
<div>
|
||||
<button
|
||||
class="w-full px-3 py-2 text-sm rounded-md hover:bg-secondary/60 flex items-center justify-between"
|
||||
(click)="joinVoice('general')"
|
||||
[class.bg-secondary/30]="isCurrentRoom('general')"
|
||||
[class.border-l-2]="isCurrentRoom('general')"
|
||||
[class.border-primary]="isCurrentRoom('general')"
|
||||
[disabled]="!voiceEnabled()"
|
||||
>
|
||||
<span class="text-foreground/90">🔊 General</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded bg-primary/15 text-primary">{{ voiceOccupancy('general') }}</span>
|
||||
</button>
|
||||
@if (voiceUsersInRoom('general').length > 0) {
|
||||
<div class="mt-1 ml-6 space-y-1">
|
||||
@for (u of voiceUsersInRoom('general'); track u.id) {
|
||||
<div class="flex items-center gap-2 p-2 rounded-md hover:bg-secondary/60 transition-colors">
|
||||
<!-- Avatar with status-colored border -->
|
||||
@if (u.avatarUrl) {
|
||||
<img
|
||||
[src]="u.avatarUrl"
|
||||
alt="avatar"
|
||||
class="w-7 h-7 rounded-full ring-2 object-cover"
|
||||
[class.ring-green-500]="u.voiceState?.isConnected && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isConnected && u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isConnected && u.voiceState?.isDeafened"
|
||||
[class.animate-pulse]="u.voiceState?.isSpeaking && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
/>
|
||||
} @else {
|
||||
<div
|
||||
class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold text-xs ring-2"
|
||||
[class.ring-green-500]="u.voiceState?.isConnected && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isConnected && u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isConnected && u.voiceState?.isDeafened"
|
||||
[class.animate-pulse]="u.voiceState?.isSpeaking && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
>
|
||||
{{ u.displayName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
<span class="text-sm truncate text-foreground/90">{{ u.displayName }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="w-full px-3 py-2 text-sm rounded-md hover:bg-secondary/60 flex items-center justify-between"
|
||||
(click)="joinVoice('afk')"
|
||||
[class.bg-secondary/30]="isCurrentRoom('afk')"
|
||||
[class.border-l-2]="isCurrentRoom('afk')"
|
||||
[class.border-primary]="isCurrentRoom('afk')"
|
||||
[disabled]="!voiceEnabled()"
|
||||
>
|
||||
<span class="text-foreground/90">🔕 AFK</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded bg-primary/15 text-primary">{{ voiceOccupancy('afk') }}</span>
|
||||
</button>
|
||||
@if (voiceUsersInRoom('afk').length > 0) {
|
||||
<div class="mt-1 ml-6 space-y-1">
|
||||
@for (u of voiceUsersInRoom('afk'); track u.id) {
|
||||
<div class="flex items-center gap-2 p-2 rounded-md hover:bg-secondary/60 transition-colors">
|
||||
<!-- Avatar with status-colored border -->
|
||||
@if (u.avatarUrl) {
|
||||
<img
|
||||
[src]="u.avatarUrl"
|
||||
alt="avatar"
|
||||
class="w-7 h-7 rounded-full ring-2 object-cover"
|
||||
[class.ring-green-500]="u.voiceState?.isConnected && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isConnected && u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isConnected && u.voiceState?.isDeafened"
|
||||
[class.animate-pulse]="u.voiceState?.isSpeaking && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
/>
|
||||
} @else {
|
||||
<div
|
||||
class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold text-xs ring-2"
|
||||
[class.ring-green-500]="u.voiceState?.isConnected && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isConnected && u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isConnected && u.voiceState?.isDeafened"
|
||||
[class.animate-pulse]="u.voiceState?.isSpeaking && !u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
>
|
||||
{{ u.displayName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
<span class="text-sm truncate text-foreground/90">{{ u.displayName }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 border-t border-border">
|
||||
<h4 class="text-xs text-muted-foreground mb-1">In Voice</h4>
|
||||
<div class="space-y-1">
|
||||
@for (u of onlineUsers(); track u.id) {
|
||||
@if (u.voiceState?.isConnected) {
|
||||
<div class="px-3 py-2 text-sm rounded-lg flex items-center gap-2 hover:bg-secondary/60">
|
||||
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
||||
<span class="truncate">{{ u.displayName }}</span>
|
||||
<span class="flex-1"></span>
|
||||
@if (u.voiceState?.isMuted) {
|
||||
<span class="inline-flex items-center justify-center w-8 h-8"><ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" /></span>
|
||||
} @else if (u.voiceState?.isSpeaking) {
|
||||
<span class="inline-flex items-center justify-center w-8 h-8"><ng-icon name="lucideMic" class="w-4 h-4 text-green-500 animate-pulse" /></span>
|
||||
} @else if (u.voiceState?.isConnected) {
|
||||
<span class="inline-flex items-center justify-center w-8 h-8"><ng-icon name="lucideMic" class="w-4 h-4 text-muted-foreground" /></span>
|
||||
}
|
||||
@if (isUserSharing(u.id)) {
|
||||
<button class="ml-2 inline-flex items-center justify-center w-8 h-8 rounded hover:bg-secondary" (click)="viewShare(u.id)">
|
||||
<ng-icon name="lucideMonitor" class="w-4 h-4 text-red-500" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Voice controls pinned to sidebar bottom -->
|
||||
@if (voiceEnabled()) {
|
||||
<app-voice-controls />
|
||||
}
|
||||
</aside>
|
||||
`,
|
||||
})
|
||||
export class RoomsSidePanelComponent {
|
||||
private store = inject(Store);
|
||||
private webrtc = inject(WebRTCService);
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
// room selection is stored in voiceState.roomId in the store; no local tracking needed
|
||||
|
||||
backToServers() {
|
||||
// Simple navigation: emit a custom event; wire to router/navigation in parent
|
||||
window.dispatchEvent(new CustomEvent('navigate:servers'));
|
||||
}
|
||||
|
||||
joinVoice(roomId: string) {
|
||||
// Gate by room permissions
|
||||
const room = this.currentRoom();
|
||||
if (room && room.permissions && room.permissions.allowVoice === false) {
|
||||
console.warn('Voice is disabled by room permissions');
|
||||
return;
|
||||
}
|
||||
// Enable microphone and broadcast voice-state
|
||||
this.webrtc.enableVoice().then(() => {
|
||||
const current = this.currentUser();
|
||||
if (current?.id) {
|
||||
this.store.dispatch(UsersActions.updateVoiceState({
|
||||
userId: current.id,
|
||||
voiceState: { isConnected: true, isMuted: false, isDeafened: false, roomId: roomId }
|
||||
}));
|
||||
}
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: current?.oderId || current?.id,
|
||||
voiceState: { isConnected: true, isMuted: false, isDeafened: false, roomId: roomId }
|
||||
});
|
||||
}).catch((e) => console.error('Failed to join voice room', roomId, e));
|
||||
}
|
||||
|
||||
leaveVoice(roomId: string) {
|
||||
const current = this.currentUser();
|
||||
// Only leave if currently in this room
|
||||
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId)) return;
|
||||
|
||||
// Disable voice locally
|
||||
this.webrtc.disableVoice();
|
||||
|
||||
// Update store voice state
|
||||
if (current?.id) {
|
||||
this.store.dispatch(UsersActions.updateVoiceState({
|
||||
userId: current.id,
|
||||
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined }
|
||||
}));
|
||||
}
|
||||
|
||||
// Broadcast disconnect
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: current?.oderId || current?.id,
|
||||
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined }
|
||||
});
|
||||
}
|
||||
|
||||
voiceOccupancy(roomId: string): number {
|
||||
const users = this.onlineUsers();
|
||||
return users.filter(u => !!u.voiceState?.isConnected && u.voiceState?.roomId === roomId).length;
|
||||
}
|
||||
|
||||
viewShare(userId: string) {
|
||||
// Focus viewer on a user's stream if present
|
||||
// Requires WebRTCService to expose a remote streams registry.
|
||||
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
||||
window.dispatchEvent(evt);
|
||||
}
|
||||
|
||||
isUserSharing(userId: string): boolean {
|
||||
const me = this.currentUser();
|
||||
if (me?.id === userId) {
|
||||
// Local user: use signal
|
||||
return this.webrtc.isScreenSharing();
|
||||
}
|
||||
const stream = this.webrtc.getRemoteStream(userId);
|
||||
return !!stream && stream.getVideoTracks().length > 0;
|
||||
}
|
||||
|
||||
voiceUsersInRoom(roomId: string) {
|
||||
return this.onlineUsers().filter(u => !!u.voiceState?.isConnected && u.voiceState?.roomId === roomId);
|
||||
}
|
||||
|
||||
isCurrentRoom(roomId: string): boolean {
|
||||
const me = this.currentUser();
|
||||
return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId);
|
||||
}
|
||||
|
||||
voiceEnabled(): boolean {
|
||||
const room = this.currentRoom();
|
||||
return room?.permissions?.allowVoice !== false;
|
||||
}
|
||||
}
|
||||
340
src/app/features/server-search/server-search.component.ts
Normal file
340
src/app/features/server-search/server-search.component.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { debounceTime, distinctUntilChanged, Subject } from 'rxjs';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideSearch,
|
||||
lucideUsers,
|
||||
lucideLock,
|
||||
lucideGlobe,
|
||||
lucidePlus,
|
||||
lucideSettings,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import * as RoomsActions from '../../store/rooms/rooms.actions';
|
||||
import {
|
||||
selectSearchResults,
|
||||
selectIsSearching,
|
||||
selectRoomsError,
|
||||
selectSavedRooms,
|
||||
} from '../../store/rooms/rooms.selectors';
|
||||
import { Room } from '../../core/models';
|
||||
import { ServerInfo } from '../../core/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-server-search',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({ lucideSearch, lucideUsers, lucideLock, lucideGlobe, lucidePlus, lucideSettings }),
|
||||
],
|
||||
template: `
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- My Servers -->
|
||||
<div class="p-4 border-b border-border">
|
||||
<h3 class="font-semibold text-foreground mb-2">My Servers</h3>
|
||||
@if (savedRooms().length === 0) {
|
||||
<p class="text-sm text-muted-foreground">No joined servers yet</p>
|
||||
} @else {
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@for (room of savedRooms(); track room.id) {
|
||||
<button
|
||||
(click)="joinSavedRoom(room)"
|
||||
class="px-3 py-1.5 text-xs rounded-full bg-secondary hover:bg-secondary/80 border border-border text-foreground"
|
||||
>
|
||||
{{ room.name }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Search Header -->
|
||||
<div class="p-4 border-b border-border">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative flex-1">
|
||||
<ng-icon
|
||||
name="lucideSearch"
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="searchQuery"
|
||||
(ngModelChange)="onSearchChange($event)"
|
||||
placeholder="Search servers..."
|
||||
class="w-full pl-10 pr-4 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
(click)="openSettings()"
|
||||
class="p-2 bg-secondary hover:bg-secondary/80 rounded-lg border border-border transition-colors"
|
||||
title="Settings"
|
||||
>
|
||||
<ng-icon name="lucideSettings" class="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Server Button -->
|
||||
<div class="p-4 border-b border-border">
|
||||
<button
|
||||
(click)="openCreateDialog()"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<ng-icon name="lucidePlus" class="w-4 h-4" />
|
||||
Create New Server
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search Results -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
@if (isSearching()) {
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
} @else if (searchResults().length === 0) {
|
||||
<div class="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<ng-icon name="lucideSearch" class="w-12 h-12 mb-4 opacity-50" />
|
||||
<p class="text-lg">No servers found</p>
|
||||
<p class="text-sm">Try a different search or create your own</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="p-4 space-y-3">
|
||||
@for (server of searchResults(); track server.id) {
|
||||
<button
|
||||
(click)="joinServer(server)"
|
||||
class="w-full p-4 bg-card rounded-lg border border-border hover:border-primary/50 hover:bg-card/80 transition-all text-left group"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
{{ server.name }}
|
||||
</h3>
|
||||
@if (server.isPrivate) {
|
||||
<ng-icon name="lucideLock" class="w-4 h-4 text-muted-foreground" />
|
||||
} @else {
|
||||
<ng-icon name="lucideGlobe" class="w-4 h-4 text-muted-foreground" />
|
||||
}
|
||||
</div>
|
||||
@if (server.description) {
|
||||
<p class="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{{ server.description }}
|
||||
</p>
|
||||
}
|
||||
@if (server.topic) {
|
||||
<span class="inline-block mt-2 px-2 py-0.5 text-xs bg-secondary rounded-full text-muted-foreground">
|
||||
{{ server.topic }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-muted-foreground text-sm ml-4">
|
||||
<ng-icon name="lucideUsers" class="w-4 h-4" />
|
||||
<span>{{ server.userCount }}/{{ server.maxUsers }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-muted-foreground">
|
||||
Hosted by {{ server.hostName }}
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<div class="p-4 bg-destructive/10 border-t border-destructive">
|
||||
<p class="text-sm text-destructive">{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Create Server Dialog -->
|
||||
@if (showCreateDialog()) {
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="closeCreateDialog()">
|
||||
<div class="bg-card border border-border rounded-lg p-6 w-full max-w-md m-4" (click)="$event.stopPropagation()">
|
||||
<h2 class="text-xl font-semibold text-foreground mb-4">Create Server</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Server Name</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newServerName"
|
||||
placeholder="My Awesome Server"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Description (optional)</label>
|
||||
<textarea
|
||||
[(ngModel)]="newServerDescription"
|
||||
placeholder="What's your server about?"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Topic (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newServerTopic"
|
||||
placeholder="gaming, music, coding..."
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="newServerPrivate"
|
||||
id="private"
|
||||
class="w-4 h-4 rounded border-border bg-secondary"
|
||||
/>
|
||||
<label for="private" class="text-sm text-foreground">Private server</label>
|
||||
</div>
|
||||
|
||||
@if (newServerPrivate()) {
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
[(ngModel)]="newServerPassword"
|
||||
placeholder="Enter password"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button
|
||||
(click)="closeCreateDialog()"
|
||||
class="flex-1 px-4 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
(click)="createServer()"
|
||||
[disabled]="!newServerName()"
|
||||
class="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ServerSearchComponent implements OnInit {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
private searchSubject = new Subject<string>();
|
||||
|
||||
searchQuery = '';
|
||||
searchResults = this.store.selectSignal(selectSearchResults);
|
||||
isSearching = this.store.selectSignal(selectIsSearching);
|
||||
error = this.store.selectSignal(selectRoomsError);
|
||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
|
||||
// Create dialog state
|
||||
showCreateDialog = signal(false);
|
||||
newServerName = signal('');
|
||||
newServerDescription = signal('');
|
||||
newServerTopic = signal('');
|
||||
newServerPrivate = signal(false);
|
||||
newServerPassword = signal('');
|
||||
|
||||
ngOnInit(): void {
|
||||
// Initial load
|
||||
this.store.dispatch(RoomsActions.searchServers({ query: '' }));
|
||||
this.store.dispatch(RoomsActions.loadRooms());
|
||||
|
||||
// Setup debounced search
|
||||
this.searchSubject
|
||||
.pipe(debounceTime(300), distinctUntilChanged())
|
||||
.subscribe((query) => {
|
||||
this.store.dispatch(RoomsActions.searchServers({ query }));
|
||||
});
|
||||
}
|
||||
|
||||
onSearchChange(query: string): void {
|
||||
this.searchSubject.next(query);
|
||||
}
|
||||
|
||||
joinServer(server: ServerInfo): void {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
return;
|
||||
}
|
||||
this.store.dispatch(RoomsActions.joinRoom({
|
||||
roomId: server.id,
|
||||
serverInfo: {
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
hostName: server.hostName,
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
openCreateDialog(): void {
|
||||
this.showCreateDialog.set(true);
|
||||
}
|
||||
|
||||
closeCreateDialog(): void {
|
||||
this.showCreateDialog.set(false);
|
||||
this.resetCreateForm();
|
||||
}
|
||||
|
||||
createServer(): void {
|
||||
if (!this.newServerName()) return;
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.createRoom({
|
||||
name: this.newServerName(),
|
||||
description: this.newServerDescription() || undefined,
|
||||
topic: this.newServerTopic() || undefined,
|
||||
isPrivate: this.newServerPrivate(),
|
||||
password: this.newServerPrivate() ? this.newServerPassword() : undefined,
|
||||
})
|
||||
);
|
||||
|
||||
this.closeCreateDialog();
|
||||
}
|
||||
|
||||
openSettings(): void {
|
||||
this.router.navigate(['/settings']);
|
||||
}
|
||||
|
||||
joinSavedRoom(room: Room): void {
|
||||
this.joinServer({
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
hostName: room.hostId || 'Unknown',
|
||||
userCount: room.userCount,
|
||||
maxUsers: room.maxUsers || 50,
|
||||
isPrivate: !!room.password,
|
||||
createdAt: room.createdAt,
|
||||
} as any);
|
||||
}
|
||||
|
||||
private resetCreateForm(): void {
|
||||
this.newServerName.set('');
|
||||
this.newServerDescription.set('');
|
||||
this.newServerTopic.set('');
|
||||
this.newServerPrivate.set(false);
|
||||
this.newServerPassword.set('');
|
||||
}
|
||||
}
|
||||
170
src/app/features/servers/servers-rail.component.ts
Normal file
170
src/app/features/servers/servers-rail.component.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucidePlus } from '@ng-icons/lucide';
|
||||
|
||||
import { Room } from '../../core/models';
|
||||
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||
import * as RoomsActions from '../../store/rooms/rooms.actions';
|
||||
|
||||
@Component({
|
||||
selector: 'app-servers-rail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucidePlus })],
|
||||
template: `
|
||||
<nav class="h-full w-16 flex flex-col items-center gap-3 py-3 border-r border-border bg-card relative">
|
||||
<!-- Create button -->
|
||||
<button
|
||||
class="w-10 h-10 rounded-2xl flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
title="Create Server"
|
||||
(click)="createServer()"
|
||||
>
|
||||
<ng-icon name="lucidePlus" class="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<!-- Saved servers icons -->
|
||||
<div class="flex-1 w-full overflow-y-auto flex flex-col items-center gap-2 mt-2">
|
||||
<ng-container *ngFor="let room of savedRooms(); trackBy: trackRoomId">
|
||||
<button
|
||||
class="w-10 h-10 flex-shrink-0 rounded-2xl overflow-hidden border border-border hover:border-primary/60 hover:shadow-sm transition-all"
|
||||
[title]="room.name"
|
||||
(click)="joinSavedRoom(room)"
|
||||
(contextmenu)="openContextMenu($event, room)"
|
||||
>
|
||||
<ng-container *ngIf="room.icon; else noIcon">
|
||||
<img [src]="room.icon" [alt]="room.name" class="w-full h-full object-cover" />
|
||||
</ng-container>
|
||||
<ng-template #noIcon>
|
||||
<div class="w-full h-full flex items-center justify-center bg-secondary">
|
||||
<span class="text-sm font-semibold text-muted-foreground">{{ initial(room.name) }}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Context menu -->
|
||||
<div *ngIf="showMenu()" class="">
|
||||
<div class="fixed inset-0 z-40" (click)="closeMenu()"></div>
|
||||
<div class="fixed z-50 bg-card border border-border rounded-lg shadow-md w-44" [style.left.px]="menuX()" [style.top.px]="menuY()">
|
||||
<button *ngIf="isCurrentContextRoom()" (click)="leaveServer()" class="w-full text-left px-3 py-2 hover:bg-secondary transition-colors text-foreground">Leave Server</button>
|
||||
<button (click)="openForgetConfirm()" class="w-full text-left px-3 py-2 hover:bg-secondary transition-colors text-foreground">Forget Server</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Forget confirmation dialog -->
|
||||
<div *ngIf="showConfirm()">
|
||||
<div class="fixed inset-0 z-40 bg-black/30" (click)="cancelForget()"></div>
|
||||
<div class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg w-[280px]">
|
||||
<div class="p-4">
|
||||
<h4 class="font-semibold text-foreground mb-2">Forget Server?</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Remove <span class="font-medium text-foreground">{{ contextRoom()?.name }}</span> from your My Servers list.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2 p-3 border-t border-border">
|
||||
<button (click)="cancelForget()" class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors">Cancel</button>
|
||||
<button (click)="confirmForget()" class="flex-1 px-3 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors">Forget</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ServersRailComponent {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
// Context menu state
|
||||
showMenu = signal(false);
|
||||
menuX = signal(72); // rail width (~64px) + padding, position menu to the right
|
||||
menuY = signal(100);
|
||||
contextRoom = signal<Room | null>(null);
|
||||
// Confirmation dialog state
|
||||
showConfirm = signal(false);
|
||||
|
||||
initial(name?: string): string {
|
||||
if (!name) return '?';
|
||||
const ch = name.trim()[0]?.toUpperCase();
|
||||
return ch || '?';
|
||||
}
|
||||
|
||||
trackRoomId = (index: number, room: Room) => room.id;
|
||||
|
||||
createServer(): void {
|
||||
// Navigate to server list (has create button)
|
||||
this.router.navigate(['/search']);
|
||||
}
|
||||
|
||||
joinSavedRoom(room: Room): void {
|
||||
// Require auth: if no current user, go to login
|
||||
const current = this.currentRoom();
|
||||
// currentRoom presence does not indicate auth; check localStorage for currentUserId
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
return;
|
||||
}
|
||||
this.store.dispatch(RoomsActions.joinRoom({
|
||||
roomId: room.id,
|
||||
serverInfo: {
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
hostName: room.hostId || 'Unknown',
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
openContextMenu(evt: MouseEvent, room: Room): void {
|
||||
evt.preventDefault();
|
||||
this.contextRoom.set(room);
|
||||
// Position menu slightly to the right of cursor to avoid overlapping the rail
|
||||
this.menuX.set(Math.max((evt.clientX + 8), 72));
|
||||
this.menuY.set(evt.clientY);
|
||||
this.showMenu.set(true);
|
||||
}
|
||||
|
||||
closeMenu(): void {
|
||||
this.showMenu.set(false);
|
||||
// keep contextRoom for potential confirmation dialog
|
||||
}
|
||||
|
||||
isCurrentContextRoom(): boolean {
|
||||
const ctx = this.contextRoom();
|
||||
const cur = this.currentRoom();
|
||||
return !!ctx && !!cur && ctx.id === cur.id;
|
||||
}
|
||||
|
||||
leaveServer(): void {
|
||||
this.closeMenu();
|
||||
this.store.dispatch(RoomsActions.leaveRoom());
|
||||
window.dispatchEvent(new CustomEvent('navigate:servers'));
|
||||
}
|
||||
|
||||
openForgetConfirm(): void {
|
||||
this.showConfirm.set(true);
|
||||
this.closeMenu();
|
||||
}
|
||||
|
||||
confirmForget(): void {
|
||||
const ctx = this.contextRoom();
|
||||
if (!ctx) return;
|
||||
if (this.currentRoom()?.id === ctx.id) {
|
||||
this.store.dispatch(RoomsActions.leaveRoom());
|
||||
window.dispatchEvent(new CustomEvent('navigate:servers'));
|
||||
}
|
||||
this.store.dispatch(RoomsActions.forgetRoom({ roomId: ctx.id }));
|
||||
this.showConfirm.set(false);
|
||||
this.contextRoom.set(null);
|
||||
}
|
||||
|
||||
cancelForget(): void {
|
||||
this.showConfirm.set(false);
|
||||
}
|
||||
|
||||
}
|
||||
297
src/app/features/settings/settings.component.ts
Normal file
297
src/app/features/settings/settings.component.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideServer,
|
||||
lucidePlus,
|
||||
lucideTrash2,
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
lucideSettings,
|
||||
lucideRefreshCw,
|
||||
lucideGlobe,
|
||||
lucideArrowLeft,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ServerDirectoryService, ServerEndpoint } from '../../core/services/server-directory.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideServer,
|
||||
lucidePlus,
|
||||
lucideTrash2,
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
lucideSettings,
|
||||
lucideRefreshCw,
|
||||
lucideGlobe,
|
||||
lucideArrowLeft,
|
||||
}),
|
||||
],
|
||||
template: `
|
||||
<div class="p-6 max-w-2xl mx-auto">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
(click)="goBack()"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
||||
title="Go back"
|
||||
>
|
||||
<ng-icon name="lucideArrowLeft" class="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
<ng-icon name="lucideSettings" class="w-6 h-6 text-primary" />
|
||||
<h1 class="text-2xl font-bold text-foreground">Settings</h1>
|
||||
</div>
|
||||
|
||||
<!-- Server Endpoints Section -->
|
||||
<div class="bg-card border border-border rounded-lg p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<ng-icon name="lucideGlobe" class="w-5 h-5 text-muted-foreground" />
|
||||
<h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2>
|
||||
</div>
|
||||
<button
|
||||
(click)="testAllServers()"
|
||||
[disabled]="isTesting()"
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<ng-icon name="lucideRefreshCw" class="w-4 h-4" [class.animate-spin]="isTesting()" />
|
||||
Test All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Add multiple server directories to search for rooms across different networks.
|
||||
The active server will be used for creating and registering new rooms.
|
||||
</p>
|
||||
|
||||
<!-- Server List -->
|
||||
<div class="space-y-3 mb-4">
|
||||
@for (server of servers(); track server.id) {
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 rounded-lg border transition-colors"
|
||||
[class.border-primary]="server.isActive"
|
||||
[class.bg-primary/5]="server.isActive"
|
||||
[class.border-border]="!server.isActive"
|
||||
[class.bg-secondary/30]="!server.isActive"
|
||||
>
|
||||
<!-- Status Indicator -->
|
||||
<div
|
||||
class="w-3 h-3 rounded-full flex-shrink-0"
|
||||
[class.bg-green-500]="server.status === 'online'"
|
||||
[class.bg-red-500]="server.status === 'offline'"
|
||||
[class.bg-yellow-500]="server.status === 'checking'"
|
||||
[class.bg-muted]="server.status === 'unknown'"
|
||||
[title]="server.status"
|
||||
></div>
|
||||
|
||||
<!-- Server Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-foreground truncate">{{ server.name }}</span>
|
||||
@if (server.isActive) {
|
||||
<span class="text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full">Active</span>
|
||||
}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground truncate">{{ server.url }}</p>
|
||||
@if (server.latency !== undefined && server.status === 'online') {
|
||||
<p class="text-xs text-muted-foreground">{{ server.latency }}ms</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
@if (!server.isActive) {
|
||||
<button
|
||||
(click)="setActiveServer(server.id)"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
||||
title="Set as active"
|
||||
>
|
||||
<ng-icon name="lucideCheck" class="w-4 h-4 text-muted-foreground hover:text-primary" />
|
||||
</button>
|
||||
}
|
||||
@if (!server.isDefault) {
|
||||
<button
|
||||
(click)="removeServer(server.id)"
|
||||
class="p-2 hover:bg-destructive/10 rounded-lg transition-colors"
|
||||
title="Remove server"
|
||||
>
|
||||
<ng-icon name="lucideTrash2" class="w-4 h-4 text-muted-foreground hover:text-destructive" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Add New Server -->
|
||||
<div class="border-t border-border pt-4">
|
||||
<h3 class="text-sm font-medium text-foreground mb-3">Add New Server</h3>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1 space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newServerName"
|
||||
placeholder="Server name (e.g., My Server)"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
[(ngModel)]="newServerUrl"
|
||||
placeholder="Server URL (e.g., http://localhost:3001)"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
(click)="addServer()"
|
||||
[disabled]="!newServerName || !newServerUrl"
|
||||
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 self-end"
|
||||
>
|
||||
<ng-icon name="lucidePlus" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
@if (addError()) {
|
||||
<p class="text-sm text-destructive mt-2">{{ addError() }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connection Settings -->
|
||||
<div class="bg-card border border-border rounded-lg p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<ng-icon name="lucideServer" class="w-5 h-5 text-muted-foreground" />
|
||||
<h2 class="text-lg font-semibold text-foreground">Connection Settings</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-foreground">Auto-reconnect</p>
|
||||
<p class="text-sm text-muted-foreground">Automatically reconnect when connection is lost</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="autoReconnect"
|
||||
(change)="saveConnectionSettings()"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div class="w-11 h-6 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-foreground">Search all servers</p>
|
||||
<p class="text-sm text-muted-foreground">Search across all configured server directories</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="searchAllServers"
|
||||
(change)="saveConnectionSettings()"
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div class="w-11 h-6 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class SettingsComponent implements OnInit {
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
private router = inject(Router);
|
||||
|
||||
servers = this.serverDirectory.servers;
|
||||
isTesting = signal(false);
|
||||
addError = signal<string | null>(null);
|
||||
|
||||
newServerName = '';
|
||||
newServerUrl = '';
|
||||
autoReconnect = true;
|
||||
searchAllServers = true;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadConnectionSettings();
|
||||
}
|
||||
|
||||
addServer(): void {
|
||||
this.addError.set(null);
|
||||
|
||||
// Validate URL
|
||||
try {
|
||||
new URL(this.newServerUrl);
|
||||
} catch {
|
||||
this.addError.set('Please enter a valid URL');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
if (this.servers().some((s) => s.url === this.newServerUrl)) {
|
||||
this.addError.set('This server URL already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
this.serverDirectory.addServer({
|
||||
name: this.newServerName.trim(),
|
||||
url: this.newServerUrl.trim().replace(/\/$/, ''), // Remove trailing slash
|
||||
});
|
||||
|
||||
// Clear form
|
||||
this.newServerName = '';
|
||||
this.newServerUrl = '';
|
||||
|
||||
// Test the new server
|
||||
const servers = this.servers();
|
||||
const newServer = servers[servers.length - 1];
|
||||
if (newServer) {
|
||||
this.serverDirectory.testServer(newServer.id);
|
||||
}
|
||||
}
|
||||
|
||||
removeServer(id: string): void {
|
||||
this.serverDirectory.removeServer(id);
|
||||
}
|
||||
|
||||
setActiveServer(id: string): void {
|
||||
this.serverDirectory.setActiveServer(id);
|
||||
}
|
||||
|
||||
async testAllServers(): Promise<void> {
|
||||
this.isTesting.set(true);
|
||||
await this.serverDirectory.testAllServers();
|
||||
this.isTesting.set(false);
|
||||
}
|
||||
|
||||
loadConnectionSettings(): void {
|
||||
const settings = localStorage.getItem('metoyou_connection_settings');
|
||||
if (settings) {
|
||||
const parsed = JSON.parse(settings);
|
||||
this.autoReconnect = parsed.autoReconnect ?? true;
|
||||
this.searchAllServers = parsed.searchAllServers ?? true;
|
||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||
}
|
||||
}
|
||||
|
||||
saveConnectionSettings(): void {
|
||||
localStorage.setItem(
|
||||
'metoyou_connection_settings',
|
||||
JSON.stringify({
|
||||
autoReconnect: this.autoReconnect,
|
||||
searchAllServers: this.searchAllServers,
|
||||
})
|
||||
);
|
||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
}
|
||||
127
src/app/features/shell/title-bar.component.ts
Normal file
127
src/app/features/shell/title-bar.component.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Component, inject, computed, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu } from '@ng-icons/lucide';
|
||||
import { Router } from '@angular/router';
|
||||
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||
import * as RoomsActions from '../../store/rooms/rooms.actions';
|
||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-title-bar',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu })],
|
||||
template: `
|
||||
<div class="fixed top-0 left-16 right-0 h-10 bg-card border-b border-border flex items-center justify-between px-4 z-50 select-none" style="-webkit-app-region: drag;">
|
||||
<div class="flex items-center gap-2 min-w-0 relative" style="-webkit-app-region: no-drag;">
|
||||
<button *ngIf="inRoom()" (click)="onBack()" class="p-2 hover:bg-secondary rounded" title="Back">
|
||||
<ng-icon name="lucideChevronLeft" class="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
<ng-container *ngIf="inRoom(); else userServer">
|
||||
<ng-icon name="lucideHash" class="w-5 h-5 text-muted-foreground" />
|
||||
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
|
||||
<span *ngIf="roomDescription()" class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">{{ roomDescription() }}</span>
|
||||
<button (click)="toggleMenu()" class="ml-2 p-2 hover:bg-secondary rounded" title="Menu">
|
||||
<ng-icon name="lucideMenu" class="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
<!-- Anchored dropdown under the menu button -->
|
||||
<div *ngIf="showMenu()" class="absolute right-0 top-full mt-1 z-50 bg-card border border-border rounded-lg shadow-lg w-48">
|
||||
<button (click)="leaveServer()" class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground">Leave Server</button>
|
||||
<div class="border-t border-border"></div>
|
||||
<button (click)="logout()" class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground">Logout</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #userServer>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-sm text-muted-foreground truncate">{{ username() }} | {{ serverName() }}</span>
|
||||
<span *ngIf="!isConnected()" class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive">Reconnecting…</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
<div class="flex items-center gap-2" style="-webkit-app-region: no-drag;">
|
||||
<button *ngIf="!isAuthed()" class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground" (click)="goLogin()" title="Login">Login</button>
|
||||
<button class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Minimize" (click)="minimize()">
|
||||
<ng-icon name="lucideMinus" class="w-4 h-4" />
|
||||
</button>
|
||||
<button class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Maximize" (click)="maximize()">
|
||||
<ng-icon name="lucideSquare" class="w-4 h-4" />
|
||||
</button>
|
||||
<button class="w-8 h-8 grid place-items-center hover:bg-destructive/10 rounded" title="Close" (click)="close()">
|
||||
<ng-icon name="lucideX" class="w-4 h-4 text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Click-away overlay to close dropdown -->
|
||||
<div *ngIf="showMenu()" class="fixed inset-0 z-40" (click)="closeMenu()" style="-webkit-app-region: no-drag;"></div>
|
||||
`,
|
||||
})
|
||||
export class TitleBarComponent {
|
||||
private store = inject(Store);
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
private router = inject(Router);
|
||||
private webrtc = inject(WebRTCService);
|
||||
showMenuState = computed(() => false);
|
||||
|
||||
private currentUserSig = this.store.selectSignal(selectCurrentUser);
|
||||
username = computed(() => this.currentUserSig()?.displayName || 'Guest');
|
||||
serverName = computed(() => this.serverDirectory.activeServer()?.name || 'No Server');
|
||||
isConnected = computed(() => this.webrtc.isConnected());
|
||||
isAuthed = computed(() => !!this.currentUserSig());
|
||||
private currentRoomSig = this.store.selectSignal(selectCurrentRoom);
|
||||
inRoom = computed(() => !!this.currentRoomSig());
|
||||
roomName = computed(() => this.currentRoomSig()?.name || '');
|
||||
roomDescription = computed(() => this.currentRoomSig()?.description || '');
|
||||
private _showMenu = signal(false);
|
||||
showMenu = computed(() => this._showMenu());
|
||||
|
||||
minimize() {
|
||||
const api = (window as any).electronAPI;
|
||||
if (api?.minimizeWindow) api.minimizeWindow();
|
||||
}
|
||||
|
||||
maximize() {
|
||||
const api = (window as any).electronAPI;
|
||||
if (api?.maximizeWindow) api.maximizeWindow();
|
||||
}
|
||||
|
||||
close() {
|
||||
const api = (window as any).electronAPI;
|
||||
if (api?.closeWindow) api.closeWindow();
|
||||
}
|
||||
|
||||
goLogin() {
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
onBack() {
|
||||
// Leave room to ensure header switches to user/server view
|
||||
this.store.dispatch(RoomsActions.leaveRoom());
|
||||
this.router.navigate(['/search']);
|
||||
}
|
||||
|
||||
toggleMenu() {
|
||||
this._showMenu.set(!this._showMenu());
|
||||
}
|
||||
|
||||
leaveServer() {
|
||||
this._showMenu.set(false);
|
||||
this.store.dispatch(RoomsActions.leaveRoom());
|
||||
window.dispatchEvent(new CustomEvent('navigate:servers'));
|
||||
}
|
||||
|
||||
closeMenu() {
|
||||
this._showMenu.set(false);
|
||||
}
|
||||
|
||||
logout() {
|
||||
this._showMenu.set(false);
|
||||
try {
|
||||
localStorage.removeItem('metoyou_currentUserId');
|
||||
} catch {}
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
231
src/app/features/voice/screen-share-viewer.component.ts
Normal file
231
src/app/features/voice/screen-share-viewer.component.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { Component, inject, signal, ElementRef, ViewChild, OnDestroy, effect } 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 {
|
||||
lucideMaximize,
|
||||
lucideMinimize,
|
||||
lucideX,
|
||||
lucideMonitor,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
import { selectOnlineUsers } from '../../store/users/users.selectors';
|
||||
import { User } from '../../core/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-screen-share-viewer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMaximize,
|
||||
lucideMinimize,
|
||||
lucideX,
|
||||
lucideMonitor,
|
||||
}),
|
||||
],
|
||||
template: `
|
||||
<div
|
||||
class="relative bg-black rounded-lg overflow-hidden"
|
||||
[class.fixed]="isFullscreen()"
|
||||
[class.inset-0]="isFullscreen()"
|
||||
[class.z-50]="isFullscreen()"
|
||||
[class.hidden]="!hasStream()"
|
||||
>
|
||||
<!-- Video Element -->
|
||||
<video
|
||||
#screenVideo
|
||||
autoplay
|
||||
playsinline
|
||||
class="w-full h-full object-contain"
|
||||
[class.max-h-[400px]]="!isFullscreen()"
|
||||
></video>
|
||||
|
||||
<!-- Overlay Controls -->
|
||||
<div class="absolute top-0 left-0 right-0 p-4 bg-gradient-to-b from-black/70 to-transparent opacity-0 hover:opacity-100 transition-opacity">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-white">
|
||||
<ng-icon name="lucideMonitor" class="w-4 h-4" />
|
||||
<span class="text-sm font-medium" *ngIf="activeScreenSharer(); else sharingUnknown">
|
||||
{{ activeScreenSharer()?.displayName }} is sharing their screen
|
||||
</span>
|
||||
<ng-template #sharingUnknown>
|
||||
<span class="text-sm font-medium">Someone is sharing their screen</span>
|
||||
</ng-template>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
(click)="toggleFullscreen()"
|
||||
class="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors"
|
||||
>
|
||||
@if (isFullscreen()) {
|
||||
<ng-icon name="lucideMinimize" class="w-4 h-4 text-white" />
|
||||
} @else {
|
||||
<ng-icon name="lucideMaximize" class="w-4 h-4 text-white" />
|
||||
}
|
||||
</button>
|
||||
@if (isLocalShare()) {
|
||||
<button
|
||||
(click)="stopSharing()"
|
||||
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
|
||||
>
|
||||
<ng-icon name="lucideX" class="w-4 h-4 text-white" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Stream Placeholder -->
|
||||
<div *ngIf="!hasStream()" class="absolute inset-0 flex items-center justify-center bg-secondary">
|
||||
<div class="text-center text-muted-foreground">
|
||||
<ng-icon name="lucideMonitor" class="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>Waiting for screen share...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ScreenShareViewerComponent implements OnDestroy {
|
||||
@ViewChild('screenVideo') videoRef!: ElementRef<HTMLVideoElement>;
|
||||
|
||||
private webrtcService = inject(WebRTCService);
|
||||
private store = inject(Store);
|
||||
private remoteStreamSub: Subscription | null = null;
|
||||
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
|
||||
activeScreenSharer = signal<User | null>(null);
|
||||
isFullscreen = signal(false);
|
||||
hasStream = signal(false);
|
||||
isLocalShare = signal(false);
|
||||
|
||||
private streamSubscription: (() => void) | null = null;
|
||||
private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => {
|
||||
try {
|
||||
const userId = evt.detail?.userId;
|
||||
if (!userId) return;
|
||||
const stream = this.webrtcService.getRemoteStream(userId);
|
||||
const user = this.onlineUsers().find((u) => u.id === userId || u.oderId === userId) || null;
|
||||
if (stream && stream.getVideoTracks().length > 0) {
|
||||
if (user) this.setRemoteStream(stream, user);
|
||||
else if (this.videoRef) {
|
||||
this.videoRef.nativeElement.srcObject = stream;
|
||||
this.hasStream.set(true);
|
||||
this.activeScreenSharer.set(null);
|
||||
this.isLocalShare.set(false);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to focus viewer on user stream:', e);
|
||||
}
|
||||
};
|
||||
|
||||
constructor() {
|
||||
// React to screen share stream changes
|
||||
effect(() => {
|
||||
const screenStream = this.webrtcService.screenStream();
|
||||
if (screenStream && this.videoRef) {
|
||||
this.videoRef.nativeElement.srcObject = screenStream;
|
||||
this.hasStream.set(true);
|
||||
} else if (this.videoRef) {
|
||||
this.videoRef.nativeElement.srcObject = null;
|
||||
this.hasStream.set(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to remote streams with video (screen shares)
|
||||
this.remoteStreamSub = this.webrtcService.onRemoteStream.subscribe(({ peerId, stream }) => {
|
||||
try {
|
||||
const hasVideo = stream.getVideoTracks().length > 0;
|
||||
if (!hasVideo) return;
|
||||
|
||||
// Find the user by peerId (oderId)
|
||||
const user = this.onlineUsers().find((u) => u.id === peerId || u.oderId === peerId) || null;
|
||||
|
||||
// If we have a video stream, show it in the viewer
|
||||
if (user) {
|
||||
this.setRemoteStream(stream, user);
|
||||
} else {
|
||||
// Fallback: still show the stream without user details
|
||||
this.activeScreenSharer.set(null);
|
||||
if (this.videoRef) {
|
||||
this.videoRef.nativeElement.srcObject = stream;
|
||||
this.hasStream.set(true);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to display remote screen share:', e);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for focus events dispatched by other components
|
||||
window.addEventListener('viewer:focus', this.viewerFocusHandler as EventListener);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.isFullscreen()) {
|
||||
this.exitFullscreen();
|
||||
}
|
||||
|
||||
// Cleanup subscription
|
||||
this.remoteStreamSub?.unsubscribe();
|
||||
|
||||
// Remove event listener
|
||||
window.removeEventListener('viewer:focus', this.viewerFocusHandler as EventListener);
|
||||
}
|
||||
|
||||
toggleFullscreen(): void {
|
||||
if (this.isFullscreen()) {
|
||||
this.exitFullscreen();
|
||||
} else {
|
||||
this.enterFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
enterFullscreen(): void {
|
||||
this.isFullscreen.set(true);
|
||||
// Request browser fullscreen if available
|
||||
if (this.videoRef?.nativeElement.requestFullscreen) {
|
||||
this.videoRef.nativeElement.requestFullscreen().catch(() => {
|
||||
// Fallback to CSS fullscreen
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
exitFullscreen(): void {
|
||||
this.isFullscreen.set(false);
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
stopSharing(): void {
|
||||
this.webrtcService.stopScreenShare();
|
||||
this.activeScreenSharer.set(null);
|
||||
this.hasStream.set(false);
|
||||
this.isLocalShare.set(false);
|
||||
}
|
||||
|
||||
// Called by parent when a remote peer starts sharing
|
||||
setRemoteStream(stream: MediaStream, user: User): void {
|
||||
this.activeScreenSharer.set(user);
|
||||
this.isLocalShare.set(false);
|
||||
if (this.videoRef) {
|
||||
this.videoRef.nativeElement.srcObject = stream;
|
||||
this.hasStream.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Called when local user starts sharing
|
||||
setLocalStream(stream: MediaStream, user: User): void {
|
||||
this.activeScreenSharer.set(user);
|
||||
this.isLocalShare.set(true);
|
||||
if (this.videoRef) {
|
||||
this.videoRef.nativeElement.srcObject = stream;
|
||||
this.hasStream.set(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
551
src/app/features/voice/voice-controls.component.ts
Normal file
551
src/app/features/voice/voice-controls.component.ts
Normal file
@@ -0,0 +1,551 @@
|
||||
import { Component, inject, signal, OnInit, OnDestroy, ElementRef, ViewChild, computed } 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,
|
||||
lucideVideo,
|
||||
lucideVideoOff,
|
||||
lucideMonitor,
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
lucideSettings,
|
||||
lucideHeadphones,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
import * as UsersActions from '../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||
|
||||
interface AudioDevice {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-voice-controls',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideVideo,
|
||||
lucideVideoOff,
|
||||
lucideMonitor,
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
lucideSettings,
|
||||
lucideHeadphones,
|
||||
}),
|
||||
],
|
||||
template: `
|
||||
<div class="bg-card border-t border-border p-4">
|
||||
<!-- User Info -->
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold text-sm">
|
||||
{{ currentUser()?.displayName?.charAt(0)?.toUpperCase() || '?' }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm text-foreground truncate">
|
||||
{{ currentUser()?.displayName || 'Unknown' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
@if (isConnected()) {
|
||||
<span class="text-green-500">● Connected</span>
|
||||
} @else {
|
||||
<span class="text-muted-foreground">● Disconnected</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
(click)="toggleSettings()"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
||||
>
|
||||
<ng-icon name="lucideSettings" class="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Voice Controls -->
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
@if (isConnected()) {
|
||||
<!-- Mute Toggle -->
|
||||
<button
|
||||
(click)="toggleMute()"
|
||||
[class]="getMuteButtonClass()"
|
||||
>
|
||||
@if (isMuted()) {
|
||||
<ng-icon name="lucideMicOff" class="w-5 h-5" />
|
||||
} @else {
|
||||
<ng-icon name="lucideMic" class="w-5 h-5" />
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Deafen Toggle -->
|
||||
<button
|
||||
(click)="toggleDeafen()"
|
||||
[class]="getDeafenButtonClass()"
|
||||
>
|
||||
<ng-icon name="lucideHeadphones" class="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<!-- Screen Share Toggle -->
|
||||
<button
|
||||
(click)="toggleScreenShare()"
|
||||
[class]="getScreenShareButtonClass()"
|
||||
>
|
||||
@if (isScreenSharing()) {
|
||||
<ng-icon name="lucideMonitorOff" class="w-5 h-5" />
|
||||
} @else {
|
||||
<ng-icon name="lucideMonitor" class="w-5 h-5" />
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Disconnect -->
|
||||
<button
|
||||
(click)="disconnect()"
|
||||
class="w-10 h-10 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-full hover:bg-destructive/90 transition-colors"
|
||||
>
|
||||
<ng-icon name="lucidePhoneOff" class="w-5 h-5" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
@if (showSettings()) {
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="closeSettings()">
|
||||
<div class="bg-card border border-border rounded-lg p-6 w-full max-w-md m-4" (click)="$event.stopPropagation()">
|
||||
<h2 class="text-xl font-semibold text-foreground mb-4">Voice Settings</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Microphone</label>
|
||||
<select
|
||||
(change)="onInputDeviceChange($event)"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
@for (device of inputDevices(); track device.deviceId) {
|
||||
<option [value]="device.deviceId" [selected]="device.deviceId === selectedInputDevice()">
|
||||
{{ device.label || 'Microphone ' + $index }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Speaker</label>
|
||||
<select
|
||||
(change)="onOutputDeviceChange($event)"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
@for (device of outputDevices(); track device.deviceId) {
|
||||
<option [value]="device.deviceId" [selected]="device.deviceId === selectedOutputDevice()">
|
||||
{{ device.label || 'Speaker ' + $index }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Input Volume: {{ inputVolume() }}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
[value]="inputVolume()"
|
||||
(input)="onInputVolumeChange($event)"
|
||||
min="0"
|
||||
max="100"
|
||||
class="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Output Volume: {{ outputVolume() }}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
[value]="outputVolume()"
|
||||
(input)="onOutputVolumeChange($event)"
|
||||
min="0"
|
||||
max="100"
|
||||
class="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Latency</label>
|
||||
<select (change)="onLatencyProfileChange($event)" class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm">
|
||||
<option value="low">Low (fast)</option>
|
||||
<option value="balanced" selected>Balanced</option>
|
||||
<option value="high">High (quality)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">
|
||||
Audio Bitrate: {{ audioBitrate() }} kbps
|
||||
</label>
|
||||
<input type="range" [value]="audioBitrate()" (input)="onAudioBitrateChange($event)" min="32" max="256" step="8" class="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button
|
||||
(click)="closeSettings()"
|
||||
class="flex-1 px-4 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
private webrtcService = inject(WebRTCService);
|
||||
private store = inject(Store);
|
||||
private remoteStreamSubscription: Subscription | null = null;
|
||||
private remoteAudioElements = new Map<string, HTMLAudioElement>();
|
||||
private pendingRemoteStreams = new Map<string, MediaStream>();
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
isConnected = computed(() => this.webrtcService.isVoiceConnected());
|
||||
isMuted = signal(false);
|
||||
isDeafened = signal(false);
|
||||
isScreenSharing = signal(false);
|
||||
showSettings = signal(false);
|
||||
|
||||
inputDevices = signal<AudioDevice[]>([]);
|
||||
outputDevices = signal<AudioDevice[]>([]);
|
||||
selectedInputDevice = signal<string>('');
|
||||
selectedOutputDevice = signal<string>('');
|
||||
inputVolume = signal(100);
|
||||
outputVolume = signal(100);
|
||||
audioBitrate = signal(96);
|
||||
latencyProfile = signal<'low'|'balanced'|'high'>('balanced');
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.loadAudioDevices();
|
||||
|
||||
// Subscribe to remote streams to play audio from peers
|
||||
this.remoteStreamSubscription = this.webrtcService.onRemoteStream.subscribe(
|
||||
({ peerId, stream }) => {
|
||||
console.log('Received remote stream from:', peerId, 'tracks:', stream.getTracks().map(t => t.kind));
|
||||
this.playRemoteAudio(peerId, stream);
|
||||
}
|
||||
);
|
||||
|
||||
// Clean up audio when peer disconnects
|
||||
this.webrtcService.onPeerDisconnected.subscribe((peerId) => {
|
||||
this.removeRemoteAudio(peerId);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.isConnected()) {
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
// Clean up audio elements
|
||||
this.remoteAudioElements.forEach((audio) => {
|
||||
audio.srcObject = null;
|
||||
audio.remove();
|
||||
});
|
||||
this.remoteAudioElements.clear();
|
||||
|
||||
this.remoteStreamSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
private removeRemoteAudio(peerId: string): void {
|
||||
// Remove from pending streams
|
||||
this.pendingRemoteStreams.delete(peerId);
|
||||
|
||||
// Remove audio element
|
||||
const audio = this.remoteAudioElements.get(peerId);
|
||||
if (audio) {
|
||||
audio.srcObject = null;
|
||||
audio.remove();
|
||||
this.remoteAudioElements.delete(peerId);
|
||||
console.log('Removed remote audio for:', peerId);
|
||||
}
|
||||
}
|
||||
|
||||
private playRemoteAudio(peerId: string, stream: MediaStream): void {
|
||||
// Only play remote audio if we have joined voice
|
||||
if (!this.isConnected()) {
|
||||
console.log('Not connected to voice, storing pending stream from:', peerId);
|
||||
// Store the stream to play later when we connect
|
||||
this.pendingRemoteStreams.set(peerId, stream);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if stream has audio tracks
|
||||
const audioTracks = stream.getAudioTracks();
|
||||
if (audioTracks.length === 0) {
|
||||
console.log('No audio tracks in stream from:', peerId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove existing audio element for this peer if any
|
||||
const existingAudio = this.remoteAudioElements.get(peerId);
|
||||
if (existingAudio) {
|
||||
existingAudio.srcObject = null;
|
||||
existingAudio.remove();
|
||||
}
|
||||
|
||||
// Create a new audio element for this peer
|
||||
const audio = new Audio();
|
||||
audio.srcObject = stream;
|
||||
audio.autoplay = true;
|
||||
audio.volume = this.outputVolume() / 100;
|
||||
|
||||
// Mute if deafened
|
||||
if (this.isDeafened()) {
|
||||
audio.muted = true;
|
||||
}
|
||||
|
||||
// Play the audio
|
||||
audio.play().then(() => {
|
||||
console.log('Playing remote audio from:', peerId);
|
||||
}).catch((error) => {
|
||||
console.error('Failed to play remote audio from:', peerId, error);
|
||||
});
|
||||
|
||||
this.remoteAudioElements.set(peerId, audio);
|
||||
}
|
||||
|
||||
async loadAudioDevices(): Promise<void> {
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
this.inputDevices.set(
|
||||
devices
|
||||
.filter((d) => d.kind === 'audioinput')
|
||||
.map((d) => ({ deviceId: d.deviceId, label: d.label }))
|
||||
);
|
||||
this.outputDevices.set(
|
||||
devices
|
||||
.filter((d) => d.kind === 'audiooutput')
|
||||
.map((d) => ({ deviceId: d.deviceId, label: d.label }))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to enumerate devices:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
deviceId: this.selectedInputDevice() || undefined,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
},
|
||||
});
|
||||
|
||||
this.webrtcService.setLocalStream(stream);
|
||||
|
||||
// Broadcast voice state to other users
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened(),
|
||||
},
|
||||
});
|
||||
|
||||
// Play any pending remote streams now that we're connected
|
||||
this.pendingRemoteStreams.forEach((pendingStream, peerId) => {
|
||||
console.log('Playing pending stream from:', peerId);
|
||||
this.playRemoteAudio(peerId, pendingStream);
|
||||
});
|
||||
this.pendingRemoteStreams.clear();
|
||||
} catch (error) {
|
||||
console.error('Failed to get user media:', error);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
// Broadcast voice disconnect to other users
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Stop screen sharing if active
|
||||
if (this.isScreenSharing()) {
|
||||
this.webrtcService.stopScreenShare();
|
||||
}
|
||||
|
||||
// Disable voice (stops audio tracks but keeps peer connections open for chat)
|
||||
this.webrtcService.disableVoice();
|
||||
|
||||
// Clear all remote audio elements
|
||||
this.remoteAudioElements.forEach((audio) => {
|
||||
audio.srcObject = null;
|
||||
audio.remove();
|
||||
});
|
||||
this.remoteAudioElements.clear();
|
||||
this.pendingRemoteStreams.clear();
|
||||
|
||||
const user = this.currentUser();
|
||||
if (user?.id) {
|
||||
this.store.dispatch(UsersActions.updateVoiceState({
|
||||
userId: user.id,
|
||||
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined }
|
||||
}));
|
||||
}
|
||||
this.isScreenSharing.set(false);
|
||||
this.isMuted.set(false);
|
||||
this.isDeafened.set(false);
|
||||
}
|
||||
|
||||
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,
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toggleDeafen(): void {
|
||||
this.isDeafened.update((v) => !v);
|
||||
this.webrtcService.toggleDeafen(this.isDeafened());
|
||||
|
||||
// Mute/unmute all remote audio elements
|
||||
this.remoteAudioElements.forEach((audio) => {
|
||||
audio.muted = 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,
|
||||
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();
|
||||
this.isScreenSharing.set(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to start screen share:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggleSettings(): void {
|
||||
this.showSettings.update((v) => !v);
|
||||
}
|
||||
|
||||
closeSettings(): void {
|
||||
this.showSettings.set(false);
|
||||
}
|
||||
|
||||
onInputDeviceChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
this.selectedInputDevice.set(select.value);
|
||||
// Reconnect with new device if connected
|
||||
if (this.isConnected()) {
|
||||
this.disconnect();
|
||||
this.connect();
|
||||
}
|
||||
}
|
||||
|
||||
onOutputDeviceChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
this.selectedOutputDevice.set(select.value);
|
||||
}
|
||||
|
||||
onInputVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.inputVolume.set(parseInt(input.value, 10));
|
||||
}
|
||||
|
||||
onOutputVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.outputVolume.set(parseInt(input.value, 10));
|
||||
this.webrtcService.setOutputVolume(this.outputVolume() / 100);
|
||||
|
||||
// Update volume on all remote audio elements
|
||||
this.remoteAudioElements.forEach((audio) => {
|
||||
audio.volume = this.outputVolume() / 100;
|
||||
});
|
||||
}
|
||||
|
||||
onLatencyProfileChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
const profile = select.value as 'low'|'balanced'|'high';
|
||||
this.latencyProfile.set(profile);
|
||||
this.webrtcService.setLatencyProfile(profile);
|
||||
}
|
||||
|
||||
onAudioBitrateChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const kbps = parseInt(input.value, 10);
|
||||
this.audioBitrate.set(kbps);
|
||||
this.webrtcService.setAudioBitrate(kbps);
|
||||
}
|
||||
|
||||
getMuteButtonClass(): string {
|
||||
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
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 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
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 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
if (this.isScreenSharing()) {
|
||||
return `${base} bg-primary/20 text-primary hover:bg-primary/30`;
|
||||
}
|
||||
return `${base} bg-secondary text-foreground hover:bg-secondary/80`;
|
||||
}
|
||||
}
|
||||
66
src/app/store/index.ts
Normal file
66
src/app/store/index.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { isDevMode } from '@angular/core';
|
||||
import { ActionReducerMap, MetaReducer } from '@ngrx/store';
|
||||
import { messagesReducer, MessagesState } from './messages/messages.reducer';
|
||||
import { usersReducer, UsersState } from './users/users.reducer';
|
||||
import { roomsReducer, RoomsState } from './rooms/rooms.reducer';
|
||||
|
||||
export interface AppState {
|
||||
messages: MessagesState;
|
||||
users: UsersState;
|
||||
rooms: RoomsState;
|
||||
}
|
||||
|
||||
export const reducers: ActionReducerMap<AppState> = {
|
||||
messages: messagesReducer,
|
||||
users: usersReducer,
|
||||
rooms: roomsReducer,
|
||||
};
|
||||
|
||||
export const metaReducers: MetaReducer<AppState>[] = isDevMode() ? [] : [];
|
||||
|
||||
// Re-export actions
|
||||
export * as MessagesActions from './messages/messages.actions';
|
||||
export * as UsersActions from './users/users.actions';
|
||||
export * as RoomsActions from './rooms/rooms.actions';
|
||||
|
||||
// Re-export selectors explicitly to avoid conflicts
|
||||
export {
|
||||
selectMessagesState,
|
||||
selectAllMessages,
|
||||
selectCurrentRoomMessages,
|
||||
selectMessageById,
|
||||
selectMessagesLoading,
|
||||
selectCurrentRoomId as selectMessagesCurrentRoomId,
|
||||
} from './messages/messages.selectors';
|
||||
|
||||
export {
|
||||
selectUsersState,
|
||||
selectAllUsers,
|
||||
selectCurrentUser,
|
||||
selectCurrentUserId,
|
||||
selectUserById,
|
||||
selectOnlineUsers,
|
||||
selectHostId,
|
||||
selectIsCurrentUserHost as selectIsCurrentUserHostFromUsers,
|
||||
selectBannedUsers,
|
||||
} from './users/users.selectors';
|
||||
|
||||
export {
|
||||
selectRoomsState,
|
||||
selectCurrentRoom,
|
||||
selectCurrentRoomId,
|
||||
selectRoomSettings,
|
||||
selectIsCurrentUserHost,
|
||||
selectSavedRooms,
|
||||
selectRoomsLoading,
|
||||
} from './rooms/rooms.selectors';
|
||||
|
||||
// Re-export effects
|
||||
export { MessagesEffects } from './messages/messages.effects';
|
||||
export { UsersEffects } from './users/users.effects';
|
||||
export { RoomsEffects } from './rooms/rooms.effects';
|
||||
|
||||
// Re-export types
|
||||
export type { MessagesState } from './messages/messages.reducer';
|
||||
export type { UsersState } from './users/users.reducer';
|
||||
export type { RoomsState } from './rooms/rooms.reducer';
|
||||
4
src/app/store/messages/index.ts
Normal file
4
src/app/store/messages/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './messages.actions';
|
||||
export * from './messages.reducer';
|
||||
export * from './messages.selectors';
|
||||
export * from './messages.effects';
|
||||
108
src/app/store/messages/messages.actions.ts
Normal file
108
src/app/store/messages/messages.actions.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { createAction, props } from '@ngrx/store';
|
||||
import { Message, Reaction } from '../../core/models';
|
||||
|
||||
// Load messages
|
||||
export const loadMessages = createAction(
|
||||
'[Messages] Load Messages',
|
||||
props<{ roomId: string }>()
|
||||
);
|
||||
|
||||
export const loadMessagesSuccess = createAction(
|
||||
'[Messages] Load Messages Success',
|
||||
props<{ messages: Message[] }>()
|
||||
);
|
||||
|
||||
export const loadMessagesFailure = createAction(
|
||||
'[Messages] Load Messages Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
|
||||
// Send message
|
||||
export const sendMessage = createAction(
|
||||
'[Messages] Send Message',
|
||||
props<{ content: string; replyToId?: string }>()
|
||||
);
|
||||
|
||||
export const sendMessageSuccess = createAction(
|
||||
'[Messages] Send Message Success',
|
||||
props<{ message: Message }>()
|
||||
);
|
||||
|
||||
export const sendMessageFailure = createAction(
|
||||
'[Messages] Send Message Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
|
||||
// Receive message from peer
|
||||
export const receiveMessage = createAction(
|
||||
'[Messages] Receive Message',
|
||||
props<{ message: Message }>()
|
||||
);
|
||||
|
||||
// Edit message
|
||||
export const editMessage = createAction(
|
||||
'[Messages] Edit Message',
|
||||
props<{ messageId: string; content: string }>()
|
||||
);
|
||||
|
||||
export const editMessageSuccess = createAction(
|
||||
'[Messages] Edit Message Success',
|
||||
props<{ messageId: string; content: string; editedAt: number }>()
|
||||
);
|
||||
|
||||
export const editMessageFailure = createAction(
|
||||
'[Messages] Edit Message Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
|
||||
// Delete message
|
||||
export const deleteMessage = createAction(
|
||||
'[Messages] Delete Message',
|
||||
props<{ messageId: string }>()
|
||||
);
|
||||
|
||||
export const deleteMessageSuccess = createAction(
|
||||
'[Messages] Delete Message Success',
|
||||
props<{ messageId: string }>()
|
||||
);
|
||||
|
||||
export const deleteMessageFailure = createAction(
|
||||
'[Messages] Delete Message Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
|
||||
// Admin delete message (can delete any message)
|
||||
export const adminDeleteMessage = createAction(
|
||||
'[Messages] Admin Delete Message',
|
||||
props<{ messageId: string }>()
|
||||
);
|
||||
|
||||
// Reactions
|
||||
export const addReaction = createAction(
|
||||
'[Messages] Add Reaction',
|
||||
props<{ messageId: string; emoji: string }>()
|
||||
);
|
||||
|
||||
export const addReactionSuccess = createAction(
|
||||
'[Messages] Add Reaction Success',
|
||||
props<{ reaction: Reaction }>()
|
||||
);
|
||||
|
||||
export const removeReaction = createAction(
|
||||
'[Messages] Remove Reaction',
|
||||
props<{ messageId: string; emoji: string }>()
|
||||
);
|
||||
|
||||
export const removeReactionSuccess = createAction(
|
||||
'[Messages] Remove Reaction Success',
|
||||
props<{ messageId: string; emoji: string; oderId: string }>()
|
||||
);
|
||||
|
||||
// Sync messages from peer
|
||||
export const syncMessages = createAction(
|
||||
'[Messages] Sync Messages',
|
||||
props<{ messages: Message[] }>()
|
||||
);
|
||||
|
||||
// Clear messages
|
||||
export const clearMessages = createAction('[Messages] Clear Messages');
|
||||
509
src/app/store/messages/messages.effects.ts
Normal file
509
src/app/store/messages/messages.effects.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { of, from } from 'rxjs';
|
||||
import { map, mergeMap, catchError, withLatestFrom, tap, switchMap } from 'rxjs/operators';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as MessagesActions from './messages.actions';
|
||||
import { selectCurrentUser } from '../users/users.selectors';
|
||||
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
||||
import { DatabaseService } from '../../core/services/database.service';
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
import { TimeSyncService } from '../../core/services/time-sync.service';
|
||||
import { Message, Reaction } from '../../core/models';
|
||||
import * as UsersActions from '../users/users.actions';
|
||||
import * as RoomsActions from '../rooms/rooms.actions';
|
||||
|
||||
@Injectable()
|
||||
export class MessagesEffects {
|
||||
private actions$ = inject(Actions);
|
||||
private store = inject(Store);
|
||||
private db = inject(DatabaseService);
|
||||
private webrtc = inject(WebRTCService);
|
||||
private timeSync = inject(TimeSyncService);
|
||||
|
||||
private readonly INVENTORY_LIMIT = 1000; // number of recent messages to consider
|
||||
private readonly CHUNK_SIZE = 200; // chunk size for inventory/batch transfers
|
||||
|
||||
// Load messages from local database
|
||||
loadMessages$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MessagesActions.loadMessages),
|
||||
switchMap(({ roomId }) =>
|
||||
from(this.db.getMessages(roomId)).pipe(
|
||||
map((messages) => MessagesActions.loadMessagesSuccess({ messages })),
|
||||
catchError((error) =>
|
||||
of(MessagesActions.loadMessagesFailure({ error: error.message }))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Send message
|
||||
sendMessage$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MessagesActions.sendMessage),
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom)
|
||||
),
|
||||
mergeMap(([{ content, replyToId }, currentUser, currentRoom]) => {
|
||||
if (!currentUser || !currentRoom) {
|
||||
return of(MessagesActions.sendMessageFailure({ error: 'Not connected to a room' }));
|
||||
}
|
||||
|
||||
const message: Message = {
|
||||
id: uuidv4(),
|
||||
roomId: currentRoom.id,
|
||||
senderId: currentUser.id,
|
||||
senderName: currentUser.displayName || currentUser.username,
|
||||
content,
|
||||
timestamp: this.timeSync.now(),
|
||||
reactions: [],
|
||||
isDeleted: false,
|
||||
replyToId,
|
||||
};
|
||||
|
||||
// Save to local DB
|
||||
this.db.saveMessage(message);
|
||||
|
||||
// Broadcast to all peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'chat-message',
|
||||
message,
|
||||
});
|
||||
|
||||
return of(MessagesActions.sendMessageSuccess({ message }));
|
||||
}),
|
||||
catchError((error) =>
|
||||
of(MessagesActions.sendMessageFailure({ error: error.message }))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Edit message
|
||||
editMessage$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MessagesActions.editMessage),
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
switchMap(([{ messageId, content }, currentUser]) => {
|
||||
if (!currentUser) {
|
||||
return of(MessagesActions.editMessageFailure({ error: 'Not logged in' }));
|
||||
}
|
||||
|
||||
return from(this.db.getMessageById(messageId)).pipe(
|
||||
mergeMap((existingMessage) => {
|
||||
if (!existingMessage) {
|
||||
return of(MessagesActions.editMessageFailure({ error: 'Message not found' }));
|
||||
}
|
||||
|
||||
// Check if user owns the message
|
||||
if (existingMessage.senderId !== currentUser.id) {
|
||||
return of(MessagesActions.editMessageFailure({ error: 'Cannot edit others messages' }));
|
||||
}
|
||||
|
||||
const editedAt = this.timeSync.now();
|
||||
|
||||
// Update in DB
|
||||
this.db.updateMessage(messageId, { content, editedAt });
|
||||
|
||||
// Broadcast to peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'message-edited',
|
||||
messageId,
|
||||
content,
|
||||
editedAt,
|
||||
});
|
||||
|
||||
return of(MessagesActions.editMessageSuccess({ messageId, content, editedAt }));
|
||||
}),
|
||||
catchError((error) =>
|
||||
of(MessagesActions.editMessageFailure({ error: error.message }))
|
||||
)
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Delete message (user's own)
|
||||
deleteMessage$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MessagesActions.deleteMessage),
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
switchMap(([{ messageId }, currentUser]) => {
|
||||
if (!currentUser) {
|
||||
return of(MessagesActions.deleteMessageFailure({ error: 'Not logged in' }));
|
||||
}
|
||||
|
||||
return from(this.db.getMessageById(messageId)).pipe(
|
||||
mergeMap((existingMessage) => {
|
||||
if (!existingMessage) {
|
||||
return of(MessagesActions.deleteMessageFailure({ error: 'Message not found' }));
|
||||
}
|
||||
|
||||
// Check if user owns the message
|
||||
if (existingMessage.senderId !== currentUser.id) {
|
||||
return of(MessagesActions.deleteMessageFailure({ error: 'Cannot delete others messages' }));
|
||||
}
|
||||
|
||||
// Soft delete - mark as deleted
|
||||
this.db.updateMessage(messageId, { isDeleted: true });
|
||||
|
||||
// Broadcast to peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'message-deleted',
|
||||
messageId,
|
||||
});
|
||||
|
||||
return of(MessagesActions.deleteMessageSuccess({ messageId }));
|
||||
}),
|
||||
catchError((error) =>
|
||||
of(MessagesActions.deleteMessageFailure({ error: error.message }))
|
||||
)
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Admin delete message
|
||||
adminDeleteMessage$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MessagesActions.adminDeleteMessage),
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
mergeMap(([{ messageId }, currentUser]) => {
|
||||
if (!currentUser) {
|
||||
return of(MessagesActions.deleteMessageFailure({ error: 'Not logged in' }));
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
if (currentUser.role !== 'host' && currentUser.role !== 'admin' && currentUser.role !== 'moderator') {
|
||||
return of(MessagesActions.deleteMessageFailure({ error: 'Permission denied' }));
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
this.db.updateMessage(messageId, { isDeleted: true });
|
||||
|
||||
// Broadcast to peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'message-deleted',
|
||||
messageId,
|
||||
deletedBy: currentUser.id,
|
||||
});
|
||||
|
||||
return of(MessagesActions.deleteMessageSuccess({ messageId }));
|
||||
}),
|
||||
catchError((error) =>
|
||||
of(MessagesActions.deleteMessageFailure({ error: error.message }))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Add reaction
|
||||
addReaction$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MessagesActions.addReaction),
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
mergeMap(([{ messageId, emoji }, currentUser]) => {
|
||||
if (!currentUser) {
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
|
||||
const reaction: Reaction = {
|
||||
id: uuidv4(),
|
||||
messageId,
|
||||
oderId: currentUser.id,
|
||||
userId: currentUser.id,
|
||||
emoji,
|
||||
timestamp: this.timeSync.now(),
|
||||
};
|
||||
|
||||
// Save to DB
|
||||
this.db.saveReaction(reaction);
|
||||
|
||||
// Broadcast to peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'reaction-added',
|
||||
reaction,
|
||||
});
|
||||
|
||||
return of(MessagesActions.addReactionSuccess({ reaction }));
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Remove reaction
|
||||
removeReaction$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(MessagesActions.removeReaction),
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
mergeMap(([{ messageId, emoji }, currentUser]) => {
|
||||
if (!currentUser) {
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
|
||||
// Remove from DB
|
||||
this.db.removeReaction(messageId, currentUser.id, emoji);
|
||||
|
||||
// Broadcast to peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'reaction-removed',
|
||||
messageId,
|
||||
oderId: currentUser.id,
|
||||
emoji,
|
||||
});
|
||||
|
||||
return of(MessagesActions.removeReactionSuccess({ messageId, oderId: currentUser.id, emoji }));
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Listen to incoming messages from WebRTC peers
|
||||
incomingMessages$ = createEffect(() =>
|
||||
this.webrtc.onMessageReceived.pipe(
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom)
|
||||
),
|
||||
mergeMap(([event, currentUser, currentRoom]: [any, any, any]) => {
|
||||
console.log('Received peer message:', event.type, event);
|
||||
|
||||
switch (event.type) {
|
||||
// Precise sync via ID inventory and targeted requests
|
||||
case 'chat-inventory-request': {
|
||||
if (!currentRoom || !event.fromPeerId) return of({ type: 'NO_OP' });
|
||||
return from(this.db.getMessages(currentRoom.id, this.INVENTORY_LIMIT, 0)).pipe(
|
||||
tap((messages) => {
|
||||
const items = messages
|
||||
.map((m) => ({ id: m.id, ts: m.editedAt || m.timestamp || 0 }))
|
||||
.sort((a, b) => a.ts - b.ts);
|
||||
for (let i = 0; i < items.length; i += this.CHUNK_SIZE) {
|
||||
const chunk = items.slice(i, i + this.CHUNK_SIZE);
|
||||
this.webrtc.sendToPeer(event.fromPeerId, {
|
||||
type: 'chat-inventory',
|
||||
roomId: currentRoom.id,
|
||||
items: chunk,
|
||||
total: items.length,
|
||||
index: i,
|
||||
} as any);
|
||||
}
|
||||
}),
|
||||
map(() => ({ type: 'NO_OP' }))
|
||||
);
|
||||
}
|
||||
|
||||
case 'chat-inventory': {
|
||||
if (!currentRoom || !Array.isArray(event.items) || !event.fromPeerId) return of({ type: 'NO_OP' });
|
||||
// Determine which IDs we are missing or have older versions of
|
||||
return from(this.db.getMessages(currentRoom.id, this.INVENTORY_LIMIT, 0)).pipe(
|
||||
mergeMap(async (local) => {
|
||||
const localMap = new Map(local.map((m) => [m.id, m.editedAt || m.timestamp || 0]));
|
||||
const missing: string[] = [];
|
||||
for (const { id, ts } of event.items as Array<{ id: string; ts: number }>) {
|
||||
const lts = localMap.get(id);
|
||||
if (lts === undefined || ts > lts) {
|
||||
missing.push(id);
|
||||
}
|
||||
}
|
||||
// Request in chunks from the sender
|
||||
for (let i = 0; i < missing.length; i += this.CHUNK_SIZE) {
|
||||
const chunk = missing.slice(i, i + this.CHUNK_SIZE);
|
||||
this.webrtc.sendToPeer(event.fromPeerId, {
|
||||
type: 'chat-sync-request-ids',
|
||||
roomId: currentRoom.id,
|
||||
ids: chunk,
|
||||
} as any);
|
||||
}
|
||||
return { type: 'NO_OP' } as any;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
case 'chat-sync-request-ids': {
|
||||
if (!currentRoom || !Array.isArray(event.ids) || !event.fromPeerId) return of({ type: 'NO_OP' });
|
||||
const ids: string[] = event.ids;
|
||||
return from(Promise.all(ids.map((id: string) => this.db.getMessageById(id)))).pipe(
|
||||
tap((maybeMessages) => {
|
||||
const messages = maybeMessages.filter((m): m is Message => !!m);
|
||||
// Send in chunks to avoid large payloads
|
||||
for (let i = 0; i < messages.length; i += this.CHUNK_SIZE) {
|
||||
const chunk = messages.slice(i, i + this.CHUNK_SIZE);
|
||||
this.webrtc.sendToPeer(event.fromPeerId, {
|
||||
type: 'chat-sync-batch',
|
||||
roomId: currentRoom.id,
|
||||
messages: chunk,
|
||||
} as any);
|
||||
}
|
||||
}),
|
||||
map(() => ({ type: 'NO_OP' }))
|
||||
);
|
||||
}
|
||||
|
||||
case 'chat-sync-batch': {
|
||||
if (!currentRoom || !Array.isArray(event.messages)) return of({ type: 'NO_OP' });
|
||||
return from((async () => {
|
||||
const accepted: Message[] = [];
|
||||
for (const m of event.messages as Message[]) {
|
||||
const existing = await this.db.getMessageById(m.id);
|
||||
const ets = existing ? (existing.editedAt || existing.timestamp || 0) : -1;
|
||||
const its = m.editedAt || m.timestamp || 0;
|
||||
if (!existing || its > ets) {
|
||||
await this.db.saveMessage(m);
|
||||
accepted.push(m);
|
||||
}
|
||||
}
|
||||
return accepted;
|
||||
})()).pipe(
|
||||
mergeMap((accepted) => accepted.length ? of(MessagesActions.syncMessages({ messages: accepted })) : of({ type: 'NO_OP' }))
|
||||
);
|
||||
}
|
||||
case 'voice-state':
|
||||
// Update voice state for the sender
|
||||
if (event.oderId && event.voiceState) {
|
||||
const userId = event.oderId;
|
||||
return of(UsersActions.updateVoiceState({ userId, voiceState: event.voiceState }));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'chat-message':
|
||||
// Save to local DB and dispatch receive action
|
||||
// Skip if this is our own message (sent via server relay)
|
||||
if (event.message && event.message.senderId !== currentUser?.id && event.message.senderId !== currentUser?.oderId) {
|
||||
this.db.saveMessage(event.message);
|
||||
return of(MessagesActions.receiveMessage({ message: event.message }));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'message-edited':
|
||||
if (event.messageId && event.content) {
|
||||
this.db.updateMessage(event.messageId, { content: event.content, editedAt: event.editedAt });
|
||||
return of(MessagesActions.editMessageSuccess({
|
||||
messageId: event.messageId,
|
||||
content: event.content,
|
||||
editedAt: event.editedAt,
|
||||
}));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'message-deleted':
|
||||
if (event.messageId) {
|
||||
this.db.deleteMessage(event.messageId);
|
||||
return of(MessagesActions.deleteMessageSuccess({ messageId: event.messageId }));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'reaction-added':
|
||||
if (event.messageId && event.reaction) {
|
||||
this.db.saveReaction(event.reaction);
|
||||
return of(MessagesActions.addReactionSuccess({
|
||||
reaction: event.reaction,
|
||||
}));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'reaction-removed':
|
||||
if (event.messageId && event.oderId && event.emoji) {
|
||||
this.db.removeReaction(event.messageId, event.oderId, event.emoji);
|
||||
return of(MessagesActions.removeReactionSuccess({
|
||||
messageId: event.messageId,
|
||||
oderId: event.oderId,
|
||||
emoji: event.emoji,
|
||||
}));
|
||||
}
|
||||
break;
|
||||
|
||||
// Chat sync handshake: summary -> request -> full
|
||||
case 'chat-sync-summary':
|
||||
// Compare summaries and request sync if the peer has newer data
|
||||
if (!currentRoom) return of({ type: 'NO_OP' });
|
||||
return from(this.db.getMessages(currentRoom.id, 10000, 0)).pipe(
|
||||
tap((local) => {
|
||||
const localCount = local.length;
|
||||
const localLastUpdated = local.reduce((max, m) => Math.max(max, m.editedAt || m.timestamp || 0), 0);
|
||||
const remoteLastUpdated = event.lastUpdated || 0;
|
||||
const remoteCount = event.count || 0;
|
||||
|
||||
const identical = localLastUpdated === remoteLastUpdated && localCount === remoteCount;
|
||||
const needsSync = remoteLastUpdated > localLastUpdated || (remoteLastUpdated === localLastUpdated && remoteCount > localCount);
|
||||
|
||||
if (!identical && needsSync && event.fromPeerId) {
|
||||
this.webrtc.sendToPeer(event.fromPeerId, { type: 'chat-sync-request', roomId: currentRoom.id } as any);
|
||||
}
|
||||
}),
|
||||
map(() => ({ type: 'NO_OP' }))
|
||||
);
|
||||
|
||||
case 'chat-sync-request':
|
||||
if (!currentRoom || !event.fromPeerId) return of({ type: 'NO_OP' });
|
||||
return from(this.db.getMessages(currentRoom.id, 10000, 0)).pipe(
|
||||
tap((all) => {
|
||||
this.webrtc.sendToPeer(event.fromPeerId, { type: 'chat-sync-full', roomId: currentRoom.id, messages: all } as any);
|
||||
}),
|
||||
map(() => ({ type: 'NO_OP' }))
|
||||
);
|
||||
|
||||
case 'chat-sync-full':
|
||||
if (event.messages && Array.isArray(event.messages)) {
|
||||
// Merge into local DB and update store
|
||||
event.messages.forEach((m: Message) => this.db.saveMessage(m));
|
||||
return of(MessagesActions.syncMessages({ messages: event.messages }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return of({ type: 'NO_OP' });
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// On peer connect, broadcast local dataset summary
|
||||
peerConnectedSync$ = createEffect(
|
||||
() =>
|
||||
this.webrtc.onPeerConnected.pipe(
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
mergeMap(([peerId, room]) => {
|
||||
if (!room) return of({ type: 'NO_OP' });
|
||||
return from(this.db.getMessages(room.id, 10000, 0)).pipe(
|
||||
map((messages) => {
|
||||
const count = messages.length;
|
||||
const lastUpdated = messages.reduce((max, m) => Math.max(max, m.editedAt || m.timestamp || 0), 0);
|
||||
// Send summary specifically to the newly connected peer
|
||||
this.webrtc.sendToPeer(peerId, { type: 'chat-sync-summary', roomId: room.id, count, lastUpdated } as any);
|
||||
// Also request their inventory for precise reconciliation
|
||||
this.webrtc.sendToPeer(peerId, { type: 'chat-inventory-request', roomId: room.id } as any);
|
||||
return { type: 'NO_OP' };
|
||||
})
|
||||
);
|
||||
})
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
// Kick off sync to all currently connected peers shortly after joining a room
|
||||
joinRoomSyncKickoff$ = createEffect(
|
||||
() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.joinRoomSuccess),
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
mergeMap(([{ room }, currentRoom]) => {
|
||||
const activeRoom = currentRoom || room;
|
||||
if (!activeRoom) return of({ type: 'NO_OP' });
|
||||
return from(this.db.getMessages(activeRoom.id, 10000, 0)).pipe(
|
||||
tap((messages) => {
|
||||
const count = messages.length;
|
||||
const lastUpdated = messages.reduce((max, m) => Math.max(max, m.editedAt || m.timestamp || 0), 0);
|
||||
const peers = this.webrtc.getConnectedPeers();
|
||||
peers.forEach((pid) => {
|
||||
try {
|
||||
this.webrtc.sendToPeer(pid, { type: 'chat-sync-summary', roomId: activeRoom.id, count, lastUpdated } as any);
|
||||
this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: activeRoom.id } as any);
|
||||
} catch {}
|
||||
});
|
||||
}),
|
||||
map(() => ({ type: 'NO_OP' }))
|
||||
);
|
||||
})
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
}
|
||||
145
src/app/store/messages/messages.reducer.ts
Normal file
145
src/app/store/messages/messages.reducer.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { createReducer, on } from '@ngrx/store';
|
||||
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
|
||||
import { Message } from '../../core/models';
|
||||
import * as MessagesActions from './messages.actions';
|
||||
|
||||
export interface MessagesState extends EntityState<Message> {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
currentRoomId: string | null;
|
||||
}
|
||||
|
||||
export const messagesAdapter: EntityAdapter<Message> = createEntityAdapter<Message>({
|
||||
selectId: (message) => message.id,
|
||||
sortComparer: (a, b) => a.timestamp - b.timestamp,
|
||||
});
|
||||
|
||||
export const initialState: MessagesState = messagesAdapter.getInitialState({
|
||||
loading: false,
|
||||
error: null,
|
||||
currentRoomId: null,
|
||||
});
|
||||
|
||||
export const messagesReducer = createReducer(
|
||||
initialState,
|
||||
|
||||
// Load messages
|
||||
on(MessagesActions.loadMessages, (state, { roomId }) => ({
|
||||
...state,
|
||||
loading: true,
|
||||
error: null,
|
||||
currentRoomId: roomId,
|
||||
})),
|
||||
|
||||
on(MessagesActions.loadMessagesSuccess, (state, { messages }) =>
|
||||
messagesAdapter.setAll(messages, {
|
||||
...state,
|
||||
loading: false,
|
||||
})
|
||||
),
|
||||
|
||||
on(MessagesActions.loadMessagesFailure, (state, { error }) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error,
|
||||
})),
|
||||
|
||||
// Send message
|
||||
on(MessagesActions.sendMessage, (state) => ({
|
||||
...state,
|
||||
loading: true,
|
||||
})),
|
||||
|
||||
on(MessagesActions.sendMessageSuccess, (state, { message }) =>
|
||||
messagesAdapter.addOne(message, {
|
||||
...state,
|
||||
loading: false,
|
||||
})
|
||||
),
|
||||
|
||||
on(MessagesActions.sendMessageFailure, (state, { error }) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error,
|
||||
})),
|
||||
|
||||
// Receive message from peer
|
||||
on(MessagesActions.receiveMessage, (state, { message }) =>
|
||||
messagesAdapter.upsertOne(message, state)
|
||||
),
|
||||
|
||||
// Edit message
|
||||
on(MessagesActions.editMessageSuccess, (state, { messageId, content, editedAt }) =>
|
||||
messagesAdapter.updateOne(
|
||||
{
|
||||
id: messageId,
|
||||
changes: { content, editedAt },
|
||||
},
|
||||
state
|
||||
)
|
||||
),
|
||||
|
||||
// Delete message
|
||||
on(MessagesActions.deleteMessageSuccess, (state, { messageId }) =>
|
||||
messagesAdapter.updateOne(
|
||||
{
|
||||
id: messageId,
|
||||
changes: { isDeleted: true, content: '[Message deleted]' },
|
||||
},
|
||||
state
|
||||
)
|
||||
),
|
||||
|
||||
// Add reaction
|
||||
on(MessagesActions.addReactionSuccess, (state, { reaction }) => {
|
||||
const message = state.entities[reaction.messageId];
|
||||
if (!message) return state;
|
||||
|
||||
const existingReaction = message.reactions.find(
|
||||
(r) => r.emoji === reaction.emoji && r.userId === reaction.userId
|
||||
);
|
||||
|
||||
if (existingReaction) return state;
|
||||
|
||||
return messagesAdapter.updateOne(
|
||||
{
|
||||
id: reaction.messageId,
|
||||
changes: {
|
||||
reactions: [...message.reactions, reaction],
|
||||
},
|
||||
},
|
||||
state
|
||||
);
|
||||
}),
|
||||
|
||||
// Remove reaction
|
||||
on(MessagesActions.removeReactionSuccess, (state, { messageId, emoji, oderId }) => {
|
||||
const message = state.entities[messageId];
|
||||
if (!message) return state;
|
||||
|
||||
return messagesAdapter.updateOne(
|
||||
{
|
||||
id: messageId,
|
||||
changes: {
|
||||
reactions: message.reactions.filter(
|
||||
(r) => !(r.emoji === emoji && r.userId === oderId)
|
||||
),
|
||||
},
|
||||
},
|
||||
state
|
||||
);
|
||||
}),
|
||||
|
||||
// Sync messages from peer
|
||||
on(MessagesActions.syncMessages, (state, { messages }) =>
|
||||
messagesAdapter.upsertMany(messages, state)
|
||||
),
|
||||
|
||||
// Clear messages
|
||||
on(MessagesActions.clearMessages, (state) =>
|
||||
messagesAdapter.removeAll({
|
||||
...state,
|
||||
currentRoomId: null,
|
||||
})
|
||||
)
|
||||
);
|
||||
53
src/app/store/messages/messages.selectors.ts
Normal file
53
src/app/store/messages/messages.selectors.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { createFeatureSelector, createSelector } from '@ngrx/store';
|
||||
import { MessagesState, messagesAdapter } from './messages.reducer';
|
||||
|
||||
export const selectMessagesState = createFeatureSelector<MessagesState>('messages');
|
||||
|
||||
const { selectIds, selectEntities, selectAll, selectTotal } = messagesAdapter.getSelectors();
|
||||
|
||||
export const selectAllMessages = createSelector(selectMessagesState, selectAll);
|
||||
|
||||
export const selectMessagesEntities = createSelector(selectMessagesState, selectEntities);
|
||||
|
||||
export const selectMessagesIds = createSelector(selectMessagesState, selectIds);
|
||||
|
||||
export const selectMessagesTotal = createSelector(selectMessagesState, selectTotal);
|
||||
|
||||
export const selectMessagesLoading = createSelector(
|
||||
selectMessagesState,
|
||||
(state) => state.loading
|
||||
);
|
||||
|
||||
export const selectMessagesError = createSelector(
|
||||
selectMessagesState,
|
||||
(state) => state.error
|
||||
);
|
||||
|
||||
export const selectCurrentRoomId = createSelector(
|
||||
selectMessagesState,
|
||||
(state) => state.currentRoomId
|
||||
);
|
||||
|
||||
export const selectCurrentRoomMessages = createSelector(
|
||||
selectAllMessages,
|
||||
selectCurrentRoomId,
|
||||
(messages, roomId) => roomId ? messages.filter((m) => m.roomId === roomId) : []
|
||||
);
|
||||
|
||||
export const selectMessageById = (id: string) =>
|
||||
createSelector(selectMessagesEntities, (entities) => entities[id]);
|
||||
|
||||
export const selectMessagesByRoomId = (roomId: string) =>
|
||||
createSelector(selectAllMessages, (messages) =>
|
||||
messages.filter((m) => m.roomId === roomId)
|
||||
);
|
||||
|
||||
export const selectRecentMessages = (limit: number) =>
|
||||
createSelector(selectAllMessages, (messages) =>
|
||||
messages.slice(-limit)
|
||||
);
|
||||
|
||||
export const selectMessagesWithReactions = createSelector(
|
||||
selectAllMessages,
|
||||
(messages) => messages.filter((m) => m.reactions.length > 0)
|
||||
);
|
||||
4
src/app/store/rooms/index.ts
Normal file
4
src/app/store/rooms/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './rooms.actions';
|
||||
export * from './rooms.reducer';
|
||||
export * from './rooms.selectors';
|
||||
export * from './rooms.effects';
|
||||
158
src/app/store/rooms/rooms.actions.ts
Normal file
158
src/app/store/rooms/rooms.actions.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { createAction, props } from '@ngrx/store';
|
||||
import { Room, RoomSettings, ServerInfo, RoomPermissions } from '../../core/models';
|
||||
|
||||
// Load rooms from storage
|
||||
export const loadRooms = createAction('[Rooms] Load Rooms');
|
||||
|
||||
export const loadRoomsSuccess = createAction(
|
||||
'[Rooms] Load Rooms Success',
|
||||
props<{ rooms: Room[] }>()
|
||||
);
|
||||
|
||||
export const loadRoomsFailure = createAction(
|
||||
'[Rooms] Load Rooms Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
|
||||
// Search servers
|
||||
export const searchServers = createAction(
|
||||
'[Rooms] Search Servers',
|
||||
props<{ query: string }>()
|
||||
);
|
||||
|
||||
export const searchServersSuccess = createAction(
|
||||
'[Rooms] Search Servers Success',
|
||||
props<{ servers: ServerInfo[] }>()
|
||||
);
|
||||
|
||||
export const searchServersFailure = createAction(
|
||||
'[Rooms] Search Servers Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
|
||||
// Create room
|
||||
export const createRoom = createAction(
|
||||
'[Rooms] Create Room',
|
||||
props<{ name: string; description?: string; topic?: string; isPrivate?: boolean; password?: string }>()
|
||||
);
|
||||
|
||||
export const createRoomSuccess = createAction(
|
||||
'[Rooms] Create Room Success',
|
||||
props<{ room: Room }>()
|
||||
);
|
||||
|
||||
export const createRoomFailure = createAction(
|
||||
'[Rooms] Create Room Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
|
||||
// Join room
|
||||
export const joinRoom = createAction(
|
||||
'[Rooms] Join Room',
|
||||
props<{ roomId: string; password?: string; serverInfo?: { name: string; description?: string; hostName?: string } }>()
|
||||
);
|
||||
|
||||
export const joinRoomSuccess = createAction(
|
||||
'[Rooms] Join Room Success',
|
||||
props<{ room: Room }>()
|
||||
);
|
||||
|
||||
export const joinRoomFailure = createAction(
|
||||
'[Rooms] Join Room Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
|
||||
// Leave room
|
||||
export const leaveRoom = createAction('[Rooms] Leave Room');
|
||||
|
||||
export const leaveRoomSuccess = createAction('[Rooms] Leave Room Success');
|
||||
|
||||
// Delete room
|
||||
export const deleteRoom = createAction(
|
||||
'[Rooms] Delete Room',
|
||||
props<{ roomId: string }>()
|
||||
);
|
||||
|
||||
export const deleteRoomSuccess = createAction(
|
||||
'[Rooms] Delete Room Success',
|
||||
props<{ roomId: string }>()
|
||||
);
|
||||
|
||||
// Forget room locally
|
||||
export const forgetRoom = createAction(
|
||||
'[Rooms] Forget Room',
|
||||
props<{ roomId: string }>()
|
||||
);
|
||||
|
||||
export const forgetRoomSuccess = createAction(
|
||||
'[Rooms] Forget Room Success',
|
||||
props<{ roomId: string }>()
|
||||
);
|
||||
|
||||
// Update room settings
|
||||
export const updateRoomSettings = createAction(
|
||||
'[Rooms] Update Room Settings',
|
||||
props<{ settings: Partial<RoomSettings> }>()
|
||||
);
|
||||
|
||||
export const updateRoomSettingsSuccess = createAction(
|
||||
'[Rooms] Update Room Settings Success',
|
||||
props<{ settings: RoomSettings }>()
|
||||
);
|
||||
|
||||
export const updateRoomSettingsFailure = createAction(
|
||||
'[Rooms] Update Room Settings Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
|
||||
// Update room permissions
|
||||
export const updateRoomPermissions = createAction(
|
||||
'[Rooms] Update Room Permissions',
|
||||
props<{ roomId: string; permissions: Partial<RoomPermissions> }>()
|
||||
);
|
||||
|
||||
// Update server icon (permission enforced)
|
||||
export const updateServerIcon = createAction(
|
||||
'[Rooms] Update Server Icon',
|
||||
props<{ roomId: string; icon: string }>()
|
||||
);
|
||||
|
||||
export const updateServerIconSuccess = createAction(
|
||||
'[Rooms] Update Server Icon Success',
|
||||
props<{ roomId: string; icon: string; iconUpdatedAt: number }>()
|
||||
);
|
||||
|
||||
export const updateServerIconFailure = createAction(
|
||||
'[Rooms] Update Server Icon Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
|
||||
// Set current room
|
||||
export const setCurrentRoom = createAction(
|
||||
'[Rooms] Set Current Room',
|
||||
props<{ room: Room }>()
|
||||
);
|
||||
|
||||
// Clear current room
|
||||
export const clearCurrentRoom = createAction('[Rooms] Clear Current Room');
|
||||
|
||||
// Update room
|
||||
export const updateRoom = createAction(
|
||||
'[Rooms] Update Room',
|
||||
props<{ roomId: string; changes: Partial<Room> }>()
|
||||
);
|
||||
|
||||
// Receive room update from peer
|
||||
export const receiveRoomUpdate = createAction(
|
||||
'[Rooms] Receive Room Update',
|
||||
props<{ room: Partial<Room> }>()
|
||||
);
|
||||
|
||||
// Clear search results
|
||||
export const clearSearchResults = createAction('[Rooms] Clear Search Results');
|
||||
|
||||
// Set connection status
|
||||
export const setConnecting = createAction(
|
||||
'[Rooms] Set Connecting',
|
||||
props<{ isConnecting: boolean }>()
|
||||
);
|
||||
594
src/app/store/rooms/rooms.effects.ts
Normal file
594
src/app/store/rooms/rooms.effects.ts
Normal file
@@ -0,0 +1,594 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { of, from } from 'rxjs';
|
||||
import {
|
||||
map,
|
||||
mergeMap,
|
||||
catchError,
|
||||
withLatestFrom,
|
||||
tap,
|
||||
debounceTime,
|
||||
switchMap,
|
||||
} from 'rxjs/operators';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as RoomsActions from './rooms.actions';
|
||||
import * as UsersActions from '../users/users.actions';
|
||||
import * as MessagesActions from '../messages/messages.actions';
|
||||
import { selectCurrentUser } from '../users/users.selectors';
|
||||
import { selectCurrentRoom } from './rooms.selectors';
|
||||
import { DatabaseService } from '../../core/services/database.service';
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||
import { Room, RoomSettings, RoomPermissions } from '../../core/models';
|
||||
import { selectAllUsers } from '../users/users.selectors';
|
||||
|
||||
@Injectable()
|
||||
export class RoomsEffects {
|
||||
private actions$ = inject(Actions);
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
private db = inject(DatabaseService);
|
||||
private webrtc = inject(WebRTCService);
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
|
||||
// Load rooms from database
|
||||
loadRooms$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.loadRooms),
|
||||
switchMap(() =>
|
||||
from(this.db.getAllRooms()).pipe(
|
||||
map((rooms) => RoomsActions.loadRoomsSuccess({ rooms })),
|
||||
catchError((error) =>
|
||||
of(RoomsActions.loadRoomsFailure({ error: error.message }))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Search servers with debounce
|
||||
searchServers$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.searchServers),
|
||||
debounceTime(300),
|
||||
switchMap(({ query }) =>
|
||||
this.serverDirectory.searchServers(query).pipe(
|
||||
map((servers) => RoomsActions.searchServersSuccess({ servers })),
|
||||
catchError((error) =>
|
||||
of(RoomsActions.searchServersFailure({ error: error.message }))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Create room
|
||||
createRoom$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.createRoom),
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
switchMap(([{ name, description, topic, isPrivate, password }, currentUser]) => {
|
||||
if (!currentUser) {
|
||||
return of(RoomsActions.createRoomFailure({ error: 'Not logged in' }));
|
||||
}
|
||||
|
||||
const room: Room = {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
description,
|
||||
topic,
|
||||
hostId: currentUser.id,
|
||||
isPrivate: isPrivate ?? false,
|
||||
password,
|
||||
createdAt: Date.now(),
|
||||
userCount: 1,
|
||||
maxUsers: 50,
|
||||
};
|
||||
|
||||
// Save to local DB
|
||||
this.db.saveRoom(room);
|
||||
|
||||
// Register with central server (using the same room ID for discoverability)
|
||||
this.serverDirectory
|
||||
.registerServer({
|
||||
id: room.id, // Use the same ID as the local room
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
ownerId: currentUser.id,
|
||||
ownerPublicKey: currentUser.oderId,
|
||||
hostName: currentUser.displayName,
|
||||
isPrivate: room.isPrivate,
|
||||
userCount: 1,
|
||||
maxUsers: room.maxUsers || 50,
|
||||
tags: [],
|
||||
})
|
||||
.subscribe({
|
||||
next: () => console.log('Room registered with directory, ID:', room.id),
|
||||
error: (err) => console.warn('Failed to register room:', err),
|
||||
});
|
||||
|
||||
return of(RoomsActions.createRoomSuccess({ room }));
|
||||
}),
|
||||
catchError((error) =>
|
||||
of(RoomsActions.createRoomFailure({ error: error.message }))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Join room
|
||||
joinRoom$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.joinRoom),
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
switchMap(([{ roomId, password, serverInfo }, currentUser]) => {
|
||||
if (!currentUser) {
|
||||
return of(RoomsActions.joinRoomFailure({ error: 'Not logged in' }));
|
||||
}
|
||||
|
||||
// First check local DB
|
||||
return from(this.db.getRoom(roomId)).pipe(
|
||||
switchMap((room) => {
|
||||
if (room) {
|
||||
return of(RoomsActions.joinRoomSuccess({ room }));
|
||||
}
|
||||
|
||||
// If not in local DB but we have server info from search, create a room entry
|
||||
if (serverInfo) {
|
||||
const newRoom: Room = {
|
||||
id: roomId,
|
||||
name: serverInfo.name,
|
||||
description: serverInfo.description,
|
||||
hostId: '', // Unknown, will be determined via signaling
|
||||
isPrivate: !!password,
|
||||
password,
|
||||
createdAt: Date.now(),
|
||||
userCount: 1,
|
||||
maxUsers: 50,
|
||||
};
|
||||
// Save to local DB for future reference
|
||||
this.db.saveRoom(newRoom);
|
||||
return of(RoomsActions.joinRoomSuccess({ room: newRoom }));
|
||||
}
|
||||
|
||||
// Try to get room info from server
|
||||
return this.serverDirectory.getServer(roomId).pipe(
|
||||
switchMap((serverData) => {
|
||||
if (serverData) {
|
||||
const newRoom: Room = {
|
||||
id: serverData.id,
|
||||
name: serverData.name,
|
||||
description: serverData.description,
|
||||
hostId: serverData.ownerId || '',
|
||||
isPrivate: serverData.isPrivate,
|
||||
password,
|
||||
createdAt: serverData.createdAt || Date.now(),
|
||||
userCount: serverData.userCount,
|
||||
maxUsers: serverData.maxUsers,
|
||||
};
|
||||
this.db.saveRoom(newRoom);
|
||||
return of(RoomsActions.joinRoomSuccess({ room: newRoom }));
|
||||
}
|
||||
return of(RoomsActions.joinRoomFailure({ error: 'Room not found' }));
|
||||
}),
|
||||
catchError(() => of(RoomsActions.joinRoomFailure({ error: 'Room not found' })))
|
||||
);
|
||||
}),
|
||||
catchError((error) =>
|
||||
of(RoomsActions.joinRoomFailure({ error: error.message }))
|
||||
)
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Navigate to room after successful create or join
|
||||
navigateToRoom$ = createEffect(
|
||||
() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess),
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
tap(([{ room }, user]) => {
|
||||
// Connect to signaling server
|
||||
const wsUrl = this.serverDirectory.getWebSocketUrl();
|
||||
console.log('Connecting to signaling server:', wsUrl);
|
||||
|
||||
this.webrtc.connectToSignalingServer(wsUrl).subscribe({
|
||||
next: (connected) => {
|
||||
if (connected) {
|
||||
console.log('Connected to signaling, identifying user and joining room');
|
||||
this.webrtc.setCurrentServer(room.id);
|
||||
const oderId = user?.oderId || this.webrtc.peerId();
|
||||
const displayName = user?.displayName || 'Anonymous';
|
||||
this.webrtc.identify(oderId, displayName);
|
||||
this.webrtc.joinRoom(room.id, oderId);
|
||||
}
|
||||
},
|
||||
error: (err) => console.error('Failed to connect to signaling server:', err),
|
||||
});
|
||||
|
||||
this.router.navigate(['/room', room.id]);
|
||||
})
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
// Leave room
|
||||
leaveRoom$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.leaveRoom),
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
mergeMap(([, currentRoom]) => {
|
||||
// Do not disconnect peers or voice on simple room exit
|
||||
// Navigation away from room should not kill voice or P2P.
|
||||
return of(RoomsActions.leaveRoomSuccess());
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Delete room
|
||||
deleteRoom$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.deleteRoom),
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom)
|
||||
),
|
||||
switchMap(([{ roomId }, currentUser, currentRoom]) => {
|
||||
if (!currentUser) {
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
|
||||
// Only host can delete the room
|
||||
if (currentRoom?.hostId !== currentUser.id) {
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
|
||||
// Delete from local DB
|
||||
this.db.deleteRoom(roomId);
|
||||
|
||||
// Notify all connected peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'room-deleted',
|
||||
roomId,
|
||||
});
|
||||
|
||||
// Disconnect everyone
|
||||
this.webrtc.disconnectAll();
|
||||
|
||||
return of(RoomsActions.deleteRoomSuccess({ roomId }));
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Forget room locally (remove from savedRooms and local DB)
|
||||
forgetRoom$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.forgetRoom),
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
switchMap(([{ roomId }, currentRoom]) => {
|
||||
// Delete from local DB
|
||||
this.db.deleteRoom(roomId);
|
||||
|
||||
// If currently in this room, disconnect
|
||||
if (currentRoom?.id === roomId) {
|
||||
this.webrtc.disconnectAll();
|
||||
}
|
||||
|
||||
return of(RoomsActions.forgetRoomSuccess({ roomId }));
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Update room settings
|
||||
updateRoomSettings$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.updateRoomSettings),
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom)
|
||||
),
|
||||
mergeMap(([{ settings }, currentUser, currentRoom]) => {
|
||||
if (!currentUser || !currentRoom) {
|
||||
return of(
|
||||
RoomsActions.updateRoomSettingsFailure({ error: 'Not in a room' })
|
||||
);
|
||||
}
|
||||
|
||||
// Only host/admin can update settings
|
||||
if (
|
||||
currentRoom.hostId !== currentUser.id &&
|
||||
currentUser.role !== 'admin'
|
||||
) {
|
||||
return of(
|
||||
RoomsActions.updateRoomSettingsFailure({
|
||||
error: 'Permission denied',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const updatedSettings: RoomSettings = {
|
||||
name: settings.name ?? currentRoom.name,
|
||||
description: settings.description ?? currentRoom.description,
|
||||
topic: settings.topic ?? currentRoom.topic,
|
||||
isPrivate: settings.isPrivate ?? currentRoom.isPrivate,
|
||||
password: settings.password ?? currentRoom.password,
|
||||
maxUsers: settings.maxUsers ?? currentRoom.maxUsers,
|
||||
};
|
||||
|
||||
// Update local DB
|
||||
this.db.updateRoom(currentRoom.id, updatedSettings);
|
||||
|
||||
// Broadcast to all peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'room-settings-update',
|
||||
settings: updatedSettings,
|
||||
});
|
||||
|
||||
return of(
|
||||
RoomsActions.updateRoomSettingsSuccess({ settings: updatedSettings })
|
||||
);
|
||||
}),
|
||||
catchError((error) =>
|
||||
of(RoomsActions.updateRoomSettingsFailure({ error: error.message }))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Update room
|
||||
updateRoom$ = createEffect(
|
||||
() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.updateRoom),
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
tap(([{ roomId, changes }, currentRoom]) => {
|
||||
if (currentRoom && currentRoom.id === roomId) {
|
||||
this.db.updateRoom(roomId, changes);
|
||||
}
|
||||
})
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
// Update room permissions (host only)
|
||||
updateRoomPermissions$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.updateRoomPermissions),
|
||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
||||
mergeMap(([{ roomId, permissions }, currentUser, currentRoom]) => {
|
||||
if (!currentUser || !currentRoom || currentRoom.id !== roomId) {
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
// Only host can change permission grant settings
|
||||
if (currentRoom.hostId !== currentUser.id) {
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
const updated: Partial<Room> = {
|
||||
permissions: { ...(currentRoom.permissions || {}), ...permissions } as RoomPermissions,
|
||||
};
|
||||
this.db.updateRoom(roomId, updated);
|
||||
// Broadcast to peers
|
||||
this.webrtc.broadcastMessage({ type: 'room-permissions-update', permissions: updated.permissions } as any);
|
||||
return of(RoomsActions.updateRoom({ roomId, changes: updated }));
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Update server icon (host or permitted roles)
|
||||
updateServerIcon$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.updateServerIcon),
|
||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
||||
mergeMap(([{ roomId, icon }, currentUser, currentRoom]) => {
|
||||
if (!currentUser || !currentRoom || currentRoom.id !== roomId) {
|
||||
return of(RoomsActions.updateServerIconFailure({ error: 'Not in room' }));
|
||||
}
|
||||
|
||||
const role = currentUser.role;
|
||||
const perms = currentRoom.permissions || {};
|
||||
const isOwner = currentRoom.hostId === currentUser.id;
|
||||
const canByRole = (role === 'admin' && perms.adminsManageIcon) || (role === 'moderator' && perms.moderatorsManageIcon);
|
||||
if (!isOwner && !canByRole) {
|
||||
return of(RoomsActions.updateServerIconFailure({ error: 'Permission denied' }));
|
||||
}
|
||||
|
||||
const iconUpdatedAt = Date.now();
|
||||
const changes: Partial<Room> = { icon, iconUpdatedAt };
|
||||
this.db.updateRoom(roomId, changes);
|
||||
// Broadcast to peers
|
||||
this.webrtc.broadcastMessage({ type: 'server-icon-update', roomId, icon, iconUpdatedAt } as any);
|
||||
return of(RoomsActions.updateServerIconSuccess({ roomId, icon, iconUpdatedAt }));
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Persist room creation to database
|
||||
persistRoomCreation$ = createEffect(
|
||||
() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.createRoomSuccess),
|
||||
tap(({ room }) => {
|
||||
this.db.saveRoom(room);
|
||||
})
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
// When joining a room, also load messages and users
|
||||
onJoinRoomSuccess$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.joinRoomSuccess),
|
||||
mergeMap(({ room }) => [
|
||||
MessagesActions.loadMessages({ roomId: room.id }),
|
||||
// Don't load users from database - they come from signaling server
|
||||
// UsersActions.loadRoomUsers({ roomId: room.id }),
|
||||
UsersActions.loadBans(),
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
// When leaving a room, clear messages and users
|
||||
onLeaveRoom$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.leaveRoomSuccess),
|
||||
mergeMap(() => [
|
||||
MessagesActions.clearMessages(),
|
||||
UsersActions.clearUsers(),
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
// Listen to WebRTC signaling messages for user presence
|
||||
signalingMessages$ = createEffect(() =>
|
||||
this.webrtc.onSignalingMessage.pipe(
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
mergeMap(([message, currentUser]: [any, any]) => {
|
||||
const actions: any[] = [];
|
||||
const myId = currentUser?.oderId || currentUser?.id;
|
||||
|
||||
if (message.type === 'server_users' && message.users) {
|
||||
// Add all existing users to the store (excluding ourselves)
|
||||
message.users.forEach((user: { oderId: string; displayName: string }) => {
|
||||
// Don't add ourselves to the list
|
||||
if (user.oderId !== myId) {
|
||||
actions.push(
|
||||
UsersActions.userJoined({
|
||||
user: {
|
||||
oderId: user.oderId,
|
||||
id: user.oderId,
|
||||
username: user.displayName.toLowerCase().replace(/\s+/g, '_'),
|
||||
displayName: user.displayName,
|
||||
status: 'online',
|
||||
isOnline: true,
|
||||
role: 'member',
|
||||
joinedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
} else if (message.type === 'user_joined') {
|
||||
// Don't add ourselves to the list
|
||||
if (message.oderId !== myId) {
|
||||
actions.push(
|
||||
UsersActions.userJoined({
|
||||
user: {
|
||||
oderId: message.oderId,
|
||||
id: message.oderId,
|
||||
username: message.displayName.toLowerCase().replace(/\s+/g, '_'),
|
||||
displayName: message.displayName,
|
||||
status: 'online',
|
||||
isOnline: true,
|
||||
role: 'member',
|
||||
joinedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
} else if (message.type === 'user_left') {
|
||||
actions.push(UsersActions.userLeft({ userId: message.oderId }));
|
||||
}
|
||||
|
||||
return actions.length > 0 ? actions : [{ type: 'NO_OP' }];
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Incoming P2P room/icon events
|
||||
incomingRoomEvents$ = createEffect(() =>
|
||||
this.webrtc.onMessageReceived.pipe(
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
mergeMap(([event, currentRoom]: [any, Room | null]) => {
|
||||
if (!currentRoom) return of({ type: 'NO_OP' });
|
||||
|
||||
switch (event.type) {
|
||||
case 'voice-state': {
|
||||
const userId = (event.fromPeerId as string) || (event.oderId as string);
|
||||
const vs = event.voiceState as Partial<import('../../core/models').VoiceState> | undefined;
|
||||
if (!userId || !vs) return of({ type: 'NO_OP' });
|
||||
return of(UsersActions.updateVoiceState({ userId, voiceState: vs }));
|
||||
}
|
||||
case 'room-settings-update': {
|
||||
const settings: RoomSettings | undefined = event.settings;
|
||||
if (!settings) return of({ type: 'NO_OP' });
|
||||
this.db.updateRoom(currentRoom.id, settings);
|
||||
return of(
|
||||
RoomsActions.receiveRoomUpdate({ room: { ...settings } as Partial<Room> })
|
||||
);
|
||||
}
|
||||
|
||||
// Server icon sync handshake
|
||||
case 'server-icon-summary': {
|
||||
const remoteUpdated = event.iconUpdatedAt || 0;
|
||||
const localUpdated = currentRoom.iconUpdatedAt || 0;
|
||||
const needsSync = remoteUpdated > localUpdated;
|
||||
if (needsSync && event.fromPeerId) {
|
||||
this.webrtc.sendToPeer(event.fromPeerId, { type: 'server-icon-request', roomId: currentRoom.id } as any);
|
||||
}
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
|
||||
case 'server-icon-request': {
|
||||
if (event.fromPeerId) {
|
||||
this.webrtc.sendToPeer(event.fromPeerId, {
|
||||
type: 'server-icon-full',
|
||||
roomId: currentRoom.id,
|
||||
icon: currentRoom.icon,
|
||||
iconUpdatedAt: currentRoom.iconUpdatedAt || 0,
|
||||
} as any);
|
||||
}
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
|
||||
case 'server-icon-full':
|
||||
case 'server-icon-update': {
|
||||
if (typeof event.icon !== 'string') return of({ type: 'NO_OP' });
|
||||
// Enforce that only owner or permitted roles can update
|
||||
const senderId = event.fromPeerId as string | undefined;
|
||||
if (!senderId) return of({ type: 'NO_OP' });
|
||||
return this.store.select(selectAllUsers).pipe(
|
||||
map((users) => users.find((u) => u.id === senderId)),
|
||||
mergeMap((sender) => {
|
||||
if (!sender) return of({ type: 'NO_OP' });
|
||||
const role = sender.role;
|
||||
const perms = currentRoom.permissions || {};
|
||||
const isOwner = currentRoom.hostId === sender.id;
|
||||
const canByRole = (role === 'admin' && perms.adminsManageIcon) || (role === 'moderator' && perms.moderatorsManageIcon);
|
||||
if (!isOwner && !canByRole) {
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
const updates: Partial<Room> = {
|
||||
icon: event.icon,
|
||||
iconUpdatedAt: event.iconUpdatedAt || Date.now(),
|
||||
};
|
||||
this.db.updateRoom(currentRoom.id, updates);
|
||||
return of(RoomsActions.updateRoom({ roomId: currentRoom.id, changes: updates }));
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return of({ type: 'NO_OP' });
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// On peer connect, broadcast local server icon summary (sync upon join/connect)
|
||||
peerConnectedIconSync$ = createEffect(
|
||||
() =>
|
||||
this.webrtc.onPeerConnected.pipe(
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
tap(([peerId, room]) => {
|
||||
if (!room) return;
|
||||
const iconUpdatedAt = room.iconUpdatedAt || 0;
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'server-icon-summary',
|
||||
roomId: room.id,
|
||||
iconUpdatedAt,
|
||||
} as any);
|
||||
})
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
}
|
||||
215
src/app/store/rooms/rooms.reducer.ts
Normal file
215
src/app/store/rooms/rooms.reducer.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { createReducer, on } from '@ngrx/store';
|
||||
import { Room, ServerInfo, RoomSettings } from '../../core/models';
|
||||
import * as RoomsActions from './rooms.actions';
|
||||
|
||||
export interface RoomsState {
|
||||
currentRoom: Room | null;
|
||||
savedRooms: Room[];
|
||||
roomSettings: RoomSettings | null;
|
||||
searchResults: ServerInfo[];
|
||||
isSearching: boolean;
|
||||
isConnecting: boolean;
|
||||
isConnected: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const initialState: RoomsState = {
|
||||
currentRoom: null,
|
||||
savedRooms: [],
|
||||
roomSettings: null,
|
||||
searchResults: [],
|
||||
isSearching: false,
|
||||
isConnecting: false,
|
||||
isConnected: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export const roomsReducer = createReducer(
|
||||
initialState,
|
||||
|
||||
// Load rooms
|
||||
on(RoomsActions.loadRooms, (state) => ({
|
||||
...state,
|
||||
loading: true,
|
||||
error: null,
|
||||
})),
|
||||
|
||||
on(RoomsActions.loadRoomsSuccess, (state, { rooms }) => ({
|
||||
...state,
|
||||
savedRooms: rooms,
|
||||
loading: false,
|
||||
})),
|
||||
|
||||
on(RoomsActions.loadRoomsFailure, (state, { error }) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error,
|
||||
})),
|
||||
|
||||
// Search servers
|
||||
on(RoomsActions.searchServers, (state) => ({
|
||||
...state,
|
||||
isSearching: true,
|
||||
error: null,
|
||||
})),
|
||||
|
||||
on(RoomsActions.searchServersSuccess, (state, { servers }) => ({
|
||||
...state,
|
||||
searchResults: servers,
|
||||
isSearching: false,
|
||||
})),
|
||||
|
||||
on(RoomsActions.searchServersFailure, (state, { error }) => ({
|
||||
...state,
|
||||
isSearching: false,
|
||||
error,
|
||||
})),
|
||||
|
||||
// Create room
|
||||
on(RoomsActions.createRoom, (state) => ({
|
||||
...state,
|
||||
isConnecting: true,
|
||||
error: null,
|
||||
})),
|
||||
|
||||
on(RoomsActions.createRoomSuccess, (state, { room }) => ({
|
||||
...state,
|
||||
currentRoom: room,
|
||||
isConnecting: false,
|
||||
isConnected: true,
|
||||
})),
|
||||
|
||||
on(RoomsActions.createRoomFailure, (state, { error }) => ({
|
||||
...state,
|
||||
isConnecting: false,
|
||||
error,
|
||||
})),
|
||||
|
||||
// Join room
|
||||
on(RoomsActions.joinRoom, (state) => ({
|
||||
...state,
|
||||
isConnecting: true,
|
||||
error: null,
|
||||
})),
|
||||
|
||||
on(RoomsActions.joinRoomSuccess, (state, { room }) => ({
|
||||
...state,
|
||||
currentRoom: room,
|
||||
isConnecting: false,
|
||||
isConnected: true,
|
||||
})),
|
||||
|
||||
on(RoomsActions.joinRoomFailure, (state, { error }) => ({
|
||||
...state,
|
||||
isConnecting: false,
|
||||
error,
|
||||
})),
|
||||
|
||||
// Leave room
|
||||
on(RoomsActions.leaveRoom, (state) => ({
|
||||
...state,
|
||||
isConnecting: true,
|
||||
})),
|
||||
|
||||
on(RoomsActions.leaveRoomSuccess, (state) => ({
|
||||
...state,
|
||||
currentRoom: null,
|
||||
roomSettings: null,
|
||||
isConnecting: false,
|
||||
isConnected: false,
|
||||
})),
|
||||
|
||||
// Update room settings
|
||||
on(RoomsActions.updateRoomSettings, (state) => ({
|
||||
...state,
|
||||
error: null,
|
||||
})),
|
||||
|
||||
on(RoomsActions.updateRoomSettingsSuccess, (state, { settings }) => ({
|
||||
...state,
|
||||
roomSettings: settings,
|
||||
currentRoom: state.currentRoom
|
||||
? {
|
||||
...state.currentRoom,
|
||||
name: settings.name,
|
||||
description: settings.description,
|
||||
topic: settings.topic,
|
||||
isPrivate: settings.isPrivate,
|
||||
password: settings.password,
|
||||
maxUsers: settings.maxUsers,
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
|
||||
on(RoomsActions.updateRoomSettingsFailure, (state, { error }) => ({
|
||||
...state,
|
||||
error,
|
||||
})),
|
||||
|
||||
// Delete room
|
||||
on(RoomsActions.deleteRoomSuccess, (state, { roomId }) => ({
|
||||
...state,
|
||||
savedRooms: state.savedRooms.filter((r) => r.id !== roomId),
|
||||
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom,
|
||||
})),
|
||||
|
||||
// Forget room (local only)
|
||||
on(RoomsActions.forgetRoomSuccess, (state, { roomId }) => ({
|
||||
...state,
|
||||
savedRooms: state.savedRooms.filter((r) => r.id !== roomId),
|
||||
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom,
|
||||
})),
|
||||
|
||||
// Set current room
|
||||
on(RoomsActions.setCurrentRoom, (state, { room }) => ({
|
||||
...state,
|
||||
currentRoom: room,
|
||||
isConnected: true,
|
||||
})),
|
||||
|
||||
// Clear current room
|
||||
on(RoomsActions.clearCurrentRoom, (state) => ({
|
||||
...state,
|
||||
currentRoom: null,
|
||||
roomSettings: null,
|
||||
isConnected: false,
|
||||
})),
|
||||
|
||||
// Update room
|
||||
on(RoomsActions.updateRoom, (state, { roomId, changes }) => {
|
||||
if (state.currentRoom?.id !== roomId) return state;
|
||||
return {
|
||||
...state,
|
||||
currentRoom: { ...state.currentRoom, ...changes },
|
||||
};
|
||||
}),
|
||||
|
||||
// Update server icon success
|
||||
on(RoomsActions.updateServerIconSuccess, (state, { roomId, icon, iconUpdatedAt }) => {
|
||||
if (state.currentRoom?.id !== roomId) return state;
|
||||
return {
|
||||
...state,
|
||||
currentRoom: { ...state.currentRoom, icon, iconUpdatedAt },
|
||||
};
|
||||
}),
|
||||
|
||||
// Receive room update
|
||||
on(RoomsActions.receiveRoomUpdate, (state, { room }) => ({
|
||||
...state,
|
||||
currentRoom: state.currentRoom ? { ...state.currentRoom, ...room } : null,
|
||||
})),
|
||||
|
||||
// Clear search results
|
||||
on(RoomsActions.clearSearchResults, (state) => ({
|
||||
...state,
|
||||
searchResults: [],
|
||||
})),
|
||||
|
||||
// Set connecting
|
||||
on(RoomsActions.setConnecting, (state, { isConnecting }) => ({
|
||||
...state,
|
||||
isConnecting,
|
||||
}))
|
||||
);
|
||||
64
src/app/store/rooms/rooms.selectors.ts
Normal file
64
src/app/store/rooms/rooms.selectors.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { createFeatureSelector, createSelector } from '@ngrx/store';
|
||||
import { RoomsState } from './rooms.reducer';
|
||||
|
||||
export const selectRoomsState = createFeatureSelector<RoomsState>('rooms');
|
||||
|
||||
export const selectCurrentRoom = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.currentRoom
|
||||
);
|
||||
|
||||
export const selectRoomSettings = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.roomSettings
|
||||
);
|
||||
|
||||
export const selectSearchResults = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.searchResults
|
||||
);
|
||||
|
||||
export const selectIsSearching = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.isSearching
|
||||
);
|
||||
|
||||
export const selectIsConnecting = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.isConnecting
|
||||
);
|
||||
|
||||
export const selectIsConnected = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.isConnected
|
||||
);
|
||||
|
||||
export const selectRoomsError = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.error
|
||||
);
|
||||
|
||||
export const selectCurrentRoomId = createSelector(
|
||||
selectCurrentRoom,
|
||||
(room) => room?.id ?? null
|
||||
);
|
||||
|
||||
export const selectCurrentRoomName = createSelector(
|
||||
selectCurrentRoom,
|
||||
(room) => room?.name ?? ''
|
||||
);
|
||||
|
||||
export const selectIsCurrentUserHost = createSelector(
|
||||
selectCurrentRoom,
|
||||
(room) => room?.hostId // Will be compared with current user ID in component
|
||||
);
|
||||
|
||||
export const selectSavedRooms = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.savedRooms
|
||||
);
|
||||
|
||||
export const selectRoomsLoading = createSelector(
|
||||
selectRoomsState,
|
||||
(state) => state.loading
|
||||
);
|
||||
4
src/app/store/users/index.ts
Normal file
4
src/app/store/users/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './users.actions';
|
||||
export * from './users.reducer';
|
||||
export * from './users.selectors';
|
||||
export * from './users.effects';
|
||||
140
src/app/store/users/users.actions.ts
Normal file
140
src/app/store/users/users.actions.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { createAction, props } from '@ngrx/store';
|
||||
import { User, BanEntry, VoiceState } from '../../core/models';
|
||||
|
||||
// Load current user from storage
|
||||
export const loadCurrentUser = createAction('[Users] Load Current User');
|
||||
|
||||
export const loadCurrentUserSuccess = createAction(
|
||||
'[Users] Load Current User Success',
|
||||
props<{ user: User }>()
|
||||
);
|
||||
|
||||
export const loadCurrentUserFailure = createAction(
|
||||
'[Users] Load Current User Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
|
||||
// Set current user
|
||||
export const setCurrentUser = createAction(
|
||||
'[Users] Set Current User',
|
||||
props<{ user: User }>()
|
||||
);
|
||||
|
||||
// Update current user
|
||||
export const updateCurrentUser = createAction(
|
||||
'[Users] Update Current User',
|
||||
props<{ updates: Partial<User> }>()
|
||||
);
|
||||
|
||||
// Load users in room
|
||||
export const loadRoomUsers = createAction(
|
||||
'[Users] Load Room Users',
|
||||
props<{ roomId: string }>()
|
||||
);
|
||||
|
||||
export const loadRoomUsersSuccess = createAction(
|
||||
'[Users] Load Room Users Success',
|
||||
props<{ users: User[] }>()
|
||||
);
|
||||
|
||||
export const loadRoomUsersFailure = createAction(
|
||||
'[Users] Load Room Users Failure',
|
||||
props<{ error: string }>()
|
||||
);
|
||||
|
||||
// User joined
|
||||
export const userJoined = createAction(
|
||||
'[Users] User Joined',
|
||||
props<{ user: User }>()
|
||||
);
|
||||
|
||||
// User left
|
||||
export const userLeft = createAction(
|
||||
'[Users] User Left',
|
||||
props<{ userId: string }>()
|
||||
);
|
||||
|
||||
// Update user
|
||||
export const updateUser = createAction(
|
||||
'[Users] Update User',
|
||||
props<{ userId: string; updates: Partial<User> }>()
|
||||
);
|
||||
|
||||
// Update user role
|
||||
export const updateUserRole = createAction(
|
||||
'[Users] Update User Role',
|
||||
props<{ userId: string; role: User['role'] }>()
|
||||
);
|
||||
|
||||
// Kick user
|
||||
export const kickUser = createAction(
|
||||
'[Users] Kick User',
|
||||
props<{ userId: string }>()
|
||||
);
|
||||
|
||||
export const kickUserSuccess = createAction(
|
||||
'[Users] Kick User Success',
|
||||
props<{ userId: string }>()
|
||||
);
|
||||
|
||||
// Ban user
|
||||
export const banUser = createAction(
|
||||
'[Users] Ban User',
|
||||
props<{ userId: string; reason?: string; expiresAt?: number }>()
|
||||
);
|
||||
|
||||
export const banUserSuccess = createAction(
|
||||
'[Users] Ban User Success',
|
||||
props<{ userId: string; ban: BanEntry }>()
|
||||
);
|
||||
|
||||
// Unban user
|
||||
export const unbanUser = createAction(
|
||||
'[Users] Unban User',
|
||||
props<{ oderId: string }>()
|
||||
);
|
||||
|
||||
export const unbanUserSuccess = createAction(
|
||||
'[Users] Unban User Success',
|
||||
props<{ oderId: string }>()
|
||||
);
|
||||
|
||||
// Load bans
|
||||
export const loadBans = createAction('[Users] Load Bans');
|
||||
|
||||
export const loadBansSuccess = createAction(
|
||||
'[Users] Load Bans Success',
|
||||
props<{ bans: BanEntry[] }>()
|
||||
);
|
||||
|
||||
// Admin mute/unmute
|
||||
export const adminMuteUser = createAction(
|
||||
'[Users] Admin Mute User',
|
||||
props<{ userId: string }>()
|
||||
);
|
||||
|
||||
export const adminUnmuteUser = createAction(
|
||||
'[Users] Admin Unmute User',
|
||||
props<{ userId: string }>()
|
||||
);
|
||||
|
||||
// Sync users from peer
|
||||
export const syncUsers = createAction(
|
||||
'[Users] Sync Users',
|
||||
props<{ users: User[] }>()
|
||||
);
|
||||
|
||||
// Clear users
|
||||
export const clearUsers = createAction('[Users] Clear Users');
|
||||
|
||||
// Update host
|
||||
export const updateHost = createAction(
|
||||
'[Users] Update Host',
|
||||
props<{ userId: string }>()
|
||||
);
|
||||
|
||||
// Update voice state for a user
|
||||
export const updateVoiceState = createAction(
|
||||
'[Users] Update Voice State',
|
||||
props<{ userId: string; voiceState: Partial<VoiceState> }>()
|
||||
);
|
||||
201
src/app/store/users/users.effects.ts
Normal file
201
src/app/store/users/users.effects.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Actions, createEffect, ofType } from '@ngrx/effects';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { of, from } from 'rxjs';
|
||||
import { map, mergeMap, catchError, withLatestFrom, tap, switchMap } from 'rxjs/operators';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as UsersActions from './users.actions';
|
||||
import { selectCurrentUser, selectCurrentUserId, selectHostId } from './users.selectors';
|
||||
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
||||
import { DatabaseService } from '../../core/services/database.service';
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
import { User, BanEntry } from '../../core/models';
|
||||
|
||||
@Injectable()
|
||||
export class UsersEffects {
|
||||
private actions$ = inject(Actions);
|
||||
private store = inject(Store);
|
||||
private db = inject(DatabaseService);
|
||||
private webrtc = inject(WebRTCService);
|
||||
|
||||
// Load current user from storage
|
||||
loadCurrentUser$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.loadCurrentUser),
|
||||
switchMap(() =>
|
||||
from(this.db.getCurrentUser()).pipe(
|
||||
map((user) => {
|
||||
if (user) {
|
||||
return UsersActions.loadCurrentUserSuccess({ user });
|
||||
}
|
||||
return UsersActions.loadCurrentUserFailure({ error: 'No current user' });
|
||||
}),
|
||||
catchError((error) =>
|
||||
of(UsersActions.loadCurrentUserFailure({ error: error.message }))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Load room users from database
|
||||
loadRoomUsers$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.loadRoomUsers),
|
||||
switchMap(({ roomId }) =>
|
||||
from(this.db.getUsersByRoom(roomId)).pipe(
|
||||
map((users) => UsersActions.loadRoomUsersSuccess({ users })),
|
||||
catchError((error) =>
|
||||
of(UsersActions.loadRoomUsersFailure({ error: error.message }))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Kick user
|
||||
kickUser$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.kickUser),
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom)
|
||||
),
|
||||
mergeMap(([{ userId }, currentUser, currentRoom]) => {
|
||||
if (!currentUser || !currentRoom) {
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
|
||||
// Check if current user has permission to kick
|
||||
if (currentUser.role !== 'host' && currentUser.role !== 'admin' && currentUser.role !== 'moderator') {
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
|
||||
// Send kick signal to the target user
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'kick',
|
||||
targetUserId: userId,
|
||||
roomId: currentRoom.id,
|
||||
kickedBy: currentUser.id,
|
||||
});
|
||||
|
||||
return of(UsersActions.kickUserSuccess({ userId }));
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Ban user
|
||||
banUser$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.banUser),
|
||||
withLatestFrom(
|
||||
this.store.select(selectCurrentUser),
|
||||
this.store.select(selectCurrentRoom)
|
||||
),
|
||||
mergeMap(([{ userId, reason, expiresAt }, currentUser, currentRoom]) => {
|
||||
if (!currentUser || !currentRoom) {
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
|
||||
// Check permission
|
||||
if (currentUser.role !== 'host' && currentUser.role !== 'admin') {
|
||||
return of({ type: 'NO_OP' });
|
||||
}
|
||||
|
||||
// Add to ban list
|
||||
const banId = uuidv4();
|
||||
const ban: BanEntry = {
|
||||
oderId: banId,
|
||||
userId,
|
||||
roomId: currentRoom.id,
|
||||
bannedBy: currentUser.id,
|
||||
reason,
|
||||
expiresAt,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.db.saveBan(ban);
|
||||
|
||||
// Send ban signal
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'ban',
|
||||
targetUserId: userId,
|
||||
roomId: currentRoom.id,
|
||||
bannedBy: currentUser.id,
|
||||
reason,
|
||||
});
|
||||
|
||||
return of(UsersActions.banUserSuccess({ userId, ban }));
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Unban user
|
||||
unbanUser$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.unbanUser),
|
||||
switchMap(({ oderId }) =>
|
||||
from(this.db.removeBan(oderId)).pipe(
|
||||
map(() => UsersActions.unbanUserSuccess({ oderId })),
|
||||
catchError(() => of({ type: 'NO_OP' }))
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Load bans
|
||||
loadBans$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.loadBans),
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
switchMap(([, currentRoom]) => {
|
||||
if (!currentRoom) {
|
||||
return of(UsersActions.loadBansSuccess({ bans: [] }));
|
||||
}
|
||||
return from(this.db.getBansForRoom(currentRoom.id)).pipe(
|
||||
map((bans) => UsersActions.loadBansSuccess({ bans })),
|
||||
catchError(() => of(UsersActions.loadBansSuccess({ bans: [] })))
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Handle host reassignment when host leaves
|
||||
handleHostLeave$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.userLeft),
|
||||
withLatestFrom(
|
||||
this.store.select(selectHostId),
|
||||
this.store.select(selectCurrentUserId)
|
||||
),
|
||||
mergeMap(([{ userId }, hostId, currentUserId]) => {
|
||||
// If the leaving user is the host, elect a new host
|
||||
if (userId === hostId && currentUserId) {
|
||||
return of(UsersActions.updateHost({ userId: currentUserId }));
|
||||
}
|
||||
return of({ type: 'NO_OP' });
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Persist user changes to database
|
||||
persistUser$ = createEffect(
|
||||
() =>
|
||||
this.actions$.pipe(
|
||||
ofType(
|
||||
UsersActions.setCurrentUser,
|
||||
UsersActions.loadCurrentUserSuccess,
|
||||
UsersActions.updateCurrentUser
|
||||
),
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
tap(([, user]) => {
|
||||
if (user) {
|
||||
this.db.saveUser(user);
|
||||
// Ensure current user ID is persisted when explicitly set
|
||||
this.db.setCurrentUserId(user.id);
|
||||
}
|
||||
})
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
}
|
||||
256
src/app/store/users/users.reducer.ts
Normal file
256
src/app/store/users/users.reducer.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { createReducer, on } from '@ngrx/store';
|
||||
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
|
||||
import { User, BanEntry } from '../../core/models';
|
||||
import * as UsersActions from './users.actions';
|
||||
|
||||
export interface UsersState extends EntityState<User> {
|
||||
currentUserId: string | null;
|
||||
hostId: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
bans: BanEntry[];
|
||||
}
|
||||
|
||||
export const usersAdapter: EntityAdapter<User> = createEntityAdapter<User>({
|
||||
selectId: (user) => user.id,
|
||||
sortComparer: (a, b) => a.username.localeCompare(b.username),
|
||||
});
|
||||
|
||||
export const initialState: UsersState = usersAdapter.getInitialState({
|
||||
currentUserId: null,
|
||||
hostId: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
bans: [],
|
||||
});
|
||||
|
||||
export const usersReducer = createReducer(
|
||||
initialState,
|
||||
|
||||
// Load current user
|
||||
on(UsersActions.loadCurrentUser, (state) => ({
|
||||
...state,
|
||||
loading: true,
|
||||
error: null,
|
||||
})),
|
||||
|
||||
on(UsersActions.loadCurrentUserSuccess, (state, { user }) =>
|
||||
usersAdapter.upsertOne(user, {
|
||||
...state,
|
||||
currentUserId: user.id,
|
||||
loading: false,
|
||||
})
|
||||
),
|
||||
|
||||
on(UsersActions.loadCurrentUserFailure, (state, { error }) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error,
|
||||
})),
|
||||
|
||||
// Set current user
|
||||
on(UsersActions.setCurrentUser, (state, { user }) =>
|
||||
usersAdapter.upsertOne(user, {
|
||||
...state,
|
||||
currentUserId: user.id,
|
||||
})
|
||||
),
|
||||
|
||||
// Update current user
|
||||
on(UsersActions.updateCurrentUser, (state, { updates }) => {
|
||||
if (!state.currentUserId) return state;
|
||||
return usersAdapter.updateOne(
|
||||
{
|
||||
id: state.currentUserId,
|
||||
changes: updates,
|
||||
},
|
||||
state
|
||||
);
|
||||
}),
|
||||
|
||||
// Load room users
|
||||
on(UsersActions.loadRoomUsers, (state) => ({
|
||||
...state,
|
||||
loading: true,
|
||||
error: null,
|
||||
})),
|
||||
|
||||
on(UsersActions.loadRoomUsersSuccess, (state, { users }) =>
|
||||
usersAdapter.upsertMany(users, {
|
||||
...state,
|
||||
loading: false,
|
||||
})
|
||||
),
|
||||
|
||||
on(UsersActions.loadRoomUsersFailure, (state, { error }) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error,
|
||||
})),
|
||||
|
||||
// User joined
|
||||
on(UsersActions.userJoined, (state, { user }) =>
|
||||
usersAdapter.upsertOne(user, state)
|
||||
),
|
||||
|
||||
// User left
|
||||
on(UsersActions.userLeft, (state, { userId }) =>
|
||||
usersAdapter.removeOne(userId, state)
|
||||
),
|
||||
|
||||
// Update user
|
||||
on(UsersActions.updateUser, (state, { userId, updates }) =>
|
||||
usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
changes: updates,
|
||||
},
|
||||
state
|
||||
)
|
||||
),
|
||||
|
||||
// Update user role
|
||||
on(UsersActions.updateUserRole, (state, { userId, role }) =>
|
||||
usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
changes: { role },
|
||||
},
|
||||
state
|
||||
)
|
||||
),
|
||||
|
||||
// Kick user
|
||||
on(UsersActions.kickUserSuccess, (state, { userId }) =>
|
||||
usersAdapter.removeOne(userId, state)
|
||||
),
|
||||
|
||||
// Ban user
|
||||
on(UsersActions.banUserSuccess, (state, { userId, ban }) => {
|
||||
const newState = usersAdapter.removeOne(userId, state);
|
||||
return {
|
||||
...newState,
|
||||
bans: [...state.bans, ban],
|
||||
};
|
||||
}),
|
||||
|
||||
// Unban user
|
||||
on(UsersActions.unbanUserSuccess, (state, { oderId }) => ({
|
||||
...state,
|
||||
bans: state.bans.filter((b) => b.oderId !== oderId),
|
||||
})),
|
||||
|
||||
// Load bans
|
||||
on(UsersActions.loadBansSuccess, (state, { bans }) => ({
|
||||
...state,
|
||||
bans,
|
||||
})),
|
||||
|
||||
// Admin mute
|
||||
on(UsersActions.adminMuteUser, (state, { userId }) =>
|
||||
usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
changes: {
|
||||
voiceState: {
|
||||
...state.entities[userId]?.voiceState,
|
||||
isConnected: state.entities[userId]?.voiceState?.isConnected ?? false,
|
||||
isMuted: true,
|
||||
isDeafened: state.entities[userId]?.voiceState?.isDeafened ?? false,
|
||||
isSpeaking: false,
|
||||
isMutedByAdmin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
state
|
||||
)
|
||||
),
|
||||
|
||||
// Admin unmute
|
||||
on(UsersActions.adminUnmuteUser, (state, { userId }) =>
|
||||
usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
changes: {
|
||||
voiceState: {
|
||||
...state.entities[userId]?.voiceState,
|
||||
isConnected: state.entities[userId]?.voiceState?.isConnected ?? false,
|
||||
isMuted: state.entities[userId]?.voiceState?.isMuted ?? false,
|
||||
isDeafened: state.entities[userId]?.voiceState?.isDeafened ?? false,
|
||||
isSpeaking: state.entities[userId]?.voiceState?.isSpeaking ?? false,
|
||||
isMutedByAdmin: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
state
|
||||
)
|
||||
),
|
||||
|
||||
// Update voice state (generic)
|
||||
on(UsersActions.updateVoiceState, (state, { userId, voiceState }) => {
|
||||
const prev = state.entities[userId]?.voiceState || {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false,
|
||||
};
|
||||
return usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
changes: {
|
||||
voiceState: {
|
||||
isConnected: voiceState.isConnected ?? prev.isConnected,
|
||||
isMuted: voiceState.isMuted ?? prev.isMuted,
|
||||
isDeafened: voiceState.isDeafened ?? prev.isDeafened,
|
||||
isSpeaking: voiceState.isSpeaking ?? prev.isSpeaking,
|
||||
isMutedByAdmin: voiceState.isMutedByAdmin ?? prev.isMutedByAdmin,
|
||||
volume: voiceState.volume ?? prev.volume,
|
||||
roomId: voiceState.roomId ?? prev.roomId,
|
||||
},
|
||||
},
|
||||
},
|
||||
state
|
||||
);
|
||||
}),
|
||||
|
||||
// Sync users
|
||||
on(UsersActions.syncUsers, (state, { users }) =>
|
||||
usersAdapter.upsertMany(users, state)
|
||||
),
|
||||
|
||||
// Clear users
|
||||
on(UsersActions.clearUsers, (state) => {
|
||||
const idsToRemove = Object.keys(state.entities).filter((id) => id !== state.currentUserId);
|
||||
return usersAdapter.removeMany(idsToRemove, {
|
||||
...state,
|
||||
hostId: null,
|
||||
});
|
||||
}),
|
||||
|
||||
// Update host
|
||||
on(UsersActions.updateHost, (state, { userId }) => {
|
||||
// Update the old host's role to member
|
||||
let newState = state;
|
||||
if (state.hostId && state.hostId !== userId) {
|
||||
newState = usersAdapter.updateOne(
|
||||
{
|
||||
id: state.hostId,
|
||||
changes: { role: 'member' },
|
||||
},
|
||||
state
|
||||
);
|
||||
}
|
||||
|
||||
// Update the new host's role
|
||||
return usersAdapter.updateOne(
|
||||
{
|
||||
id: userId,
|
||||
changes: { role: 'host' },
|
||||
},
|
||||
{
|
||||
...newState,
|
||||
hostId: userId,
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
80
src/app/store/users/users.selectors.ts
Normal file
80
src/app/store/users/users.selectors.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { createFeatureSelector, createSelector } from '@ngrx/store';
|
||||
import { UsersState, usersAdapter } from './users.reducer';
|
||||
|
||||
export const selectUsersState = createFeatureSelector<UsersState>('users');
|
||||
|
||||
const { selectIds, selectEntities, selectAll, selectTotal } = usersAdapter.getSelectors();
|
||||
|
||||
export const selectAllUsers = createSelector(selectUsersState, selectAll);
|
||||
|
||||
export const selectUsersEntities = createSelector(selectUsersState, selectEntities);
|
||||
|
||||
export const selectUsersIds = createSelector(selectUsersState, selectIds);
|
||||
|
||||
export const selectUsersTotal = createSelector(selectUsersState, selectTotal);
|
||||
|
||||
export const selectUsersLoading = createSelector(
|
||||
selectUsersState,
|
||||
(state) => state.loading
|
||||
);
|
||||
|
||||
export const selectUsersError = createSelector(
|
||||
selectUsersState,
|
||||
(state) => state.error
|
||||
);
|
||||
|
||||
export const selectCurrentUserId = createSelector(
|
||||
selectUsersState,
|
||||
(state) => state.currentUserId
|
||||
);
|
||||
|
||||
export const selectHostId = createSelector(
|
||||
selectUsersState,
|
||||
(state) => state.hostId
|
||||
);
|
||||
|
||||
export const selectBannedUsers = createSelector(
|
||||
selectUsersState,
|
||||
(state) => state.bans
|
||||
);
|
||||
|
||||
export const selectCurrentUser = createSelector(
|
||||
selectUsersEntities,
|
||||
selectCurrentUserId,
|
||||
(entities, currentUserId) => (currentUserId ? entities[currentUserId] : null)
|
||||
);
|
||||
|
||||
export const selectHost = createSelector(
|
||||
selectUsersEntities,
|
||||
selectHostId,
|
||||
(entities, hostId) => (hostId ? entities[hostId] : null)
|
||||
);
|
||||
|
||||
export const selectUserById = (id: string) =>
|
||||
createSelector(selectUsersEntities, (entities) => entities[id]);
|
||||
|
||||
export const selectIsCurrentUserHost = createSelector(
|
||||
selectCurrentUserId,
|
||||
selectHostId,
|
||||
(currentUserId, hostId) => currentUserId === hostId
|
||||
);
|
||||
|
||||
export const selectIsCurrentUserAdmin = createSelector(
|
||||
selectCurrentUser,
|
||||
(user) => user?.role === 'host' || user?.role === 'admin' || user?.role === 'moderator'
|
||||
);
|
||||
|
||||
export const selectOnlineUsers = createSelector(
|
||||
selectAllUsers,
|
||||
(users) => users.filter((u) => u.status !== 'offline' || u.isOnline === true)
|
||||
);
|
||||
|
||||
export const selectUsersByRole = (role: string) =>
|
||||
createSelector(selectAllUsers, (users) =>
|
||||
users.filter((u) => u.role === role)
|
||||
);
|
||||
|
||||
export const selectAdmins = createSelector(
|
||||
selectAllUsers,
|
||||
(users) => users.filter((u) => u.role === 'host' || u.role === 'admin' || u.role === 'moderator')
|
||||
);
|
||||
5
src/environments/environment.prod.ts
Normal file
5
src/environments/environment.prod.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
serverUrl: 'https://your-server.com/api',
|
||||
signalingUrl: 'wss://your-server.com/signaling',
|
||||
};
|
||||
5
src/environments/environment.ts
Normal file
5
src/environments/environment.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
serverUrl: 'http://localhost:3000/api',
|
||||
signalingUrl: 'ws://localhost:3001',
|
||||
};
|
||||
50
src/index.html
Normal file
50
src/index.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>MeToYou</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; connect-src 'self' ws: wss: http: https:; media-src 'self' blob:; img-src 'self' data: blob:;">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<script>
|
||||
// Polyfills for Node.js modules used in browser
|
||||
if (typeof global === 'undefined') {
|
||||
window.global = window;
|
||||
}
|
||||
if (typeof process === 'undefined') {
|
||||
window.process = { env: {}, browser: true, version: '', versions: {} };
|
||||
}
|
||||
// Add nextTick polyfill for simple-peer/streams
|
||||
if (typeof process.nextTick === 'undefined') {
|
||||
window.process.nextTick = function(fn) {
|
||||
setTimeout(fn, 0);
|
||||
};
|
||||
}
|
||||
if (typeof Buffer === 'undefined') {
|
||||
window.Buffer = {
|
||||
isBuffer: function() { return false; },
|
||||
from: function() { return []; },
|
||||
alloc: function() { return []; }
|
||||
};
|
||||
}
|
||||
// Polyfill for util module (used by simple-peer/debug)
|
||||
if (typeof window.util === 'undefined') {
|
||||
window.util = {
|
||||
debuglog: function() { return function() {}; },
|
||||
inspect: function(obj) { return JSON.stringify(obj); },
|
||||
format: function() { return Array.prototype.slice.call(arguments).join(' '); },
|
||||
inherits: function(ctor, superCtor) {
|
||||
ctor.super_ = superCtor;
|
||||
ctor.prototype = Object.create(superCtor.prototype, {
|
||||
constructor: { value: ctor, enumerable: false, writable: true, configurable: true }
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
6
src/main.ts
Normal file
6
src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
|
||||
bootstrapApplication(App, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
66
src/styles.scss
Normal file
66
src/styles.scss
Normal file
@@ -0,0 +1,66 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 262.1 83.3% 57.8%;
|
||||
--primary-foreground: 210 20% 98%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 262.1 83.3% 57.8%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
|
||||
/* Custom utilities */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
73
tailwind.config.js
Normal file
73
tailwind.config.js
Normal file
@@ -0,0 +1,73 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./src/**/*.{html,ts}',
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: '2rem',
|
||||
screens: {
|
||||
'2xl': '1400px',
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: { height: '0' },
|
||||
to: { height: 'var(--radix-accordion-content-height)' },
|
||||
},
|
||||
'accordion-up': {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: '0' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
15
tsconfig.app.json
Normal file
15
tsconfig.app.json
Normal file
@@ -0,0 +1,15 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
30
tsconfig.json
Normal file
30
tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
tsconfig.spec.json
Normal file
15
tsconfig.spec.json
Normal file
@@ -0,0 +1,15 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"vitest/globals"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user