ddd test 2
This commit is contained in:
62
src/app/domains/README.md
Normal file
62
src/app/domains/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Domains
|
||||
|
||||
Each folder below is a **bounded context** — a self-contained slice of
|
||||
business logic with its own models, application services, and (optionally)
|
||||
infrastructure adapters and UI.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Domain | Purpose | Public entry point |
|
||||
|---|---|---|
|
||||
| **attachment** | File upload/download, chunk transfer, persistence | `AttachmentFacade` |
|
||||
| **auth** | Login / register HTTP orchestration, user-bar UI | `AuthService` |
|
||||
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
|
||||
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
||||
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
|
||||
| **voice-connection** | Voice activity detection, bitrate profiles | `VoiceConnectionFacade` |
|
||||
| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` |
|
||||
|
||||
## Folder convention
|
||||
|
||||
Every domain follows the same internal layout:
|
||||
|
||||
```
|
||||
domains/<name>/
|
||||
├── index.ts # Barrel — the ONLY file outsiders import
|
||||
├── domain/ # Pure types, interfaces, business rules
|
||||
│ ├── <name>.models.ts
|
||||
│ └── <name>.logic.ts # Pure functions (no Angular, no side effects)
|
||||
├── application/ # Angular services that orchestrate domain logic
|
||||
│ └── <name>.facade.ts # Public entry point for the domain
|
||||
├── infrastructure/ # Technical adapters (HTTP, storage, WebSocket)
|
||||
└── feature/ # Optional: domain-owned UI components / routes
|
||||
└── settings/ # e.g. settings subpanel owned by this domain
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Import from the barrel.** Outside a domain, always import from
|
||||
`domains/<name>` (the `index.ts`), never from internal paths.
|
||||
|
||||
2. **No cross-domain imports.** Domain A must never import from Domain B's
|
||||
internals. Shared types live in `shared-kernel/`.
|
||||
|
||||
3. **Features compose domains.** Top-level `features/` components inject
|
||||
domain facades and compose their outputs — they never contain business
|
||||
logic.
|
||||
|
||||
4. **Store slices are application-level.** `store/messages`, `store/rooms`,
|
||||
`store/users` are global state managed by NgRx. They import from
|
||||
`shared-kernel` for types and from domain facades for side-effects.
|
||||
|
||||
## Where do I put new code?
|
||||
|
||||
| I want to… | Put it in… |
|
||||
|---|---|
|
||||
| Add a new business concept | New folder under `domains/` following the convention above |
|
||||
| Add a type used by multiple domains | `shared-kernel/` with a descriptive file name |
|
||||
| Add a UI component for a domain feature | `domains/<name>/feature/` or `domains/<name>/ui/` |
|
||||
| Add a settings subpanel | `domains/<name>/feature/settings/` |
|
||||
| Add a top-level page or shell component | `features/` |
|
||||
| Add persistence logic | `infrastructure/persistence/` or `domains/<name>/infrastructure/` |
|
||||
| Add realtime/WebRTC logic | `infrastructure/realtime/` |
|
||||
148
src/app/domains/attachment/README.md
Normal file
148
src/app/domains/attachment/README.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Attachment Domain
|
||||
|
||||
Handles file sharing between peers over WebRTC data channels. Files are announced, chunked into 64 KB pieces, streamed peer-to-peer as base64, and optionally persisted to disk (Electron) or kept in memory (browser).
|
||||
|
||||
## Module map
|
||||
|
||||
```
|
||||
attachment/
|
||||
├── application/
|
||||
│ ├── attachment.facade.ts Thin entry point, delegates to manager
|
||||
│ ├── attachment-manager.service.ts Orchestrates lifecycle, auto-download, peer listeners
|
||||
│ ├── attachment-transfer.service.ts P2P file transfer protocol (announce/request/chunk/cancel)
|
||||
│ ├── attachment-transfer-transport.service.ts Base64 encode/decode, chunked streaming
|
||||
│ ├── attachment-persistence.service.ts DB + filesystem persistence, migration from localStorage
|
||||
│ └── attachment-runtime.store.ts In-memory signal-based state (Maps for attachments, chunks, pending)
|
||||
│
|
||||
├── domain/
|
||||
│ ├── attachment.models.ts Attachment type extending AttachmentMeta with runtime state
|
||||
│ ├── attachment.logic.ts isAttachmentMedia, shouldAutoRequestWhenWatched, shouldPersistDownloadedAttachment
|
||||
│ ├── attachment.constants.ts MAX_AUTO_SAVE_SIZE_BYTES = 10 MB
|
||||
│ ├── attachment-transfer.models.ts Protocol event types (file-announce, file-chunk, file-request, ...)
|
||||
│ └── attachment-transfer.constants.ts FILE_CHUNK_SIZE_BYTES = 64 KB, EWMA weights, error messages
|
||||
│
|
||||
├── infrastructure/
|
||||
│ ├── attachment-storage.service.ts Electron filesystem access (save / read / delete)
|
||||
│ └── attachment-storage.helpers.ts sanitizeAttachmentRoomName, resolveAttachmentStorageBucket
|
||||
│
|
||||
└── index.ts Barrel exports
|
||||
```
|
||||
|
||||
## Service composition
|
||||
|
||||
The facade is a thin pass-through. All real work happens inside the manager, which coordinates the transfer service (protocol), persistence service (DB/disk), and runtime store (signals).
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Facade[AttachmentFacade]
|
||||
Manager[AttachmentManagerService]
|
||||
Transfer[AttachmentTransferService]
|
||||
Transport[AttachmentTransferTransportService]
|
||||
Persistence[AttachmentPersistenceService]
|
||||
Store[AttachmentRuntimeStore]
|
||||
Storage[AttachmentStorageService]
|
||||
Logic[attachment.logic]
|
||||
|
||||
Facade --> Manager
|
||||
Manager --> Transfer
|
||||
Manager --> Persistence
|
||||
Manager --> Store
|
||||
Manager --> Logic
|
||||
Transfer --> Transport
|
||||
Transfer --> Store
|
||||
Persistence --> Storage
|
||||
Persistence --> Store
|
||||
Storage --> Helpers[attachment-storage.helpers]
|
||||
|
||||
click Facade "application/attachment.facade.ts" "Thin entry point"
|
||||
click Manager "application/attachment-manager.service.ts" "Orchestrates lifecycle"
|
||||
click Transfer "application/attachment-transfer.service.ts" "P2P file transfer protocol"
|
||||
click Transport "application/attachment-transfer-transport.service.ts" "Base64 encode/decode, chunked streaming"
|
||||
click Persistence "application/attachment-persistence.service.ts" "DB + filesystem persistence"
|
||||
click Store "application/attachment-runtime.store.ts" "In-memory signal-based state"
|
||||
click Storage "infrastructure/attachment-storage.service.ts" "Electron filesystem access"
|
||||
click Helpers "infrastructure/attachment-storage.helpers.ts" "Path helpers"
|
||||
click Logic "domain/attachment.logic.ts" "Pure decision functions"
|
||||
```
|
||||
|
||||
## File transfer protocol
|
||||
|
||||
Files move between peers using a request/response pattern over the WebRTC data channel. The sender announces a file, the receiver requests it, and chunks flow back one by one.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as Sender
|
||||
participant R as Receiver
|
||||
|
||||
S->>R: file-announce (id, name, size, mimeType)
|
||||
Note over R: Store metadata in runtime store
|
||||
Note over R: shouldAutoRequestWhenWatched?
|
||||
|
||||
R->>S: file-request (attachmentId)
|
||||
Note over S: Look up file in runtime store or on disk
|
||||
|
||||
loop Every 64 KB chunk
|
||||
S->>R: file-chunk (attachmentId, index, data, progress, speed)
|
||||
Note over R: Append to chunk buffer
|
||||
Note over R: Update progress + EWMA speed
|
||||
end
|
||||
|
||||
Note over R: All chunks received
|
||||
Note over R: Reassemble blob
|
||||
Note over R: shouldPersistDownloadedAttachment? Save to disk
|
||||
```
|
||||
|
||||
### Failure handling
|
||||
|
||||
If the sender cannot find the file, it replies with `file-not-found`. The transfer service then tries the next connected peer that has announced the same attachment. Either side can send `file-cancel` to abort a transfer in progress.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant R as Receiver
|
||||
participant P1 as Peer A
|
||||
participant P2 as Peer B
|
||||
|
||||
R->>P1: file-request
|
||||
P1->>R: file-not-found
|
||||
Note over R: Try next peer
|
||||
R->>P2: file-request
|
||||
P2->>R: file-chunk (1/N)
|
||||
P2->>R: file-chunk (2/N)
|
||||
P2->>R: file-chunk (N/N)
|
||||
Note over R: Transfer complete
|
||||
```
|
||||
|
||||
## Auto-download rules
|
||||
|
||||
When the user navigates to a room, the manager watches the route and decides which attachments to request automatically based on domain logic:
|
||||
|
||||
| Condition | Auto-download? |
|
||||
|---|---|
|
||||
| Image or video, size <= 10 MB | Yes |
|
||||
| Image or video, size > 10 MB | No |
|
||||
| Non-media file | No |
|
||||
|
||||
The decision lives in `shouldAutoRequestWhenWatched()` which calls `isAttachmentMedia()` and checks against `MAX_AUTO_SAVE_SIZE_BYTES`.
|
||||
|
||||
## Persistence
|
||||
|
||||
On Electron, completed downloads are written to the app-data directory. The storage path is resolved per room and bucket:
|
||||
|
||||
```
|
||||
{appDataPath}/{serverId}/{roomName}/{bucket}/{filename}
|
||||
```
|
||||
|
||||
Room names are sanitised to remove filesystem-unsafe characters. The bucket is either `attachments` or `media` depending on the attachment type.
|
||||
|
||||
`AttachmentPersistenceService` handles startup migration from an older localStorage-based format into the database, and restores attachment metadata from the DB on init. On browser builds, files stay in memory only.
|
||||
|
||||
## Runtime store
|
||||
|
||||
`AttachmentRuntimeStore` is a signal-based in-memory store using `Map` instances for:
|
||||
|
||||
- **attachments**: all known attachments keyed by ID
|
||||
- **chunks**: incoming chunk buffers during active transfers
|
||||
- **pendingRequests**: outbound requests waiting for a response
|
||||
- **cancellations**: IDs of transfers the user cancelled
|
||||
|
||||
Components read attachment state reactively through the store's signals. The store has no persistence of its own; that responsibility belongs to the persistence service.
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ChatAttachmentAnnouncement, ChatEvent } from '../../../core/models/index';
|
||||
import type { ChatEvent } from '../../../shared-kernel';
|
||||
import type { ChatAttachmentAnnouncement } from '../../../shared-kernel';
|
||||
|
||||
export type FileAnnounceEvent = ChatEvent & {
|
||||
type: 'file-announce';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChatAttachmentMeta } from '../../../core/models/index';
|
||||
import type { ChatAttachmentMeta } from '../../../shared-kernel';
|
||||
|
||||
export type AttachmentMeta = ChatAttachmentMeta;
|
||||
|
||||
|
||||
74
src/app/domains/auth/README.md
Normal file
74
src/app/domains/auth/README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Auth Domain
|
||||
|
||||
Handles user authentication (login and registration) against the configured server endpoint. Provides the login, register, and user-bar UI components.
|
||||
|
||||
## Module map
|
||||
|
||||
```
|
||||
auth/
|
||||
├── application/
|
||||
│ └── auth.service.ts HTTP login/register against the active server endpoint
|
||||
│
|
||||
├── feature/
|
||||
│ ├── login/ Login form component
|
||||
│ ├── register/ Registration form component
|
||||
│ └── user-bar/ Displays current user or login/register links
|
||||
│
|
||||
└── index.ts Barrel exports
|
||||
```
|
||||
|
||||
## Service overview
|
||||
|
||||
`AuthService` resolves the API base URL from `ServerDirectoryFacade`, then makes POST requests for login and registration. It does not hold session state itself; after a successful login the calling component stores `currentUserId` in localStorage and dispatches `UsersActions.setCurrentUser` into the NgRx store.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Login[LoginComponent]
|
||||
Register[RegisterComponent]
|
||||
UserBar[UserBarComponent]
|
||||
Auth[AuthService]
|
||||
SD[ServerDirectoryFacade]
|
||||
Store[NgRx Store]
|
||||
|
||||
Login --> Auth
|
||||
Register --> Auth
|
||||
UserBar --> Store
|
||||
Auth --> SD
|
||||
Login --> Store
|
||||
|
||||
click Auth "application/auth.service.ts" "HTTP login/register"
|
||||
click Login "feature/login/" "Login form"
|
||||
click Register "feature/register/" "Registration form"
|
||||
click UserBar "feature/user-bar/" "Current user display"
|
||||
click SD "../server-directory/application/server-directory.facade.ts" "Resolves API URL"
|
||||
```
|
||||
|
||||
## Login flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Login as LoginComponent
|
||||
participant Auth as AuthService
|
||||
participant SD as ServerDirectoryFacade
|
||||
participant API as Server API
|
||||
participant Store as NgRx Store
|
||||
|
||||
User->>Login: Submit credentials
|
||||
Login->>Auth: login(username, password)
|
||||
Auth->>SD: getApiBaseUrl()
|
||||
SD-->>Auth: https://server/api
|
||||
Auth->>API: POST /api/auth/login
|
||||
API-->>Auth: { userId, displayName }
|
||||
Auth-->>Login: success
|
||||
Login->>Store: UsersActions.setCurrentUser
|
||||
Login->>Login: localStorage.setItem(currentUserId)
|
||||
```
|
||||
|
||||
## Registration flow
|
||||
|
||||
Registration follows the same pattern but posts to `/api/auth/register` with an additional `displayName` field. On success the user is treated as logged in and the same store dispatch happens.
|
||||
|
||||
## User bar
|
||||
|
||||
`UserBarComponent` reads the current user from the NgRx store. When logged in it shows the user's display name; when not logged in it shows links to the login and register views.
|
||||
76
src/app/domains/auth/feature/login/login.component.html
Normal file
76
src/app/domains/auth/feature/login/login.component.html
Normal file
@@ -0,0 +1,76 @@
|
||||
<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
|
||||
for="login-username"
|
||||
class="block text-xs text-muted-foreground mb-1"
|
||||
>Username</label
|
||||
>
|
||||
<input
|
||||
[(ngModel)]="username"
|
||||
type="text"
|
||||
id="login-username"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="login-password"
|
||||
class="block text-xs text-muted-foreground mb-1"
|
||||
>Password</label
|
||||
>
|
||||
<input
|
||||
[(ngModel)]="password"
|
||||
type="password"
|
||||
id="login-password"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="login-server"
|
||||
class="block text-xs text-muted-foreground mb-1"
|
||||
>Server App</label
|
||||
>
|
||||
<select
|
||||
[(ngModel)]="serverId"
|
||||
id="login-server"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
>
|
||||
@for (s of servers(); track s.id) {
|
||||
<option [value]="s.id">{{ s.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@if (error()) {
|
||||
<p class="text-xs text-destructive">{{ error() }}</p>
|
||||
}
|
||||
<button
|
||||
(click)="submit()"
|
||||
type="button"
|
||||
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?
|
||||
<button
|
||||
type="button"
|
||||
(click)="goRegister()"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
100
src/app/domains/auth/feature/login/login.component.ts
Normal file
100
src/app/domains/auth/feature/login/login.component.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/* eslint-disable max-statements-per-line */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, 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 '../../application/auth.service';
|
||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideLogIn })],
|
||||
templateUrl: './login.component.html'
|
||||
})
|
||||
/**
|
||||
* Login form allowing existing users to authenticate against a selected server.
|
||||
*/
|
||||
export class LoginComponent {
|
||||
serversSvc = inject(ServerDirectoryFacade);
|
||||
|
||||
servers = this.serversSvc.servers;
|
||||
username = '';
|
||||
password = '';
|
||||
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
||||
error = signal<string | null>(null);
|
||||
|
||||
private auth = inject(AuthService);
|
||||
private store = inject(Store);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
|
||||
/** TrackBy function for server list rendering. */
|
||||
trackById(_index: number, item: { id: string }) { return item.id; }
|
||||
|
||||
/** Validate and submit the login form, then navigate to search on success. */
|
||||
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(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
||||
|
||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||
|
||||
if (returnUrl?.startsWith('/')) {
|
||||
this.router.navigateByUrl(returnUrl);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.router.navigate(['/search']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || 'Login failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Navigate to the registration page. */
|
||||
goRegister() {
|
||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||
|
||||
this.router.navigate(['/register'], {
|
||||
queryParams: returnUrl ? { returnUrl } : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<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
|
||||
for="register-username"
|
||||
class="block text-xs text-muted-foreground mb-1"
|
||||
>Username</label
|
||||
>
|
||||
<input
|
||||
[(ngModel)]="username"
|
||||
type="text"
|
||||
id="register-username"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="register-display-name"
|
||||
class="block text-xs text-muted-foreground mb-1"
|
||||
>Display Name</label
|
||||
>
|
||||
<input
|
||||
[(ngModel)]="displayName"
|
||||
type="text"
|
||||
id="register-display-name"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="register-password"
|
||||
class="block text-xs text-muted-foreground mb-1"
|
||||
>Password</label
|
||||
>
|
||||
<input
|
||||
[(ngModel)]="password"
|
||||
type="password"
|
||||
id="register-password"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="register-server"
|
||||
class="block text-xs text-muted-foreground mb-1"
|
||||
>Server App</label
|
||||
>
|
||||
<select
|
||||
[(ngModel)]="serverId"
|
||||
id="register-server"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
>
|
||||
@for (s of servers(); track s.id) {
|
||||
<option [value]="s.id">{{ s.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@if (error()) {
|
||||
<p class="text-xs text-destructive">{{ error() }}</p>
|
||||
}
|
||||
<button
|
||||
(click)="submit()"
|
||||
type="button"
|
||||
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?
|
||||
<button
|
||||
type="button"
|
||||
(click)="goLogin()"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
102
src/app/domains/auth/feature/register/register.component.ts
Normal file
102
src/app/domains/auth/feature/register/register.component.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/* eslint-disable max-statements-per-line */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, 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 '../../application/auth.service';
|
||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideUserPlus })],
|
||||
templateUrl: './register.component.html'
|
||||
})
|
||||
/**
|
||||
* Registration form allowing new users to create an account on a selected server.
|
||||
*/
|
||||
export class RegisterComponent {
|
||||
serversSvc = inject(ServerDirectoryFacade);
|
||||
|
||||
servers = this.serversSvc.servers;
|
||||
username = '';
|
||||
displayName = '';
|
||||
password = '';
|
||||
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
||||
error = signal<string | null>(null);
|
||||
|
||||
private auth = inject(AuthService);
|
||||
private store = inject(Store);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
|
||||
/** TrackBy function for server list rendering. */
|
||||
trackById(_index: number, item: { id: string }) { return item.id; }
|
||||
|
||||
/** Validate and submit the registration form, then navigate to search on success. */
|
||||
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(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
|
||||
|
||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||
|
||||
if (returnUrl?.startsWith('/')) {
|
||||
this.router.navigateByUrl(returnUrl);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.router.navigate(['/search']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || 'Registration failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Navigate to the login page. */
|
||||
goLogin() {
|
||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: returnUrl ? { returnUrl } : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<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
|
||||
type="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
|
||||
type="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>
|
||||
37
src/app/domains/auth/feature/user-bar/user-bar.component.ts
Normal file
37
src/app/domains/auth/feature/user-bar/user-bar.component.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
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 })
|
||||
],
|
||||
templateUrl: './user-bar.component.html'
|
||||
})
|
||||
/**
|
||||
* Compact user status bar showing the current user with login/register navigation links.
|
||||
*/
|
||||
export class UserBarComponent {
|
||||
store = inject(Store);
|
||||
user = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
private router = inject(Router);
|
||||
|
||||
/** Navigate to the specified authentication page. */
|
||||
goto(path: 'login' | 'register') {
|
||||
this.router.navigate([`/${path}`]);
|
||||
}
|
||||
}
|
||||
143
src/app/domains/chat/README.md
Normal file
143
src/app/domains/chat/README.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Chat Domain
|
||||
|
||||
Text messaging, reactions, GIF search, typing indicators, and the user list. All UI is under `feature/`; application services handle GIF integration; domain rules govern message editing, deletion, and sync.
|
||||
|
||||
## Module map
|
||||
|
||||
```
|
||||
chat/
|
||||
├── application/
|
||||
│ └── klipy.service.ts GIF search via the KLIPY API (proxied through the server)
|
||||
│
|
||||
├── domain/
|
||||
│ ├── message.rules.ts canEditMessage, normaliseDeletedMessage, getMessageTimestamp
|
||||
│ └── message-sync.rules.ts Inventory-based sync: chunkArray, findMissingIds, limits
|
||||
│
|
||||
├── feature/
|
||||
│ ├── chat-messages/ Main chat view (orchestrates composer, list, overlays)
|
||||
│ │ ├── chat-messages.component.ts Root component: replies, GIF picker, reactions, drag-drop
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── message-composer/ Markdown toolbar, file drag-drop, send
|
||||
│ │ │ ├── message-item/ Single message bubble with edit/delete/react
|
||||
│ │ │ ├── message-list/ Paginated list (50 msgs/page), auto-scroll, Prism highlighting
|
||||
│ │ │ └── message-overlays/ Context menus, reaction picker, reply preview
|
||||
│ │ ├── models/ View models for messages
|
||||
│ │ └── services/
|
||||
│ │ └── chat-markdown.service.ts Markdown-to-HTML rendering
|
||||
│ │
|
||||
│ ├── klipy-gif-picker/ GIF search/browse picker panel
|
||||
│ ├── typing-indicator/ "X is typing..." display (3 s TTL, max 4 names)
|
||||
│ └── user-list/ Online user sidebar
|
||||
│
|
||||
└── index.ts Barrel exports
|
||||
```
|
||||
|
||||
## Component composition
|
||||
|
||||
`ChatMessagesComponent` is the root of the chat view. It renders the message list, composer, and overlays as child components and coordinates cross-cutting interactions like replying to a message or inserting a GIF.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Chat[ChatMessagesComponent]
|
||||
List[MessageListComponent]
|
||||
Composer[MessageComposerComponent]
|
||||
Overlays[MessageOverlays]
|
||||
Item[MessageItemComponent]
|
||||
GIF[KlipyGifPickerComponent]
|
||||
Typing[TypingIndicatorComponent]
|
||||
Users[UserListComponent]
|
||||
|
||||
Chat --> List
|
||||
Chat --> Composer
|
||||
Chat --> Overlays
|
||||
Chat --> GIF
|
||||
List --> Item
|
||||
Item --> Overlays
|
||||
|
||||
click Chat "feature/chat-messages/chat-messages.component.ts" "Root chat view"
|
||||
click List "feature/chat-messages/components/message-list/" "Paginated message list"
|
||||
click Composer "feature/chat-messages/components/message-composer/" "Markdown toolbar + send"
|
||||
click Overlays "feature/chat-messages/components/message-overlays/" "Context menus, reaction picker"
|
||||
click Item "feature/chat-messages/components/message-item/" "Single message bubble"
|
||||
click GIF "feature/klipy-gif-picker/" "GIF search panel"
|
||||
click Typing "feature/typing-indicator/" "Typing indicator"
|
||||
click Users "feature/user-list/" "Online user sidebar"
|
||||
```
|
||||
|
||||
## Message lifecycle
|
||||
|
||||
Messages are created in the composer, broadcast to peers over the data channel, and rendered in the list. Editing and deletion are sender-only operations.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Composer as MessageComposer
|
||||
participant Store as NgRx Store
|
||||
participant DC as Data Channel
|
||||
participant Peer as Remote Peer
|
||||
|
||||
User->>Composer: Type + send
|
||||
Composer->>Store: dispatch addMessage
|
||||
Composer->>DC: broadcastMessage(chat-message)
|
||||
DC->>Peer: chat-message event
|
||||
|
||||
Note over User: Edit
|
||||
User->>Store: dispatch editMessage
|
||||
User->>DC: broadcastMessage(edit-message)
|
||||
|
||||
Note over User: Delete
|
||||
User->>Store: dispatch deleteMessage (normaliseDeletedMessage)
|
||||
User->>DC: broadcastMessage(delete-message)
|
||||
```
|
||||
|
||||
## Message sync
|
||||
|
||||
When a peer connects (or reconnects), both sides exchange an inventory of their recent messages so each can request anything it missed. The inventory is capped at 1 000 messages and sent in chunks of 200.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Peer A
|
||||
participant B as Peer B
|
||||
|
||||
A->>B: inventory (up to 1000 msg IDs + timestamps)
|
||||
B->>B: findMissingIds(remote, local)
|
||||
B->>A: request missing message IDs
|
||||
A->>B: message payloads (chunked, 200/batch)
|
||||
```
|
||||
|
||||
`findMissingIds` compares each remote item's timestamp and reaction/attachment counts against the local map. Any item that is missing, newer, or has different counts is requested.
|
||||
|
||||
## GIF integration
|
||||
|
||||
`KlipyService` checks availability on the active server, then proxies search requests through the server API. Images are rendered via an image proxy endpoint to avoid mixed-content issues.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Picker[KlipyGifPickerComponent]
|
||||
Klipy[KlipyService]
|
||||
SD[ServerDirectoryFacade]
|
||||
API[Server API]
|
||||
|
||||
Picker --> Klipy
|
||||
Klipy --> SD
|
||||
Klipy --> API
|
||||
|
||||
click Picker "feature/klipy-gif-picker/" "GIF search panel"
|
||||
click Klipy "application/klipy.service.ts" "GIF search via KLIPY API"
|
||||
click SD "../server-directory/application/server-directory.facade.ts" "Resolves API base URL"
|
||||
```
|
||||
|
||||
## Domain rules
|
||||
|
||||
| Function | Purpose |
|
||||
|---|---|
|
||||
| `canEditMessage(msg, userId)` | Only the sender can edit their own message |
|
||||
| `normaliseDeletedMessage(msg)` | Strips content and reactions from deleted messages |
|
||||
| `getMessageTimestamp(msg)` | Returns `editedAt` if present, otherwise `timestamp` |
|
||||
| `getLatestTimestamp(msgs)` | Max timestamp across a batch, used for sync ordering |
|
||||
| `chunkArray(items, size)` | Splits arrays into fixed-size chunks for batched transfer |
|
||||
| `findMissingIds(remote, local)` | Compares inventories and returns IDs to request |
|
||||
|
||||
## Typing indicator
|
||||
|
||||
`TypingIndicatorComponent` listens for typing events from peers. Each event resets a 3-second TTL timer. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing".
|
||||
59
src/app/domains/chat/domain/message-sync.rules.ts
Normal file
59
src/app/domains/chat/domain/message-sync.rules.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/** Maximum number of recent messages to include in sync inventories. */
|
||||
export const INVENTORY_LIMIT = 1000;
|
||||
|
||||
/** Number of messages per chunk for inventory / batch transfers. */
|
||||
export const CHUNK_SIZE = 200;
|
||||
|
||||
/** Aggressive sync poll interval (10 seconds). */
|
||||
export const SYNC_POLL_FAST_MS = 10_000;
|
||||
|
||||
/** Idle sync poll interval after a clean (no-new-messages) cycle (15 minutes). */
|
||||
export const SYNC_POLL_SLOW_MS = 900_000;
|
||||
|
||||
/** Sync timeout duration before auto-completing a cycle (5 seconds). */
|
||||
export const SYNC_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** Large limit used for legacy full-sync operations. */
|
||||
export const FULL_SYNC_LIMIT = 10_000;
|
||||
|
||||
/** Inventory item representing a message's sync state. */
|
||||
export interface InventoryItem {
|
||||
id: string;
|
||||
ts: number;
|
||||
rc: number;
|
||||
ac?: number;
|
||||
}
|
||||
|
||||
/** Splits an array into chunks of the given size. */
|
||||
export function chunkArray<T>(items: T[], size: number): T[][] {
|
||||
const chunks: T[][] = [];
|
||||
|
||||
for (let index = 0; index < items.length; index += size) {
|
||||
chunks.push(items.slice(index, index + size));
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/** Identifies missing or stale message IDs by comparing remote items against a local map. */
|
||||
export function findMissingIds(
|
||||
remoteItems: readonly { id: string; ts: number; rc?: number; ac?: number }[],
|
||||
localMap: ReadonlyMap<string, { ts: number; rc: number; ac: number }>
|
||||
): string[] {
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const item of remoteItems) {
|
||||
const local = localMap.get(item.id);
|
||||
|
||||
if (
|
||||
!local ||
|
||||
item.ts > local.ts ||
|
||||
(item.rc !== undefined && item.rc !== local.rc) ||
|
||||
(item.ac !== undefined && item.ac !== local.ac)
|
||||
) {
|
||||
missing.push(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
31
src/app/domains/chat/domain/message.rules.ts
Normal file
31
src/app/domains/chat/domain/message.rules.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { DELETED_MESSAGE_CONTENT, type Message } from '../../../shared-kernel';
|
||||
|
||||
/** Extracts the effective timestamp from a message (editedAt takes priority). */
|
||||
export function getMessageTimestamp(msg: Message): number {
|
||||
return msg.editedAt || msg.timestamp || 0;
|
||||
}
|
||||
|
||||
/** Computes the most recent timestamp across a batch of messages. */
|
||||
export function getLatestTimestamp(messages: Message[]): number {
|
||||
return messages.reduce(
|
||||
(max, msg) => Math.max(max, getMessageTimestamp(msg)),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/** Strips sensitive content from a deleted message. */
|
||||
export function normaliseDeletedMessage(message: Message): Message {
|
||||
if (!message.isDeleted)
|
||||
return message;
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: DELETED_MESSAGE_CONTENT,
|
||||
reactions: []
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether the given user is allowed to edit this message. */
|
||||
export function canEditMessage(message: Message, userId: string): boolean {
|
||||
return message.senderId === userId;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<div class="chat-layout relative h-full">
|
||||
<app-chat-message-list
|
||||
[allMessages]="allMessages()"
|
||||
[channelMessages]="channelMessages()"
|
||||
[loading]="loading()"
|
||||
[syncing]="syncing()"
|
||||
[currentUserId]="currentUser()?.id ?? null"
|
||||
[isAdmin]="isAdmin()"
|
||||
[bottomPadding]="composerBottomPadding()"
|
||||
[conversationKey]="conversationKey()"
|
||||
(replyRequested)="setReplyTo($event)"
|
||||
(deleteRequested)="handleDeleteRequested($event)"
|
||||
(editSaved)="handleEditSaved($event)"
|
||||
(reactionAdded)="handleReactionAdded($event)"
|
||||
(reactionToggled)="handleReactionToggled($event)"
|
||||
(downloadRequested)="downloadAttachment($event)"
|
||||
(imageOpened)="openLightbox($event)"
|
||||
(imageContextMenuRequested)="openImageContextMenu($event)"
|
||||
/>
|
||||
|
||||
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
|
||||
<app-chat-message-composer
|
||||
[replyTo]="replyTo()"
|
||||
[showKlipyGifPicker]="showKlipyGifPicker()"
|
||||
(messageSubmitted)="handleMessageSubmitted($event)"
|
||||
(typingStarted)="handleTypingStarted()"
|
||||
(replyCleared)="clearReply()"
|
||||
(heightChanged)="handleComposerHeightChanged($event)"
|
||||
(klipyGifPickerToggleRequested)="toggleKlipyGifPicker()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if (showKlipyGifPicker()) {
|
||||
<div
|
||||
class="fixed inset-0 z-[89]"
|
||||
(click)="closeKlipyGifPicker()"
|
||||
(keydown.enter)="closeKlipyGifPicker()"
|
||||
(keydown.space)="closeKlipyGifPicker()"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label="Close GIF picker"
|
||||
style="-webkit-app-region: no-drag"
|
||||
></div>
|
||||
|
||||
<div class="pointer-events-none fixed inset-0 z-[90]">
|
||||
<div
|
||||
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
|
||||
[style.bottom.px]="composerBottomPadding() + 8"
|
||||
[style.right.px]="klipyGifPickerAnchorRight()"
|
||||
>
|
||||
<app-klipy-gif-picker
|
||||
(gifSelected)="handleKlipyGifSelected($event)"
|
||||
(closed)="closeKlipyGifPicker()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-chat-message-overlays
|
||||
[lightboxAttachment]="lightboxAttachment()"
|
||||
[imageContextMenu]="imageContextMenu()"
|
||||
(lightboxClosed)="closeLightbox()"
|
||||
(contextMenuClosed)="closeImageContextMenu()"
|
||||
(downloadRequested)="downloadAttachment($event)"
|
||||
(copyRequested)="copyImageToClipboard($event)"
|
||||
(imageContextMenuRequested)="openImageContextMenu($event)"
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,12 @@
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-bottom-bar {
|
||||
pointer-events: auto;
|
||||
right: 8px;
|
||||
background: hsl(var(--background) / 0.85);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
HostListener,
|
||||
ViewChild,
|
||||
computed,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { Attachment, AttachmentFacade } from '../../../attachment';
|
||||
import { KlipyGif } from '../../application/klipy.service';
|
||||
import { MessagesActions } from '../../../../store/messages/messages.actions';
|
||||
import {
|
||||
selectAllMessages,
|
||||
selectMessagesLoading,
|
||||
selectMessagesSyncing
|
||||
} from '../../../../store/messages/messages.selectors';
|
||||
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
|
||||
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||
import { Message } from '../../../../shared-kernel';
|
||||
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
|
||||
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
|
||||
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
|
||||
import { ChatMessageOverlaysComponent } from './components/message-overlays/chat-message-overlays.component';
|
||||
import {
|
||||
ChatMessageComposerSubmitEvent,
|
||||
ChatMessageDeleteEvent,
|
||||
ChatMessageEditEvent,
|
||||
ChatMessageImageContextMenuEvent,
|
||||
ChatMessageReactionEvent,
|
||||
ChatMessageReplyEvent
|
||||
} from './models/chat-messages.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-messages',
|
||||
standalone: true,
|
||||
imports: [
|
||||
ChatMessageComposerComponent,
|
||||
KlipyGifPickerComponent,
|
||||
ChatMessageListComponent,
|
||||
ChatMessageOverlaysComponent
|
||||
],
|
||||
templateUrl: './chat-messages.component.html',
|
||||
styleUrl: './chat-messages.component.scss'
|
||||
})
|
||||
export class ChatMessagesComponent {
|
||||
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
|
||||
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private readonly store = inject(Store);
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly attachmentsSvc = inject(AttachmentFacade);
|
||||
|
||||
readonly allMessages = this.store.selectSignal(selectAllMessages);
|
||||
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
readonly loading = this.store.selectSignal(selectMessagesLoading);
|
||||
readonly syncing = this.store.selectSignal(selectMessagesSyncing);
|
||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
readonly isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
|
||||
readonly channelMessages = computed(() => {
|
||||
const channelId = this.activeChannelId();
|
||||
const roomId = this.currentRoom()?.id;
|
||||
|
||||
return this.allMessages().filter(
|
||||
(message) =>
|
||||
message.roomId === roomId &&
|
||||
(message.channelId || 'general') === channelId
|
||||
);
|
||||
});
|
||||
|
||||
readonly conversationKey = computed(
|
||||
() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`
|
||||
);
|
||||
readonly composerBottomPadding = signal(140);
|
||||
readonly klipyGifPickerAnchorRight = signal(16);
|
||||
readonly replyTo = signal<Message | null>(null);
|
||||
readonly showKlipyGifPicker = signal(false);
|
||||
readonly lightboxAttachment = signal<Attachment | null>(null);
|
||||
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
|
||||
|
||||
@HostListener('window:resize')
|
||||
onWindowResize(): void {
|
||||
if (this.showKlipyGifPicker()) {
|
||||
this.syncKlipyGifPickerAnchor();
|
||||
}
|
||||
}
|
||||
|
||||
handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void {
|
||||
this.store.dispatch(
|
||||
MessagesActions.sendMessage({
|
||||
content: event.content,
|
||||
replyToId: this.replyTo()?.id,
|
||||
channelId: this.activeChannelId()
|
||||
})
|
||||
);
|
||||
|
||||
this.clearReply();
|
||||
|
||||
if (event.pendingFiles.length > 0) {
|
||||
setTimeout(() => this.attachFilesToLastOwnMessage(event.content, event.pendingFiles), 100);
|
||||
}
|
||||
}
|
||||
|
||||
handleTypingStarted(): void {
|
||||
try {
|
||||
this.webrtc.sendRawMessage({ type: 'typing', serverId: this.webrtc.currentServerId });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
setReplyTo(message: ChatMessageReplyEvent): void {
|
||||
this.replyTo.set(message);
|
||||
}
|
||||
|
||||
clearReply(): void {
|
||||
this.replyTo.set(null);
|
||||
}
|
||||
|
||||
handleEditSaved(event: ChatMessageEditEvent): void {
|
||||
this.store.dispatch(
|
||||
MessagesActions.editMessage({
|
||||
messageId: event.messageId,
|
||||
content: event.content
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
handleDeleteRequested(message: ChatMessageDeleteEvent): 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 }));
|
||||
}
|
||||
}
|
||||
|
||||
handleReactionAdded(event: ChatMessageReactionEvent): void {
|
||||
this.store.dispatch(
|
||||
MessagesActions.addReaction({
|
||||
messageId: event.messageId,
|
||||
emoji: event.emoji
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
handleReactionToggled(event: ChatMessageReactionEvent): void {
|
||||
const message = this.channelMessages().find((entry) => entry.id === event.messageId);
|
||||
const currentUserId = this.currentUser()?.id;
|
||||
|
||||
if (!message || !currentUserId)
|
||||
return;
|
||||
|
||||
const hasReacted = message.reactions.some(
|
||||
(reaction) => reaction.emoji === event.emoji && reaction.userId === currentUserId
|
||||
);
|
||||
|
||||
if (hasReacted) {
|
||||
this.store.dispatch(
|
||||
MessagesActions.removeReaction({
|
||||
messageId: event.messageId,
|
||||
emoji: event.emoji
|
||||
})
|
||||
);
|
||||
} else {
|
||||
this.store.dispatch(
|
||||
MessagesActions.addReaction({
|
||||
messageId: event.messageId,
|
||||
emoji: event.emoji
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handleComposerHeightChanged(height: number): void {
|
||||
this.composerBottomPadding.set(height + 20);
|
||||
}
|
||||
|
||||
toggleKlipyGifPicker(): void {
|
||||
const nextState = !this.showKlipyGifPicker();
|
||||
|
||||
this.showKlipyGifPicker.set(nextState);
|
||||
|
||||
if (nextState) {
|
||||
requestAnimationFrame(() => this.syncKlipyGifPickerAnchor());
|
||||
}
|
||||
}
|
||||
|
||||
closeKlipyGifPicker(): void {
|
||||
this.showKlipyGifPicker.set(false);
|
||||
}
|
||||
|
||||
handleKlipyGifSelected(gif: KlipyGif): void {
|
||||
this.closeKlipyGifPicker();
|
||||
this.composer?.handleKlipyGifSelected(gif);
|
||||
}
|
||||
|
||||
private syncKlipyGifPickerAnchor(): void {
|
||||
const triggerRect = this.composer?.getKlipyTriggerRect();
|
||||
|
||||
if (!triggerRect) {
|
||||
this.klipyGifPickerAnchorRight.set(16);
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const popupWidth = this.getKlipyGifPickerWidth(viewportWidth);
|
||||
const preferredRight = viewportWidth - triggerRect.right;
|
||||
const minRight = 16;
|
||||
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
|
||||
|
||||
this.klipyGifPickerAnchorRight.set(
|
||||
Math.min(Math.max(Math.round(preferredRight), minRight), maxRight)
|
||||
);
|
||||
}
|
||||
|
||||
private getKlipyGifPickerWidth(viewportWidth: number): number {
|
||||
if (viewportWidth >= 1280)
|
||||
return 52 * 16;
|
||||
|
||||
if (viewportWidth >= 768)
|
||||
return 42 * 16;
|
||||
|
||||
if (viewportWidth >= 640)
|
||||
return 34 * 16;
|
||||
|
||||
return Math.max(0, viewportWidth - 32);
|
||||
}
|
||||
|
||||
openLightbox(attachment: Attachment): void {
|
||||
if (attachment.available && attachment.objectUrl) {
|
||||
this.lightboxAttachment.set(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
closeLightbox(): void {
|
||||
this.lightboxAttachment.set(null);
|
||||
}
|
||||
|
||||
openImageContextMenu(event: ChatMessageImageContextMenuEvent): void {
|
||||
this.imageContextMenu.set(event);
|
||||
}
|
||||
|
||||
closeImageContextMenu(): void {
|
||||
this.imageContextMenu.set(null);
|
||||
}
|
||||
|
||||
async downloadAttachment(attachment: Attachment): Promise<void> {
|
||||
if (!attachment.available || !attachment.objectUrl)
|
||||
return;
|
||||
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (electronApi) {
|
||||
const blob = await this.getAttachmentBlob(attachment);
|
||||
|
||||
if (blob) {
|
||||
try {
|
||||
const result = await electronApi.saveFileAs(
|
||||
attachment.filename,
|
||||
await this.blobToBase64(blob)
|
||||
);
|
||||
|
||||
if (result.saved || result.cancelled)
|
||||
return;
|
||||
} catch {
|
||||
/* fall back to browser download */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
|
||||
link.href = attachment.objectUrl;
|
||||
link.download = attachment.filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
async copyImageToClipboard(attachment: Attachment): Promise<void> {
|
||||
this.closeImageContextMenu();
|
||||
|
||||
if (!attachment.objectUrl)
|
||||
return;
|
||||
|
||||
try {
|
||||
const response = await fetch(attachment.objectUrl);
|
||||
const blob = await response.blob();
|
||||
const pngBlob = await this.convertToPng(blob);
|
||||
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
private isOwnMessage(message: Message): boolean {
|
||||
return message.senderId === this.currentUser()?.id;
|
||||
}
|
||||
|
||||
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
|
||||
if (!attachment.objectUrl)
|
||||
return null;
|
||||
|
||||
try {
|
||||
const response = await fetch(attachment.objectUrl);
|
||||
|
||||
return await response.blob();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== 'string') {
|
||||
reject(new Error('Failed to encode attachment'));
|
||||
return;
|
||||
}
|
||||
|
||||
const [, base64 = ''] = reader.result.split(',', 2);
|
||||
|
||||
resolve(base64);
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
private convertToPng(blob: Blob): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (blob.type === 'image/png') {
|
||||
resolve(blob);
|
||||
return;
|
||||
}
|
||||
|
||||
const image = new Image();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
image.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (!context) {
|
||||
reject(new Error('Canvas not supported'));
|
||||
return;
|
||||
}
|
||||
|
||||
context.drawImage(image, 0, 0);
|
||||
canvas.toBlob((pngBlob) => {
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
if (pngBlob)
|
||||
resolve(pngBlob);
|
||||
else
|
||||
reject(new Error('PNG conversion failed'));
|
||||
}, 'image/png');
|
||||
};
|
||||
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Image load failed'));
|
||||
};
|
||||
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
private attachFilesToLastOwnMessage(content: string, pendingFiles: File[]): void {
|
||||
const currentUserId = this.currentUser()?.id;
|
||||
|
||||
if (!currentUserId)
|
||||
return;
|
||||
|
||||
const message = [...this.channelMessages()]
|
||||
.reverse()
|
||||
.find(
|
||||
(entry) =>
|
||||
entry.senderId === currentUserId &&
|
||||
entry.content === content &&
|
||||
!entry.isDeleted
|
||||
);
|
||||
|
||||
if (!message) {
|
||||
setTimeout(() => this.attachFilesToLastOwnMessage(content, pendingFiles), 150);
|
||||
return;
|
||||
}
|
||||
|
||||
this.attachmentsSvc.publishAttachments(message.id, pendingFiles, currentUserId || undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/prefer-ngsrc -->
|
||||
<div #composerRoot>
|
||||
@if (replyTo()) {
|
||||
<div class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2">
|
||||
<ng-icon
|
||||
name="lucideReply"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
<span class="flex-1 text-sm text-muted-foreground">
|
||||
Replying to <span class="font-semibold">{{ replyTo()?.senderName }}</span>
|
||||
</span>
|
||||
<button
|
||||
(click)="clearReply()"
|
||||
class="rounded p-1 hover:bg-secondary"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-typing-indicator />
|
||||
|
||||
@if (toolbarVisible()) {
|
||||
<div
|
||||
class="pointer-events-auto"
|
||||
(mousedown)="$event.preventDefault()"
|
||||
(mouseenter)="onToolbarMouseEnter()"
|
||||
(mouseleave)="onToolbarMouseLeave()"
|
||||
>
|
||||
<div
|
||||
class="mx-4 -mb-2 flex flex-wrap items-center justify-start gap-2 rounded-lg border border-border bg-card/70 px-2 py-1 shadow-sm backdrop-blur"
|
||||
>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyInline('**')"
|
||||
>
|
||||
<b>B</b>
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyInline('*')"
|
||||
>
|
||||
<i>I</i>
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyInline('~~')"
|
||||
>
|
||||
<s>S</s>
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyInline(inlineCodeToken)"
|
||||
>
|
||||
`
|
||||
</button>
|
||||
<span class="mx-1 text-muted-foreground">|</span>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyHeading(1)"
|
||||
>
|
||||
H1
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyHeading(2)"
|
||||
>
|
||||
H2
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyHeading(3)"
|
||||
>
|
||||
H3
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyPrefix('> ')"
|
||||
>
|
||||
Quote
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyPrefix('- ')"
|
||||
>
|
||||
• List
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyOrderedList()"
|
||||
>
|
||||
1. List
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyCodeBlock()"
|
||||
>
|
||||
Code
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyLink()"
|
||||
>
|
||||
Link
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyImage()"
|
||||
>
|
||||
Image
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs hover:bg-secondary"
|
||||
(click)="applyHorizontalRule()"
|
||||
>
|
||||
HR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="border-border p-4">
|
||||
<div
|
||||
class="chat-input-wrapper relative"
|
||||
(mouseenter)="inputHovered.set(true)"
|
||||
(mouseleave)="inputHovered.set(false)"
|
||||
(dragenter)="onDragEnter($event)"
|
||||
(dragover)="onDragOver($event)"
|
||||
(dragleave)="onDragLeave($event)"
|
||||
(drop)="onDrop($event)"
|
||||
>
|
||||
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2 m-0.5">
|
||||
@if (klipy.isEnabled()) {
|
||||
<button
|
||||
#klipyTrigger
|
||||
type="button"
|
||||
(click)="toggleKlipyGifPicker()"
|
||||
class="inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-2xl border border-border/70 bg-secondary/55 px-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground"
|
||||
[class.border-primary]="showKlipyGifPicker()"
|
||||
[class.opacity-100]="inputHovered() || showKlipyGifPicker()"
|
||||
[class.opacity-70]="!inputHovered() && !showKlipyGifPicker()"
|
||||
[class.shadow-none]="!inputHovered() && !showKlipyGifPicker()"
|
||||
[class.text-primary]="showKlipyGifPicker()"
|
||||
aria-label="Search KLIPY GIFs"
|
||||
title="Search KLIPY GIFs"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideImage"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<span class="hidden sm:inline">GIF</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="sendMessage()"
|
||||
[disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()"
|
||||
class="send-btn visible inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-1 ring-primary/20 transition-all duration-200 hover:-translate-y-0.5 hover:bg-primary/90 disabled:translate-y-0 disabled:cursor-not-allowed disabled:bg-secondary disabled:text-muted-foreground disabled:shadow-none disabled:ring-0"
|
||||
aria-label="Send message"
|
||||
title="Send message"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideSend"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
#messageInputRef
|
||||
rows="1"
|
||||
[(ngModel)]="messageContent"
|
||||
(focus)="onInputFocus()"
|
||||
(blur)="onInputBlur()"
|
||||
(keydown.enter)="onEnter($event)"
|
||||
(input)="onInputChange(); autoResizeTextarea()"
|
||||
(paste)="onPaste($event)"
|
||||
(dragenter)="onDragEnter($event)"
|
||||
(dragover)="onDragOver($event)"
|
||||
(dragleave)="onDragLeave($event)"
|
||||
(drop)="onDrop($event)"
|
||||
placeholder="Type a message..."
|
||||
class="chat-textarea w-full rounded-[1.35rem] border border-border pl-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
[class.border-dashed]="dragActive()"
|
||||
[class.border-primary]="dragActive()"
|
||||
[class.chat-textarea-expanded]="textareaExpanded()"
|
||||
[class.ctrl-resize]="ctrlHeld()"
|
||||
[class.pr-16]="!klipy.isEnabled()"
|
||||
[class.pr-40]="klipy.isEnabled()"
|
||||
></textarea>
|
||||
|
||||
@if (dragActive()) {
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary bg-primary/5"
|
||||
>
|
||||
<div class="text-sm text-muted-foreground">Drop files to attach</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (pendingKlipyGif()) {
|
||||
<div class="mt-2 flex">
|
||||
<div class="group flex max-w-sm items-center gap-3 rounded-xl border border-border bg-secondary/60 px-2.5 py-2">
|
||||
<div class="relative h-12 w-12 overflow-hidden rounded-lg bg-secondary/80">
|
||||
<img
|
||||
[src]="getPendingKlipyGifPreviewUrl()"
|
||||
[alt]="pendingKlipyGif()!.title || 'KLIPY GIF'"
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span
|
||||
class="absolute bottom-1 left-1 rounded bg-black/70 px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-[0.18em] text-white/90"
|
||||
>
|
||||
KLIPY
|
||||
</span>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-medium text-foreground">GIF ready to send</div>
|
||||
<div class="max-w-[12rem] truncate text-[10px] text-muted-foreground">
|
||||
{{ pendingKlipyGif()!.title || 'KLIPY GIF' }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="removePendingKlipyGif()"
|
||||
class="rounded px-2 py-1 text-[10px] text-destructive transition-colors hover:bg-destructive/10"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (pendingFiles.length > 0) {
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@for (file of pendingFiles; track file.name) {
|
||||
<div class="group flex items-center gap-2 rounded border border-border bg-secondary/60 px-2 py-1">
|
||||
<div class="max-w-[14rem] truncate text-xs font-medium">{{ file.name }}</div>
|
||||
<div class="text-[10px] text-muted-foreground">{{ formatBytes(file.size) }}</div>
|
||||
<button
|
||||
(click)="removePendingFile(file)"
|
||||
class="rounded bg-destructive/20 px-1 py-0.5 text-[10px] text-destructive opacity-70 group-hover:opacity-100"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
.chat-textarea {
|
||||
--textarea-bg: hsl(40deg 3.7% 15.9% / 25%);
|
||||
--textarea-collapsed-padding-y: 18px;
|
||||
--textarea-expanded-padding-y: 8px;
|
||||
|
||||
background: var(--textarea-bg);
|
||||
height: 62px;
|
||||
min-height: 62px;
|
||||
max-height: 520px;
|
||||
overflow-y: hidden;
|
||||
padding-top: var(--textarea-collapsed-padding-y);
|
||||
padding-bottom: var(--textarea-collapsed-padding-y);
|
||||
resize: none;
|
||||
transition:
|
||||
height 0.12s ease,
|
||||
padding 0.12s ease;
|
||||
|
||||
&.chat-textarea-expanded {
|
||||
padding-top: var(--textarea-expanded-padding-y);
|
||||
padding-bottom: var(--textarea-expanded-padding-y);
|
||||
}
|
||||
|
||||
&.ctrl-resize {
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: scale(0.85);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
ViewChild,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideImage,
|
||||
lucideReply,
|
||||
lucideSend,
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
import type { ClipboardFilePayload } from '../../../../../../core/platform/electron/electron-api.models';
|
||||
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
||||
import { KlipyGif, KlipyService } from '../../../../application/klipy.service';
|
||||
import { Message } from '../../../../../../shared-kernel';
|
||||
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
|
||||
import { ChatMarkdownService } from '../../services/chat-markdown.service';
|
||||
import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models';
|
||||
|
||||
type LocalFileWithPath = File & {
|
||||
path?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_TEXTAREA_HEIGHT = 62;
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-message-composer',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
TypingIndicatorComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideImage,
|
||||
lucideReply,
|
||||
lucideSend,
|
||||
lucideX
|
||||
})
|
||||
],
|
||||
templateUrl: './chat-message-composer.component.html',
|
||||
styleUrl: './chat-message-composer.component.scss',
|
||||
host: {
|
||||
'(document:keydown)': 'onDocKeydown($event)',
|
||||
'(document:keyup)': 'onDocKeyup($event)'
|
||||
}
|
||||
})
|
||||
export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
@ViewChild('messageInputRef') messageInputRef?: ElementRef<HTMLTextAreaElement>;
|
||||
@ViewChild('composerRoot') composerRoot?: ElementRef<HTMLDivElement>;
|
||||
@ViewChild('klipyTrigger') klipyTrigger?: ElementRef<HTMLButtonElement>;
|
||||
|
||||
readonly replyTo = input<Message | null>(null);
|
||||
readonly showKlipyGifPicker = input(false);
|
||||
|
||||
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
|
||||
readonly typingStarted = output();
|
||||
readonly replyCleared = output();
|
||||
readonly heightChanged = output<number>();
|
||||
readonly klipyGifPickerToggleRequested = output();
|
||||
|
||||
readonly klipy = inject(KlipyService);
|
||||
private readonly markdown = inject(ChatMarkdownService);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
|
||||
readonly toolbarVisible = signal(false);
|
||||
readonly dragActive = signal(false);
|
||||
readonly inputHovered = signal(false);
|
||||
readonly ctrlHeld = signal(false);
|
||||
readonly textareaExpanded = signal(false);
|
||||
|
||||
messageContent = '';
|
||||
pendingFiles: File[] = [];
|
||||
inlineCodeToken = '`';
|
||||
|
||||
private toolbarHovering = false;
|
||||
private dragDepth = 0;
|
||||
private lastTypingSentAt = 0;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.autoResizeTextarea();
|
||||
this.observeHeight();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.resizeObserver?.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
|
||||
sendMessage(): void {
|
||||
const raw = this.messageContent.trim();
|
||||
|
||||
if (!raw && this.pendingFiles.length === 0 && !this.pendingKlipyGif())
|
||||
return;
|
||||
|
||||
const content = this.buildOutgoingMessageContent(raw);
|
||||
|
||||
this.messageSubmitted.emit({
|
||||
content,
|
||||
pendingFiles: [...this.pendingFiles]
|
||||
});
|
||||
|
||||
this.messageContent = '';
|
||||
this.pendingFiles = [];
|
||||
this.pendingKlipyGif.set(null);
|
||||
this.replyCleared.emit();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.autoResizeTextarea();
|
||||
this.messageInputRef?.nativeElement.focus();
|
||||
});
|
||||
}
|
||||
|
||||
onInputChange(): void {
|
||||
const now = Date.now();
|
||||
|
||||
if (now - this.lastTypingSentAt > 1000) {
|
||||
this.typingStarted.emit();
|
||||
this.lastTypingSentAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
clearReply(): void {
|
||||
this.replyCleared.emit();
|
||||
}
|
||||
|
||||
onEnter(event: Event): void {
|
||||
const keyEvent = event as KeyboardEvent;
|
||||
|
||||
if (keyEvent.shiftKey)
|
||||
return;
|
||||
|
||||
keyEvent.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
|
||||
applyInline(token: string): void {
|
||||
const result = this.markdown.applyInline(this.messageContent, this.getSelection(), token);
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
|
||||
applyPrefix(prefix: string): void {
|
||||
const result = this.markdown.applyPrefix(this.messageContent, this.getSelection(), prefix);
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
|
||||
applyHeading(level: number): void {
|
||||
const result = this.markdown.applyHeading(this.messageContent, this.getSelection(), level);
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
|
||||
applyOrderedList(): void {
|
||||
const result = this.markdown.applyOrderedList(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
|
||||
applyCodeBlock(): void {
|
||||
const result = this.markdown.applyCodeBlock(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
|
||||
applyLink(): void {
|
||||
const result = this.markdown.applyLink(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
|
||||
applyImage(): void {
|
||||
const result = this.markdown.applyImage(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
|
||||
applyHorizontalRule(): void {
|
||||
const result = this.markdown.applyHorizontalRule(this.messageContent, this.getSelection());
|
||||
|
||||
this.messageContent = result.text;
|
||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||
}
|
||||
|
||||
toggleKlipyGifPicker(): void {
|
||||
if (!this.klipy.isEnabled())
|
||||
return;
|
||||
|
||||
this.klipyGifPickerToggleRequested.emit();
|
||||
}
|
||||
|
||||
getKlipyTriggerRect(): DOMRect | null {
|
||||
return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null;
|
||||
}
|
||||
|
||||
handleKlipyGifSelected(gif: KlipyGif): void {
|
||||
this.pendingKlipyGif.set(gif);
|
||||
|
||||
if (!this.messageContent.trim() && this.pendingFiles.length === 0) {
|
||||
this.sendMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
|
||||
}
|
||||
|
||||
removePendingKlipyGif(): void {
|
||||
this.pendingKlipyGif.set(null);
|
||||
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
|
||||
}
|
||||
|
||||
getPendingKlipyGifPreviewUrl(): string {
|
||||
const gif = this.pendingKlipyGif();
|
||||
|
||||
return gif ? this.klipy.buildRenderableImageUrl(gif.previewUrl || gif.url) : '';
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
const units = [
|
||||
'B',
|
||||
'KB',
|
||||
'MB',
|
||||
'GB'
|
||||
];
|
||||
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
removePendingFile(file: File): void {
|
||||
const index = this.pendingFiles.findIndex((pendingFile) => pendingFile === file);
|
||||
|
||||
if (index >= 0) {
|
||||
this.pendingFiles.splice(index, 1);
|
||||
this.emitHeight();
|
||||
}
|
||||
}
|
||||
|
||||
onDragEnter(event: DragEvent): void {
|
||||
if (!this.hasPotentialFilePayload(event.dataTransfer))
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.dragDepth++;
|
||||
this.dragActive.set(true);
|
||||
}
|
||||
|
||||
onDragOver(event: DragEvent): void {
|
||||
if (!this.hasPotentialFilePayload(event.dataTransfer))
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
|
||||
this.dragActive.set(true);
|
||||
}
|
||||
|
||||
onDragLeave(event: DragEvent): void {
|
||||
if (!this.dragActive())
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.dragDepth = Math.max(0, this.dragDepth - 1);
|
||||
|
||||
if (this.dragDepth === 0) {
|
||||
this.dragActive.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
onDrop(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.dragDepth = 0;
|
||||
const droppedFiles = this.extractFilesFromTransfer(event.dataTransfer);
|
||||
|
||||
if (droppedFiles.length === 0) {
|
||||
this.dragActive.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.addPendingFiles(droppedFiles);
|
||||
this.dragActive.set(false);
|
||||
}
|
||||
|
||||
async onPaste(event: ClipboardEvent): Promise<void> {
|
||||
if (!this.hasPotentialFilePayload(event.clipboardData, false))
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const pastedFiles = await this.extractPastedFiles(event);
|
||||
|
||||
if (pastedFiles.length === 0)
|
||||
return;
|
||||
|
||||
this.addPendingFiles(pastedFiles);
|
||||
}
|
||||
|
||||
autoResizeTextarea(): void {
|
||||
const element = this.messageInputRef?.nativeElement;
|
||||
|
||||
if (!element)
|
||||
return;
|
||||
|
||||
element.style.height = 'auto';
|
||||
element.style.height = Math.min(element.scrollHeight, 520) + 'px';
|
||||
element.style.overflowY = element.scrollHeight > 520 ? 'auto' : 'hidden';
|
||||
this.syncTextareaExpandedState();
|
||||
}
|
||||
|
||||
onInputFocus(): void {
|
||||
this.toolbarVisible.set(true);
|
||||
}
|
||||
|
||||
onInputBlur(): void {
|
||||
setTimeout(() => {
|
||||
if (!this.toolbarHovering) {
|
||||
this.toolbarVisible.set(false);
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
onToolbarMouseEnter(): void {
|
||||
this.toolbarHovering = true;
|
||||
}
|
||||
|
||||
onToolbarMouseLeave(): void {
|
||||
this.toolbarHovering = false;
|
||||
|
||||
if (document.activeElement !== this.messageInputRef?.nativeElement) {
|
||||
this.toolbarVisible.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
onDocKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Control') {
|
||||
this.ctrlHeld.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
onDocKeyup(event: KeyboardEvent): void {
|
||||
if (event.key === 'Control') {
|
||||
this.ctrlHeld.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private getSelection(): { start: number; end: number } {
|
||||
const element = this.messageInputRef?.nativeElement;
|
||||
|
||||
return {
|
||||
start: element?.selectionStart ?? this.messageContent.length,
|
||||
end: element?.selectionEnd ?? this.messageContent.length
|
||||
};
|
||||
}
|
||||
|
||||
private setSelection(start: number, end: number): void {
|
||||
const element = this.messageInputRef?.nativeElement;
|
||||
|
||||
if (element) {
|
||||
element.selectionStart = start;
|
||||
element.selectionEnd = end;
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private addPendingFiles(files: File[]): void {
|
||||
if (files.length === 0)
|
||||
return;
|
||||
|
||||
const mergedFiles = this.mergeUniqueFiles(this.pendingFiles, files);
|
||||
|
||||
if (mergedFiles.length === this.pendingFiles.length)
|
||||
return;
|
||||
|
||||
this.pendingFiles = mergedFiles;
|
||||
this.toolbarVisible.set(true);
|
||||
this.emitHeight();
|
||||
|
||||
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
|
||||
}
|
||||
|
||||
private hasPotentialFilePayload(
|
||||
dataTransfer: DataTransfer | null,
|
||||
treatMissingTypesAsPotentialFile = true
|
||||
): boolean {
|
||||
|
||||
if (!dataTransfer)
|
||||
return false;
|
||||
|
||||
if (dataTransfer.files?.length)
|
||||
return true;
|
||||
|
||||
const items = dataTransfer.items;
|
||||
|
||||
if (items?.length) {
|
||||
for (const item of items) {
|
||||
if (item.kind === 'file') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const types = dataTransfer.types;
|
||||
|
||||
if (!types || types.length === 0)
|
||||
return treatMissingTypesAsPotentialFile;
|
||||
|
||||
for (const type of types) {
|
||||
if (
|
||||
type === 'Files' ||
|
||||
type === 'application/x-moz-file' ||
|
||||
type === 'public.file-url' ||
|
||||
type === 'text/uri-list' ||
|
||||
type === 'x-special/gnome-copied-files'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private extractFilesFromTransfer(dataTransfer: DataTransfer | null): File[] {
|
||||
const extractedFiles: File[] = [];
|
||||
const items = dataTransfer?.items ?? null;
|
||||
|
||||
if (items && items.length) {
|
||||
for (const item of items) {
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
|
||||
if (file) {
|
||||
this.pushUniqueFile(extractedFiles, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const files = dataTransfer?.files;
|
||||
|
||||
if (!files?.length)
|
||||
return extractedFiles;
|
||||
|
||||
for (const file of files) {
|
||||
this.pushUniqueFile(extractedFiles, file);
|
||||
}
|
||||
|
||||
return extractedFiles;
|
||||
}
|
||||
|
||||
private mergeUniqueFiles(existingFiles: File[], incomingFiles: File[]): File[] {
|
||||
const mergedFiles = [...existingFiles];
|
||||
|
||||
for (const file of incomingFiles) {
|
||||
this.pushUniqueFile(mergedFiles, file);
|
||||
}
|
||||
|
||||
return mergedFiles;
|
||||
}
|
||||
|
||||
private pushUniqueFile(target: File[], candidate: File): void {
|
||||
const exists = target.some((file) => this.areFilesEquivalent(file, candidate));
|
||||
|
||||
if (!exists) {
|
||||
target.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
private areFilesEquivalent(left: File, right: File): boolean {
|
||||
const leftPath = this.getLocalFilePath(left);
|
||||
const rightPath = this.getLocalFilePath(right);
|
||||
|
||||
if (leftPath && rightPath) {
|
||||
return leftPath === rightPath;
|
||||
}
|
||||
|
||||
if (left.name !== right.name || left.size !== right.size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const leftType = left.type.trim();
|
||||
const rightType = right.type.trim();
|
||||
|
||||
if (leftType && rightType && leftType !== rightType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const leftLastModified = Number.isFinite(left.lastModified) ? left.lastModified : 0;
|
||||
const rightLastModified = Number.isFinite(right.lastModified) ? right.lastModified : 0;
|
||||
|
||||
if (!leftLastModified || !rightLastModified) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Math.abs(leftLastModified - rightLastModified) <= 1000;
|
||||
}
|
||||
|
||||
private getLocalFilePath(file: File): string {
|
||||
return ((file as LocalFileWithPath).path || '').trim();
|
||||
}
|
||||
|
||||
private async extractPastedFiles(event: ClipboardEvent): Promise<File[]> {
|
||||
const directFiles = this.extractFilesFromTransfer(event.clipboardData);
|
||||
|
||||
if (directFiles.length > 0)
|
||||
return directFiles;
|
||||
|
||||
return await this.readFilesFromElectronClipboard();
|
||||
}
|
||||
|
||||
private async readFilesFromElectronClipboard(): Promise<File[]> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi)
|
||||
return [];
|
||||
|
||||
try {
|
||||
const clipboardFiles = await electronApi.readClipboardFiles();
|
||||
|
||||
return clipboardFiles.map((clipboardFile) => this.createFileFromClipboardPayload(clipboardFile));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private createFileFromClipboardPayload(payload: ClipboardFilePayload): File {
|
||||
const file = new File([this.base64ToArrayBuffer(payload.data)], payload.name, {
|
||||
lastModified: payload.lastModified,
|
||||
type: payload.mime
|
||||
});
|
||||
|
||||
if (payload.path) {
|
||||
try {
|
||||
Object.defineProperty(file, 'path', {
|
||||
configurable: true,
|
||||
value: payload.path
|
||||
});
|
||||
} catch {
|
||||
(file as LocalFileWithPath).path = payload.path;
|
||||
}
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
private base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
|
||||
for (let index = 0; index < binaryString.length; index++) {
|
||||
bytes[index] = binaryString.charCodeAt(index);
|
||||
}
|
||||
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
private buildOutgoingMessageContent(raw: string): string {
|
||||
const withEmbeddedImages = this.markdown.appendImageMarkdown(raw);
|
||||
const gif = this.pendingKlipyGif();
|
||||
|
||||
if (!gif)
|
||||
return withEmbeddedImages;
|
||||
|
||||
const gifMarkdown = this.buildKlipyGifMarkdown(gif);
|
||||
|
||||
return withEmbeddedImages ? `${withEmbeddedImages}\n${gifMarkdown}` : gifMarkdown;
|
||||
}
|
||||
|
||||
private buildKlipyGifMarkdown(gif: KlipyGif): string {
|
||||
return `})`;
|
||||
}
|
||||
|
||||
private observeHeight(): void {
|
||||
const root = this.composerRoot?.nativeElement;
|
||||
|
||||
if (!root)
|
||||
return;
|
||||
|
||||
this.syncTextareaExpandedState();
|
||||
this.emitHeight();
|
||||
|
||||
if (typeof ResizeObserver === 'undefined')
|
||||
return;
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.syncTextareaExpandedState();
|
||||
this.emitHeight();
|
||||
});
|
||||
|
||||
this.resizeObserver.observe(root);
|
||||
}
|
||||
|
||||
private syncTextareaExpandedState(): void {
|
||||
const textarea = this.messageInputRef?.nativeElement;
|
||||
|
||||
this.textareaExpanded.set(Boolean(textarea && textarea.offsetHeight > DEFAULT_TEXTAREA_HEIGHT));
|
||||
}
|
||||
|
||||
private emitHeight(): void {
|
||||
const root = this.composerRoot?.nativeElement;
|
||||
|
||||
if (root) {
|
||||
this.heightChanged.emit(root.offsetHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc -->
|
||||
@let msg = message();
|
||||
@let attachmentsList = attachmentViewModels();
|
||||
<div
|
||||
[attr.data-message-id]="msg.id"
|
||||
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
|
||||
[class.opacity-50]="msg.isDeleted"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="msg.senderName"
|
||||
size="md"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
@if (msg.replyToId) {
|
||||
@let reply = repliedMessage();
|
||||
<div
|
||||
class="mb-1 flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
(click)="requestReferenceScroll(msg.replyToId)"
|
||||
>
|
||||
<div class="h-3 w-4 rounded-tl-md border-l-2 border-t-2 border-muted-foreground/50"></div>
|
||||
<ng-icon
|
||||
name="lucideReply"
|
||||
class="h-3 w-3"
|
||||
/>
|
||||
@if (reply) {
|
||||
<span class="font-medium">{{ reply.senderName }}</span>
|
||||
<span class="max-w-[200px] truncate">{{ reply.isDeleted ? deletedMessageContent : reply.content }}</span>
|
||||
} @else {
|
||||
<span class="italic">Original message not found</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="font-semibold text-foreground">{{ msg.senderName }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ formatTimestamp(msg.timestamp) }}</span>
|
||||
@if (msg.editedAt && !msg.isDeleted) {
|
||||
<span class="text-xs text-muted-foreground">(edited)</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (isEditing()) {
|
||||
<div class="mt-1 flex items-start gap-2">
|
||||
<textarea
|
||||
#editTextareaRef
|
||||
rows="1"
|
||||
[(ngModel)]="editContent"
|
||||
(keydown.enter)="onEditEnter($event)"
|
||||
(keydown.escape)="cancelEdit()"
|
||||
(input)="autoResizeEditTextarea()"
|
||||
class="edit-textarea flex-1 rounded border border-border bg-secondary px-3 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
></textarea>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
(click)="saveEdit()"
|
||||
class="rounded p-1 text-primary hover:bg-primary/10"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideCheck"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
(click)="cancelEdit()"
|
||||
class="rounded p-1 text-muted-foreground hover:bg-secondary"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
@if (msg.isDeleted) {
|
||||
<div class="mt-1 text-sm italic text-muted-foreground">{{ deletedMessageContent }}</div>
|
||||
} @else {
|
||||
<div class="chat-markdown mt-1 break-words">
|
||||
<remark
|
||||
[markdown]="msg.content"
|
||||
[processor]="$any(remarkProcessor)"
|
||||
>
|
||||
<ng-template
|
||||
[remarkTemplate]="'code'"
|
||||
let-node
|
||||
>
|
||||
@if (isMermaidCodeBlock(node.lang) && getMermaidCode(node.value)) {
|
||||
<remark-mermaid [code]="getMermaidCode(node.value)" />
|
||||
} @else {
|
||||
<pre [class]="getCodeBlockClass(node.lang)"><code [class]="getCodeBlockClass(node.lang)">{{ node.value }}</code></pre>
|
||||
}
|
||||
</ng-template>
|
||||
<ng-template
|
||||
[remarkTemplate]="'image'"
|
||||
let-node
|
||||
>
|
||||
<div class="relative mt-2 inline-block overflow-hidden rounded-md border border-border/60 bg-secondary/20">
|
||||
<img
|
||||
[src]="getMarkdownImageSource(node.url)"
|
||||
[alt]="node.alt || 'Shared image'"
|
||||
class="block max-h-80 max-w-full w-auto"
|
||||
loading="lazy"
|
||||
/>
|
||||
@if (isKlipyMediaUrl(node.url)) {
|
||||
<span
|
||||
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
|
||||
>
|
||||
KLIPY
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</remark>
|
||||
</div>
|
||||
|
||||
@if (attachmentsList.length > 0) {
|
||||
<div class="mt-2 space-y-2">
|
||||
@for (att of attachmentsList; track att.id) {
|
||||
@if (att.isImage) {
|
||||
@if (att.available && att.objectUrl) {
|
||||
<div
|
||||
class="group/img relative inline-block"
|
||||
(contextmenu)="openImageContextMenu($event, att)"
|
||||
>
|
||||
<img
|
||||
[src]="att.objectUrl"
|
||||
[alt]="att.filename"
|
||||
class="max-h-80 w-auto cursor-pointer rounded-md"
|
||||
(click)="openLightbox(att)"
|
||||
/>
|
||||
<div class="pointer-events-none absolute inset-0 rounded-md bg-black/0 transition-colors group-hover/img:bg-black/20"></div>
|
||||
<div class="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover/img:opacity-100">
|
||||
<button
|
||||
(click)="openLightbox(att); $event.stopPropagation()"
|
||||
class="rounded-md bg-black/60 p-1.5 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
|
||||
title="View full size"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideExpand"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
(click)="downloadAttachment(att); $event.stopPropagation()"
|
||||
class="rounded-md bg-black/60 p-1.5 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
|
||||
title="Download"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideDownload"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else if ((att.receivedBytes || 0) > 0) {
|
||||
<div class="max-w-xs rounded-md border border-border bg-secondary/40 p-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-primary/10">
|
||||
<ng-icon
|
||||
name="lucideImage"
|
||||
class="h-5 w-5 text-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
|
||||
</div>
|
||||
<div class="text-xs font-medium text-primary">{{ ((att.receivedBytes || 0) * 100) / att.size | number: '1.0-0' }}%</div>
|
||||
</div>
|
||||
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-all duration-300"
|
||||
[style.width.%]="((att.receivedBytes || 0) * 100) / att.size"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="max-w-xs rounded-md border border-dashed border-border bg-secondary/20 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
<ng-icon
|
||||
name="lucideImage"
|
||||
class="h-5 w-5 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
|
||||
<div
|
||||
class="mt-0.5 text-xs"
|
||||
[class.italic]="!att.requestError"
|
||||
[class.opacity-70]="!att.requestError"
|
||||
[class.text-destructive]="!!att.requestError"
|
||||
[class.text-muted-foreground]="!att.requestError"
|
||||
>
|
||||
{{ att.requestError || 'Waiting for image source…' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
(click)="retryImageRequest(att)"
|
||||
class="mt-2 w-full rounded-md bg-secondary px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} @else if (att.isVideo || att.isAudio) {
|
||||
@if (att.available && att.objectUrl) {
|
||||
@if (att.isVideo) {
|
||||
<app-chat-video-player
|
||||
[src]="att.objectUrl"
|
||||
[filename]="att.filename"
|
||||
[sizeLabel]="formatBytes(att.size)"
|
||||
(downloadRequested)="downloadAttachment(att)"
|
||||
/>
|
||||
} @else {
|
||||
<app-chat-audio-player
|
||||
[src]="att.objectUrl"
|
||||
[filename]="att.filename"
|
||||
[sizeLabel]="formatBytes(att.size)"
|
||||
(downloadRequested)="downloadAttachment(att)"
|
||||
/>
|
||||
}
|
||||
} @else if ((att.receivedBytes || 0) > 0) {
|
||||
<div class="max-w-xl rounded-md border border-border bg-secondary/40 p-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="rounded bg-destructive px-2 py-1 text-xs text-destructive-foreground"
|
||||
(click)="cancelAttachment(att)"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-all duration-300"
|
||||
[style.width.%]="att.progressPercent"
|
||||
></div>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{{ att.progressPercent | number: '1.0-0' }}%</span>
|
||||
@if (att.speedBps) {
|
||||
<span>{{ formatSpeed(att.speedBps) }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="max-w-xl rounded-md border border-dashed border-border bg-secondary/20 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
|
||||
<div
|
||||
class="mt-1 text-xs leading-relaxed"
|
||||
[class.opacity-80]="!att.requestError"
|
||||
[class.text-destructive]="!!att.requestError"
|
||||
[class.text-muted-foreground]="!att.requestError"
|
||||
>
|
||||
{{ att.mediaStatusText }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
(click)="requestAttachment(att)"
|
||||
class="shrink-0 rounded-md bg-secondary px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
{{ att.mediaActionLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="rounded-md border border-border bg-secondary/40 p-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if (!att.isUploader) {
|
||||
@if (!att.available) {
|
||||
<div class="h-1.5 w-24 rounded bg-muted">
|
||||
<div
|
||||
class="h-1.5 rounded bg-primary"
|
||||
[style.width.%]="att.progressPercent"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{{ att.progressPercent | number: '1.0-0' }}%</span>
|
||||
@if (att.speedBps) {
|
||||
<span>• {{ formatSpeed(att.speedBps) }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (!(att.receivedBytes || 0)) {
|
||||
<button
|
||||
class="rounded bg-secondary px-2 py-1 text-xs text-foreground"
|
||||
(click)="requestAttachment(att)"
|
||||
>
|
||||
{{ att.requestError ? 'Retry' : 'Request' }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
class="rounded bg-destructive px-2 py-1 text-xs text-destructive-foreground"
|
||||
(click)="cancelAttachment(att)"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
}
|
||||
} @else {
|
||||
<button
|
||||
class="rounded bg-primary px-2 py-1 text-xs text-primary-foreground"
|
||||
(click)="downloadAttachment(att)"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
}
|
||||
} @else {
|
||||
<div class="text-xs text-muted-foreground">Shared from your device</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (!att.available && att.requestError) {
|
||||
<div
|
||||
class="mt-2 w-full rounded-md border border-destructive/20 bg-destructive/5 px-2.5 py-1.5 text-xs leading-relaxed text-destructive"
|
||||
>
|
||||
{{ att.requestError }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@if (!msg.isDeleted && msg.reactions.length > 0) {
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
@for (reaction of getGroupedReactions(); track reaction.emoji) {
|
||||
<button
|
||||
(click)="toggleReaction(reaction.emoji)"
|
||||
class="flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-xs transition-colors hover:bg-secondary/80"
|
||||
[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>
|
||||
|
||||
@if (!msg.isDeleted) {
|
||||
<div
|
||||
class="absolute right-2 top-2 flex items-center gap-1 rounded-lg border border-border bg-card shadow-lg opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<div class="relative">
|
||||
<button
|
||||
(click)="toggleEmojiPicker()"
|
||||
class="rounded-l-lg p-1.5 transition-colors hover:bg-secondary"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideSmile"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@if (showEmojiPicker()) {
|
||||
<div class="absolute bottom-full right-0 z-10 mb-2 flex gap-1 rounded-lg border border-border bg-card p-2 shadow-lg">
|
||||
@for (emoji of commonEmojis; track emoji) {
|
||||
<button
|
||||
(click)="addReaction(emoji)"
|
||||
class="rounded p-1 text-lg transition-colors hover:bg-secondary"
|
||||
>
|
||||
{{ emoji }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button
|
||||
(click)="requestReply()"
|
||||
class="p-1.5 transition-colors hover:bg-secondary"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideReply"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@if (isOwnMessage()) {
|
||||
<button
|
||||
(click)="startEdit()"
|
||||
class="p-1.5 transition-colors hover:bg-secondary"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideEdit"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (isOwnMessage() || isAdmin()) {
|
||||
<button
|
||||
(click)="requestDelete()"
|
||||
class="rounded-r-lg p-1.5 transition-colors hover:bg-destructive/10"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideTrash2"
|
||||
class="h-4 w-4 text-destructive"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,189 @@
|
||||
.chat-markdown {
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--foreground));
|
||||
|
||||
::ng-deep {
|
||||
remark {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
del {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
a {
|
||||
color: hsl(var(--primary));
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 0.5em 0 0.25em;
|
||||
color: hsl(var(--foreground));
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1 { font-size: 1.5em; }
|
||||
h2 { font-size: 1.3em; }
|
||||
h3 { font-size: 1.15em; }
|
||||
|
||||
ul, ol {
|
||||
margin: 0.25em 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.125em 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0.5em 0;
|
||||
border-left: 3px solid hsl(var(--primary) / 0.5);
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
background: hsl(var(--secondary) / 0.3);
|
||||
padding: 0.25em 0.75em;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
code:not([class*='language-']) {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border-radius: 4px;
|
||||
background: hsl(var(--secondary));
|
||||
padding: 0.15em 0.35em;
|
||||
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0.5em 0;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
background: hsl(var(--secondary));
|
||||
padding: 0.75em 1em;
|
||||
|
||||
code:not([class*='language-']) {
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
}
|
||||
}
|
||||
|
||||
pre[class*='language-'],
|
||||
code[class*='language-'] {
|
||||
text-shadow: none;
|
||||
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
pre[class*='language-'] {
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
padding: 0.875em 1rem;
|
||||
}
|
||||
|
||||
pre[class*='language-'] > code[class*='language-'] {
|
||||
display: block;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 0.75em 0;
|
||||
border: none;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
table {
|
||||
display: block;
|
||||
margin: 0.5em 0;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid hsl(var(--border));
|
||||
padding: 0.35em 0.75em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: hsl(var(--secondary));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-height: 320px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
p + p {
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
remark-mermaid {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
|
||||
svg {
|
||||
pointer-events: none;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.edit-textarea {
|
||||
min-height: 42px;
|
||||
max-height: 520px;
|
||||
overflow-y: hidden;
|
||||
resize: none;
|
||||
transition: height 0.12s ease;
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, */
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideCheck,
|
||||
lucideDownload,
|
||||
lucideEdit,
|
||||
lucideExpand,
|
||||
lucideImage,
|
||||
lucideReply,
|
||||
lucideSmile,
|
||||
lucideTrash2,
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
import { MermaidComponent, RemarkModule } from 'ngx-remark';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkParse from 'remark-parse';
|
||||
import { unified } from 'unified';
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentFacade,
|
||||
MAX_AUTO_SAVE_SIZE_BYTES
|
||||
} from '../../../../../attachment';
|
||||
import { KlipyService } from '../../../../application/klipy.service';
|
||||
import { DELETED_MESSAGE_CONTENT, Message } from '../../../../../../shared-kernel';
|
||||
import {
|
||||
ChatAudioPlayerComponent,
|
||||
ChatVideoPlayerComponent,
|
||||
UserAvatarComponent
|
||||
} from '../../../../../../shared';
|
||||
import {
|
||||
ChatMessageDeleteEvent,
|
||||
ChatMessageEditEvent,
|
||||
ChatMessageImageContextMenuEvent,
|
||||
ChatMessageReactionEvent,
|
||||
ChatMessageReplyEvent
|
||||
} from '../../models/chat-messages.models';
|
||||
|
||||
const COMMON_EMOJIS = [
|
||||
'👍',
|
||||
'❤️',
|
||||
'😂',
|
||||
'😮',
|
||||
'😢',
|
||||
'🎉',
|
||||
'🔥',
|
||||
'👀'
|
||||
];
|
||||
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
|
||||
cs: 'csharp',
|
||||
html: 'markup',
|
||||
js: 'javascript',
|
||||
md: 'markdown',
|
||||
plain: 'none',
|
||||
plaintext: 'none',
|
||||
py: 'python',
|
||||
sh: 'bash',
|
||||
shell: 'bash',
|
||||
svg: 'markup',
|
||||
text: 'none',
|
||||
ts: 'typescript',
|
||||
xml: 'markup',
|
||||
yml: 'yaml',
|
||||
zsh: 'bash'
|
||||
};
|
||||
const MERMAID_LINE_BREAK_PATTERN = /\r\n?/g;
|
||||
const REMARK_PROCESSOR = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkBreaks);
|
||||
|
||||
interface ChatMessageAttachmentViewModel extends Attachment {
|
||||
isAudio: boolean;
|
||||
isUploader: boolean;
|
||||
isVideo: boolean;
|
||||
mediaActionLabel: string;
|
||||
mediaStatusText: string;
|
||||
progressPercent: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-message-item',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
ChatAudioPlayerComponent,
|
||||
ChatVideoPlayerComponent,
|
||||
RemarkModule,
|
||||
MermaidComponent,
|
||||
UserAvatarComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideCheck,
|
||||
lucideDownload,
|
||||
lucideEdit,
|
||||
lucideExpand,
|
||||
lucideImage,
|
||||
lucideReply,
|
||||
lucideSmile,
|
||||
lucideTrash2,
|
||||
lucideX
|
||||
})
|
||||
],
|
||||
templateUrl: './chat-message-item.component.html',
|
||||
styleUrl: './chat-message-item.component.scss',
|
||||
host: {
|
||||
style: 'display: contents;'
|
||||
}
|
||||
})
|
||||
export class ChatMessageItemComponent {
|
||||
@ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>;
|
||||
|
||||
private readonly attachmentsSvc = inject(AttachmentFacade);
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
|
||||
|
||||
readonly message = input.required<Message>();
|
||||
readonly repliedMessage = input<Message | undefined>();
|
||||
readonly currentUserId = input<string | null>(null);
|
||||
readonly isAdmin = input(false);
|
||||
readonly remarkProcessor = REMARK_PROCESSOR;
|
||||
|
||||
readonly replyRequested = output<ChatMessageReplyEvent>();
|
||||
readonly deleteRequested = output<ChatMessageDeleteEvent>();
|
||||
readonly editSaved = output<ChatMessageEditEvent>();
|
||||
readonly reactionAdded = output<ChatMessageReactionEvent>();
|
||||
readonly reactionToggled = output<ChatMessageReactionEvent>();
|
||||
readonly referenceRequested = output<string>();
|
||||
readonly downloadRequested = output<Attachment>();
|
||||
readonly imageOpened = output<Attachment>();
|
||||
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
||||
|
||||
readonly commonEmojis = COMMON_EMOJIS;
|
||||
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
|
||||
readonly isEditing = signal(false);
|
||||
readonly showEmojiPicker = signal(false);
|
||||
|
||||
editContent = '';
|
||||
|
||||
readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => {
|
||||
void this.attachmentVersion();
|
||||
|
||||
return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) =>
|
||||
this.buildAttachmentViewModel(attachment)
|
||||
);
|
||||
});
|
||||
private readonly syncAttachmentVersion = effect(() => {
|
||||
const version = this.attachmentsSvc.updated();
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (this.attachmentVersion() !== version) {
|
||||
this.attachmentVersion.set(version);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
startEdit(): void {
|
||||
this.editContent = this.message().content;
|
||||
this.isEditing.set(true);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.autoResizeEditTextarea();
|
||||
|
||||
const element = this.editTextareaRef?.nativeElement;
|
||||
|
||||
if (!element)
|
||||
return;
|
||||
|
||||
element.focus();
|
||||
element.setSelectionRange(element.value.length, element.value.length);
|
||||
});
|
||||
}
|
||||
|
||||
onEditEnter(event: Event): void {
|
||||
const keyEvent = event as KeyboardEvent;
|
||||
|
||||
if (keyEvent.shiftKey)
|
||||
return;
|
||||
|
||||
keyEvent.preventDefault();
|
||||
this.saveEdit();
|
||||
}
|
||||
|
||||
saveEdit(): void {
|
||||
if (!this.editContent.trim())
|
||||
return;
|
||||
|
||||
this.editSaved.emit({
|
||||
messageId: this.message().id,
|
||||
content: this.editContent.trim()
|
||||
});
|
||||
|
||||
this.cancelEdit();
|
||||
}
|
||||
|
||||
cancelEdit(): void {
|
||||
this.isEditing.set(false);
|
||||
this.editContent = '';
|
||||
}
|
||||
|
||||
autoResizeEditTextarea(): void {
|
||||
const element = this.editTextareaRef?.nativeElement;
|
||||
|
||||
if (!element)
|
||||
return;
|
||||
|
||||
element.style.height = 'auto';
|
||||
element.style.height = Math.min(element.scrollHeight, 520) + 'px';
|
||||
element.style.overflowY = element.scrollHeight > 520 ? 'auto' : 'hidden';
|
||||
}
|
||||
|
||||
toggleEmojiPicker(): void {
|
||||
this.showEmojiPicker.update((current) => !current);
|
||||
}
|
||||
|
||||
addReaction(emoji: string): void {
|
||||
this.reactionAdded.emit({
|
||||
messageId: this.message().id,
|
||||
emoji
|
||||
});
|
||||
|
||||
this.showEmojiPicker.set(false);
|
||||
}
|
||||
|
||||
toggleReaction(emoji: string): void {
|
||||
this.reactionToggled.emit({
|
||||
messageId: this.message().id,
|
||||
emoji
|
||||
});
|
||||
}
|
||||
|
||||
requestReply(): void {
|
||||
this.replyRequested.emit(this.message());
|
||||
}
|
||||
|
||||
requestDelete(): void {
|
||||
this.deleteRequested.emit(this.message());
|
||||
}
|
||||
|
||||
requestReferenceScroll(messageId: string): void {
|
||||
this.referenceRequested.emit(messageId);
|
||||
}
|
||||
|
||||
isOwnMessage(): boolean {
|
||||
return this.message().senderId === this.currentUserId();
|
||||
}
|
||||
|
||||
getGroupedReactions(): { emoji: string; count: number; hasCurrentUser: boolean }[] {
|
||||
const groups = new Map<string, { count: number; hasCurrentUser: boolean }>();
|
||||
const currentUserId = this.currentUserId();
|
||||
|
||||
this.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();
|
||||
const time = date.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
const toDay = (value: Date) =>
|
||||
new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
|
||||
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (dayDiff === 0)
|
||||
return time;
|
||||
|
||||
if (dayDiff === 1)
|
||||
return 'Yesterday ' + time;
|
||||
|
||||
if (dayDiff < 7) {
|
||||
return (
|
||||
date.toLocaleDateString([], { weekday: 'short' }) +
|
||||
' ' +
|
||||
time
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
date.toLocaleDateString([], {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}) +
|
||||
' ' +
|
||||
time
|
||||
);
|
||||
}
|
||||
|
||||
getMarkdownImageSource(url?: string): string {
|
||||
return url ? this.klipy.buildRenderableImageUrl(url) : '';
|
||||
}
|
||||
|
||||
getMermaidCode(code?: string): string {
|
||||
return (code ?? '').replace(MERMAID_LINE_BREAK_PATTERN, '\n').trim();
|
||||
}
|
||||
|
||||
isKlipyMediaUrl(url?: string): boolean {
|
||||
if (!url)
|
||||
return false;
|
||||
|
||||
return /^(?:https?:)?\/\/(?:[^/]+\.)?klipy\.com/i.test(url);
|
||||
}
|
||||
|
||||
isMermaidCodeBlock(lang?: string): boolean {
|
||||
return this.normalizeCodeLanguage(lang) === 'mermaid';
|
||||
}
|
||||
|
||||
getCodeBlockClass(lang?: string): string {
|
||||
return `language-${this.normalizeCodeLanguage(lang)}`;
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
const units = [
|
||||
'B',
|
||||
'KB',
|
||||
'MB',
|
||||
'GB'
|
||||
];
|
||||
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
formatSpeed(bytesPerSecond?: number): string {
|
||||
if (!bytesPerSecond || bytesPerSecond <= 0)
|
||||
return '0 B/s';
|
||||
|
||||
const units = [
|
||||
'B/s',
|
||||
'KB/s',
|
||||
'MB/s',
|
||||
'GB/s'
|
||||
];
|
||||
|
||||
let speed = bytesPerSecond;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (speed >= 1024 && unitIndex < units.length - 1) {
|
||||
speed /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
isVideoAttachment(attachment: Attachment): boolean {
|
||||
return attachment.mime.startsWith('video/');
|
||||
}
|
||||
|
||||
isAudioAttachment(attachment: Attachment): boolean {
|
||||
return attachment.mime.startsWith('audio/');
|
||||
}
|
||||
|
||||
requiresMediaDownloadAcceptance(attachment: Attachment): boolean {
|
||||
return (
|
||||
(this.isVideoAttachment(attachment) || this.isAudioAttachment(attachment)) &&
|
||||
attachment.size > MAX_AUTO_SAVE_SIZE_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
getMediaAttachmentStatusText(attachment: Attachment): string {
|
||||
if (attachment.requestError)
|
||||
return attachment.requestError;
|
||||
|
||||
if (this.requiresMediaDownloadAcceptance(attachment)) {
|
||||
return this.isVideoAttachment(attachment)
|
||||
? 'Large video. Accept the download to watch it in chat.'
|
||||
: 'Large audio file. Accept the download to play it in chat.';
|
||||
}
|
||||
|
||||
return this.isVideoAttachment(attachment)
|
||||
? 'Waiting for video source…'
|
||||
: 'Waiting for audio source…';
|
||||
}
|
||||
|
||||
getMediaAttachmentActionLabel(attachment: Attachment): string {
|
||||
if (this.requiresMediaDownloadAcceptance(attachment)) {
|
||||
return attachment.requestError ? 'Retry download' : 'Accept download';
|
||||
}
|
||||
|
||||
return attachment.requestError ? 'Retry' : 'Request';
|
||||
}
|
||||
|
||||
isUploader(attachment: Attachment): boolean {
|
||||
const currentUserId = this.currentUserId();
|
||||
|
||||
return !!attachment.uploaderPeerId && !!currentUserId && attachment.uploaderPeerId === currentUserId;
|
||||
}
|
||||
|
||||
requestAttachment(attachment: Attachment): void {
|
||||
const liveAttachment = this.getLiveAttachment(attachment.id);
|
||||
|
||||
if (liveAttachment) {
|
||||
this.attachmentsSvc.requestFile(this.message().id, liveAttachment);
|
||||
}
|
||||
}
|
||||
|
||||
cancelAttachment(attachment: Attachment): void {
|
||||
const liveAttachment = this.getLiveAttachment(attachment.id);
|
||||
|
||||
if (liveAttachment) {
|
||||
this.attachmentsSvc.cancelRequest(this.message().id, liveAttachment);
|
||||
}
|
||||
}
|
||||
|
||||
retryImageRequest(attachment: Attachment): void {
|
||||
const liveAttachment = this.getLiveAttachment(attachment.id);
|
||||
|
||||
if (liveAttachment) {
|
||||
this.attachmentsSvc.requestImageFromAnyPeer(this.message().id, liveAttachment);
|
||||
}
|
||||
}
|
||||
|
||||
openLightbox(attachment: Attachment): void {
|
||||
if (attachment.available && attachment.objectUrl) {
|
||||
this.imageOpened.emit(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
openImageContextMenu(event: MouseEvent, attachment: Attachment): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.imageContextMenuRequested.emit({
|
||||
positionX: event.clientX,
|
||||
positionY: event.clientY,
|
||||
attachment
|
||||
});
|
||||
}
|
||||
|
||||
downloadAttachment(attachment: Attachment): void {
|
||||
this.downloadRequested.emit(attachment);
|
||||
}
|
||||
|
||||
private normalizeCodeLanguage(lang?: string): string {
|
||||
const normalized = (lang || '').trim().toLowerCase();
|
||||
|
||||
if (!normalized)
|
||||
return 'none';
|
||||
|
||||
return PRISM_LANGUAGE_ALIASES[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
|
||||
const isVideo = this.isVideoAttachment(attachment);
|
||||
const isAudio = this.isAudioAttachment(attachment);
|
||||
const requiresMediaDownloadAcceptance =
|
||||
(isVideo || isAudio) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
|
||||
|
||||
return {
|
||||
...attachment,
|
||||
isAudio,
|
||||
isUploader: this.isUploader(attachment),
|
||||
isVideo,
|
||||
mediaActionLabel: requiresMediaDownloadAcceptance
|
||||
? attachment.requestError ? 'Retry download' : 'Accept download'
|
||||
: attachment.requestError ? 'Retry' : 'Request',
|
||||
mediaStatusText: attachment.requestError
|
||||
? attachment.requestError
|
||||
: requiresMediaDownloadAcceptance
|
||||
? isVideo
|
||||
? 'Large video. Accept the download to watch it in chat.'
|
||||
: 'Large audio file. Accept the download to play it in chat.'
|
||||
: isVideo
|
||||
? 'Waiting for video source…'
|
||||
: 'Waiting for audio source…',
|
||||
progressPercent: attachment.size > 0
|
||||
? ((attachment.receivedBytes || 0) * 100) / attachment.size
|
||||
: 0
|
||||
};
|
||||
}
|
||||
|
||||
private getLiveAttachment(attachmentId: string): Attachment | undefined {
|
||||
return this.attachmentsSvc
|
||||
.getForMessage(this.message().id)
|
||||
.find((attachment) => attachment.id === attachmentId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<div
|
||||
#messagesContainer
|
||||
class="absolute inset-0 space-y-4 overflow-y-auto p-4"
|
||||
[style.padding-bottom.px]="bottomPadding()"
|
||||
(scroll)="onScroll()"
|
||||
>
|
||||
@if (syncing() && !loading()) {
|
||||
<div class="flex items-center justify-center gap-2 py-1.5 text-xs text-muted-foreground">
|
||||
<div class="h-3 w-3 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||
<span>Syncing messages…</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||
</div>
|
||||
} @else if (messages().length === 0) {
|
||||
<div class="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<p class="text-lg">No messages yet</p>
|
||||
<p class="text-sm">Be the first to say something!</p>
|
||||
</div>
|
||||
} @else {
|
||||
@if (hasMoreMessages()) {
|
||||
<div class="flex items-center justify-center py-3">
|
||||
@if (loadingMore()) {
|
||||
<div class="h-5 w-5 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
(click)="loadMore()"
|
||||
class="rounded-md px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
Load older messages
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (message of messages(); track message.id) {
|
||||
<app-chat-message-item
|
||||
[message]="message"
|
||||
[repliedMessage]="findRepliedMessage(message.replyToId)"
|
||||
[currentUserId]="currentUserId()"
|
||||
[isAdmin]="isAdmin()"
|
||||
(replyRequested)="handleReplyRequested($event)"
|
||||
(deleteRequested)="handleDeleteRequested($event)"
|
||||
(editSaved)="handleEditSaved($event)"
|
||||
(reactionAdded)="handleReactionAdded($event)"
|
||||
(reactionToggled)="handleReactionToggled($event)"
|
||||
(referenceRequested)="handleReferenceRequested($event)"
|
||||
(downloadRequested)="handleDownloadRequested($event)"
|
||||
(imageOpened)="handleImageOpened($event)"
|
||||
(imageContextMenuRequested)="handleImageContextMenuRequested($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
@if (showNewMessagesBar()) {
|
||||
<div class="pointer-events-none sticky bottom-4 flex justify-center">
|
||||
<div class="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2 shadow">
|
||||
<span class="text-sm text-muted-foreground">New messages</span>
|
||||
<button
|
||||
type="button"
|
||||
(click)="readLatest()"
|
||||
class="rounded bg-primary px-2 py-1 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Read latest
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,412 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
AfterViewChecked,
|
||||
Component,
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
ViewChild,
|
||||
computed,
|
||||
effect,
|
||||
input,
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { Attachment } from '../../../../../attachment';
|
||||
import { Message } from '../../../../../../shared-kernel';
|
||||
import {
|
||||
ChatMessageDeleteEvent,
|
||||
ChatMessageEditEvent,
|
||||
ChatMessageImageContextMenuEvent,
|
||||
ChatMessageReactionEvent,
|
||||
ChatMessageReplyEvent
|
||||
} from '../../models/chat-messages.models';
|
||||
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
|
||||
|
||||
interface PrismGlobal {
|
||||
highlightElement(element: Element): void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Prism?: PrismGlobal;
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-message-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ChatMessageItemComponent],
|
||||
templateUrl: './chat-message-list.component.html',
|
||||
host: {
|
||||
style: 'display: contents;'
|
||||
}
|
||||
})
|
||||
export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
|
||||
|
||||
readonly allMessages = input.required<Message[]>();
|
||||
readonly channelMessages = input.required<Message[]>();
|
||||
readonly loading = input(false);
|
||||
readonly syncing = input(false);
|
||||
readonly currentUserId = input<string | null>(null);
|
||||
readonly isAdmin = input(false);
|
||||
readonly bottomPadding = input(120);
|
||||
readonly conversationKey = input.required<string>();
|
||||
|
||||
readonly replyRequested = output<ChatMessageReplyEvent>();
|
||||
readonly deleteRequested = output<ChatMessageDeleteEvent>();
|
||||
readonly editSaved = output<ChatMessageEditEvent>();
|
||||
readonly reactionAdded = output<ChatMessageReactionEvent>();
|
||||
readonly reactionToggled = output<ChatMessageReactionEvent>();
|
||||
readonly downloadRequested = output<Attachment>();
|
||||
readonly imageOpened = output<Attachment>();
|
||||
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
||||
|
||||
private readonly PAGE_SIZE = 50;
|
||||
|
||||
readonly displayLimit = signal(this.PAGE_SIZE);
|
||||
readonly loadingMore = signal(false);
|
||||
readonly showNewMessagesBar = signal(false);
|
||||
|
||||
readonly messages = computed(() => {
|
||||
const all = this.channelMessages();
|
||||
const limit = this.displayLimit();
|
||||
|
||||
if (all.length <= limit)
|
||||
return all;
|
||||
|
||||
return all.slice(all.length - limit);
|
||||
});
|
||||
|
||||
readonly hasMoreMessages = computed(
|
||||
() => this.channelMessages().length > this.displayLimit()
|
||||
);
|
||||
|
||||
private initialScrollObserver: MutationObserver | null = null;
|
||||
private initialScrollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private boundOnImageLoad: (() => void) | null = null;
|
||||
private isAutoScrolling = false;
|
||||
private lastMessageCount = 0;
|
||||
private initialScrollPending = true;
|
||||
private prismHighlightScheduled = false;
|
||||
|
||||
private readonly onConversationChanged = effect(() => {
|
||||
void this.conversationKey();
|
||||
this.resetScrollingState();
|
||||
});
|
||||
|
||||
private readonly onMessagesChanged = effect(() => {
|
||||
const currentCount = this.channelMessages().length;
|
||||
const element = this.messagesContainer?.nativeElement;
|
||||
|
||||
if (!element) {
|
||||
this.lastMessageCount = currentCount;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.initialScrollPending) {
|
||||
this.lastMessageCount = currentCount;
|
||||
return;
|
||||
}
|
||||
|
||||
const distanceFromBottom =
|
||||
element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||
const newMessages = currentCount > this.lastMessageCount;
|
||||
|
||||
if (newMessages) {
|
||||
if (distanceFromBottom <= 300) {
|
||||
this.scheduleScrollToBottomSmooth();
|
||||
this.showNewMessagesBar.set(false);
|
||||
} else {
|
||||
queueMicrotask(() => this.showNewMessagesBar.set(true));
|
||||
}
|
||||
}
|
||||
|
||||
this.lastMessageCount = currentCount;
|
||||
});
|
||||
|
||||
ngAfterViewChecked(): void {
|
||||
const element = this.messagesContainer?.nativeElement;
|
||||
|
||||
if (!element)
|
||||
return;
|
||||
|
||||
if (this.initialScrollPending) {
|
||||
if (this.messages().length > 0) {
|
||||
this.initialScrollPending = false;
|
||||
this.isAutoScrolling = true;
|
||||
element.scrollTop = element.scrollHeight;
|
||||
requestAnimationFrame(() => {
|
||||
this.isAutoScrolling = false;
|
||||
});
|
||||
|
||||
this.startInitialScrollWatch();
|
||||
this.showNewMessagesBar.set(false);
|
||||
this.lastMessageCount = this.messages().length;
|
||||
this.scheduleCodeHighlight();
|
||||
} else if (!this.loading()) {
|
||||
this.initialScrollPending = false;
|
||||
this.lastMessageCount = 0;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleCodeHighlight();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopInitialScrollWatch();
|
||||
}
|
||||
|
||||
findRepliedMessage(messageId?: string | null): Message | undefined {
|
||||
if (!messageId)
|
||||
return undefined;
|
||||
|
||||
return this.allMessages().find((message) => message.id === messageId);
|
||||
}
|
||||
|
||||
onScroll(): void {
|
||||
const element = this.messagesContainer?.nativeElement;
|
||||
|
||||
if (!element || this.isAutoScrolling)
|
||||
return;
|
||||
|
||||
const distanceFromBottom =
|
||||
element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||
const shouldStickToBottom = distanceFromBottom <= 300;
|
||||
|
||||
if (shouldStickToBottom) {
|
||||
this.showNewMessagesBar.set(false);
|
||||
}
|
||||
|
||||
if (this.initialScrollObserver) {
|
||||
this.stopInitialScrollWatch();
|
||||
}
|
||||
|
||||
if (element.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
|
||||
this.loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
loadMore(): void {
|
||||
if (this.loadingMore() || !this.hasMoreMessages())
|
||||
return;
|
||||
|
||||
this.loadingMore.set(true);
|
||||
|
||||
const element = this.messagesContainer?.nativeElement;
|
||||
const previousScrollHeight = element?.scrollHeight ?? 0;
|
||||
|
||||
this.displayLimit.update((limit) => limit + this.PAGE_SIZE);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (element) {
|
||||
const newScrollHeight = element.scrollHeight;
|
||||
|
||||
element.scrollTop += newScrollHeight - previousScrollHeight;
|
||||
}
|
||||
|
||||
this.loadingMore.set(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
readLatest(): void {
|
||||
this.scrollToBottomSmooth();
|
||||
this.showNewMessagesBar.set(false);
|
||||
}
|
||||
|
||||
scrollToMessage(messageId: string): void {
|
||||
const container = this.messagesContainer?.nativeElement;
|
||||
|
||||
if (!container)
|
||||
return;
|
||||
|
||||
const element = container.querySelector(`[data-message-id="${messageId}"]`) as HTMLElement | null;
|
||||
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
});
|
||||
|
||||
element.classList.add('bg-primary/10');
|
||||
setTimeout(() => element.classList.remove('bg-primary/10'), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
handleReplyRequested(message: ChatMessageReplyEvent): void {
|
||||
this.replyRequested.emit(message);
|
||||
}
|
||||
|
||||
handleDeleteRequested(message: ChatMessageDeleteEvent): void {
|
||||
this.deleteRequested.emit(message);
|
||||
}
|
||||
|
||||
handleEditSaved(event: ChatMessageEditEvent): void {
|
||||
this.editSaved.emit(event);
|
||||
}
|
||||
|
||||
handleReactionAdded(event: ChatMessageReactionEvent): void {
|
||||
this.reactionAdded.emit(event);
|
||||
}
|
||||
|
||||
handleReactionToggled(event: ChatMessageReactionEvent): void {
|
||||
this.reactionToggled.emit(event);
|
||||
}
|
||||
|
||||
handleReferenceRequested(messageId: string): void {
|
||||
this.scrollToMessage(messageId);
|
||||
}
|
||||
|
||||
handleDownloadRequested(attachment: Attachment): void {
|
||||
this.downloadRequested.emit(attachment);
|
||||
}
|
||||
|
||||
handleImageOpened(attachment: Attachment): void {
|
||||
this.imageOpened.emit(attachment);
|
||||
}
|
||||
|
||||
handleImageContextMenuRequested(event: ChatMessageImageContextMenuEvent): void {
|
||||
this.imageContextMenuRequested.emit(event);
|
||||
}
|
||||
|
||||
private resetScrollingState(): void {
|
||||
this.initialScrollPending = true;
|
||||
this.stopInitialScrollWatch();
|
||||
this.showNewMessagesBar.set(false);
|
||||
this.lastMessageCount = 0;
|
||||
this.displayLimit.set(this.PAGE_SIZE);
|
||||
}
|
||||
|
||||
private startInitialScrollWatch(): void {
|
||||
this.stopInitialScrollWatch();
|
||||
|
||||
const element = this.messagesContainer?.nativeElement;
|
||||
|
||||
if (!element)
|
||||
return;
|
||||
|
||||
const snapToBottom = () => {
|
||||
const container = this.messagesContainer?.nativeElement;
|
||||
|
||||
if (!container)
|
||||
return;
|
||||
|
||||
this.isAutoScrolling = true;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
requestAnimationFrame(() => {
|
||||
this.isAutoScrolling = false;
|
||||
});
|
||||
};
|
||||
|
||||
this.initialScrollObserver = new MutationObserver(() => {
|
||||
requestAnimationFrame(snapToBottom);
|
||||
});
|
||||
|
||||
this.initialScrollObserver.observe(element, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['src']
|
||||
});
|
||||
|
||||
this.boundOnImageLoad = () => requestAnimationFrame(snapToBottom);
|
||||
element.addEventListener('load', this.boundOnImageLoad, true);
|
||||
|
||||
this.initialScrollTimer = setTimeout(() => this.stopInitialScrollWatch(), 5000);
|
||||
}
|
||||
|
||||
private stopInitialScrollWatch(): void {
|
||||
if (this.initialScrollObserver) {
|
||||
this.initialScrollObserver.disconnect();
|
||||
this.initialScrollObserver = null;
|
||||
}
|
||||
|
||||
if (this.boundOnImageLoad && this.messagesContainer) {
|
||||
this.messagesContainer.nativeElement.removeEventListener(
|
||||
'load',
|
||||
this.boundOnImageLoad,
|
||||
true
|
||||
);
|
||||
|
||||
this.boundOnImageLoad = null;
|
||||
}
|
||||
|
||||
if (this.initialScrollTimer) {
|
||||
clearTimeout(this.initialScrollTimer);
|
||||
this.initialScrollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scrollToBottomSmooth(): void {
|
||||
const element = this.messagesContainer?.nativeElement;
|
||||
|
||||
if (!element)
|
||||
return;
|
||||
|
||||
try {
|
||||
element.scrollTo({
|
||||
top: element.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
} catch {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleScrollToBottomSmooth(): void {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => this.scrollToBottomSmooth());
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleCodeHighlight(): void {
|
||||
if (this.prismHighlightScheduled)
|
||||
return;
|
||||
|
||||
this.prismHighlightScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.prismHighlightScheduled = false;
|
||||
this.highlightRenderedCodeBlocks();
|
||||
});
|
||||
}
|
||||
|
||||
private highlightRenderedCodeBlocks(): void {
|
||||
const container = this.messagesContainer?.nativeElement;
|
||||
const prism = window.Prism;
|
||||
|
||||
if (!container || !prism?.highlightElement)
|
||||
return;
|
||||
|
||||
const blocks = container.querySelectorAll<HTMLElement>('pre > code[class*="language-"]');
|
||||
|
||||
for (const block of blocks) {
|
||||
const signature = this.getCodeBlockSignature(block);
|
||||
|
||||
if (block.dataset['prismSignature'] === signature)
|
||||
continue;
|
||||
|
||||
try {
|
||||
prism.highlightElement(block);
|
||||
} finally {
|
||||
block.dataset['prismSignature'] = signature;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getCodeBlockSignature(block: HTMLElement): string {
|
||||
const value = `${block.className}:${block.textContent ?? ''}`;
|
||||
|
||||
let hash = 0;
|
||||
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0;
|
||||
}
|
||||
|
||||
return String(hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/prefer-ngsrc -->
|
||||
@if (lightboxAttachment()) {
|
||||
<div
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||
(click)="closeLightbox()"
|
||||
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!)"
|
||||
(keydown.escape)="closeLightbox()"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="relative max-h-[90vh] max-w-[90vw]"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<img
|
||||
[src]="lightboxAttachment()!.objectUrl"
|
||||
[alt]="lightboxAttachment()!.filename"
|
||||
class="max-h-[90vh] max-w-[90vw] rounded-lg object-contain shadow-2xl"
|
||||
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!); $event.stopPropagation()"
|
||||
/>
|
||||
<div class="absolute right-3 top-3 flex gap-2">
|
||||
<button
|
||||
(click)="downloadAttachment(lightboxAttachment()!)"
|
||||
class="rounded-lg bg-black/60 p-2 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
|
||||
title="Download"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideDownload"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
(click)="closeLightbox()"
|
||||
class="rounded-lg bg-black/60 p-2 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
|
||||
title="Close"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="absolute bottom-3 left-3 right-3 flex items-center justify-between">
|
||||
<div class="rounded-lg bg-black/60 px-3 py-1.5 backdrop-blur-sm">
|
||||
<span class="text-sm text-white">{{ lightboxAttachment()!.filename }}</span>
|
||||
<span class="ml-2 text-xs text-white/60">{{ formatBytes(lightboxAttachment()!.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (imageContextMenu()) {
|
||||
<app-context-menu
|
||||
[x]="imageContextMenu()!.positionX"
|
||||
[y]="imageContextMenu()!.positionY"
|
||||
(closed)="closeImageContextMenu()"
|
||||
>
|
||||
<button
|
||||
(click)="copyImageToClipboard(imageContextMenu()!.attachment); closeImageContextMenu()"
|
||||
class="context-menu-item-icon"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideCopy"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
Copy Image
|
||||
</button>
|
||||
<button
|
||||
(click)="downloadAttachment(imageContextMenu()!.attachment); closeImageContextMenu()"
|
||||
class="context-menu-item-icon"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideDownload"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
Save Image
|
||||
</button>
|
||||
</app-context-menu>
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
output
|
||||
} from '@angular/core';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideCopy,
|
||||
lucideDownload,
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
import { Attachment } from '../../../../../attachment';
|
||||
import { ContextMenuComponent } from '../../../../../../shared';
|
||||
import { ChatMessageImageContextMenuEvent } from '../../models/chat-messages.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-message-overlays',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
ContextMenuComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideCopy,
|
||||
lucideDownload,
|
||||
lucideX
|
||||
})
|
||||
],
|
||||
templateUrl: './chat-message-overlays.component.html',
|
||||
host: {
|
||||
style: 'display: contents;'
|
||||
}
|
||||
})
|
||||
export class ChatMessageOverlaysComponent {
|
||||
readonly lightboxAttachment = input<Attachment | null>(null);
|
||||
readonly imageContextMenu = input<ChatMessageImageContextMenuEvent | null>(null);
|
||||
|
||||
readonly lightboxClosed = output();
|
||||
readonly contextMenuClosed = output();
|
||||
readonly downloadRequested = output<Attachment>();
|
||||
readonly copyRequested = output<Attachment>();
|
||||
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
||||
|
||||
closeLightbox(): void {
|
||||
this.lightboxClosed.emit();
|
||||
}
|
||||
|
||||
closeImageContextMenu(): void {
|
||||
this.contextMenuClosed.emit();
|
||||
}
|
||||
|
||||
downloadAttachment(attachment: Attachment): void {
|
||||
this.downloadRequested.emit(attachment);
|
||||
}
|
||||
|
||||
copyImageToClipboard(attachment: Attachment): void {
|
||||
this.copyRequested.emit(attachment);
|
||||
}
|
||||
|
||||
openImageContextMenu(event: MouseEvent, attachment: Attachment): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.imageContextMenuRequested.emit({
|
||||
positionX: event.clientX,
|
||||
positionY: event.clientY,
|
||||
attachment
|
||||
});
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
const units = [
|
||||
'B',
|
||||
'KB',
|
||||
'MB',
|
||||
'GB'
|
||||
];
|
||||
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Attachment } from '../../../../attachment';
|
||||
import { Message } from '../../../../../shared-kernel';
|
||||
|
||||
export interface ChatMessageComposerSubmitEvent {
|
||||
content: string;
|
||||
pendingFiles: File[];
|
||||
}
|
||||
|
||||
export interface ChatMessageEditEvent {
|
||||
messageId: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ChatMessageReactionEvent {
|
||||
messageId: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
export interface ChatMessageAttachmentEvent {
|
||||
messageId: string;
|
||||
attachment: Attachment;
|
||||
}
|
||||
|
||||
export interface ChatMessageImageContextMenuEvent {
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
attachment: Attachment;
|
||||
}
|
||||
|
||||
export type ChatMessageReplyEvent = Message;
|
||||
export type ChatMessageDeleteEvent = Message;
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
export interface SelectionRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface ComposeResult {
|
||||
text: string;
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ChatMarkdownService {
|
||||
applyInline(content: string, selection: SelectionRange, token: string): ComposeResult {
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const selected = content.slice(start, end) || 'text';
|
||||
const after = content.slice(end);
|
||||
const newText = `${before}${token}${selected}${token}${after}`;
|
||||
const cursor = before.length + token.length + selected.length + token.length;
|
||||
|
||||
return { text: newText,
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor };
|
||||
}
|
||||
|
||||
applyPrefix(content: string, selection: SelectionRange, prefix: string): ComposeResult {
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const selected = content.slice(start, end) || 'text';
|
||||
const after = content.slice(end);
|
||||
const lines = selected.split('\n').map(line => `${prefix}${line}`);
|
||||
const newSelected = lines.join('\n');
|
||||
const text = `${before}${newSelected}${after}`;
|
||||
const cursor = before.length + newSelected.length;
|
||||
|
||||
return { text,
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor };
|
||||
}
|
||||
|
||||
applyHeading(content: string, selection: SelectionRange, level: number): ComposeResult {
|
||||
const hashes = '#'.repeat(Math.max(1, Math.min(6, level)));
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const selected = content.slice(start, end) || 'Heading';
|
||||
const after = content.slice(end);
|
||||
const needsLeadingNewline = before.length > 0 && !before.endsWith('\n');
|
||||
const needsTrailingNewline = after.length > 0 && !after.startsWith('\n');
|
||||
const block = `${needsLeadingNewline ? '\n' : ''}${hashes} ${selected}${needsTrailingNewline ? '\n' : ''}`;
|
||||
const text = `${before}${block}${after}`;
|
||||
const cursor = before.length + block.length;
|
||||
|
||||
return { text,
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor };
|
||||
}
|
||||
|
||||
applyOrderedList(content: string, selection: SelectionRange): ComposeResult {
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const selected = content.slice(start, end) || 'item\nitem';
|
||||
const after = content.slice(end);
|
||||
const lines = selected.split('\n').map((line, index) => `${index + 1}. ${line}`);
|
||||
const newSelected = lines.join('\n');
|
||||
const text = `${before}${newSelected}${after}`;
|
||||
const cursor = before.length + newSelected.length;
|
||||
|
||||
return { text,
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor };
|
||||
}
|
||||
|
||||
applyCodeBlock(content: string, selection: SelectionRange): ComposeResult {
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const selected = content.slice(start, end) || 'code';
|
||||
const after = content.slice(end);
|
||||
const fenced = `\`\`\`\n${selected}\n\`\`\`\n\n`;
|
||||
const text = `${before}${fenced}${after}`;
|
||||
const cursor = before.length + fenced.length;
|
||||
|
||||
return { text,
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor };
|
||||
}
|
||||
|
||||
applyLink(content: string, selection: SelectionRange): ComposeResult {
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const selected = content.slice(start, end) || 'link';
|
||||
const after = content.slice(end);
|
||||
const link = `[${selected}](https://)`;
|
||||
const text = `${before}${link}${after}`;
|
||||
const cursorStart = before.length + link.length - 1;
|
||||
|
||||
// Position inside the URL placeholder
|
||||
return { text,
|
||||
selectionStart: cursorStart - 8,
|
||||
selectionEnd: cursorStart - 1 };
|
||||
}
|
||||
|
||||
applyImage(content: string, selection: SelectionRange): ComposeResult {
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const selected = content.slice(start, end) || 'alt';
|
||||
const after = content.slice(end);
|
||||
const img = ``;
|
||||
const text = `${before}${img}${after}`;
|
||||
const cursorStart = before.length + img.length - 1;
|
||||
|
||||
return { text,
|
||||
selectionStart: cursorStart - 8,
|
||||
selectionEnd: cursorStart - 1 };
|
||||
}
|
||||
|
||||
applyHorizontalRule(content: string, selection: SelectionRange): ComposeResult {
|
||||
const { start, end } = selection;
|
||||
const before = content.slice(0, start);
|
||||
const after = content.slice(end);
|
||||
const hr = '\n\n---\n\n';
|
||||
const text = `${before}${hr}${after}`;
|
||||
const cursor = before.length + hr.length;
|
||||
|
||||
return { text,
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor };
|
||||
}
|
||||
|
||||
appendImageMarkdown(content: string): string {
|
||||
const imageUrlRegex = /(https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg|bmp|tiff)(?:\?[^\s)]*)?)/ig;
|
||||
const urls = new Set<string>();
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
const text = content;
|
||||
|
||||
while ((match = imageUrlRegex.exec(text)) !== null) {
|
||||
urls.add(match[1]);
|
||||
}
|
||||
|
||||
if (urls.size === 0)
|
||||
return content;
|
||||
|
||||
let append = '';
|
||||
|
||||
for (const url of urls) {
|
||||
const alreadyEmbedded = new RegExp(`!\\[[^\\]]*\\]\\(\\s*${this.escapeRegex(url)}\\s*\\)`, 'i').test(text);
|
||||
|
||||
if (!alreadyEmbedded) {
|
||||
append += `\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return append ? content + append : content;
|
||||
}
|
||||
|
||||
private escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
|
||||
<div
|
||||
class="flex h-[min(70vh,42rem)] w-full flex-col overflow-hidden rounded-[1.65rem] border border-border/80 shadow-2xl ring-1 ring-white/5"
|
||||
role="dialog"
|
||||
aria-label="KLIPY GIF picker"
|
||||
style="background: hsl(var(--background) / 0.85); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4 border-b border-border/70 bg-secondary/15 px-5 py-4">
|
||||
<div>
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">KLIPY</div>
|
||||
<h3 class="mt-1 text-lg font-semibold text-foreground">Choose a GIF</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ searchQuery.trim() ? 'Search results from KLIPY.' : 'Trending GIFs from KLIPY.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="close()"
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-border/70 bg-secondary/30 text-muted-foreground transition-colors hover:bg-secondary/80 hover:text-foreground"
|
||||
aria-label="Close GIF picker"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-border/70 bg-secondary/10 px-5 py-4">
|
||||
<label class="relative block">
|
||||
<ng-icon
|
||||
name="lucideSearch"
|
||||
class="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
#searchInput
|
||||
type="text"
|
||||
[ngModel]="searchQuery"
|
||||
(ngModelChange)="onSearchQueryChanged($event)"
|
||||
placeholder="Search KLIPY"
|
||||
class="relative z-0 w-full rounded-xl border border-border/80 bg-background/70 px-10 py-3 text-sm text-foreground placeholder:text-muted-foreground shadow-sm backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-5 py-4">
|
||||
@if (errorMessage()) {
|
||||
<div
|
||||
class="mb-4 flex items-center justify-between gap-3 rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive backdrop-blur-sm"
|
||||
>
|
||||
<span>{{ errorMessage() }}</span>
|
||||
<button
|
||||
type="button"
|
||||
(click)="retry()"
|
||||
class="rounded-lg bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading() && results().length === 0) {
|
||||
<div class="flex h-full min-h-56 flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<span class="h-6 w-6 animate-spin rounded-full border-2 border-primary/20 border-t-primary"></span>
|
||||
<p class="text-sm">Loading GIFs from KLIPY…</p>
|
||||
</div>
|
||||
} @else if (results().length === 0) {
|
||||
<div
|
||||
class="flex h-full min-h-56 flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border/80 bg-secondary/10 px-6 text-center text-muted-foreground"
|
||||
>
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<ng-icon
|
||||
name="lucideImage"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">No GIFs found</p>
|
||||
<p class="mt-1 text-sm">Try another search term or clear the search to browse trending GIFs.</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4">
|
||||
@for (gif of results(); track gif.id) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="selectGif(gif)"
|
||||
class="group overflow-hidden rounded-2xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30"
|
||||
>
|
||||
<div
|
||||
class="relative overflow-hidden bg-secondary/30"
|
||||
[style.aspect-ratio]="gifAspectRatio(gif)"
|
||||
>
|
||||
<img
|
||||
[src]="gifPreviewUrl(gif)"
|
||||
[alt]="gif.title || 'KLIPY GIF'"
|
||||
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span
|
||||
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
|
||||
>
|
||||
KLIPY
|
||||
</span>
|
||||
</div>
|
||||
<div class="px-3 py-2">
|
||||
<p class="truncate text-xs font-medium text-foreground">
|
||||
{{ gif.title || 'KLIPY GIF' }}
|
||||
</p>
|
||||
<p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">Click to select</p>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 border-t border-border/70 bg-secondary/10 px-5 py-4">
|
||||
<p class="text-xs text-muted-foreground">Click a GIF to select it. Powered by KLIPY.</p>
|
||||
|
||||
@if (hasNext()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="loadMore()"
|
||||
[disabled]="loading()"
|
||||
class="rounded-full border border-border/80 bg-background/60 px-4 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{{ loading() ? 'Loading…' : 'Load more' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,187 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
inject,
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideImage,
|
||||
lucideSearch,
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
import { KlipyGif, KlipyService } from '../../application/klipy.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-klipy-gif-picker',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideImage,
|
||||
lucideSearch,
|
||||
lucideX
|
||||
})
|
||||
],
|
||||
templateUrl: './klipy-gif-picker.component.html'
|
||||
})
|
||||
export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
readonly gifSelected = output<KlipyGif>();
|
||||
readonly closed = output<undefined>();
|
||||
|
||||
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
|
||||
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private currentPage = 1;
|
||||
private searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private requestId = 0;
|
||||
|
||||
searchQuery = '';
|
||||
results = signal<KlipyGif[]>([]);
|
||||
loading = signal(false);
|
||||
errorMessage = signal('');
|
||||
hasNext = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
void this.loadResults(true);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
requestAnimationFrame(() => {
|
||||
this.searchInput?.nativeElement.focus();
|
||||
this.searchInput?.nativeElement.select();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.clearSearchTimer();
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
this.close();
|
||||
}
|
||||
|
||||
onSearchQueryChanged(query: string): void {
|
||||
this.searchQuery = query;
|
||||
this.clearSearchTimer();
|
||||
this.searchTimer = setTimeout(() => {
|
||||
void this.loadResults(true);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
void this.loadResults(true);
|
||||
}
|
||||
|
||||
async loadMore(): Promise<void> {
|
||||
if (this.loading() || !this.hasNext())
|
||||
return;
|
||||
|
||||
this.currentPage += 1;
|
||||
await this.loadResults(false);
|
||||
}
|
||||
|
||||
selectGif(gif: KlipyGif): void {
|
||||
this.gifSelected.emit(gif);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed.emit(undefined);
|
||||
}
|
||||
|
||||
gifAspectRatio(gif: KlipyGif): string {
|
||||
if (gif.width > 0 && gif.height > 0) {
|
||||
return `${gif.width} / ${gif.height}`;
|
||||
}
|
||||
|
||||
return '1 / 1';
|
||||
}
|
||||
|
||||
gifPreviewUrl(gif: KlipyGif): string {
|
||||
return this.klipy.buildRenderableImageUrl(gif.previewUrl || gif.url);
|
||||
}
|
||||
|
||||
private async loadResults(reset: boolean): Promise<void> {
|
||||
if (reset) {
|
||||
this.currentPage = 1;
|
||||
}
|
||||
|
||||
const requestId = ++this.requestId;
|
||||
|
||||
this.loading.set(true);
|
||||
this.errorMessage.set('');
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.klipy.searchGifs(this.searchQuery, this.currentPage)
|
||||
);
|
||||
|
||||
if (requestId !== this.requestId)
|
||||
return;
|
||||
|
||||
this.results.set(
|
||||
reset
|
||||
? response.results
|
||||
: this.mergeResults(this.results(), response.results)
|
||||
);
|
||||
|
||||
this.hasNext.set(response.hasNext);
|
||||
} catch (error) {
|
||||
if (requestId !== this.requestId)
|
||||
return;
|
||||
|
||||
this.errorMessage.set(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to load GIFs from KLIPY.'
|
||||
);
|
||||
|
||||
if (reset) {
|
||||
this.results.set([]);
|
||||
}
|
||||
|
||||
this.hasNext.set(false);
|
||||
} finally {
|
||||
if (requestId === this.requestId) {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mergeResults(existing: KlipyGif[], incoming: KlipyGif[]): KlipyGif[] {
|
||||
const seen = new Set(existing.map((gif) => gif.id));
|
||||
const merged = [...existing];
|
||||
|
||||
for (const gif of incoming) {
|
||||
if (seen.has(gif.id))
|
||||
continue;
|
||||
|
||||
merged.push(gif);
|
||||
seen.add(gif.id);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private clearSearchTimer(): void {
|
||||
if (this.searchTimer) {
|
||||
clearTimeout(this.searchTimer);
|
||||
this.searchTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@if (typingDisplay().length > 0) {
|
||||
<div class="px-4 py-2 backdrop-blur-sm bg-background/60">
|
||||
<span class="inline-block px-3 py-1 rounded-full text-sm text-muted-foreground">
|
||||
{{ typingDisplay().join(', ') }}
|
||||
@if (typingOthersCount() > 0) {
|
||||
and {{ typingOthersCount() }} others are typing...
|
||||
} @else {
|
||||
{{ typingDisplay().length === 1 ? 'is' : 'are' }} typing...
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
DestroyRef,
|
||||
effect
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||
import {
|
||||
merge,
|
||||
interval,
|
||||
filter,
|
||||
map,
|
||||
tap
|
||||
} from 'rxjs';
|
||||
|
||||
const TYPING_TTL = 3_000;
|
||||
const PURGE_INTERVAL = 1_000;
|
||||
const MAX_SHOWN = 4;
|
||||
|
||||
interface TypingSignalingMessage {
|
||||
type: string;
|
||||
displayName: string;
|
||||
oderId: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-typing-indicator',
|
||||
standalone: true,
|
||||
templateUrl: './typing-indicator.component.html',
|
||||
host: {
|
||||
'class': 'block',
|
||||
'style': 'background: linear-gradient(to bottom, transparent, hsl(var(--background)));'
|
||||
}
|
||||
})
|
||||
export class TypingIndicatorComponent {
|
||||
private readonly typingMap = new Map<string, { name: string; expiresAt: number }>();
|
||||
private readonly store = inject(Store);
|
||||
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
private lastRoomId: string | null = null;
|
||||
|
||||
typingDisplay = signal<string[]>([]);
|
||||
typingOthersCount = signal<number>(0);
|
||||
|
||||
constructor() {
|
||||
const webrtc = inject(RealtimeSessionFacade);
|
||||
const destroyRef = inject(DestroyRef);
|
||||
const typing$ = webrtc.onSignalingMessage.pipe(
|
||||
filter((msg): msg is TypingSignalingMessage =>
|
||||
msg?.type === 'user_typing' &&
|
||||
typeof msg.displayName === 'string' &&
|
||||
typeof msg.oderId === 'string' &&
|
||||
typeof msg.serverId === 'string'
|
||||
),
|
||||
filter((msg) => msg.serverId === this.currentRoom()?.id),
|
||||
tap((msg) => {
|
||||
const now = Date.now();
|
||||
|
||||
this.typingMap.set(msg.oderId, {
|
||||
name: msg.displayName,
|
||||
expiresAt: now + TYPING_TTL
|
||||
});
|
||||
})
|
||||
);
|
||||
const purge$ = interval(PURGE_INTERVAL).pipe(
|
||||
map(() => Date.now()),
|
||||
filter((now) => {
|
||||
let changed = false;
|
||||
|
||||
for (const [key, entry] of this.typingMap) {
|
||||
if (entry.expiresAt <= now) {
|
||||
this.typingMap.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
})
|
||||
);
|
||||
|
||||
merge(typing$, purge$)
|
||||
.pipe(takeUntilDestroyed(destroyRef))
|
||||
.subscribe(() => this.recomputeDisplay());
|
||||
|
||||
effect(() => {
|
||||
const roomId = this.currentRoom()?.id ?? null;
|
||||
|
||||
if (roomId === this.lastRoomId)
|
||||
return;
|
||||
|
||||
this.lastRoomId = roomId;
|
||||
this.typingMap.clear();
|
||||
this.recomputeDisplay();
|
||||
});
|
||||
}
|
||||
|
||||
private recomputeDisplay(): void {
|
||||
const now = Date.now();
|
||||
const names = Array.from(this.typingMap.values())
|
||||
.filter((e) => e.expiresAt > now)
|
||||
.map((e) => e.name);
|
||||
|
||||
this.typingDisplay.set(names.slice(0, MAX_SHOWN));
|
||||
this.typingOthersCount.set(Math.max(0, names.length - MAX_SHOWN));
|
||||
}
|
||||
}
|
||||
202
src/app/domains/chat/feature/user-list/user-list.component.html
Normal file
202
src/app/domains/chat/feature/user-list/user-list.component.html
Normal file
@@ -0,0 +1,202 @@
|
||||
<!-- 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)"
|
||||
(keydown.enter)="toggleUserMenu(user.id)"
|
||||
(keydown.space)="toggleUserMenu(user.id)"
|
||||
(keyup.enter)="toggleUserMenu(user.id)"
|
||||
(keyup.space)="toggleUserMenu(user.id)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- Avatar with online indicator -->
|
||||
<div class="relative">
|
||||
<app-user-avatar
|
||||
[name]="user.displayName"
|
||||
size="sm"
|
||||
/>
|
||||
<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()"
|
||||
(keydown)="$event.stopPropagation()"
|
||||
role="menu"
|
||||
tabindex="0"
|
||||
>
|
||||
@if (user.voiceState?.isConnected) {
|
||||
<button
|
||||
type="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
|
||||
type="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
|
||||
type="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>
|
||||
|
||||
<!-- Ban Dialog -->
|
||||
@if (showBanDialog()) {
|
||||
<app-confirm-dialog
|
||||
title="Ban User"
|
||||
confirmLabel="Ban User"
|
||||
variant="danger"
|
||||
[widthClass]="'w-96 max-w-[90vw]'"
|
||||
(confirmed)="confirmBan()"
|
||||
(cancelled)="closeBanDialog()"
|
||||
>
|
||||
<p class="mb-4">
|
||||
Are you sure you want to ban <span class="font-semibold text-foreground">{{ userToBan()?.displayName }}</span
|
||||
>?
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="ban-reason-input"
|
||||
class="block text-sm font-medium text-foreground mb-1"
|
||||
>Reason (optional)</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="banReason"
|
||||
placeholder="Enter ban reason..."
|
||||
id="ban-reason-input"
|
||||
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>
|
||||
<label
|
||||
for="ban-duration-select"
|
||||
class="block text-sm font-medium text-foreground mb-1"
|
||||
>Duration</label
|
||||
>
|
||||
<select
|
||||
[(ngModel)]="banDuration"
|
||||
id="ban-duration-select"
|
||||
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>
|
||||
</app-confirm-dialog>
|
||||
}
|
||||
139
src/app/domains/chat/feature/user-list/user-list.component.ts
Normal file
139
src/app/domains/chat/feature/user-list/user-list.component.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
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 { UsersActions } from '../../../../store/users/users.actions';
|
||||
import {
|
||||
selectOnlineUsers,
|
||||
selectCurrentUser,
|
||||
selectIsCurrentUserAdmin
|
||||
} from '../../../../store/users/users.selectors';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../../shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent,
|
||||
ConfirmDialogComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideMonitor,
|
||||
lucideShield,
|
||||
lucideCrown,
|
||||
lucideMoreVertical,
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideVolume2,
|
||||
lucideVolumeX
|
||||
})
|
||||
],
|
||||
templateUrl: './user-list.component.html'
|
||||
})
|
||||
/**
|
||||
* Displays the list of online users with voice state indicators and admin actions.
|
||||
*/
|
||||
export class UserListComponent {
|
||||
private store = inject(Store);
|
||||
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers) as import('@angular/core').Signal<User[]>;
|
||||
voiceUsers = computed(() => this.onlineUsers().filter((user: User) => !!user.voiceState?.isConnected));
|
||||
currentUser = this.store.selectSignal(selectCurrentUser) as import('@angular/core').Signal<User | undefined | null>;
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
|
||||
showUserMenu = signal<string | null>(null);
|
||||
showBanDialog = signal(false);
|
||||
userToBan = signal<User | null>(null);
|
||||
banReason = '';
|
||||
banDuration = '86400000'; // Default 1 day
|
||||
|
||||
/** Toggle the context menu for a specific user. */
|
||||
toggleUserMenu(userId: string): void {
|
||||
this.showUserMenu.update((current) => (current === userId ? null : userId));
|
||||
}
|
||||
|
||||
/** Check whether the given user is the currently authenticated user. */
|
||||
isCurrentUser(user: User): boolean {
|
||||
return user.id === this.currentUser()?.id;
|
||||
}
|
||||
|
||||
/** Toggle server-side mute on a user (admin action). */
|
||||
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);
|
||||
}
|
||||
|
||||
/** Kick a user from the server (admin action). */
|
||||
kickUser(user: User): void {
|
||||
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
||||
this.showUserMenu.set(null);
|
||||
}
|
||||
|
||||
/** Open the ban confirmation dialog for a user (admin action). */
|
||||
banUser(user: User): void {
|
||||
this.userToBan.set(user);
|
||||
this.showBanDialog.set(true);
|
||||
this.showUserMenu.set(null);
|
||||
}
|
||||
|
||||
/** Close the ban dialog and reset its form fields. */
|
||||
closeBanDialog(): void {
|
||||
this.showBanDialog.set(false);
|
||||
this.userToBan.set(null);
|
||||
this.banReason = '';
|
||||
this.banDuration = '86400000';
|
||||
}
|
||||
|
||||
/** Confirm the ban, dispatch the action with duration, and close the dialog. */
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1 +1,7 @@
|
||||
export * from './application/klipy.service';
|
||||
export * from './domain/message.rules';
|
||||
export * from './domain/message-sync.rules';
|
||||
export { ChatMessagesComponent } from './feature/chat-messages/chat-messages.component';
|
||||
export { TypingIndicatorComponent } from './feature/typing-indicator/typing-indicator.component';
|
||||
export { KlipyGifPickerComponent } from './feature/klipy-gif-picker/klipy-gif-picker.component';
|
||||
export { UserListComponent } from './feature/user-list/user-list.component';
|
||||
|
||||
137
src/app/domains/screen-share/README.md
Normal file
137
src/app/domains/screen-share/README.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Screen Share Domain
|
||||
|
||||
Manages screen sharing sessions, source selection (Electron), quality presets, and the viewer/workspace UI. Like `voice-connection`, the actual WebRTC track distribution lives in `infrastructure/realtime`; this domain provides the application-facing API and UI components.
|
||||
|
||||
## Module map
|
||||
|
||||
```
|
||||
screen-share/
|
||||
├── application/
|
||||
│ ├── screen-share.facade.ts Proxy to RealtimeSessionFacade for screen share signals and methods
|
||||
│ └── screen-share-source-picker.service.ts Electron desktop source picker (Promise-based open/confirm/cancel)
|
||||
│
|
||||
├── domain/
|
||||
│ └── screen-share.config.ts Quality presets and types (re-exported from shared-kernel)
|
||||
│
|
||||
├── feature/
|
||||
│ ├── screen-share-viewer/ Single-stream video player with fullscreen + volume
|
||||
│ └── screen-share-workspace/ Multi-stream grid workspace
|
||||
│ ├── screen-share-workspace.component.ts Grid layout, featured/thumbnail streams, mini-window mode
|
||||
│ ├── screen-share-stream-tile.component.ts Individual stream tile with fullscreen/volume controls
|
||||
│ ├── screen-share-playback.service.ts Per-user mute/volume state for screen share audio
|
||||
│ └── screen-share-workspace.models.ts ScreenShareWorkspaceStreamItem
|
||||
│
|
||||
└── index.ts Barrel exports
|
||||
```
|
||||
|
||||
## Service relationships
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
SSF[ScreenShareFacade]
|
||||
Picker[ScreenShareSourcePickerService]
|
||||
RSF[RealtimeSessionFacade]
|
||||
Config[screen-share.config]
|
||||
Viewer[ScreenShareViewerComponent]
|
||||
Workspace[ScreenShareWorkspaceComponent]
|
||||
Tile[ScreenShareStreamTileComponent]
|
||||
Playback[ScreenSharePlaybackService]
|
||||
|
||||
SSF --> RSF
|
||||
Viewer --> SSF
|
||||
Workspace --> SSF
|
||||
Workspace --> Playback
|
||||
Workspace --> Tile
|
||||
Picker --> Config
|
||||
|
||||
click SSF "application/screen-share.facade.ts" "Proxy to RealtimeSessionFacade"
|
||||
click Picker "application/screen-share-source-picker.service.ts" "Electron source picker"
|
||||
click RSF "../../infrastructure/realtime/realtime-session.service.ts" "Low-level WebRTC composition root"
|
||||
click Viewer "feature/screen-share-viewer/screen-share-viewer.component.ts" "Single-stream player"
|
||||
click Workspace "feature/screen-share-workspace/screen-share-workspace.component.ts" "Multi-stream workspace"
|
||||
click Tile "feature/screen-share-workspace/screen-share-stream-tile.component.ts" "Stream tile"
|
||||
click Playback "feature/screen-share-workspace/screen-share-playback.service.ts" "Per-user volume state"
|
||||
click Config "domain/screen-share.config.ts" "Quality presets"
|
||||
```
|
||||
|
||||
## Starting a screen share
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Controls as VoiceControls
|
||||
participant Facade as ScreenShareFacade
|
||||
participant Realtime as RealtimeSessionFacade
|
||||
participant Picker as SourcePickerService
|
||||
|
||||
User->>Controls: Click "Share Screen"
|
||||
|
||||
alt Electron
|
||||
Controls->>Picker: open(sources)
|
||||
Picker-->>Controls: selected source + includeSystemAudio
|
||||
end
|
||||
|
||||
Controls->>Facade: startScreenShare(options)
|
||||
Facade->>Realtime: startScreenShare(options)
|
||||
Note over Realtime: Captures screen via platform strategy
|
||||
Note over Realtime: Waits for SCREEN_SHARE_REQUEST from viewers
|
||||
Realtime-->>Facade: MediaStream
|
||||
|
||||
User->>Controls: Click "Stop"
|
||||
Controls->>Facade: stopScreenShare()
|
||||
Facade->>Realtime: stopScreenShare()
|
||||
```
|
||||
|
||||
## Source picker (Electron)
|
||||
|
||||
`ScreenShareSourcePickerService` manages a Promise-based flow for Electron desktop capture. `open()` sets a signal with the available sources, and the UI renders a picker dialog. When the user selects a source, `confirm(sourceId, includeSystemAudio)` resolves the Promise. `cancel()` rejects with an `AbortError`.
|
||||
|
||||
Sources are classified as either `screen` or `window` based on the source ID prefix or name. The `includeSystemAudio` preference is persisted to voice settings storage.
|
||||
|
||||
## Quality presets
|
||||
|
||||
Screen share quality is configured through presets defined in the shared kernel:
|
||||
|
||||
| Preset | Resolution | Framerate |
|
||||
|---|---|---|
|
||||
| `low` | Reduced | Lower FPS |
|
||||
| `balanced` | Medium | Medium FPS |
|
||||
| `high` | Full | High FPS |
|
||||
|
||||
The quality dialog can be shown before each share (`askScreenShareQuality` setting) or skipped to use the last chosen preset.
|
||||
|
||||
## Viewer component
|
||||
|
||||
`ScreenShareViewerComponent` is a single-stream video player. It supports:
|
||||
|
||||
- Fullscreen toggle (browser Fullscreen API with CSS fallback)
|
||||
- Volume control for remote streams (delegated to `VoicePlaybackService`)
|
||||
- Local shares are always muted to avoid feedback
|
||||
- Focus events from other components via a `viewer:focus` custom DOM event
|
||||
- Auto-stop when the watched user stops sharing or the stream's video tracks end
|
||||
|
||||
## Workspace component
|
||||
|
||||
`ScreenShareWorkspaceComponent` is the multi-stream grid view inside the voice workspace panel. It handles:
|
||||
|
||||
- Listing all active screen shares (local + remote) sorted with remote first
|
||||
- Featured/widescreen mode for a single focused stream with thumbnail sidebar
|
||||
- Mini-window mode (draggable, position-clamped to viewport)
|
||||
- Auto-hide header chrome in widescreen mode (2.2 s timeout, revealed on pointer move)
|
||||
- On-demand remote stream requests via `syncRemoteScreenShareRequests`
|
||||
- Per-stream volume and mute via `ScreenSharePlaybackService`
|
||||
- Voice controls (mute, deafen, disconnect, share toggle) integrated into the workspace header
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Hidden
|
||||
Hidden --> Expanded: open()
|
||||
Expanded --> GridView: multiple shares, no focus
|
||||
Expanded --> WidescreenView: single share or focused stream
|
||||
WidescreenView --> GridView: showAllStreams()
|
||||
GridView --> WidescreenView: focusShare(peerKey)
|
||||
Expanded --> Minimized: minimize()
|
||||
Minimized --> Expanded: restore()
|
||||
Expanded --> Hidden: close()
|
||||
Minimized --> Hidden: close()
|
||||
```
|
||||
@@ -1,81 +1,21 @@
|
||||
export type ScreenShareQuality = 'performance' | 'balanced' | 'high-fps' | 'quality';
|
||||
import {
|
||||
DEFAULT_SCREEN_SHARE_QUALITY,
|
||||
DEFAULT_SCREEN_SHARE_START_OPTIONS,
|
||||
ELECTRON_ENTIRE_SCREEN_SOURCE_NAME,
|
||||
SCREEN_SHARE_QUALITY_OPTIONS,
|
||||
SCREEN_SHARE_QUALITY_PRESETS,
|
||||
type ScreenShareQualityPreset,
|
||||
type ScreenShareStartOptions,
|
||||
type ScreenShareQuality
|
||||
} from '../../../shared-kernel';
|
||||
|
||||
export interface ScreenShareStartOptions {
|
||||
includeSystemAudio: boolean;
|
||||
quality: ScreenShareQuality;
|
||||
}
|
||||
|
||||
export interface ScreenShareQualityPreset {
|
||||
label: string;
|
||||
description: string;
|
||||
width: number;
|
||||
height: number;
|
||||
frameRate: number;
|
||||
maxBitrateBps: number;
|
||||
contentHint: 'motion' | 'detail';
|
||||
degradationPreference: 'maintain-framerate' | 'maintain-resolution';
|
||||
scaleResolutionDownBy?: number;
|
||||
}
|
||||
|
||||
export const ELECTRON_ENTIRE_SCREEN_SOURCE_NAME = 'Entire Screen';
|
||||
|
||||
export const DEFAULT_SCREEN_SHARE_QUALITY: ScreenShareQuality = 'balanced';
|
||||
|
||||
export const DEFAULT_SCREEN_SHARE_START_OPTIONS: ScreenShareStartOptions = {
|
||||
includeSystemAudio: false,
|
||||
quality: DEFAULT_SCREEN_SHARE_QUALITY
|
||||
export {
|
||||
DEFAULT_SCREEN_SHARE_QUALITY,
|
||||
DEFAULT_SCREEN_SHARE_START_OPTIONS,
|
||||
ELECTRON_ENTIRE_SCREEN_SOURCE_NAME,
|
||||
SCREEN_SHARE_QUALITY_OPTIONS,
|
||||
SCREEN_SHARE_QUALITY_PRESETS,
|
||||
type ScreenShareQualityPreset,
|
||||
type ScreenShareStartOptions,
|
||||
type ScreenShareQuality
|
||||
};
|
||||
|
||||
export const SCREEN_SHARE_QUALITY_PRESETS: Record<ScreenShareQuality, ScreenShareQualityPreset> = {
|
||||
performance: {
|
||||
label: 'Performance saver',
|
||||
description: '720p / 30 FPS with lower CPU and bandwidth usage.',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
frameRate: 30,
|
||||
maxBitrateBps: 2_000_000,
|
||||
contentHint: 'motion',
|
||||
degradationPreference: 'maintain-framerate',
|
||||
scaleResolutionDownBy: 1
|
||||
},
|
||||
balanced: {
|
||||
label: 'Balanced',
|
||||
description: '1080p / 30 FPS for stable quality in most cases.',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
frameRate: 30,
|
||||
maxBitrateBps: 4_000_000,
|
||||
contentHint: 'detail',
|
||||
degradationPreference: 'maintain-resolution',
|
||||
scaleResolutionDownBy: 1
|
||||
},
|
||||
'high-fps': {
|
||||
label: 'High FPS',
|
||||
description: '1080p / 60 FPS for games and fast motion.',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
frameRate: 60,
|
||||
maxBitrateBps: 6_000_000,
|
||||
contentHint: 'motion',
|
||||
degradationPreference: 'maintain-framerate',
|
||||
scaleResolutionDownBy: 1
|
||||
},
|
||||
quality: {
|
||||
label: 'Sharp text',
|
||||
description: '1440p / 30 FPS for detailed UI and text clarity.',
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
frameRate: 30,
|
||||
maxBitrateBps: 8_000_000,
|
||||
contentHint: 'detail',
|
||||
degradationPreference: 'maintain-resolution',
|
||||
scaleResolutionDownBy: 1
|
||||
}
|
||||
};
|
||||
|
||||
export const SCREEN_SHARE_QUALITY_OPTIONS = (
|
||||
Object.entries(SCREEN_SHARE_QUALITY_PRESETS) as [ScreenShareQuality, ScreenShareQualityPreset][]
|
||||
).map(([id, preset]) => ({
|
||||
id,
|
||||
...preset
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<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"
|
||||
/>
|
||||
@if (activeScreenSharer()) {
|
||||
<span class="text-sm font-medium"> {{ activeScreenSharer()?.displayName }} is sharing their screen </span>
|
||||
} @else {
|
||||
<span class="text-sm font-medium">Someone is sharing their screen</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Viewer volume -->
|
||||
<div class="flex items-center gap-2 text-white">
|
||||
<span class="text-xs opacity-80">Volume: {{ screenVolume() }}%</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
[value]="screenVolume()"
|
||||
(input)="onScreenVolumeChange($event)"
|
||||
class="w-32 accent-white"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
(click)="toggleFullscreen()"
|
||||
type="button"
|
||||
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()"
|
||||
type="button"
|
||||
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
|
||||
title="Stop sharing"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="w-4 h-4 text-white"
|
||||
/>
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
(click)="stopWatching()"
|
||||
type="button"
|
||||
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
|
||||
title="Stop watching"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="w-4 h-4 text-white"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Stream Placeholder -->
|
||||
@if (!hasStream()) {
|
||||
<div 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>
|
||||
@@ -0,0 +1,279 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
||||
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 { ScreenShareFacade } from '../../application/screen-share.facade';
|
||||
import { selectOnlineUsers } from '../../../../store/users/users.selectors';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { DEFAULT_VOLUME } from '../../../../core/constants';
|
||||
import { VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-screen-share-viewer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMaximize,
|
||||
lucideMinimize,
|
||||
lucideX,
|
||||
lucideMonitor
|
||||
})
|
||||
],
|
||||
templateUrl: './screen-share-viewer.component.html'
|
||||
})
|
||||
/**
|
||||
* Displays a local or remote screen-share stream in a video player.
|
||||
* Supports fullscreen toggling, volume control, and viewer focus events.
|
||||
*/
|
||||
export class ScreenShareViewerComponent implements OnDestroy {
|
||||
@ViewChild('screenVideo') videoRef!: ElementRef<HTMLVideoElement>;
|
||||
|
||||
private readonly screenShareService = inject(ScreenShareFacade);
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly store = inject(Store);
|
||||
private remoteStreamSub: Subscription | null = null;
|
||||
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
|
||||
activeScreenSharer = signal<User | null>(null);
|
||||
// Track the userId we're currently watching (for detecting when they stop sharing)
|
||||
private watchingUserId = signal<string | null>(null);
|
||||
isFullscreen = signal(false);
|
||||
hasStream = signal(false);
|
||||
isLocalShare = signal(false);
|
||||
screenVolume = signal(DEFAULT_VOLUME);
|
||||
|
||||
private streamSubscription: (() => void) | null = null;
|
||||
private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => {
|
||||
try {
|
||||
const userId = evt.detail?.userId;
|
||||
|
||||
if (!userId)
|
||||
return;
|
||||
|
||||
const stream = this.screenShareService.getRemoteScreenShareStream(userId);
|
||||
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.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.videoRef.nativeElement.volume = 0;
|
||||
this.videoRef.nativeElement.muted = true;
|
||||
this.hasStream.set(true);
|
||||
this.activeScreenSharer.set(null);
|
||||
this.watchingUserId.set(userId);
|
||||
this.screenVolume.set(this.voicePlayback.getUserVolume(userId));
|
||||
this.isLocalShare.set(false);
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// Failed to focus viewer on user stream
|
||||
}
|
||||
};
|
||||
|
||||
constructor() {
|
||||
// React to screen share stream changes
|
||||
effect(() => {
|
||||
const screenStream = this.screenShareService.screenStream();
|
||||
|
||||
if (screenStream && this.videoRef) {
|
||||
// Local share: always mute to avoid audio feedback
|
||||
this.videoRef.nativeElement.srcObject = screenStream;
|
||||
this.videoRef.nativeElement.volume = 0;
|
||||
this.videoRef.nativeElement.muted = true;
|
||||
this.isLocalShare.set(true);
|
||||
this.hasStream.set(true);
|
||||
} else if (this.videoRef) {
|
||||
this.videoRef.nativeElement.srcObject = null;
|
||||
this.isLocalShare.set(false);
|
||||
this.hasStream.set(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for when the user we're watching stops sharing
|
||||
effect(() => {
|
||||
const watchingId = this.watchingUserId();
|
||||
const isWatchingRemote = this.hasStream() && !this.isLocalShare();
|
||||
|
||||
// Only check if we're actually watching a remote stream
|
||||
if (!watchingId || !isWatchingRemote)
|
||||
return;
|
||||
|
||||
const users = this.onlineUsers();
|
||||
const watchedUser = users.find(user => user.id === watchingId || user.oderId === watchingId);
|
||||
|
||||
// If the user is no longer sharing (screenShareState.isSharing is false), stop watching
|
||||
if (watchedUser && watchedUser.screenShareState?.isSharing === false) {
|
||||
this.stopWatching();
|
||||
return;
|
||||
}
|
||||
|
||||
// Also check if the stream's video tracks are still available
|
||||
const stream = this.screenShareService.getRemoteScreenShareStream(watchingId);
|
||||
const hasActiveVideo = stream?.getVideoTracks().some(track => track.readyState === 'live');
|
||||
|
||||
if (!hasActiveVideo) {
|
||||
// Stream or video tracks are gone - stop watching
|
||||
this.stopWatching();
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to remote streams with video (screen shares)
|
||||
// NOTE: We no longer auto-display remote streams. Users must click "Live" to view.
|
||||
// This subscription is kept for potential future use (e.g., tracking available streams)
|
||||
this.remoteStreamSub = this.screenShareService.onRemoteStream.subscribe(({ peerId }) => {
|
||||
if (peerId !== this.watchingUserId() || this.isLocalShare()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = this.screenShareService.getRemoteScreenShareStream(peerId);
|
||||
const hasActiveVideo = stream?.getVideoTracks().some((track) => track.readyState === 'live') ?? false;
|
||||
|
||||
if (!hasActiveVideo) {
|
||||
this.stopWatching();
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/** Toggle between fullscreen and windowed display. */
|
||||
toggleFullscreen(): void {
|
||||
if (this.isFullscreen()) {
|
||||
this.exitFullscreen();
|
||||
} else {
|
||||
this.enterFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
/** Enter fullscreen mode, requesting browser fullscreen if available. */
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Exit fullscreen mode. */
|
||||
exitFullscreen(): void {
|
||||
this.isFullscreen.set(false);
|
||||
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop the local screen share and reset viewer state. */
|
||||
stopSharing(): void {
|
||||
this.screenShareService.stopScreenShare();
|
||||
this.activeScreenSharer.set(null);
|
||||
this.hasStream.set(false);
|
||||
this.isLocalShare.set(false);
|
||||
}
|
||||
|
||||
/** Stop watching a remote stream and reset the viewer. */
|
||||
// Stop watching a remote stream (for viewers)
|
||||
stopWatching(): void {
|
||||
if (this.videoRef) {
|
||||
this.videoRef.nativeElement.srcObject = null;
|
||||
}
|
||||
|
||||
this.activeScreenSharer.set(null);
|
||||
this.watchingUserId.set(null);
|
||||
this.hasStream.set(false);
|
||||
this.isLocalShare.set(false);
|
||||
|
||||
if (this.isFullscreen()) {
|
||||
this.exitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
/** Attach and play a remote peer's screen-share stream. */
|
||||
// Called by parent when a remote peer starts sharing
|
||||
setRemoteStream(stream: MediaStream, user: User): void {
|
||||
this.activeScreenSharer.set(user);
|
||||
this.watchingUserId.set(user.id || user.oderId || null);
|
||||
this.isLocalShare.set(false);
|
||||
this.screenVolume.set(this.voicePlayback.getUserVolume(user.id || user.oderId || ''));
|
||||
|
||||
if (this.videoRef) {
|
||||
const el = this.videoRef.nativeElement;
|
||||
|
||||
el.srcObject = stream;
|
||||
// Keep the viewer muted so screen-share audio only plays once via VoicePlaybackService.
|
||||
el.muted = true;
|
||||
el.volume = 0;
|
||||
el.play().catch(() => {});
|
||||
|
||||
this.hasStream.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Attach and play the local user's screen-share stream (always muted). */
|
||||
// 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;
|
||||
// Always mute local share playback
|
||||
this.videoRef.nativeElement.volume = 0;
|
||||
this.videoRef.nativeElement.muted = true;
|
||||
this.hasStream.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle volume slider changes, applying only to remote streams. */
|
||||
onScreenVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const val = Math.max(0, Math.min(200, parseInt(input.value, 10)));
|
||||
|
||||
this.screenVolume.set(val);
|
||||
|
||||
if (!this.isLocalShare()) {
|
||||
const userId = this.watchingUserId();
|
||||
|
||||
if (userId) {
|
||||
this.voicePlayback.setUserVolume(userId, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
interface ScreenSharePlaybackSettings {
|
||||
muted: boolean;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: ScreenSharePlaybackSettings = {
|
||||
muted: false,
|
||||
volume: 100
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ScreenSharePlaybackService {
|
||||
private readonly _settings = signal<ReadonlyMap<string, ScreenSharePlaybackSettings>>(new Map());
|
||||
|
||||
settings(): ReadonlyMap<string, ScreenSharePlaybackSettings> {
|
||||
return this._settings();
|
||||
}
|
||||
|
||||
getUserVolume(peerId: string): number {
|
||||
return this._settings().get(peerId)?.volume ?? DEFAULT_SETTINGS.volume;
|
||||
}
|
||||
|
||||
isUserMuted(peerId: string): boolean {
|
||||
return this._settings().get(peerId)?.muted ?? DEFAULT_SETTINGS.muted;
|
||||
}
|
||||
|
||||
setUserVolume(peerId: string, volume: number): void {
|
||||
const nextVolume = Math.max(0, Math.min(100, volume));
|
||||
const current = this._settings().get(peerId) ?? DEFAULT_SETTINGS;
|
||||
|
||||
this._settings.update((settings) => {
|
||||
const next = new Map(settings);
|
||||
|
||||
next.set(peerId, {
|
||||
...current,
|
||||
muted: nextVolume === 0 ? current.muted : false,
|
||||
volume: nextVolume
|
||||
});
|
||||
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
setUserMuted(peerId: string, muted: boolean): void {
|
||||
const current = this._settings().get(peerId) ?? DEFAULT_SETTINGS;
|
||||
|
||||
this._settings.update((settings) => {
|
||||
const next = new Map(settings);
|
||||
|
||||
next.set(peerId, {
|
||||
...current,
|
||||
muted
|
||||
});
|
||||
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
resetUser(peerId: string): void {
|
||||
this._settings.update((settings) => {
|
||||
if (!settings.has(peerId)) {
|
||||
return settings;
|
||||
}
|
||||
|
||||
const next = new Map(settings);
|
||||
|
||||
next.delete(peerId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
teardownAll(): void {
|
||||
// Screen-share audio is played directly by the video element.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
<div
|
||||
#tileRoot
|
||||
class="group relative flex h-full min-h-0 flex-col overflow-hidden bg-black/85 transition duration-200"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
[attr.aria-label]="mini() ? 'Focus ' + displayName() + ' stream' : 'Open ' + displayName() + ' stream in widescreen mode'"
|
||||
[attr.title]="canToggleFullscreen() ? (isFullscreen() ? 'Double-click to exit fullscreen' : 'Double-click for fullscreen') : null"
|
||||
[ngClass]="{
|
||||
'ring-2 ring-primary/70': focused() && !immersive() && !mini() && !isFullscreen(),
|
||||
'min-h-[24rem] rounded-[1.75rem] border border-white/10 shadow-2xl': featured() && !compact() && !immersive() && !mini() && !isFullscreen(),
|
||||
'rounded-[1.75rem] border border-white/10 shadow-2xl': !featured() && !compact() && !immersive() && !mini() && !isFullscreen(),
|
||||
'rounded-2xl border border-white/10 shadow-2xl': compact() && !immersive() && !mini() && !isFullscreen(),
|
||||
'rounded-2xl border border-white/10 shadow-xl': mini() && !isFullscreen(),
|
||||
'shadow-none': immersive() || isFullscreen()
|
||||
}"
|
||||
(click)="requestFocus()"
|
||||
(dblclick)="onTileDoubleClick($event)"
|
||||
(mousemove)="onTilePointerMove()"
|
||||
(keydown.enter)="requestFocus()"
|
||||
(keydown.space)="requestFocus(); $event.preventDefault()"
|
||||
>
|
||||
<video
|
||||
#streamVideo
|
||||
autoplay
|
||||
playsinline
|
||||
class="absolute inset-0 h-full w-full bg-black object-contain"
|
||||
></video>
|
||||
|
||||
<div class="pointer-events-none absolute inset-0 bg-gradient-to-b from-black/70 via-black/10 to-black/80"></div>
|
||||
|
||||
@if (isFullscreen()) {
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-3 top-3 z-20 transition-all duration-300 sm:inset-x-4 sm:top-4"
|
||||
[class.opacity-0]="!showFullscreenHeader()"
|
||||
[class.translate-y-[-12px]]="!showFullscreenHeader()"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-auto flex items-center gap-3 rounded-2xl border border-white/10 bg-black/45 px-4 py-3 backdrop-blur-lg"
|
||||
[class.pointer-events-none]="!showFullscreenHeader()"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<app-user-avatar
|
||||
[name]="displayName()"
|
||||
[avatarUrl]="item().user.avatarUrl"
|
||||
size="xs"
|
||||
/>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="truncate text-sm font-semibold text-white sm:text-base">{{ displayName() }}</p>
|
||||
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-primary"> Live </span>
|
||||
</div>
|
||||
|
||||
<p class="mt-1 text-xs text-white/60">
|
||||
{{ item().isLocal ? 'Local preview in fullscreen' : 'Fullscreen stream view' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!item().isLocal) {
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/45 text-white/75 transition hover:bg-black/60 hover:text-white"
|
||||
[title]="muted() ? 'Unmute stream audio' : 'Mute stream audio'"
|
||||
(click)="toggleMuted(); $event.stopPropagation()"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="muted() ? 'lucideVolumeX' : 'lucideVolume2'"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/45 text-white/75 transition hover:bg-black/60 hover:text-white"
|
||||
title="Exit fullscreen"
|
||||
(click)="exitFullscreen($event)"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMinimize"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (mini()) {
|
||||
<div class="absolute inset-x-0 bottom-0 p-2">
|
||||
<div class="rounded-xl border border-white/10 bg-black/55 px-2.5 py-2 backdrop-blur-md">
|
||||
<div class="flex items-center gap-2">
|
||||
<app-user-avatar
|
||||
[name]="displayName()"
|
||||
[avatarUrl]="item().user.avatarUrl"
|
||||
size="xs"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs font-semibold text-white">{{ displayName() }}</p>
|
||||
<p class="text-[10px] uppercase tracking-[0.16em] text-white/60">Live stream</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (!immersive()) {
|
||||
<div
|
||||
class="absolute left-4 top-4 flex items-center gap-3 bg-black/50 backdrop-blur-md"
|
||||
[ngClass]="compact() ? 'max-w-[calc(100%-5rem)] rounded-xl px-2.5 py-2' : 'max-w-[calc(100%-8rem)] rounded-full px-3 py-2'"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="displayName()"
|
||||
[avatarUrl]="item().user.avatarUrl"
|
||||
size="xs"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<p
|
||||
class="truncate font-semibold text-white"
|
||||
[class.text-xs]="compact()"
|
||||
[class.text-sm]="!compact()"
|
||||
>
|
||||
{{ displayName() }}
|
||||
</p>
|
||||
<p
|
||||
class="flex items-center gap-1 uppercase text-white/65"
|
||||
[class.text-[10px]]="compact()"
|
||||
[class.text-[11px]]="!compact()"
|
||||
[class.tracking-[0.18em]]="compact()"
|
||||
[class.tracking-[0.24em]]="!compact()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
class="h-3 w-3"
|
||||
/>
|
||||
Live
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute right-4 top-4 flex items-center gap-2 opacity-100 transition md:opacity-0 md:group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-full border border-white/15 bg-black/45 text-white/90 backdrop-blur-md transition hover:bg-black/65"
|
||||
[class.h-8]="compact()"
|
||||
[class.w-8]="compact()"
|
||||
[class.h-10]="!compact()"
|
||||
[class.w-10]="!compact()"
|
||||
[title]="focused() ? 'Viewing in widescreen' : 'View in widescreen'"
|
||||
(click)="requestFocus(); $event.stopPropagation()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMaximize"
|
||||
[class.h-3.5]="compact()"
|
||||
[class.w-3.5]="compact()"
|
||||
[class.h-4]="!compact()"
|
||||
[class.w-4]="!compact()"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@if (!item().isLocal) {
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-full border border-white/15 bg-black/45 text-white/90 backdrop-blur-md transition hover:bg-black/65"
|
||||
[class.h-8]="compact()"
|
||||
[class.w-8]="compact()"
|
||||
[class.h-10]="!compact()"
|
||||
[class.w-10]="!compact()"
|
||||
[title]="muted() ? 'Unmute stream audio' : 'Mute stream audio'"
|
||||
(click)="toggleMuted(); $event.stopPropagation()"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="muted() ? 'lucideVolumeX' : 'lucideVolume2'"
|
||||
[class.h-3.5]="compact()"
|
||||
[class.w-3.5]="compact()"
|
||||
[class.h-4]="!compact()"
|
||||
[class.w-4]="!compact()"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="absolute inset-x-0 bottom-0 p-4">
|
||||
@if (item().isLocal) {
|
||||
@if (!compact()) {
|
||||
<div class="rounded-2xl bg-black/50 px-4 py-3 text-xs text-white/75 backdrop-blur-md">
|
||||
Your preview stays muted locally to avoid audio feedback.
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
@if (compact()) {
|
||||
<div class="rounded-xl bg-black/50 px-3 py-2 text-[11px] text-white/80 backdrop-blur-md">
|
||||
{{ muted() ? 'Muted' : volume() + '% audio' }}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="rounded-2xl bg-black/50 px-4 py-3 backdrop-blur-md">
|
||||
<div class="mb-2 flex items-center justify-between text-xs text-white/80">
|
||||
<span class="flex items-center gap-2 font-medium">
|
||||
<ng-icon
|
||||
[name]="muted() ? 'lucideVolumeX' : 'lucideVolume2'"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
Stream audio
|
||||
</span>
|
||||
<span>{{ muted() ? 'Muted' : volume() + '%' }}</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
[value]="volume()"
|
||||
class="w-full accent-primary"
|
||||
(click)="$event.stopPropagation()"
|
||||
(input)="updateVolume($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,263 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
effect,
|
||||
HostListener,
|
||||
inject,
|
||||
input,
|
||||
OnDestroy,
|
||||
output,
|
||||
signal,
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideMaximize,
|
||||
lucideMinimize,
|
||||
lucideMonitor,
|
||||
lucideVolume2,
|
||||
lucideVolumeX
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { UserAvatarComponent } from '../../../../shared';
|
||||
import { ScreenSharePlaybackService } from './screen-share-playback.service';
|
||||
import { ScreenShareWorkspaceStreamItem } from './screen-share-workspace.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-screen-share-stream-tile',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMaximize,
|
||||
lucideMinimize,
|
||||
lucideMonitor,
|
||||
lucideVolume2,
|
||||
lucideVolumeX
|
||||
})
|
||||
],
|
||||
templateUrl: './screen-share-stream-tile.component.html',
|
||||
host: {
|
||||
class: 'block h-full'
|
||||
}
|
||||
})
|
||||
export class ScreenShareStreamTileComponent implements OnDestroy {
|
||||
private readonly screenSharePlayback = inject(ScreenSharePlaybackService);
|
||||
private fullscreenHeaderHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
readonly item = input.required<ScreenShareWorkspaceStreamItem>();
|
||||
readonly focused = input(false);
|
||||
readonly featured = input(false);
|
||||
readonly compact = input(false);
|
||||
readonly mini = input(false);
|
||||
readonly immersive = input(false);
|
||||
readonly focusRequested = output<string>();
|
||||
readonly tileRef = viewChild<ElementRef<HTMLElement>>('tileRoot');
|
||||
readonly videoRef = viewChild<ElementRef<HTMLVideoElement>>('streamVideo');
|
||||
|
||||
readonly isFullscreen = signal(false);
|
||||
readonly showFullscreenHeader = signal(true);
|
||||
readonly volume = signal(100);
|
||||
readonly muted = signal(false);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const ref = this.videoRef();
|
||||
const item = this.item();
|
||||
|
||||
if (!ref) {
|
||||
return;
|
||||
}
|
||||
|
||||
const video = ref.nativeElement;
|
||||
|
||||
if (video.srcObject !== item.stream) {
|
||||
video.srcObject = item.stream;
|
||||
}
|
||||
|
||||
void video.play().catch(() => {});
|
||||
});
|
||||
|
||||
effect(
|
||||
() => {
|
||||
this.screenSharePlayback.settings();
|
||||
|
||||
const item = this.item();
|
||||
|
||||
if (item.isLocal) {
|
||||
this.volume.set(0);
|
||||
this.muted.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.volume.set(this.screenSharePlayback.getUserVolume(item.peerKey));
|
||||
this.muted.set(this.screenSharePlayback.isUserMuted(item.peerKey));
|
||||
},
|
||||
{ allowSignalWrites: true }
|
||||
);
|
||||
|
||||
effect(() => {
|
||||
const ref = this.videoRef();
|
||||
const item = this.item();
|
||||
const muted = this.muted();
|
||||
const volume = this.volume();
|
||||
|
||||
if (!ref) {
|
||||
return;
|
||||
}
|
||||
|
||||
const video = ref.nativeElement;
|
||||
|
||||
if (item.isLocal) {
|
||||
video.muted = true;
|
||||
video.volume = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
video.muted = muted;
|
||||
video.volume = Math.max(0, Math.min(1, volume / 100));
|
||||
void video.play().catch(() => {});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@HostListener('document:fullscreenchange')
|
||||
onFullscreenChange(): void {
|
||||
const tile = this.tileRef()?.nativeElement;
|
||||
const isFullscreen = !!tile && document.fullscreenElement === tile;
|
||||
|
||||
this.isFullscreen.set(isFullscreen);
|
||||
|
||||
if (isFullscreen) {
|
||||
this.revealFullscreenHeader();
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearFullscreenHeaderHideTimeout();
|
||||
this.showFullscreenHeader.set(true);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.clearFullscreenHeaderHideTimeout();
|
||||
|
||||
const tile = this.tileRef()?.nativeElement;
|
||||
|
||||
if (tile && document.fullscreenElement === tile) {
|
||||
void document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
canToggleFullscreen(): boolean {
|
||||
return !this.mini() && !this.compact() && (this.immersive() || this.focused());
|
||||
}
|
||||
|
||||
onTilePointerMove(): void {
|
||||
if (!this.isFullscreen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.revealFullscreenHeader();
|
||||
}
|
||||
|
||||
async onTileDoubleClick(event: MouseEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (!this.canToggleFullscreen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tile = this.tileRef()?.nativeElement;
|
||||
|
||||
if (!tile || !tile.requestFullscreen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.fullscreenElement === tile) {
|
||||
await document.exitFullscreen().catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
await tile.requestFullscreen().catch(() => {});
|
||||
}
|
||||
|
||||
async exitFullscreen(event?: Event): Promise<void> {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
if (!this.isFullscreen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
|
||||
requestFocus(): void {
|
||||
this.focusRequested.emit(this.item().peerKey);
|
||||
}
|
||||
|
||||
toggleMuted(): void {
|
||||
const item = this.item();
|
||||
|
||||
if (item.isLocal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextMuted = !this.muted();
|
||||
|
||||
this.muted.set(nextMuted);
|
||||
this.screenSharePlayback.setUserMuted(item.peerKey, nextMuted);
|
||||
}
|
||||
|
||||
updateVolume(event: Event): void {
|
||||
const item = this.item();
|
||||
|
||||
if (item.isLocal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const input = event.target as HTMLInputElement;
|
||||
const nextVolume = Math.max(0, Math.min(100, parseInt(input.value, 10) || 0));
|
||||
|
||||
this.volume.set(nextVolume);
|
||||
this.screenSharePlayback.setUserVolume(item.peerKey, nextVolume);
|
||||
|
||||
if (nextVolume > 0 && this.muted()) {
|
||||
this.muted.set(false);
|
||||
this.screenSharePlayback.setUserMuted(item.peerKey, false);
|
||||
}
|
||||
}
|
||||
|
||||
displayName(): string {
|
||||
return this.item().isLocal ? 'You' : this.item().user.displayName;
|
||||
}
|
||||
|
||||
private scheduleFullscreenHeaderHide(): void {
|
||||
this.clearFullscreenHeaderHideTimeout();
|
||||
|
||||
this.fullscreenHeaderHideTimeoutId = setTimeout(() => {
|
||||
this.showFullscreenHeader.set(false);
|
||||
this.fullscreenHeaderHideTimeoutId = null;
|
||||
}, 2200);
|
||||
}
|
||||
|
||||
private revealFullscreenHeader(): void {
|
||||
this.showFullscreenHeader.set(true);
|
||||
this.scheduleFullscreenHeaderHide();
|
||||
}
|
||||
|
||||
private clearFullscreenHeaderHideTimeout(): void {
|
||||
if (this.fullscreenHeaderHideTimeoutId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this.fullscreenHeaderHideTimeoutId);
|
||||
this.fullscreenHeaderHideTimeoutId = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
||||
<div class="absolute inset-0">
|
||||
@if (showExpanded()) {
|
||||
<section
|
||||
class="pointer-events-auto absolute inset-0 bg-background/95 backdrop-blur-xl"
|
||||
(mouseenter)="onWorkspacePointerMove()"
|
||||
(mousemove)="onWorkspacePointerMove()"
|
||||
>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="relative flex-1 min-h-0 overflow-hidden">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-3 top-3 z-10 transition-all duration-300 sm:inset-x-4 sm:top-4"
|
||||
[class.opacity-0]="!showWorkspaceHeader()"
|
||||
[class.translate-y-[-12px]]="!showWorkspaceHeader()"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-auto flex flex-wrap items-center gap-3 rounded-2xl border border-white/10 bg-black/45 px-4 py-3 backdrop-blur-lg"
|
||||
[class.pointer-events-none]="!showWorkspaceHeader()"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2 class="truncate text-sm font-semibold text-white sm:text-base">{{ connectedVoiceChannelName() }}</h2>
|
||||
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-primary">
|
||||
Streams
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-white/65">
|
||||
<span>{{ serverName() }}</span>
|
||||
<span class="h-1 w-1 rounded-full bg-white/25"></span>
|
||||
<span>{{ connectedVoiceUsers().length }} in voice</span>
|
||||
<span class="h-1 w-1 rounded-full bg-white/25"></span>
|
||||
<span>{{ liveShareCount() }} live {{ liveShareCount() === 1 ? 'stream' : 'streams' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (connectedVoiceUsers().length > 0) {
|
||||
<div class="hidden items-center gap-2 lg:flex">
|
||||
@for (participant of connectedVoiceUsers().slice(0, 4); track trackUser($index, participant)) {
|
||||
<app-user-avatar
|
||||
[name]="participant.displayName"
|
||||
[avatarUrl]="participant.avatarUrl"
|
||||
size="xs"
|
||||
[ringClass]="'ring-2 ring-white/10'"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (connectedVoiceUsers().length > 4) {
|
||||
<div class="rounded-full bg-white/10 px-2.5 py-1 text-[11px] font-medium text-white/70">
|
||||
+{{ connectedVoiceUsers().length - 4 }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isWidescreenMode() && widescreenShare()) {
|
||||
<div class="flex min-w-0 items-center gap-2 rounded-2xl border border-white/10 bg-black/35 px-2.5 py-2 text-white/85">
|
||||
<app-user-avatar
|
||||
[name]="focusedShareTitle()"
|
||||
[avatarUrl]="widescreenShare()!.user.avatarUrl"
|
||||
size="xs"
|
||||
/>
|
||||
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-xs font-semibold text-white">{{ focusedShareTitle() }}</p>
|
||||
<p class="text-[10px] uppercase tracking-[0.18em] text-white/55">
|
||||
{{ widescreenShare()!.isLocal ? 'Local preview' : 'Focused stream' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (focusedAudioShare()) {
|
||||
<div class="mx-1 hidden h-6 w-px bg-white/10 sm:block"></div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-black/45 text-white/75 transition hover:bg-black/60 hover:text-white"
|
||||
[title]="focusedShareMuted() ? 'Unmute stream audio' : 'Mute stream audio'"
|
||||
(click)="toggleFocusedShareMuted()"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="focusedShareMuted() ? 'lucideVolumeX' : 'lucideVolume2'"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
[value]="focusedShareVolume()"
|
||||
class="h-1.5 w-20 accent-primary sm:w-24"
|
||||
(input)="updateFocusedShareVolume($event)"
|
||||
/>
|
||||
|
||||
<span class="w-10 text-right text-[11px] text-white/65">
|
||||
{{ focusedShareMuted() ? 'Muted' : focusedShareVolume() + '%' }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
@if (isWidescreenMode() && hasMultipleShares()) {
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/35 px-3 py-2 text-xs font-medium text-white/80 transition hover:bg-black/55 hover:text-white"
|
||||
title="Show all streams"
|
||||
(click)="showAllStreams()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUsers"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
All streams
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-black/35 text-white/70 transition hover:bg-black/55 hover:text-white"
|
||||
title="Minimize stream workspace"
|
||||
(click)="minimizeWorkspace()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMinimize"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-black/35 text-white/70 transition hover:bg-black/55 hover:text-white"
|
||||
title="Return to chat"
|
||||
(click)="closeWorkspace()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isWidescreenMode() && thumbnailShares().length > 0) {
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-3 bottom-3 z-10 transition-all duration-300 sm:inset-x-4 sm:bottom-4"
|
||||
[class.opacity-0]="!showWorkspaceHeader()"
|
||||
[class.translate-y-[12px]]="!showWorkspaceHeader()"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-auto rounded-2xl border border-white/10 bg-black/45 p-2.5 backdrop-blur-lg"
|
||||
[class.pointer-events-none]="!showWorkspaceHeader()"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between px-1">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.18em] text-white/55">Other live streams</span>
|
||||
<span class="text-[10px] text-white/40">{{ thumbnailShares().length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 overflow-x-auto pb-1">
|
||||
@for (share of thumbnailShares(); track trackShare($index, share)) {
|
||||
<div class="h-[5.25rem] w-[9.5rem] shrink-0 sm:h-[5.5rem] sm:w-[10rem]">
|
||||
<app-screen-share-stream-tile
|
||||
[item]="share"
|
||||
[mini]="true"
|
||||
[focused]="false"
|
||||
(focusRequested)="focusShare($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div
|
||||
class="h-full min-h-0"
|
||||
[ngClass]="isWidescreenMode() ? 'p-0' : 'p-3 pt-20 sm:p-4 sm:pt-24'"
|
||||
>
|
||||
@if (activeShares().length > 0) {
|
||||
@if (isWidescreenMode() && widescreenShare()) {
|
||||
<div class="h-full min-h-0">
|
||||
<app-screen-share-stream-tile
|
||||
[item]="widescreenShare()!"
|
||||
[featured]="true"
|
||||
[focused]="true"
|
||||
[immersive]="true"
|
||||
(focusRequested)="focusShare($event)"
|
||||
/>
|
||||
</div>
|
||||
} @else {
|
||||
<div
|
||||
class="grid h-full min-h-0 auto-rows-[minmax(15rem,1fr)] grid-cols-1 gap-3 overflow-auto sm:grid-cols-2 sm:gap-4"
|
||||
[ngClass]="{ '2xl:grid-cols-3': activeShares().length > 2 }"
|
||||
>
|
||||
@for (share of activeShares(); track trackShare($index, share)) {
|
||||
<div class="min-h-[15rem]">
|
||||
<app-screen-share-stream-tile
|
||||
[item]="share"
|
||||
[focused]="false"
|
||||
(focusRequested)="focusShare($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<div class="w-full max-w-3xl rounded-[2rem] border border-dashed border-white/10 bg-card/60 p-8 text-center shadow-2xl sm:p-10">
|
||||
<div class="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
class="h-8 w-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="text-2xl font-semibold text-foreground">No live screen shares yet</h2>
|
||||
<p class="mx-auto mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
Click Screen Share below to start streaming, or wait for someone in {{ connectedVoiceChannelName() }} to go live.
|
||||
</p>
|
||||
|
||||
@if (connectedVoiceUsers().length > 0) {
|
||||
<div class="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
@for (participant of connectedVoiceUsers().slice(0, 4); track trackUser($index, participant)) {
|
||||
<div class="flex items-center gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-2">
|
||||
<app-user-avatar
|
||||
[name]="participant.displayName"
|
||||
[avatarUrl]="participant.avatarUrl"
|
||||
size="xs"
|
||||
/>
|
||||
<span class="text-sm text-foreground">{{ participant.displayName }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3 text-sm text-muted-foreground">
|
||||
<span class="inline-flex items-center gap-2 rounded-full bg-secondary/70 px-4 py-2">
|
||||
<ng-icon
|
||||
name="lucideUsers"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{{ connectedVoiceUsers().length }} participants ready
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-primary px-5 py-2.5 font-medium text-primary-foreground transition hover:bg-primary/90"
|
||||
(click)="toggleScreenShare()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
Start screen sharing
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (showMiniWindow()) {
|
||||
<div
|
||||
class="pointer-events-auto absolute z-20 w-[20rem] select-none overflow-hidden rounded-[1.75rem] border border-white/10 bg-card/95 shadow-2xl backdrop-blur-xl"
|
||||
[style.left.px]="miniPosition().left"
|
||||
[style.top.px]="miniPosition().top"
|
||||
(dblclick)="restoreWorkspace()"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-move items-center gap-3 border-b border-white/10 bg-black/25 px-4 py-3"
|
||||
(mousedown)="startMiniWindowDrag($event)"
|
||||
>
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-semibold text-foreground">{{ connectedVoiceChannelName() }}</p>
|
||||
<p class="truncate text-xs text-muted-foreground">
|
||||
{{ liveShareCount() }} live {{ liveShareCount() === 1 ? 'stream' : 'streams' }} · double-click to expand
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition hover:bg-black/30 hover:text-foreground"
|
||||
title="Expand"
|
||||
(click)="restoreWorkspace()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMaximize"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition hover:bg-black/30 hover:text-foreground"
|
||||
title="Close"
|
||||
(click)="closeWorkspace()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative aspect-video bg-black">
|
||||
@if (miniPreviewShare()) {
|
||||
<video
|
||||
#miniPreview
|
||||
autoplay
|
||||
playsinline
|
||||
class="h-full w-full bg-black object-cover"
|
||||
></video>
|
||||
} @else {
|
||||
<div class="flex h-full items-center justify-center text-muted-foreground">
|
||||
<div class="text-center">
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
class="mx-auto h-8 w-8 opacity-50"
|
||||
/>
|
||||
<p class="mt-2 text-sm">Waiting for a live stream</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/50 to-transparent px-4 py-3 text-white">
|
||||
<p class="truncate text-sm font-semibold">
|
||||
{{ miniPreviewTitle() }}
|
||||
</p>
|
||||
<p class="truncate text-xs text-white/75">Connected to {{ serverName() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showScreenShareQualityDialog()) {
|
||||
<app-screen-share-quality-dialog
|
||||
[selectedQuality]="screenShareQuality()"
|
||||
[includeSystemAudio]="includeSystemAudio()"
|
||||
(cancelled)="onScreenShareQualityCancelled()"
|
||||
(confirmed)="onScreenShareQualityConfirmed($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,963 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, complexity */
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideHeadphones,
|
||||
lucideMaximize,
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideMinimize,
|
||||
lucideMonitor,
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
lucideUsers,
|
||||
lucideVolume2,
|
||||
lucideVolumeX,
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import {
|
||||
loadVoiceSettingsFromStorage,
|
||||
saveVoiceSettingsToStorage,
|
||||
VoiceSessionFacade,
|
||||
VoiceWorkspacePosition,
|
||||
VoiceWorkspaceService
|
||||
} from '../../../../domains/voice-session';
|
||||
import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
|
||||
import { VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
|
||||
import { ScreenShareFacade } from '../../application/screen-share.facade';
|
||||
import { ScreenShareQuality, ScreenShareStartOptions } from '../../domain/screen-share.config';
|
||||
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { selectCurrentUser, selectOnlineUsers } from '../../../../store/users/users.selectors';
|
||||
import { ScreenShareQualityDialogComponent, UserAvatarComponent } from '../../../../shared';
|
||||
import { ScreenSharePlaybackService } from './screen-share-playback.service';
|
||||
import { ScreenShareStreamTileComponent } from './screen-share-stream-tile.component';
|
||||
import { ScreenShareWorkspaceStreamItem } from './screen-share-workspace.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-screen-share-workspace',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
ScreenShareQualityDialogComponent,
|
||||
ScreenShareStreamTileComponent,
|
||||
UserAvatarComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideHeadphones,
|
||||
lucideMaximize,
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideMinimize,
|
||||
lucideMonitor,
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
lucideUsers,
|
||||
lucideVolume2,
|
||||
lucideVolumeX,
|
||||
lucideX
|
||||
})
|
||||
],
|
||||
templateUrl: './screen-share-workspace.component.html',
|
||||
host: {
|
||||
class: 'pointer-events-none absolute inset-0 z-20 block'
|
||||
}
|
||||
})
|
||||
export class ScreenShareWorkspaceComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly store = inject(Store);
|
||||
private readonly webrtc = inject(VoiceConnectionFacade);
|
||||
private readonly screenShare = inject(ScreenShareFacade);
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly screenSharePlayback = inject(ScreenSharePlaybackService);
|
||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||
private readonly voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
|
||||
private readonly remoteStreamRevision = signal(0);
|
||||
|
||||
private readonly miniWindowWidth = 320;
|
||||
private readonly miniWindowHeight = 228;
|
||||
private miniWindowDragging = false;
|
||||
private miniDragOffsetX = 0;
|
||||
private miniDragOffsetY = 0;
|
||||
private wasExpanded = false;
|
||||
private wasAutoHideChrome = false;
|
||||
private headerHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly observedRemoteStreams = new Map<string, {
|
||||
stream: MediaStream;
|
||||
cleanup: () => void;
|
||||
}>();
|
||||
|
||||
readonly miniPreviewRef = viewChild<ElementRef<HTMLVideoElement>>('miniPreview');
|
||||
|
||||
readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
readonly onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
readonly voiceSessionInfo = this.voiceSession.voiceSession;
|
||||
|
||||
readonly showExpanded = this.voiceWorkspace.isExpanded;
|
||||
readonly showMiniWindow = this.voiceWorkspace.isMinimized;
|
||||
readonly shouldConnectRemoteShares = this.voiceWorkspace.shouldConnectRemoteShares;
|
||||
readonly miniPosition = this.voiceWorkspace.miniWindowPosition;
|
||||
readonly showWorkspaceHeader = signal(true);
|
||||
|
||||
readonly isConnected = computed(() => this.webrtc.isVoiceConnected());
|
||||
readonly isMuted = computed(() => this.webrtc.isMuted());
|
||||
readonly isDeafened = computed(() => this.webrtc.isDeafened());
|
||||
readonly isScreenSharing = computed(() => this.screenShare.isScreenSharing());
|
||||
|
||||
readonly includeSystemAudio = signal(false);
|
||||
readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||
readonly askScreenShareQuality = signal(true);
|
||||
readonly showScreenShareQualityDialog = signal(false);
|
||||
|
||||
readonly connectedVoiceUsers = computed(() => {
|
||||
const room = this.currentRoom();
|
||||
const me = this.currentUser();
|
||||
const roomId = me?.voiceState?.roomId;
|
||||
const serverId = me?.voiceState?.serverId;
|
||||
|
||||
if (!room || !roomId || !serverId || serverId !== room.id) {
|
||||
return [] as User[];
|
||||
}
|
||||
|
||||
const voiceUsers = this.onlineUsers().filter(
|
||||
(user) =>
|
||||
!!user.voiceState?.isConnected
|
||||
&& user.voiceState.roomId === roomId
|
||||
&& user.voiceState.serverId === room.id
|
||||
);
|
||||
|
||||
if (!me?.voiceState?.isConnected) {
|
||||
return voiceUsers;
|
||||
}
|
||||
|
||||
const currentKeys = new Set(voiceUsers.map((user) => user.oderId || user.id));
|
||||
const meKey = me.oderId || me.id;
|
||||
|
||||
if (meKey && !currentKeys.has(meKey)) {
|
||||
return [me, ...voiceUsers];
|
||||
}
|
||||
|
||||
return voiceUsers;
|
||||
});
|
||||
|
||||
readonly activeShares = computed<ScreenShareWorkspaceStreamItem[]>(() => {
|
||||
this.remoteStreamRevision();
|
||||
|
||||
const room = this.currentRoom();
|
||||
const me = this.currentUser();
|
||||
const connectedRoomId = me?.voiceState?.roomId;
|
||||
const connectedServerId = me?.voiceState?.serverId;
|
||||
|
||||
if (!room || !me || !connectedRoomId || connectedServerId !== room.id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const shares: ScreenShareWorkspaceStreamItem[] = [];
|
||||
const localStream = this.screenShare.screenStream();
|
||||
const localPeerKey = this.getUserPeerKey(me);
|
||||
|
||||
if (localStream && localPeerKey) {
|
||||
shares.push({
|
||||
id: localPeerKey,
|
||||
peerKey: localPeerKey,
|
||||
user: me,
|
||||
stream: localStream,
|
||||
isLocal: true
|
||||
});
|
||||
}
|
||||
|
||||
for (const user of this.onlineUsers()) {
|
||||
const peerKey = this.getUserPeerKey(user);
|
||||
|
||||
if (!peerKey || peerKey === localPeerKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
!user.voiceState?.isConnected
|
||||
|| user.voiceState.roomId !== connectedRoomId
|
||||
|| user.voiceState.serverId !== room.id
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (user.screenShareState?.isSharing === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const remoteShare = this.getRemoteShareStream(user);
|
||||
|
||||
if (!remoteShare) {
|
||||
continue;
|
||||
}
|
||||
|
||||
shares.push({
|
||||
id: remoteShare.peerKey,
|
||||
peerKey: remoteShare.peerKey,
|
||||
user,
|
||||
stream: remoteShare.stream,
|
||||
isLocal: false
|
||||
});
|
||||
}
|
||||
|
||||
return shares.sort((shareA, shareB) => {
|
||||
if (shareA.isLocal !== shareB.isLocal) {
|
||||
return shareA.isLocal ? 1 : -1;
|
||||
}
|
||||
|
||||
return shareA.user.displayName.localeCompare(shareB.user.displayName);
|
||||
});
|
||||
});
|
||||
|
||||
readonly widescreenShareId = computed(() => {
|
||||
const requested = this.voiceWorkspace.focusedStreamId();
|
||||
const activeShares = this.activeShares();
|
||||
|
||||
if (requested && activeShares.some((share) => share.peerKey === requested)) {
|
||||
return requested;
|
||||
}
|
||||
|
||||
if (activeShares.length === 1) {
|
||||
return activeShares[0].peerKey;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
readonly isWidescreenMode = computed(() => this.widescreenShareId() !== null);
|
||||
readonly shouldAutoHideChrome = computed(
|
||||
() => this.showExpanded() && this.isWidescreenMode() && this.activeShares().length > 0
|
||||
);
|
||||
readonly hasMultipleShares = computed(() => this.activeShares().length > 1);
|
||||
readonly widescreenShare = computed(
|
||||
() => this.activeShares().find((share) => share.peerKey === this.widescreenShareId()) ?? null
|
||||
);
|
||||
readonly focusedAudioShare = computed(() => {
|
||||
const share = this.widescreenShare();
|
||||
|
||||
return share && !share.isLocal ? share : null;
|
||||
});
|
||||
readonly focusedShareTitle = computed(() => {
|
||||
const share = this.widescreenShare();
|
||||
|
||||
if (!share) {
|
||||
return 'Focused stream';
|
||||
}
|
||||
|
||||
return share.isLocal ? 'Your stream' : share.user.displayName;
|
||||
});
|
||||
readonly thumbnailShares = computed(() => {
|
||||
const widescreenShareId = this.widescreenShareId();
|
||||
|
||||
if (!widescreenShareId) {
|
||||
return [] as ScreenShareWorkspaceStreamItem[];
|
||||
}
|
||||
|
||||
return this.activeShares().filter((share) => share.peerKey !== widescreenShareId);
|
||||
});
|
||||
readonly miniPreviewShare = computed(
|
||||
() => this.widescreenShare() ?? this.activeShares()[0] ?? null
|
||||
);
|
||||
readonly miniPreviewTitle = computed(() => {
|
||||
const previewShare = this.miniPreviewShare();
|
||||
|
||||
if (!previewShare) {
|
||||
return 'Voice workspace';
|
||||
}
|
||||
|
||||
return previewShare.isLocal ? 'Your stream' : previewShare.user.displayName;
|
||||
});
|
||||
readonly liveShareCount = computed(() => this.activeShares().length);
|
||||
readonly connectedVoiceChannelName = computed(() => {
|
||||
const me = this.currentUser();
|
||||
const room = this.currentRoom();
|
||||
const channelId = me?.voiceState?.roomId ?? this.voiceSessionInfo()?.roomId;
|
||||
const channel = room?.channels?.find(
|
||||
(candidate) => candidate.id === channelId && candidate.type === 'voice'
|
||||
);
|
||||
|
||||
if (channel) {
|
||||
return channel.name;
|
||||
}
|
||||
|
||||
const sessionRoomName = this.voiceSessionInfo()?.roomName?.replace(/^🔊\s*/, '');
|
||||
|
||||
return sessionRoomName || 'Voice Lounge';
|
||||
});
|
||||
readonly serverName = computed(
|
||||
() => this.currentRoom()?.name || this.voiceSessionInfo()?.serverName || 'Voice server'
|
||||
);
|
||||
|
||||
constructor() {
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.clearHeaderHideTimeout();
|
||||
this.cleanupObservedRemoteStreams();
|
||||
this.screenShare.syncRemoteScreenShareRequests([], false);
|
||||
this.screenSharePlayback.teardownAll();
|
||||
});
|
||||
|
||||
this.screenShare.onRemoteStream
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(({ peerId }) => {
|
||||
this.observeRemoteStream(peerId);
|
||||
this.bumpRemoteStreamRevision();
|
||||
});
|
||||
|
||||
this.screenShare.onPeerDisconnected
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => this.bumpRemoteStreamRevision());
|
||||
|
||||
effect(() => {
|
||||
const ref = this.miniPreviewRef();
|
||||
const previewShare = this.miniPreviewShare();
|
||||
const showMiniWindow = this.showMiniWindow();
|
||||
|
||||
if (!ref) {
|
||||
return;
|
||||
}
|
||||
|
||||
const video = ref.nativeElement;
|
||||
|
||||
if (!showMiniWindow || !previewShare) {
|
||||
video.srcObject = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (video.srcObject !== previewShare.stream) {
|
||||
video.srcObject = previewShare.stream;
|
||||
}
|
||||
|
||||
video.muted = true;
|
||||
video.volume = 0;
|
||||
void video.play().catch(() => {});
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (!this.showMiniWindow()) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => this.ensureMiniWindowPosition());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const shouldConnectRemoteShares = this.shouldConnectRemoteShares();
|
||||
const currentUserPeerKey = this.getUserPeerKey(this.currentUser());
|
||||
const peerKeys = Array.from(new Set(
|
||||
this.connectedVoiceUsers()
|
||||
.map((user) => this.getUserPeerKey(user))
|
||||
.filter((peerKey): peerKey is string => !!peerKey && peerKey !== currentUserPeerKey)
|
||||
));
|
||||
|
||||
this.screenShare.syncRemoteScreenShareRequests(peerKeys, shouldConnectRemoteShares);
|
||||
|
||||
if (!shouldConnectRemoteShares) {
|
||||
this.screenSharePlayback.teardownAll();
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
this.remoteStreamRevision();
|
||||
|
||||
const room = this.currentRoom();
|
||||
const currentUser = this.currentUser();
|
||||
const connectedRoomId = currentUser?.voiceState?.roomId;
|
||||
const connectedServerId = currentUser?.voiceState?.serverId;
|
||||
const peerKeys = new Set<string>();
|
||||
|
||||
if (room && connectedRoomId && connectedServerId === room.id) {
|
||||
for (const user of this.onlineUsers()) {
|
||||
if (
|
||||
!user.voiceState?.isConnected
|
||||
|| user.voiceState.roomId !== connectedRoomId
|
||||
|| user.voiceState.serverId !== room.id
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const peerKey of [user.oderId, user.id]) {
|
||||
if (!peerKey || peerKey === this.getUserPeerKey(currentUser)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
peerKeys.add(peerKey);
|
||||
this.observeRemoteStream(peerKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.pruneObservedRemoteStreams(peerKeys);
|
||||
});
|
||||
|
||||
effect(
|
||||
() => {
|
||||
const isExpanded = this.showExpanded();
|
||||
const shouldAutoHideChrome = this.shouldAutoHideChrome();
|
||||
|
||||
if (!isExpanded) {
|
||||
this.clearHeaderHideTimeout();
|
||||
this.showWorkspaceHeader.set(true);
|
||||
this.wasExpanded = false;
|
||||
this.wasAutoHideChrome = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldAutoHideChrome) {
|
||||
this.clearHeaderHideTimeout();
|
||||
this.showWorkspaceHeader.set(true);
|
||||
this.wasExpanded = true;
|
||||
this.wasAutoHideChrome = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldRevealChrome = !this.wasExpanded || !this.wasAutoHideChrome;
|
||||
|
||||
this.wasExpanded = true;
|
||||
this.wasAutoHideChrome = true;
|
||||
|
||||
if (shouldRevealChrome) {
|
||||
this.revealWorkspaceChrome();
|
||||
}
|
||||
},
|
||||
{ allowSignalWrites: true }
|
||||
);
|
||||
}
|
||||
|
||||
onWorkspacePointerMove(): void {
|
||||
if (!this.shouldAutoHideChrome()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.revealWorkspaceChrome();
|
||||
}
|
||||
|
||||
@HostListener('window:mousemove', ['$event'])
|
||||
onWindowMouseMove(event: MouseEvent): void {
|
||||
if (!this.miniWindowDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const bounds = this.getWorkspaceBounds();
|
||||
const nextPosition = this.clampMiniWindowPosition({
|
||||
left: event.clientX - bounds.left - this.miniDragOffsetX,
|
||||
top: event.clientY - bounds.top - this.miniDragOffsetY
|
||||
});
|
||||
|
||||
this.voiceWorkspace.setMiniWindowPosition(nextPosition);
|
||||
}
|
||||
|
||||
@HostListener('window:mouseup')
|
||||
onWindowMouseUp(): void {
|
||||
this.miniWindowDragging = false;
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
onWindowResize(): void {
|
||||
if (!this.showMiniWindow()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ensureMiniWindowPosition();
|
||||
}
|
||||
|
||||
trackUser(index: number, user: User): string {
|
||||
return this.getUserPeerKey(user) || `${index}`;
|
||||
}
|
||||
|
||||
trackShare(index: number, share: ScreenShareWorkspaceStreamItem): string {
|
||||
return share.id || `${index}`;
|
||||
}
|
||||
|
||||
focusShare(peerKey: string): void {
|
||||
if (this.widescreenShareId() === peerKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.voiceWorkspace.focusStream(peerKey);
|
||||
}
|
||||
|
||||
showAllStreams(): void {
|
||||
this.voiceWorkspace.clearFocusedStream();
|
||||
}
|
||||
|
||||
minimizeWorkspace(): void {
|
||||
this.voiceWorkspace.minimize();
|
||||
this.ensureMiniWindowPosition();
|
||||
}
|
||||
|
||||
restoreWorkspace(): void {
|
||||
this.voiceWorkspace.restore();
|
||||
}
|
||||
|
||||
closeWorkspace(): void {
|
||||
this.voiceWorkspace.clearFocusedStream();
|
||||
this.voiceWorkspace.close();
|
||||
}
|
||||
|
||||
focusedShareVolume(): number {
|
||||
const share = this.focusedAudioShare();
|
||||
|
||||
if (!share) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return this.screenSharePlayback.getUserVolume(share.peerKey);
|
||||
}
|
||||
|
||||
focusedShareMuted(): boolean {
|
||||
const share = this.focusedAudioShare();
|
||||
|
||||
if (!share) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.screenSharePlayback.isUserMuted(share.peerKey);
|
||||
}
|
||||
|
||||
toggleFocusedShareMuted(): void {
|
||||
const share = this.focusedAudioShare();
|
||||
|
||||
if (!share) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.screenSharePlayback.setUserMuted(
|
||||
share.peerKey,
|
||||
!this.screenSharePlayback.isUserMuted(share.peerKey)
|
||||
);
|
||||
}
|
||||
|
||||
updateFocusedShareVolume(event: Event): void {
|
||||
const share = this.focusedAudioShare();
|
||||
|
||||
if (!share) {
|
||||
return;
|
||||
}
|
||||
|
||||
const input = event.target as HTMLInputElement;
|
||||
const nextVolume = Math.max(0, Math.min(100, parseInt(input.value, 10) || 0));
|
||||
|
||||
this.screenSharePlayback.setUserVolume(share.peerKey, nextVolume);
|
||||
|
||||
if (nextVolume > 0 && this.screenSharePlayback.isUserMuted(share.peerKey)) {
|
||||
this.screenSharePlayback.setUserMuted(share.peerKey, false);
|
||||
}
|
||||
}
|
||||
|
||||
startMiniWindowDrag(event: MouseEvent): void {
|
||||
const target = event.target as HTMLElement | null;
|
||||
|
||||
if (target?.closest('button, input')) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const bounds = this.getWorkspaceBounds();
|
||||
const currentPosition = this.voiceWorkspace.miniWindowPosition();
|
||||
|
||||
this.miniWindowDragging = true;
|
||||
this.miniDragOffsetX = event.clientX - bounds.left - currentPosition.left;
|
||||
this.miniDragOffsetY = event.clientY - bounds.top - currentPosition.top;
|
||||
}
|
||||
|
||||
toggleMute(): void {
|
||||
const nextMuted = !this.isMuted();
|
||||
|
||||
this.webrtc.toggleMute(nextMuted);
|
||||
this.syncVoiceState({
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: nextMuted,
|
||||
isDeafened: this.isDeafened()
|
||||
});
|
||||
|
||||
this.broadcastVoiceState(nextMuted, this.isDeafened());
|
||||
}
|
||||
|
||||
toggleDeafen(): void {
|
||||
const nextDeafened = !this.isDeafened();
|
||||
|
||||
let nextMuted = this.isMuted();
|
||||
|
||||
this.webrtc.toggleDeafen(nextDeafened);
|
||||
this.voicePlayback.updateDeafened(nextDeafened);
|
||||
|
||||
if (nextDeafened && !nextMuted) {
|
||||
nextMuted = true;
|
||||
this.webrtc.toggleMute(true);
|
||||
}
|
||||
|
||||
this.syncVoiceState({
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: nextMuted,
|
||||
isDeafened: nextDeafened
|
||||
});
|
||||
|
||||
this.broadcastVoiceState(nextMuted, nextDeafened);
|
||||
}
|
||||
|
||||
async toggleScreenShare(): Promise<void> {
|
||||
if (this.isScreenSharing()) {
|
||||
this.screenShare.stopScreenShare();
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncScreenShareSettings();
|
||||
|
||||
if (this.askScreenShareQuality()) {
|
||||
this.showScreenShareQualityDialog.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.startScreenShareWithOptions(this.screenShareQuality());
|
||||
}
|
||||
|
||||
onScreenShareQualityCancelled(): void {
|
||||
this.showScreenShareQualityDialog.set(false);
|
||||
}
|
||||
|
||||
async onScreenShareQualityConfirmed(quality: ScreenShareQuality): Promise<void> {
|
||||
this.showScreenShareQualityDialog.set(false);
|
||||
this.screenShareQuality.set(quality);
|
||||
saveVoiceSettingsToStorage({ screenShareQuality: quality });
|
||||
await this.startScreenShareWithOptions(quality);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.webrtc.stopVoiceHeartbeat();
|
||||
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||
displayName: this.currentUser()?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined
|
||||
}
|
||||
});
|
||||
|
||||
if (this.isScreenSharing()) {
|
||||
this.screenShare.stopScreenShare();
|
||||
}
|
||||
|
||||
this.webrtc.disableVoice();
|
||||
this.voicePlayback.teardownAll();
|
||||
this.voicePlayback.updateDeafened(false);
|
||||
|
||||
const user = this.currentUser();
|
||||
|
||||
if (user?.id) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: user.id,
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.voiceSession.endSession();
|
||||
this.voiceWorkspace.reset();
|
||||
}
|
||||
|
||||
getControlButtonClass(
|
||||
isActive: boolean,
|
||||
accent: 'default' | 'primary' | 'danger' = 'default'
|
||||
): string {
|
||||
const base = 'inline-flex min-w-[5.5rem] flex-col items-center gap-2 rounded-2xl px-4 py-3 text-sm font-medium transition-colors';
|
||||
|
||||
if (accent === 'danger') {
|
||||
return `${base} bg-destructive text-destructive-foreground hover:bg-destructive/90`;
|
||||
}
|
||||
|
||||
if (accent === 'primary' || isActive) {
|
||||
return `${base} bg-primary/15 text-primary hover:bg-primary/25`;
|
||||
}
|
||||
|
||||
return `${base} bg-secondary/80 text-foreground hover:bg-secondary`;
|
||||
}
|
||||
|
||||
private bumpRemoteStreamRevision(): void {
|
||||
this.remoteStreamRevision.update((value) => value + 1);
|
||||
}
|
||||
|
||||
private syncVoiceState(voiceState: {
|
||||
isConnected: boolean;
|
||||
isMuted: boolean;
|
||||
isDeafened: boolean;
|
||||
}): void {
|
||||
const user = this.currentUser();
|
||||
const identifiers = this.getCurrentVoiceIdentifiers();
|
||||
|
||||
if (!user?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: user.id,
|
||||
voiceState: {
|
||||
...voiceState,
|
||||
roomId: identifiers.roomId,
|
||||
serverId: identifiers.serverId
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private broadcastVoiceState(isMuted: boolean, isDeafened: boolean): void {
|
||||
const identifiers = this.getCurrentVoiceIdentifiers();
|
||||
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||
displayName: this.currentUser()?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted,
|
||||
isDeafened,
|
||||
roomId: identifiers.roomId,
|
||||
serverId: identifiers.serverId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getCurrentVoiceIdentifiers(): {
|
||||
roomId: string | undefined;
|
||||
serverId: string | undefined;
|
||||
} {
|
||||
const me = this.currentUser();
|
||||
|
||||
return {
|
||||
roomId: me?.voiceState?.roomId ?? this.voiceSessionInfo()?.roomId,
|
||||
serverId: me?.voiceState?.serverId ?? this.currentRoom()?.id ?? this.voiceSessionInfo()?.serverId
|
||||
};
|
||||
}
|
||||
|
||||
private syncScreenShareSettings(): void {
|
||||
const settings = loadVoiceSettingsFromStorage();
|
||||
|
||||
this.includeSystemAudio.set(settings.includeSystemAudio);
|
||||
this.screenShareQuality.set(settings.screenShareQuality);
|
||||
this.askScreenShareQuality.set(settings.askScreenShareQuality);
|
||||
}
|
||||
|
||||
private async startScreenShareWithOptions(quality: ScreenShareQuality): Promise<void> {
|
||||
const options: ScreenShareStartOptions = {
|
||||
includeSystemAudio: this.includeSystemAudio(),
|
||||
quality
|
||||
};
|
||||
|
||||
try {
|
||||
await this.screenShare.startScreenShare(options);
|
||||
|
||||
this.voiceWorkspace.open(null);
|
||||
} catch {
|
||||
// Screen-share prompt was dismissed or failed.
|
||||
}
|
||||
}
|
||||
|
||||
private getUserPeerKey(user: User | null | undefined): string | null {
|
||||
return user?.oderId || user?.id || null;
|
||||
}
|
||||
|
||||
private getRemoteShareStream(user: User): { peerKey: string; stream: MediaStream } | null {
|
||||
const peerKeys = [user.oderId, user.id].filter(
|
||||
(candidate): candidate is string => !!candidate
|
||||
);
|
||||
|
||||
for (const peerKey of peerKeys) {
|
||||
const stream = this.screenShare.getRemoteScreenShareStream(peerKey);
|
||||
|
||||
if (stream && this.hasActiveVideo(stream)) {
|
||||
return { peerKey, stream };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private hasActiveVideo(stream: MediaStream): boolean {
|
||||
return stream.getVideoTracks().some((track) => track.readyState === 'live');
|
||||
}
|
||||
|
||||
private ensureMiniWindowPosition(): void {
|
||||
const bounds = this.getWorkspaceBounds();
|
||||
|
||||
if (bounds.width === 0 || bounds.height === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.voiceWorkspace.hasCustomMiniWindowPosition()) {
|
||||
this.voiceWorkspace.setMiniWindowPosition(
|
||||
this.clampMiniWindowPosition({
|
||||
left: bounds.width - this.miniWindowWidth - 20,
|
||||
top: bounds.height - this.miniWindowHeight - 20
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.voiceWorkspace.setMiniWindowPosition(
|
||||
this.clampMiniWindowPosition(this.voiceWorkspace.miniWindowPosition()),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
private clampMiniWindowPosition(position: VoiceWorkspacePosition): VoiceWorkspacePosition {
|
||||
const bounds = this.getWorkspaceBounds();
|
||||
const minLeft = 8;
|
||||
const minTop = 8;
|
||||
const maxLeft = Math.max(minLeft, bounds.width - this.miniWindowWidth - 8);
|
||||
const maxTop = Math.max(minTop, bounds.height - this.miniWindowHeight - 8);
|
||||
|
||||
return {
|
||||
left: this.clamp(position.left, minLeft, maxLeft),
|
||||
top: this.clamp(position.top, minTop, maxTop)
|
||||
};
|
||||
}
|
||||
|
||||
private getWorkspaceBounds(): DOMRect {
|
||||
return this.elementRef.nativeElement.getBoundingClientRect();
|
||||
}
|
||||
|
||||
private observeRemoteStream(peerKey: string): void {
|
||||
const stream = this.screenShare.getRemoteScreenShareStream(peerKey);
|
||||
const existing = this.observedRemoteStreams.get(peerKey);
|
||||
|
||||
if (!stream) {
|
||||
if (existing) {
|
||||
existing.cleanup();
|
||||
this.observedRemoteStreams.delete(peerKey);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing?.stream === stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
existing?.cleanup();
|
||||
|
||||
const onChanged = () => this.bumpRemoteStreamRevision();
|
||||
const trackCleanups: (() => void)[] = [];
|
||||
const bindTrack = (track: MediaStreamTrack) => {
|
||||
if (track.kind !== 'video') {
|
||||
return;
|
||||
}
|
||||
|
||||
const onTrackChanged = () => onChanged();
|
||||
|
||||
track.addEventListener('ended', onTrackChanged);
|
||||
track.addEventListener('mute', onTrackChanged);
|
||||
track.addEventListener('unmute', onTrackChanged);
|
||||
|
||||
trackCleanups.push(() => {
|
||||
track.removeEventListener('ended', onTrackChanged);
|
||||
track.removeEventListener('mute', onTrackChanged);
|
||||
track.removeEventListener('unmute', onTrackChanged);
|
||||
});
|
||||
};
|
||||
|
||||
stream.getVideoTracks().forEach((track) => bindTrack(track));
|
||||
|
||||
const onAddTrack = (event: MediaStreamTrackEvent) => {
|
||||
bindTrack(event.track);
|
||||
onChanged();
|
||||
};
|
||||
const onRemoveTrack = () => onChanged();
|
||||
|
||||
stream.addEventListener('addtrack', onAddTrack);
|
||||
stream.addEventListener('removetrack', onRemoveTrack);
|
||||
|
||||
this.observedRemoteStreams.set(peerKey, {
|
||||
stream,
|
||||
cleanup: () => {
|
||||
stream.removeEventListener('addtrack', onAddTrack);
|
||||
stream.removeEventListener('removetrack', onRemoveTrack);
|
||||
trackCleanups.forEach((cleanup) => cleanup());
|
||||
}
|
||||
});
|
||||
|
||||
onChanged();
|
||||
}
|
||||
|
||||
private pruneObservedRemoteStreams(activePeerKeys: Set<string>): void {
|
||||
for (const [peerKey, observed] of this.observedRemoteStreams.entries()) {
|
||||
if (activePeerKeys.has(peerKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
observed.cleanup();
|
||||
this.observedRemoteStreams.delete(peerKey);
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupObservedRemoteStreams(): void {
|
||||
for (const observed of this.observedRemoteStreams.values()) {
|
||||
observed.cleanup();
|
||||
}
|
||||
|
||||
this.observedRemoteStreams.clear();
|
||||
}
|
||||
|
||||
private scheduleHeaderHide(): void {
|
||||
this.clearHeaderHideTimeout();
|
||||
|
||||
this.headerHideTimeoutId = setTimeout(() => {
|
||||
this.showWorkspaceHeader.set(false);
|
||||
this.headerHideTimeoutId = null;
|
||||
}, 2200);
|
||||
}
|
||||
|
||||
private revealWorkspaceChrome(): void {
|
||||
this.showWorkspaceHeader.set(true);
|
||||
this.scheduleHeaderHide();
|
||||
}
|
||||
|
||||
private clearHeaderHideTimeout(): void {
|
||||
if (this.headerHideTimeoutId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this.headerHideTimeoutId);
|
||||
this.headerHideTimeoutId = null;
|
||||
}
|
||||
|
||||
private clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { User } from '../../../../shared-kernel';
|
||||
|
||||
export interface ScreenShareWorkspaceStreamItem {
|
||||
id: string;
|
||||
peerKey: string;
|
||||
user: User;
|
||||
stream: MediaStream;
|
||||
isLocal: boolean;
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
export * from './application/screen-share.facade';
|
||||
export * from './application/screen-share-source-picker.service';
|
||||
export * from './domain/screen-share.config';
|
||||
|
||||
// Feature components
|
||||
export { ScreenShareViewerComponent } from './feature/screen-share-viewer/screen-share-viewer.component';
|
||||
export { ScreenShareWorkspaceComponent } from './feature/screen-share-workspace/screen-share-workspace.component';
|
||||
export { ScreenShareStreamTileComponent } from './feature/screen-share-workspace/screen-share-stream-tile.component';
|
||||
|
||||
176
src/app/domains/server-directory/README.md
Normal file
176
src/app/domains/server-directory/README.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Server Directory Domain
|
||||
|
||||
Manages the list of server endpoints the client can connect to, health-checking them, resolving API URLs, and providing server CRUD, search, invites, and moderation. This is the central domain that other domains (auth, chat, attachment) depend on for knowing where the backend is.
|
||||
|
||||
## Module map
|
||||
|
||||
```
|
||||
server-directory/
|
||||
├── application/
|
||||
│ ├── server-directory.facade.ts High-level API: server CRUD, search, health, invites, moderation
|
||||
│ └── server-endpoint-state.service.ts Signal-based endpoint list, reconciliation with defaults, localStorage persistence
|
||||
│
|
||||
├── domain/
|
||||
│ ├── server-directory.models.ts ServerEndpoint, ServerInfo, ServerJoinAccessResponse, invite/ban/kick types
|
||||
│ ├── server-directory.constants.ts CLIENT_UPDATE_REQUIRED_MESSAGE
|
||||
│ └── server-endpoint-defaults.ts Default endpoint templates, URL sanitisation, reconciliation helpers
|
||||
│
|
||||
├── infrastructure/
|
||||
│ ├── server-directory-api.service.ts HTTP client for all server API calls
|
||||
│ ├── server-endpoint-health.service.ts Health probe (GET /api/health with 5 s timeout, fallback to /api/servers)
|
||||
│ ├── server-endpoint-compatibility.service.ts Semantic version comparison for client/server compatibility
|
||||
│ └── server-endpoint-storage.service.ts localStorage read/write for endpoint list and removed-default tracking
|
||||
│
|
||||
├── feature/
|
||||
│ ├── invite/ Invite creation and resolution UI
|
||||
│ ├── server-search/ Server search/browse panel
|
||||
│ └── settings/ Server endpoint management settings
|
||||
│
|
||||
└── index.ts Barrel exports
|
||||
```
|
||||
|
||||
## Layer composition
|
||||
|
||||
The facade delegates HTTP work to the API service and endpoint state to the state service. Health probing combines the health service and compatibility service. Storage is accessed only through the state service.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Facade[ServerDirectoryFacade]
|
||||
State[ServerEndpointStateService]
|
||||
API[ServerDirectoryApiService]
|
||||
Health[ServerEndpointHealthService]
|
||||
Compat[ServerEndpointCompatibilityService]
|
||||
Storage[ServerEndpointStorageService]
|
||||
Defaults[server-endpoint-defaults]
|
||||
Models[server-directory.models]
|
||||
|
||||
Facade --> API
|
||||
Facade --> State
|
||||
Facade --> Health
|
||||
Facade --> Compat
|
||||
API --> State
|
||||
State --> Storage
|
||||
State --> Defaults
|
||||
Health --> Compat
|
||||
|
||||
click Facade "application/server-directory.facade.ts" "High-level API"
|
||||
click State "application/server-endpoint-state.service.ts" "Signal-based endpoint state"
|
||||
click API "infrastructure/server-directory-api.service.ts" "HTTP client for server API"
|
||||
click Health "infrastructure/server-endpoint-health.service.ts" "Health probe"
|
||||
click Compat "infrastructure/server-endpoint-compatibility.service.ts" "Version compatibility"
|
||||
click Storage "infrastructure/server-endpoint-storage.service.ts" "localStorage persistence"
|
||||
click Defaults "domain/server-endpoint-defaults.ts" "Default endpoint templates"
|
||||
click Models "domain/server-directory.models.ts" "Domain types"
|
||||
```
|
||||
|
||||
## Endpoint lifecycle
|
||||
|
||||
On startup, `ServerEndpointStateService` loads endpoints from localStorage, reconciles them with the configured defaults from the environment, and ensures at least one endpoint is active.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Load: constructor
|
||||
Load --> HasStored: localStorage has endpoints
|
||||
Load --> InitDefaults: no stored endpoints
|
||||
InitDefaults --> Ready: save default endpoints
|
||||
HasStored --> Reconcile: compare stored vs defaults
|
||||
Reconcile --> Ready: merge, ensure active
|
||||
Ready --> HealthCheck: facade.testAllServers()
|
||||
|
||||
state HealthCheck {
|
||||
[*] --> Probing
|
||||
Probing --> Online: /api/health 200 OK
|
||||
Probing --> Incompatible: version mismatch
|
||||
Probing --> Offline: request failed
|
||||
}
|
||||
```
|
||||
|
||||
## Health probing
|
||||
|
||||
The facade exposes `testServer(endpointId)` and `testAllServers()`. Both delegate to `ServerEndpointHealthService.probeEndpoint()`, which:
|
||||
|
||||
1. Sends `GET /api/health` with a 5-second timeout
|
||||
2. On success, checks the response's `serverVersion` against the client version via `ServerEndpointCompatibilityService`
|
||||
3. If versions are incompatible, the endpoint is marked `incompatible` and deactivated
|
||||
4. If `/api/health` fails, falls back to `GET /api/servers` as a basic liveness check
|
||||
5. Updates the endpoint's status, latency, and version info in the state service
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Facade
|
||||
participant Health as HealthService
|
||||
participant Compat as CompatibilityService
|
||||
participant API as Server
|
||||
|
||||
Facade->>Health: probeEndpoint(endpoint, clientVersion)
|
||||
Health->>API: GET /api/health (5s timeout)
|
||||
|
||||
alt 200 OK
|
||||
API-->>Health: { serverVersion }
|
||||
Health->>Compat: evaluateServerVersion(serverVersion, clientVersion)
|
||||
Compat-->>Health: { isCompatible, serverVersion }
|
||||
Health-->>Facade: online / incompatible + latency + versions
|
||||
else Request failed
|
||||
Health->>API: GET /api/servers (fallback)
|
||||
alt 200 OK
|
||||
API-->>Health: servers list
|
||||
Health-->>Facade: online + latency
|
||||
else Also failed
|
||||
Health-->>Facade: offline
|
||||
end
|
||||
end
|
||||
|
||||
Facade->>Facade: updateServerStatus(id, status, latency, versions)
|
||||
```
|
||||
|
||||
## Server search
|
||||
|
||||
The facade's `searchServers(query)` method supports two modes controlled by a `searchAllServers` flag:
|
||||
|
||||
- **Single endpoint**: searches only the active server's API
|
||||
- **All endpoints**: fans out the query to every online active endpoint via `forkJoin`, then deduplicates results by server ID
|
||||
|
||||
The API service normalises every `ServerInfo` response, filling in `sourceId`, `sourceName`, and `sourceUrl` so the UI knows which endpoint each server came from.
|
||||
|
||||
## Default endpoint management
|
||||
|
||||
Default servers are configured in the environment file. The state service builds `DefaultEndpointTemplate` objects from the configuration and uses them during reconciliation:
|
||||
|
||||
- Stored endpoints are matched to defaults by `defaultKey` or URL
|
||||
- Missing defaults are added unless the user explicitly removed them (tracked in a separate localStorage key)
|
||||
- `restoreDefaultServers()` re-adds any removed defaults and clears the removal tracking
|
||||
- The primary default URL is used as a fallback when no endpoint is resolved
|
||||
|
||||
URL sanitisation strips trailing slashes and `/api` suffixes. Protocol-less URLs get `http` or `https` based on the current page protocol.
|
||||
|
||||
## Server administration
|
||||
|
||||
The facade provides methods for server registration, updates, and unregistration. These map directly to the API service's HTTP calls:
|
||||
|
||||
| Method | HTTP | Endpoint |
|
||||
|---|---|---|
|
||||
| `registerServer` | POST | `/api/servers` |
|
||||
| `updateServer` | PUT | `/api/servers/:id` |
|
||||
| `unregisterServer` | DELETE | `/api/servers/:id` |
|
||||
|
||||
## Invites and moderation
|
||||
|
||||
| Method | Purpose |
|
||||
|---|---|
|
||||
| `createInvite(serverId, request)` | Creates a time-limited invite link |
|
||||
| `getInvite(inviteId)` | Resolves invite metadata |
|
||||
| `requestServerAccess(request)` | Joins a server (via membership, password, invite, or public access) |
|
||||
| `kickServerMember(serverId, request)` | Removes a user from the server |
|
||||
| `banServerMember(serverId, request)` | Bans a user with optional reason and expiry |
|
||||
| `unbanServerMember(serverId, request)` | Lifts a ban |
|
||||
|
||||
## Persistence
|
||||
|
||||
All endpoint state is persisted to localStorage under two keys:
|
||||
|
||||
| Key | Contents |
|
||||
|---|---|
|
||||
| `metoyou_server_endpoints` | Full `ServerEndpoint[]` array |
|
||||
| `metoyou_removed_default_server_keys` | Set of default endpoint keys the user explicitly removed |
|
||||
|
||||
The storage service handles JSON serialisation and defensive parsing. Invalid data falls back to empty state rather than throwing.
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../core/constants';
|
||||
import { ServerInfo, User } from '../../../core/models';
|
||||
import { User } from '../../../shared-kernel';
|
||||
import { CLIENT_UPDATE_REQUIRED_MESSAGE } from '../domain/server-directory.constants';
|
||||
import { ServerDirectoryApiService } from '../infrastructure/server-directory-api.service';
|
||||
import type {
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
KickServerMemberRequest,
|
||||
ServerEndpoint,
|
||||
ServerEndpointVersions,
|
||||
ServerInfo,
|
||||
ServerInviteInfo,
|
||||
ServerJoinAccessRequest,
|
||||
ServerJoinAccessResponse,
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import type { ServerInfo } from '../../../core/models';
|
||||
|
||||
export type ServerEndpointStatus = 'online' | 'offline' | 'checking' | 'unknown' | 'incompatible';
|
||||
|
||||
export interface ServerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
topic?: string;
|
||||
hostName: string;
|
||||
ownerId?: string;
|
||||
ownerName?: string;
|
||||
ownerPublicKey?: string;
|
||||
userCount: number;
|
||||
maxUsers: number;
|
||||
hasPassword?: boolean;
|
||||
isPrivate: boolean;
|
||||
tags?: string[];
|
||||
createdAt: number;
|
||||
sourceId?: string;
|
||||
sourceName?: string;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
export interface ConfiguredDefaultServerDefinition {
|
||||
key?: string;
|
||||
name?: string;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<div class="min-h-full bg-background px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto flex min-h-[calc(100vh-8rem)] max-w-4xl items-center justify-center">
|
||||
<div class="w-full overflow-hidden rounded-3xl border border-border bg-card/90 shadow-2xl backdrop-blur">
|
||||
<div class="border-b border-border bg-gradient-to-br from-primary/20 via-transparent to-blue-500/10 px-6 py-8 sm:px-10">
|
||||
<div
|
||||
class="inline-flex items-center rounded-full border border-border bg-secondary/70 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.25em] text-muted-foreground"
|
||||
>
|
||||
Invite link
|
||||
</div>
|
||||
<h1 class="mt-4 text-3xl font-semibold tracking-tight text-foreground sm:text-4xl">
|
||||
@if (invite()) {
|
||||
Join {{ invite()!.server.name }}
|
||||
} @else {
|
||||
Toju server invite
|
||||
}
|
||||
</h1>
|
||||
<p class="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground sm:text-base">
|
||||
@switch (status()) {
|
||||
@case ('redirecting') {
|
||||
Sign in to continue with this invite.
|
||||
}
|
||||
@case ('joining') {
|
||||
We are connecting you to the invited server.
|
||||
}
|
||||
@case ('error') {
|
||||
This invite could not be completed automatically.
|
||||
}
|
||||
@default {
|
||||
Loading invite details and preparing the correct signal server.
|
||||
}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 px-6 py-8 sm:px-10 lg:grid-cols-[1.2fr,0.8fr]">
|
||||
<section class="space-y-4">
|
||||
<div class="rounded-2xl border border-border bg-secondary/20 p-5">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">Status</h2>
|
||||
<p class="mt-3 text-lg font-medium text-foreground">{{ message() }}</p>
|
||||
</div>
|
||||
|
||||
@if (invite()) {
|
||||
<div class="rounded-2xl border border-border bg-secondary/20 p-5">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">Server</h2>
|
||||
<p class="mt-3 text-xl font-semibold text-foreground">{{ invite()!.server.name }}</p>
|
||||
@if (invite()!.server.description) {
|
||||
<p class="mt-2 text-sm leading-6 text-muted-foreground">{{ invite()!.server.description }}</p>
|
||||
}
|
||||
<div class="mt-4 flex flex-wrap gap-2 text-xs">
|
||||
@if (invite()!.server.isPrivate) {
|
||||
<span class="rounded-full bg-secondary px-2.5 py-1 text-muted-foreground">Private</span>
|
||||
}
|
||||
@if (invite()!.server.hasPassword) {
|
||||
<span class="rounded-full bg-secondary px-2.5 py-1 text-muted-foreground">Password bypassed by invite</span>
|
||||
}
|
||||
<span class="rounded-full bg-primary/10 px-2.5 py-1 text-primary"> Expires {{ invite()!.expiresAt | date: 'medium' }} </span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<aside class="space-y-4">
|
||||
<div class="rounded-2xl border border-border bg-secondary/20 p-5">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">What happens next</h2>
|
||||
<ul class="mt-4 space-y-3 text-sm leading-6 text-muted-foreground">
|
||||
<li>• The linked signal server is added to your configured server list if needed.</li>
|
||||
<li>• Invite links bypass private and password restrictions.</li>
|
||||
<li>• Banned users still cannot join through invites.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@if (status() === 'error') {
|
||||
<button
|
||||
type="button"
|
||||
(click)="goToSearch()"
|
||||
class="inline-flex w-full items-center justify-center rounded-2xl bg-primary px-4 py-3 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Back to server search
|
||||
</button>
|
||||
}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,193 @@
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import type { ServerInviteInfo } from '../../domain/server-directory.models';
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
|
||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import { ServerDirectoryFacade } from '../../application/server-directory.facade';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
|
||||
@Component({
|
||||
selector: 'app-invite',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './invite.component.html'
|
||||
})
|
||||
export class InviteComponent implements OnInit {
|
||||
readonly currentUser = inject(Store).selectSignal(selectCurrentUser);
|
||||
readonly invite = signal<ServerInviteInfo | null>(null);
|
||||
readonly status = signal<'loading' | 'redirecting' | 'joining' | 'error'>('loading');
|
||||
readonly message = signal('Loading invite…');
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const inviteContext = this.resolveInviteContext();
|
||||
|
||||
if (!inviteContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||
|
||||
if (!currentUserId) {
|
||||
await this.redirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.joinInvite(inviteContext, currentUserId);
|
||||
} catch (error: unknown) {
|
||||
this.applyInviteError(error);
|
||||
}
|
||||
}
|
||||
|
||||
goToSearch(): void {
|
||||
this.router.navigate(['/search']).catch(() => {});
|
||||
}
|
||||
|
||||
private buildEndpointName(sourceUrl: string): string {
|
||||
try {
|
||||
const url = new URL(sourceUrl);
|
||||
|
||||
return url.hostname;
|
||||
} catch {
|
||||
return 'Signal Server';
|
||||
}
|
||||
}
|
||||
|
||||
private applyInviteError(error: unknown): void {
|
||||
const inviteError = error as {
|
||||
error?: { error?: string; errorCode?: string };
|
||||
};
|
||||
const errorCode = inviteError?.error?.errorCode;
|
||||
const fallbackMessage = inviteError?.error?.error || 'Unable to accept this invite.';
|
||||
|
||||
this.status.set('error');
|
||||
|
||||
if (errorCode === 'BANNED') {
|
||||
this.message.set('You are banned from this server and cannot accept this invite.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorCode === 'INVITE_EXPIRED') {
|
||||
this.message.set('This invite has expired. Ask for a fresh invite link.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.message.set(fallbackMessage);
|
||||
}
|
||||
|
||||
private async hydrateCurrentUser(): Promise<User | null> {
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
if (currentUser) {
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
const storedUser = await this.databaseService.getCurrentUser();
|
||||
|
||||
if (storedUser) {
|
||||
this.store.dispatch(UsersActions.setCurrentUser({ user: storedUser }));
|
||||
}
|
||||
|
||||
return storedUser;
|
||||
}
|
||||
|
||||
private async joinInvite(
|
||||
context: { endpoint: { id: string; name: string }; inviteId: string; sourceUrl: string },
|
||||
currentUserId: string
|
||||
): Promise<void> {
|
||||
const invite = await firstValueFrom(this.serverDirectory.getInvite(context.inviteId, {
|
||||
sourceId: context.endpoint.id,
|
||||
sourceUrl: context.sourceUrl
|
||||
}));
|
||||
|
||||
this.invite.set(invite);
|
||||
this.status.set('joining');
|
||||
this.message.set(`Joining ${invite.server.name}…`);
|
||||
|
||||
const currentUser = await this.hydrateCurrentUser();
|
||||
const joinResponse = await firstValueFrom(this.serverDirectory.requestJoin({
|
||||
roomId: invite.server.id,
|
||||
userId: currentUserId,
|
||||
userPublicKey: currentUser?.oderId || currentUserId,
|
||||
displayName: currentUser?.displayName || 'Anonymous',
|
||||
inviteId: context.inviteId
|
||||
}, {
|
||||
sourceId: context.endpoint.id,
|
||||
sourceUrl: context.sourceUrl
|
||||
}));
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.joinRoom({
|
||||
roomId: joinResponse.server.id,
|
||||
serverInfo: {
|
||||
...joinResponse.server,
|
||||
sourceId: context.endpoint.id,
|
||||
sourceName: context.endpoint.name,
|
||||
sourceUrl: context.sourceUrl
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async redirectToLogin(): Promise<void> {
|
||||
this.status.set('redirecting');
|
||||
this.message.set('Redirecting to login…');
|
||||
|
||||
await this.router.navigate(['/login'], {
|
||||
queryParams: {
|
||||
returnUrl: this.router.url
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resolveInviteContext(): {
|
||||
endpoint: { id: string; name: string };
|
||||
inviteId: string;
|
||||
sourceUrl: string;
|
||||
} | null {
|
||||
const inviteId = this.route.snapshot.paramMap.get('inviteId')?.trim() || '';
|
||||
const sourceUrl = this.route.snapshot.queryParamMap.get('server')?.trim() || '';
|
||||
|
||||
if (!inviteId || !sourceUrl) {
|
||||
this.status.set('error');
|
||||
this.message.set('This invite link is missing required server information.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const endpoint = this.serverDirectory.ensureServerEndpoint({
|
||||
name: this.buildEndpointName(sourceUrl),
|
||||
url: sourceUrl
|
||||
}, {
|
||||
setActive: !localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID)
|
||||
});
|
||||
|
||||
return {
|
||||
endpoint: {
|
||||
id: endpoint.id,
|
||||
name: endpoint.name
|
||||
},
|
||||
inviteId,
|
||||
sourceUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
<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)"
|
||||
type="button"
|
||||
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()"
|
||||
type="button"
|
||||
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()"
|
||||
type="button"
|
||||
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)"
|
||||
type="button"
|
||||
class="w-full p-4 bg-card rounded-lg border transition-all text-left group"
|
||||
[class.border-border]="!isServerMarkedBanned(server)"
|
||||
[class.hover:border-primary/50]="!isServerMarkedBanned(server)"
|
||||
[class.hover:bg-card/80]="!isServerMarkedBanned(server)"
|
||||
[class.border-destructive/40]="isServerMarkedBanned(server)"
|
||||
[class.bg-destructive/5]="isServerMarkedBanned(server)"
|
||||
[class.hover:border-destructive/60]="isServerMarkedBanned(server)"
|
||||
[attr.aria-label]="isServerMarkedBanned(server) ? 'Banned server' : 'Join server'"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3
|
||||
class="font-semibold transition-colors"
|
||||
[class.text-foreground]="!isServerMarkedBanned(server)"
|
||||
[class.group-hover:text-primary]="!isServerMarkedBanned(server)"
|
||||
[class.text-destructive]="isServerMarkedBanned(server)"
|
||||
>
|
||||
{{ server.name }}
|
||||
</h3>
|
||||
@if (isServerMarkedBanned(server)) {
|
||||
<ng-icon
|
||||
name="lucideLock"
|
||||
class="w-4 h-4 text-destructive"
|
||||
/>
|
||||
<span class="inline-flex items-center rounded-full bg-destructive/10 px-2 py-0.5 text-[11px] font-medium text-destructive"
|
||||
>Banned</span
|
||||
>
|
||||
} @else if (server.isPrivate) {
|
||||
<ng-icon
|
||||
name="lucideLock"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<span class="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
|
||||
>Private</span
|
||||
>
|
||||
} @else if (server.hasPassword) {
|
||||
<ng-icon
|
||||
name="lucideLock"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<span class="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
|
||||
>Password</span
|
||||
>
|
||||
} @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>{{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 space-y-1 text-xs">
|
||||
<div class="text-muted-foreground">
|
||||
Users: <span class="text-foreground/80">{{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }}</span>
|
||||
</div>
|
||||
<div class="text-muted-foreground">
|
||||
Listed by: <span class="text-foreground/80">{{ server.sourceName || server.hostName || 'Unknown' }}</span>
|
||||
</div>
|
||||
<div class="text-muted-foreground">
|
||||
Owner: <span class="text-foreground/80">{{ server.ownerName || server.ownerId || 'Unknown' }}</span>
|
||||
</div>
|
||||
@if (server.hasPassword && !server.isPrivate && !isServerMarkedBanned(server)) {
|
||||
<div class="text-muted-foreground">Access: <span class="text-foreground/80">Password required</span></div>
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (joinErrorMessage() || error()) {
|
||||
<div class="p-4 bg-destructive/10 border-t border-destructive">
|
||||
<p class="text-sm text-destructive">{{ joinErrorMessage() || error() }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showBannedDialog()) {
|
||||
<app-confirm-dialog
|
||||
title="Banned"
|
||||
confirmLabel="OK"
|
||||
cancelLabel="Close"
|
||||
variant="danger"
|
||||
[widthClass]="'w-96 max-w-[90vw]'"
|
||||
(confirmed)="closeBannedDialog()"
|
||||
(cancelled)="closeBannedDialog()"
|
||||
>
|
||||
<p>You are banned from {{ bannedServerName() || 'this server' }}.</p>
|
||||
</app-confirm-dialog>
|
||||
}
|
||||
|
||||
@if (showPasswordDialog() && passwordPromptServer()) {
|
||||
<app-confirm-dialog
|
||||
title="Password required"
|
||||
confirmLabel="Join server"
|
||||
cancelLabel="Cancel"
|
||||
[widthClass]="'w-[420px] max-w-[92vw]'"
|
||||
(confirmed)="confirmPasswordJoin()"
|
||||
(cancelled)="closePasswordDialog()"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<p>Enter the password to join {{ passwordPromptServer()!.name }}.</p>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="join-server-password"
|
||||
class="mb-1 block text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Server password
|
||||
</label>
|
||||
<input
|
||||
id="join-server-password"
|
||||
type="password"
|
||||
[(ngModel)]="joinPassword"
|
||||
placeholder="Enter password"
|
||||
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if (joinPasswordError()) {
|
||||
<p class="text-sm text-destructive">{{ joinPasswordError() }}</p>
|
||||
}
|
||||
</div>
|
||||
</app-confirm-dialog>
|
||||
}
|
||||
|
||||
<!-- Create Server Dialog -->
|
||||
@if (showCreateDialog()) {
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
(click)="closeCreateDialog()"
|
||||
(keydown.enter)="closeCreateDialog()"
|
||||
(keydown.space)="closeCreateDialog()"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Close create server dialog"
|
||||
>
|
||||
<div
|
||||
class="bg-card border border-border rounded-lg p-6 w-full max-w-md m-4"
|
||||
(click)="$event.stopPropagation()"
|
||||
(keydown.enter)="$event.stopPropagation()"
|
||||
(keydown.space)="$event.stopPropagation()"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<h2 class="text-xl font-semibold text-foreground mb-4">Create Server</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="create-server-name"
|
||||
class="block text-sm font-medium text-foreground mb-1"
|
||||
>Server Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newServerName"
|
||||
placeholder="My Awesome Server"
|
||||
id="create-server-name"
|
||||
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
|
||||
for="create-server-description"
|
||||
class="block text-sm font-medium text-foreground mb-1"
|
||||
>Description (optional)</label
|
||||
>
|
||||
<textarea
|
||||
[(ngModel)]="newServerDescription"
|
||||
placeholder="What's your server about?"
|
||||
rows="3"
|
||||
id="create-server-description"
|
||||
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
|
||||
for="create-server-topic"
|
||||
class="block text-sm font-medium text-foreground mb-1"
|
||||
>Topic (optional)</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newServerTopic"
|
||||
placeholder="gaming, music, coding..."
|
||||
id="create-server-topic"
|
||||
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
|
||||
for="create-server-signal-endpoint"
|
||||
class="block text-sm font-medium text-foreground mb-1"
|
||||
>Signal Server Endpoint</label
|
||||
>
|
||||
<select
|
||||
id="create-server-signal-endpoint"
|
||||
[(ngModel)]="newServerSourceId"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
@for (endpoint of activeEndpoints(); track endpoint.id) {
|
||||
<option [value]="endpoint.id">{{ endpoint.name }} ({{ endpoint.url }})</option>
|
||||
}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-muted-foreground">This endpoint handles all signaling for this chat server.</p>
|
||||
</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>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="create-server-password"
|
||||
class="block text-sm font-medium text-foreground mb-1"
|
||||
>Password (optional)</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
[(ngModel)]="newServerPassword"
|
||||
placeholder="Leave blank to allow joining without a password"
|
||||
id="create-server-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"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Users who already joined keep access even if you change the password later.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button
|
||||
(click)="closeCreateDialog()"
|
||||
type="button"
|
||||
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() || !newServerSourceId"
|
||||
type="button"
|
||||
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>
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
effect,
|
||||
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,
|
||||
firstValueFrom,
|
||||
Subject
|
||||
} from 'rxjs';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideSearch,
|
||||
lucideUsers,
|
||||
lucideLock,
|
||||
lucideGlobe,
|
||||
lucidePlus,
|
||||
lucideSettings
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import {
|
||||
selectSearchResults,
|
||||
selectIsSearching,
|
||||
selectRoomsError,
|
||||
selectSavedRooms
|
||||
} from '../../../../store/rooms/rooms.selectors';
|
||||
import {
|
||||
Room,
|
||||
User
|
||||
} from '../../../../shared-kernel';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import { type ServerInfo } from '../../domain/server-directory.models';
|
||||
import { ServerDirectoryFacade } from '../../application/server-directory.facade';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { ConfirmDialogComponent } from '../../../../shared';
|
||||
import { hasRoomBanForUser } from '../../../../core/helpers/room-ban.helpers';
|
||||
|
||||
@Component({
|
||||
selector: 'app-server-search',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
ConfirmDialogComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideSearch,
|
||||
lucideUsers,
|
||||
lucideLock,
|
||||
lucideGlobe,
|
||||
lucidePlus,
|
||||
lucideSettings
|
||||
})
|
||||
],
|
||||
templateUrl: './server-search.component.html'
|
||||
})
|
||||
/**
|
||||
* Server search and discovery view with server creation dialog.
|
||||
* Allows users to search for, join, and create new servers.
|
||||
*/
|
||||
export class ServerSearchComponent implements OnInit {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
private db = inject(DatabaseService);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private searchSubject = new Subject<string>();
|
||||
private banLookupRequestVersion = 0;
|
||||
|
||||
searchQuery = '';
|
||||
searchResults = this.store.selectSignal(selectSearchResults);
|
||||
isSearching = this.store.selectSignal(selectIsSearching);
|
||||
error = this.store.selectSignal(selectRoomsError);
|
||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
activeEndpoints = this.serverDirectory.activeServers;
|
||||
bannedServerLookup = signal<Record<string, boolean>>({});
|
||||
bannedServerName = signal('');
|
||||
showBannedDialog = signal(false);
|
||||
showPasswordDialog = signal(false);
|
||||
passwordPromptServer = signal<ServerInfo | null>(null);
|
||||
joinPassword = signal('');
|
||||
joinPasswordError = signal<string | null>(null);
|
||||
joinErrorMessage = signal<string | null>(null);
|
||||
|
||||
// Create dialog state
|
||||
showCreateDialog = signal(false);
|
||||
newServerName = signal('');
|
||||
newServerDescription = signal('');
|
||||
newServerTopic = signal('');
|
||||
newServerPrivate = signal(false);
|
||||
newServerPassword = signal('');
|
||||
newServerSourceId = '';
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const servers = this.searchResults();
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
void this.refreshBannedLookup(servers, currentUser ?? null);
|
||||
});
|
||||
}
|
||||
|
||||
/** Initialize server search, load saved rooms, and set up debounced search. */
|
||||
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 }));
|
||||
});
|
||||
}
|
||||
|
||||
/** Emit a search query to the debounced search subject. */
|
||||
onSearchChange(query: string): void {
|
||||
this.searchSubject.next(query);
|
||||
}
|
||||
|
||||
/** Join a server from the search results. Redirects to login if unauthenticated. */
|
||||
async joinServer(server: ServerInfo): Promise<void> {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await this.isServerBanned(server)) {
|
||||
this.bannedServerName.set(server.name);
|
||||
this.showBannedDialog.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.attemptJoinServer(server);
|
||||
}
|
||||
|
||||
/** Open the create-server dialog. */
|
||||
openCreateDialog(): void {
|
||||
this.newServerSourceId = this.activeEndpoints()[0]?.id ?? '';
|
||||
this.showCreateDialog.set(true);
|
||||
}
|
||||
|
||||
/** Close the create-server dialog and reset the form. */
|
||||
closeCreateDialog(): void {
|
||||
this.showCreateDialog.set(false);
|
||||
this.resetCreateForm();
|
||||
}
|
||||
|
||||
/** Submit the new server creation form and dispatch the create action. */
|
||||
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.newServerPassword().trim() || undefined,
|
||||
sourceId: this.newServerSourceId || undefined
|
||||
})
|
||||
);
|
||||
|
||||
this.closeCreateDialog();
|
||||
}
|
||||
|
||||
/** Open the unified settings modal to the Network page. */
|
||||
openSettings(): void {
|
||||
this.settingsModal.open('network');
|
||||
}
|
||||
|
||||
/** Join a previously saved room by converting it to a ServerInfo payload. */
|
||||
joinSavedRoom(room: Room): void {
|
||||
void this.joinServer(this.toServerInfo(room));
|
||||
}
|
||||
|
||||
closeBannedDialog(): void {
|
||||
this.showBannedDialog.set(false);
|
||||
this.bannedServerName.set('');
|
||||
}
|
||||
|
||||
closePasswordDialog(): void {
|
||||
this.showPasswordDialog.set(false);
|
||||
this.passwordPromptServer.set(null);
|
||||
this.joinPassword.set('');
|
||||
this.joinPasswordError.set(null);
|
||||
}
|
||||
|
||||
async confirmPasswordJoin(): Promise<void> {
|
||||
const server = this.passwordPromptServer();
|
||||
|
||||
if (!server)
|
||||
return;
|
||||
|
||||
await this.attemptJoinServer(server, this.joinPassword());
|
||||
}
|
||||
|
||||
isServerMarkedBanned(server: ServerInfo): boolean {
|
||||
return !!this.bannedServerLookup()[server.id];
|
||||
}
|
||||
|
||||
getServerUserCount(server: ServerInfo): number {
|
||||
const candidate = server as ServerInfo & { currentUsers?: number };
|
||||
|
||||
if (typeof server.userCount === 'number')
|
||||
return server.userCount;
|
||||
|
||||
return typeof candidate.currentUsers === 'number' ? candidate.currentUsers : 0;
|
||||
}
|
||||
|
||||
getServerCapacityLabel(server: ServerInfo): string {
|
||||
return server.maxUsers > 0 ? String(server.maxUsers) : '∞';
|
||||
}
|
||||
|
||||
private toServerInfo(room: Room): ServerInfo {
|
||||
return {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
hostName: room.hostId || 'Unknown',
|
||||
userCount: room.userCount ?? 0,
|
||||
maxUsers: room.maxUsers ?? 50,
|
||||
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
|
||||
isPrivate: room.isPrivate,
|
||||
createdAt: room.createdAt,
|
||||
ownerId: room.hostId,
|
||||
sourceId: room.sourceId,
|
||||
sourceName: room.sourceName,
|
||||
sourceUrl: room.sourceUrl
|
||||
};
|
||||
}
|
||||
|
||||
private async attemptJoinServer(server: ServerInfo, password?: string): Promise<void> {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
return;
|
||||
}
|
||||
|
||||
this.joinErrorMessage.set(null);
|
||||
this.joinPasswordError.set(null);
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(this.serverDirectory.requestJoin({
|
||||
roomId: server.id,
|
||||
userId: currentUserId,
|
||||
userPublicKey: currentUser?.oderId || currentUserId,
|
||||
displayName: currentUser?.displayName || 'Anonymous',
|
||||
password: password?.trim() || undefined
|
||||
}, {
|
||||
sourceId: server.sourceId,
|
||||
sourceUrl: server.sourceUrl
|
||||
}));
|
||||
const resolvedServer = response.server ?? server;
|
||||
|
||||
this.closePasswordDialog();
|
||||
this.store.dispatch(
|
||||
RoomsActions.joinRoom({
|
||||
roomId: resolvedServer.id,
|
||||
serverInfo: resolvedServer
|
||||
})
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const serverError = error as {
|
||||
error?: { error?: string; errorCode?: string };
|
||||
};
|
||||
const errorCode = serverError?.error?.errorCode;
|
||||
const message = serverError?.error?.error || 'Failed to join server';
|
||||
|
||||
if (errorCode === 'PASSWORD_REQUIRED') {
|
||||
this.passwordPromptServer.set(server);
|
||||
this.showPasswordDialog.set(true);
|
||||
this.joinPasswordError.set(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorCode === 'BANNED') {
|
||||
this.bannedServerName.set(server.name);
|
||||
this.showBannedDialog.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.joinErrorMessage.set(message);
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshBannedLookup(servers: ServerInfo[], currentUser: User | null): Promise<void> {
|
||||
const requestVersion = ++this.banLookupRequestVersion;
|
||||
|
||||
if (!currentUser || servers.length === 0) {
|
||||
this.bannedServerLookup.set({});
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
const entries = await Promise.all(
|
||||
servers.map(async (server) => {
|
||||
const bans = await this.db.getBansForRoom(server.id);
|
||||
const isBanned = hasRoomBanForUser(bans, currentUser, currentUserId);
|
||||
|
||||
return [server.id, isBanned] as const;
|
||||
})
|
||||
);
|
||||
|
||||
if (requestVersion !== this.banLookupRequestVersion)
|
||||
return;
|
||||
|
||||
this.bannedServerLookup.set(Object.fromEntries(entries));
|
||||
}
|
||||
|
||||
private async isServerBanned(server: ServerInfo): Promise<boolean> {
|
||||
const currentUser = this.currentUser();
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUser && !currentUserId)
|
||||
return false;
|
||||
|
||||
const bans = await this.db.getBansForRoom(server.id);
|
||||
|
||||
return hasRoomBanForUser(bans, currentUser, currentUserId);
|
||||
}
|
||||
|
||||
private resetCreateForm(): void {
|
||||
this.newServerName.set('');
|
||||
this.newServerDescription.set('');
|
||||
this.newServerTopic.set('');
|
||||
this.newServerPrivate.set(false);
|
||||
this.newServerPassword.set('');
|
||||
this.newServerSourceId = this.activeEndpoints()[0]?.id ?? '';
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,14 @@ import {
|
||||
throwError
|
||||
} from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { ServerInfo, User } from '../../../core/models';
|
||||
import { User } from '../../../shared-kernel';
|
||||
import { ServerEndpointStateService } from '../application/server-endpoint-state.service';
|
||||
import type {
|
||||
BanServerMemberRequest,
|
||||
CreateServerInviteRequest,
|
||||
KickServerMemberRequest,
|
||||
ServerEndpoint,
|
||||
ServerInfo,
|
||||
ServerInviteInfo,
|
||||
ServerJoinAccessRequest,
|
||||
ServerJoinAccessResponse,
|
||||
|
||||
97
src/app/domains/voice-connection/README.md
Normal file
97
src/app/domains/voice-connection/README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Voice Connection Domain
|
||||
|
||||
Bridges the application layer to the low-level realtime infrastructure for voice calls. Provides speaking detection via Web Audio analysis and per-peer volume control for playback. The actual WebRTC plumbing lives in `infrastructure/realtime`; this domain wraps it with a clean facade.
|
||||
|
||||
## Module map
|
||||
|
||||
```
|
||||
voice-connection/
|
||||
├── application/
|
||||
│ ├── voice-connection.facade.ts Proxy to RealtimeSessionFacade for voice signals and methods
|
||||
│ ├── voice-activity.service.ts RMS-based speaking detection via AnalyserNode (per-user signals)
|
||||
│ └── voice-playback.service.ts Per-peer GainNode chain, 0-200% volume, deafen support
|
||||
│
|
||||
├── domain/
|
||||
│ └── voice-connection.models.ts Re-exports LatencyProfile, VoiceStateSnapshot from shared-kernel / realtime
|
||||
│
|
||||
└── index.ts Barrel exports
|
||||
```
|
||||
|
||||
## Service relationships
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
VCF[VoiceConnectionFacade]
|
||||
VAS[VoiceActivityService]
|
||||
VPS[VoicePlaybackService]
|
||||
RSF[RealtimeSessionFacade]
|
||||
Models[voice-connection.models]
|
||||
|
||||
VCF --> RSF
|
||||
VAS --> VCF
|
||||
VPS --> VCF
|
||||
|
||||
click VCF "application/voice-connection.facade.ts" "Proxy to RealtimeSessionFacade"
|
||||
click VAS "application/voice-activity.service.ts" "RMS-based speaking detection"
|
||||
click VPS "application/voice-playback.service.ts" "Per-peer GainNode volume chain"
|
||||
click RSF "../../infrastructure/realtime/realtime-session.service.ts" "Low-level WebRTC composition root"
|
||||
click Models "domain/voice-connection.models.ts" "Re-exported types"
|
||||
```
|
||||
|
||||
## Voice connection facade
|
||||
|
||||
`VoiceConnectionFacade` exposes signals and methods from `RealtimeSessionFacade` without leaking infrastructure details into feature components. It covers:
|
||||
|
||||
- Connection state: `isVoiceConnected`, `isMuted`, `isDeafened`, `hasConnectionError`
|
||||
- Stream access: `getRemoteVoiceStream`, `getLocalStream`, `getRawMicStream`
|
||||
- Controls: `enableVoice`, `disableVoice`, `toggleMute`, `toggleDeafen`, `toggleNoiseReduction`
|
||||
- Audio tuning: `setOutputVolume`, `setInputVolume`, `setAudioBitrate`, `setLatencyProfile`
|
||||
- Peer events: `onRemoteStream`, `onPeerConnected`, `onPeerDisconnected`
|
||||
- Heartbeat: `startVoiceHeartbeat`, `stopVoiceHeartbeat`
|
||||
|
||||
## Speaking detection
|
||||
|
||||
`VoiceActivityService` monitors audio levels for local and remote streams using the Web Audio API. Each tracked stream gets its own `AudioContext` with an `AnalyserNode`. A single `requestAnimationFrame` loop polls all analysers.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Stream[MediaStream] --> Ctx[AudioContext]
|
||||
Ctx --> Src[MediaStreamAudioSourceNode]
|
||||
Src --> Analyser[AnalyserNode<br/>fftSize = 256]
|
||||
Analyser --> Poll[rAF poll loop]
|
||||
Poll --> RMS{RMS >= 0.015?}
|
||||
RMS -- yes --> Speaking[speakingSignal = true]
|
||||
RMS -- no, 8 frames --> Silent[speakingSignal = false]
|
||||
|
||||
click Stream "application/voice-activity.service.ts" "VoiceActivityService.trackStream()"
|
||||
click Poll "application/voice-activity.service.ts" "VoiceActivityService.poll()"
|
||||
```
|
||||
|
||||
| Parameter | Value |
|
||||
|---|---|
|
||||
| FFT size | 256 samples |
|
||||
| Speaking threshold | RMS >= 0.015 |
|
||||
| Silent grace period | 8 consecutive frames below threshold |
|
||||
|
||||
The service exposes `isSpeaking(userId)` and `volume(userId)` as Angular signals. It automatically tracks remote peers via the `onRemoteStream` and `onPeerDisconnected` observables. Local mic tracking is started explicitly by calling `trackLocalMic(userId, stream)`.
|
||||
|
||||
A reactive `speakingMap` signal (a `Map<string, boolean>`) is published whenever any user's speaking state changes, so components can bind directly.
|
||||
|
||||
## Voice playback
|
||||
|
||||
`VoicePlaybackService` handles audio output for remote peers. Each peer gets an independent Web Audio pipeline:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Remote[Remote stream] --> Src[MediaStreamAudioSourceNode]
|
||||
Src --> Gain[GainNode<br/>0 - 200%]
|
||||
Gain --> Dest[MediaStreamAudioDestinationNode]
|
||||
Dest --> Audio[HTMLAudioElement<br/>.play]
|
||||
|
||||
click Remote "application/voice-playback.service.ts" "VoicePlaybackService.setupPeer()"
|
||||
click Gain "application/voice-playback.service.ts" "VoicePlaybackService.setUserVolume()"
|
||||
```
|
||||
|
||||
Volume per peer is stored in localStorage and restored on reconnect. The range is 0% to 200% (gain values 0.0 to 2.0). When the user deafens, all gain nodes are set to zero; undeafening restores the previous values.
|
||||
|
||||
A Chrome workaround attaches a muted `<audio>` element to keep the `AudioContext` from suspending when no audible output is detected.
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { ChatEvent } from '../../../core/models/index';
|
||||
import { ChatEvent } from '../../../shared-kernel';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { LatencyProfile } from '../domain/voice-connection.models';
|
||||
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import {
|
||||
Injectable,
|
||||
effect,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { STORAGE_KEY_USER_VOLUMES } from '../../../core/constants';
|
||||
import { ScreenShareFacade } from '../../../domains/screen-share';
|
||||
import { VoiceConnectionFacade } from './voice-connection.facade';
|
||||
|
||||
export interface PlaybackOptions {
|
||||
isConnected: boolean;
|
||||
outputVolume: number;
|
||||
isDeafened: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-peer Web Audio pipeline that routes the remote MediaStream
|
||||
* through a GainNode so volume can be amplified beyond 100% (up to 200%).
|
||||
*
|
||||
* Chrome/Electron workaround: a muted HTMLAudioElement is attached to
|
||||
* the stream first so that `createMediaStreamSource` actually outputs
|
||||
* audio. The priming element itself is silent; audible output is routed
|
||||
* through a separate output element fed by
|
||||
* `GainNode -> MediaStreamDestination` so output-device switching stays
|
||||
* reliable during Linux screen sharing.
|
||||
*/
|
||||
interface PeerAudioPipeline {
|
||||
audioElement: HTMLAudioElement;
|
||||
outputElement: HTMLAudioElement;
|
||||
context: AudioContext;
|
||||
sourceNodes: MediaStreamAudioSourceNode[];
|
||||
gainNode: GainNode;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VoicePlaybackService {
|
||||
private readonly voiceConnection = inject(VoiceConnectionFacade);
|
||||
private readonly screenShare = inject(ScreenShareFacade);
|
||||
|
||||
private peerPipelines = new Map<string, PeerAudioPipeline>();
|
||||
private pendingRemoteStreams = new Map<string, MediaStream>();
|
||||
private rawRemoteStreams = new Map<string, MediaStream>();
|
||||
private userVolumes = new Map<string, number>();
|
||||
private userMuted = new Map<string, boolean>();
|
||||
private preferredOutputDeviceId = 'default';
|
||||
private temporaryOutputDeviceId: string | null = null;
|
||||
private masterVolume = 1;
|
||||
private deafened = false;
|
||||
private captureEchoSuppressed = false;
|
||||
|
||||
constructor() {
|
||||
this.loadPersistedVolumes();
|
||||
|
||||
effect(() => {
|
||||
this.captureEchoSuppressed = this.screenShare.isScreenShareRemotePlaybackSuppressed();
|
||||
this.recalcAllGains();
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
this.temporaryOutputDeviceId = this.screenShare.forceDefaultRemotePlaybackOutput()
|
||||
? 'default'
|
||||
: null;
|
||||
|
||||
void this.applyEffectiveOutputDeviceToAllPipelines();
|
||||
});
|
||||
|
||||
this.voiceConnection.onRemoteStream.subscribe(({ peerId }) => {
|
||||
const voiceStream = this.voiceConnection.getRemoteVoiceStream(peerId);
|
||||
|
||||
if (!voiceStream) {
|
||||
this.removeRemoteAudio(peerId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleRemoteStream(peerId, voiceStream, this.buildPlaybackOptions());
|
||||
});
|
||||
|
||||
this.voiceConnection.onVoiceConnected.subscribe(() => {
|
||||
const options = this.buildPlaybackOptions(true);
|
||||
|
||||
this.playPendingStreams(options);
|
||||
this.ensureAllRemoteStreamsPlaying(options);
|
||||
});
|
||||
|
||||
this.voiceConnection.onPeerDisconnected.subscribe((peerId) => {
|
||||
this.removeRemoteAudio(peerId);
|
||||
});
|
||||
}
|
||||
|
||||
handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void {
|
||||
if (!options.isConnected) {
|
||||
this.pendingRemoteStreams.set(peerId, stream);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.hasAudio(stream)) {
|
||||
this.rawRemoteStreams.delete(peerId);
|
||||
this.removePipeline(peerId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.removePipeline(peerId);
|
||||
this.rawRemoteStreams.set(peerId, stream);
|
||||
this.masterVolume = options.outputVolume;
|
||||
this.deafened = options.isDeafened;
|
||||
this.createPipeline(peerId, stream);
|
||||
}
|
||||
|
||||
removeRemoteAudio(peerId: string): void {
|
||||
this.pendingRemoteStreams.delete(peerId);
|
||||
this.rawRemoteStreams.delete(peerId);
|
||||
this.removePipeline(peerId);
|
||||
}
|
||||
|
||||
playPendingStreams(options: PlaybackOptions): void {
|
||||
if (!options.isConnected)
|
||||
return;
|
||||
|
||||
this.pendingRemoteStreams.forEach((stream, peerId) => this.handleRemoteStream(peerId, stream, options));
|
||||
this.pendingRemoteStreams.clear();
|
||||
}
|
||||
|
||||
ensureAllRemoteStreamsPlaying(options: PlaybackOptions): void {
|
||||
if (!options.isConnected)
|
||||
return;
|
||||
|
||||
const peers = this.voiceConnection.getConnectedPeers();
|
||||
|
||||
for (const peerId of peers) {
|
||||
const stream = this.voiceConnection.getRemoteVoiceStream(peerId);
|
||||
|
||||
if (stream && this.hasAudio(stream)) {
|
||||
const trackedRaw = this.rawRemoteStreams.get(peerId);
|
||||
|
||||
if (!trackedRaw || trackedRaw !== stream) {
|
||||
this.handleRemoteStream(peerId, stream, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateOutputVolume(volume: number): void {
|
||||
this.masterVolume = volume;
|
||||
this.recalcAllGains();
|
||||
}
|
||||
|
||||
updateDeafened(isDeafened: boolean): void {
|
||||
this.deafened = isDeafened;
|
||||
this.recalcAllGains();
|
||||
}
|
||||
|
||||
getUserVolume(peerId: string): number {
|
||||
return this.userVolumes.get(peerId) ?? 100;
|
||||
}
|
||||
|
||||
setUserVolume(peerId: string, volume: number): void {
|
||||
const clamped = Math.max(0, Math.min(200, volume));
|
||||
|
||||
this.userVolumes.set(peerId, clamped);
|
||||
this.applyGain(peerId);
|
||||
this.persistVolumes();
|
||||
}
|
||||
|
||||
isUserMuted(peerId: string): boolean {
|
||||
return this.userMuted.get(peerId) ?? false;
|
||||
}
|
||||
|
||||
setUserMuted(peerId: string, muted: boolean): void {
|
||||
this.userMuted.set(peerId, muted);
|
||||
this.applyGain(peerId);
|
||||
this.persistVolumes();
|
||||
}
|
||||
|
||||
applyOutputDevice(deviceId: string): void {
|
||||
this.preferredOutputDeviceId = deviceId || 'default';
|
||||
void this.applyEffectiveOutputDeviceToAllPipelines();
|
||||
}
|
||||
|
||||
teardownAll(): void {
|
||||
this.peerPipelines.forEach((_pipeline, peerId) => this.removePipeline(peerId));
|
||||
this.peerPipelines.clear();
|
||||
this.rawRemoteStreams.clear();
|
||||
this.pendingRemoteStreams.clear();
|
||||
}
|
||||
|
||||
private buildPlaybackOptions(forceConnected = this.voiceConnection.isVoiceConnected()): PlaybackOptions {
|
||||
return {
|
||||
isConnected: forceConnected,
|
||||
outputVolume: this.masterVolume,
|
||||
isDeafened: this.deafened
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Web Audio graph for a remote peer:
|
||||
*
|
||||
* remoteStream
|
||||
* ↓
|
||||
* muted <audio> element (Chrome workaround - primes the stream)
|
||||
* ↓
|
||||
* MediaStreamSource → GainNode → MediaStreamDestination → output <audio>
|
||||
*/
|
||||
private createPipeline(peerId: string, stream: MediaStream): void {
|
||||
// Chromium/Electron needs a muted <audio> element before Web Audio can read the stream.
|
||||
const audioEl = new Audio();
|
||||
const outputEl = new Audio();
|
||||
const audioTracks = stream.getAudioTracks().filter((track) => track.readyState === 'live');
|
||||
|
||||
audioEl.srcObject = stream;
|
||||
audioEl.muted = true;
|
||||
audioEl.play().catch(() => {});
|
||||
|
||||
const ctx = new AudioContext();
|
||||
const gainNode = ctx.createGain();
|
||||
const mediaDestination = ctx.createMediaStreamDestination();
|
||||
const sourceNodes = audioTracks.map((track) => ctx.createMediaStreamSource(new MediaStream([track])));
|
||||
|
||||
sourceNodes.forEach((sourceNode) => sourceNode.connect(gainNode));
|
||||
gainNode.connect(mediaDestination);
|
||||
|
||||
outputEl.srcObject = mediaDestination.stream;
|
||||
outputEl.muted = false;
|
||||
outputEl.volume = 1;
|
||||
outputEl.play().catch(() => {});
|
||||
|
||||
const pipeline: PeerAudioPipeline = {
|
||||
audioElement: audioEl,
|
||||
outputElement: outputEl,
|
||||
context: ctx,
|
||||
sourceNodes,
|
||||
gainNode
|
||||
};
|
||||
|
||||
this.peerPipelines.set(peerId, pipeline);
|
||||
|
||||
this.applyGain(peerId);
|
||||
void this.applyEffectiveOutputDeviceToPipeline(pipeline);
|
||||
}
|
||||
|
||||
private async applyEffectiveOutputDeviceToAllPipelines(): Promise<void> {
|
||||
await Promise.all(Array.from(this.peerPipelines.values(), (pipeline) =>
|
||||
this.applyEffectiveOutputDeviceToPipeline(pipeline)
|
||||
));
|
||||
}
|
||||
|
||||
private async applyEffectiveOutputDeviceToPipeline(pipeline: PeerAudioPipeline): Promise<void> {
|
||||
const deviceId = this.getEffectiveOutputDeviceId();
|
||||
|
||||
if (!deviceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
const anyAudio = pipeline.outputElement as any;
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
|
||||
if (typeof anyAudio.setSinkId === 'function') {
|
||||
tasks.push(anyAudio.setSinkId(deviceId).catch(() => undefined));
|
||||
}
|
||||
|
||||
if (tasks.length > 0) {
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
}
|
||||
|
||||
private getEffectiveOutputDeviceId(): string {
|
||||
return this.temporaryOutputDeviceId ?? this.preferredOutputDeviceId;
|
||||
}
|
||||
|
||||
private removePipeline(peerId: string): void {
|
||||
const pipeline = this.peerPipelines.get(peerId);
|
||||
|
||||
if (!pipeline)
|
||||
return;
|
||||
|
||||
try {
|
||||
pipeline.gainNode.disconnect();
|
||||
} catch {
|
||||
// nodes may already be disconnected
|
||||
}
|
||||
|
||||
pipeline.sourceNodes.forEach((sourceNode) => {
|
||||
try {
|
||||
sourceNode.disconnect();
|
||||
} catch {
|
||||
// nodes may already be disconnected
|
||||
}
|
||||
});
|
||||
|
||||
pipeline.audioElement.srcObject = null;
|
||||
pipeline.audioElement.remove();
|
||||
pipeline.outputElement.srcObject = null;
|
||||
pipeline.outputElement.remove();
|
||||
|
||||
if (pipeline.context.state !== 'closed') {
|
||||
pipeline.context.close().catch(() => {});
|
||||
}
|
||||
|
||||
this.peerPipelines.delete(peerId);
|
||||
}
|
||||
|
||||
private applyGain(peerId: string): void {
|
||||
const pipeline = this.peerPipelines.get(peerId);
|
||||
|
||||
if (!pipeline)
|
||||
return;
|
||||
|
||||
if (this.deafened || this.captureEchoSuppressed || this.isUserMuted(peerId)) {
|
||||
pipeline.gainNode.gain.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const userVol = this.getUserVolume(peerId) / 100; // 0.0-2.0
|
||||
const effective = this.masterVolume * userVol;
|
||||
|
||||
pipeline.gainNode.gain.value = effective;
|
||||
}
|
||||
|
||||
private recalcAllGains(): void {
|
||||
this.peerPipelines.forEach((_pipeline, peerId) => this.applyGain(peerId));
|
||||
}
|
||||
|
||||
private persistVolumes(): void {
|
||||
try {
|
||||
const data: Record<string, { volume: number; muted: boolean }> = {};
|
||||
|
||||
this.userVolumes.forEach((vol, id) => {
|
||||
data[id] = { volume: vol, muted: this.userMuted.get(id) ?? false };
|
||||
});
|
||||
|
||||
// Also persist any muted-only entries
|
||||
this.userMuted.forEach((muted, id) => {
|
||||
if (!data[id]) {
|
||||
data[id] = { volume: 100, muted };
|
||||
}
|
||||
});
|
||||
|
||||
localStorage.setItem(STORAGE_KEY_USER_VOLUMES, JSON.stringify(data));
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}
|
||||
|
||||
private loadPersistedVolumes(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_USER_VOLUMES);
|
||||
|
||||
if (!raw)
|
||||
return;
|
||||
|
||||
const data = JSON.parse(raw) as Record<string, { volume: number; muted: boolean }>;
|
||||
|
||||
Object.entries(data).forEach(([id, entry]) => {
|
||||
if (typeof entry.volume === 'number') {
|
||||
this.userVolumes.set(id, entry.volume);
|
||||
}
|
||||
|
||||
if (entry.muted) {
|
||||
this.userMuted.set(id, true);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// corrupted data - ignore
|
||||
}
|
||||
}
|
||||
|
||||
private hasAudio(stream: MediaStream): boolean {
|
||||
return stream.getAudioTracks().length > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
export {
|
||||
LATENCY_PROFILE_BITRATES,
|
||||
type LatencyProfile
|
||||
} from '../../../infrastructure/realtime/realtime.constants';
|
||||
export { LATENCY_PROFILE_BITRATES } from '../../../infrastructure/realtime/realtime.constants';
|
||||
export type { LatencyProfile } from '../../../shared-kernel';
|
||||
export type { VoiceStateSnapshot } from '../../../infrastructure/realtime/realtime.types';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './application/voice-connection.facade';
|
||||
export * from './application/voice-activity.service';
|
||||
export * from './application/voice-playback.service';
|
||||
export * from './domain/voice-connection.models';
|
||||
|
||||
111
src/app/domains/voice-session/README.md
Normal file
111
src/app/domains/voice-session/README.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Voice Session Domain
|
||||
|
||||
Tracks voice session metadata across client-side navigation and manages the voice workspace UI state (expanded, minimized, hidden). This domain does not touch WebRTC directly; actual connections live in `voice-connection` and `infrastructure/realtime`.
|
||||
|
||||
## Module map
|
||||
|
||||
```
|
||||
voice-session/
|
||||
├── application/
|
||||
│ ├── voice-session.facade.ts Tracks active voice session, drives floating controls
|
||||
│ └── voice-workspace.service.ts Workspace mode (hidden/expanded/minimized), focused stream, mini-window position
|
||||
│
|
||||
├── domain/
|
||||
│ ├── voice-session.logic.ts isViewingVoiceSessionServer, buildVoiceSessionRoom
|
||||
│ └── voice-session.models.ts VoiceSessionInfo interface
|
||||
│
|
||||
├── infrastructure/
|
||||
│ └── voice-settings.storage.ts Persists audio device IDs, volumes, bitrate, latency, noise reduction to localStorage
|
||||
│
|
||||
├── feature/
|
||||
│ ├── voice-controls/ Full voice control panel (mic, deafen, devices, screen share, settings)
|
||||
│ └── floating-voice-controls/ Minimal overlay when user navigates away from the voice server
|
||||
│
|
||||
└── index.ts Barrel exports
|
||||
```
|
||||
|
||||
## How the pieces connect
|
||||
|
||||
The facade manages session bookkeeping. The workspace service owns view state. Settings storage provides persistence for user preferences. Neither service opens any WebRTC connections.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
VSF[VoiceSessionFacade]
|
||||
VWS[VoiceWorkspaceService]
|
||||
VSS[voiceSettingsStorage]
|
||||
Logic[voice-session.logic]
|
||||
VC[VoiceControlsComponent]
|
||||
FC[FloatingVoiceControlsComponent]
|
||||
Store[NgRx Store]
|
||||
|
||||
VC --> VSF
|
||||
VC --> VWS
|
||||
VC --> VSS
|
||||
FC --> VSF
|
||||
FC --> VWS
|
||||
VSF --> Logic
|
||||
VSF --> Store
|
||||
VWS --> VSF
|
||||
|
||||
click VSF "application/voice-session.facade.ts" "Tracks active voice session"
|
||||
click VWS "application/voice-workspace.service.ts" "Workspace mode and focused stream"
|
||||
click VSS "infrastructure/voice-settings.storage.ts" "localStorage persistence for audio settings"
|
||||
click Logic "domain/voice-session.logic.ts" "Pure helper functions"
|
||||
click VC "feature/voice-controls/" "Full voice control panel"
|
||||
click FC "feature/floating-voice-controls/" "Minimal floating overlay"
|
||||
```
|
||||
|
||||
## Session lifecycle
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> NoSession
|
||||
NoSession --> Active: startSession(info)
|
||||
Active --> Active: checkCurrentRoute(serverId)
|
||||
Active --> NoSession: endSession()
|
||||
|
||||
state Active {
|
||||
[*] --> ViewingServer
|
||||
ViewingServer --> AwayFromServer: navigated to different server
|
||||
AwayFromServer --> ViewingServer: navigated back / navigateToVoiceServer()
|
||||
}
|
||||
```
|
||||
|
||||
When a voice session is active and the user navigates away from the voice-connected server, `showFloatingControls` becomes `true` and the floating overlay appears. Clicking the overlay dispatches `RoomsActions.viewServer` to navigate back.
|
||||
|
||||
## Workspace modes
|
||||
|
||||
`VoiceWorkspaceService` controls the voice workspace panel state. The workspace is only visible when the user is viewing the voice-connected server.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Hidden
|
||||
Hidden --> Expanded: open()
|
||||
Expanded --> Minimized: minimize()
|
||||
Expanded --> Hidden: close() / showChat()
|
||||
Minimized --> Expanded: restore()
|
||||
Minimized --> Hidden: close()
|
||||
Expanded --> Hidden: voice session ends
|
||||
Minimized --> Hidden: voice session ends
|
||||
```
|
||||
|
||||
The minimized mode renders a draggable mini-window. Its position is tracked in `miniWindowPosition` and clamped to viewport bounds on resize. `focusedStreamId` controls which screen-share stream gets the widescreen treatment in expanded mode.
|
||||
|
||||
## Voice settings
|
||||
|
||||
Settings are stored in localStorage under a single JSON key. All values are validated and clamped on load to defend against corrupt storage.
|
||||
|
||||
| Setting | Default | Range |
|
||||
|---|---|---|
|
||||
| inputDevice | `""` | device ID string |
|
||||
| outputDevice | `""` | device ID string |
|
||||
| inputVolume | 100 | 0 -- 100 |
|
||||
| outputVolume | 100 | 0 -- 100 |
|
||||
| audioBitrate | 96 kbps | 32 -- 256 |
|
||||
| latencyProfile | `"balanced"` | low / balanced / high |
|
||||
| noiseReduction | `true` | boolean |
|
||||
| screenShareQuality | `"balanced"` | low / balanced / high |
|
||||
| askScreenShareQuality | `true` | boolean |
|
||||
| includeSystemAudio | `false` | boolean |
|
||||
|
||||
`loadVoiceSettingsFromStorage()` and `saveVoiceSettingsToStorage(patch)` are the only entry points. The save function merges the patch with the current stored value so callers only need to pass changed fields.
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Room } from '../../../core/models';
|
||||
import type { Room } from '../../../shared-kernel';
|
||||
import type { VoiceSessionInfo } from './voice-session.models';
|
||||
|
||||
export function isViewingVoiceSessionServer(
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
@if (showFloatingControls()) {
|
||||
<!-- Centered relative to rooms-side-panel (w-80 = 320px, so right-40 = 160px from right edge = center) -->
|
||||
<div class="fixed bottom-4 right-40 translate-x-1/2 z-50 bg-card border border-border rounded-xl shadow-lg">
|
||||
<div class="p-2 flex items-center gap-2">
|
||||
<!-- Back to server button -->
|
||||
<button
|
||||
(click)="navigateToServer()"
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 px-2 py-1 bg-primary/10 hover:bg-primary/20 text-primary rounded-lg transition-colors"
|
||||
title="Back to {{ voiceSession()?.serverName }}"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideArrowLeft"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
@if (voiceSession()?.serverIcon) {
|
||||
<img
|
||||
[src]="voiceSession()?.serverIcon"
|
||||
class="w-5 h-5 rounded object-cover"
|
||||
alt=""
|
||||
/>
|
||||
} @else {
|
||||
<div class="w-5 h-5 rounded bg-primary/20 flex items-center justify-center text-[10px] font-semibold">
|
||||
{{ voiceSession()?.serverName?.charAt(0)?.toUpperCase() || '?' }}
|
||||
</div>
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Voice status indicator -->
|
||||
<div class="flex items-center gap-1 px-1">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span>
|
||||
<span class="text-xs text-muted-foreground max-w-20 truncate">{{ voiceSession()?.roomName || 'Voice' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="w-px h-6 bg-border"></div>
|
||||
|
||||
<!-- Voice controls -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
(click)="toggleMute()"
|
||||
type="button"
|
||||
[class]="getCompactButtonClass(isMuted())"
|
||||
title="Toggle Mute"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="isMuted() ? 'lucideMicOff' : 'lucideMic'"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="toggleDeafen()"
|
||||
type="button"
|
||||
[class]="getCompactButtonClass(isDeafened())"
|
||||
title="Toggle Deafen"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideHeadphones"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="toggleScreenShare()"
|
||||
type="button"
|
||||
[class]="getCompactScreenShareClass()"
|
||||
title="Toggle Screen Share"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="isScreenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<app-debug-console
|
||||
launcherVariant="compact"
|
||||
[showPanel]="false"
|
||||
/>
|
||||
|
||||
<button
|
||||
(click)="disconnect()"
|
||||
type="button"
|
||||
class="w-7 h-7 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
|
||||
title="Disconnect"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePhoneOff"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showScreenShareQualityDialog()) {
|
||||
<app-screen-share-quality-dialog
|
||||
[selectedQuality]="screenShareQuality()"
|
||||
[includeSystemAudio]="includeSystemAudio()"
|
||||
(cancelled)="onScreenShareQualityCancelled()"
|
||||
(confirmed)="onScreenShareQualityConfirmed($event)"
|
||||
/>
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideMonitor,
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
lucideHeadphones,
|
||||
lucideArrowLeft
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { VoiceSessionFacade } from '../../application/voice-session.facade';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../infrastructure/voice-settings.storage';
|
||||
import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
|
||||
import { VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
|
||||
import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../../shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-floating-voice-controls',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
DebugConsoleComponent,
|
||||
ScreenShareQualityDialogComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideMonitor,
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
lucideHeadphones,
|
||||
lucideArrowLeft
|
||||
})
|
||||
],
|
||||
templateUrl: './floating-voice-controls.component.html'
|
||||
})
|
||||
/**
|
||||
* Floating voice controls displayed when the user navigates away from the voice-connected server.
|
||||
* Provides mute, deafen, screen-share, and disconnect actions in a compact overlay.
|
||||
*/
|
||||
export class FloatingVoiceControlsComponent implements OnInit {
|
||||
private readonly webrtcService = inject(VoiceConnectionFacade);
|
||||
private readonly screenShareService = inject(ScreenShareFacade);
|
||||
private readonly voiceSessionService = inject(VoiceSessionFacade);
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly store = inject(Store);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
// Voice state from services
|
||||
showFloatingControls = this.voiceSessionService.showFloatingControls;
|
||||
voiceSession = this.voiceSessionService.voiceSession;
|
||||
|
||||
isConnected = computed(() => this.webrtcService.isVoiceConnected());
|
||||
isMuted = signal(false);
|
||||
isDeafened = signal(false);
|
||||
isScreenSharing = this.screenShareService.isScreenSharing;
|
||||
includeSystemAudio = signal(false);
|
||||
screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||
askScreenShareQuality = signal(true);
|
||||
showScreenShareQualityDialog = signal(false);
|
||||
|
||||
/** Sync local mute/deafen state from the WebRTC service on init. */
|
||||
ngOnInit(): void {
|
||||
// Sync mute/deafen state from webrtc service
|
||||
this.isMuted.set(this.webrtcService.isMuted());
|
||||
this.isDeafened.set(this.webrtcService.isDeafened());
|
||||
this.syncScreenShareSettings();
|
||||
|
||||
const settings = loadVoiceSettingsFromStorage();
|
||||
|
||||
this.voicePlayback.updateOutputVolume(settings.outputVolume / 100);
|
||||
this.voicePlayback.updateDeafened(this.isDeafened());
|
||||
|
||||
if (settings.outputDevice) {
|
||||
this.voicePlayback.applyOutputDevice(settings.outputDevice);
|
||||
}
|
||||
}
|
||||
|
||||
/** Navigate back to the voice-connected server. */
|
||||
navigateToServer(): void {
|
||||
this.voiceSessionService.navigateToVoiceServer();
|
||||
}
|
||||
|
||||
/** Toggle microphone mute and broadcast the updated voice state. */
|
||||
toggleMute(): void {
|
||||
this.isMuted.update((current) => !current);
|
||||
this.webrtcService.toggleMute(this.isMuted());
|
||||
|
||||
// Broadcast mute state change
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||
displayName: this.currentUser()?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle deafen state (muting audio output) and broadcast the updated voice state. */
|
||||
toggleDeafen(): void {
|
||||
this.isDeafened.update((current) => !current);
|
||||
this.webrtcService.toggleDeafen(this.isDeafened());
|
||||
this.voicePlayback.updateDeafened(this.isDeafened());
|
||||
|
||||
// When deafening, also mute
|
||||
if (this.isDeafened() && !this.isMuted()) {
|
||||
this.isMuted.set(true);
|
||||
this.webrtcService.toggleMute(true);
|
||||
}
|
||||
|
||||
// Broadcast deafen state change
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||
displayName: this.currentUser()?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle screen sharing on or off. */
|
||||
async toggleScreenShare(): Promise<void> {
|
||||
if (this.isScreenSharing()) {
|
||||
this.screenShareService.stopScreenShare();
|
||||
} else {
|
||||
this.syncScreenShareSettings();
|
||||
|
||||
if (this.askScreenShareQuality()) {
|
||||
this.showScreenShareQualityDialog.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.startScreenShareWithOptions(this.screenShareQuality());
|
||||
}
|
||||
}
|
||||
|
||||
onScreenShareQualityCancelled(): void {
|
||||
this.showScreenShareQualityDialog.set(false);
|
||||
}
|
||||
|
||||
async onScreenShareQualityConfirmed(quality: ScreenShareQuality): Promise<void> {
|
||||
this.showScreenShareQualityDialog.set(false);
|
||||
this.screenShareQuality.set(quality);
|
||||
saveVoiceSettingsToStorage({ screenShareQuality: quality });
|
||||
await this.startScreenShareWithOptions(quality);
|
||||
}
|
||||
|
||||
/** Disconnect from the voice session entirely, cleaning up all voice state. */
|
||||
disconnect(): void {
|
||||
// Stop voice heartbeat
|
||||
this.webrtcService.stopVoiceHeartbeat();
|
||||
|
||||
// Broadcast voice disconnect
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||
displayName: this.currentUser()?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false
|
||||
}
|
||||
});
|
||||
|
||||
// Stop screen sharing if active
|
||||
if (this.isScreenSharing()) {
|
||||
this.screenShareService.stopScreenShare();
|
||||
}
|
||||
|
||||
// Disable voice
|
||||
this.webrtcService.disableVoice();
|
||||
this.voicePlayback.teardownAll();
|
||||
this.voicePlayback.updateDeafened(false);
|
||||
|
||||
// Update user voice state in store
|
||||
const user = this.currentUser();
|
||||
|
||||
if (user?.id) {
|
||||
this.store.dispatch(UsersActions.updateVoiceState({
|
||||
userId: user.id,
|
||||
voiceState: { isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined }
|
||||
}));
|
||||
}
|
||||
|
||||
// End voice session
|
||||
this.voiceSessionService.endSession();
|
||||
|
||||
// Reset local state
|
||||
this.isMuted.set(false);
|
||||
this.isDeafened.set(false);
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the compact control button based on active state. */
|
||||
getCompactButtonClass(isActive: boolean): string {
|
||||
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
|
||||
|
||||
if (isActive) {
|
||||
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the compact screen-share button. */
|
||||
getCompactScreenShareClass(): string {
|
||||
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
|
||||
|
||||
if (this.isScreenSharing()) {
|
||||
return base + ' bg-primary/20 text-primary hover:bg-primary/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the mute toggle button. */
|
||||
getMuteButtonClass(): string {
|
||||
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
||||
|
||||
if (this.isMuted()) {
|
||||
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the deafen toggle button. */
|
||||
getDeafenButtonClass(): string {
|
||||
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
||||
|
||||
if (this.isDeafened()) {
|
||||
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
/** Return the CSS classes for the screen-share toggle button. */
|
||||
getScreenShareButtonClass(): string {
|
||||
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
|
||||
|
||||
if (this.isScreenSharing()) {
|
||||
return base + ' bg-primary/20 text-primary hover:bg-primary/30';
|
||||
}
|
||||
|
||||
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
|
||||
}
|
||||
|
||||
private syncScreenShareSettings(): void {
|
||||
const settings = loadVoiceSettingsFromStorage();
|
||||
|
||||
this.includeSystemAudio.set(settings.includeSystemAudio);
|
||||
this.screenShareQuality.set(settings.screenShareQuality);
|
||||
this.askScreenShareQuality.set(settings.askScreenShareQuality);
|
||||
}
|
||||
|
||||
private async startScreenShareWithOptions(quality: ScreenShareQuality): Promise<void> {
|
||||
try {
|
||||
await this.screenShareService.startScreenShare({
|
||||
includeSystemAudio: this.includeSystemAudio(),
|
||||
quality
|
||||
});
|
||||
} catch (_error) {
|
||||
// Screen share request was denied or failed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<div class="bg-card border-border p-4">
|
||||
<!-- Connection Error Banner -->
|
||||
@if (showConnectionError()) {
|
||||
<div class="mb-3 p-2 bg-destructive/20 border border-destructive/30 rounded-lg flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-destructive animate-pulse"></span>
|
||||
<span class="text-xs text-destructive">{{ connectionErrorMessage() || 'Connection error' }}</span>
|
||||
<button
|
||||
type="button"
|
||||
(click)="retryConnection()"
|
||||
class="ml-auto text-xs text-destructive hover:underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- User Info -->
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<app-user-avatar
|
||||
[name]="currentUser()?.displayName || '?'"
|
||||
size="sm"
|
||||
/>
|
||||
<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 (showConnectionError()) {
|
||||
<span class="text-destructive">● Connection Error</span>
|
||||
} @else if (isConnected()) {
|
||||
<span class="text-green-500">● Connected</span>
|
||||
} @else {
|
||||
<span class="text-muted-foreground">● Disconnected</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<app-debug-console
|
||||
launcherVariant="inline"
|
||||
[showPanel]="false"
|
||||
/>
|
||||
<button
|
||||
type="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>
|
||||
</div>
|
||||
|
||||
<!-- Voice Controls -->
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
@if (isConnected()) {
|
||||
<!-- Mute Toggle -->
|
||||
<button
|
||||
type="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
|
||||
type="button"
|
||||
(click)="toggleDeafen()"
|
||||
[class]="getDeafenButtonClass()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideHeadphones"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Screen Share Toggle -->
|
||||
<button
|
||||
type="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
|
||||
type="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>
|
||||
</div>
|
||||
|
||||
@if (showScreenShareQualityDialog()) {
|
||||
<app-screen-share-quality-dialog
|
||||
[selectedQuality]="screenShareQuality()"
|
||||
[includeSystemAudio]="includeSystemAudio()"
|
||||
(cancelled)="onScreenShareQualityCancelled()"
|
||||
(confirmed)="onScreenShareQualityConfirmed($event)"
|
||||
/>
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, complexity */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
computed
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideVideo,
|
||||
lucideVideoOff,
|
||||
lucideMonitor,
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
lucideSettings,
|
||||
lucideHeadphones
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { VoiceSessionFacade } from '../../application/voice-session.facade';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../infrastructure/voice-settings.storage';
|
||||
import { VoiceActivityService, VoiceConnectionFacade } from '../../../../domains/voice-connection';
|
||||
import { PlaybackOptions, VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
|
||||
import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import {
|
||||
DebugConsoleComponent,
|
||||
ScreenShareQualityDialogComponent,
|
||||
UserAvatarComponent
|
||||
} from '../../../../shared';
|
||||
|
||||
interface AudioDevice {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-voice-controls',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
DebugConsoleComponent,
|
||||
ScreenShareQualityDialogComponent,
|
||||
UserAvatarComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideVideo,
|
||||
lucideVideoOff,
|
||||
lucideMonitor,
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
lucideSettings,
|
||||
lucideHeadphones
|
||||
})
|
||||
],
|
||||
templateUrl: './voice-controls.component.html'
|
||||
})
|
||||
export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
private readonly webrtcService = inject(VoiceConnectionFacade);
|
||||
private readonly screenShareService = inject(ScreenShareFacade);
|
||||
private readonly voiceSessionService = inject(VoiceSessionFacade);
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly store = inject(Store);
|
||||
private readonly settingsModal = inject(SettingsModalService);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
isConnected = computed(() => this.webrtcService.isVoiceConnected());
|
||||
showConnectionError = computed(() => this.webrtcService.shouldShowConnectionError());
|
||||
connectionErrorMessage = computed(() => this.webrtcService.connectionErrorMessage());
|
||||
isMuted = signal(false);
|
||||
isDeafened = signal(false);
|
||||
isScreenSharing = this.screenShareService.isScreenSharing;
|
||||
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');
|
||||
includeSystemAudio = signal(false);
|
||||
noiseReduction = signal(true);
|
||||
screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||
askScreenShareQuality = signal(true);
|
||||
showScreenShareQualityDialog = signal(false);
|
||||
|
||||
private playbackOptions(): PlaybackOptions {
|
||||
return {
|
||||
isConnected: this.isConnected(),
|
||||
outputVolume: this.outputVolume() / 100,
|
||||
isDeafened: this.isDeafened()
|
||||
};
|
||||
}
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.loadAudioDevices();
|
||||
|
||||
// Load persisted voice settings and apply
|
||||
this.loadSettings();
|
||||
this.applySettingsToWebRTC();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (!this.webrtcService.isVoiceConnected()) {
|
||||
this.voicePlayback.teardownAll();
|
||||
}
|
||||
}
|
||||
|
||||
async loadAudioDevices(): Promise<void> {
|
||||
try {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) {
|
||||
return;
|
||||
}
|
||||
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
|
||||
this.inputDevices.set(
|
||||
devices
|
||||
.filter((device) => device.kind === 'audioinput')
|
||||
.map((device) => ({ deviceId: device.deviceId,
|
||||
label: device.label }))
|
||||
);
|
||||
|
||||
this.outputDevices.set(
|
||||
devices
|
||||
.filter((device) => device.kind === 'audiooutput')
|
||||
.map((device) => ({ deviceId: device.deviceId,
|
||||
label: device.label }))
|
||||
);
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
try {
|
||||
// Require signaling connectivity first
|
||||
const ok = await this.webrtcService.ensureSignalingConnected();
|
||||
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
deviceId: this.selectedInputDevice() || undefined,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: !this.noiseReduction()
|
||||
}
|
||||
});
|
||||
|
||||
await this.webrtcService.setLocalStream(stream);
|
||||
|
||||
// Track local mic for voice-activity visualisation
|
||||
// Use oderId||id to match the key used by the rooms-side-panel template.
|
||||
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
|
||||
|
||||
if (userId) {
|
||||
this.voiceActivity.trackLocalMic(userId, stream);
|
||||
}
|
||||
|
||||
// Start voice heartbeat to broadcast presence every 5 seconds
|
||||
const room = this.currentRoom();
|
||||
const roomId = this.currentUser()?.voiceState?.roomId || room?.id;
|
||||
const serverId = room?.id;
|
||||
|
||||
this.webrtcService.startVoiceHeartbeat(roomId, serverId);
|
||||
|
||||
// Update local user's voice state in the store so the side panel
|
||||
// shows us in the voice channel with a speaking indicator.
|
||||
const user = this.currentUser();
|
||||
|
||||
if (user?.id) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: user.id,
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened(),
|
||||
roomId,
|
||||
serverId
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Broadcast voice state to other users
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||
displayName: this.currentUser()?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened(),
|
||||
roomId,
|
||||
serverId
|
||||
}
|
||||
});
|
||||
|
||||
// Play any pending remote streams now that we're connected
|
||||
this.voicePlayback.playPendingStreams(this.playbackOptions());
|
||||
|
||||
// Persist settings after successful connection
|
||||
this.saveSettings();
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
// Retry connection when there's a connection error
|
||||
async retryConnection(): Promise<void> {
|
||||
try {
|
||||
await this.webrtcService.ensureSignalingConnected(10000);
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
// Stop voice heartbeat
|
||||
this.webrtcService.stopVoiceHeartbeat();
|
||||
|
||||
// Broadcast voice disconnect to other users
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||
displayName: this.currentUser()?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
serverId: this.currentRoom()?.id
|
||||
}
|
||||
});
|
||||
|
||||
// Stop screen sharing if active
|
||||
if (this.isScreenSharing()) {
|
||||
this.screenShareService.stopScreenShare();
|
||||
}
|
||||
|
||||
// Untrack local mic from voice-activity visualisation
|
||||
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
|
||||
|
||||
if (userId) {
|
||||
this.voiceActivity.untrackLocalMic(userId);
|
||||
}
|
||||
|
||||
// Disable voice (stops audio tracks but keeps peer connections open for chat)
|
||||
this.webrtcService.disableVoice();
|
||||
this.voicePlayback.teardownAll();
|
||||
this.voicePlayback.updateDeafened(false);
|
||||
|
||||
const user = this.currentUser();
|
||||
|
||||
if (user?.id) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: user.id,
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// End voice session for floating controls
|
||||
this.voiceSessionService.endSession();
|
||||
|
||||
this.isMuted.set(false);
|
||||
this.isDeafened.set(false);
|
||||
}
|
||||
|
||||
toggleMute(): void {
|
||||
this.isMuted.update((current) => !current);
|
||||
this.webrtcService.toggleMute(this.isMuted());
|
||||
|
||||
// Update local store so the side panel reflects the mute state
|
||||
const user = this.currentUser();
|
||||
|
||||
if (user?.id) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: user.id,
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened()
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Broadcast mute state change
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||
displayName: this.currentUser()?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleDeafen(): void {
|
||||
this.isDeafened.update((current) => !current);
|
||||
this.webrtcService.toggleDeafen(this.isDeafened());
|
||||
|
||||
this.voicePlayback.updateDeafened(this.isDeafened());
|
||||
|
||||
// When deafening, also mute
|
||||
if (this.isDeafened() && !this.isMuted()) {
|
||||
this.isMuted.set(true);
|
||||
this.webrtcService.toggleMute(true);
|
||||
}
|
||||
|
||||
// Broadcast deafen state change
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
|
||||
displayName: this.currentUser()?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened()
|
||||
}
|
||||
});
|
||||
|
||||
// Update local store so the side panel reflects the deafen/mute state
|
||||
const user = this.currentUser();
|
||||
|
||||
if (user?.id) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: user.id,
|
||||
voiceState: {
|
||||
isConnected: this.isConnected(),
|
||||
isMuted: this.isMuted(),
|
||||
isDeafened: this.isDeafened()
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleScreenShare(): Promise<void> {
|
||||
if (this.isScreenSharing()) {
|
||||
this.screenShareService.stopScreenShare();
|
||||
} else {
|
||||
this.syncScreenShareSettings();
|
||||
|
||||
if (this.askScreenShareQuality()) {
|
||||
this.showScreenShareQualityDialog.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.startScreenShareWithOptions(this.screenShareQuality());
|
||||
}
|
||||
}
|
||||
|
||||
onScreenShareQualityCancelled(): void {
|
||||
this.showScreenShareQualityDialog.set(false);
|
||||
}
|
||||
|
||||
async onScreenShareQualityConfirmed(quality: ScreenShareQuality): Promise<void> {
|
||||
this.showScreenShareQualityDialog.set(false);
|
||||
this.screenShareQuality.set(quality);
|
||||
this.saveSettings();
|
||||
await this.startScreenShareWithOptions(quality);
|
||||
}
|
||||
|
||||
toggleSettings(): void {
|
||||
this.settingsModal.open('voice');
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
onOutputDeviceChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
|
||||
this.selectedOutputDevice.set(select.value);
|
||||
this.applyOutputDevice();
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
onInputVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.inputVolume.set(parseInt(input.value, 10));
|
||||
this.webrtcService.setInputVolume(this.inputVolume() / 100);
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
onOutputVolumeChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.outputVolume.set(parseInt(input.value, 10));
|
||||
this.webrtcService.setOutputVolume(this.outputVolume() / 100);
|
||||
this.voicePlayback.updateOutputVolume(this.outputVolume() / 100);
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
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);
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
onAudioBitrateChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const kbps = parseInt(input.value, 10);
|
||||
|
||||
this.audioBitrate.set(kbps);
|
||||
this.webrtcService.setAudioBitrate(kbps);
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
onIncludeSystemAudioChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.includeSystemAudio.set(!!input.checked);
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
async onNoiseReductionChange(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.noiseReduction.set(!!input.checked);
|
||||
await this.webrtcService.toggleNoiseReduction(this.noiseReduction());
|
||||
this.saveSettings();
|
||||
}
|
||||
|
||||
private loadSettings(): void {
|
||||
const settings = loadVoiceSettingsFromStorage();
|
||||
|
||||
this.selectedInputDevice.set(settings.inputDevice);
|
||||
this.selectedOutputDevice.set(settings.outputDevice);
|
||||
this.inputVolume.set(settings.inputVolume);
|
||||
this.outputVolume.set(settings.outputVolume);
|
||||
this.audioBitrate.set(settings.audioBitrate);
|
||||
this.latencyProfile.set(settings.latencyProfile);
|
||||
this.includeSystemAudio.set(settings.includeSystemAudio);
|
||||
this.noiseReduction.set(settings.noiseReduction);
|
||||
this.screenShareQuality.set(settings.screenShareQuality);
|
||||
this.askScreenShareQuality.set(settings.askScreenShareQuality);
|
||||
}
|
||||
|
||||
private saveSettings(): void {
|
||||
saveVoiceSettingsToStorage({
|
||||
inputDevice: this.selectedInputDevice(),
|
||||
outputDevice: this.selectedOutputDevice(),
|
||||
inputVolume: this.inputVolume(),
|
||||
outputVolume: this.outputVolume(),
|
||||
audioBitrate: this.audioBitrate(),
|
||||
latencyProfile: this.latencyProfile(),
|
||||
includeSystemAudio: this.includeSystemAudio(),
|
||||
noiseReduction: this.noiseReduction(),
|
||||
screenShareQuality: this.screenShareQuality(),
|
||||
askScreenShareQuality: this.askScreenShareQuality()
|
||||
});
|
||||
}
|
||||
|
||||
private applySettingsToWebRTC(): void {
|
||||
try {
|
||||
this.webrtcService.setOutputVolume(this.outputVolume() / 100);
|
||||
this.voicePlayback.updateOutputVolume(this.outputVolume() / 100);
|
||||
this.webrtcService.setInputVolume(this.inputVolume() / 100);
|
||||
this.webrtcService.setAudioBitrate(this.audioBitrate());
|
||||
this.webrtcService.setLatencyProfile(this.latencyProfile());
|
||||
this.applyOutputDevice();
|
||||
// Always sync the desired noise-reduction preference (even before
|
||||
// a mic stream exists - the flag will be honoured on connect).
|
||||
this.webrtcService.toggleNoiseReduction(this.noiseReduction());
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private async applyOutputDevice(): Promise<void> {
|
||||
const deviceId = this.selectedOutputDevice();
|
||||
|
||||
if (!deviceId)
|
||||
return;
|
||||
|
||||
this.voicePlayback.applyOutputDevice(deviceId);
|
||||
}
|
||||
|
||||
private syncScreenShareSettings(): void {
|
||||
const settings = loadVoiceSettingsFromStorage();
|
||||
|
||||
this.includeSystemAudio.set(settings.includeSystemAudio);
|
||||
this.screenShareQuality.set(settings.screenShareQuality);
|
||||
this.askScreenShareQuality.set(settings.askScreenShareQuality);
|
||||
}
|
||||
|
||||
private async startScreenShareWithOptions(quality: ScreenShareQuality): Promise<void> {
|
||||
try {
|
||||
await this.screenShareService.startScreenShare({
|
||||
includeSystemAudio: this.includeSystemAudio(),
|
||||
quality
|
||||
});
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,7 @@ export * from './application/voice-session.facade';
|
||||
export * from './application/voice-workspace.service';
|
||||
export * from './domain/voice-session.models';
|
||||
export * from './infrastructure/voice-settings.storage';
|
||||
|
||||
// Feature components
|
||||
export { VoiceControlsComponent } from './feature/voice-controls/voice-controls.component';
|
||||
export { FloatingVoiceControlsComponent } from './feature/floating-voice-controls/floating-voice-controls.component';
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../core/constants';
|
||||
import { LatencyProfile } from '../../voice-connection/domain/voice-connection.models';
|
||||
import { DEFAULT_SCREEN_SHARE_QUALITY, ScreenShareQuality } from '../../screen-share/domain/screen-share.config';
|
||||
|
||||
const LATENCY_PROFILES: LatencyProfile[] = [
|
||||
'low',
|
||||
'balanced',
|
||||
'high'
|
||||
];
|
||||
const SCREEN_SHARE_QUALITIES: ScreenShareQuality[] = [
|
||||
'performance',
|
||||
'balanced',
|
||||
'high-fps',
|
||||
'quality'
|
||||
];
|
||||
import {
|
||||
DEFAULT_LATENCY_PROFILE,
|
||||
DEFAULT_SCREEN_SHARE_QUALITY,
|
||||
LATENCY_PROFILES,
|
||||
SCREEN_SHARE_QUALITIES,
|
||||
type LatencyProfile,
|
||||
type ScreenShareQuality
|
||||
} from '../../../shared-kernel';
|
||||
|
||||
export interface VoiceSettings {
|
||||
inputDevice: string;
|
||||
@@ -33,7 +27,7 @@ export const DEFAULT_VOICE_SETTINGS: VoiceSettings = {
|
||||
inputVolume: 100,
|
||||
outputVolume: 100,
|
||||
audioBitrate: 96,
|
||||
latencyProfile: 'balanced',
|
||||
latencyProfile: DEFAULT_LATENCY_PROFILE,
|
||||
includeSystemAudio: false,
|
||||
noiseReduction: true,
|
||||
screenShareQuality: DEFAULT_SCREEN_SHARE_QUALITY,
|
||||
@@ -71,7 +65,7 @@ function normaliseVoiceSettings(raw: Partial<VoiceSettings>): VoiceSettings {
|
||||
inputDevice: typeof raw.inputDevice === 'string' ? raw.inputDevice : DEFAULT_VOICE_SETTINGS.inputDevice,
|
||||
outputDevice: typeof raw.outputDevice === 'string' ? raw.outputDevice : DEFAULT_VOICE_SETTINGS.outputDevice,
|
||||
inputVolume: clampNumber(raw.inputVolume, 0, 100, DEFAULT_VOICE_SETTINGS.inputVolume),
|
||||
outputVolume: clampNumber(raw.outputVolume, 0, 100, DEFAULT_VOICE_SETTINGS.outputVolume),
|
||||
outputVolume: clampNumber(raw.outputVolume, 0, 200, DEFAULT_VOICE_SETTINGS.outputVolume),
|
||||
audioBitrate: clampNumber(raw.audioBitrate, 32, 256, DEFAULT_VOICE_SETTINGS.audioBitrate),
|
||||
latencyProfile: LATENCY_PROFILES.includes(raw.latencyProfile as LatencyProfile)
|
||||
? raw.latencyProfile as LatencyProfile
|
||||
|
||||
Reference in New Issue
Block a user