Many new functionalities

This commit is contained in:
2026-03-10 02:49:27 +01:00
parent 640d92d231
commit 61612b52d3
15 changed files with 1920 additions and 97 deletions

View File

@@ -1,3 +1,3 @@
window.__PRIVATECHAT_ENV__ = {
"PRIVATECHAT_CLIENT_SERVER_URL": "http://chatter.dubertrand.fr"
"PRIVATECHAT_CLIENT_SERVER_URL": "https://chatter.dubertrand.fr"
};

View File

@@ -1,6 +1,13 @@
<main class="chat-shell py-4">
<div class="container-lg">
<section class="chat-page panel p-3 p-lg-4">
<app-peer-video-modal
[visible]="remoteVideoModalVisible()"
[stream]="remoteVideoStream()"
[title]="(peer()?.displayName ?? 'Peer') + ' webcam'"
(closeRequested)="closeRemoteVideoModal()"
></app-peer-video-modal>
<div class="chat-header d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3 mb-4">
<div>
<a class="back-link" routerLink="/">← Back to dashboard</a>
@@ -100,18 +107,41 @@
[class.bubble-outgoing]="entry.direction === 'outgoing'"
[class.bubble-system]="entry.direction === 'system'"
>
<button
class="bubble-delete"
type="button"
(click)="deleteMessage(entry)"
title="Delete message"
aria-label="Delete message"
>
×
</button>
@if (entry.direction !== 'system') {
<div class="bubble-actions">
<button
class="bubble-action"
type="button"
(click)="toggleForwardMenu(entry, $event)"
title="Forward message"
aria-label="Forward message"
>
</button>
<button
class="bubble-action bubble-delete"
type="button"
(click)="deleteMessage(entry)"
title="Delete message"
aria-label="Delete message"
>
×
</button>
@if (isForwardMenuOpen(entry.id)) {
<div class="bubble-forward-menu">
<select #forwardSelect class="bubble-forward-select" (change)="forwardEntry(entry, forwardSelect.value, forwardSelect)">
<option value="">Forward to…</option>
@for (targetPeer of forwardTargets(entry); track targetPeer.id) {
<option [value]="targetPeer.id">{{ targetPeer.displayName }}</option>
}
</select>
</div>
}
</div>
}
<div class="bubble-meta">
<span>{{ entry.authorLabel }}</span>
<time>{{ entry.createdAt | date: 'shortTime' }}</time>
<span class="bubble-author">{{ entry.authorLabel }}</span>
<time class="bubble-time">{{ entry.createdAt | date: 'shortTime' }}</time>
</div>
@switch (entry.kind) {
@@ -131,6 +161,18 @@
/>
}
@if (isVideoEntry(entry)) {
<video
class="bubble-video"
[src]="entry.downloadUrl"
controls
autoplay
muted
playsinline
preload="metadata"
></video>
}
@if (isIncomingJsonFileEntry(entry)) {
<app-json-file-viewer [entry]="entry"></app-json-file-viewer>
}
@@ -157,6 +199,18 @@
<div class="composer">
@if (peer(); as selectedPeer) {
<div class="composer-actions">
<button
class="composer-camera"
type="button"
[disabled]="selectedPeer.channelState !== 'open' && !isStreamingCameraToSelectedPeer()"
(click)="toggleCameraStream(selectedPeer.id)"
[title]="isStreamingCameraToSelectedPeer() ? 'Stop webcam' : 'Start webcam'"
[attr.aria-label]="isStreamingCameraToSelectedPeer() ? 'Stop webcam' : 'Start webcam'"
>
{{ isStreamingCameraToSelectedPeer() ? '🛑' : '📹' }}
</button>
<input
#fileInput
class="composer-file-input"
@@ -174,27 +228,74 @@
>
+
</button>
</div>
}
<textarea
#composerTextarea
class="form-control composer-textarea"
rows="3"
[(ngModel)]="messageText"
(ngModelChange)="handleMessageTextChange($event)"
(keydown.enter)="handleComposerEnter($event)"
(click)="trackComposerSelection(composerTextarea)"
(keyup)="trackComposerSelection(composerTextarea)"
(select)="trackComposerSelection(composerTextarea)"
[disabled]="!session.isSelectedPeerReady()"
placeholder="Write a text message to your peer"
></textarea>
<button
class="send-emoji"
type="button"
[disabled]="!session.isSelectedPeerReady()"
(click)="sendMessage()"
title="Send message"
aria-label="Send message"
>
</button>
<div class="composer-send">
<button
class="composer-image-generate"
type="button"
[disabled]="!peer() || session.signalingState() !== 'connected' || !messageText.trim()"
(click)="requestGeneratedImage()"
title="Generate image from prompt"
aria-label="Generate image from prompt"
>
🖼️
</button>
<div class="composer-emoji-picker-shell">
@if (emojiPickerOpen()) {
<div class="composer-emoji-picker">
@for (emoji of emojiOptions; track emoji) {
<button
class="composer-emoji-option"
type="button"
(click)="insertEmoji(emoji, composerTextarea)"
[attr.aria-label]="'Insert ' + emoji"
[title]="'Insert ' + emoji"
>
{{ emoji }}
</button>
}
</div>
}
<button
class="composer-emoji-trigger"
type="button"
[disabled]="!session.isSelectedPeerReady()"
(click)="toggleEmojiPicker($event)"
title="Insert emoji"
aria-label="Insert emoji"
>
😀
</button>
</div>
<button
class="send-emoji"
type="button"
[disabled]="!session.isSelectedPeerReady()"
(click)="sendMessage()"
title="Send message"
aria-label="Send message"
>
</button>
</div>
</div>
</div>
</div>

View File

