3 Commits
3.0 ... 3.5

Author SHA1 Message Date
11cc5350c8 documents preview - image 2026-03-11 09:40:03 +01:00
0e4c79b735 documents preview 2026-03-11 09:09:15 +01:00
ffdea4fe62 video call 2026-03-11 08:05:54 +01:00
15 changed files with 1232 additions and 419 deletions

View File

@@ -15,6 +15,7 @@
"@angular/platform-browser": "^21.2.0", "@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0", "@angular/router": "^21.2.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"ngx-extended-pdf-viewer": "^25.6.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },
@@ -5960,6 +5961,19 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/ngx-extended-pdf-viewer": {
"version": "25.6.4",
"resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-25.6.4.tgz",
"integrity": "sha512-eYIiWzatcupB7HKDtcOOZN7gcLFjqAkeIAlZOMIO6XyUJnTe+PUZLZGit/19mtO/8fAaH41lMyyh8MAcU8NAhA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": ">=17.0.0 <22.0.0",
"@angular/core": ">=17.0.0 <22.0.0"
}
},
"node_modules/node-addon-api": { "node_modules/node-addon-api": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",

View File

@@ -19,6 +19,7 @@
"@angular/platform-browser": "^21.2.0", "@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0", "@angular/router": "^21.2.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"ngx-extended-pdf-viewer": "^25.6.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },

View File

