This commit is contained in:
2025-12-28 05:37:19 +01:00
commit 87c722b5ae
74 changed files with 10264 additions and 0 deletions

17
.editorconfig Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

27
server/data/servers.json Normal file
View 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
View 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
View File

@@ -0,0 +1,2 @@
export {};
//# sourceMappingURL=index.d.ts.map

1
server/dist/index.d.ts.map vendored Normal file
View 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
View 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

File diff suppressed because one or more lines are too long

27
server/package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

68
src/app/app.ts Normal file
View 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);
}
});
}
}

View 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;
}

View 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));
}
}

View 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();
}
}

View File

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

View 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');
}
}

View 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
}
}
}

View 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();
}
}

View 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' });
}
}

View 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']);
}
}

View 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']);
}
}

View 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}`]);
}
}

View 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);
}
}

View 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();
}
}

View 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
}

View 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;
}
}

View 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('');
}
}

View 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);
}
}

View 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(['/']);
}
}

View 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']);
}
}

View 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);
}
}
}

View 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
View 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';

View File

@@ -0,0 +1,4 @@
export * from './messages.actions';
export * from './messages.reducer';
export * from './messages.selectors';
export * from './messages.effects';

View 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');

View 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 }
);
}

View 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,
})
)
);

View 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)
);

View File

@@ -0,0 +1,4 @@
export * from './rooms.actions';
export * from './rooms.reducer';
export * from './rooms.selectors';
export * from './rooms.effects';

View 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 }>()
);

View 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 }
);
}

View 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,
}))
);

View 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
);

View File

@@ -0,0 +1,4 @@
export * from './users.actions';
export * from './users.reducer';
export * from './users.selectors';
export * from './users.effects';

View 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> }>()
);

View 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 }
);
}

View 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,
}
);
})
);

View 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')
);

View File

@@ -0,0 +1,5 @@
export const environment = {
production: true,
serverUrl: 'https://your-server.com/api',
signalingUrl: 'wss://your-server.com/signaling',
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"
]
}