@@ -211,23 +211,55 @@
position: relative;
align-self: start;
max-width: min(75%, 34rem);
padding: 0.9rem 1rem;
padding: 0.9rem 3.4rem 0.9rem 1rem;
border-radius: 1.2rem;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.14);
}
.bubble-delete {
.bubble-actions {
position: absolute;
top: 0.45rem;
right: 0.55rem;
display: flex;
align-items: flex-start;
gap: 0.35rem;
}
.bubble-action {
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: 0;
border-radius: 999px;
color: #fff;
background: var(--danger-background);
background: var(--badge-background);
line-height: 1;
font-size: 1rem;
font-size: 0.9rem;
}
.bubble-delete {
background: var(--danger-background);
}
.bubble-forward-menu {
position: absolute;
top: 1.9rem;
right: 0;
z-index: 2;
min-width: 12rem;
padding: 0.45rem;
border: 1px solid var(--surface-border);
border-radius: 0.85rem;
background: var(--surface-background);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.18);
}
.bubble-forward-select {
width: 100%;
border: 1px solid var(--input-border);
border-radius: 0.65rem;
color: var(--page-text);
background: var(--input-background);
}
.bubble-incoming {
@@ -250,14 +282,21 @@
}
.bubble-meta {
display: flex;
justify-content: space-between;
gap: 1rem;
display: grid;
gap: 0.12rem;
margin-bottom: 0.35rem;
font-size: 0.78rem;
opacity: 0.7;
}
.bubble-author {
font-weight: 600;
}
.bubble-time {
display: block;
}
.composer {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
@@ -268,10 +307,27 @@
border-top: 1px solid var(--surface-border-soft);
}
.composer-actions {
display: grid;
gap: 0.6rem;
}
.composer-send {
display: grid;
gap: 0.6rem;
}
.composer-emoji-picker-shell {
position: relative;
}
.composer-file-input {
display: none;
}
.composer-camera,
.composer-image-generate,
.composer-emoji-trigger,
.composer-plus,
.send-emoji {
width: 3.25rem;
@@ -293,6 +349,21 @@
color: var(--placeholder-color);
}
.composer-camera {
color: var(--page-text);
background: var(--badge-background);
}
.composer-image-generate {
color: var(--page-text);
background: linear-gradient(135deg, #ffe6b0, #ffc8a8);
}
.composer-emoji-trigger {
color: var(--page-text);
background: var(--badge-background);
}
.composer-plus {
color: var(--page-text);
background: var(--badge-background);
@@ -302,6 +373,41 @@
background: linear-gradient(135deg, #def7dd, #9bd5ff);
}
.composer-emoji-picker {
position: absolute;
right: 0;
bottom: calc(100% + 0.65rem);
z-index: 3;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.35rem;
width: min(14rem, 70vw);
max-height: 10.35rem;
overflow-y: auto;
overflow-x: hidden;
padding: 0.65rem;
border: 1px solid var(--surface-border);
border-radius: 1rem;
background: var(--panel-background);
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.18);
}
.composer-emoji-option {
width: 2.1rem;
height: 2.1rem;
padding: 0;
border: 0;
border-radius: 0.75rem;
background: var(--surface-background);
font-size: 1.2rem;
line-height: 1;
}
.composer-emoji-option:hover,
.composer-emoji-option:focus-visible {
background: var(--surface-hover-background);
}
.bubble-image {
width: 200px;
max-width: 100%;
@@ -310,6 +416,15 @@
display: block;
}
.bubble-video {
width: 200px;
max-width: 100%;
height: auto;
display: block;
border-radius: 1rem;
background: #000;
}
.bubble-download {
color: inherit;
font-weight: 600;

View File

@@ -1,16 +1,17 @@
import { CommonModule } from '@angular/common';
import { Component, computed, effect, inject } from '@angular/core';
import { Component, computed, effect, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { PeerVideoModalComponent } from './peer-video-modal.component';
import { ChatSessionService } from './chat-session.service';
import { JsonFileViewerComponent } from './json-file-viewer.component';
import type { ChatEntry, ConnectionState } from './models';
import type { ChatEntry, ConnectionState, PeerSummary } from './models';
@Component({
selector: 'app-chat-page',
imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent],
imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent, PeerVideoModalComponent],
templateUrl: './chat-page.component.html',
styleUrl: './chat-page.component.scss',
})
@@ -20,8 +21,22 @@ export class ChatPageComponent {
private readonly routeParamMap = toSignal(this.route.paramMap, {
initialValue: this.route.snapshot.paramMap,
});
private composerSelectionStart = 0;
private composerSelectionEnd = 0;
messageText = '';
readonly forwardingEntryId = signal<string | null>(null);
readonly emojiPickerOpen = signal(false);
readonly emojiOptions = [
'😀', '😁', '😂', '🤣', '😊',
'😉', '😍', '😘', '😎', '🤔',
'😅', '😭', '😡', '😴', '🙃',
'👍', '👎', '👏', '🙏', '🤝',
'🎉', '🔥', '❤️', '💡', '✅',
'🚀', '👀', '📹', '📎', '💬',
'🌍', '⚡', '⭐', '🎵', '📷',
'🗑️', '⏩', '🛑', '🙌', '👌',
];
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
readonly currentUser = computed(() => this.session.currentUser());
@@ -30,6 +45,10 @@ export class ChatPageComponent {
.messages()
.filter((entry) => entry.peerId === this.peerId()),
);
readonly remoteVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.peerId()));
readonly remoteVideoModalVisible = computed(
() => this.session.remoteVideoModalPeerId() === this.peerId() && !!this.remoteVideoStream(),
);
readonly webRtcState = computed<ConnectionState>(() => {
const selectedPeer = this.peer();
@@ -85,6 +104,19 @@ export class ChatPageComponent {
await this.session.sendText(peerId, this.messageText);
this.messageText = '';
this.emojiPickerOpen.set(false);
this.composerSelectionStart = 0;
this.composerSelectionEnd = 0;
}
async requestGeneratedImage(): Promise<void> {
const peerId = this.peerId();
if (!peerId) {
return;
}
await this.session.requestGeneratedImage(peerId, this.messageText);
}
handleComposerEnter(event: Event): void {
@@ -106,6 +138,37 @@ export class ChatPageComponent {
this.session.notifyTypingActivity(peerId, text);
}
trackComposerSelection(textarea: HTMLTextAreaElement): void {
this.composerSelectionStart = textarea.selectionStart ?? this.messageText.length;
this.composerSelectionEnd = textarea.selectionEnd ?? this.composerSelectionStart;
}
toggleEmojiPicker(event?: Event): void {
event?.stopPropagation();
this.emojiPickerOpen.update((open) => !open);
}
insertEmoji(emoji: string, textarea: HTMLTextAreaElement): void {
const selectionStart = textarea.selectionStart ?? this.composerSelectionStart;
const selectionEnd = textarea.selectionEnd ?? this.composerSelectionEnd;
const before = this.messageText.slice(0, selectionStart);
const after = this.messageText.slice(selectionEnd);
this.messageText = `${before}${emoji}${after}`;
this.emojiPickerOpen.set(false);
this.handleMessageTextChange(this.messageText);
const nextSelection = selectionStart + emoji.length;
this.composerSelectionStart = nextSelection;
this.composerSelectionEnd = nextSelection;
queueMicrotask(() => {
textarea.focus();
textarea.setSelectionRange(nextSelection, nextSelection);
this.trackComposerSelection(textarea);
});
}
async sendFile(peerId: string, input: HTMLInputElement): Promise<void> {
const file = input.files?.item(0);
@@ -126,10 +189,64 @@ export class ChatPageComponent {
await this.session.deleteConversation(peerId);
}
toggleForwardMenu(entry: ChatEntry, event?: Event): void {
event?.stopPropagation();
if (entry.kind === 'system' || entry.direction === 'system' || this.forwardTargets(entry).length === 0) {
this.forwardingEntryId.set(null);
return;
}
this.forwardingEntryId.update((currentEntryId) => (currentEntryId === entry.id ? null : entry.id));
}
isForwardMenuOpen(entryId: string): boolean {
return this.forwardingEntryId() === entryId;
}
forwardTargets(entry: ChatEntry): PeerSummary[] {
if (entry.kind === 'system' || entry.direction === 'system') {
return [];
}
return this.session.peers().filter((peer) => peer.id !== entry.peerId);
}
async forwardEntry(entry: ChatEntry, targetPeerId: string, select: HTMLSelectElement): Promise<void> {
if (!targetPeerId) {
return;
}
await this.session.forwardMessage(targetPeerId, entry);
select.value = '';
this.forwardingEntryId.set(null);
}
async toggleCameraStream(peerId: string): Promise<void> {
if (this.session.isStreamingCameraToPeer(peerId)) {
await this.session.stopCameraStream(peerId);
return;
}
await this.session.startCameraStream(peerId);
}
isImageEntry(entry: ChatEntry): boolean {
return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false);
}
isVideoEntry(entry: ChatEntry): boolean {
if (entry.kind !== 'file' || !entry.downloadUrl) {
return false;
}
if (entry.fileMimeType?.startsWith('video/')) {
return true;
}
return /\.(mp4|webm|ogg|ogv|mov|m4v)$/i.test(entry.fileName ?? '');
}
isIncomingJsonFileEntry(entry: ChatEntry): boolean {
return (
entry.kind === 'file' &&
@@ -160,11 +277,29 @@ export class ChatPageComponent {
return this.indicatorTone(this.webRtcState()) === 'offline';
}
isStreamingCameraToSelectedPeer(): boolean {
const peerId = this.peerId();
return !!peerId && this.session.isStreamingCameraToPeer(peerId);
}
closeRemoteVideoModal(): void {
const peerId = this.peerId();
if (!peerId) {
return;
}
this.session.dismissRemoteVideoModal(peerId);
}
async switchPeer(peerId: string): Promise<void> {
if (!peerId || peerId === this.peerId()) {
return;
}
this.forwardingEntryId.set(null);
this.emojiPickerOpen.set(false);
this.session.selectPeer(peerId);
await this.router.navigate(['/chat', peerId]);
}

View File

@@ -1,10 +1,10 @@
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { computed, Injectable, signal } from '@angular/core';
import type { HttpErrorResponse } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import {
AccessKeySummary,
AdminUserSummary,
AuthenticationOptionsResponse,
AuthResponse,
ChatEntry,
@@ -24,6 +24,10 @@ type PeerBundle = {
pc: RTCPeerConnection;
channel?: RTCDataChannel;
pendingCandidates: RTCIceCandidateInit[];
pendingNegotiation: boolean;
localCameraStream?: MediaStream;
cameraSenders: RTCRtpSender[];
remoteCameraStream?: MediaStream;
};
type IncomingFileTransfer = {
@@ -101,6 +105,10 @@ export class ChatSessionService {
private static readonly messageDatabaseName = 'privatechat';
private static readonly messageStoreName = 'conversation_messages';
private static readonly messageRetentionLimit = 256;
private static readonly sessionKeepaliveMs = 5 * 60 * 1000;
private static readonly signalingHeartbeatMs = 25 * 1000;
private static readonly signalingReconnectBaseMs = 1000;
private static readonly signalingReconnectMaxMs = 10 * 1000;
private static readonly systemMessageLifetimeMs = 5000;
private static readonly typingIndicatorLifetimeMs = 1800;
private static readonly typingIdleMs = 1200;
@@ -114,6 +122,7 @@ export class ChatSessionService {
readonly messages = signal<ChatEntry[]>([]);
readonly unreadPeerIds = signal<string[]>([]);
readonly typingPeerIds = signal<string[]>([]);
readonly remoteVideoModalPeerId = signal<string | null>(null);
readonly signalingState = signal<ConnectionState>('disconnected');
readonly status = signal('Disconnected from signaling server.');
readonly error = signal<string | null>(null);
@@ -148,6 +157,14 @@ export class ChatSessionService {
private readonly outgoingTypingIdleTimeouts = new Map<string, number>();
private readonly outgoingTypingStates = new Map<string, { active: boolean; lastSentAt: number }>();
private readonly messageStoreOperations = new Map<string, Promise<void>>();
private readonly pendingImageGenerationRequests = new Map<string, { peerId: string; prompt: string }>();
private readonly remoteVideoStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]);
private readonly activeCameraPeerId = signal<string | null>(null);
private sessionKeepaliveIntervalId: number | null = null;
private websocketHeartbeatIntervalId: number | null = null;
private websocketReconnectTimeoutId: number | null = null;
private websocketReconnectAttempt = 0;
private suppressSocketReconnect = false;
private messageEncryptionKey: CryptoKey | null = null;
private messageDatabasePromise: Promise<IDBDatabase | null> | null = null;
private websocket: WebSocket | null = null;
@@ -325,14 +342,107 @@ export class ChatSessionService {
this.patchPeer(peerId, { connectionState: 'connecting', channelState: 'connecting' });
this.addSystemMessage(peerId, 'Opening WebRTC data channel.');
await this.negotiatePeer(peerId, bundle);
}
const offer = await bundle.pc.createOffer();
await bundle.pc.setLocalDescription(offer);
async startCameraStream(peerId: string): Promise<void> {
if (typeof navigator === 'undefined' || typeof navigator.mediaDevices?.getUserMedia !== 'function') {
this.error.set('This browser does not support webcam capture.');
return;
}
this.sendSignal(peerId, {
type: 'sdp',
description: bundle.pc.localDescription!.toJSON(),
});
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
this.error.set('You must be connected to signaling before starting webcam capture.');
return;
}
const activeCameraPeerId = this.activeCameraPeerId();
if (activeCameraPeerId && activeCameraPeerId !== peerId) {
await this.stopCameraStream(activeCameraPeerId);
}
const bundle = this.ensurePeerBundle(peerId, true);
if (bundle.localCameraStream) {
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false,
});
bundle.localCameraStream = stream;
bundle.cameraSenders = stream.getTracks().map((track) => {
track.onended = () => {
void this.stopCameraStream(peerId, false);
};
return bundle.pc.addTrack(track, stream);
});
this.activeCameraPeerId.set(peerId);
this.sendCameraState(peerId, true);
this.addSystemMessage(peerId, 'Sharing webcam capture.');
await this.negotiatePeer(peerId, bundle);
} catch {
this.error.set('Could not start webcam capture.');
}
}
async stopCameraStream(peerId: string, notifyPeer = true): Promise<void> {
const bundle = this.peerBundles.get(peerId);
if (!bundle?.localCameraStream && this.activeCameraPeerId() !== peerId) {
return;
}
if (bundle) {
for (const sender of bundle.cameraSenders) {
bundle.pc.removeTrack(sender);
}
bundle.cameraSenders = [];
if (bundle.localCameraStream) {
for (const track of bundle.localCameraStream.getTracks()) {
track.onended = null;
track.stop();
}
}
bundle.localCameraStream = undefined;
}
if (this.activeCameraPeerId() === peerId) {
this.activeCameraPeerId.set(null);
}
if (notifyPeer) {
this.sendCameraState(peerId, false);
}
this.addSystemMessage(peerId, 'Stopped webcam capture.');
if (bundle) {
await this.negotiatePeer(peerId, bundle);
}
}
isStreamingCameraToPeer(peerId: string): boolean {
return this.activeCameraPeerId() === peerId;
}
remoteVideoStreamForPeer(peerId: string): MediaStream | null {
return this.remoteVideoStreams().find((entry) => entry.peerId === peerId)?.stream ?? null;
}
dismissRemoteVideoModal(peerId: string): void {
if (this.remoteVideoModalPeerId() === peerId) {
this.remoteVideoModalPeerId.set(null);
}
}
async registerAccessKey(label: string): Promise<void> {
@@ -397,26 +507,7 @@ export class ChatSessionService {
return;
}
const envelope: DataEnvelope = {
type: 'text',
id: crypto.randomUUID(),
body: trimmed,
authorId: this.currentUser()!.id,
authorName: this.currentUser()!.displayName,
sentAt: Date.now(),
};
channel.send(JSON.stringify(envelope));
this.sendTypingState(peerId, false);
this.pushMessage({
id: envelope.id,
peerId,
direction: 'outgoing',
kind: 'text',
createdAt: envelope.sentAt,
authorLabel: 'You',
text: trimmed,
});
this.sendTextEnvelope(peerId, channel, trimmed);
}
async sendJson(peerId: string, rawPayload: string): Promise<void> {
@@ -439,25 +530,7 @@ export class ChatSessionService {
return;
}
const envelope: DataEnvelope = {
type: 'json',
id: crypto.randomUUID(),
body: parsedPayload,
authorId: this.currentUser()!.id,
authorName: this.currentUser()!.displayName,
sentAt: Date.now(),
};
channel.send(JSON.stringify(envelope));
this.pushMessage({
id: envelope.id,
peerId,
direction: 'outgoing',
kind: 'json',
createdAt: envelope.sentAt,
authorLabel: 'You',
payload: parsedPayload,
});
this.sendJsonEnvelope(peerId, channel, parsedPayload);
}
async sendFile(peerId: string, file: File): Promise<void> {
@@ -506,6 +579,51 @@ export class ChatSessionService {
}, file);
}
async forwardMessage(targetPeerId: string, entry: ChatEntry): Promise<void> {
if (entry.kind === 'system' || entry.direction === 'system') {
return;
}
const channel = await this.ensureOpenChannel(targetPeerId);
if (!channel) {
return;
}
switch (entry.kind) {
case 'text':
if (!entry.text) {
return;
}
this.sendTextEnvelope(targetPeerId, channel, entry.text);
return;
case 'json':
this.sendJsonEnvelope(targetPeerId, channel, entry.payload);
return;
case 'file':
if (!entry.downloadUrl) {
this.error.set('This file cannot be forwarded because its data is unavailable.');
return;
}
try {
const response = await fetch(entry.downloadUrl);
const blob = await response.blob();
const file = new File([blob], entry.fileName || 'attachment', {
type: entry.fileMimeType || blob.type || 'application/octet-stream',
});
await this.sendFile(targetPeerId, file);
} catch {
this.error.set('Could not forward this file.');
}
return;
default:
return;
}
}
private async authenticate(path: string, payload: Record<string, unknown>): Promise<void> {
this.error.set(null);
this.notice.set(null);
@@ -533,9 +651,61 @@ export class ChatSessionService {
this.status.set(`Authenticated as ${response.user.displayName}.`);
await this.loadPersistedMessages(response.user.id);
await this.loadAccessKeys();
this.startSessionKeepalive();
await this.connectWebSocket();
}
private sendTextEnvelope(peerId: string, channel: RTCDataChannel, text: string): void {
const trimmed = text.trim();
if (!trimmed) {
return;
}
const envelope: DataEnvelope = {
type: 'text',
id: crypto.randomUUID(),
body: trimmed,
authorId: this.currentUser()!.id,
authorName: this.currentUser()!.displayName,
sentAt: Date.now(),
};
channel.send(JSON.stringify(envelope));
this.sendTypingState(peerId, false);
this.pushMessage({
id: envelope.id,
peerId,
direction: 'outgoing',
kind: 'text',
createdAt: envelope.sentAt,
authorLabel: 'You',
text: trimmed,
});
}
private sendJsonEnvelope(peerId: string, channel: RTCDataChannel, payload: unknown): void {
const envelope: DataEnvelope = {
type: 'json',
id: crypto.randomUUID(),
body: payload,
authorId: this.currentUser()!.id,
authorName: this.currentUser()!.displayName,
sentAt: Date.now(),
};
channel.send(JSON.stringify(envelope));
this.pushMessage({
id: envelope.id,
peerId,
direction: 'outgoing',
kind: 'json',
createdAt: envelope.sentAt,
authorLabel: 'You',
payload,
});
}
async loadPendingApprovalUsers(): Promise<PendingApprovalUser[]> {
const token = this.token();
@@ -568,6 +738,69 @@ export class ChatSessionService {
);
}
async loadAdminUsers(): Promise<AdminUserSummary[]> {
const token = this.token();
if (!token) {
throw new Error('Authentication required.');
}
const response = await firstValueFrom(
this.http.get<{ users: AdminUserSummary[] }>(`${this.serverUrl()}/api/admin/users`, {
headers: { Authorization: `Bearer ${token}` },
}),
);
return response.users;
}
async deleteUserAccount(userId: string): Promise<void> {
const token = this.token();
if (!token) {
throw new Error('Authentication required.');
}
await firstValueFrom(
this.http.delete(`${this.serverUrl()}/api/admin/users/${encodeURIComponent(userId)}`, {
headers: { Authorization: `Bearer ${token}` },
}),
);
if (this.currentUser()?.id === userId) {
this.clearLocalAuth('User deleted.');
}
}
async requestGeneratedImage(peerId: string, prompt: string): Promise<void> {
const trimmedPrompt = prompt.trim();
if (!trimmedPrompt) {
this.error.set('Enter a text prompt before requesting an image.');
return;
}
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
this.error.set('You must be connected to signaling before requesting an image.');
return;
}
const requestId = crypto.randomUUID();
this.pendingImageGenerationRequests.set(requestId, {
peerId,
prompt: trimmedPrompt,
});
this.error.set(null);
this.addSystemMessage(peerId, 'Generating image from prompt.');
this.websocket.send(JSON.stringify({
type: 'image-generation',
requestId,
peerId,
prompt: trimmedPrompt,
}));
}
private async loadAccessKeys(): Promise<void> {
const token = this.token();
@@ -596,6 +829,7 @@ export class ChatSessionService {
return;
}
this.clearWebSocketReconnect();
this.disconnectWebSocket();
this.resetPeerConnections();
@@ -607,32 +841,65 @@ export class ChatSessionService {
this.websocket = websocket;
websocket.onopen = () => {
if (this.websocket !== websocket) {
return;
}
this.websocketReconnectAttempt = 0;
this.startWebSocketHeartbeat(websocket);
this.signalingState.set('connected');
this.status.set('Connected to signaling server.');
};
websocket.onmessage = (event) => {
if (this.websocket !== websocket) {
return;
}
const message = JSON.parse(event.data) as ServerEvent;
void this.handleServerEvent(message);
};
websocket.onerror = () => {
if (this.websocket !== websocket) {
return;
}
this.signalingState.set('failed');
this.error.set('The signaling socket encountered an error.');
};
websocket.onclose = () => {
const shouldReconnect = this.websocket === websocket && !this.suppressSocketReconnect;
this.stopWebSocketHeartbeat();
this.signalingState.set('disconnected');
this.status.set('Signaling connection closed.');
this.websocket = null;
if (this.websocket === websocket) {
this.websocket = null;
}
this.peers.update((peers) =>
peers.map((peer) => ({ ...peer, connectionState: 'disconnected', channelState: 'closed' })),
);
if (this.suppressSocketReconnect) {
this.suppressSocketReconnect = false;
return;
}
if (shouldReconnect) {
this.scheduleWebSocketReconnect();
}
};
}
private disconnectWebSocket(): void {
this.stopWebSocketHeartbeat();
if (this.websocket) {
this.suppressSocketReconnect = true;
this.websocket.close();
this.websocket = null;
}
@@ -652,8 +919,9 @@ export class ChatSessionService {
case 'peer-left':
this.releasePeerBundle(event.peerId, false);
this.peers.update((peers) => peers.filter((peer) => peer.id !== event.peerId));
this.clearUnreadPeer(event.peerId);
this.clearPeerTyping(event.peerId);
this.clearUnreadPeer(event.peerId);
this.clearPeerTyping(event.peerId);
this.clearRemoteVideoState(event.peerId);
if (this.activePeerId() === event.peerId) {
this.activePeerId.set(this.peers()[0]?.id ?? null);
}
@@ -662,6 +930,14 @@ export class ChatSessionService {
case 'signal':
await this.handleSignal(event.from, event.signal);
break;
case 'image-generated':
this.handleGeneratedImage(event);
break;
case 'image-generation-error':
this.handleGeneratedImageError(event);
break;
case 'pong':
break;
case 'error':
this.error.set(event.message);
if (/auth|session/i.test(event.message)) {
@@ -671,6 +947,44 @@ export class ChatSessionService {
}
}
private handleGeneratedImage(event: Extract<ServerEvent, { type: 'image-generated' }>): void {
const pendingRequest = this.pendingImageGenerationRequests.get(event.requestId);
if (pendingRequest) {
this.pendingImageGenerationRequests.delete(event.requestId);
}
const peerId = pendingRequest?.peerId ?? event.peerId;
const imageBlob = this.base64ToBlob(event.imageBase64, event.mimeType);
const extension = this.fileExtensionForMimeType(event.mimeType);
const fileName = `generated-image-${event.requestId.slice(0, 8)}.${extension}`;
this.pushMessage({
id: event.requestId,
peerId,
direction: 'outgoing',
kind: 'file',
createdAt: event.createdAt,
authorLabel: 'You',
text: pendingRequest?.prompt ?? event.prompt,
fileName,
fileSize: imageBlob.size,
fileMimeType: event.mimeType,
downloadUrl: URL.createObjectURL(imageBlob),
}, imageBlob);
}
private handleGeneratedImageError(event: Extract<ServerEvent, { type: 'image-generation-error' }>): void {
const pendingRequest = this.pendingImageGenerationRequests.get(event.requestId);
if (pendingRequest) {
this.pendingImageGenerationRequests.delete(event.requestId);
this.addSystemMessage(pendingRequest.peerId, 'Image generation failed.');
}
this.error.set(event.message);
}
private async restoreSession(): Promise<void> {
const token = this.token();
@@ -694,12 +1008,108 @@ export class ChatSessionService {
this.writeStorage('privatechat.user', JSON.stringify(response.user));
await this.loadPersistedMessages(response.user.id);
await this.loadAccessKeys();
this.startSessionKeepalive();
await this.connectWebSocket();
} catch {
this.clearLocalAuth('Saved session expired. Sign in again.');
}
}
private startSessionKeepalive(): void {
this.stopSessionKeepalive();
if (typeof window === 'undefined' || !this.token()) {
return;
}
this.sessionKeepaliveIntervalId = window.setInterval(() => {
void this.refreshSessionLease();
}, ChatSessionService.sessionKeepaliveMs);
}
private stopSessionKeepalive(): void {
if (this.sessionKeepaliveIntervalId === null || typeof window === 'undefined') {
return;
}
window.clearInterval(this.sessionKeepaliveIntervalId);
this.sessionKeepaliveIntervalId = null;
}
private async refreshSessionLease(): Promise<void> {
const token = this.token();
if (!token) {
this.stopSessionKeepalive();
return;
}
try {
await firstValueFrom(
this.http.get<SessionResponse>(`${this.serverUrl()}/api/auth/session`, {
headers: { Authorization: `Bearer ${token}` },
}),
);
} catch (error) {
if (error instanceof HttpErrorResponse && (error.status === 401 || error.status === 403)) {
this.clearLocalAuth('Session expired. Sign in again.');
}
}
}
private startWebSocketHeartbeat(websocket: WebSocket): void {
this.stopWebSocketHeartbeat();
if (typeof window === 'undefined') {
return;
}
this.websocketHeartbeatIntervalId = window.setInterval(() => {
if (this.websocket !== websocket || websocket.readyState !== WebSocket.OPEN) {
this.stopWebSocketHeartbeat();
return;
}
websocket.send(JSON.stringify({ type: 'ping' }));
}, ChatSessionService.signalingHeartbeatMs);
}
private stopWebSocketHeartbeat(): void {
if (this.websocketHeartbeatIntervalId === null || typeof window === 'undefined') {
return;
}
window.clearInterval(this.websocketHeartbeatIntervalId);
this.websocketHeartbeatIntervalId = null;
}
private scheduleWebSocketReconnect(): void {
if (typeof window === 'undefined' || this.websocketReconnectTimeoutId !== null || !this.token() || !this.currentUser()) {
return;
}
const delay = Math.min(
ChatSessionService.signalingReconnectBaseMs * 2 ** this.websocketReconnectAttempt,
ChatSessionService.signalingReconnectMaxMs,
);
this.websocketReconnectAttempt += 1;
this.status.set(`Reconnecting to signaling server in ${Math.round(delay / 1000)}s.`);
this.websocketReconnectTimeoutId = window.setTimeout(() => {
this.websocketReconnectTimeoutId = null;
void this.connectWebSocket();
}, delay);
}
private clearWebSocketReconnect(): void {
if (this.websocketReconnectTimeoutId === null || typeof window === 'undefined') {
return;
}
window.clearTimeout(this.websocketReconnectTimeoutId);
this.websocketReconnectTimeoutId = null;
}
private mergePresence(peers: Array<UserProfile | PeerSummary>): void {
const previous = new Map(this.peers().map((peer) => [peer.id, peer]));
@@ -792,6 +1202,8 @@ export class ChatSessionService {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
}),
pendingCandidates: [],
pendingNegotiation: false,
cameraSenders: [],
};
bundle.pc.onicecandidate = (event) => {
@@ -816,10 +1228,44 @@ export class ChatSessionService {
}
};
bundle.pc.onsignalingstatechange = () => {
if (bundle.pc.signalingState === 'stable' && bundle.pendingNegotiation) {
bundle.pendingNegotiation = false;
void this.negotiatePeer(peerId, bundle);
}
};
bundle.pc.ondatachannel = (event) => {
this.attachDataChannel(peerId, event.channel, bundle);
};
bundle.pc.ontrack = (event) => {
const [stream] = event.streams;
const remoteStream = stream ?? bundle.remoteCameraStream ?? new MediaStream();
if (!stream) {
remoteStream.addTrack(event.track);
}
bundle.remoteCameraStream = remoteStream;
this.upsertRemoteVideoStream(peerId, remoteStream);
this.remoteVideoModalPeerId.set(peerId);
event.track.onended = () => {
if (!bundle.remoteCameraStream) {
return;
}
const remainingLiveTracks = bundle.remoteCameraStream
.getVideoTracks()
.filter((track) => track.readyState === 'live' && track !== event.track);
if (remainingLiveTracks.length === 0) {
this.clearRemoteVideoState(peerId);
}
};
};
if (initiator) {
const channel = bundle.pc.createDataChannel('privatechat');
this.attachDataChannel(peerId, channel, bundle);
@@ -904,6 +1350,13 @@ export class ChatSessionService {
case 'typing':
this.setPeerTyping(peerId, envelope.active);
break;
case 'camera-state':
if (envelope.active) {
this.remoteVideoModalPeerId.set(peerId);
} else {
this.clearRemoteVideoState(peerId);
}
break;
}
}
@@ -956,6 +1409,35 @@ export class ChatSessionService {
}
}
private async negotiatePeer(peerId: string, bundle: PeerBundle): Promise<void> {
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
return;
}
if (bundle.pc.signalingState !== 'stable') {
bundle.pendingNegotiation = true;
return;
}
const offer = await bundle.pc.createOffer();
await bundle.pc.setLocalDescription(offer);
this.sendSignal(peerId, {
type: 'sdp',
description: bundle.pc.localDescription!.toJSON(),
});
}
private sendCameraState(peerId: string, active: boolean): void {
const channel = this.peerBundles.get(peerId)?.channel;
if (!channel || channel.readyState !== 'open') {
return;
}
channel.send(JSON.stringify({ type: 'camera-state', active } satisfies DataEnvelope));
}
private sendSignal(peerId: string, signal: SignalPayload): void {
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
return;
@@ -975,22 +1457,63 @@ export class ChatSessionService {
return channel;
}
private async ensureOpenChannel(peerId: string): Promise<RTCDataChannel | null> {
const openChannel = this.requireOpenChannel(peerId);
if (openChannel) {
return openChannel;
}
await this.connectToPeer(peerId);
return this.waitForOpenChannel(peerId);
}
private async waitForBufferedAmount(channel: RTCDataChannel, threshold: number): Promise<void> {
while (channel.bufferedAmount > threshold) {
await new Promise((resolve) => window.setTimeout(resolve, 25));
}
}
private async waitForOpenChannel(peerId: string, timeoutMs = 8000): Promise<RTCDataChannel | null> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const channel = this.peerBundles.get(peerId)?.channel;
if (channel?.readyState === 'open') {
return channel;
}
await new Promise((resolve) => window.setTimeout(resolve, 100));
}
this.error.set('Could not open a peer channel for forwarding.');
return null;
}
private releasePeerBundle(peerId: string, preservePeerState: boolean): void {
const bundle = this.peerBundles.get(peerId);
this.clearPeerTyping(peerId);
this.clearOutgoingTyping(peerId);
this.clearRemoteVideoState(peerId);
if (!bundle) {
return;
}
if (bundle.localCameraStream) {
for (const track of bundle.localCameraStream.getTracks()) {
track.onended = null;
track.stop();
}
}
if (this.activeCameraPeerId() === peerId) {
this.activeCameraPeerId.set(null);
}
bundle.channel?.close();
bundle.pc.close();
this.peerBundles.delete(peerId);
@@ -1143,10 +1666,16 @@ export class ChatSessionService {
}
private clearLocalAuth(statusMessage: string): void {
this.clearWebSocketReconnect();
this.disconnectWebSocket();
this.resetPeerConnections();
this.stopSessionKeepalive();
this.clearSystemMessageTimeouts();
this.clearTypingTimeouts();
this.pendingImageGenerationRequests.clear();
this.remoteVideoStreams.set([]);
this.remoteVideoModalPeerId.set(null);
this.activeCameraPeerId.set(null);
this.messageEncryptionKey = null;
this.revokeMessageDownloads(this.messages());
this.currentUser.set(null);
@@ -1576,6 +2105,28 @@ export class ChatSessionService {
this.unreadPeerIds.update((peerIds) => peerIds.filter((id) => id !== peerId));
}
private upsertRemoteVideoStream(peerId: string, stream: MediaStream): void {
this.remoteVideoStreams.update((entries) => {
const existingIndex = entries.findIndex((entry) => entry.peerId === peerId);
if (existingIndex === -1) {
return [...entries, { peerId, stream }];
}
const nextEntries = [...entries];
nextEntries[existingIndex] = { peerId, stream };
return nextEntries;
});
}
private clearRemoteVideoState(peerId: string): void {
this.remoteVideoStreams.update((entries) => entries.filter((entry) => entry.peerId !== peerId));
if (this.remoteVideoModalPeerId() === peerId) {
this.remoteVideoModalPeerId.set(null);
}
}
private setPeerTyping(peerId: string, active: boolean): void {
const existingTimeoutId = this.typingIndicatorTimeouts.get(peerId);
@@ -1694,6 +2245,32 @@ export class ChatSessionService {
});
}
private base64ToBlob(value: string, mimeType: string): Blob {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes], { type: mimeType });
}
private fileExtensionForMimeType(mimeType: string): string {
switch (mimeType) {
case 'image/png':
return 'png';
case 'image/jpeg':
return 'jpg';
case 'image/webp':
return 'webp';
case 'image/gif':
return 'gif';
default:
return 'bin';
}
}
private toWebSocketUrl(httpUrl: string, token: string): string {
const normalized = new URL(httpUrl);
normalized.protocol = normalized.protocol === 'https:' ? 'wss:' : 'ws:';

View File

@@ -261,6 +261,63 @@
}
</div>
</section>
@if (session.isApprovalAdmin()) {
<section class="access-key-panel mt-4">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<h3 class="h5 mb-1">User administration</h3>
<p class="small text-secondary mb-0">Delete any user account directly from SQLite.</p>
</div>
<button
class="btn btn-sm btn-outline-light"
type="button"
[disabled]="loadingAdminUsers()"
(click)="reloadAdminUsers()"
>
Refresh
</button>
</div>
@if (adminUsersError()) {
<div class="alert alert-danger mb-3">{{ adminUsersError() }}</div>
}
<div class="d-grid gap-2">
@if (loadingAdminUsers()) {
<div class="empty-state p-3 text-center text-secondary">Loading users...</div>
} @else if (adminUsers().length === 0) {
<div class="empty-state p-3 text-center text-secondary">No users found.</div>
} @else {
@for (user of adminUsers(); track user.id) {
<article class="access-key-card p-3">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<div class="fw-semibold">{{ user.displayName }}</div>
<div class="small text-secondary">@{{ user.username }}</div>
<div class="small text-secondary">
{{ user.isActive ? 'Approved' : 'Pending approval' }}
@if (user.approvedAt) {
· {{ user.approvedAt | date: 'short' }}
}
</div>
<div class="small text-secondary">Created: {{ user.createdAt | date: 'medium' }}</div>
</div>
<button
class="btn btn-sm btn-outline-danger"
type="button"
[disabled]="deletingUserId() === user.id"
(click)="deleteUser(user)"
>
{{ deletingUserId() === user.id ? 'Deleting...' : 'Delete user' }}
</button>
</div>
</article>
}
}
</div>
</section>
}
</div>
</div>
</section>

View File

@@ -1,9 +1,10 @@
import { CommonModule } from '@angular/common';
import { Component, effect, inject } from '@angular/core';
import { Component, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { ChatSessionService } from './chat-session.service';
import type { AdminUserSummary } from './models';
import { ThemeService } from './theme.service';
@Component({
@@ -23,6 +24,10 @@ export class HomePageComponent {
username = '';
password = '';
accessKeyLabel = '';
readonly adminUsers = signal<AdminUserSummary[]>([]);
readonly loadingAdminUsers = signal(false);
readonly deletingUserId = signal<string | null>(null);
readonly adminUsersError = signal<string | null>(null);
constructor(readonly session: ChatSessionService) {
this.serverUrl = session.serverUrl();
@@ -39,6 +44,19 @@ export class HomePageComponent {
void this.router.navigate(['/chat', activePeerId], { replaceUrl: true });
});
}
effect(() => {
const currentUser = this.session.currentUser();
if (!currentUser || !this.session.isApprovalAdmin()) {
this.adminUsers.set([]);
this.adminUsersError.set(null);
this.loadingAdminUsers.set(false);
return;
}
void this.reloadAdminUsers();
});
}
async submitAuth(): Promise<void> {
@@ -80,6 +98,44 @@ export class HomePageComponent {
this.accessKeyLabel = '';
}
async reloadAdminUsers(): Promise<void> {
this.loadingAdminUsers.set(true);
this.adminUsersError.set(null);
try {
this.adminUsers.set(await this.session.loadAdminUsers());
} catch (error) {
this.adminUsersError.set(
error instanceof Error ? error.message : 'Could not load users.',
);
} finally {
this.loadingAdminUsers.set(false);
}
}
async deleteUser(user: AdminUserSummary): Promise<void> {
if (
typeof window !== 'undefined' &&
!window.confirm(`Delete user ${user.username}? This removes the account from SQLite.`)
) {
return;
}
this.deletingUserId.set(user.id);
this.adminUsersError.set(null);
try {
await this.session.deleteUserAccount(user.id);
this.adminUsers.update((users) => users.filter((candidate) => candidate.id !== user.id));
} catch (error) {
this.adminUsersError.set(
error instanceof Error ? error.message : 'Could not delete that user.',
);
} finally {
this.deletingUserId.set(null);
}
}
async openChatUi(): Promise<void> {
const peerId = this.session.activePeerId() ?? this.session.peers()[0]?.id;

View File

@@ -1,11 +1,11 @@
:host {
display: block;
max-width: 95%;
max-width: min(95%, 320px);
}
.json-viewer-shell {
width: 95%;
max-width: 95%;
width: min(95%, 480px);
max-width: min(95%, 480px);
min-width: 0;
overflow: hidden;
border-radius: 0.9rem;

View File

@@ -35,6 +35,15 @@ export interface PendingApprovalUser {
createdAt: string;
}
export interface AdminUserSummary {
id: string;
username: string;
displayName: string;
isActive: boolean;
createdAt: string;
approvedAt: string | null;
}
export interface AccessKeySummary {
id: string;
credentialId: string;
@@ -105,6 +114,22 @@ export type ServerEvent =
| { type: 'peer-joined'; peer: UserProfile }
| { type: 'peer-left'; peerId: string }
| { type: 'signal'; from: string; signal: SignalPayload }
| {
type: 'image-generated';
requestId: string;
peerId: string;
prompt: string;
createdAt: number;
mimeType: string;
imageBase64: string;
}
| {
type: 'image-generation-error';
requestId: string;
peerId: string;
message: string;
}
| { type: 'pong' }
| { type: 'error'; message: string };
export type DataEnvelope =
@@ -141,4 +166,8 @@ export type DataEnvelope =
| {
type: 'typing';
active: boolean;
}
| {
type: 'camera-state';
active: boolean;
};

View File

@@ -0,0 +1,54 @@
:host {
display: contents;
}
.video-modal-backdrop {
position: fixed;
inset: 0;
z-index: 1200;
display: grid;
place-items: center;
padding: 1.5rem;
background: rgba(3, 8, 14, 0.72);
backdrop-filter: blur(10px);
}
.video-modal-card {
width: min(100%, 56rem);
border: 1px solid var(--surface-border);
border-radius: 1.5rem;
background: var(--panel-background);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
}
.video-modal-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: start;
padding: 1rem 1rem 0;
}
.video-modal-close {
width: 2.5rem;
height: 2.5rem;
border: 0;
border-radius: 999px;
color: var(--page-text);
background: var(--badge-background);
font-size: 1.35rem;
line-height: 1;
}
.video-modal-body {
padding: 1rem;
}
.video-modal-player {
width: 100%;
display: block;
border-radius: 1rem;
background: #000;
aspect-ratio: 16 / 9;
object-fit: cover;
}

View File

@@ -0,0 +1,86 @@
import { CommonModule } from '@angular/common';
import { AfterViewInit, Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, ViewChild } from '@angular/core';
@Component({
selector: 'app-peer-video-modal',
imports: [CommonModule],
template: `
@if (visible) {
<div class="video-modal-backdrop" (click)="requestClose()">
<section class="video-modal-card" (click)="$event.stopPropagation()">
<div class="video-modal-header">
<div>
<h2 class="h5 mb-1">{{ title }}</h2>
<p class="small mb-0">Live webcam capture from your peer.</p>
</div>
<button class="video-modal-close" type="button" (click)="requestClose()" aria-label="Close live video">
×
</button>
</div>
<div class="video-modal-body">
<video #videoElement class="video-modal-player" autoplay playsinline></video>
</div>
</section>
</div>
}
`,
styleUrl: './peer-video-modal.component.scss',
})
export class PeerVideoModalComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input() visible = false;
@Input() stream: MediaStream | null = null;
@Input() title = 'Live webcam';
@Output() readonly closeRequested = new EventEmitter<void>();
@ViewChild('videoElement')
set videoElementRef(value: ElementRef<HTMLVideoElement> | undefined) {
this.videoElement = value;
this.syncVideoSource();
}
private videoElement?: ElementRef<HTMLVideoElement>;
ngAfterViewInit(): void {
this.syncVideoSource();
}
ngOnChanges(): void {
this.syncVideoSource();
}
ngOnDestroy(): void {
this.detachVideoSource();
}
requestClose(): void {
this.closeRequested.emit();
}
private syncVideoSource(): void {
const video = this.videoElement?.nativeElement;
if (!video) {
return;
}
video.muted = true;
video.srcObject = this.visible ? this.stream : null;
if (this.visible && this.stream) {
void video.play().catch(() => {
// Autoplay may be delayed until user interaction depending on platform policy.
});
}
}
private detachVideoSource(): void {
const video = this.videoElement?.nativeElement;
if (!video) {
return;
}
video.pause();
video.srcObject = null;
}
}