@@ -1,37 +1,39 @@
<main class="chat-shell py-4"> <main class="chat-shell py-4">
<div class="container-lg"> <div class="container-lg">
<section class="chat-page panel p-3 p-lg-4"> <section class="chat-page panel p-3 p-lg-4">
<app-peer-video-modal <app-peer-call-modal
[visible]="remoteVideoModalVisible()" [visible]="callModalVisible()"
[stream]="remoteVideoStream()" [peerName]="callModalPeer()?.displayName ?? 'Peer'"
[title]="(peer()?.displayName ?? 'Peer') + ' webcam'" [callState]="callModalState()"
(closeRequested)="closeRemoteVideoModal()" [callMode]="callModalMode()"
></app-peer-video-modal> [statusText]="callModalStatusText()"
[localStream]="localCallStream()"
[remoteStream]="remoteCallVideoStream()"
(acceptRequested)="callModalPeer() && acceptIncomingVoiceCall(callModalPeer()!.id)"
(rejectRequested)="callModalPeer() && rejectIncomingVoiceCall(callModalPeer()!.id)"
(hangupRequested)="callModalPeer() && endVoiceCall(callModalPeer()!.id)"
></app-peer-call-modal>
<audio #callAudioElement hidden autoplay playsinline></audio> <audio #callAudioElement hidden autoplay playsinline></audio>
@if (incomingVoiceCallPeer(); as callingPeer) { @if (callChoicePeer(); as selectedCallPeer) {
<div class="call-modal-backdrop"> <div class="call-choice-backdrop" (click)="closeCallChoice()">
<section class="panel p-4" style="width:min(100%,24rem)" (click)="$event.stopPropagation()"> <section class="call-choice-card panel p-4" (click)="$event.stopPropagation()">
<div class="mb-3"> <p class="call-choice-eyebrow">Start a call</p>
<div> <h2 class="h5 mb-2">{{ selectedCallPeer.displayName }}</h2>
<h2 class="h5 mb-1">Incoming voice call</h2> <p class="small mb-3">Choose whether to place a full video call or audio only.</p>
<p class="small mb-0">{{ callingPeer.displayName }} is calling you.</p> <div class="call-choice-actions">
</div> <button class="call-choice-button" type="button" (click)="startSelectedCall('video')">
</div> <span class="call-choice-icon">📹</span>
<div class="d-flex flex-wrap gap-2 justify-content-end"> <span>Video call</span>
<button
class="btn btn-success"
type="button"
(click)="acceptIncomingVoiceCall(callingPeer.id)"
>
Accept
</button> </button>
<button <button class="call-choice-button" type="button" (click)="startSelectedCall('audio')">
class="btn btn-outline-secondary" <span class="call-choice-icon">🎙️</span>
type="button" <span>Audio only</span>
(click)="rejectIncomingVoiceCall(callingPeer.id)" </button>
> </div>
Reject <div class="d-flex justify-content-end mt-3">
<button class="btn btn-outline-secondary" type="button" (click)="closeCallChoice()">
Cancel
</button> </button>
</div> </div>
</section> </section>
@@ -127,7 +129,7 @@
</aside> </aside>
<div class="chat-main"> <div class="chat-main">
<div class="conversation"> <div #conversationContainer class="conversation">
@if (conversation().length === 0) { @if (conversation().length === 0) {
<div class="empty-chat"> <div class="empty-chat">
No text messages yet. The chat page is ready as soon as the peer channel opens. No text messages yet. The chat page is ready as soon as the peer channel opens.
@@ -143,6 +145,17 @@
> >
@if (entry.direction !== 'system') { @if (entry.direction !== 'system') {
<div class="bubble-actions"> <div class="bubble-actions">
@if (isGeneratedImageEntry(entry)) {
<button
class="bubble-action"
type="button"
(click)="sendGeneratedImage(entry)"
title="Send image to peer"
aria-label="Send image to peer"
>
📤
</button>
}
<button <button
class="bubble-action" class="bubble-action"
type="button" type="button"
@@ -221,6 +234,17 @@
@if (entry.downloadUrl) { @if (entry.downloadUrl) {
<a class="bubble-download" [href]="entry.downloadUrl" [download]="entry.fileName">Download</a> <a class="bubble-download" [href]="entry.downloadUrl" [download]="entry.fileName">Download</a>
} }
@if (hasDocumentPreviewImage(entry)) {
<div class="bubble-preview">
<div class="bubble-preview-label">Preview</div>
<img
class="bubble-preview-image"
[src]="documentPreviewImageUrl(entry)"
[alt]="entry.fileName || 'Document preview'"
/>
</div>
}
</div> </div>
} }
@case ('voice') { @case ('voice') {
@@ -272,9 +296,9 @@
class="composer-call" class="composer-call"
type="button" type="button"
[disabled]="!canStartSelectedVoiceCall()" [disabled]="!canStartSelectedVoiceCall()"
(click)="startVoiceCall(selectedPeer.id)" (click)="openCallChoice(selectedPeer.id)"
title="Start voice call" title="Start call"
aria-label="Start voice call" aria-label="Start call"
> >
📞 📞
</button> </button>
@@ -284,24 +308,13 @@
class="composer-hangup" class="composer-hangup"
type="button" type="button"
(click)="endVoiceCall(selectedPeer.id)" (click)="endVoiceCall(selectedPeer.id)"
title="End voice call" title="End call"
aria-label="End voice call" aria-label="End call"
> >
🛑 🛑
</button> </button>
} }
<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>
<button <button
class="composer-voice" class="composer-voice"
type="button" type="button"

View File

@@ -33,6 +33,63 @@
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
} }
.call-choice-backdrop {
position: fixed;
inset: 0;
z-index: 1240;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(3, 8, 14, 0.46);
backdrop-filter: blur(6px);
}
.call-choice-card {
width: min(100%, 25rem);
}
.call-choice-eyebrow {
margin-bottom: 0.45rem;
font-size: 0.78rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--page-text-soft);
}
.call-choice-actions {
display: grid;
gap: 0.85rem;
}
.call-choice-button {
display: flex;
align-items: center;
gap: 0.85rem;
width: 100%;
padding: 1rem 1.1rem;
border: 1px solid var(--surface-border);
border-radius: 1rem;
color: var(--page-text);
background: var(--surface-background);
text-align: left;
}
.call-choice-button:hover,
.call-choice-button:focus-visible {
border-color: color-mix(in srgb, var(--accent-color) 35%, transparent);
background: var(--surface-hover-background);
}
.call-choice-icon {
display: inline-grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 999px;
background: var(--badge-background);
font-size: 1.2rem;
}
.back-link { .back-link {
color: var(--link-color); color: var(--link-color);
text-decoration: none; text-decoration: none;
@@ -470,6 +527,27 @@
font-weight: 600; font-weight: 600;
} }
.bubble-preview {
display: grid;
gap: 0.45rem;
}
.bubble-preview-label {
font-size: 0.82rem;
font-weight: 600;
opacity: 0.78;
}
.bubble-preview-image {
display: block;
width: min(240px, 100%);
max-width: 100%;
height: auto;
border: 1px solid var(--surface-border);
border-radius: 1rem;
background: #fff;
}
.bubble-image, .bubble-image,
.bubble-video { .bubble-video {
width: 200px; width: 200px;

View File

@@ -4,14 +4,20 @@ import { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { PeerVideoModalComponent } from './peer-video-modal.component'; import { PeerCallModalComponent } from './peer-call-modal.component';
import { ChatSessionService } from './chat-session.service'; import { ChatSessionService } from './chat-session.service';
import { JsonFileViewerComponent } from './json-file-viewer.component'; import { JsonFileViewerComponent } from './json-file-viewer.component';
import type { ChatEntry, ConnectionState, PeerSummary } from './models'; import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models';
@Component({ @Component({
selector: 'app-chat-page', selector: 'app-chat-page',
imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent, PeerVideoModalComponent], imports: [
CommonModule,
FormsModule,
RouterLink,
JsonFileViewerComponent,
PeerCallModalComponent,
],
templateUrl: './chat-page.component.html', templateUrl: './chat-page.component.html',
styleUrl: './chat-page.component.scss', styleUrl: './chat-page.component.scss',
}) })
@@ -37,15 +43,22 @@ export class ChatPageComponent implements OnDestroy {
private dictationCompletionPromise: Promise<void> | null = null; private dictationCompletionPromise: Promise<void> | null = null;
private resolveDictationCompletion: (() => void) | null = null; private resolveDictationCompletion: (() => void) | null = null;
private dictationApplyToken = 0; private dictationApplyToken = 0;
private lastConversationSnapshot: { peerId: string; length: number; lastEntryId: string | null } | null = null;
@ViewChild('callAudioElement') @ViewChild('callAudioElement')
set callAudioElementRef(value: ElementRef<HTMLAudioElement> | undefined) { set callAudioElementRef(value: ElementRef<HTMLAudioElement> | undefined) {
this.callAudioElement = value; this.callAudioElement = value;
this.syncCallAudioSource(); this.syncCallAudioSource();
} }
private callAudioElement?: ElementRef<HTMLAudioElement>; private callAudioElement?: ElementRef<HTMLAudioElement>;
@ViewChild('conversationContainer')
set conversationContainerRef(value: ElementRef<HTMLDivElement> | undefined) {
this.conversationContainer = value;
}
private conversationContainer?: ElementRef<HTMLDivElement>;
messageText = ''; messageText = '';
readonly forwardingEntryId = signal<string | null>(null); readonly forwardingEntryId = signal<string | null>(null);
readonly callChoicePeerId = signal<string | null>(null);
readonly emojiPickerOpen = signal(false); readonly emojiPickerOpen = signal(false);
readonly isRecordingVoice = signal(false); readonly isRecordingVoice = signal(false);
readonly isDictating = signal(false); readonly isDictating = signal(false);
@@ -63,8 +76,19 @@ export class ChatPageComponent implements OnDestroy {
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? ''); readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null); readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
readonly currentUser = computed(() => this.session.currentUser()); readonly currentUser = computed(() => this.session.currentUser());
readonly incomingVoiceCallPeer = computed(() => { readonly callModalPeerId = computed(() =>
const peerId = this.session.incomingVoiceCallPeerId(); this.session.activeVoiceCallPeerId()
?? this.session.incomingVoiceCallPeerId()
?? this.session.outgoingVoiceCallPeerId()
?? null,
);
readonly callModalPeer = computed(() => {
const peerId = this.callModalPeerId();
return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null;
});
readonly callChoicePeer = computed(() => {
const peerId = this.callChoicePeerId();
return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null; return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null;
}); });
@@ -73,13 +97,50 @@ export class ChatPageComponent implements OnDestroy {
.messages() .messages()
.filter((entry) => entry.peerId === this.peerId()), .filter((entry) => entry.peerId === this.peerId()),
); );
readonly remoteVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.peerId()));
readonly remoteCallAudioStream = computed(() => readonly remoteCallAudioStream = computed(() =>
this.session.remoteAudioStreamForPeer(this.session.activeVoiceCallPeerId() ?? ''), this.session.remoteAudioStreamForPeer(this.callModalPeerId() ?? ''),
);
readonly remoteVideoModalVisible = computed(
() => this.session.remoteVideoModalPeerId() === this.peerId() && !!this.remoteVideoStream(),
); );
readonly callModalMode = computed<CallMode>(() => this.session.callModeForPeer(this.callModalPeerId() ?? '') ?? 'video');
readonly localCallStream = computed(() => this.session.localCallStreamForPeer(this.callModalPeerId() ?? ''));
readonly remoteCallVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.callModalPeerId() ?? ''));
readonly callModalVisible = computed(() => !!this.callModalPeer());
readonly callModalState = computed<'incoming' | 'outgoing' | 'active'>(() => {
const peerId = this.callModalPeerId();
if (!peerId) {
return 'active';
}
if (this.session.incomingVoiceCallPeerId() === peerId) {
return 'incoming';
}
if (this.session.outgoingVoiceCallPeerId() === peerId) {
return 'outgoing';
}
return 'active';
});
readonly callModalStatusText = computed(() => {
const peer = this.callModalPeer();
if (!peer) {
return '';
}
switch (this.callModalState()) {
case 'incoming':
return `${peer.displayName} is calling you${this.callModalMode() === 'audio' ? ' with audio only.' : '.'}`;
case 'outgoing':
return this.callModalMode() === 'audio'
? 'Calling… your microphone is ready.'
: 'Calling… your camera and microphone are ready.';
default:
return this.callModalMode() === 'audio'
? 'Connected with live audio.'
: 'Connected with live video and audio.';
}
});
readonly selectedPeerVoiceCallState = computed<'idle' | 'incoming' | 'outgoing' | 'active'>(() => { readonly selectedPeerVoiceCallState = computed<'idle' | 'incoming' | 'outgoing' | 'active'>(() => {
const peerId = this.peerId(); const peerId = this.peerId();
@@ -160,6 +221,32 @@ export class ChatPageComponent implements OnDestroy {
this.remoteCallAudioStream(); this.remoteCallAudioStream();
this.syncCallAudioSource(); this.syncCallAudioSource();
}); });
effect(() => {
const peerId = this.peerId();
const entries = this.conversation();
const snapshot = {
peerId,
length: entries.length,
lastEntryId: entries.at(-1)?.id ?? null,
};
const previousSnapshot = this.lastConversationSnapshot;
this.lastConversationSnapshot = snapshot;
if (!peerId || !previousSnapshot || previousSnapshot.peerId !== peerId) {
return;
}
const hasNewTailEntry = snapshot.length > previousSnapshot.length
|| (snapshot.length > 0 && snapshot.lastEntryId !== previousSnapshot.lastEntryId);
if (!hasNewTailEntry) {
return;
}
this.scrollConversationToBottom();
});
} }
ngOnDestroy(): void { ngOnDestroy(): void {
@@ -265,6 +352,29 @@ export class ChatPageComponent implements OnDestroy {
}); });
} }
openCallChoice(peerId: string): void {
if (!peerId) {
return;
}
this.callChoicePeerId.set(peerId);
}
closeCallChoice(): void {
this.callChoicePeerId.set(null);
}
async startSelectedCall(mode: CallMode): Promise<void> {
const peerId = this.callChoicePeerId() ?? this.peerId();
if (!peerId) {
return;
}
this.callChoicePeerId.set(null);
await this.session.startVoiceCall(peerId, mode);
}
async sendFile(peerId: string, input: HTMLInputElement): Promise<void> { async sendFile(peerId: string, input: HTMLInputElement): Promise<void> {
const file = input.files?.item(0); const file = input.files?.item(0);
@@ -474,17 +584,14 @@ export class ChatPageComponent implements OnDestroy {
this.forwardingEntryId.set(null); this.forwardingEntryId.set(null);
} }
async toggleCameraStream(peerId: string): Promise<void> { async sendGeneratedImage(entry: ChatEntry): Promise<void> {
if (this.session.isStreamingCameraToPeer(peerId)) { const peerId = this.peerId();
await this.session.stopCameraStream(peerId);
if (!peerId) {
return; return;
} }
await this.session.startCameraStream(peerId); await this.session.sendGeneratedImageToPeer(entry, peerId);
}
async startVoiceCall(peerId: string): Promise<void> {
await this.session.startVoiceCall(peerId);
} }
async endVoiceCall(peerId: string): Promise<void> { async endVoiceCall(peerId: string): Promise<void> {
@@ -496,10 +603,6 @@ export class ChatPageComponent implements OnDestroy {
return; return;
} }
if (peerId !== this.peerId()) {
await this.router.navigate(['/chat', peerId]);
}
await this.session.acceptVoiceCall(peerId); await this.session.acceptVoiceCall(peerId);
} }
@@ -515,6 +618,10 @@ export class ChatPageComponent implements OnDestroy {
return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false); return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false);
} }
isGeneratedImageEntry(entry: ChatEntry): boolean {
return this.isImageEntry(entry) && entry.generatedByAi === true;
}
isVideoEntry(entry: ChatEntry): boolean { isVideoEntry(entry: ChatEntry): boolean {
if (entry.kind !== 'file' || !entry.downloadUrl) { if (entry.kind !== 'file' || !entry.downloadUrl) {
return false; return false;
@@ -537,6 +644,22 @@ export class ChatPageComponent implements OnDestroy {
); );
} }
hasDocumentPreviewImage(entry: ChatEntry): boolean {
return (
entry.kind === 'file' &&
!!entry.previewDownloadUrl &&
(entry.previewMimeType?.startsWith('image/') ?? false)
);
}
documentPreviewImageUrl(entry: ChatEntry): string | null {
if (!this.hasDocumentPreviewImage(entry)) {
return null;
}
return entry.previewDownloadUrl ?? null;
}
isPeerTyping(peerId: string): boolean { isPeerTyping(peerId: string): boolean {
return this.session.typingPeerIds().includes(peerId); return this.session.typingPeerIds().includes(peerId);
} }
@@ -561,22 +684,6 @@ export class ChatPageComponent implements OnDestroy {
return this.indicatorTone(this.webRtcState()) === 'offline'; 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> { async switchPeer(peerId: string): Promise<void> {
if (!peerId || peerId === this.peerId()) { if (!peerId || peerId === this.peerId()) {
return; return;
@@ -585,6 +692,7 @@ export class ChatPageComponent implements OnDestroy {
await this.stopDictation(true); await this.stopDictation(true);
this.stopVoiceRecording(true); this.stopVoiceRecording(true);
this.forwardingEntryId.set(null); this.forwardingEntryId.set(null);
this.callChoicePeerId.set(null);
this.emojiPickerOpen.set(false); this.emojiPickerOpen.set(false);
this.session.selectPeer(peerId); this.session.selectPeer(peerId);
await this.router.navigate(['/chat', peerId]); await this.router.navigate(['/chat', peerId]);
@@ -764,4 +872,18 @@ export class ChatPageComponent implements OnDestroy {
audio.pause(); audio.pause();
audio.srcObject = null; audio.srcObject = null;
} }
private scrollConversationToBottom(): void {
const container = this.conversationContainer?.nativeElement;
if (!container) {
return;
}
queueMicrotask(() => {
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
});
});
}
} }

View File

@@ -7,6 +7,7 @@ import {
AdminUserSummary, AdminUserSummary,
AuthenticationOptionsResponse, AuthenticationOptionsResponse,
AuthResponse, AuthResponse,
CallMode,
ChatEntry, ChatEntry,
ConnectionState, ConnectionState,
DataEnvelope, DataEnvelope,
@@ -26,11 +27,9 @@ type PeerBundle = {
pendingCandidates: RTCIceCandidateInit[]; pendingCandidates: RTCIceCandidateInit[];
pendingNegotiation: boolean; pendingNegotiation: boolean;
announceConnectionEvents: boolean; announceConnectionEvents: boolean;
localCameraStream?: MediaStream; localCallStream?: MediaStream;
cameraSenders: RTCRtpSender[]; mediaSenders: RTCRtpSender[];
remoteCameraStream?: MediaStream; remoteCameraStream?: MediaStream;
localAudioStream?: MediaStream;
audioSenders: RTCRtpSender[];
remoteAudioStream?: MediaStream; remoteAudioStream?: MediaStream;
}; };
@@ -58,12 +57,15 @@ type LegacyPersistedChatEntry = {
kind: Exclude<ChatEntry['kind'], 'system'>; kind: Exclude<ChatEntry['kind'], 'system'>;
createdAt: number; createdAt: number;
authorLabel: string; authorLabel: string;
generatedByAi?: boolean;
text?: string; text?: string;
payload?: unknown; payload?: unknown;
fileName?: string; fileName?: string;
fileSize?: number; fileSize?: number;
fileMimeType?: string; fileMimeType?: string;
fileBlob?: Blob; fileBlob?: Blob;
previewMimeType?: string;
previewBlob?: Blob;
}; };
type EncryptedPersistedChatEntry = { type EncryptedPersistedChatEntry = {
@@ -79,17 +81,26 @@ type EncryptedPersistedChatEntry = {
payloadIv: number[]; payloadIv: number[];
encryptedFileBlob?: PersistedBinary; encryptedFileBlob?: PersistedBinary;
fileIv?: number[]; fileIv?: number[];
encryptedPreviewBlob?: PersistedBinary;
previewIv?: number[];
}; };
type PersistedChatEntry = LegacyPersistedChatEntry | EncryptedPersistedChatEntry; type PersistedChatEntry = LegacyPersistedChatEntry | EncryptedPersistedChatEntry;
type PersistedChatEntryContent = { type PersistedChatEntryContent = {
authorLabel: string; authorLabel: string;
generatedByAi?: boolean;
text?: string; text?: string;
payload?: unknown; payload?: unknown;
fileName?: string; fileName?: string;
fileSize?: number; fileSize?: number;
fileMimeType?: string; fileMimeType?: string;
previewMimeType?: string;
};
type DocumentPreviewImageResponse = {
mimeType: string;
imageBase64: string;
}; };
type RuntimeEnv = { type RuntimeEnv = {
@@ -128,7 +139,6 @@ export class ChatSessionService {
readonly messages = signal<ChatEntry[]>([]); readonly messages = signal<ChatEntry[]>([]);
readonly unreadPeerIds = signal<string[]>([]); readonly unreadPeerIds = signal<string[]>([]);
readonly typingPeerIds = signal<string[]>([]); readonly typingPeerIds = signal<string[]>([]);
readonly remoteVideoModalPeerId = signal<string | null>(null);
readonly incomingVoiceCallPeerId = signal<string | null>(null); readonly incomingVoiceCallPeerId = signal<string | null>(null);
readonly outgoingVoiceCallPeerId = signal<string | null>(null); readonly outgoingVoiceCallPeerId = signal<string | null>(null);
readonly activeVoiceCallPeerId = signal<string | null>(null); readonly activeVoiceCallPeerId = signal<string | null>(null);
@@ -174,10 +184,13 @@ export class ChatSessionService {
string, string,
{ resolve: (text: string) => void; reject: (reason?: unknown) => void } { resolve: (text: string) => void; reject: (reason?: unknown) => void }
>(); >();
private readonly incomingCallModes = signal<Array<{ peerId: string; mode: CallMode }>>([]);
private readonly outgoingCallModes = signal<Array<{ peerId: string; mode: CallMode }>>([]);
private readonly activeCallModes = signal<Array<{ peerId: string; mode: CallMode }>>([]);
private readonly localCallStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]);
private readonly remoteVideoStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]); private readonly remoteVideoStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]);
private readonly remoteAudioStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]); private readonly remoteAudioStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]);
private readonly activeCameraPeerId = signal<string | null>(null); private readonly localCallPeerId = signal<string | null>(null);
private readonly activeAudioPeerId = signal<string | null>(null);
private sessionKeepaliveIntervalId: number | null = null; private sessionKeepaliveIntervalId: number | null = null;
private websocketHeartbeatIntervalId: number | null = null; private websocketHeartbeatIntervalId: number | null = null;
private websocketReconnectTimeoutId: number | null = null; private websocketReconnectTimeoutId: number | null = null;
@@ -369,94 +382,15 @@ export class ChatSessionService {
await this.negotiatePeer(peerId, bundle); await this.negotiatePeer(peerId, bundle);
} }
async startCameraStream(peerId: string): Promise<void> { localCallStreamForPeer(peerId: string): MediaStream | null {
if (typeof navigator === 'undefined' || typeof navigator.mediaDevices?.getUserMedia !== 'function') { return this.localCallStreams().find((entry) => entry.peerId === peerId)?.stream ?? null;
this.error.set('This browser does not support webcam capture.');
return;
} }
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) { callModeForPeer(peerId: string): CallMode | null {
this.error.set('You must be connected to signaling before starting webcam capture.'); return this.activeCallModes().find((entry) => entry.peerId === peerId)?.mode
return; ?? this.incomingCallModes().find((entry) => entry.peerId === peerId)?.mode
} ?? this.outgoingCallModes().find((entry) => entry.peerId === peerId)?.mode
?? null;
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 { remoteVideoStreamForPeer(peerId: string): MediaStream | null {
@@ -467,29 +401,34 @@ export class ChatSessionService {
return this.remoteAudioStreams().find((entry) => entry.peerId === peerId)?.stream ?? null; return this.remoteAudioStreams().find((entry) => entry.peerId === peerId)?.stream ?? null;
} }
dismissRemoteVideoModal(peerId: string): void { async startVoiceCall(peerId: string, mode: CallMode): Promise<void> {
if (this.remoteVideoModalPeerId() === peerId) {
this.remoteVideoModalPeerId.set(null);
}
}
async startVoiceCall(peerId: string): Promise<void> {
const channel = this.requireOpenChannel(peerId); const channel = this.requireOpenChannel(peerId);
if (!channel) { if (!channel) {
return; return;
} }
if (this.hasVoiceCallConflict(peerId) || this.outgoingVoiceCallPeerId() === peerId || this.activeVoiceCallPeerId() === peerId) { if (
this.error.set('Finish the current voice call before starting another one.'); this.hasVoiceCallConflict(peerId)
|| this.outgoingVoiceCallPeerId() === peerId
|| this.activeVoiceCallPeerId() === peerId
) {
this.error.set('Finish the current call before starting another one.');
return;
}
const bundle = await this.ensureLocalCallStream(peerId, mode);
if (!bundle) {
return; return;
} }
this.error.set(null); this.error.set(null);
this.incomingVoiceCallPeerId.set(null); this.incomingVoiceCallPeerId.set(null);
this.outgoingVoiceCallPeerId.set(peerId); this.outgoingVoiceCallPeerId.set(peerId);
channel.send(JSON.stringify({ type: 'voice-call-offer' } satisfies DataEnvelope)); this.upsertCallMode(this.outgoingCallModes, peerId, mode);
this.addSystemMessage(peerId, 'Calling peer.'); channel.send(JSON.stringify({ type: 'voice-call-offer', mode } satisfies DataEnvelope));
this.addSystemMessage(peerId, mode === 'video' ? 'Calling peer with video.' : 'Calling peer with audio only.');
} }
async acceptVoiceCall(peerId: string): Promise<void> { async acceptVoiceCall(peerId: string): Promise<void> {
@@ -497,7 +436,8 @@ export class ChatSessionService {
return; return;
} }
const bundle = await this.ensureLocalAudioStream(peerId); const mode = this.callModeForPeer(peerId) ?? 'video';
const bundle = await this.ensureLocalCallStream(peerId, mode);
if (!bundle) { if (!bundle) {
this.sendVoiceCallResponse(peerId, false); this.sendVoiceCallResponse(peerId, false);
@@ -507,10 +447,12 @@ export class ChatSessionService {
this.stopRingtone(); this.stopRingtone();
this.incomingVoiceCallPeerId.set(null); this.incomingVoiceCallPeerId.set(null);
this.clearCallMode(this.incomingCallModes, peerId);
this.outgoingVoiceCallPeerId.set(null); this.outgoingVoiceCallPeerId.set(null);
this.activeVoiceCallPeerId.set(peerId); this.activeVoiceCallPeerId.set(peerId);
this.upsertCallMode(this.activeCallModes, peerId, mode);
this.sendVoiceCallResponse(peerId, true); this.sendVoiceCallResponse(peerId, true);
this.addSystemMessage(peerId, 'Voice call connected.'); this.addSystemMessage(peerId, mode === 'video' ? 'Video call connected.' : 'Audio call connected.');
await this.negotiatePeer(peerId, bundle); await this.negotiatePeer(peerId, bundle);
} }
@@ -520,15 +462,16 @@ export class ChatSessionService {
} }
this.sendVoiceCallResponse(peerId, false); this.sendVoiceCallResponse(peerId, false);
void this.stopLocalCallStream(peerId, false);
this.clearVoiceCallSignals(peerId); this.clearVoiceCallSignals(peerId);
this.addSystemMessage(peerId, 'Voice call rejected.'); this.addSystemMessage(peerId, 'Call rejected.');
} }
async endVoiceCall(peerId: string, notifyPeer = true): Promise<void> { async endVoiceCall(peerId: string, notifyPeer = true): Promise<void> {
const hadVoiceCall = this.incomingVoiceCallPeerId() === peerId const hadVoiceCall = this.incomingVoiceCallPeerId() === peerId
|| this.outgoingVoiceCallPeerId() === peerId || this.outgoingVoiceCallPeerId() === peerId
|| this.activeVoiceCallPeerId() === peerId || this.activeVoiceCallPeerId() === peerId
|| this.activeAudioPeerId() === peerId; || this.localCallPeerId() === peerId;
if (!hadVoiceCall) { if (!hadVoiceCall) {
return; return;
@@ -538,10 +481,11 @@ export class ChatSessionService {
this.sendVoiceCallEnded(peerId); this.sendVoiceCallEnded(peerId);
} }
await this.stopLocalAudioStream(peerId, true); await this.stopLocalCallStream(peerId, true);
this.clearRemoteVideoState(peerId);
this.clearRemoteAudioState(peerId); this.clearRemoteAudioState(peerId);
this.clearVoiceCallSignals(peerId); this.clearVoiceCallSignals(peerId);
this.addSystemMessage(peerId, 'Voice call ended.'); this.addSystemMessage(peerId, 'Call ended.');
} }
async registerAccessKey(label: string): Promise<void> { async registerAccessKey(label: string): Promise<void> {
@@ -737,6 +681,31 @@ export class ChatSessionService {
} }
} }
async sendGeneratedImageToPeer(entry: ChatEntry, targetPeerId: string): Promise<void> {
if (entry.kind !== 'file' || !entry.generatedByAi || !entry.downloadUrl) {
this.error.set('This image is not available to send.');
return;
}
const channel = await this.ensureOpenChannel(targetPeerId);
if (!channel) {
return;
}
try {
const response = await fetch(entry.downloadUrl);
const blob = await response.blob();
const file = new File([blob], entry.fileName || 'generated-image', {
type: entry.fileMimeType || blob.type || 'application/octet-stream',
});
await this.sendFile(targetPeerId, file, 'file');
} catch {
this.error.set('Could not send this generated image.');
}
}
private async authenticate(path: string, payload: Record<string, unknown>): Promise<void> { private async authenticate(path: string, payload: Record<string, unknown>): Promise<void> {
this.error.set(null); this.error.set(null);
this.notice.set(null); this.notice.set(null);
@@ -1123,6 +1092,7 @@ export class ChatSessionService {
kind: 'file', kind: 'file',
createdAt: event.createdAt, createdAt: event.createdAt,
authorLabel: 'You', authorLabel: 'You',
generatedByAi: true,
text: pendingRequest?.prompt ?? event.prompt, text: pendingRequest?.prompt ?? event.prompt,
fileName, fileName,
fileSize: imageBlob.size, fileSize: imageBlob.size,
@@ -1394,8 +1364,7 @@ export class ChatSessionService {
pendingCandidates: [], pendingCandidates: [],
pendingNegotiation: false, pendingNegotiation: false,
announceConnectionEvents: announce, announceConnectionEvents: announce,
cameraSenders: [], mediaSenders: [],
audioSenders: [],
}; };
bundle.pc.onicecandidate = (event) => { bundle.pc.onicecandidate = (event) => {
@@ -1443,7 +1412,6 @@ export class ChatSessionService {
bundle.remoteCameraStream = remoteStream; bundle.remoteCameraStream = remoteStream;
this.upsertRemoteVideoStream(peerId, remoteStream); this.upsertRemoteVideoStream(peerId, remoteStream);
this.remoteVideoModalPeerId.set(peerId);
event.track.onended = () => { event.track.onended = () => {
if (!bundle.remoteCameraStream) { if (!bundle.remoteCameraStream) {
@@ -1573,20 +1541,13 @@ export class ChatSessionService {
this.addSystemMessage(peerId, `Receiving file ${envelope.name}.`); this.addSystemMessage(peerId, `Receiving file ${envelope.name}.`);
break; break;
case 'file-complete': case 'file-complete':
this.finalizeIncomingFile(peerId, envelope.id); void this.finalizeIncomingFile(peerId, envelope.id);
break; break;
case 'typing': case 'typing':
this.setPeerTyping(peerId, envelope.active); this.setPeerTyping(peerId, envelope.active);
break; break;
case 'camera-state':
if (envelope.active) {
this.remoteVideoModalPeerId.set(peerId);
} else {
this.clearRemoteVideoState(peerId);
}
break;
case 'voice-call-offer': case 'voice-call-offer':
this.handleIncomingVoiceCallOffer(peerId); this.handleIncomingVoiceCallOffer(peerId, envelope.mode);
break; break;
case 'voice-call-response': case 'voice-call-response':
void this.handleVoiceCallResponse(peerId, envelope.accepted); void this.handleVoiceCallResponse(peerId, envelope.accepted);
@@ -1610,15 +1571,30 @@ export class ChatSessionService {
transfer.receivedBytes += arrayBuffer.byteLength; transfer.receivedBytes += arrayBuffer.byteLength;
} }
private finalizeIncomingFile(peerId: string, transferId: string): void { private async finalizeIncomingFile(peerId: string, transferId: string): Promise<void> {
const transfer = this.incomingFiles.get(peerId); const transfer = this.incomingFiles.get(peerId);
if (!transfer || transfer.id !== transferId) { if (!transfer || transfer.id !== transferId) {
return; return;
} }
this.incomingFiles.delete(peerId);
const blob = new Blob(transfer.chunks, { type: transfer.mimeType }); const blob = new Blob(transfer.chunks, { type: transfer.mimeType });
const downloadUrl = URL.createObjectURL(blob); const downloadUrl = URL.createObjectURL(blob);
let previewBlob: Blob | undefined;
let previewMimeType: string | undefined;
let previewDownloadUrl: string | undefined;
if (transfer.kind === 'file' && this.isPreviewableDocumentFile(transfer.name, transfer.mimeType)) {
const imagePreview = await this.generateDocumentPreviewImage(transfer.name, blob);
if (imagePreview) {
previewBlob = imagePreview.blob;
previewMimeType = imagePreview.mimeType;
previewDownloadUrl = URL.createObjectURL(imagePreview.blob);
}
}
this.pushMessage({ this.pushMessage({
id: transfer.id, id: transfer.id,
@@ -1631,9 +1607,9 @@ export class ChatSessionService {
fileSize: transfer.size, fileSize: transfer.size,
fileMimeType: transfer.mimeType, fileMimeType: transfer.mimeType,
downloadUrl, downloadUrl,
}, blob); previewMimeType,
previewDownloadUrl,
this.incomingFiles.delete(peerId); }, blob, previewBlob);
} }
private async flushPendingCandidates(bundle: PeerBundle): Promise<void> { private async flushPendingCandidates(bundle: PeerBundle): Promise<void> {
@@ -1665,16 +1641,6 @@ export class ChatSessionService {
}); });
} }
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 sendVoiceCallResponse(peerId: string, accepted: boolean): void { private sendVoiceCallResponse(peerId: string, accepted: boolean): void {
const channel = this.peerBundles.get(peerId)?.channel; const channel = this.peerBundles.get(peerId)?.channel;
@@ -1760,35 +1726,37 @@ export class ChatSessionService {
} }
private hasVoiceCallConflict(peerId: string): boolean { private hasVoiceCallConflict(peerId: string): boolean {
return [this.incomingVoiceCallPeerId(), this.outgoingVoiceCallPeerId(), this.activeVoiceCallPeerId()] return [this.incomingVoiceCallPeerId(), this.outgoingVoiceCallPeerId(), this.activeVoiceCallPeerId(), this.localCallPeerId()]
.some((candidatePeerId) => !!candidatePeerId && candidatePeerId !== peerId); .some((candidatePeerId) => !!candidatePeerId && candidatePeerId !== peerId);
} }
private async ensureLocalAudioStream(peerId: string): Promise<PeerBundle | null> { private async ensureLocalCallStream(peerId: string, mode: CallMode): Promise<PeerBundle | null> {
if (typeof navigator === 'undefined' || typeof navigator.mediaDevices?.getUserMedia !== 'function') { if (typeof navigator === 'undefined' || typeof navigator.mediaDevices?.getUserMedia !== 'function') {
this.error.set('This browser does not support microphone capture.'); this.error.set(mode === 'video'
? 'This browser does not support camera and microphone capture.'
: 'This browser does not support microphone capture.');
return null; return null;
} }
if (this.activeAudioPeerId() && this.activeAudioPeerId() !== peerId) { if (this.localCallPeerId() && this.localCallPeerId() !== peerId) {
this.error.set('Finish the current voice call before starting another one.'); this.error.set('Finish the current call before starting another one.');
return null; return null;
} }
const bundle = this.ensurePeerBundle(peerId, true); const bundle = this.ensurePeerBundle(peerId, true);
if (bundle.localAudioStream) { if (bundle.localCallStream) {
return bundle; return bundle;
} }
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await navigator.mediaDevices.getUserMedia({
audio: true, audio: true,
video: false, video: mode === 'video',
}); });
bundle.localAudioStream = stream; bundle.localCallStream = stream;
bundle.audioSenders = stream.getTracks().map((track) => { bundle.mediaSenders = stream.getTracks().map((track) => {
track.onended = () => { track.onended = () => {
void this.endVoiceCall(peerId); void this.endVoiceCall(peerId);
}; };
@@ -1796,49 +1764,54 @@ export class ChatSessionService {
return bundle.pc.addTrack(track, stream); return bundle.pc.addTrack(track, stream);
}); });
this.activeAudioPeerId.set(peerId); this.localCallPeerId.set(peerId);
this.upsertLocalCallStream(peerId, stream);
return bundle; return bundle;
} catch { } catch {
this.error.set('Could not start microphone capture for the voice call.'); this.error.set(mode === 'video'
? 'Could not start camera and microphone capture for the call.'
: 'Could not start microphone capture for the call.');
return null; return null;
} }
} }
private async stopLocalAudioStream(peerId: string, renegotiate: boolean): Promise<void> { private async stopLocalCallStream(peerId: string, renegotiate: boolean): Promise<void> {
const bundle = this.peerBundles.get(peerId); const bundle = this.peerBundles.get(peerId);
if (!bundle?.localAudioStream && this.activeAudioPeerId() !== peerId) { if (!bundle?.localCallStream && this.localCallPeerId() !== peerId) {
return; return;
} }
if (bundle) { if (bundle) {
for (const sender of bundle.audioSenders) { for (const sender of bundle.mediaSenders) {
bundle.pc.removeTrack(sender); bundle.pc.removeTrack(sender);
} }
bundle.audioSenders = []; bundle.mediaSenders = [];
if (bundle.localAudioStream) { if (bundle.localCallStream) {
for (const track of bundle.localAudioStream.getTracks()) { for (const track of bundle.localCallStream.getTracks()) {
track.onended = null; track.onended = null;
track.stop(); track.stop();
} }
} }
bundle.localAudioStream = undefined; bundle.localCallStream = undefined;
} }
if (this.activeAudioPeerId() === peerId) { if (this.localCallPeerId() === peerId) {
this.activeAudioPeerId.set(null); this.localCallPeerId.set(null);
} }
this.clearLocalCallStream(peerId);
if (renegotiate && bundle) { if (renegotiate && bundle) {
await this.negotiatePeer(peerId, bundle); await this.negotiatePeer(peerId, bundle);
} }
} }
private handleIncomingVoiceCallOffer(peerId: string): void { private handleIncomingVoiceCallOffer(peerId: string, mode: CallMode): void {
if (this.hasVoiceCallConflict(peerId) || this.activeAudioPeerId()) { if (this.hasVoiceCallConflict(peerId) || this.localCallPeerId()) {
this.sendVoiceCallResponse(peerId, false); this.sendVoiceCallResponse(peerId, false);
return; return;
} }
@@ -1846,8 +1819,9 @@ export class ChatSessionService {
this.outgoingVoiceCallPeerId.set(null); this.outgoingVoiceCallPeerId.set(null);
this.activeVoiceCallPeerId.set(null); this.activeVoiceCallPeerId.set(null);
this.incomingVoiceCallPeerId.set(peerId); this.incomingVoiceCallPeerId.set(peerId);
this.upsertCallMode(this.incomingCallModes, peerId, mode);
this.startRingtone(); this.startRingtone();
this.addSystemMessage(peerId, 'Incoming voice call.'); this.addSystemMessage(peerId, mode === 'video' ? 'Incoming video call.' : 'Incoming audio call.');
} }
private async handleVoiceCallResponse(peerId: string, accepted: boolean): Promise<void> { private async handleVoiceCallResponse(peerId: string, accepted: boolean): Promise<void> {
@@ -1858,19 +1832,24 @@ export class ChatSessionService {
this.outgoingVoiceCallPeerId.set(null); this.outgoingVoiceCallPeerId.set(null);
if (!accepted) { if (!accepted) {
this.addSystemMessage(peerId, 'Voice call declined.'); this.clearCallMode(this.outgoingCallModes, peerId);
await this.stopLocalCallStream(peerId, true);
this.addSystemMessage(peerId, 'Call declined.');
return; return;
} }
const mode = this.callModeForPeer(peerId) ?? 'video';
this.activeVoiceCallPeerId.set(peerId); this.activeVoiceCallPeerId.set(peerId);
const bundle = await this.ensureLocalAudioStream(peerId); this.clearCallMode(this.outgoingCallModes, peerId);
this.upsertCallMode(this.activeCallModes, peerId, mode);
const bundle = await this.ensureLocalCallStream(peerId, mode);
if (!bundle) { if (!bundle) {
await this.endVoiceCall(peerId); await this.endVoiceCall(peerId);
return; return;
} }
this.addSystemMessage(peerId, 'Voice call connected.'); this.addSystemMessage(peerId, mode === 'video' ? 'Video call connected.' : 'Audio call connected.');
await this.negotiatePeer(peerId, bundle); await this.negotiatePeer(peerId, bundle);
} }
@@ -1878,14 +1857,15 @@ export class ChatSessionService {
const hadVoiceCall = this.incomingVoiceCallPeerId() === peerId const hadVoiceCall = this.incomingVoiceCallPeerId() === peerId
|| this.outgoingVoiceCallPeerId() === peerId || this.outgoingVoiceCallPeerId() === peerId
|| this.activeVoiceCallPeerId() === peerId || this.activeVoiceCallPeerId() === peerId
|| this.activeAudioPeerId() === peerId; || this.localCallPeerId() === peerId;
await this.stopLocalAudioStream(peerId, true); await this.stopLocalCallStream(peerId, true);
this.clearRemoteVideoState(peerId);
this.clearRemoteAudioState(peerId); this.clearRemoteAudioState(peerId);
this.clearVoiceCallSignals(peerId); this.clearVoiceCallSignals(peerId);
if (hadVoiceCall) { if (hadVoiceCall) {
this.addSystemMessage(peerId, 'Voice call ended.'); this.addSystemMessage(peerId, 'Call ended.');
} }
} }
@@ -1902,27 +1882,18 @@ export class ChatSessionService {
return; return;
} }
if (bundle.localCameraStream) { if (bundle.localCallStream) {
for (const track of bundle.localCameraStream.getTracks()) { for (const track of bundle.localCallStream.getTracks()) {
track.onended = null; track.onended = null;
track.stop(); track.stop();
} }
} }
if (bundle.localAudioStream) { if (this.localCallPeerId() === peerId) {
for (const track of bundle.localAudioStream.getTracks()) { this.localCallPeerId.set(null);
track.onended = null;
track.stop();
}
} }
if (this.activeCameraPeerId() === peerId) { this.clearLocalCallStream(peerId);
this.activeCameraPeerId.set(null);
}
if (this.activeAudioPeerId() === peerId) {
this.activeAudioPeerId.set(null);
}
bundle.channel?.close(); bundle.channel?.close();
bundle.pc.close(); bundle.pc.close();
@@ -1949,7 +1920,7 @@ export class ChatSessionService {
); );
} }
private pushMessage(entry: ChatEntry, fileBlob?: Blob): void { private pushMessage(entry: ChatEntry, fileBlob?: Blob, previewBlob?: Blob): void {
this.messages.update((messages) => [...messages, entry].sort((left, right) => left.createdAt - right.createdAt)); this.messages.update((messages) => [...messages, entry].sort((left, right) => left.createdAt - right.createdAt));
if (entry.direction === 'incoming' && entry.kind !== 'system' && this.activePeerId() !== entry.peerId) { if (entry.direction === 'incoming' && entry.kind !== 'system' && this.activePeerId() !== entry.peerId) {
@@ -1957,7 +1928,7 @@ export class ChatSessionService {
} }
if (entry.kind !== 'system') { if (entry.kind !== 'system') {
void this.persistMessage(entry, fileBlob); void this.persistMessage(entry, fileBlob, previewBlob);
} }
} }
@@ -2107,11 +2078,13 @@ export class ChatSessionService {
this.releasePreloadedRingtone(); this.releasePreloadedRingtone();
this.pendingImageGenerationRequests.clear(); this.pendingImageGenerationRequests.clear();
this.rejectPendingSpeechTranscriptions('Session ended during dictation.'); this.rejectPendingSpeechTranscriptions('Session ended during dictation.');
this.incomingCallModes.set([]);
this.outgoingCallModes.set([]);
this.activeCallModes.set([]);
this.localCallStreams.set([]);
this.remoteVideoStreams.set([]); this.remoteVideoStreams.set([]);
this.remoteAudioStreams.set([]); this.remoteAudioStreams.set([]);
this.remoteVideoModalPeerId.set(null); this.localCallPeerId.set(null);
this.activeCameraPeerId.set(null);
this.activeAudioPeerId.set(null);
this.incomingVoiceCallPeerId.set(null); this.incomingVoiceCallPeerId.set(null);
this.outgoingVoiceCallPeerId.set(null); this.outgoingVoiceCallPeerId.set(null);
this.activeVoiceCallPeerId.set(null); this.activeVoiceCallPeerId.set(null);
@@ -2201,7 +2174,7 @@ export class ChatSessionService {
} }
} }
private async persistMessage(entry: ChatEntry, fileBlob?: Blob): Promise<void> { private async persistMessage(entry: ChatEntry, fileBlob?: Blob, previewBlob?: Blob): Promise<void> {
const currentUserId = this.currentUser()?.id; const currentUserId = this.currentUser()?.id;
const messageEncryptionKey = this.messageEncryptionKey; const messageEncryptionKey = this.messageEncryptionKey;
@@ -2214,15 +2187,20 @@ export class ChatSessionService {
const storageKey = this.messageStorageKey(currentUserId, entry.peerId, entry.id); const storageKey = this.messageStorageKey(currentUserId, entry.peerId, entry.id);
const encryptedPayload = await this.encryptPersistedMessageContent(messageEncryptionKey, { const encryptedPayload = await this.encryptPersistedMessageContent(messageEncryptionKey, {
authorLabel: entry.authorLabel, authorLabel: entry.authorLabel,
generatedByAi: entry.generatedByAi,
text: entry.text, text: entry.text,
payload: entry.payload, payload: entry.payload,
fileName: entry.fileName, fileName: entry.fileName,
fileSize: entry.fileSize, fileSize: entry.fileSize,
fileMimeType: entry.fileMimeType, fileMimeType: entry.fileMimeType,
previewMimeType: entry.previewMimeType,
}); });
const encryptedFileBlob = fileBlob const encryptedFileBlob = fileBlob
? await this.encryptBinary(messageEncryptionKey, await fileBlob.arrayBuffer()) ? await this.encryptBinary(messageEncryptionKey, await fileBlob.arrayBuffer())
: null; : null;
const encryptedPreviewBlob = previewBlob
? await this.encryptBinary(messageEncryptionKey, await previewBlob.arrayBuffer())
: null;
const persistedEntry: EncryptedPersistedChatEntry = { const persistedEntry: EncryptedPersistedChatEntry = {
storageKey, storageKey,
ownerUserId: currentUserId, ownerUserId: currentUserId,
@@ -2238,6 +2216,10 @@ export class ChatSessionService {
? this.serializePersistedBinary(encryptedFileBlob.ciphertext) ? this.serializePersistedBinary(encryptedFileBlob.ciphertext)
: undefined, : undefined,
fileIv: encryptedFileBlob ? Array.from(encryptedFileBlob.iv) : undefined, fileIv: encryptedFileBlob ? Array.from(encryptedFileBlob.iv) : undefined,
encryptedPreviewBlob: encryptedPreviewBlob
? this.serializePersistedBinary(encryptedPreviewBlob.ciphertext)
: undefined,
previewIv: encryptedPreviewBlob ? Array.from(encryptedPreviewBlob.iv) : undefined,
}; };
await this.queueMessageStoreOperation(storageKey, async () => { await this.queueMessageStoreOperation(storageKey, async () => {
@@ -2284,6 +2266,7 @@ export class ChatSessionService {
try { try {
const content = await this.decryptPersistedMessageContent(messageEncryptionKey, entry); const content = await this.decryptPersistedMessageContent(messageEncryptionKey, entry);
let downloadUrl: string | undefined; let downloadUrl: string | undefined;
let previewDownloadUrl: string | undefined;
if (entry.encryptedFileBlob && entry.fileIv) { if (entry.encryptedFileBlob && entry.fileIv) {
const decryptedFile = await this.decryptBinary( const decryptedFile = await this.decryptBinary(
@@ -2297,6 +2280,18 @@ export class ChatSessionService {
downloadUrl = URL.createObjectURL(fileBlob); downloadUrl = URL.createObjectURL(fileBlob);
} }
if (entry.encryptedPreviewBlob && entry.previewIv) {
const decryptedPreview = await this.decryptBinary(
messageEncryptionKey,
this.deserializePersistedBinary(entry.encryptedPreviewBlob),
Uint8Array.from(entry.previewIv).buffer,
);
const previewBlob = new Blob([decryptedPreview], {
type: content.previewMimeType || 'image/png',
});
previewDownloadUrl = URL.createObjectURL(previewBlob);
}
return { return {
id: entry.id, id: entry.id,
peerId: entry.peerId, peerId: entry.peerId,
@@ -2304,12 +2299,15 @@ export class ChatSessionService {
kind: entry.kind, kind: entry.kind,
createdAt: entry.createdAt, createdAt: entry.createdAt,
authorLabel: content.authorLabel, authorLabel: content.authorLabel,
generatedByAi: content.generatedByAi,
text: content.text, text: content.text,
payload: content.payload, payload: content.payload,
fileName: content.fileName, fileName: content.fileName,
fileSize: content.fileSize, fileSize: content.fileSize,
fileMimeType: content.fileMimeType, fileMimeType: content.fileMimeType,
downloadUrl, downloadUrl,
previewMimeType: content.previewMimeType,
previewDownloadUrl,
}; };
} catch (error) { } catch (error) {
console.warn('Could not decrypt persisted chat message.', error); console.warn('Could not decrypt persisted chat message.', error);
@@ -2325,12 +2323,15 @@ export class ChatSessionService {
kind: entry.kind, kind: entry.kind,
createdAt: entry.createdAt, createdAt: entry.createdAt,
authorLabel: entry.authorLabel, authorLabel: entry.authorLabel,
generatedByAi: entry.generatedByAi,
text: entry.text, text: entry.text,
payload: entry.payload, payload: entry.payload,
fileName: entry.fileName, fileName: entry.fileName,
fileSize: entry.fileSize, fileSize: entry.fileSize,
fileMimeType: entry.fileMimeType, fileMimeType: entry.fileMimeType,
previewMimeType: entry.previewMimeType,
downloadUrl: entry.fileBlob ? URL.createObjectURL(entry.fileBlob) : undefined, downloadUrl: entry.fileBlob ? URL.createObjectURL(entry.fileBlob) : undefined,
previewDownloadUrl: entry.previewBlob ? URL.createObjectURL(entry.previewBlob) : undefined,
}; };
} }
@@ -2341,8 +2342,13 @@ export class ChatSessionService {
type: entry.fileMimeType || 'application/octet-stream', type: entry.fileMimeType || 'application/octet-stream',
}) })
: undefined; : undefined;
const previewBlob = entry.previewBlob
? new Blob([await entry.previewBlob.arrayBuffer()], {
type: entry.previewMimeType || 'image/png',
})
: undefined;
await this.persistMessage(hydratedEntry, fileBlob); await this.persistMessage(hydratedEntry, fileBlob, previewBlob);
} }
private async migrateEncryptedPersistedMessage(entry: EncryptedPersistedChatEntry): Promise<void> { private async migrateEncryptedPersistedMessage(entry: EncryptedPersistedChatEntry): Promise<void> {
@@ -2373,6 +2379,10 @@ export class ChatSessionService {
if (entry.downloadUrl?.startsWith('blob:')) { if (entry.downloadUrl?.startsWith('blob:')) {
URL.revokeObjectURL(entry.downloadUrl); URL.revokeObjectURL(entry.downloadUrl);
} }
if (entry.previewDownloadUrl?.startsWith('blob:')) {
URL.revokeObjectURL(entry.previewDownloadUrl);
}
} }
} }
@@ -2531,6 +2541,10 @@ export class ChatSessionService {
URL.revokeObjectURL(message.downloadUrl); URL.revokeObjectURL(message.downloadUrl);
} }
if (message.previewDownloadUrl?.startsWith('blob:')) {
URL.revokeObjectURL(message.previewDownloadUrl);
}
const timeoutId = this.systemMessageTimeouts.get(messageId); const timeoutId = this.systemMessageTimeouts.get(messageId);
if (typeof timeoutId !== 'undefined') { if (typeof timeoutId !== 'undefined') {
@@ -2557,6 +2571,38 @@ export class ChatSessionService {
this.unreadPeerIds.update((peerIds) => peerIds.filter((id) => id !== peerId)); this.unreadPeerIds.update((peerIds) => peerIds.filter((id) => id !== peerId));
} }
private upsertLocalCallStream(peerId: string, stream: MediaStream): void {
this.localCallStreams.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 upsertCallMode(
store: { update: (updater: (entries: Array<{ peerId: string; mode: CallMode }>) => Array<{ peerId: string; mode: CallMode }>) => void },
peerId: string,
mode: CallMode,
): void {
store.update((entries) => {
const existingIndex = entries.findIndex((entry) => entry.peerId === peerId);
if (existingIndex === -1) {
return [...entries, { peerId, mode }];
}
const nextEntries = [...entries];
nextEntries[existingIndex] = { peerId, mode };
return nextEntries;
});
}
private upsertRemoteVideoStream(peerId: string, stream: MediaStream): void { private upsertRemoteVideoStream(peerId: string, stream: MediaStream): void {
this.remoteVideoStreams.update((entries) => { this.remoteVideoStreams.update((entries) => {
const existingIndex = entries.findIndex((entry) => entry.peerId === peerId); const existingIndex = entries.findIndex((entry) => entry.peerId === peerId);
@@ -2587,29 +2633,39 @@ export class ChatSessionService {
private clearRemoteVideoState(peerId: string): void { private clearRemoteVideoState(peerId: string): void {
this.remoteVideoStreams.update((entries) => entries.filter((entry) => entry.peerId !== peerId)); this.remoteVideoStreams.update((entries) => entries.filter((entry) => entry.peerId !== peerId));
if (this.remoteVideoModalPeerId() === peerId) {
this.remoteVideoModalPeerId.set(null);
}
} }
private clearRemoteAudioState(peerId: string): void { private clearRemoteAudioState(peerId: string): void {
this.remoteAudioStreams.update((entries) => entries.filter((entry) => entry.peerId !== peerId)); this.remoteAudioStreams.update((entries) => entries.filter((entry) => entry.peerId !== peerId));
} }
private clearLocalCallStream(peerId: string): void {
this.localCallStreams.update((entries) => entries.filter((entry) => entry.peerId !== peerId));
}
private clearCallMode(
store: { update: (updater: (entries: Array<{ peerId: string; mode: CallMode }>) => Array<{ peerId: string; mode: CallMode }>) => void },
peerId: string,
): void {
store.update((entries) => entries.filter((entry) => entry.peerId !== peerId));
}
private clearVoiceCallSignals(peerId: string): void { private clearVoiceCallSignals(peerId: string): void {
if (this.incomingVoiceCallPeerId() === peerId) { if (this.incomingVoiceCallPeerId() === peerId) {
this.incomingVoiceCallPeerId.set(null); this.incomingVoiceCallPeerId.set(null);
this.stopRingtone(); this.stopRingtone();
} }
this.clearCallMode(this.incomingCallModes, peerId);
if (this.outgoingVoiceCallPeerId() === peerId) { if (this.outgoingVoiceCallPeerId() === peerId) {
this.outgoingVoiceCallPeerId.set(null); this.outgoingVoiceCallPeerId.set(null);
} }
this.clearCallMode(this.outgoingCallModes, peerId);
if (this.activeVoiceCallPeerId() === peerId) { if (this.activeVoiceCallPeerId() === peerId) {
this.activeVoiceCallPeerId.set(null); this.activeVoiceCallPeerId.set(null);
} }
this.clearCallMode(this.activeCallModes, peerId);
} }
private startRingtone(): void { private startRingtone(): void {
@@ -2875,6 +2931,67 @@ export class ChatSessionService {
return new Blob([bytes], { type: mimeType }); return new Blob([bytes], { type: mimeType });
} }
private async generateDocumentPreviewImage(
fileName: string,
fileBlob: Blob,
): Promise<{ blob: Blob; mimeType: string } | null> {
const token = this.token();
if (!token) {
return null;
}
try {
const response = await firstValueFrom(
this.http.post<DocumentPreviewImageResponse>(
`${this.serverUrl()}/api/files/document-preview-image`,
{
fileName,
mimeType: fileBlob.type || 'application/octet-stream',
fileBase64: await this.blobToBase64(fileBlob),
},
{
headers: { Authorization: `Bearer ${token}` },
},
),
);
return {
blob: this.base64ToBlob(response.imageBase64, response.mimeType),
mimeType: response.mimeType,
};
} catch (error) {
console.warn('Could not generate document preview image.', error);
return null;
}
}
private isPreviewableDocumentFile(fileName?: string, mimeType?: string): boolean {
return this.isOfficeDocumentFile(fileName, mimeType) || this.isPdfFile(fileName, mimeType);
}
private isOfficeDocumentFile(fileName?: string, mimeType?: string): boolean {
const normalizedName = fileName?.trim().toLowerCase() ?? '';
const normalizedMimeType = mimeType?.trim().toLowerCase() ?? '';
if (
normalizedMimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
) {
return true;
}
return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedName);
}
private isPdfFile(fileName?: string, mimeType?: string): boolean {
const normalizedName = fileName?.trim().toLowerCase() ?? '';
const normalizedMimeType = mimeType?.trim().toLowerCase() ?? '';
return normalizedMimeType === 'application/pdf' || normalizedName.endsWith('.pdf');
}
private fileExtensionForMimeType(mimeType: string): string { private fileExtensionForMimeType(mimeType: string): string {
const normalizedMimeType = mimeType.split(';', 1)[0]?.trim().toLowerCase() || 'application/octet-stream'; const normalizedMimeType = mimeType.split(';', 1)[0]?.trim().toLowerCase() || 'application/octet-stream';

View File

@@ -97,6 +97,7 @@ export interface ChatEntry {
kind: 'text' | 'json' | 'file' | 'voice' | 'system'; kind: 'text' | 'json' | 'file' | 'voice' | 'system';
createdAt: number; createdAt: number;
authorLabel: string; authorLabel: string;
generatedByAi?: boolean;
showSpinner?: boolean; showSpinner?: boolean;
text?: string; text?: string;
payload?: unknown; payload?: unknown;
@@ -104,8 +105,12 @@ export interface ChatEntry {
fileSize?: number; fileSize?: number;
fileMimeType?: string; fileMimeType?: string;
downloadUrl?: string; downloadUrl?: string;
previewMimeType?: string;
previewDownloadUrl?: string;
} }
export type CallMode = 'audio' | 'video';
export type SignalPayload = export type SignalPayload =
| { type: 'sdp'; description: RTCSessionDescriptionInit } | { type: 'sdp'; description: RTCSessionDescriptionInit }
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit }; | { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
@@ -179,12 +184,9 @@ export type DataEnvelope =
type: 'typing'; type: 'typing';
active: boolean; active: boolean;
} }
| {
type: 'camera-state';
active: boolean;
}
| { | {
type: 'voice-call-offer'; type: 'voice-call-offer';
mode: CallMode;
} }
| { | {
type: 'voice-call-response'; type: 'voice-call-response';

View File

@@ -0,0 +1,155 @@
:host {
display: contents;
}
.call-modal-backdrop {
position: fixed;
inset: 0;
z-index: 1250;
display: grid;
place-items: center;
padding: 1.5rem;
background:
radial-gradient(circle at top, rgba(78, 114, 255, 0.18), transparent 34%),
rgba(3, 8, 14, 0.82);
backdrop-filter: blur(16px);
}
.call-modal-card {
width: min(100%, 72rem);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 1.75rem;
background:
linear-gradient(180deg, rgba(9, 16, 28, 0.98), rgba(4, 8, 16, 0.96));
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.48);
}
.call-modal-header,
.call-modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem 1.25rem 0;
}
.call-modal-footer {
justify-content: flex-end;
padding: 1rem 1.25rem 1.25rem;
}
.call-modal-eyebrow {
margin-bottom: 0.35rem;
font-size: 0.78rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.64);
}
.call-modal-close {
width: 2.75rem;
height: 2.75rem;
border: 0;
border-radius: 999px;
color: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.08);
font-size: 1.5rem;
line-height: 1;
}
.call-modal-stage {
padding: 1.25rem;
}
.call-video-panel {
position: relative;
min-height: min(72vh, 42rem);
overflow: hidden;
border-radius: 1.35rem;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03));
}
.call-video-panel-local {
position: absolute;
right: 1rem;
bottom: 1rem;
width: min(22vw, 12rem);
min-height: auto;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 1rem;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.34);
backdrop-filter: blur(10px);
z-index: 1;
}
.call-video-label {
position: absolute;
top: 0.85rem;
left: 0.85rem;
z-index: 1;
padding: 0.35rem 0.7rem;
border-radius: 999px;
font-size: 0.82rem;
color: rgba(255, 255, 255, 0.88);
background: rgba(0, 0, 0, 0.34);
backdrop-filter: blur(8px);
}
.call-video-player,
.call-video-placeholder {
width: 100%;
height: 100%;
display: grid;
place-items: center;
background:
radial-gradient(circle at top, rgba(140, 191, 255, 0.18), transparent 36%),
#03070f;
aspect-ratio: 16 / 10;
}
.call-video-player {
display: block;
object-fit: cover;
}
.call-video-player-local {
transform: scaleX(-1);
}
.call-video-placeholder {
padding: 1.25rem;
text-align: center;
color: rgba(255, 255, 255, 0.7);
font-size: 0.98rem;
}
.call-video-placeholder-local {
min-height: 8rem;
font-size: 0.82rem;
}
@media (max-width: 767.98px) {
.call-modal-backdrop {
padding: 0.9rem;
}
.call-modal-card {
border-radius: 1.4rem;
}
.call-video-panel {
min-height: 18rem;
}
.call-video-panel-local {
right: 0.75rem;
bottom: 0.75rem;
width: min(38vw, 8.5rem);
}
.call-modal-header,
.call-modal-footer {
padding-inline: 1rem;
}
}

View File

@@ -0,0 +1,168 @@
import { CommonModule } from '@angular/common';
import {
AfterViewInit,
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
OnDestroy,
Output,
ViewChild,
} from '@angular/core';
import type { CallMode } from './models';
@Component({
selector: 'app-peer-call-modal',
imports: [CommonModule],
template: `
@if (visible) {
<div class="call-modal-backdrop">
<section class="call-modal-card" (click)="$event.stopPropagation()">
<header class="call-modal-header">
<div>
<p class="call-modal-eyebrow">Private {{ callMode === 'audio' ? 'audio' : 'video' }} call</p>
<h2 class="h4 mb-1">{{ peerName }}</h2>
<p class="small mb-0">{{ statusText }}</p>
</div>
<button
class="call-modal-close"
type="button"
(click)="requestDismiss()"
[attr.aria-label]="callState === 'incoming' ? 'Decline call' : 'End call'"
>
×
</button>
</header>
<div class="call-modal-stage">
<section class="call-video-panel call-video-panel-remote">
<div class="call-video-label">{{ callMode === 'audio' ? 'Peer audio' : 'Peer' }}</div>
@if (callMode === 'video' && remoteStream) {
<video #remoteVideoElement class="call-video-player" autoplay playsinline></video>
} @else {
<div class="call-video-placeholder">
{{
callMode === 'audio'
? 'Audio-only call in progress.'
: callState === 'incoming'
? 'Waiting for you to join.'
: 'Waiting for peer video…'
}}
</div>
}
<section class="call-video-panel call-video-panel-local">
<div class="call-video-label">You</div>
@if (callMode === 'video' && localStream) {
<video #localVideoElement class="call-video-player call-video-player-local" autoplay playsinline></video>
} @else {
<div class="call-video-placeholder call-video-placeholder-local">
{{ callMode === 'audio' ? 'Audio only' : callState === 'incoming' ? 'Camera starts when you accept.' : 'Starting your camera…' }}
</div>
}
</section>
</section>
</div>
<footer class="call-modal-footer">
@if (callState === 'incoming') {
<button class="btn btn-success" type="button" (click)="acceptRequested.emit()">
Accept
</button>
<button class="btn btn-outline-light" type="button" (click)="rejectRequested.emit()">
Reject
</button>
} @else {
<button class="btn btn-danger" type="button" (click)="hangupRequested.emit()">
{{ callState === 'outgoing' ? 'Cancel call' : 'End call' }}
</button>
}
</footer>
</section>
</div>
}
`,
styleUrl: './peer-call-modal.component.scss',
})
export class PeerCallModalComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input() visible = false;
@Input() peerName = 'Peer';
@Input() callState: 'incoming' | 'outgoing' | 'active' = 'active';
@Input() callMode: CallMode = 'video';
@Input() statusText = '';
@Input() localStream: MediaStream | null = null;
@Input() remoteStream: MediaStream | null = null;
@Output() readonly acceptRequested = new EventEmitter<void>();
@Output() readonly rejectRequested = new EventEmitter<void>();
@Output() readonly hangupRequested = new EventEmitter<void>();
@ViewChild('localVideoElement')
set localVideoElementRef(value: ElementRef<HTMLVideoElement> | undefined) {
this.localVideoElement = value;
this.syncVideoSources();
}
@ViewChild('remoteVideoElement')
set remoteVideoElementRef(value: ElementRef<HTMLVideoElement> | undefined) {
this.remoteVideoElement = value;
this.syncVideoSources();
}
private localVideoElement?: ElementRef<HTMLVideoElement>;
private remoteVideoElement?: ElementRef<HTMLVideoElement>;
ngAfterViewInit(): void {
this.syncVideoSources();
}
ngOnChanges(): void {
this.syncVideoSources();
}
ngOnDestroy(): void {
this.detachVideo(this.localVideoElement?.nativeElement);
this.detachVideo(this.remoteVideoElement?.nativeElement);
}
requestDismiss(): void {
if (this.callState === 'incoming') {
this.rejectRequested.emit();
return;
}
this.hangupRequested.emit();
}
private syncVideoSources(): void {
this.syncVideo(this.localVideoElement?.nativeElement, this.visible ? this.localStream : null, true);
this.syncVideo(this.remoteVideoElement?.nativeElement, this.visible ? this.remoteStream : null, true);
}
private syncVideo(video: HTMLVideoElement | undefined, stream: MediaStream | null, muted: boolean): void {
if (!video) {
return;
}
video.muted = muted;
video.srcObject = stream;
if (stream) {
void video.play().catch(() => {
// Autoplay can be delayed until the next user gesture on some platforms.
});
return;
}
video.pause();
}
private detachVideo(video: HTMLVideoElement | undefined): void {
if (!video) {
return;
}
video.pause();
video.srcObject = null;
}
}

View File

@@ -1,54 +0,0 @@
: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

@@ -1,86 +0,0 @@
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;
}
}

110
server/dist/index.js vendored
View File

@@ -1,14 +1,17 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { execFile } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { TextEncoder } from 'node:util'; import { promisify, TextEncoder } from 'node:util';
import { DatabaseSync } from 'node:sqlite'; import { DatabaseSync } from 'node:sqlite';
import cors from '@fastify/cors'; import cors from '@fastify/cors';
import jwt from '@fastify/jwt'; import jwt from '@fastify/jwt';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import websocket from '@fastify/websocket'; import websocket from '@fastify/websocket';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import libreOffice from 'libreoffice-convert';
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server'; import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
import Fastify from 'fastify'; import Fastify from 'fastify';
import { Redis } from 'ioredis'; import { Redis } from 'ioredis';
@@ -47,6 +50,11 @@ const adminDeleteUserParamsSchema = z.object({
const webBundleFileParamsSchema = z.object({ const webBundleFileParamsSchema = z.object({
'*': z.string().min(1), '*': z.string().min(1),
}); });
const documentPreviewSchema = z.object({
fileName: z.string().trim().min(1).max(256),
mimeType: z.string().trim().min(1).max(256),
fileBase64: z.string().min(1).max(96_000_000),
});
const wsQuerySchema = z.object({ const wsQuerySchema = z.object({
token: z.string().min(1), token: z.string().min(1),
}); });
@@ -111,6 +119,8 @@ const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat';
const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEBAUTHN_USER_VERIFICATION); const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEBAUTHN_USER_VERIFICATION);
const frontendIndexPath = path.join(frontendDistPath, 'index.html'); const frontendIndexPath = path.join(frontendDistPath, 'index.html');
const hasFrontendBuild = fs.existsSync(frontendIndexPath); const hasFrontendBuild = fs.existsSync(frontendIndexPath);
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
const execFileAsync = promisify(execFile);
const speechTranscriber = new SpeechTranscriber({ const speechTranscriber = new SpeechTranscriber({
serviceUrl: speechTranscriptionServiceUrl, serviceUrl: speechTranscriptionServiceUrl,
language: speechTranscriptionLanguage, language: speechTranscriptionLanguage,
@@ -462,6 +472,35 @@ app.get('/api/auth/session', async (request, reply) => {
messageEncryptionKey: authContext.user.messageEncryptionKey, messageEncryptionKey: authContext.user.messageEncryptionKey,
}; };
}); });
app.post('/api/files/document-preview-image', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
if (!authContext) {
return;
}
const parsed = documentPreviewSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({
message: 'Invalid document preview payload.',
issues: parsed.error.flatten(),
});
}
if (!isSupportedPreviewDocument(parsed.data.fileName, parsed.data.mimeType)) {
return reply.code(400).send({ message: 'Only PDF, DOCX, XLSX, and PPTX files can be previewed.' });
}
try {
const previewImageBuffer = await createDocumentPreviewImage(parsed.data.fileName, parsed.data.mimeType, parsed.data.fileBase64);
return {
mimeType: 'image/png',
imageBase64: previewImageBuffer.toString('base64'),
};
}
catch (error) {
app.log.warn({ err: error, userId: authContext.user.id }, 'Document preview generation failed');
return reply.code(422).send({
message: describeDocumentPreviewFailure(error),
});
}
});
app.get('/api/admin/pending-users', async (request, reply) => { app.get('/api/admin/pending-users', async (request, reply) => {
const authContext = await authenticateRequest(request, reply); const authContext = await authenticateRequest(request, reply);
if (!authContext) { if (!authContext) {
@@ -835,6 +874,75 @@ async function authenticateTokenFromSession(userId, sessionId, decoded) {
}, },
}; };
} }
async function convertOfficeDocumentToPdf(fileName, fileBase64) {
const inputBuffer = Buffer.from(fileBase64, 'base64');
if (inputBuffer.byteLength === 0) {
throw new Error('The uploaded office document is empty.');
}
const normalizedFileName = normalizeOfficeDocumentFileName(fileName);
return convertOfficeDocument(inputBuffer, '.pdf', undefined, { fileName: normalizedFileName });
}
async function createDocumentPreviewImage(fileName, mimeType, fileBase64) {
const normalizedMimeType = mimeType.trim().toLowerCase();
const pdfBuffer = normalizedMimeType === 'application/pdf'
? decodeBase64File(fileBase64, 'The uploaded PDF is empty.')
: await convertOfficeDocumentToPdf(fileName, fileBase64);
return renderPdfFirstPageToPng(pdfBuffer);
}
async function renderPdfFirstPageToPng(pdfBuffer) {
const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'privatechat-preview-'));
const pdfPath = path.join(tempDirectory, 'source.pdf');
const outputBasePath = path.join(tempDirectory, 'page-preview');
const imagePath = `${outputBasePath}.png`;
try {
await fs.promises.writeFile(pdfPath, pdfBuffer);
await execFileAsync('pdftoppm', ['-png', '-f', '1', '-singlefile', pdfPath, outputBasePath]);
return await fs.promises.readFile(imagePath);
}
finally {
await fs.promises.rm(tempDirectory, { recursive: true, force: true });
}
}
function decodeBase64File(fileBase64, emptyMessage) {
const inputBuffer = Buffer.from(fileBase64, 'base64');
if (inputBuffer.byteLength === 0) {
throw new Error(emptyMessage);
}
return inputBuffer;
}
function isSupportedPreviewDocument(fileName, mimeType) {
if (isPdfFile(fileName, mimeType)) {
return true;
}
return isSupportedOfficeDocument(fileName, mimeType);
}
function isSupportedOfficeDocument(fileName, mimeType) {
const normalizedFileName = fileName.trim().toLowerCase();
const normalizedMimeType = mimeType.trim().toLowerCase();
if (normalizedMimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') {
return true;
}
return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedFileName);
}
function isPdfFile(fileName, mimeType) {
const normalizedFileName = fileName.trim().toLowerCase();
const normalizedMimeType = mimeType.trim().toLowerCase();
return normalizedMimeType === 'application/pdf' || normalizedFileName.endsWith('.pdf');
}
function normalizeOfficeDocumentFileName(fileName) {
return fileName.trim().replace(/\.xslx$/i, '.xlsx');
}
function describeDocumentPreviewFailure(error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return 'Document preview generation failed because a required conversion tool is missing on the server.';
}
if (error instanceof Error && error.message.trim()) {
return `Document preview generation failed: ${error.message}`;
}
return 'Document preview generation failed.';
}
function createUser(input) { function createUser(input) {
const createdAt = new Date().toISOString(); const createdAt = new Date().toISOString();
const user = { const user = {

View File

@@ -16,6 +16,7 @@
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"fastify": "^5.8.2", "fastify": "^5.8.2",
"ioredis": "^5.10.0", "ioredis": "^5.10.0",
"libreoffice-convert": "^1.8.1",
"ws": "^8.19.0", "ws": "^8.19.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
@@ -1002,6 +1003,12 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/atomic-sleep": { "node_modules/atomic-sleep": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@@ -1536,6 +1543,19 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/libreoffice-convert": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/libreoffice-convert/-/libreoffice-convert-1.8.1.tgz",
"integrity": "sha512-iZ1DD/EMTlPvol8G++QQ/0w4pVecSwRuhMLXRm7nRim/gcaSscSXuTO9Tgbkieyw5UdJg7UXD+lkFT8SCi51Dw==",
"license": "MIT",
"dependencies": {
"async": "^3.2.3",
"tmp": "^0.2.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/light-my-request": { "node_modules/light-my-request": {
"version": "6.6.0", "version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
@@ -2029,6 +2049,15 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"license": "MIT",
"engines": {
"node": ">=14.14"
}
},
"node_modules/toad-cache": { "node_modules/toad-cache": {
"version": "3.7.0", "version": "3.7.0",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",

View File

@@ -17,6 +17,7 @@
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"fastify": "^5.8.2", "fastify": "^5.8.2",
"ioredis": "^5.10.0", "ioredis": "^5.10.0",
"libreoffice-convert": "^1.8.1",
"ws": "^8.19.0", "ws": "^8.19.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },

View File

@@ -1,8 +1,10 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { execFile } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { TextEncoder } from 'node:util'; import { promisify, TextEncoder } from 'node:util';
import { DatabaseSync } from 'node:sqlite'; import { DatabaseSync } from 'node:sqlite';
import cors from '@fastify/cors'; import cors from '@fastify/cors';
@@ -10,6 +12,7 @@ import jwt from '@fastify/jwt';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import websocket from '@fastify/websocket'; import websocket from '@fastify/websocket';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import libreOffice from 'libreoffice-convert';
import { import {
generateAuthenticationOptions, generateAuthenticationOptions,
generateRegistrationOptions, generateRegistrationOptions,
@@ -271,6 +274,12 @@ const webBundleFileParamsSchema = z.object({
'*': z.string().min(1), '*': z.string().min(1),
}); });
const documentPreviewSchema = z.object({
fileName: z.string().trim().min(1).max(256),
mimeType: z.string().trim().min(1).max(256),
fileBase64: z.string().min(1).max(96_000_000),
});
const wsQuerySchema = z.object({ const wsQuerySchema = z.object({
token: z.string().min(1), token: z.string().min(1),
}); });
@@ -346,6 +355,8 @@ const webAuthnUserVerification = resolveWebAuthnUserVerification(
); );
const frontendIndexPath = path.join(frontendDistPath, 'index.html'); const frontendIndexPath = path.join(frontendDistPath, 'index.html');
const hasFrontendBuild = fs.existsSync(frontendIndexPath); const hasFrontendBuild = fs.existsSync(frontendIndexPath);
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
const execFileAsync = promisify(execFile);
const speechTranscriber = new SpeechTranscriber( const speechTranscriber = new SpeechTranscriber(
{ {
@@ -795,6 +806,45 @@ app.get('/api/auth/session', async (request, reply) => {
}; };
}); });
app.post('/api/files/document-preview-image', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
if (!authContext) {
return;
}
const parsed = documentPreviewSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({
message: 'Invalid document preview payload.',
issues: parsed.error.flatten(),
});
}
if (!isSupportedPreviewDocument(parsed.data.fileName, parsed.data.mimeType)) {
return reply.code(400).send({ message: 'Only PDF, DOCX, XLSX, and PPTX files can be previewed.' });
}
try {
const previewImageBuffer = await createDocumentPreviewImage(
parsed.data.fileName,
parsed.data.mimeType,
parsed.data.fileBase64,
);
return {
mimeType: 'image/png',
imageBase64: previewImageBuffer.toString('base64'),
};
} catch (error) {
app.log.warn({ err: error, userId: authContext.user.id }, 'Document preview generation failed');
return reply.code(422).send({
message: describeDocumentPreviewFailure(error),
});
}
});
app.get('/api/admin/pending-users', async (request, reply) => { app.get('/api/admin/pending-users', async (request, reply) => {
const authContext = await authenticateRequest(request, reply); const authContext = await authenticateRequest(request, reply);
@@ -1294,6 +1344,101 @@ async function authenticateTokenFromSession(
}; };
} }
async function convertOfficeDocumentToPdf(fileName: string, fileBase64: string): Promise<Buffer> {
const inputBuffer = Buffer.from(fileBase64, 'base64');
if (inputBuffer.byteLength === 0) {
throw new Error('The uploaded office document is empty.');
}
const normalizedFileName = normalizeOfficeDocumentFileName(fileName);
return convertOfficeDocument(inputBuffer, '.pdf', undefined, { fileName: normalizedFileName });
}
async function createDocumentPreviewImage(
fileName: string,
mimeType: string,
fileBase64: string,
): Promise<Buffer> {
const normalizedMimeType = mimeType.trim().toLowerCase();
const pdfBuffer = normalizedMimeType === 'application/pdf'
? decodeBase64File(fileBase64, 'The uploaded PDF is empty.')
: await convertOfficeDocumentToPdf(fileName, fileBase64);
return renderPdfFirstPageToPng(pdfBuffer);
}
async function renderPdfFirstPageToPng(pdfBuffer: Buffer): Promise<Buffer> {
const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'privatechat-preview-'));
const pdfPath = path.join(tempDirectory, 'source.pdf');
const outputBasePath = path.join(tempDirectory, 'page-preview');
const imagePath = `${outputBasePath}.png`;
try {
await fs.promises.writeFile(pdfPath, pdfBuffer);
await execFileAsync('pdftoppm', ['-png', '-f', '1', '-singlefile', pdfPath, outputBasePath]);
return await fs.promises.readFile(imagePath);
} finally {
await fs.promises.rm(tempDirectory, { recursive: true, force: true });
}
}
function decodeBase64File(fileBase64: string, emptyMessage: string): Buffer {
const inputBuffer = Buffer.from(fileBase64, 'base64');
if (inputBuffer.byteLength === 0) {
throw new Error(emptyMessage);
}
return inputBuffer;
}
function isSupportedPreviewDocument(fileName: string, mimeType: string): boolean {
if (isPdfFile(fileName, mimeType)) {
return true;
}
return isSupportedOfficeDocument(fileName, mimeType);
}
function isSupportedOfficeDocument(fileName: string, mimeType: string): boolean {
const normalizedFileName = fileName.trim().toLowerCase();
const normalizedMimeType = mimeType.trim().toLowerCase();
if (
normalizedMimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
) {
return true;
}
return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedFileName);
}
function isPdfFile(fileName: string, mimeType: string): boolean {
const normalizedFileName = fileName.trim().toLowerCase();
const normalizedMimeType = mimeType.trim().toLowerCase();
return normalizedMimeType === 'application/pdf' || normalizedFileName.endsWith('.pdf');
}
function normalizeOfficeDocumentFileName(fileName: string): string {
return fileName.trim().replace(/\.xslx$/i, '.xlsx');
}
function describeDocumentPreviewFailure(error: unknown): string {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return 'Document preview generation failed because a required conversion tool is missing on the server.';
}
if (error instanceof Error && error.message.trim()) {
return `Document preview generation failed: ${error.message}`;
}
return 'Document preview generation failed.';
}
function createUser(input: { function createUser(input: {
username: string; username: string;
displayName: string; displayName: string;