Move toju-app into own its folder
This commit is contained in:
62
toju-app/src/app/domains/README.md
Normal file
62
toju-app/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
toju-app/src/app/domains/attachment/README.md
Normal file
148
toju-app/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" _blank
|
||||
click Manager "application/attachment-manager.service.ts" "Orchestrates lifecycle" _blank
|
||||
click Transfer "application/attachment-transfer.service.ts" "P2P file transfer protocol" _blank
|
||||
click Transport "application/attachment-transfer-transport.service.ts" "Base64 encode/decode, chunked streaming" _blank
|
||||
click Persistence "application/attachment-persistence.service.ts" "DB + filesystem persistence" _blank
|
||||
click Store "application/attachment-runtime.store.ts" "In-memory signal-based state" _blank
|
||||
click Storage "infrastructure/attachment-storage.service.ts" "Electron filesystem access" _blank
|
||||
click Helpers "infrastructure/attachment-storage.helpers.ts" "Path helpers" _blank
|
||||
click Logic "domain/attachment.logic.ts" "Pure decision functions" _blank
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,224 @@
|
||||
import {
|
||||
Injectable,
|
||||
effect,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { DatabaseService } from '../../../infrastructure/persistence';
|
||||
import { ROOM_URL_PATTERN } from '../../../core/constants';
|
||||
import { shouldAutoRequestWhenWatched } from '../domain/attachment.logic';
|
||||
import type { Attachment, AttachmentMeta } from '../domain/attachment.models';
|
||||
import type {
|
||||
FileAnnouncePayload,
|
||||
FileCancelPayload,
|
||||
FileChunkPayload,
|
||||
FileNotFoundPayload,
|
||||
FileRequestPayload
|
||||
} from '../domain/attachment-transfer.models';
|
||||
import { AttachmentPersistenceService } from './attachment-persistence.service';
|
||||
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||
import { AttachmentTransferService } from './attachment-transfer.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentManagerService {
|
||||
get updated() {
|
||||
return this.runtimeStore.updated;
|
||||
}
|
||||
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly router = inject(Router);
|
||||
private readonly database = inject(DatabaseService);
|
||||
private readonly runtimeStore = inject(AttachmentRuntimeStore);
|
||||
private readonly persistence = inject(AttachmentPersistenceService);
|
||||
private readonly transfer = inject(AttachmentTransferService);
|
||||
|
||||
private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url);
|
||||
private isDatabaseInitialised = false;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.database.isReady() && !this.isDatabaseInitialised) {
|
||||
this.isDatabaseInitialised = true;
|
||||
void this.persistence.initFromDatabase();
|
||||
}
|
||||
});
|
||||
|
||||
this.router.events.subscribe((event) => {
|
||||
if (!(event instanceof NavigationEnd)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.watchedRoomId = this.extractWatchedRoomId(event.urlAfterRedirects || event.url);
|
||||
|
||||
if (this.watchedRoomId) {
|
||||
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
|
||||
}
|
||||
});
|
||||
|
||||
this.webrtc.onPeerConnected.subscribe(() => {
|
||||
if (this.watchedRoomId) {
|
||||
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getForMessage(messageId: string): Attachment[] {
|
||||
return this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||
}
|
||||
|
||||
rememberMessageRoom(messageId: string, roomId: string): void {
|
||||
if (!messageId || !roomId)
|
||||
return;
|
||||
|
||||
this.runtimeStore.rememberMessageRoom(messageId, roomId);
|
||||
}
|
||||
|
||||
queueAutoDownloadsForMessage(messageId: string, attachmentId?: string): void {
|
||||
void this.requestAutoDownloadsForMessage(messageId, attachmentId);
|
||||
}
|
||||
|
||||
async requestAutoDownloadsForRoom(roomId: string): Promise<void> {
|
||||
if (!roomId || !this.isRoomWatched(roomId))
|
||||
return;
|
||||
|
||||
if (this.database.isReady()) {
|
||||
const messages = await this.database.getMessages(roomId, 500, 0);
|
||||
|
||||
for (const message of messages) {
|
||||
this.runtimeStore.rememberMessageRoom(message.id, message.roomId);
|
||||
await this.requestAutoDownloadsForMessage(message.id);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [messageId] of this.runtimeStore.getAttachmentEntries()) {
|
||||
const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId);
|
||||
|
||||
if (attachmentRoomId === roomId) {
|
||||
await this.requestAutoDownloadsForMessage(messageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteForMessage(messageId: string): Promise<void> {
|
||||
await this.persistence.deleteForMessage(messageId);
|
||||
}
|
||||
|
||||
getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> {
|
||||
return this.transfer.getAttachmentMetasForMessages(messageIds);
|
||||
}
|
||||
|
||||
registerSyncedAttachments(
|
||||
attachmentMap: Record<string, AttachmentMeta[]>,
|
||||
messageRoomIds?: Record<string, string>
|
||||
): void {
|
||||
this.transfer.registerSyncedAttachments(attachmentMap, messageRoomIds);
|
||||
|
||||
for (const [messageId, attachments] of Object.entries(attachmentMap)) {
|
||||
for (const attachment of attachments) {
|
||||
this.queueAutoDownloadsForMessage(messageId, attachment.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requestFromAnyPeer(messageId: string, attachment: Attachment): void {
|
||||
this.transfer.requestFromAnyPeer(messageId, attachment);
|
||||
}
|
||||
|
||||
handleFileNotFound(payload: FileNotFoundPayload): void {
|
||||
this.transfer.handleFileNotFound(payload);
|
||||
}
|
||||
|
||||
requestImageFromAnyPeer(messageId: string, attachment: Attachment): void {
|
||||
this.transfer.requestImageFromAnyPeer(messageId, attachment);
|
||||
}
|
||||
|
||||
requestFile(messageId: string, attachment: Attachment): void {
|
||||
this.transfer.requestFile(messageId, attachment);
|
||||
}
|
||||
|
||||
async publishAttachments(
|
||||
messageId: string,
|
||||
files: File[],
|
||||
uploaderPeerId?: string
|
||||
): Promise<void> {
|
||||
await this.transfer.publishAttachments(messageId, files, uploaderPeerId);
|
||||
}
|
||||
|
||||
handleFileAnnounce(payload: FileAnnouncePayload): void {
|
||||
this.transfer.handleFileAnnounce(payload);
|
||||
|
||||
if (payload.messageId && payload.file?.id) {
|
||||
this.queueAutoDownloadsForMessage(payload.messageId, payload.file.id);
|
||||
}
|
||||
}
|
||||
|
||||
handleFileChunk(payload: FileChunkPayload): void {
|
||||
this.transfer.handleFileChunk(payload);
|
||||
}
|
||||
|
||||
async handleFileRequest(payload: FileRequestPayload): Promise<void> {
|
||||
await this.transfer.handleFileRequest(payload);
|
||||
}
|
||||
|
||||
cancelRequest(messageId: string, attachment: Attachment): void {
|
||||
this.transfer.cancelRequest(messageId, attachment);
|
||||
}
|
||||
|
||||
handleFileCancel(payload: FileCancelPayload): void {
|
||||
this.transfer.handleFileCancel(payload);
|
||||
}
|
||||
|
||||
async fulfillRequestWithFile(
|
||||
messageId: string,
|
||||
fileId: string,
|
||||
targetPeerId: string,
|
||||
file: File
|
||||
): Promise<void> {
|
||||
await this.transfer.fulfillRequestWithFile(messageId, fileId, targetPeerId, file);
|
||||
}
|
||||
|
||||
private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise<void> {
|
||||
if (!messageId)
|
||||
return;
|
||||
|
||||
const roomId = await this.persistence.resolveMessageRoomId(messageId);
|
||||
|
||||
if (!roomId || !this.isRoomWatched(roomId) || this.webrtc.getConnectedPeers().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attachments = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (attachmentId && attachment.id !== attachmentId)
|
||||
continue;
|
||||
|
||||
if (!shouldAutoRequestWhenWatched(attachment))
|
||||
continue;
|
||||
|
||||
if (attachment.available)
|
||||
continue;
|
||||
|
||||
if ((attachment.receivedBytes ?? 0) > 0)
|
||||
continue;
|
||||
|
||||
if (this.transfer.hasPendingRequest(messageId, attachment.id))
|
||||
continue;
|
||||
|
||||
this.transfer.requestFromAnyPeer(messageId, attachment);
|
||||
}
|
||||
}
|
||||
|
||||
private extractWatchedRoomId(url: string): string | null {
|
||||
const roomMatch = url.match(ROOM_URL_PATTERN);
|
||||
|
||||
return roomMatch ? roomMatch[1] : null;
|
||||
}
|
||||
|
||||
private isRoomWatched(roomId: string | null | undefined): boolean {
|
||||
return !!roomId && roomId === this.watchedRoomId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { take } from 'rxjs';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { selectCurrentRoomName } from '../../../store/rooms/rooms.selectors';
|
||||
import { DatabaseService } from '../../../infrastructure/persistence';
|
||||
import { AttachmentStorageService } from '../infrastructure/attachment-storage.service';
|
||||
import type { Attachment, AttachmentMeta } from '../domain/attachment.models';
|
||||
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../domain/attachment.constants';
|
||||
import { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../domain/attachment-transfer.constants';
|
||||
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentPersistenceService {
|
||||
private readonly runtimeStore = inject(AttachmentRuntimeStore);
|
||||
private readonly ngrxStore = inject(Store);
|
||||
private readonly attachmentStorage = inject(AttachmentStorageService);
|
||||
private readonly database = inject(DatabaseService);
|
||||
|
||||
async deleteForMessage(messageId: string): Promise<void> {
|
||||
const attachments = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||
const hadCachedAttachments = attachments.length > 0 || this.runtimeStore.hasAttachmentsForMessage(messageId);
|
||||
const retainedSavedPaths = await this.getRetainedSavedPathsForOtherMessages(messageId);
|
||||
const savedPathsToDelete = new Set<string>();
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.objectUrl) {
|
||||
try {
|
||||
URL.revokeObjectURL(attachment.objectUrl);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (attachment.savedPath && !retainedSavedPaths.has(attachment.savedPath)) {
|
||||
savedPathsToDelete.add(attachment.savedPath);
|
||||
}
|
||||
}
|
||||
|
||||
this.runtimeStore.deleteAttachmentsForMessage(messageId);
|
||||
this.runtimeStore.deleteMessageRoom(messageId);
|
||||
this.runtimeStore.clearMessageScopedState(messageId);
|
||||
|
||||
if (hadCachedAttachments) {
|
||||
this.runtimeStore.touch();
|
||||
}
|
||||
|
||||
if (this.database.isReady()) {
|
||||
await this.database.deleteAttachmentsForMessage(messageId);
|
||||
}
|
||||
|
||||
for (const diskPath of savedPathsToDelete) {
|
||||
await this.attachmentStorage.deleteFile(diskPath);
|
||||
}
|
||||
}
|
||||
|
||||
async persistAttachmentMeta(attachment: Attachment): Promise<void> {
|
||||
if (!this.database.isReady())
|
||||
return;
|
||||
|
||||
try {
|
||||
await this.database.saveAttachment({
|
||||
id: attachment.id,
|
||||
messageId: attachment.messageId,
|
||||
filename: attachment.filename,
|
||||
size: attachment.size,
|
||||
mime: attachment.mime,
|
||||
isImage: attachment.isImage,
|
||||
uploaderPeerId: attachment.uploaderPeerId,
|
||||
filePath: attachment.filePath,
|
||||
savedPath: attachment.savedPath
|
||||
});
|
||||
} catch { /* persistence is best-effort */ }
|
||||
}
|
||||
|
||||
async saveFileToDisk(attachment: Attachment, blob: Blob): Promise<void> {
|
||||
try {
|
||||
const roomName = await this.resolveCurrentRoomName();
|
||||
const diskPath = await this.attachmentStorage.saveBlob(attachment, blob, roomName);
|
||||
|
||||
if (!diskPath)
|
||||
return;
|
||||
|
||||
attachment.savedPath = diskPath;
|
||||
void this.persistAttachmentMeta(attachment);
|
||||
} catch { /* disk save is best-effort */ }
|
||||
}
|
||||
|
||||
async initFromDatabase(): Promise<void> {
|
||||
await this.loadFromDatabase();
|
||||
await this.migrateFromLocalStorage();
|
||||
await this.tryLoadSavedFiles();
|
||||
}
|
||||
|
||||
async resolveMessageRoomId(messageId: string): Promise<string | null> {
|
||||
const cachedRoomId = this.runtimeStore.getMessageRoomId(messageId);
|
||||
|
||||
if (cachedRoomId)
|
||||
return cachedRoomId;
|
||||
|
||||
if (!this.database.isReady())
|
||||
return null;
|
||||
|
||||
try {
|
||||
const message = await this.database.getMessageById(messageId);
|
||||
|
||||
if (!message?.roomId)
|
||||
return null;
|
||||
|
||||
this.runtimeStore.rememberMessageRoom(messageId, message.roomId);
|
||||
return message.roomId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async resolveCurrentRoomName(): Promise<string> {
|
||||
return new Promise<string>((resolve) => {
|
||||
this.ngrxStore
|
||||
.select(selectCurrentRoomName)
|
||||
.pipe(take(1))
|
||||
.subscribe((name) => resolve(name || ''));
|
||||
});
|
||||
}
|
||||
|
||||
private async loadFromDatabase(): Promise<void> {
|
||||
try {
|
||||
const allRecords: AttachmentMeta[] = await this.database.getAllAttachments();
|
||||
const grouped = new Map<string, Attachment[]>();
|
||||
|
||||
for (const record of allRecords) {
|
||||
const attachment: Attachment = { ...record,
|
||||
available: false };
|
||||
const bucket = grouped.get(record.messageId) ?? [];
|
||||
|
||||
bucket.push(attachment);
|
||||
grouped.set(record.messageId, bucket);
|
||||
}
|
||||
|
||||
this.runtimeStore.replaceAttachments(grouped);
|
||||
this.runtimeStore.touch();
|
||||
} catch { /* load is best-effort */ }
|
||||
}
|
||||
|
||||
private async migrateFromLocalStorage(): Promise<void> {
|
||||
try {
|
||||
const raw = localStorage.getItem(LEGACY_ATTACHMENTS_STORAGE_KEY);
|
||||
|
||||
if (!raw)
|
||||
return;
|
||||
|
||||
const legacyRecords: AttachmentMeta[] = JSON.parse(raw);
|
||||
|
||||
for (const meta of legacyRecords) {
|
||||
const existing = [...this.runtimeStore.getAttachmentsForMessage(meta.messageId)];
|
||||
|
||||
if (!existing.find((entry) => entry.id === meta.id)) {
|
||||
const attachment: Attachment = { ...meta,
|
||||
available: false };
|
||||
|
||||
existing.push(attachment);
|
||||
this.runtimeStore.setAttachmentsForMessage(meta.messageId, existing);
|
||||
void this.persistAttachmentMeta(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.removeItem(LEGACY_ATTACHMENTS_STORAGE_KEY);
|
||||
this.runtimeStore.touch();
|
||||
} catch { /* migration is best-effort */ }
|
||||
}
|
||||
|
||||
private async tryLoadSavedFiles(): Promise<void> {
|
||||
try {
|
||||
let hasChanges = false;
|
||||
|
||||
for (const [, attachments] of this.runtimeStore.getAttachmentEntries()) {
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.available)
|
||||
continue;
|
||||
|
||||
if (attachment.savedPath) {
|
||||
const savedBase64 = await this.attachmentStorage.readFile(attachment.savedPath);
|
||||
|
||||
if (savedBase64) {
|
||||
this.restoreAttachmentFromDisk(attachment, savedBase64);
|
||||
hasChanges = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (attachment.filePath) {
|
||||
const originalBase64 = await this.attachmentStorage.readFile(attachment.filePath);
|
||||
|
||||
if (originalBase64) {
|
||||
this.restoreAttachmentFromDisk(attachment, originalBase64);
|
||||
hasChanges = true;
|
||||
|
||||
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES && attachment.objectUrl) {
|
||||
const response = await fetch(attachment.objectUrl);
|
||||
|
||||
void this.saveFileToDisk(attachment, await response.blob());
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges)
|
||||
this.runtimeStore.touch();
|
||||
} catch { /* startup load is best-effort */ }
|
||||
}
|
||||
|
||||
private restoreAttachmentFromDisk(attachment: Attachment, base64: string): void {
|
||||
const bytes = this.base64ToUint8Array(base64);
|
||||
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime });
|
||||
|
||||
attachment.objectUrl = URL.createObjectURL(blob);
|
||||
attachment.available = true;
|
||||
|
||||
this.runtimeStore.setOriginalFile(
|
||||
`${attachment.messageId}:${attachment.id}`,
|
||||
new File([blob], attachment.filename, { type: attachment.mime })
|
||||
);
|
||||
}
|
||||
|
||||
private async getRetainedSavedPathsForOtherMessages(messageId: string): Promise<Set<string>> {
|
||||
const retainedSavedPaths = new Set<string>();
|
||||
|
||||
for (const [existingMessageId, attachments] of this.runtimeStore.getAttachmentEntries()) {
|
||||
if (existingMessageId === messageId)
|
||||
continue;
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.savedPath) {
|
||||
retainedSavedPaths.add(attachment.savedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.database.isReady()) {
|
||||
return retainedSavedPaths;
|
||||
}
|
||||
|
||||
const persistedAttachments = await this.database.getAllAttachments();
|
||||
|
||||
for (const attachment of persistedAttachments) {
|
||||
if (attachment.messageId !== messageId && attachment.savedPath) {
|
||||
retainedSavedPaths.add(attachment.savedPath);
|
||||
}
|
||||
}
|
||||
|
||||
return retainedSavedPaths;
|
||||
}
|
||||
|
||||
private base64ToUint8Array(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let index = 0; index < binary.length; index++) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import type { Attachment } from '../domain/attachment.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentRuntimeStore {
|
||||
readonly updated = signal<number>(0);
|
||||
|
||||
private attachmentsByMessage = new Map<string, Attachment[]>();
|
||||
private messageRoomIds = new Map<string, string>();
|
||||
private originalFiles = new Map<string, File>();
|
||||
private cancelledTransfers = new Set<string>();
|
||||
private pendingRequests = new Map<string, Set<string>>();
|
||||
private chunkBuffers = new Map<string, ArrayBuffer[]>();
|
||||
private chunkCounts = new Map<string, number>();
|
||||
|
||||
touch(): void {
|
||||
this.updated.set(this.updated() + 1);
|
||||
}
|
||||
|
||||
getAttachmentsForMessage(messageId: string): Attachment[] {
|
||||
return this.attachmentsByMessage.get(messageId) ?? [];
|
||||
}
|
||||
|
||||
setAttachmentsForMessage(messageId: string, attachments: Attachment[]): void {
|
||||
if (attachments.length === 0) {
|
||||
this.attachmentsByMessage.delete(messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.attachmentsByMessage.set(messageId, attachments);
|
||||
}
|
||||
|
||||
hasAttachmentsForMessage(messageId: string): boolean {
|
||||
return this.attachmentsByMessage.has(messageId);
|
||||
}
|
||||
|
||||
deleteAttachmentsForMessage(messageId: string): void {
|
||||
this.attachmentsByMessage.delete(messageId);
|
||||
}
|
||||
|
||||
replaceAttachments(nextAttachments: Map<string, Attachment[]>): void {
|
||||
this.attachmentsByMessage = nextAttachments;
|
||||
}
|
||||
|
||||
getAttachmentEntries(): IterableIterator<[string, Attachment[]]> {
|
||||
return this.attachmentsByMessage.entries();
|
||||
}
|
||||
|
||||
rememberMessageRoom(messageId: string, roomId: string): void {
|
||||
this.messageRoomIds.set(messageId, roomId);
|
||||
}
|
||||
|
||||
getMessageRoomId(messageId: string): string | undefined {
|
||||
return this.messageRoomIds.get(messageId);
|
||||
}
|
||||
|
||||
deleteMessageRoom(messageId: string): void {
|
||||
this.messageRoomIds.delete(messageId);
|
||||
}
|
||||
|
||||
setOriginalFile(key: string, file: File): void {
|
||||
this.originalFiles.set(key, file);
|
||||
}
|
||||
|
||||
getOriginalFile(key: string): File | undefined {
|
||||
return this.originalFiles.get(key);
|
||||
}
|
||||
|
||||
findOriginalFileByFileId(fileId: string): File | null {
|
||||
for (const [key, file] of this.originalFiles) {
|
||||
if (key.endsWith(`:${fileId}`)) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
addCancelledTransfer(key: string): void {
|
||||
this.cancelledTransfers.add(key);
|
||||
}
|
||||
|
||||
hasCancelledTransfer(key: string): boolean {
|
||||
return this.cancelledTransfers.has(key);
|
||||
}
|
||||
|
||||
setPendingRequestPeers(key: string, peers: Set<string>): void {
|
||||
this.pendingRequests.set(key, peers);
|
||||
}
|
||||
|
||||
getPendingRequestPeers(key: string): Set<string> | undefined {
|
||||
return this.pendingRequests.get(key);
|
||||
}
|
||||
|
||||
hasPendingRequest(key: string): boolean {
|
||||
return this.pendingRequests.has(key);
|
||||
}
|
||||
|
||||
deletePendingRequest(key: string): void {
|
||||
this.pendingRequests.delete(key);
|
||||
}
|
||||
|
||||
setChunkBuffer(key: string, buffer: ArrayBuffer[]): void {
|
||||
this.chunkBuffers.set(key, buffer);
|
||||
}
|
||||
|
||||
getChunkBuffer(key: string): ArrayBuffer[] | undefined {
|
||||
return this.chunkBuffers.get(key);
|
||||
}
|
||||
|
||||
deleteChunkBuffer(key: string): void {
|
||||
this.chunkBuffers.delete(key);
|
||||
}
|
||||
|
||||
setChunkCount(key: string, count: number): void {
|
||||
this.chunkCounts.set(key, count);
|
||||
}
|
||||
|
||||
getChunkCount(key: string): number | undefined {
|
||||
return this.chunkCounts.get(key);
|
||||
}
|
||||
|
||||
deleteChunkCount(key: string): void {
|
||||
this.chunkCounts.delete(key);
|
||||
}
|
||||
|
||||
clearMessageScopedState(messageId: string): void {
|
||||
const scopedPrefix = `${messageId}:`;
|
||||
|
||||
for (const key of Array.from(this.originalFiles.keys())) {
|
||||
if (key.startsWith(scopedPrefix)) {
|
||||
this.originalFiles.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Array.from(this.pendingRequests.keys())) {
|
||||
if (key.startsWith(scopedPrefix)) {
|
||||
this.pendingRequests.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Array.from(this.chunkBuffers.keys())) {
|
||||
if (key.startsWith(scopedPrefix)) {
|
||||
this.chunkBuffers.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Array.from(this.chunkCounts.keys())) {
|
||||
if (key.startsWith(scopedPrefix)) {
|
||||
this.chunkCounts.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Array.from(this.cancelledTransfers)) {
|
||||
if (key.startsWith(scopedPrefix)) {
|
||||
this.cancelledTransfers.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { AttachmentStorageService } from '../infrastructure/attachment-storage.service';
|
||||
import { FILE_CHUNK_SIZE_BYTES } from '../domain/attachment-transfer.constants';
|
||||
import { FileChunkEvent } from '../domain/attachment-transfer.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentTransferTransportService {
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly attachmentStorage = inject(AttachmentStorageService);
|
||||
|
||||
decodeBase64(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let index = 0; index < binary.length; index++) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async streamFileToPeer(
|
||||
targetPeerId: string,
|
||||
messageId: string,
|
||||
fileId: string,
|
||||
file: File,
|
||||
isCancelled: () => boolean
|
||||
): Promise<void> {
|
||||
const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES);
|
||||
|
||||
let offset = 0;
|
||||
let chunkIndex = 0;
|
||||
|
||||
while (offset < file.size) {
|
||||
if (isCancelled())
|
||||
break;
|
||||
|
||||
const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES);
|
||||
const arrayBuffer = await slice.arrayBuffer();
|
||||
const base64 = this.arrayBufferToBase64(arrayBuffer);
|
||||
const fileChunkEvent: FileChunkEvent = {
|
||||
type: 'file-chunk',
|
||||
messageId,
|
||||
fileId,
|
||||
index: chunkIndex,
|
||||
total: totalChunks,
|
||||
data: base64
|
||||
};
|
||||
|
||||
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
|
||||
|
||||
offset += FILE_CHUNK_SIZE_BYTES;
|
||||
chunkIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
async streamFileFromDiskToPeer(
|
||||
targetPeerId: string,
|
||||
messageId: string,
|
||||
fileId: string,
|
||||
diskPath: string,
|
||||
isCancelled: () => boolean
|
||||
): Promise<void> {
|
||||
const base64Full = await this.attachmentStorage.readFile(diskPath);
|
||||
|
||||
if (!base64Full)
|
||||
return;
|
||||
|
||||
const fileBytes = this.decodeBase64(base64Full);
|
||||
const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES);
|
||||
|
||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||
if (isCancelled())
|
||||
break;
|
||||
|
||||
const start = chunkIndex * FILE_CHUNK_SIZE_BYTES;
|
||||
const end = Math.min(fileBytes.byteLength, start + FILE_CHUNK_SIZE_BYTES);
|
||||
const slice = fileBytes.subarray(start, end);
|
||||
const sliceBuffer = (slice.buffer as ArrayBuffer).slice(
|
||||
slice.byteOffset,
|
||||
slice.byteOffset + slice.byteLength
|
||||
);
|
||||
const base64Chunk = this.arrayBufferToBase64(sliceBuffer);
|
||||
const fileChunkEvent: FileChunkEvent = {
|
||||
type: 'file-chunk',
|
||||
messageId,
|
||||
fileId,
|
||||
index: chunkIndex,
|
||||
total: totalChunks,
|
||||
data: base64Chunk
|
||||
};
|
||||
|
||||
this.webrtc.sendToPeer(targetPeerId, fileChunkEvent);
|
||||
}
|
||||
}
|
||||
|
||||
private arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
let binary = '';
|
||||
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
for (let index = 0; index < bytes.byteLength; index++) {
|
||||
binary += String.fromCharCode(bytes[index]);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { recordDebugNetworkFileChunk } from '../../../infrastructure/realtime/logging/debug-network-metrics';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { AttachmentStorageService } from '../infrastructure/attachment-storage.service';
|
||||
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../domain/attachment.constants';
|
||||
import { shouldPersistDownloadedAttachment } from '../domain/attachment.logic';
|
||||
import type { Attachment, AttachmentMeta } from '../domain/attachment.models';
|
||||
import {
|
||||
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT,
|
||||
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT,
|
||||
DEFAULT_ATTACHMENT_MIME_TYPE,
|
||||
FILE_NOT_FOUND_REQUEST_ERROR,
|
||||
NO_CONNECTED_PEERS_REQUEST_ERROR
|
||||
} from '../domain/attachment-transfer.constants';
|
||||
import {
|
||||
type FileAnnounceEvent,
|
||||
type FileAnnouncePayload,
|
||||
type FileCancelEvent,
|
||||
type FileCancelPayload,
|
||||
type FileChunkPayload,
|
||||
type FileNotFoundEvent,
|
||||
type FileNotFoundPayload,
|
||||
type FileRequestEvent,
|
||||
type FileRequestPayload,
|
||||
type LocalFileWithPath
|
||||
} from '../domain/attachment-transfer.models';
|
||||
import { AttachmentPersistenceService } from './attachment-persistence.service';
|
||||
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentTransferService {
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly runtimeStore = inject(AttachmentRuntimeStore);
|
||||
private readonly attachmentStorage = inject(AttachmentStorageService);
|
||||
private readonly persistence = inject(AttachmentPersistenceService);
|
||||
private readonly transport = inject(AttachmentTransferTransportService);
|
||||
|
||||
getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> {
|
||||
const result: Record<string, AttachmentMeta[]> = {};
|
||||
|
||||
for (const messageId of messageIds) {
|
||||
const attachments = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||
|
||||
if (attachments.length > 0) {
|
||||
result[messageId] = attachments.map((attachment) => ({
|
||||
id: attachment.id,
|
||||
messageId: attachment.messageId,
|
||||
filename: attachment.filename,
|
||||
size: attachment.size,
|
||||
mime: attachment.mime,
|
||||
isImage: attachment.isImage,
|
||||
uploaderPeerId: attachment.uploaderPeerId,
|
||||
filePath: undefined,
|
||||
savedPath: undefined
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
registerSyncedAttachments(
|
||||
attachmentMap: Record<string, AttachmentMeta[]>,
|
||||
messageRoomIds?: Record<string, string>
|
||||
): void {
|
||||
if (messageRoomIds) {
|
||||
for (const [messageId, roomId] of Object.entries(messageRoomIds)) {
|
||||
this.runtimeStore.rememberMessageRoom(messageId, roomId);
|
||||
}
|
||||
}
|
||||
|
||||
const newAttachments: Attachment[] = [];
|
||||
|
||||
for (const [messageId, metas] of Object.entries(attachmentMap)) {
|
||||
const existing = [...this.runtimeStore.getAttachmentsForMessage(messageId)];
|
||||
|
||||
for (const meta of metas) {
|
||||
const alreadyKnown = existing.find((entry) => entry.id === meta.id);
|
||||
|
||||
if (!alreadyKnown) {
|
||||
const attachment: Attachment = { ...meta,
|
||||
available: false,
|
||||
receivedBytes: 0 };
|
||||
|
||||
existing.push(attachment);
|
||||
newAttachments.push(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
this.runtimeStore.setAttachmentsForMessage(messageId, existing);
|
||||
}
|
||||
|
||||
if (newAttachments.length > 0) {
|
||||
this.runtimeStore.touch();
|
||||
|
||||
for (const attachment of newAttachments) {
|
||||
void this.persistence.persistAttachmentMeta(attachment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requestFromAnyPeer(messageId: string, attachment: Attachment): void {
|
||||
const clearedRequestError = this.clearAttachmentRequestError(attachment);
|
||||
const connectedPeers = this.webrtc.getConnectedPeers();
|
||||
|
||||
if (connectedPeers.length === 0) {
|
||||
attachment.requestError = NO_CONNECTED_PEERS_REQUEST_ERROR;
|
||||
this.runtimeStore.touch();
|
||||
console.warn('[Attachments] No connected peers to request file from');
|
||||
return;
|
||||
}
|
||||
|
||||
if (clearedRequestError)
|
||||
this.runtimeStore.touch();
|
||||
|
||||
this.runtimeStore.setPendingRequestPeers(
|
||||
this.buildRequestKey(messageId, attachment.id),
|
||||
new Set<string>()
|
||||
);
|
||||
|
||||
this.sendFileRequestToNextPeer(messageId, attachment.id, attachment.uploaderPeerId);
|
||||
}
|
||||
|
||||
handleFileNotFound(payload: FileNotFoundPayload): void {
|
||||
const { messageId, fileId } = payload;
|
||||
|
||||
if (!messageId || !fileId)
|
||||
return;
|
||||
|
||||
const attachments = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||
const attachment = attachments.find((entry) => entry.id === fileId);
|
||||
const didSendRequest = this.sendFileRequestToNextPeer(messageId, fileId, attachment?.uploaderPeerId);
|
||||
|
||||
if (!didSendRequest && attachment) {
|
||||
attachment.requestError = FILE_NOT_FOUND_REQUEST_ERROR;
|
||||
this.runtimeStore.touch();
|
||||
}
|
||||
}
|
||||
|
||||
requestImageFromAnyPeer(messageId: string, attachment: Attachment): void {
|
||||
this.requestFromAnyPeer(messageId, attachment);
|
||||
}
|
||||
|
||||
requestFile(messageId: string, attachment: Attachment): void {
|
||||
this.requestFromAnyPeer(messageId, attachment);
|
||||
}
|
||||
|
||||
hasPendingRequest(messageId: string, fileId: string): boolean {
|
||||
return this.runtimeStore.hasPendingRequest(this.buildRequestKey(messageId, fileId));
|
||||
}
|
||||
|
||||
async publishAttachments(
|
||||
messageId: string,
|
||||
files: File[],
|
||||
uploaderPeerId?: string
|
||||
): Promise<void> {
|
||||
const attachments: Attachment[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const fileId = crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
|
||||
const attachment: Attachment = {
|
||||
id: fileId,
|
||||
messageId,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
mime: file.type || DEFAULT_ATTACHMENT_MIME_TYPE,
|
||||
isImage: file.type.startsWith('image/'),
|
||||
uploaderPeerId,
|
||||
filePath: (file as LocalFileWithPath).path,
|
||||
available: false
|
||||
};
|
||||
|
||||
attachments.push(attachment);
|
||||
this.runtimeStore.setOriginalFile(`${messageId}:${fileId}`, file);
|
||||
|
||||
try {
|
||||
attachment.objectUrl = URL.createObjectURL(file);
|
||||
attachment.available = true;
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
|
||||
void this.persistence.saveFileToDisk(attachment, file);
|
||||
}
|
||||
|
||||
const fileAnnounceEvent: FileAnnounceEvent = {
|
||||
type: 'file-announce',
|
||||
messageId,
|
||||
file: {
|
||||
id: fileId,
|
||||
filename: attachment.filename,
|
||||
size: attachment.size,
|
||||
mime: attachment.mime,
|
||||
isImage: attachment.isImage,
|
||||
uploaderPeerId
|
||||
}
|
||||
};
|
||||
|
||||
this.webrtc.broadcastMessage(fileAnnounceEvent);
|
||||
}
|
||||
|
||||
const existingList = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||
|
||||
this.runtimeStore.setAttachmentsForMessage(messageId, [...existingList, ...attachments]);
|
||||
this.runtimeStore.touch();
|
||||
|
||||
for (const attachment of attachments) {
|
||||
void this.persistence.persistAttachmentMeta(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
handleFileAnnounce(payload: FileAnnouncePayload): void {
|
||||
const { messageId, file } = payload;
|
||||
|
||||
if (!messageId || !file)
|
||||
return;
|
||||
|
||||
const list = [...this.runtimeStore.getAttachmentsForMessage(messageId)];
|
||||
const alreadyKnown = list.find((entry) => entry.id === file.id);
|
||||
|
||||
if (alreadyKnown)
|
||||
return;
|
||||
|
||||
const attachment: Attachment = {
|
||||
id: file.id,
|
||||
messageId,
|
||||
filename: file.filename,
|
||||
size: file.size,
|
||||
mime: file.mime,
|
||||
isImage: !!file.isImage,
|
||||
uploaderPeerId: file.uploaderPeerId,
|
||||
available: false,
|
||||
receivedBytes: 0
|
||||
};
|
||||
|
||||
list.push(attachment);
|
||||
this.runtimeStore.setAttachmentsForMessage(messageId, list);
|
||||
this.runtimeStore.touch();
|
||||
void this.persistence.persistAttachmentMeta(attachment);
|
||||
}
|
||||
|
||||
handleFileChunk(payload: FileChunkPayload): void {
|
||||
const { messageId, fileId, fromPeerId, index, total, data } = payload;
|
||||
|
||||
if (
|
||||
!messageId || !fileId ||
|
||||
typeof index !== 'number' ||
|
||||
typeof total !== 'number' ||
|
||||
typeof data !== 'string'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||
const attachment = list.find((entry) => entry.id === fileId);
|
||||
|
||||
if (!attachment)
|
||||
return;
|
||||
|
||||
const decodedBytes = this.transport.decodeBase64(data);
|
||||
const assemblyKey = `${messageId}:${fileId}`;
|
||||
const requestKey = this.buildRequestKey(messageId, fileId);
|
||||
|
||||
this.runtimeStore.deletePendingRequest(requestKey);
|
||||
this.clearAttachmentRequestError(attachment);
|
||||
|
||||
const chunkBuffer = this.getOrCreateChunkBuffer(assemblyKey, total);
|
||||
|
||||
if (!chunkBuffer[index]) {
|
||||
chunkBuffer[index] = decodedBytes.buffer as ArrayBuffer;
|
||||
this.runtimeStore.setChunkCount(assemblyKey, (this.runtimeStore.getChunkCount(assemblyKey) ?? 0) + 1);
|
||||
}
|
||||
|
||||
this.updateTransferProgress(attachment, decodedBytes, fromPeerId);
|
||||
|
||||
this.runtimeStore.touch();
|
||||
this.finalizeTransferIfComplete(attachment, assemblyKey, total);
|
||||
}
|
||||
|
||||
async handleFileRequest(payload: FileRequestPayload): Promise<void> {
|
||||
const { messageId, fileId, fromPeerId } = payload;
|
||||
|
||||
if (!messageId || !fileId || !fromPeerId)
|
||||
return;
|
||||
|
||||
const exactKey = `${messageId}:${fileId}`;
|
||||
const originalFile = this.runtimeStore.getOriginalFile(exactKey)
|
||||
?? this.runtimeStore.findOriginalFileByFileId(fileId);
|
||||
|
||||
if (originalFile) {
|
||||
await this.transport.streamFileToPeer(
|
||||
fromPeerId,
|
||||
messageId,
|
||||
fileId,
|
||||
originalFile,
|
||||
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const list = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||
const attachment = list.find((entry) => entry.id === fileId);
|
||||
const diskPath = attachment
|
||||
? await this.attachmentStorage.resolveExistingPath(attachment)
|
||||
: null;
|
||||
|
||||
if (diskPath) {
|
||||
await this.transport.streamFileFromDiskToPeer(
|
||||
fromPeerId,
|
||||
messageId,
|
||||
fileId,
|
||||
diskPath,
|
||||
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachment?.isImage) {
|
||||
const roomName = await this.persistence.resolveCurrentRoomName();
|
||||
const legacyDiskPath = await this.attachmentStorage.resolveLegacyImagePath(
|
||||
attachment.filename,
|
||||
roomName
|
||||
);
|
||||
|
||||
if (legacyDiskPath) {
|
||||
await this.transport.streamFileFromDiskToPeer(
|
||||
fromPeerId,
|
||||
messageId,
|
||||
fileId,
|
||||
legacyDiskPath,
|
||||
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (attachment?.available && attachment.objectUrl) {
|
||||
try {
|
||||
const response = await fetch(attachment.objectUrl);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], attachment.filename, { type: attachment.mime });
|
||||
|
||||
await this.transport.streamFileToPeer(
|
||||
fromPeerId,
|
||||
messageId,
|
||||
fileId,
|
||||
file,
|
||||
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
|
||||
);
|
||||
|
||||
return;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
const fileNotFoundEvent: FileNotFoundEvent = {
|
||||
type: 'file-not-found',
|
||||
messageId,
|
||||
fileId
|
||||
};
|
||||
|
||||
this.webrtc.sendToPeer(fromPeerId, fileNotFoundEvent);
|
||||
}
|
||||
|
||||
cancelRequest(messageId: string, attachment: Attachment): void {
|
||||
const targetPeerId = attachment.uploaderPeerId;
|
||||
|
||||
if (!targetPeerId)
|
||||
return;
|
||||
|
||||
try {
|
||||
const assemblyKey = `${messageId}:${attachment.id}`;
|
||||
|
||||
this.runtimeStore.deleteChunkBuffer(assemblyKey);
|
||||
this.runtimeStore.deleteChunkCount(assemblyKey);
|
||||
|
||||
attachment.receivedBytes = 0;
|
||||
attachment.speedBps = 0;
|
||||
attachment.startedAtMs = undefined;
|
||||
attachment.lastUpdateMs = undefined;
|
||||
|
||||
if (attachment.objectUrl) {
|
||||
try {
|
||||
URL.revokeObjectURL(attachment.objectUrl);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
attachment.objectUrl = undefined;
|
||||
}
|
||||
|
||||
attachment.available = false;
|
||||
this.runtimeStore.touch();
|
||||
|
||||
const fileCancelEvent: FileCancelEvent = {
|
||||
type: 'file-cancel',
|
||||
messageId,
|
||||
fileId: attachment.id
|
||||
};
|
||||
|
||||
this.webrtc.sendToPeer(targetPeerId, fileCancelEvent);
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
handleFileCancel(payload: FileCancelPayload): void {
|
||||
const { messageId, fileId, fromPeerId } = payload;
|
||||
|
||||
if (!messageId || !fileId || !fromPeerId)
|
||||
return;
|
||||
|
||||
this.runtimeStore.addCancelledTransfer(
|
||||
this.buildTransferKey(messageId, fileId, fromPeerId)
|
||||
);
|
||||
}
|
||||
|
||||
async fulfillRequestWithFile(
|
||||
messageId: string,
|
||||
fileId: string,
|
||||
targetPeerId: string,
|
||||
file: File
|
||||
): Promise<void> {
|
||||
this.runtimeStore.setOriginalFile(`${messageId}:${fileId}`, file);
|
||||
await this.transport.streamFileToPeer(
|
||||
targetPeerId,
|
||||
messageId,
|
||||
fileId,
|
||||
file,
|
||||
() => this.isTransferCancelled(targetPeerId, messageId, fileId)
|
||||
);
|
||||
}
|
||||
|
||||
private buildTransferKey(messageId: string, fileId: string, peerId: string): string {
|
||||
return `${messageId}:${fileId}:${peerId}`;
|
||||
}
|
||||
|
||||
private buildRequestKey(messageId: string, fileId: string): string {
|
||||
return `${messageId}:${fileId}`;
|
||||
}
|
||||
|
||||
private clearAttachmentRequestError(attachment: Attachment): boolean {
|
||||
if (!attachment.requestError)
|
||||
return false;
|
||||
|
||||
attachment.requestError = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
private isTransferCancelled(targetPeerId: string, messageId: string, fileId: string): boolean {
|
||||
return this.runtimeStore.hasCancelledTransfer(
|
||||
this.buildTransferKey(messageId, fileId, targetPeerId)
|
||||
);
|
||||
}
|
||||
|
||||
private sendFileRequestToNextPeer(
|
||||
messageId: string,
|
||||
fileId: string,
|
||||
preferredPeerId?: string
|
||||
): boolean {
|
||||
const connectedPeers = this.webrtc.getConnectedPeers();
|
||||
const requestKey = this.buildRequestKey(messageId, fileId);
|
||||
const triedPeers = this.runtimeStore.getPendingRequestPeers(requestKey) ?? new Set<string>();
|
||||
|
||||
let targetPeerId: string | undefined;
|
||||
|
||||
if (preferredPeerId && connectedPeers.includes(preferredPeerId) && !triedPeers.has(preferredPeerId)) {
|
||||
targetPeerId = preferredPeerId;
|
||||
} else {
|
||||
targetPeerId = connectedPeers.find((peerId) => !triedPeers.has(peerId));
|
||||
}
|
||||
|
||||
if (!targetPeerId) {
|
||||
this.runtimeStore.deletePendingRequest(requestKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
triedPeers.add(targetPeerId);
|
||||
this.runtimeStore.setPendingRequestPeers(requestKey, triedPeers);
|
||||
|
||||
const fileRequestEvent: FileRequestEvent = {
|
||||
type: 'file-request',
|
||||
messageId,
|
||||
fileId
|
||||
};
|
||||
|
||||
this.webrtc.sendToPeer(targetPeerId, fileRequestEvent);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private getOrCreateChunkBuffer(assemblyKey: string, total: number): ArrayBuffer[] {
|
||||
const existingChunkBuffer = this.runtimeStore.getChunkBuffer(assemblyKey);
|
||||
|
||||
if (existingChunkBuffer) {
|
||||
return existingChunkBuffer;
|
||||
}
|
||||
|
||||
const createdChunkBuffer = new Array(total);
|
||||
|
||||
this.runtimeStore.setChunkBuffer(assemblyKey, createdChunkBuffer);
|
||||
this.runtimeStore.setChunkCount(assemblyKey, 0);
|
||||
|
||||
return createdChunkBuffer;
|
||||
}
|
||||
|
||||
private updateTransferProgress(
|
||||
attachment: Attachment,
|
||||
decodedBytes: Uint8Array,
|
||||
fromPeerId?: string
|
||||
): void {
|
||||
const now = Date.now();
|
||||
const previousReceived = attachment.receivedBytes ?? 0;
|
||||
|
||||
attachment.receivedBytes = previousReceived + decodedBytes.byteLength;
|
||||
|
||||
if (fromPeerId) {
|
||||
recordDebugNetworkFileChunk(fromPeerId, decodedBytes.byteLength, now);
|
||||
}
|
||||
|
||||
if (!attachment.startedAtMs)
|
||||
attachment.startedAtMs = now;
|
||||
|
||||
if (!attachment.lastUpdateMs)
|
||||
attachment.lastUpdateMs = now;
|
||||
|
||||
const elapsedMs = Math.max(1, now - attachment.lastUpdateMs);
|
||||
const instantaneousBps = (decodedBytes.byteLength / elapsedMs) * 1000;
|
||||
const previousSpeed = attachment.speedBps ?? instantaneousBps;
|
||||
|
||||
attachment.speedBps =
|
||||
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT * previousSpeed +
|
||||
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT * instantaneousBps;
|
||||
|
||||
attachment.lastUpdateMs = now;
|
||||
}
|
||||
|
||||
private finalizeTransferIfComplete(
|
||||
attachment: Attachment,
|
||||
assemblyKey: string,
|
||||
total: number
|
||||
): void {
|
||||
const receivedChunkCount = this.runtimeStore.getChunkCount(assemblyKey) ?? 0;
|
||||
const completeBuffer = this.runtimeStore.getChunkBuffer(assemblyKey);
|
||||
|
||||
if (
|
||||
!completeBuffer
|
||||
|| (receivedChunkCount !== total && (attachment.receivedBytes ?? 0) < attachment.size)
|
||||
|| !completeBuffer.every((part) => part instanceof ArrayBuffer)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob(completeBuffer, { type: attachment.mime });
|
||||
|
||||
attachment.available = true;
|
||||
attachment.objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
if (shouldPersistDownloadedAttachment(attachment)) {
|
||||
void this.persistence.saveFileToDisk(attachment, blob);
|
||||
}
|
||||
|
||||
this.runtimeStore.deleteChunkBuffer(assemblyKey);
|
||||
this.runtimeStore.deleteChunkCount(assemblyKey);
|
||||
this.runtimeStore.touch();
|
||||
void this.persistence.persistAttachmentMeta(attachment);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { AttachmentManagerService } from './attachment-manager.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentFacade {
|
||||
get updated() {
|
||||
return this.manager.updated;
|
||||
}
|
||||
|
||||
private readonly manager = inject(AttachmentManagerService);
|
||||
|
||||
getForMessage(
|
||||
...args: Parameters<AttachmentManagerService['getForMessage']>
|
||||
): ReturnType<AttachmentManagerService['getForMessage']> {
|
||||
return this.manager.getForMessage(...args);
|
||||
}
|
||||
|
||||
rememberMessageRoom(
|
||||
...args: Parameters<AttachmentManagerService['rememberMessageRoom']>
|
||||
): ReturnType<AttachmentManagerService['rememberMessageRoom']> {
|
||||
return this.manager.rememberMessageRoom(...args);
|
||||
}
|
||||
|
||||
queueAutoDownloadsForMessage(
|
||||
...args: Parameters<AttachmentManagerService['queueAutoDownloadsForMessage']>
|
||||
): ReturnType<AttachmentManagerService['queueAutoDownloadsForMessage']> {
|
||||
return this.manager.queueAutoDownloadsForMessage(...args);
|
||||
}
|
||||
|
||||
requestAutoDownloadsForRoom(
|
||||
...args: Parameters<AttachmentManagerService['requestAutoDownloadsForRoom']>
|
||||
): ReturnType<AttachmentManagerService['requestAutoDownloadsForRoom']> {
|
||||
return this.manager.requestAutoDownloadsForRoom(...args);
|
||||
}
|
||||
|
||||
deleteForMessage(
|
||||
...args: Parameters<AttachmentManagerService['deleteForMessage']>
|
||||
): ReturnType<AttachmentManagerService['deleteForMessage']> {
|
||||
return this.manager.deleteForMessage(...args);
|
||||
}
|
||||
|
||||
getAttachmentMetasForMessages(
|
||||
...args: Parameters<AttachmentManagerService['getAttachmentMetasForMessages']>
|
||||
): ReturnType<AttachmentManagerService['getAttachmentMetasForMessages']> {
|
||||
return this.manager.getAttachmentMetasForMessages(...args);
|
||||
}
|
||||
|
||||
registerSyncedAttachments(
|
||||
...args: Parameters<AttachmentManagerService['registerSyncedAttachments']>
|
||||
): ReturnType<AttachmentManagerService['registerSyncedAttachments']> {
|
||||
return this.manager.registerSyncedAttachments(...args);
|
||||
}
|
||||
|
||||
requestFromAnyPeer(
|
||||
...args: Parameters<AttachmentManagerService['requestFromAnyPeer']>
|
||||
): ReturnType<AttachmentManagerService['requestFromAnyPeer']> {
|
||||
return this.manager.requestFromAnyPeer(...args);
|
||||
}
|
||||
|
||||
handleFileNotFound(
|
||||
...args: Parameters<AttachmentManagerService['handleFileNotFound']>
|
||||
): ReturnType<AttachmentManagerService['handleFileNotFound']> {
|
||||
return this.manager.handleFileNotFound(...args);
|
||||
}
|
||||
|
||||
requestImageFromAnyPeer(
|
||||
...args: Parameters<AttachmentManagerService['requestImageFromAnyPeer']>
|
||||
): ReturnType<AttachmentManagerService['requestImageFromAnyPeer']> {
|
||||
return this.manager.requestImageFromAnyPeer(...args);
|
||||
}
|
||||
|
||||
requestFile(
|
||||
...args: Parameters<AttachmentManagerService['requestFile']>
|
||||
): ReturnType<AttachmentManagerService['requestFile']> {
|
||||
return this.manager.requestFile(...args);
|
||||
}
|
||||
|
||||
publishAttachments(
|
||||
...args: Parameters<AttachmentManagerService['publishAttachments']>
|
||||
): ReturnType<AttachmentManagerService['publishAttachments']> {
|
||||
return this.manager.publishAttachments(...args);
|
||||
}
|
||||
|
||||
handleFileAnnounce(
|
||||
...args: Parameters<AttachmentManagerService['handleFileAnnounce']>
|
||||
): ReturnType<AttachmentManagerService['handleFileAnnounce']> {
|
||||
return this.manager.handleFileAnnounce(...args);
|
||||
}
|
||||
|
||||
handleFileChunk(
|
||||
...args: Parameters<AttachmentManagerService['handleFileChunk']>
|
||||
): ReturnType<AttachmentManagerService['handleFileChunk']> {
|
||||
return this.manager.handleFileChunk(...args);
|
||||
}
|
||||
|
||||
handleFileRequest(
|
||||
...args: Parameters<AttachmentManagerService['handleFileRequest']>
|
||||
): ReturnType<AttachmentManagerService['handleFileRequest']> {
|
||||
return this.manager.handleFileRequest(...args);
|
||||
}
|
||||
|
||||
cancelRequest(
|
||||
...args: Parameters<AttachmentManagerService['cancelRequest']>
|
||||
): ReturnType<AttachmentManagerService['cancelRequest']> {
|
||||
return this.manager.cancelRequest(...args);
|
||||
}
|
||||
|
||||
handleFileCancel(
|
||||
...args: Parameters<AttachmentManagerService['handleFileCancel']>
|
||||
): ReturnType<AttachmentManagerService['handleFileCancel']> {
|
||||
return this.manager.handleFileCancel(...args);
|
||||
}
|
||||
|
||||
fulfillRequestWithFile(
|
||||
...args: Parameters<AttachmentManagerService['fulfillRequestWithFile']>
|
||||
): ReturnType<AttachmentManagerService['fulfillRequestWithFile']> {
|
||||
return this.manager.fulfillRequestWithFile(...args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */
|
||||
export const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB
|
||||
|
||||
/**
|
||||
* EWMA smoothing weight for the previous speed estimate.
|
||||
* The complementary weight is applied to the latest sample.
|
||||
*/
|
||||
export const ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT = 0.7;
|
||||
export const ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT = 1 - ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT;
|
||||
|
||||
/** Fallback MIME type when none is provided by the sender. */
|
||||
export const DEFAULT_ATTACHMENT_MIME_TYPE = 'application/octet-stream';
|
||||
|
||||
/** localStorage key used by the legacy attachment store during migration. */
|
||||
export const LEGACY_ATTACHMENTS_STORAGE_KEY = 'metoyou_attachments';
|
||||
|
||||
/** User-facing error when no peers are available for a request. */
|
||||
export const NO_CONNECTED_PEERS_REQUEST_ERROR = 'No connected peers are available to provide this file right now.';
|
||||
|
||||
/** User-facing error when connected peers cannot provide a requested file. */
|
||||
export const FILE_NOT_FOUND_REQUEST_ERROR = 'The connected peers do not have this file right now.';
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { ChatEvent } from '../../../shared-kernel';
|
||||
import type { ChatAttachmentAnnouncement } from '../../../shared-kernel';
|
||||
|
||||
export type FileAnnounceEvent = ChatEvent & {
|
||||
type: 'file-announce';
|
||||
messageId: string;
|
||||
file: ChatAttachmentAnnouncement;
|
||||
};
|
||||
|
||||
export type FileChunkEvent = ChatEvent & {
|
||||
type: 'file-chunk';
|
||||
messageId: string;
|
||||
fileId: string;
|
||||
index: number;
|
||||
total: number;
|
||||
data: string;
|
||||
fromPeerId?: string;
|
||||
};
|
||||
|
||||
export type FileRequestEvent = ChatEvent & {
|
||||
type: 'file-request';
|
||||
messageId: string;
|
||||
fileId: string;
|
||||
fromPeerId?: string;
|
||||
};
|
||||
|
||||
export type FileCancelEvent = ChatEvent & {
|
||||
type: 'file-cancel';
|
||||
messageId: string;
|
||||
fileId: string;
|
||||
fromPeerId?: string;
|
||||
};
|
||||
|
||||
export type FileNotFoundEvent = ChatEvent & {
|
||||
type: 'file-not-found';
|
||||
messageId: string;
|
||||
fileId: string;
|
||||
};
|
||||
|
||||
export type FileAnnouncePayload = Pick<ChatEvent, 'messageId' | 'file'>;
|
||||
|
||||
export interface FileChunkPayload {
|
||||
messageId?: string;
|
||||
fileId?: string;
|
||||
fromPeerId?: string;
|
||||
index?: number;
|
||||
total?: number;
|
||||
data?: ChatEvent['data'];
|
||||
}
|
||||
|
||||
export type FileRequestPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
|
||||
export type FileCancelPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
|
||||
export type FileNotFoundPayload = Pick<ChatEvent, 'messageId' | 'fileId'>;
|
||||
|
||||
export type LocalFileWithPath = File & {
|
||||
path?: string;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Maximum file size (bytes) that is automatically saved or pushed for inline previews. */
|
||||
export const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MAX_AUTO_SAVE_SIZE_BYTES } from './attachment.constants';
|
||||
import type { Attachment } from './attachment.models';
|
||||
|
||||
export function isAttachmentMedia(attachment: Pick<Attachment, 'mime'>): boolean {
|
||||
return attachment.mime.startsWith('image/') ||
|
||||
attachment.mime.startsWith('video/') ||
|
||||
attachment.mime.startsWith('audio/');
|
||||
}
|
||||
|
||||
export function shouldAutoRequestWhenWatched(attachment: Attachment): boolean {
|
||||
return attachment.isImage ||
|
||||
(isAttachmentMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES);
|
||||
}
|
||||
|
||||
export function shouldPersistDownloadedAttachment(attachment: Pick<Attachment, 'size' | 'mime'>): boolean {
|
||||
return attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES ||
|
||||
attachment.mime.startsWith('video/') ||
|
||||
attachment.mime.startsWith('audio/');
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ChatAttachmentMeta } from '../../../shared-kernel';
|
||||
|
||||
export type AttachmentMeta = ChatAttachmentMeta;
|
||||
|
||||
export interface Attachment extends AttachmentMeta {
|
||||
available: boolean;
|
||||
objectUrl?: string;
|
||||
receivedBytes?: number;
|
||||
speedBps?: number;
|
||||
startedAtMs?: number;
|
||||
lastUpdateMs?: number;
|
||||
requestError?: string;
|
||||
}
|
||||
3
toju-app/src/app/domains/attachment/index.ts
Normal file
3
toju-app/src/app/domains/attachment/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './application/attachment.facade';
|
||||
export * from './domain/attachment.constants';
|
||||
export * from './domain/attachment.models';
|
||||
@@ -0,0 +1,23 @@
|
||||
const ROOM_NAME_SANITIZER = /[^\w.-]+/g;
|
||||
|
||||
export function sanitizeAttachmentRoomName(roomName: string): string {
|
||||
const sanitizedRoomName = roomName.trim().replace(ROOM_NAME_SANITIZER, '_');
|
||||
|
||||
return sanitizedRoomName || 'room';
|
||||
}
|
||||
|
||||
export function resolveAttachmentStorageBucket(mime: string): 'video' | 'audio' | 'image' | 'files' {
|
||||
if (mime.startsWith('video/')) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (mime.startsWith('audio/')) {
|
||||
return 'audio';
|
||||
}
|
||||
|
||||
if (mime.startsWith('image/')) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
return 'files';
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||
import type { Attachment } from '../domain/attachment.models';
|
||||
import { resolveAttachmentStorageBucket, sanitizeAttachmentRoomName } from './attachment-storage.helpers';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentStorageService {
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
async resolveExistingPath(
|
||||
attachment: Pick<Attachment, 'filePath' | 'savedPath'>
|
||||
): Promise<string | null> {
|
||||
return this.findExistingPath([attachment.filePath, attachment.savedPath]);
|
||||
}
|
||||
|
||||
async resolveLegacyImagePath(filename: string, roomName: string): Promise<string | null> {
|
||||
const appDataPath = await this.resolveAppDataPath();
|
||||
|
||||
if (!appDataPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.findExistingPath([`${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/image/${filename}`]);
|
||||
}
|
||||
|
||||
async readFile(filePath: string): Promise<string | null> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi || !filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await electronApi.readFile(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async saveBlob(
|
||||
attachment: Pick<Attachment, 'filename' | 'mime'>,
|
||||
blob: Blob,
|
||||
roomName: string
|
||||
): Promise<string | null> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
const appDataPath = await this.resolveAppDataPath();
|
||||
|
||||
if (!electronApi || !appDataPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const directoryPath = `${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/${resolveAttachmentStorageBucket(attachment.mime)}`;
|
||||
|
||||
await electronApi.ensureDir(directoryPath);
|
||||
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const diskPath = `${directoryPath}/${attachment.filename}`;
|
||||
|
||||
await electronApi.writeFile(diskPath, this.arrayBufferToBase64(arrayBuffer));
|
||||
|
||||
return diskPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFile(filePath: string): Promise<void> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi || !filePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await electronApi.deleteFile(filePath);
|
||||
} catch { /* best-effort cleanup */ }
|
||||
}
|
||||
|
||||
private async resolveAppDataPath(): Promise<string | null> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await electronApi.getAppDataPath();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async findExistingPath(candidates: (string | null | undefined)[]): Promise<string | null> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const candidatePath of candidates) {
|
||||
if (!candidatePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (await electronApi.fileExists(candidatePath)) {
|
||||
return candidatePath;
|
||||
}
|
||||
} catch { /* keep trying remaining candidates */ }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
let binary = '';
|
||||
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
for (let index = 0; index < bytes.byteLength; index++) {
|
||||
binary += String.fromCharCode(bytes[index]);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
}
|
||||
74
toju-app/src/app/domains/auth/README.md
Normal file
74
toju-app/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" _blank
|
||||
click Login "feature/login/" "Login form" _blank
|
||||
click Register "feature/register/" "Registration form" _blank
|
||||
click UserBar "feature/user-bar/" "Current user display" _blank
|
||||
click SD "../server-directory/application/server-directory.facade.ts" "Resolves API URL" _blank
|
||||
```
|
||||
|
||||
## 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.
|
||||
101
toju-app/src/app/domains/auth/application/auth.service.ts
Normal file
101
toju-app/src/app/domains/auth/application/auth.service.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { type ServerEndpoint, ServerDirectoryFacade } from '../../server-directory';
|
||||
|
||||
/**
|
||||
* Response returned by the authentication endpoints (login / register).
|
||||
*/
|
||||
export interface LoginResponse {
|
||||
/** Unique user identifier assigned by the server. */
|
||||
id: string;
|
||||
/** Login username. */
|
||||
username: string;
|
||||
/** Human-readable display name. */
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles user authentication (login and registration) against a
|
||||
* configurable back-end server.
|
||||
*
|
||||
* The target server is resolved via {@link ServerDirectoryFacade}: the
|
||||
* caller may pass an explicit `serverId`, otherwise the currently active
|
||||
* server endpoint is used.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
|
||||
/**
|
||||
* Resolve the API base URL for the given server.
|
||||
*
|
||||
* @param serverId - Optional server ID to look up. When omitted the
|
||||
* currently active endpoint is used.
|
||||
* @returns Fully-qualified API base URL (e.g. `http://host:3001/api`).
|
||||
*/
|
||||
private endpointFor(serverId?: string): string {
|
||||
let endpoint: ServerEndpoint | undefined;
|
||||
|
||||
if (serverId) {
|
||||
endpoint = this.serverDirectory.servers().find(
|
||||
(server) => server.id === serverId
|
||||
);
|
||||
}
|
||||
|
||||
const activeEndpoint = endpoint ?? this.serverDirectory.activeServer();
|
||||
|
||||
return activeEndpoint
|
||||
? `${activeEndpoint.url}/api`
|
||||
: this.serverDirectory.getApiBaseUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user account on the target server.
|
||||
*
|
||||
* @param params - Registration parameters.
|
||||
* @param params.username - Desired login username.
|
||||
* @param params.password - Account password.
|
||||
* @param params.displayName - Optional display name (defaults to username on the server).
|
||||
* @param params.serverId - Optional server ID to register against.
|
||||
* @returns Observable emitting the {@link LoginResponse} on success.
|
||||
*/
|
||||
register(params: {
|
||||
username: string;
|
||||
password: string;
|
||||
displayName?: string;
|
||||
serverId?: string;
|
||||
}): Observable<LoginResponse> {
|
||||
const url = `${this.endpointFor(params.serverId)}/users/register`;
|
||||
|
||||
return this.http.post<LoginResponse>(url, {
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
displayName: params.displayName
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in to an existing user account on the target server.
|
||||
*
|
||||
* @param params - Login parameters.
|
||||
* @param params.username - Login username.
|
||||
* @param params.password - Account password.
|
||||
* @param params.serverId - Optional server ID to authenticate against.
|
||||
* @returns Observable emitting the {@link LoginResponse} on success.
|
||||
*/
|
||||
login(params: {
|
||||
username: string;
|
||||
password: string;
|
||||
serverId?: string;
|
||||
}): Observable<LoginResponse> {
|
||||
const url = `${this.endpointFor(params.serverId)}/users/login`;
|
||||
|
||||
return this.http.post<LoginResponse>(url, {
|
||||
username: params.username,
|
||||
password: params.password
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
toju-app/src/app/domains/auth/feature/login/login.component.ts
Normal file
100
toju-app/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>
|
||||
@@ -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>
|
||||
@@ -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}`]);
|
||||
}
|
||||
}
|
||||
1
toju-app/src/app/domains/auth/index.ts
Normal file
1
toju-app/src/app/domains/auth/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './application/auth.service';
|
||||
143
toju-app/src/app/domains/chat/README.md
Normal file
143
toju-app/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" _blank
|
||||
click List "feature/chat-messages/components/message-list/" "Paginated message list" _blank
|
||||
click Composer "feature/chat-messages/components/message-composer/" "Markdown toolbar + send" _blank
|
||||
click Overlays "feature/chat-messages/components/message-overlays/" "Context menus, reaction picker" _blank
|
||||
click Item "feature/chat-messages/components/message-item/" "Single message bubble" _blank
|
||||
click GIF "feature/klipy-gif-picker/" "GIF search panel" _blank
|
||||
click Typing "feature/typing-indicator/" "Typing indicator" _blank
|
||||
click Users "feature/user-list/" "Online user sidebar" _blank
|
||||
```
|
||||
|
||||
## 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" _blank
|
||||
click Klipy "application/klipy.service.ts" "GIF search via KLIPY API" _blank
|
||||
click SD "../server-directory/application/server-directory.facade.ts" "Resolves API base URL" _blank
|
||||
```
|
||||
|
||||
## 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".
|
||||
200
toju-app/src/app/domains/chat/application/klipy.service.ts
Normal file
200
toju-app/src/app/domains/chat/application/klipy.service.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import {
|
||||
Observable,
|
||||
firstValueFrom,
|
||||
throwError
|
||||
} from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { ServerDirectoryFacade } from '../../server-directory';
|
||||
|
||||
export interface KlipyGif {
|
||||
id: string;
|
||||
slug: string;
|
||||
title?: string;
|
||||
url: string;
|
||||
previewUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface KlipyAvailabilityResponse {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface KlipyGifSearchResponse {
|
||||
enabled: boolean;
|
||||
results: KlipyGif[];
|
||||
hasNext: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 24;
|
||||
const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class KlipyService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
private readonly availabilityState = signal({
|
||||
enabled: false,
|
||||
loading: true
|
||||
});
|
||||
private lastAvailabilityKey = '';
|
||||
|
||||
readonly isEnabled = computed(() => this.availabilityState().enabled);
|
||||
readonly isLoading = computed(() => this.availabilityState().loading);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const activeServer = this.serverDirectory.activeServer();
|
||||
const apiBaseUrl = this.serverDirectory.getApiBaseUrl();
|
||||
const nextKey = `${activeServer?.id ?? 'default'}:${apiBaseUrl}`;
|
||||
|
||||
if (nextKey === this.lastAvailabilityKey)
|
||||
return;
|
||||
|
||||
this.lastAvailabilityKey = nextKey;
|
||||
void this.refreshAvailability();
|
||||
});
|
||||
}
|
||||
|
||||
async refreshAvailability(): Promise<void> {
|
||||
this.availabilityState.set({ enabled: false,
|
||||
loading: true });
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<KlipyAvailabilityResponse>(
|
||||
`${this.serverDirectory.getApiBaseUrl()}/klipy/config`
|
||||
)
|
||||
);
|
||||
|
||||
this.availabilityState.set({
|
||||
enabled: response.enabled === true,
|
||||
loading: false
|
||||
});
|
||||
} catch {
|
||||
this.availabilityState.set({ enabled: false,
|
||||
loading: false });
|
||||
}
|
||||
}
|
||||
|
||||
searchGifs(
|
||||
query: string,
|
||||
page = 1,
|
||||
perPage = DEFAULT_PAGE_SIZE
|
||||
): Observable<KlipyGifSearchResponse> {
|
||||
let params = new HttpParams()
|
||||
.set('page', String(Math.max(1, Math.floor(page))))
|
||||
.set('per_page', String(Math.max(1, Math.floor(perPage))))
|
||||
.set('customer_id', this.getOrCreateCustomerId());
|
||||
|
||||
const trimmedQuery = query.trim();
|
||||
|
||||
if (trimmedQuery) {
|
||||
params = params.set('q', trimmedQuery);
|
||||
}
|
||||
|
||||
const locale = this.getPreferredLocale();
|
||||
|
||||
if (locale) {
|
||||
params = params.set('locale', locale);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl()}/klipy/gifs`, { params })
|
||||
.pipe(
|
||||
map((response) => ({
|
||||
enabled: response.enabled !== false,
|
||||
results: Array.isArray(response.results) ? response.results : [],
|
||||
hasNext: response.hasNext === true
|
||||
})),
|
||||
catchError((error) =>
|
||||
throwError(() => new Error(this.extractErrorMessage(error)))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
normalizeMediaUrl(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
|
||||
if (!trimmed)
|
||||
return '';
|
||||
|
||||
if (trimmed.startsWith('//'))
|
||||
return `https:${trimmed}`;
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
buildRenderableImageUrl(url: string): string {
|
||||
const trimmed = this.normalizeMediaUrl(url);
|
||||
|
||||
if (!trimmed)
|
||||
return '';
|
||||
|
||||
if (!/^https?:\/\//i.test(trimmed))
|
||||
return trimmed;
|
||||
|
||||
return `${this.serverDirectory.getApiBaseUrl()}/image-proxy?url=${encodeURIComponent(trimmed)}`;
|
||||
}
|
||||
|
||||
private getPreferredLocale(): string | null {
|
||||
if (typeof navigator === 'undefined' || !navigator.language)
|
||||
return null;
|
||||
|
||||
const locale = navigator.language.trim();
|
||||
|
||||
return locale || null;
|
||||
}
|
||||
|
||||
private getOrCreateCustomerId(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'server';
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = window.localStorage.getItem(KLIPY_CUSTOMER_ID_STORAGE_KEY);
|
||||
|
||||
if (existing?.trim())
|
||||
return existing;
|
||||
|
||||
const created = window.crypto?.randomUUID?.()
|
||||
?? `klipy-${Date.now().toString(36)}-${Math.random().toString(36)
|
||||
.slice(2, 10)}`;
|
||||
|
||||
window.localStorage.setItem(KLIPY_CUSTOMER_ID_STORAGE_KEY, created);
|
||||
return created;
|
||||
} catch {
|
||||
return `klipy-${Date.now().toString(36)}`;
|
||||
}
|
||||
}
|
||||
|
||||
private extractErrorMessage(error: unknown): string {
|
||||
const httpError = error as {
|
||||
error?: {
|
||||
error?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
message?: unknown;
|
||||
};
|
||||
|
||||
if (typeof httpError?.error?.error === 'string')
|
||||
return httpError.error.error;
|
||||
|
||||
if (typeof httpError?.error?.message === 'string')
|
||||
return httpError.error.message;
|
||||
|
||||
if (typeof httpError?.message === 'string')
|
||||
return httpError.message;
|
||||
|
||||
return 'Failed to load GIFs from KLIPY.';
|
||||
}
|
||||
}
|
||||
59
toju-app/src/app/domains/chat/domain/message-sync.rules.ts
Normal file
59
toju-app/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
toju-app/src/app/domains/chat/domain/message.rules.ts
Normal file
31
toju-app/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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
7
toju-app/src/app/domains/chat/index.ts
Normal file
7
toju-app/src/app/domains/chat/index.ts
Normal file
@@ -0,0 +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
toju-app/src/app/domains/screen-share/README.md
Normal file
137
toju-app/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" _blank
|
||||
click Picker "application/screen-share-source-picker.service.ts" "Electron source picker" _blank
|
||||
click RSF "../../infrastructure/realtime/realtime-session.service.ts" "Low-level WebRTC composition root" _blank
|
||||
click Viewer "feature/screen-share-viewer/screen-share-viewer.component.ts" "Single-stream player" _blank
|
||||
click Workspace "feature/screen-share-workspace/screen-share-workspace.component.ts" "Multi-stream workspace" _blank
|
||||
click Tile "feature/screen-share-workspace/screen-share-stream-tile.component.ts" "Stream tile" _blank
|
||||
click Playback "feature/screen-share-workspace/screen-share-playback.service.ts" "Per-user volume state" _blank
|
||||
click Config "domain/screen-share.config.ts" "Quality presets" _blank
|
||||
```
|
||||
|
||||
## 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()
|
||||
```
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../voice-session';
|
||||
import { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from '../domain/screen-share.config';
|
||||
|
||||
export type ScreenShareSourceKind = 'screen' | 'window';
|
||||
|
||||
export interface ScreenShareSourceOption {
|
||||
id: string;
|
||||
kind: ScreenShareSourceKind;
|
||||
name: string;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
export interface ScreenShareSourceSelection {
|
||||
includeSystemAudio: boolean;
|
||||
source: ScreenShareSourceOption;
|
||||
}
|
||||
|
||||
interface ScreenShareSourcePickerRequest {
|
||||
includeSystemAudio: boolean;
|
||||
sources: readonly ScreenShareSourceOption[];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ScreenShareSourcePickerService {
|
||||
readonly request = computed(() => this._request());
|
||||
|
||||
private readonly _request = signal<ScreenShareSourcePickerRequest | null>(null);
|
||||
|
||||
private pendingResolve: ((selection: ScreenShareSourceSelection) => void) | null = null;
|
||||
private pendingReject: ((reason?: unknown) => void) | null = null;
|
||||
|
||||
open(
|
||||
sources: readonly Pick<ScreenShareSourceOption, 'id' | 'name' | 'thumbnail'>[],
|
||||
initialIncludeSystemAudio = loadVoiceSettingsFromStorage().includeSystemAudio
|
||||
): Promise<ScreenShareSourceSelection> {
|
||||
if (sources.length === 0) {
|
||||
throw new Error('No desktop capture sources were available.');
|
||||
}
|
||||
|
||||
this.cancelPendingRequest();
|
||||
|
||||
const normalizedSources = sources.map((source) => {
|
||||
const kind = this.getSourceKind(source);
|
||||
|
||||
return {
|
||||
...source,
|
||||
kind,
|
||||
name: this.getSourceDisplayName(source.name, kind)
|
||||
};
|
||||
});
|
||||
|
||||
this._request.set({
|
||||
includeSystemAudio: initialIncludeSystemAudio,
|
||||
sources: normalizedSources
|
||||
});
|
||||
|
||||
return new Promise<ScreenShareSourceSelection>((resolve, reject) => {
|
||||
this.pendingResolve = resolve;
|
||||
this.pendingReject = reject;
|
||||
});
|
||||
}
|
||||
|
||||
confirm(sourceId: string, includeSystemAudio: boolean): void {
|
||||
const activeRequest = this._request();
|
||||
const source = activeRequest?.sources.find((entry) => entry.id === sourceId);
|
||||
const resolve = this.pendingResolve;
|
||||
|
||||
if (!source || !resolve) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearPendingRequest();
|
||||
saveVoiceSettingsToStorage({ includeSystemAudio });
|
||||
resolve({
|
||||
includeSystemAudio,
|
||||
source
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.cancelPendingRequest();
|
||||
}
|
||||
|
||||
private cancelPendingRequest(): void {
|
||||
const reject = this.pendingReject;
|
||||
|
||||
this.clearPendingRequest();
|
||||
|
||||
if (reject) {
|
||||
reject(this.createAbortError());
|
||||
}
|
||||
}
|
||||
|
||||
private clearPendingRequest(): void {
|
||||
this._request.set(null);
|
||||
this.pendingResolve = null;
|
||||
this.pendingReject = null;
|
||||
}
|
||||
|
||||
private getSourceKind(
|
||||
source: Pick<ScreenShareSourceOption, 'id' | 'name'>
|
||||
): ScreenShareSourceKind {
|
||||
return source.id.startsWith('screen') || source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME
|
||||
? 'screen'
|
||||
: 'window';
|
||||
}
|
||||
|
||||
private getSourceDisplayName(name: string, kind: ScreenShareSourceKind): string {
|
||||
const trimmedName = name.trim();
|
||||
|
||||
if (trimmedName) {
|
||||
return trimmedName;
|
||||
}
|
||||
|
||||
return kind === 'screen' ? 'Entire screen' : 'Window';
|
||||
}
|
||||
|
||||
private createAbortError(): Error {
|
||||
if (typeof DOMException !== 'undefined') {
|
||||
return new DOMException('The user aborted a request.', 'AbortError');
|
||||
}
|
||||
|
||||
const error = new Error('The user aborted a request.');
|
||||
|
||||
error.name = 'AbortError';
|
||||
|
||||
return error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { ScreenShareStartOptions } from '../domain/screen-share.config';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ScreenShareFacade {
|
||||
readonly isScreenSharing = inject(RealtimeSessionFacade).isScreenSharing;
|
||||
readonly screenStream = inject(RealtimeSessionFacade).screenStream;
|
||||
readonly isScreenShareRemotePlaybackSuppressed = inject(RealtimeSessionFacade).isScreenShareRemotePlaybackSuppressed;
|
||||
readonly forceDefaultRemotePlaybackOutput = inject(RealtimeSessionFacade).forceDefaultRemotePlaybackOutput;
|
||||
readonly onRemoteStream = inject(RealtimeSessionFacade).onRemoteStream;
|
||||
readonly onPeerDisconnected = inject(RealtimeSessionFacade).onPeerDisconnected;
|
||||
|
||||
private readonly realtime = inject(RealtimeSessionFacade);
|
||||
|
||||
getRemoteScreenShareStream(peerId: string): MediaStream | null {
|
||||
return this.realtime.getRemoteScreenShareStream(peerId);
|
||||
}
|
||||
|
||||
async startScreenShare(options: ScreenShareStartOptions): Promise<MediaStream> {
|
||||
return await this.realtime.startScreenShare(options);
|
||||
}
|
||||
|
||||
stopScreenShare(): void {
|
||||
this.realtime.stopScreenShare();
|
||||
}
|
||||
|
||||
syncRemoteScreenShareRequests(peerIds: string[], enabled: boolean): void {
|
||||
this.realtime.syncRemoteScreenShareRequests(peerIds, enabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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 {
|
||||
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
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
8
toju-app/src/app/domains/screen-share/index.ts
Normal file
8
toju-app/src/app/domains/screen-share/index.ts
Normal file
@@ -0,0 +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
toju-app/src/app/domains/server-directory/README.md
Normal file
176
toju-app/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" _blank
|
||||
click State "application/server-endpoint-state.service.ts" "Signal-based endpoint state" _blank
|
||||
click API "infrastructure/server-directory-api.service.ts" "HTTP client for server API" _blank
|
||||
click Health "infrastructure/server-endpoint-health.service.ts" "Health probe" _blank
|
||||
click Compat "infrastructure/server-endpoint-compatibility.service.ts" "Version compatibility" _blank
|
||||
click Storage "infrastructure/server-endpoint-storage.service.ts" "localStorage persistence" _blank
|
||||
click Defaults "domain/server-endpoint-defaults.ts" "Default endpoint templates" _blank
|
||||
click Models "domain/server-directory.models.ts" "Domain types" _blank
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,260 @@
|
||||
import {
|
||||
Injectable,
|
||||
inject,
|
||||
type Signal
|
||||
} from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../core/constants';
|
||||
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 {
|
||||
BanServerMemberRequest,
|
||||
CreateServerInviteRequest,
|
||||
KickServerMemberRequest,
|
||||
ServerEndpoint,
|
||||
ServerEndpointVersions,
|
||||
ServerInfo,
|
||||
ServerInviteInfo,
|
||||
ServerJoinAccessRequest,
|
||||
ServerJoinAccessResponse,
|
||||
ServerSourceSelector,
|
||||
UnbanServerMemberRequest
|
||||
} from '../domain/server-directory.models';
|
||||
import { ServerEndpointCompatibilityService } from '../infrastructure/server-endpoint-compatibility.service';
|
||||
import { ServerEndpointHealthService } from '../infrastructure/server-endpoint-health.service';
|
||||
import { ServerEndpointStateService } from './server-endpoint-state.service';
|
||||
|
||||
export { CLIENT_UPDATE_REQUIRED_MESSAGE };
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ServerDirectoryFacade {
|
||||
readonly servers: Signal<ServerEndpoint[]>;
|
||||
readonly activeServers: Signal<ServerEndpoint[]>;
|
||||
readonly hasMissingDefaultServers: Signal<boolean>;
|
||||
readonly activeServer: Signal<ServerEndpoint | null>;
|
||||
|
||||
private readonly endpointState = inject(ServerEndpointStateService);
|
||||
private readonly endpointCompatibility = inject(ServerEndpointCompatibilityService);
|
||||
private readonly endpointHealth = inject(ServerEndpointHealthService);
|
||||
private readonly api = inject(ServerDirectoryApiService);
|
||||
private shouldSearchAllServers = true;
|
||||
|
||||
constructor() {
|
||||
this.servers = this.endpointState.servers;
|
||||
this.activeServers = this.endpointState.activeServers;
|
||||
this.hasMissingDefaultServers = this.endpointState.hasMissingDefaultServers;
|
||||
this.activeServer = this.endpointState.activeServer;
|
||||
|
||||
this.loadConnectionSettings();
|
||||
void this.testAllServers();
|
||||
}
|
||||
|
||||
addServer(server: { name: string; url: string }): ServerEndpoint {
|
||||
return this.endpointState.addServer(server);
|
||||
}
|
||||
|
||||
ensureServerEndpoint(
|
||||
server: { name: string; url: string },
|
||||
options?: { setActive?: boolean }
|
||||
): ServerEndpoint {
|
||||
return this.endpointState.ensureServerEndpoint(server, options);
|
||||
}
|
||||
|
||||
findServerByUrl(url: string): ServerEndpoint | undefined {
|
||||
return this.endpointState.findServerByUrl(url);
|
||||
}
|
||||
|
||||
removeServer(endpointId: string): void {
|
||||
this.endpointState.removeServer(endpointId);
|
||||
}
|
||||
|
||||
restoreDefaultServers(): ServerEndpoint[] {
|
||||
return this.endpointState.restoreDefaultServers();
|
||||
}
|
||||
|
||||
setActiveServer(endpointId: string): void {
|
||||
this.endpointState.setActiveServer(endpointId);
|
||||
}
|
||||
|
||||
deactivateServer(endpointId: string): void {
|
||||
this.endpointState.deactivateServer(endpointId);
|
||||
}
|
||||
|
||||
updateServerStatus(
|
||||
endpointId: string,
|
||||
status: ServerEndpoint['status'],
|
||||
latency?: number,
|
||||
versions?: ServerEndpointVersions
|
||||
): void {
|
||||
this.endpointState.updateServerStatus(endpointId, status, latency, versions);
|
||||
}
|
||||
|
||||
async ensureEndpointVersionCompatibility(selector?: ServerSourceSelector): Promise<boolean> {
|
||||
const endpoint = this.api.resolveEndpoint(selector);
|
||||
|
||||
if (!endpoint || endpoint.status === 'incompatible') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const clientVersion = await this.endpointCompatibility.getClientVersion();
|
||||
|
||||
if (!clientVersion) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await this.testServer(endpoint.id);
|
||||
|
||||
const refreshedEndpoint = this.servers().find((candidate) => candidate.id === endpoint.id);
|
||||
|
||||
return !!refreshedEndpoint && refreshedEndpoint.status !== 'incompatible';
|
||||
}
|
||||
|
||||
setSearchAllServers(enabled: boolean): void {
|
||||
this.shouldSearchAllServers = enabled;
|
||||
}
|
||||
|
||||
async testServer(endpointId: string): Promise<boolean> {
|
||||
const endpoint = this.servers().find((entry) => entry.id === endpointId);
|
||||
|
||||
if (!endpoint) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.updateServerStatus(endpointId, 'checking');
|
||||
const clientVersion = await this.endpointCompatibility.getClientVersion();
|
||||
const healthResult = await this.endpointHealth.probeEndpoint(endpoint, clientVersion);
|
||||
|
||||
this.updateServerStatus(
|
||||
endpointId,
|
||||
healthResult.status,
|
||||
healthResult.latency,
|
||||
healthResult.versions
|
||||
);
|
||||
|
||||
return healthResult.status === 'online';
|
||||
}
|
||||
|
||||
async testAllServers(): Promise<void> {
|
||||
await Promise.all(this.servers().map((endpoint) => this.testServer(endpoint.id)));
|
||||
}
|
||||
|
||||
getApiBaseUrl(selector?: ServerSourceSelector): string {
|
||||
return this.api.getApiBaseUrl(selector);
|
||||
}
|
||||
|
||||
getWebSocketUrl(selector?: ServerSourceSelector): string {
|
||||
return this.api.getWebSocketUrl(selector);
|
||||
}
|
||||
|
||||
searchServers(query: string): Observable<ServerInfo[]> {
|
||||
return this.api.searchServers(query, this.shouldSearchAllServers);
|
||||
}
|
||||
|
||||
getServers(): Observable<ServerInfo[]> {
|
||||
return this.api.getServers(this.shouldSearchAllServers);
|
||||
}
|
||||
|
||||
getServer(serverId: string, selector?: ServerSourceSelector): Observable<ServerInfo | null> {
|
||||
return this.api.getServer(serverId, selector);
|
||||
}
|
||||
|
||||
registerServer(
|
||||
server: Omit<ServerInfo, 'createdAt'> & { id?: string; password?: string | null },
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerInfo> {
|
||||
return this.api.registerServer(server, selector);
|
||||
}
|
||||
|
||||
updateServer(
|
||||
serverId: string,
|
||||
updates: Partial<ServerInfo> & {
|
||||
currentOwnerId: string;
|
||||
actingRole?: string;
|
||||
password?: string | null;
|
||||
},
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerInfo> {
|
||||
return this.api.updateServer(serverId, updates, selector);
|
||||
}
|
||||
|
||||
unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable<void> {
|
||||
return this.api.unregisterServer(serverId, selector);
|
||||
}
|
||||
|
||||
getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable<User[]> {
|
||||
return this.api.getServerUsers(serverId, selector);
|
||||
}
|
||||
|
||||
requestJoin(
|
||||
request: ServerJoinAccessRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerJoinAccessResponse> {
|
||||
return this.api.requestJoin(request, selector);
|
||||
}
|
||||
|
||||
createInvite(
|
||||
serverId: string,
|
||||
request: CreateServerInviteRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerInviteInfo> {
|
||||
return this.api.createInvite(serverId, request, selector);
|
||||
}
|
||||
|
||||
getInvite(inviteId: string, selector?: ServerSourceSelector): Observable<ServerInviteInfo> {
|
||||
return this.api.getInvite(inviteId, selector);
|
||||
}
|
||||
|
||||
kickServerMember(
|
||||
serverId: string,
|
||||
request: KickServerMemberRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<void> {
|
||||
return this.api.kickServerMember(serverId, request, selector);
|
||||
}
|
||||
|
||||
banServerMember(
|
||||
serverId: string,
|
||||
request: BanServerMemberRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<void> {
|
||||
return this.api.banServerMember(serverId, request, selector);
|
||||
}
|
||||
|
||||
unbanServerMember(
|
||||
serverId: string,
|
||||
request: UnbanServerMemberRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<void> {
|
||||
return this.api.unbanServerMember(serverId, request, selector);
|
||||
}
|
||||
|
||||
notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable<void> {
|
||||
return this.api.notifyLeave(serverId, userId, selector);
|
||||
}
|
||||
|
||||
updateUserCount(serverId: string, count: number): Observable<void> {
|
||||
return this.api.updateUserCount(serverId, count);
|
||||
}
|
||||
|
||||
sendHeartbeat(serverId: string): Observable<void> {
|
||||
return this.api.sendHeartbeat(serverId);
|
||||
}
|
||||
|
||||
private loadConnectionSettings(): void {
|
||||
const stored = localStorage.getItem(STORAGE_KEY_CONNECTION_SETTINGS);
|
||||
|
||||
if (!stored) {
|
||||
this.shouldSearchAllServers = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as { searchAllServers?: boolean };
|
||||
|
||||
this.shouldSearchAllServers = parsed.searchAllServers ?? true;
|
||||
} catch {
|
||||
this.shouldSearchAllServers = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
type Signal
|
||||
} from '@angular/core';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import {
|
||||
buildDefaultEndpointTemplates,
|
||||
buildDefaultServerDefinitions,
|
||||
ensureAnyActiveEndpoint,
|
||||
ensureCompatibleActiveEndpoint,
|
||||
findDefaultEndpointKeyByUrl,
|
||||
hasEndpointForDefault,
|
||||
matchDefaultEndpointTemplate,
|
||||
sanitiseServerBaseUrl
|
||||
} from '../domain/server-endpoint-defaults';
|
||||
import { ServerEndpointStorageService } from '../infrastructure/server-endpoint-storage.service';
|
||||
import type {
|
||||
ConfiguredDefaultServerDefinition,
|
||||
DefaultEndpointTemplate,
|
||||
ServerEndpoint,
|
||||
ServerEndpointVersions
|
||||
} from '../domain/server-directory.models';
|
||||
|
||||
function resolveDefaultHttpProtocol(): 'http' | 'https' {
|
||||
return typeof window !== 'undefined' && window.location?.protocol === 'https:'
|
||||
? 'https'
|
||||
: 'http';
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ServerEndpointStateService {
|
||||
readonly servers: Signal<ServerEndpoint[]>;
|
||||
readonly activeServers: Signal<ServerEndpoint[]>;
|
||||
readonly hasMissingDefaultServers: Signal<boolean>;
|
||||
readonly activeServer: Signal<ServerEndpoint | null>;
|
||||
|
||||
private readonly storage = inject(ServerEndpointStorageService);
|
||||
private readonly _servers = signal<ServerEndpoint[]>([]);
|
||||
private readonly defaultEndpoints: DefaultEndpointTemplate[];
|
||||
private readonly primaryDefaultServerUrl: string;
|
||||
|
||||
constructor() {
|
||||
const defaultServerDefinitions = buildDefaultServerDefinitions(
|
||||
Array.isArray(environment.defaultServers)
|
||||
? environment.defaultServers as ConfiguredDefaultServerDefinition[]
|
||||
: [],
|
||||
environment.defaultServerUrl,
|
||||
resolveDefaultHttpProtocol()
|
||||
);
|
||||
|
||||
this.defaultEndpoints = buildDefaultEndpointTemplates(defaultServerDefinitions);
|
||||
this.primaryDefaultServerUrl = this.defaultEndpoints[0]?.url ?? 'http://localhost:3001';
|
||||
|
||||
this.servers = computed(() => this._servers());
|
||||
this.activeServers = computed(() =>
|
||||
this._servers().filter((endpoint) => endpoint.isActive && endpoint.status !== 'incompatible')
|
||||
);
|
||||
|
||||
this.hasMissingDefaultServers = computed(() =>
|
||||
this.defaultEndpoints.some((endpoint) => !hasEndpointForDefault(this._servers(), endpoint))
|
||||
);
|
||||
|
||||
this.activeServer = computed(() => this.activeServers()[0] ?? null);
|
||||
|
||||
this.loadEndpoints();
|
||||
}
|
||||
|
||||
getPrimaryDefaultServerUrl(): string {
|
||||
return this.primaryDefaultServerUrl;
|
||||
}
|
||||
|
||||
sanitiseUrl(rawUrl: string): string {
|
||||
return sanitiseServerBaseUrl(rawUrl);
|
||||
}
|
||||
|
||||
addServer(server: { name: string; url: string }): ServerEndpoint {
|
||||
const newEndpoint: ServerEndpoint = {
|
||||
id: uuidv4(),
|
||||
name: server.name,
|
||||
url: this.sanitiseUrl(server.url),
|
||||
isActive: true,
|
||||
isDefault: false,
|
||||
status: 'unknown'
|
||||
};
|
||||
|
||||
this._servers.update((endpoints) => [...endpoints, newEndpoint]);
|
||||
this.saveEndpoints();
|
||||
return newEndpoint;
|
||||
}
|
||||
|
||||
ensureServerEndpoint(
|
||||
server: { name: string; url: string },
|
||||
options?: { setActive?: boolean }
|
||||
): ServerEndpoint {
|
||||
const existing = this.findServerByUrl(server.url);
|
||||
|
||||
if (existing) {
|
||||
if (options?.setActive) {
|
||||
this.setActiveServer(existing.id);
|
||||
}
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
const created = this.addServer(server);
|
||||
|
||||
if (options?.setActive) {
|
||||
this.setActiveServer(created.id);
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
findServerByUrl(url: string): ServerEndpoint | undefined {
|
||||
const sanitisedUrl = this.sanitiseUrl(url);
|
||||
|
||||
return this._servers().find((endpoint) => this.sanitiseUrl(endpoint.url) === sanitisedUrl);
|
||||
}
|
||||
|
||||
removeServer(endpointId: string): void {
|
||||
const endpoints = this._servers();
|
||||
const target = endpoints.find((endpoint) => endpoint.id === endpointId);
|
||||
|
||||
if (!target || endpoints.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.isDefault) {
|
||||
this.markDefaultEndpointRemoved(target);
|
||||
}
|
||||
|
||||
const updatedEndpoints = ensureAnyActiveEndpoint(
|
||||
endpoints.filter((endpoint) => endpoint.id !== endpointId)
|
||||
);
|
||||
|
||||
this._servers.set(updatedEndpoints);
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
restoreDefaultServers(): ServerEndpoint[] {
|
||||
const restoredEndpoints = this.defaultEndpoints
|
||||
.filter((defaultEndpoint) => !hasEndpointForDefault(this._servers(), defaultEndpoint))
|
||||
.map((defaultEndpoint) => ({
|
||||
...defaultEndpoint,
|
||||
id: uuidv4(),
|
||||
isActive: true
|
||||
}));
|
||||
|
||||
if (restoredEndpoints.length === 0) {
|
||||
this.storage.clearRemovedDefaultEndpointKeys();
|
||||
return [];
|
||||
}
|
||||
|
||||
this._servers.update((endpoints) => ensureAnyActiveEndpoint([...endpoints, ...restoredEndpoints]));
|
||||
this.storage.clearRemovedDefaultEndpointKeys();
|
||||
this.saveEndpoints();
|
||||
return restoredEndpoints;
|
||||
}
|
||||
|
||||
setActiveServer(endpointId: string): void {
|
||||
this._servers.update((endpoints) => {
|
||||
const target = endpoints.find((endpoint) => endpoint.id === endpointId);
|
||||
|
||||
if (!target || target.status === 'incompatible') {
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
return endpoints.map((endpoint) =>
|
||||
endpoint.id === endpointId
|
||||
? { ...endpoint, isActive: true }
|
||||
: endpoint
|
||||
);
|
||||
});
|
||||
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
deactivateServer(endpointId: string): void {
|
||||
if (this.activeServers().length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._servers.update((endpoints) =>
|
||||
endpoints.map((endpoint) =>
|
||||
endpoint.id === endpointId
|
||||
? { ...endpoint, isActive: false }
|
||||
: endpoint
|
||||
)
|
||||
);
|
||||
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
updateServerStatus(
|
||||
endpointId: string,
|
||||
status: ServerEndpoint['status'],
|
||||
latency?: number,
|
||||
versions?: ServerEndpointVersions
|
||||
): void {
|
||||
this._servers.update((endpoints) => ensureCompatibleActiveEndpoint(endpoints.map((endpoint) => {
|
||||
if (endpoint.id !== endpointId) {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
return {
|
||||
...endpoint,
|
||||
status,
|
||||
latency,
|
||||
isActive: status === 'incompatible' ? false : endpoint.isActive,
|
||||
serverVersion: versions?.serverVersion ?? endpoint.serverVersion,
|
||||
clientVersion: versions?.clientVersion ?? endpoint.clientVersion
|
||||
};
|
||||
})));
|
||||
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
private loadEndpoints(): void {
|
||||
const storedEndpoints = this.storage.loadEndpoints();
|
||||
|
||||
if (!storedEndpoints) {
|
||||
this.initialiseDefaultEndpoints();
|
||||
return;
|
||||
}
|
||||
|
||||
this._servers.set(this.reconcileStoredEndpoints(storedEndpoints));
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
private initialiseDefaultEndpoints(): void {
|
||||
this._servers.set(this.defaultEndpoints.map((endpoint) => ({
|
||||
...endpoint,
|
||||
id: uuidv4()
|
||||
})));
|
||||
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
private reconcileStoredEndpoints(storedEndpoints: ServerEndpoint[]): ServerEndpoint[] {
|
||||
const reconciled: ServerEndpoint[] = [];
|
||||
const claimedDefaultKeys = new Set<string>();
|
||||
const removedDefaultKeys = this.storage.loadRemovedDefaultEndpointKeys();
|
||||
|
||||
for (const endpoint of storedEndpoints) {
|
||||
if (!endpoint || typeof endpoint.id !== 'string' || typeof endpoint.url !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sanitisedUrl = this.sanitiseUrl(endpoint.url);
|
||||
const matchedDefault = matchDefaultEndpointTemplate(
|
||||
this.defaultEndpoints,
|
||||
endpoint,
|
||||
sanitisedUrl,
|
||||
claimedDefaultKeys
|
||||
);
|
||||
|
||||
if (matchedDefault) {
|
||||
claimedDefaultKeys.add(matchedDefault.defaultKey);
|
||||
reconciled.push({
|
||||
...endpoint,
|
||||
name: matchedDefault.name,
|
||||
url: matchedDefault.url,
|
||||
isDefault: true,
|
||||
defaultKey: matchedDefault.defaultKey,
|
||||
status: endpoint.status ?? 'unknown'
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
reconciled.push({
|
||||
...endpoint,
|
||||
url: sanitisedUrl,
|
||||
status: endpoint.status ?? 'unknown'
|
||||
});
|
||||
}
|
||||
|
||||
for (const defaultEndpoint of this.defaultEndpoints) {
|
||||
if (
|
||||
!claimedDefaultKeys.has(defaultEndpoint.defaultKey)
|
||||
&& !removedDefaultKeys.has(defaultEndpoint.defaultKey)
|
||||
&& !hasEndpointForDefault(reconciled, defaultEndpoint)
|
||||
) {
|
||||
reconciled.push({
|
||||
...defaultEndpoint,
|
||||
id: uuidv4(),
|
||||
isActive: defaultEndpoint.isActive
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ensureAnyActiveEndpoint(reconciled);
|
||||
}
|
||||
|
||||
private markDefaultEndpointRemoved(endpoint: ServerEndpoint): void {
|
||||
const defaultKey = endpoint.defaultKey ?? findDefaultEndpointKeyByUrl(this.defaultEndpoints, endpoint.url);
|
||||
|
||||
if (!defaultKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const removedDefaultKeys = this.storage.loadRemovedDefaultEndpointKeys();
|
||||
|
||||
removedDefaultKeys.add(defaultKey);
|
||||
this.storage.saveRemovedDefaultEndpointKeys(removedDefaultKeys);
|
||||
}
|
||||
|
||||
private saveEndpoints(): void {
|
||||
this.storage.saveEndpoints(this._servers());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const CLIENT_UPDATE_REQUIRED_MESSAGE = 'Update the client in order to connect to other users';
|
||||
@@ -0,0 +1,133 @@
|
||||
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;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface DefaultServerDefinition {
|
||||
key: string;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ServerEndpointVersions {
|
||||
serverVersion?: string | null;
|
||||
clientVersion?: string | null;
|
||||
}
|
||||
|
||||
export interface ServerEndpoint {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
defaultKey?: string;
|
||||
status: ServerEndpointStatus;
|
||||
latency?: number;
|
||||
serverVersion?: string;
|
||||
clientVersion?: string;
|
||||
}
|
||||
|
||||
export type DefaultEndpointTemplate = Omit<ServerEndpoint, 'id' | 'defaultKey'> & {
|
||||
defaultKey: string;
|
||||
};
|
||||
|
||||
export interface ServerSourceSelector {
|
||||
sourceId?: string;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
export interface ServerJoinAccessRequest {
|
||||
roomId: string;
|
||||
userId: string;
|
||||
userPublicKey: string;
|
||||
displayName: string;
|
||||
password?: string;
|
||||
inviteId?: string;
|
||||
}
|
||||
|
||||
export interface ServerJoinAccessResponse {
|
||||
success: boolean;
|
||||
signalingUrl: string;
|
||||
joinedBefore: boolean;
|
||||
via: 'membership' | 'password' | 'invite' | 'public';
|
||||
server: ServerInfo;
|
||||
}
|
||||
|
||||
export interface CreateServerInviteRequest {
|
||||
requesterUserId: string;
|
||||
requesterDisplayName?: string;
|
||||
requesterRole?: string;
|
||||
}
|
||||
|
||||
export interface ServerInviteInfo {
|
||||
id: string;
|
||||
serverId: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
inviteUrl: string;
|
||||
browserUrl: string;
|
||||
appUrl: string;
|
||||
sourceUrl: string;
|
||||
createdBy?: string;
|
||||
createdByDisplayName?: string;
|
||||
isExpired: boolean;
|
||||
server: ServerInfo;
|
||||
}
|
||||
|
||||
export interface KickServerMemberRequest {
|
||||
actorUserId: string;
|
||||
actorRole?: string;
|
||||
targetUserId: string;
|
||||
}
|
||||
|
||||
export interface BanServerMemberRequest extends KickServerMemberRequest {
|
||||
banId?: string;
|
||||
displayName?: string;
|
||||
reason?: string;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
export interface UnbanServerMemberRequest {
|
||||
actorUserId: string;
|
||||
actorRole?: string;
|
||||
banId?: string;
|
||||
targetUserId?: string;
|
||||
}
|
||||
|
||||
export interface ServerVersionCompatibilityResult {
|
||||
isCompatible: boolean;
|
||||
serverVersion: string | null;
|
||||
}
|
||||
|
||||
export interface ServerHealthCheckPayload {
|
||||
serverVersion?: unknown;
|
||||
}
|
||||
|
||||
export interface ServerEndpointHealthResult {
|
||||
status: ServerEndpointStatus;
|
||||
latency?: number;
|
||||
versions?: ServerEndpointVersions;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type {
|
||||
ConfiguredDefaultServerDefinition,
|
||||
DefaultEndpointTemplate,
|
||||
DefaultServerDefinition,
|
||||
ServerEndpoint
|
||||
} from './server-directory.models';
|
||||
|
||||
export function sanitiseServerBaseUrl(rawUrl: string): string {
|
||||
let cleaned = rawUrl.trim().replace(/\/+$/, '');
|
||||
|
||||
if (cleaned.toLowerCase().endsWith('/api')) {
|
||||
cleaned = cleaned.slice(0, -4);
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export function normaliseConfiguredServerUrl(
|
||||
rawUrl: string,
|
||||
defaultProtocol: 'http' | 'https'
|
||||
): string {
|
||||
let cleaned = rawUrl.trim();
|
||||
|
||||
if (!cleaned) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (cleaned.toLowerCase().startsWith('ws://')) {
|
||||
cleaned = `http://${cleaned.slice(5)}`;
|
||||
} else if (cleaned.toLowerCase().startsWith('wss://')) {
|
||||
cleaned = `https://${cleaned.slice(6)}`;
|
||||
} else if (cleaned.startsWith('//')) {
|
||||
cleaned = `${defaultProtocol}:${cleaned}`;
|
||||
} else if (!/^[a-z][a-z\d+.-]*:\/\//i.test(cleaned)) {
|
||||
cleaned = `${defaultProtocol}://${cleaned}`;
|
||||
}
|
||||
|
||||
return sanitiseServerBaseUrl(cleaned);
|
||||
}
|
||||
|
||||
export function buildFallbackDefaultServerUrl(
|
||||
configuredUrl: string | undefined,
|
||||
defaultProtocol: 'http' | 'https'
|
||||
): string {
|
||||
if (configuredUrl?.trim()) {
|
||||
return normaliseConfiguredServerUrl(configuredUrl, defaultProtocol);
|
||||
}
|
||||
|
||||
return `${defaultProtocol}://localhost:3001`;
|
||||
}
|
||||
|
||||
export function buildDefaultServerDefinitions(
|
||||
configuredDefaults: ConfiguredDefaultServerDefinition[] | undefined,
|
||||
configuredUrl: string | undefined,
|
||||
defaultProtocol: 'http' | 'https'
|
||||
): DefaultServerDefinition[] {
|
||||
const seenKeys = new Set<string>();
|
||||
const seenUrls = new Set<string>();
|
||||
const definitions = (configuredDefaults ?? [])
|
||||
.map((server, index) => {
|
||||
const key = server.key?.trim() || `default-${index + 1}`;
|
||||
const url = normaliseConfiguredServerUrl(server.url ?? '', defaultProtocol);
|
||||
|
||||
if (!key || !url || seenKeys.has(key) || seenUrls.has(url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
seenKeys.add(key);
|
||||
seenUrls.add(url);
|
||||
|
||||
return {
|
||||
key,
|
||||
name: server.name?.trim() || (index === 0 ? 'Default Server' : `Default Server ${index + 1}`),
|
||||
url
|
||||
} satisfies DefaultServerDefinition;
|
||||
})
|
||||
.filter((definition): definition is DefaultServerDefinition => definition !== null);
|
||||
|
||||
if (definitions.length > 0) {
|
||||
return definitions;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'default',
|
||||
name: 'Default Server',
|
||||
url: buildFallbackDefaultServerUrl(configuredUrl, defaultProtocol)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export function buildDefaultEndpointTemplates(
|
||||
definitions: DefaultServerDefinition[]
|
||||
): DefaultEndpointTemplate[] {
|
||||
return definitions.map((definition) => ({
|
||||
name: definition.name,
|
||||
url: definition.url,
|
||||
isActive: true,
|
||||
isDefault: true,
|
||||
defaultKey: definition.key,
|
||||
status: 'unknown'
|
||||
}));
|
||||
}
|
||||
|
||||
export function hasEndpointForDefault(
|
||||
endpoints: ServerEndpoint[],
|
||||
defaultEndpoint: DefaultEndpointTemplate
|
||||
): boolean {
|
||||
return endpoints.some((endpoint) =>
|
||||
endpoint.defaultKey === defaultEndpoint.defaultKey
|
||||
|| sanitiseServerBaseUrl(endpoint.url) === defaultEndpoint.url
|
||||
);
|
||||
}
|
||||
|
||||
export function matchDefaultEndpointTemplate(
|
||||
defaultEndpoints: DefaultEndpointTemplate[],
|
||||
endpoint: ServerEndpoint,
|
||||
sanitisedUrl: string,
|
||||
claimedDefaultKeys: Set<string>
|
||||
): DefaultEndpointTemplate | null {
|
||||
if (endpoint.defaultKey) {
|
||||
return defaultEndpoints.find(
|
||||
(candidate) => candidate.defaultKey === endpoint.defaultKey && !claimedDefaultKeys.has(candidate.defaultKey)
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
if (!endpoint.isDefault) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matchingCurrentDefault = defaultEndpoints.find(
|
||||
(candidate) => candidate.url === sanitisedUrl && !claimedDefaultKeys.has(candidate.defaultKey)
|
||||
);
|
||||
|
||||
if (matchingCurrentDefault) {
|
||||
return matchingCurrentDefault;
|
||||
}
|
||||
|
||||
return defaultEndpoints.find(
|
||||
(candidate) => !claimedDefaultKeys.has(candidate.defaultKey)
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
export function findDefaultEndpointKeyByUrl(
|
||||
defaultEndpoints: DefaultEndpointTemplate[],
|
||||
url: string
|
||||
): string | null {
|
||||
const sanitisedUrl = sanitiseServerBaseUrl(url);
|
||||
|
||||
return defaultEndpoints.find((endpoint) => endpoint.url === sanitisedUrl)?.defaultKey ?? null;
|
||||
}
|
||||
|
||||
export function ensureAnyActiveEndpoint(endpoints: ServerEndpoint[]): ServerEndpoint[] {
|
||||
if (endpoints.length === 0 || endpoints.some((endpoint) => endpoint.isActive)) {
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
const nextEndpoints = [...endpoints];
|
||||
|
||||
nextEndpoints[0] = {
|
||||
...nextEndpoints[0],
|
||||
isActive: true
|
||||
};
|
||||
|
||||
return nextEndpoints;
|
||||
}
|
||||
|
||||
export function ensureCompatibleActiveEndpoint(endpoints: ServerEndpoint[]): ServerEndpoint[] {
|
||||
if (endpoints.length === 0 || endpoints.some((endpoint) => endpoint.isActive)) {
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
const fallbackIndex = endpoints.findIndex((endpoint) => endpoint.status !== 'incompatible');
|
||||
|
||||
if (fallbackIndex < 0) {
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
const nextEndpoints = [...endpoints];
|
||||
|
||||
nextEndpoints[fallbackIndex] = {
|
||||
...nextEndpoints[fallbackIndex],
|
||||
isActive: true
|
||||
};
|
||||
|
||||
return nextEndpoints;
|
||||
}
|
||||
@@ -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,352 @@
|
||||
/* 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 ?? '';
|
||||
}
|
||||
}
|
||||
3
toju-app/src/app/domains/server-directory/index.ts
Normal file
3
toju-app/src/app/domains/server-directory/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './application/server-directory.facade';
|
||||
export * from './domain/server-directory.constants';
|
||||
export * from './domain/server-directory.models';
|
||||
@@ -0,0 +1,405 @@
|
||||
/* eslint-disable @typescript-eslint/no-invalid-void-type */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import {
|
||||
Observable,
|
||||
forkJoin,
|
||||
of,
|
||||
throwError
|
||||
} from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { User } from '../../../shared-kernel';
|
||||
import { ServerEndpointStateService } from '../application/server-endpoint-state.service';
|
||||
import type {
|
||||
BanServerMemberRequest,
|
||||
CreateServerInviteRequest,
|
||||
KickServerMemberRequest,
|
||||
ServerEndpoint,
|
||||
ServerInfo,
|
||||
ServerInviteInfo,
|
||||
ServerJoinAccessRequest,
|
||||
ServerJoinAccessResponse,
|
||||
ServerSourceSelector,
|
||||
UnbanServerMemberRequest
|
||||
} from '../domain/server-directory.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ServerDirectoryApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly endpointState = inject(ServerEndpointStateService);
|
||||
|
||||
getApiBaseUrl(selector?: ServerSourceSelector): string {
|
||||
return `${this.resolveBaseServerUrl(selector)}/api`;
|
||||
}
|
||||
|
||||
getWebSocketUrl(selector?: ServerSourceSelector): string {
|
||||
return this.resolveBaseServerUrl(selector).replace(/^http/, 'ws');
|
||||
}
|
||||
|
||||
resolveEndpoint(selector?: ServerSourceSelector): ServerEndpoint | null {
|
||||
if (selector?.sourceId) {
|
||||
return this.endpointState.servers().find((endpoint) => endpoint.id === selector.sourceId) ?? null;
|
||||
}
|
||||
|
||||
if (selector?.sourceUrl) {
|
||||
return this.endpointState.findServerByUrl(selector.sourceUrl) ?? null;
|
||||
}
|
||||
|
||||
return this.endpointState.activeServer()
|
||||
?? this.endpointState.servers().find((endpoint) => endpoint.status !== 'incompatible')
|
||||
?? this.endpointState.servers()[0]
|
||||
?? null;
|
||||
}
|
||||
|
||||
searchServers(query: string, shouldSearchAllServers: boolean): Observable<ServerInfo[]> {
|
||||
if (shouldSearchAllServers) {
|
||||
return this.searchAllEndpoints(query);
|
||||
}
|
||||
|
||||
return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer());
|
||||
}
|
||||
|
||||
getServers(shouldSearchAllServers: boolean): Observable<ServerInfo[]> {
|
||||
if (shouldSearchAllServers) {
|
||||
return this.getAllServersFromAllEndpoints();
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`)
|
||||
.pipe(
|
||||
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
|
||||
catchError((error) => {
|
||||
console.error('Failed to get servers:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getServer(serverId: string, selector?: ServerSourceSelector): Observable<ServerInfo | null> {
|
||||
return this.http
|
||||
.get<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`)
|
||||
.pipe(
|
||||
map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))),
|
||||
catchError((error) => {
|
||||
console.error('Failed to get server:', error);
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
registerServer(
|
||||
server: Omit<ServerInfo, 'createdAt'> & { id?: string; password?: string | null },
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerInfo> {
|
||||
return this.http
|
||||
.post<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers`, server)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to register server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
updateServer(
|
||||
serverId: string,
|
||||
updates: Partial<ServerInfo> & {
|
||||
currentOwnerId: string;
|
||||
actingRole?: string;
|
||||
password?: string | null;
|
||||
},
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerInfo> {
|
||||
return this.http
|
||||
.put<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`, updates)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to update server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable<void> {
|
||||
return this.http
|
||||
.delete<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to unregister server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable<User[]> {
|
||||
return this.http
|
||||
.get<User[]>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/users`)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to get server users:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
requestJoin(
|
||||
request: ServerJoinAccessRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerJoinAccessResponse> {
|
||||
return this.http
|
||||
.post<ServerJoinAccessResponse>(
|
||||
`${this.getApiBaseUrl(selector)}/servers/${request.roomId}/join`,
|
||||
request
|
||||
)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to send join request:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
createInvite(
|
||||
serverId: string,
|
||||
request: CreateServerInviteRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerInviteInfo> {
|
||||
return this.http
|
||||
.post<ServerInviteInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/invites`, request)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to create invite:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getInvite(inviteId: string, selector?: ServerSourceSelector): Observable<ServerInviteInfo> {
|
||||
return this.http
|
||||
.get<ServerInviteInfo>(`${this.getApiBaseUrl(selector)}/invites/${inviteId}`)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to get invite:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
kickServerMember(
|
||||
serverId: string,
|
||||
request: KickServerMemberRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/kick`, request)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to kick server member:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
banServerMember(
|
||||
serverId: string,
|
||||
request: BanServerMemberRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/ban`, request)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to ban server member:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
unbanServerMember(
|
||||
serverId: string,
|
||||
request: UnbanServerMemberRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/unban`, request)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to unban server member:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/leave`, { userId })
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to notify leave:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
updateUserCount(serverId: string, count: number): Observable<void> {
|
||||
return this.http
|
||||
.patch<void>(`${this.getApiBaseUrl()}/servers/${serverId}/user-count`, { count })
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to update user count:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
sendHeartbeat(serverId: string): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.getApiBaseUrl()}/servers/${serverId}/heartbeat`, {})
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to send heartbeat:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private resolveBaseServerUrl(selector?: ServerSourceSelector): string {
|
||||
if (selector?.sourceUrl) {
|
||||
return this.endpointState.sanitiseUrl(selector.sourceUrl);
|
||||
}
|
||||
|
||||
return this.resolveEndpoint(selector)?.url ?? this.endpointState.getPrimaryDefaultServerUrl();
|
||||
}
|
||||
|
||||
private unwrapServersResponse(
|
||||
response: { servers: ServerInfo[]; total: number } | ServerInfo[]
|
||||
): ServerInfo[] {
|
||||
return Array.isArray(response)
|
||||
? response
|
||||
: (response.servers ?? []);
|
||||
}
|
||||
|
||||
private searchSingleEndpoint(
|
||||
query: string,
|
||||
apiBaseUrl: string,
|
||||
source?: ServerEndpoint | null
|
||||
): Observable<ServerInfo[]> {
|
||||
const params = new HttpParams().set('q', query);
|
||||
|
||||
return this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params })
|
||||
.pipe(
|
||||
map((response) => this.normalizeServerList(response, source)),
|
||||
catchError((error) => {
|
||||
console.error('Failed to search servers:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private searchAllEndpoints(query: string): Observable<ServerInfo[]> {
|
||||
const onlineEndpoints = this.endpointState.activeServers().filter(
|
||||
(endpoint) => endpoint.status !== 'offline'
|
||||
);
|
||||
|
||||
if (onlineEndpoints.length === 0) {
|
||||
return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer());
|
||||
}
|
||||
|
||||
return forkJoin(
|
||||
onlineEndpoints.map((endpoint) => this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint))
|
||||
).pipe(
|
||||
map((resultArrays) => resultArrays.flat()),
|
||||
map((servers) => this.deduplicateById(servers))
|
||||
);
|
||||
}
|
||||
|
||||
private getAllServersFromAllEndpoints(): Observable<ServerInfo[]> {
|
||||
const onlineEndpoints = this.endpointState.activeServers().filter(
|
||||
(endpoint) => endpoint.status !== 'offline'
|
||||
);
|
||||
|
||||
if (onlineEndpoints.length === 0) {
|
||||
return this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`)
|
||||
.pipe(
|
||||
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
|
||||
catchError(() => of([]))
|
||||
);
|
||||
}
|
||||
|
||||
return forkJoin(
|
||||
onlineEndpoints.map((endpoint) =>
|
||||
this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`)
|
||||
.pipe(
|
||||
map((response) => this.normalizeServerList(response, endpoint)),
|
||||
catchError(() => of([] as ServerInfo[]))
|
||||
)
|
||||
)
|
||||
).pipe(map((resultArrays) => resultArrays.flat()));
|
||||
}
|
||||
|
||||
private deduplicateById<T extends { id: string }>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return items.filter((item) => {
|
||||
if (seen.has(item.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeServerList(
|
||||
response: { servers: ServerInfo[]; total: number } | ServerInfo[],
|
||||
source?: ServerEndpoint | null
|
||||
): ServerInfo[] {
|
||||
return this.unwrapServersResponse(response).map((server) => this.normalizeServerInfo(server, source));
|
||||
}
|
||||
|
||||
private normalizeServerInfo(
|
||||
server: ServerInfo | Record<string, unknown>,
|
||||
source?: ServerEndpoint | null
|
||||
): ServerInfo {
|
||||
const candidate = server as Record<string, unknown>;
|
||||
const sourceName = this.getStringValue(candidate['sourceName']);
|
||||
const sourceUrl = this.getStringValue(candidate['sourceUrl']);
|
||||
|
||||
return {
|
||||
id: this.getStringValue(candidate['id']) ?? '',
|
||||
name: this.getStringValue(candidate['name']) ?? 'Unnamed server',
|
||||
description: this.getStringValue(candidate['description']),
|
||||
topic: this.getStringValue(candidate['topic']),
|
||||
hostName: this.getStringValue(candidate['hostName']) ?? sourceName ?? source?.name ?? 'Unknown API',
|
||||
ownerId: this.getStringValue(candidate['ownerId']),
|
||||
ownerName: this.getStringValue(candidate['ownerName']),
|
||||
ownerPublicKey: this.getStringValue(candidate['ownerPublicKey']),
|
||||
userCount: this.getNumberValue(candidate['userCount'], this.getNumberValue(candidate['currentUsers'])),
|
||||
maxUsers: this.getNumberValue(candidate['maxUsers']),
|
||||
hasPassword: this.getBooleanValue(candidate['hasPassword']),
|
||||
isPrivate: this.getBooleanValue(candidate['isPrivate']),
|
||||
tags: Array.isArray(candidate['tags']) ? candidate['tags'] as string[] : [],
|
||||
createdAt: this.getNumberValue(candidate['createdAt'], Date.now()),
|
||||
sourceId: this.getStringValue(candidate['sourceId']) ?? source?.id,
|
||||
sourceName: sourceName ?? source?.name,
|
||||
sourceUrl: sourceUrl
|
||||
? this.endpointState.sanitiseUrl(sourceUrl)
|
||||
: (source ? this.endpointState.sanitiseUrl(source.url) : undefined)
|
||||
};
|
||||
}
|
||||
|
||||
private getBooleanValue(value: unknown): boolean {
|
||||
return typeof value === 'boolean' ? value : value === 1;
|
||||
}
|
||||
|
||||
private getNumberValue(value: unknown, fallback = 0): number {
|
||||
return typeof value === 'number' ? value : fallback;
|
||||
}
|
||||
|
||||
private getStringValue(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const SERVER_ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
|
||||
export const REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys';
|
||||
export const SERVER_HEALTH_CHECK_TIMEOUT_MS = 5000;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||
import type { ServerVersionCompatibilityResult } from '../domain/server-directory.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ServerEndpointCompatibilityService {
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private clientVersionPromise: Promise<string | null> | null = null;
|
||||
|
||||
async getClientVersion(): Promise<string | null> {
|
||||
if (!this.clientVersionPromise) {
|
||||
this.clientVersionPromise = this.resolveClientVersion();
|
||||
}
|
||||
|
||||
return await this.clientVersionPromise;
|
||||
}
|
||||
|
||||
evaluateServerVersion(
|
||||
rawServerVersion: unknown,
|
||||
clientVersion: string | null
|
||||
): ServerVersionCompatibilityResult {
|
||||
const serverVersion = normalizeSemanticVersion(rawServerVersion);
|
||||
|
||||
return {
|
||||
isCompatible: !clientVersion || (serverVersion !== null && serverVersion === clientVersion),
|
||||
serverVersion
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveClientVersion(): Promise<string | null> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const state = await electronApi.getAutoUpdateState();
|
||||
|
||||
return normalizeSemanticVersion(state?.currentVersion);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSemanticVersion(rawVersion: unknown): string | null {
|
||||
if (typeof rawVersion !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = rawVersion.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = trimmed.match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const major = Number.parseInt(match[1], 10);
|
||||
const minor = Number.parseInt(match[2], 10);
|
||||
const patch = Number.parseInt(match[3], 10);
|
||||
|
||||
if (
|
||||
Number.isNaN(major)
|
||||
|| Number.isNaN(minor)
|
||||
|| Number.isNaN(patch)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${major}.${minor}.${patch}`;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { SERVER_HEALTH_CHECK_TIMEOUT_MS } from './server-directory.infrastructure.constants';
|
||||
import type {
|
||||
ServerEndpoint,
|
||||
ServerEndpointHealthResult,
|
||||
ServerHealthCheckPayload
|
||||
} from '../domain/server-directory.models';
|
||||
import { ServerEndpointCompatibilityService } from './server-endpoint-compatibility.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ServerEndpointHealthService {
|
||||
private readonly endpointCompatibility = inject(ServerEndpointCompatibilityService);
|
||||
|
||||
async probeEndpoint(
|
||||
endpoint: Pick<ServerEndpoint, 'url'>,
|
||||
clientVersion: string | null
|
||||
): Promise<ServerEndpointHealthResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${endpoint.url}/api/health`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(SERVER_HEALTH_CHECK_TIMEOUT_MS)
|
||||
});
|
||||
const latency = Date.now() - startTime;
|
||||
|
||||
if (response.ok) {
|
||||
const payload = await response.json() as ServerHealthCheckPayload;
|
||||
const versionCompatibility = this.endpointCompatibility.evaluateServerVersion(
|
||||
payload.serverVersion,
|
||||
clientVersion
|
||||
);
|
||||
|
||||
if (!versionCompatibility.isCompatible) {
|
||||
return {
|
||||
status: 'incompatible',
|
||||
latency,
|
||||
versions: {
|
||||
serverVersion: versionCompatibility.serverVersion,
|
||||
clientVersion
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'online',
|
||||
latency,
|
||||
versions: {
|
||||
serverVersion: versionCompatibility.serverVersion,
|
||||
clientVersion
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return { status: 'offline' };
|
||||
} catch {
|
||||
try {
|
||||
const response = await fetch(`${endpoint.url}/api/servers`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(SERVER_HEALTH_CHECK_TIMEOUT_MS)
|
||||
});
|
||||
const latency = Date.now() - startTime;
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
status: 'online',
|
||||
latency
|
||||
};
|
||||
}
|
||||
} catch { /* both checks failed */ }
|
||||
|
||||
return { status: 'offline' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY, SERVER_ENDPOINTS_STORAGE_KEY } from './server-directory.infrastructure.constants';
|
||||
import type { ServerEndpoint } from '../domain/server-directory.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ServerEndpointStorageService {
|
||||
loadEndpoints(): ServerEndpoint[] | null {
|
||||
const stored = localStorage.getItem(SERVER_ENDPOINTS_STORAGE_KEY);
|
||||
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as unknown;
|
||||
|
||||
return Array.isArray(parsed)
|
||||
? parsed as ServerEndpoint[]
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
saveEndpoints(endpoints: ServerEndpoint[]): void {
|
||||
localStorage.setItem(SERVER_ENDPOINTS_STORAGE_KEY, JSON.stringify(endpoints));
|
||||
}
|
||||
|
||||
loadRemovedDefaultEndpointKeys(): Set<string> {
|
||||
const stored = localStorage.getItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
|
||||
|
||||
if (!stored) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as unknown;
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
return new Set(parsed.filter((value): value is string => typeof value === 'string'));
|
||||
} catch {
|
||||
return new Set<string>();
|
||||
}
|
||||
}
|
||||
|
||||
saveRemovedDefaultEndpointKeys(keys: Set<string>): void {
|
||||
if (keys.size === 0) {
|
||||
this.clearRemovedDefaultEndpointKeys();
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY, JSON.stringify([...keys]));
|
||||
}
|
||||
|
||||
clearRemovedDefaultEndpointKeys(): void {
|
||||
localStorage.removeItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
97
toju-app/src/app/domains/voice-connection/README.md
Normal file
97
toju-app/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" _blank
|
||||
click VAS "application/voice-activity.service.ts" "RMS-based speaking detection" _blank
|
||||
click VPS "application/voice-playback.service.ts" "Per-peer GainNode volume chain" _blank
|
||||
click RSF "../../infrastructure/realtime/realtime-session.service.ts" "Low-level WebRTC composition root" _blank
|
||||
click Models "domain/voice-connection.models.ts" "Re-exported types" _blank
|
||||
```
|
||||
|
||||
## 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()" _blank
|
||||
click Poll "application/voice-activity.service.ts" "VoiceActivityService.poll()" _blank
|
||||
```
|
||||
|
||||
| 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()" _blank
|
||||
click Gain "application/voice-playback.service.ts" "VoicePlaybackService.setUserVolume()" _blank
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* VoiceActivityService - monitors audio levels for local microphone
|
||||
* and remote peer streams, exposing per-user "speaking" state as
|
||||
* reactive Angular signals.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const speaking = voiceActivity.isSpeaking(userId);
|
||||
* // speaking() => true when the user's audio level exceeds the threshold
|
||||
*
|
||||
* const volume = voiceActivity.volume(userId);
|
||||
* // volume() => normalised 0-1 audio level
|
||||
* ```
|
||||
*
|
||||
* Internally uses the Web Audio API ({@link AudioContext} +
|
||||
* {@link AnalyserNode}) per tracked stream, with a single
|
||||
* `requestAnimationFrame` poll loop.
|
||||
*/
|
||||
import {
|
||||
Injectable,
|
||||
signal,
|
||||
computed,
|
||||
inject,
|
||||
OnDestroy,
|
||||
Signal
|
||||
} from '@angular/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { VoiceConnectionFacade } from './voice-connection.facade';
|
||||
import { DebuggingService } from '../../../core/services/debugging.service';
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, max-statements-per-line */
|
||||
|
||||
const SPEAKING_THRESHOLD = 0.015;
|
||||
const SILENT_FRAME_GRACE = 8;
|
||||
const FFT_SIZE = 256;
|
||||
|
||||
interface TrackedStream {
|
||||
ctx: AudioContext;
|
||||
sources: MediaStreamAudioSourceNode[];
|
||||
analyser: AnalyserNode;
|
||||
dataArray: Uint8Array<ArrayBuffer>;
|
||||
volumeSignal: ReturnType<typeof signal<number>>;
|
||||
speakingSignal: ReturnType<typeof signal<boolean>>;
|
||||
silentFrames: number;
|
||||
stream: MediaStream;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VoiceActivityService implements OnDestroy {
|
||||
private readonly voiceConnection = inject(VoiceConnectionFacade);
|
||||
private readonly debugging = inject(DebuggingService);
|
||||
|
||||
private readonly tracked = new Map<string, TrackedStream>();
|
||||
private animFrameId: number | null = null;
|
||||
private readonly subs: Subscription[] = [];
|
||||
private readonly _speakingMap = signal<ReadonlyMap<string, boolean>>(new Map());
|
||||
|
||||
readonly speakingMap: Signal<ReadonlyMap<string, boolean>> = this._speakingMap;
|
||||
|
||||
constructor() {
|
||||
this.subs.push(
|
||||
this.voiceConnection.onRemoteStream.subscribe(({ peerId }) => {
|
||||
const voiceStream = this.voiceConnection.getRemoteVoiceStream(peerId);
|
||||
|
||||
if (!voiceStream) {
|
||||
this.untrackStream(peerId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.trackStream(peerId, voiceStream);
|
||||
})
|
||||
);
|
||||
|
||||
this.subs.push(
|
||||
this.voiceConnection.onPeerDisconnected.subscribe((peerId) => {
|
||||
this.untrackStream(peerId);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
trackLocalMic(userId: string, stream: MediaStream): void {
|
||||
this.trackStream(userId, stream);
|
||||
}
|
||||
|
||||
untrackLocalMic(userId: string): void {
|
||||
this.untrackStream(userId);
|
||||
}
|
||||
|
||||
isSpeaking(userId: string): Signal<boolean> {
|
||||
const entry = this.tracked.get(userId);
|
||||
|
||||
if (entry)
|
||||
return entry.speakingSignal.asReadonly();
|
||||
|
||||
return computed(() => this._speakingMap().get(userId) ?? false);
|
||||
}
|
||||
|
||||
volume(userId: string): Signal<number> {
|
||||
const entry = this.tracked.get(userId);
|
||||
|
||||
if (entry)
|
||||
return entry.volumeSignal.asReadonly();
|
||||
|
||||
return computed(() => 0);
|
||||
}
|
||||
|
||||
trackStream(id: string, stream: MediaStream): void {
|
||||
const existing = this.tracked.get(id);
|
||||
const audioTracks = stream.getAudioTracks().filter((track) => track.readyState === 'live');
|
||||
|
||||
if (existing && existing.stream === stream)
|
||||
return;
|
||||
|
||||
if (existing)
|
||||
this.disposeEntry(existing);
|
||||
|
||||
if (audioTracks.length === 0) {
|
||||
this.tracked.delete(id);
|
||||
this.publishSpeakingMap();
|
||||
|
||||
if (this.tracked.size === 0)
|
||||
this.stopPolling();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = new AudioContext();
|
||||
const analyser = ctx.createAnalyser();
|
||||
const sources = audioTracks.map((track) => ctx.createMediaStreamSource(new MediaStream([track])));
|
||||
|
||||
analyser.fftSize = FFT_SIZE;
|
||||
sources.forEach((source) => source.connect(analyser));
|
||||
|
||||
const dataArray = new Uint8Array(analyser.fftSize) as Uint8Array<ArrayBuffer>;
|
||||
const volumeSignal = signal(0);
|
||||
const speakingSignal = signal(false);
|
||||
|
||||
this.tracked.set(id, {
|
||||
ctx,
|
||||
sources,
|
||||
analyser,
|
||||
dataArray,
|
||||
volumeSignal,
|
||||
speakingSignal,
|
||||
silentFrames: 0,
|
||||
stream
|
||||
});
|
||||
|
||||
this.ensurePolling();
|
||||
}
|
||||
|
||||
untrackStream(id: string): void {
|
||||
const entry = this.tracked.get(id);
|
||||
|
||||
if (!entry)
|
||||
return;
|
||||
|
||||
if (entry.speakingSignal()) {
|
||||
this.reportSpeakingState(id, false, 0);
|
||||
}
|
||||
|
||||
this.disposeEntry(entry);
|
||||
this.tracked.delete(id);
|
||||
this.publishSpeakingMap();
|
||||
|
||||
if (this.tracked.size === 0)
|
||||
this.stopPolling();
|
||||
}
|
||||
|
||||
private ensurePolling(): void {
|
||||
if (this.animFrameId !== null)
|
||||
return;
|
||||
|
||||
this.poll();
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.animFrameId !== null) {
|
||||
cancelAnimationFrame(this.animFrameId);
|
||||
this.animFrameId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private poll = (): void => {
|
||||
let mapDirty = false;
|
||||
|
||||
this.tracked.forEach((entry, id) => {
|
||||
const { analyser, dataArray, volumeSignal, speakingSignal } = entry;
|
||||
|
||||
analyser.getByteTimeDomainData(dataArray);
|
||||
|
||||
let sumSquares = 0;
|
||||
|
||||
for (let sampleIndex = 0; sampleIndex < dataArray.length; sampleIndex++) {
|
||||
const normalised = (dataArray[sampleIndex] - 128) / 128;
|
||||
|
||||
sumSquares += normalised * normalised;
|
||||
}
|
||||
|
||||
const rms = Math.sqrt(sumSquares / dataArray.length);
|
||||
|
||||
volumeSignal.set(rms);
|
||||
|
||||
const wasSpeaking = speakingSignal();
|
||||
|
||||
if (rms >= SPEAKING_THRESHOLD) {
|
||||
entry.silentFrames = 0;
|
||||
|
||||
if (!wasSpeaking) {
|
||||
speakingSignal.set(true);
|
||||
this.reportSpeakingState(id, true, rms);
|
||||
mapDirty = true;
|
||||
}
|
||||
} else {
|
||||
entry.silentFrames++;
|
||||
|
||||
if (wasSpeaking && entry.silentFrames >= SILENT_FRAME_GRACE) {
|
||||
speakingSignal.set(false);
|
||||
this.reportSpeakingState(id, false, rms);
|
||||
mapDirty = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (mapDirty)
|
||||
this.publishSpeakingMap();
|
||||
|
||||
this.animFrameId = requestAnimationFrame(this.poll);
|
||||
};
|
||||
|
||||
private publishSpeakingMap(): void {
|
||||
const map = new Map<string, boolean>();
|
||||
|
||||
this.tracked.forEach((entry, id) => {
|
||||
map.set(id, entry.speakingSignal());
|
||||
});
|
||||
|
||||
this._speakingMap.set(map);
|
||||
}
|
||||
|
||||
private reportSpeakingState(peerId: string, isSpeaking: boolean, volume: number): void {
|
||||
this.debugging.recordEvent('webrtc:voice-activity', 'Speaking state changed', {
|
||||
peerId,
|
||||
isSpeaking,
|
||||
volume: Number(volume.toFixed(3))
|
||||
});
|
||||
}
|
||||
|
||||
private disposeEntry(entry: TrackedStream): void {
|
||||
entry.sources.forEach((source) => {
|
||||
try { source.disconnect(); } catch { /* already disconnected */ }
|
||||
});
|
||||
|
||||
try { entry.ctx.close(); } catch { /* already closed */ }
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopPolling();
|
||||
this.tracked.forEach((entry) => this.disposeEntry(entry));
|
||||
this.tracked.clear();
|
||||
this.subs.forEach((subscription) => subscription.unsubscribe());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { ChatEvent } from '../../../shared-kernel';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { LatencyProfile } from '../domain/voice-connection.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VoiceConnectionFacade {
|
||||
readonly isVoiceConnected = inject(RealtimeSessionFacade).isVoiceConnected;
|
||||
readonly isMuted = inject(RealtimeSessionFacade).isMuted;
|
||||
readonly isDeafened = inject(RealtimeSessionFacade).isDeafened;
|
||||
readonly isNoiseReductionEnabled = inject(RealtimeSessionFacade).isNoiseReductionEnabled;
|
||||
readonly hasConnectionError = inject(RealtimeSessionFacade).hasConnectionError;
|
||||
readonly connectionErrorMessage = inject(RealtimeSessionFacade).connectionErrorMessage;
|
||||
readonly shouldShowConnectionError = inject(RealtimeSessionFacade).shouldShowConnectionError;
|
||||
readonly peerLatencies = inject(RealtimeSessionFacade).peerLatencies;
|
||||
readonly onRemoteStream = inject(RealtimeSessionFacade).onRemoteStream;
|
||||
readonly onPeerConnected = inject(RealtimeSessionFacade).onPeerConnected;
|
||||
readonly onPeerDisconnected = inject(RealtimeSessionFacade).onPeerDisconnected;
|
||||
readonly onVoiceConnected = inject(RealtimeSessionFacade).onVoiceConnected;
|
||||
|
||||
private readonly realtime = inject(RealtimeSessionFacade);
|
||||
|
||||
async ensureSignalingConnected(timeoutMs?: number): Promise<boolean> {
|
||||
return await this.realtime.ensureSignalingConnected(timeoutMs);
|
||||
}
|
||||
|
||||
broadcastMessage(event: ChatEvent): void {
|
||||
this.realtime.broadcastMessage(event);
|
||||
}
|
||||
|
||||
getConnectedPeers(): string[] {
|
||||
return this.realtime.getConnectedPeers();
|
||||
}
|
||||
|
||||
getRemoteVoiceStream(peerId: string): MediaStream | null {
|
||||
return this.realtime.getRemoteVoiceStream(peerId);
|
||||
}
|
||||
|
||||
getLocalStream(): MediaStream | null {
|
||||
return this.realtime.getLocalStream();
|
||||
}
|
||||
|
||||
getRawMicStream(): MediaStream | null {
|
||||
return this.realtime.getRawMicStream();
|
||||
}
|
||||
|
||||
async enableVoice(): Promise<MediaStream> {
|
||||
return await this.realtime.enableVoice();
|
||||
}
|
||||
|
||||
disableVoice(): void {
|
||||
this.realtime.disableVoice();
|
||||
}
|
||||
|
||||
async setLocalStream(stream: MediaStream): Promise<void> {
|
||||
await this.realtime.setLocalStream(stream);
|
||||
}
|
||||
|
||||
toggleMute(muted?: boolean): void {
|
||||
this.realtime.toggleMute(muted);
|
||||
}
|
||||
|
||||
toggleDeafen(deafened?: boolean): void {
|
||||
this.realtime.toggleDeafen(deafened);
|
||||
}
|
||||
|
||||
async toggleNoiseReduction(enabled?: boolean): Promise<void> {
|
||||
await this.realtime.toggleNoiseReduction(enabled);
|
||||
}
|
||||
|
||||
setOutputVolume(volume: number): void {
|
||||
this.realtime.setOutputVolume(volume);
|
||||
}
|
||||
|
||||
setInputVolume(volume: number): void {
|
||||
this.realtime.setInputVolume(volume);
|
||||
}
|
||||
|
||||
async setAudioBitrate(kbps: number): Promise<void> {
|
||||
await this.realtime.setAudioBitrate(kbps);
|
||||
}
|
||||
|
||||
async setLatencyProfile(profile: LatencyProfile): Promise<void> {
|
||||
await this.realtime.setLatencyProfile(profile);
|
||||
}
|
||||
|
||||
startVoiceHeartbeat(roomId?: string, serverId?: string): void {
|
||||
this.realtime.startVoiceHeartbeat(roomId, serverId);
|
||||
}
|
||||
|
||||
stopVoiceHeartbeat(): void {
|
||||
this.realtime.stopVoiceHeartbeat();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { LATENCY_PROFILE_BITRATES } from '../../../infrastructure/realtime/realtime.constants';
|
||||
export type { LatencyProfile } from '../../../shared-kernel';
|
||||
export type { VoiceStateSnapshot } from '../../../infrastructure/realtime/realtime.types';
|
||||
4
toju-app/src/app/domains/voice-connection/index.ts
Normal file
4
toju-app/src/app/domains/voice-connection/index.ts
Normal file
@@ -0,0 +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
toju-app/src/app/domains/voice-session/README.md
Normal file
111
toju-app/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" _blank
|
||||
click VWS "application/voice-workspace.service.ts" "Workspace mode and focused stream" _blank
|
||||
click VSS "infrastructure/voice-settings.storage.ts" "localStorage persistence for audio settings" _blank
|
||||
click Logic "domain/voice-session.logic.ts" "Pure helper functions" _blank
|
||||
click VC "feature/voice-controls/" "Full voice control panel" _blank
|
||||
click FC "feature/floating-voice-controls/" "Minimal floating overlay" _blank
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,114 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, */
|
||||
import {
|
||||
Injectable,
|
||||
signal,
|
||||
computed,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { RoomsActions } from '../../../store/rooms/rooms.actions';
|
||||
import { buildVoiceSessionRoom, isViewingVoiceSessionServer } from '../domain/voice-session.logic';
|
||||
import type { VoiceSessionInfo } from '../domain/voice-session.models';
|
||||
|
||||
/**
|
||||
* Tracks the user's current voice session across client-side
|
||||
* navigation so that floating voice controls remain visible when
|
||||
* the user is browsing a different server or view.
|
||||
*
|
||||
* This service is purely a UI-state tracker - actual WebRTC
|
||||
* voice management lives in {@link WebRTCService} and its managers.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VoiceSessionFacade {
|
||||
private readonly store = inject(Store);
|
||||
|
||||
/** Current voice session metadata, or `null` when disconnected. */
|
||||
private readonly _voiceSession = signal<VoiceSessionInfo | null>(null);
|
||||
|
||||
/** Whether the user is currently viewing the voice-connected server. */
|
||||
private readonly _isViewingVoiceServer = signal<boolean>(true);
|
||||
|
||||
/** Reactive read-only voice session. */
|
||||
readonly voiceSession = computed(() => this._voiceSession());
|
||||
|
||||
/** Reactive flag: is the user's current view the voice server? */
|
||||
readonly isViewingVoiceServer = computed(() => this._isViewingVoiceServer());
|
||||
|
||||
/**
|
||||
* Whether the floating voice-controls overlay should be visible.
|
||||
* `true` when a voice session is active AND the user is viewing
|
||||
* a different server.
|
||||
*/
|
||||
readonly showFloatingControls = computed(
|
||||
() => this._voiceSession() !== null && !this._isViewingVoiceServer()
|
||||
);
|
||||
|
||||
/**
|
||||
* Begin tracking a voice session.
|
||||
* Called when the user joins a voice channel.
|
||||
*
|
||||
* @param sessionInfo - Metadata describing the voice-connected server/channel.
|
||||
*/
|
||||
startSession(sessionInfo: VoiceSessionInfo): void {
|
||||
this._voiceSession.set(sessionInfo);
|
||||
this._isViewingVoiceServer.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop tracking the voice session.
|
||||
* Called when the user disconnects from voice.
|
||||
*/
|
||||
endSession(): void {
|
||||
this._voiceSession.set(null);
|
||||
this._isViewingVoiceServer.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually flag whether the user is currently viewing the
|
||||
* voice-connected server.
|
||||
*
|
||||
* @param isViewing - `true` if the user's current view is the voice server.
|
||||
*/
|
||||
setViewingVoiceServer(isViewing: boolean): void {
|
||||
this._isViewingVoiceServer.set(isViewing);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the given server ID to the voice session's server and
|
||||
* update the {@link isViewingVoiceServer} flag accordingly.
|
||||
*
|
||||
* @param currentServerId - ID of the server the user is currently viewing.
|
||||
*/
|
||||
checkCurrentRoute(currentServerId: string | null): void {
|
||||
this._isViewingVoiceServer.set(
|
||||
isViewingVoiceSessionServer(this._voiceSession(), currentServerId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate the user back to the voice-connected server by
|
||||
* dispatching a `viewServer` action.
|
||||
*/
|
||||
navigateToVoiceServer(): void {
|
||||
const session = this._voiceSession();
|
||||
|
||||
if (!session)
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.viewServer({
|
||||
room: buildVoiceSessionRoom(session)
|
||||
})
|
||||
);
|
||||
|
||||
this._isViewingVoiceServer.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the server ID of the active voice session, or `null`
|
||||
* if the user is not in a voice channel.
|
||||
*/
|
||||
getVoiceServerId(): string | null {
|
||||
return this._voiceSession()?.serverId ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
|
||||
import { VoiceSessionFacade } from './voice-session.facade';
|
||||
|
||||
export type VoiceWorkspaceMode = 'hidden' | 'expanded' | 'minimized';
|
||||
|
||||
export interface VoiceWorkspacePosition {
|
||||
left: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MINI_WINDOW_POSITION: VoiceWorkspacePosition = {
|
||||
left: 24,
|
||||
top: 24
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VoiceWorkspaceService {
|
||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||
|
||||
private readonly _mode = signal<VoiceWorkspaceMode>('hidden');
|
||||
private readonly _focusedStreamId = signal<string | null>(null);
|
||||
private readonly _connectRemoteShares = signal(false);
|
||||
private readonly _miniWindowPosition = signal<VoiceWorkspacePosition>(
|
||||
DEFAULT_MINI_WINDOW_POSITION
|
||||
);
|
||||
private readonly _hasCustomMiniWindowPosition = signal(false);
|
||||
|
||||
readonly mode = computed<VoiceWorkspaceMode>(() => {
|
||||
if (!this.voiceSession.voiceSession() || !this.voiceSession.isViewingVoiceServer()) {
|
||||
return 'hidden';
|
||||
}
|
||||
|
||||
return this._mode();
|
||||
});
|
||||
|
||||
readonly isExpanded = computed(() => this.mode() === 'expanded');
|
||||
readonly isMinimized = computed(() => this.mode() === 'minimized');
|
||||
readonly isVisible = computed(() => this.mode() !== 'hidden');
|
||||
readonly focusedStreamId = computed(() => this._focusedStreamId());
|
||||
readonly shouldConnectRemoteShares = computed(
|
||||
() => this.isVisible() && this._connectRemoteShares()
|
||||
);
|
||||
readonly miniWindowPosition = computed(() => this._miniWindowPosition());
|
||||
readonly hasCustomMiniWindowPosition = computed(() => this._hasCustomMiniWindowPosition());
|
||||
|
||||
constructor() {
|
||||
effect(
|
||||
() => {
|
||||
if (this.voiceSession.voiceSession()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reset();
|
||||
},
|
||||
{ allowSignalWrites: true }
|
||||
);
|
||||
}
|
||||
|
||||
open(
|
||||
focusedStreamId: string | null = null,
|
||||
options?: { connectRemoteShares?: boolean }
|
||||
): void {
|
||||
if (!this.voiceSession.voiceSession()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options && Object.prototype.hasOwnProperty.call(options, 'connectRemoteShares')) {
|
||||
this._connectRemoteShares.set(options.connectRemoteShares === true);
|
||||
}
|
||||
|
||||
this._focusedStreamId.set(focusedStreamId);
|
||||
this._mode.set('expanded');
|
||||
}
|
||||
|
||||
focusStream(streamId: string, options?: { connectRemoteShares?: boolean }): void {
|
||||
this.open(streamId, options);
|
||||
}
|
||||
|
||||
minimize(): void {
|
||||
if (!this.voiceSession.voiceSession()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._mode.set('minimized');
|
||||
}
|
||||
|
||||
restore(): void {
|
||||
this.open(this._focusedStreamId());
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this._mode.set('hidden');
|
||||
this._connectRemoteShares.set(false);
|
||||
}
|
||||
|
||||
showChat(): void {
|
||||
if (this._mode() === 'expanded') {
|
||||
this._mode.set('hidden');
|
||||
this._connectRemoteShares.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
clearFocusedStream(): void {
|
||||
this._focusedStreamId.set(null);
|
||||
}
|
||||
|
||||
setMiniWindowPosition(position: VoiceWorkspacePosition, markCustom = true): void {
|
||||
this._miniWindowPosition.set(position);
|
||||
this._hasCustomMiniWindowPosition.set(markCustom);
|
||||
}
|
||||
|
||||
resetMiniWindowPosition(): void {
|
||||
this._miniWindowPosition.set(DEFAULT_MINI_WINDOW_POSITION);
|
||||
this._hasCustomMiniWindowPosition.set(false);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this._mode.set('hidden');
|
||||
this._focusedStreamId.set(null);
|
||||
this._connectRemoteShares.set(false);
|
||||
this.resetMiniWindowPosition();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Room } from '../../../shared-kernel';
|
||||
import type { VoiceSessionInfo } from './voice-session.models';
|
||||
|
||||
export function isViewingVoiceSessionServer(
|
||||
session: VoiceSessionInfo | null,
|
||||
currentServerId: string | null
|
||||
): boolean {
|
||||
return !session || currentServerId === session.serverId;
|
||||
}
|
||||
|
||||
export function buildVoiceSessionRoom(session: VoiceSessionInfo): Room {
|
||||
return {
|
||||
id: session.serverId,
|
||||
name: session.serverName,
|
||||
description: session.serverDescription,
|
||||
hostId: '',
|
||||
isPrivate: false,
|
||||
createdAt: 0,
|
||||
userCount: 0,
|
||||
maxUsers: 50,
|
||||
icon: session.serverIcon
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Snapshot of an active voice session, retained so that floating
|
||||
* voice controls can display the connection details when the user
|
||||
* navigates away from the server view.
|
||||
*/
|
||||
export interface VoiceSessionInfo {
|
||||
/** Unique server identifier. */
|
||||
serverId: string;
|
||||
/** Display name of the server. */
|
||||
serverName: string;
|
||||
/** Room/channel ID within the server. */
|
||||
roomId: string;
|
||||
/** Display name of the room/channel. */
|
||||
roomName: string;
|
||||
/** Optional server icon (data-URL or remote URL). */
|
||||
serverIcon?: string;
|
||||
/** Optional server description. */
|
||||
serverDescription?: string;
|
||||
/** Angular route path to navigate back to the server. */
|
||||
serverRoute: string;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
8
toju-app/src/app/domains/voice-session/index.ts
Normal file
8
toju-app/src/app/domains/voice-session/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
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';
|
||||
@@ -0,0 +1,99 @@
|
||||
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../core/constants';
|
||||
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;
|
||||
outputDevice: string;
|
||||
inputVolume: number;
|
||||
outputVolume: number;
|
||||
audioBitrate: number;
|
||||
latencyProfile: LatencyProfile;
|
||||
includeSystemAudio: boolean;
|
||||
noiseReduction: boolean;
|
||||
screenShareQuality: ScreenShareQuality;
|
||||
askScreenShareQuality: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_VOICE_SETTINGS: VoiceSettings = {
|
||||
inputDevice: '',
|
||||
outputDevice: '',
|
||||
inputVolume: 100,
|
||||
outputVolume: 100,
|
||||
audioBitrate: 96,
|
||||
latencyProfile: DEFAULT_LATENCY_PROFILE,
|
||||
includeSystemAudio: false,
|
||||
noiseReduction: true,
|
||||
screenShareQuality: DEFAULT_SCREEN_SHARE_QUALITY,
|
||||
askScreenShareQuality: true
|
||||
};
|
||||
|
||||
export function loadVoiceSettingsFromStorage(): VoiceSettings {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
|
||||
|
||||
if (!raw)
|
||||
return { ...DEFAULT_VOICE_SETTINGS };
|
||||
|
||||
return normaliseVoiceSettings(JSON.parse(raw) as Partial<VoiceSettings>);
|
||||
} catch {
|
||||
return { ...DEFAULT_VOICE_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveVoiceSettingsToStorage(patch: Partial<VoiceSettings>): VoiceSettings {
|
||||
const nextSettings = normaliseVoiceSettings({
|
||||
...loadVoiceSettingsFromStorage(),
|
||||
...patch
|
||||
});
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_VOICE_SETTINGS, JSON.stringify(nextSettings));
|
||||
} catch {}
|
||||
|
||||
return nextSettings;
|
||||
}
|
||||
|
||||
function normaliseVoiceSettings(raw: Partial<VoiceSettings>): VoiceSettings {
|
||||
return {
|
||||
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, 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
|
||||
: DEFAULT_VOICE_SETTINGS.latencyProfile,
|
||||
includeSystemAudio: typeof raw.includeSystemAudio === 'boolean'
|
||||
? raw.includeSystemAudio
|
||||
: DEFAULT_VOICE_SETTINGS.includeSystemAudio,
|
||||
noiseReduction: typeof raw.noiseReduction === 'boolean'
|
||||
? raw.noiseReduction
|
||||
: DEFAULT_VOICE_SETTINGS.noiseReduction,
|
||||
screenShareQuality: SCREEN_SHARE_QUALITIES.includes(raw.screenShareQuality as ScreenShareQuality)
|
||||
? raw.screenShareQuality as ScreenShareQuality
|
||||
: DEFAULT_VOICE_SETTINGS.screenShareQuality,
|
||||
askScreenShareQuality: typeof raw.askScreenShareQuality === 'boolean'
|
||||
? raw.askScreenShareQuality
|
||||
: DEFAULT_VOICE_SETTINGS.askScreenShareQuality
|
||||
};
|
||||
}
|
||||
|
||||
function clampNumber(
|
||||
value: unknown,
|
||||
min: number,
|
||||
max: number,
|
||||
fallback: number
|
||||
): number {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
Reference in New Issue
Block a user