Many new functionalities
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
window.__PRIVATECHAT_ENV__ = {
|
||||
"PRIVATECHAT_CLIENT_SERVER_URL": "http://chatter.dubertrand.fr"
|
||||
"PRIVATECHAT_CLIENT_SERVER_URL": "https://chatter.dubertrand.fr"
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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:';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
54
client/src/app/peer-video-modal.component.scss
Normal file
54
client/src/app/peer-video-modal.component.scss
Normal 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;
|
||||
}
|
||||
86
client/src/app/peer-video-modal.component.ts
Normal file
86
client/src/app/peer-video-modal.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user