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/router": "^21.2.0",
"bootstrap": "^5.3.8",
"ngx-extended-pdf-viewer": "^25.6.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
@@ -5960,6 +5961,19 @@
"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": {
"version": "6.1.0",
"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/router": "^21.2.0",
"bootstrap": "^5.3.8",
"ngx-extended-pdf-viewer": "^25.6.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},

View File

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

View File

@@ -33,6 +33,63 @@
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 {
color: var(--link-color);
text-decoration: none;
@@ -470,6 +527,27 @@
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-video {
width: 200px;

View File

@@ -4,14 +4,20 @@ 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 { PeerCallModalComponent } from './peer-call-modal.component';
import { ChatSessionService } from './chat-session.service';
import { JsonFileViewerComponent } from './json-file-viewer.component';
import type { ChatEntry, ConnectionState, PeerSummary } from './models';
import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models';
@Component({
selector: 'app-chat-page',
imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent, PeerVideoModalComponent],
imports: [
CommonModule,
FormsModule,
RouterLink,
JsonFileViewerComponent,
PeerCallModalComponent,
],
templateUrl: './chat-page.component.html',
styleUrl: './chat-page.component.scss',
})
@@ -37,15 +43,22 @@ export class ChatPageComponent implements OnDestroy {
private dictationCompletionPromise: Promise<void> | null = null;
private resolveDictationCompletion: (() => void) | null = null;
private dictationApplyToken = 0;
private lastConversationSnapshot: { peerId: string; length: number; lastEntryId: string | null } | null = null;
@ViewChild('callAudioElement')
set callAudioElementRef(value: ElementRef<HTMLAudioElement> | undefined) {
this.callAudioElement = value;
this.syncCallAudioSource();
}
private callAudioElement?: ElementRef<HTMLAudioElement>;
@ViewChild('conversationContainer')
set conversationContainerRef(value: ElementRef<HTMLDivElement> | undefined) {
this.conversationContainer = value;
}
private conversationContainer?: ElementRef<HTMLDivElement>;
messageText = '';
readonly forwardingEntryId = signal<string | null>(null);
readonly callChoicePeerId = signal<string | null>(null);
readonly emojiPickerOpen = signal(false);
readonly isRecordingVoice = signal(false);
readonly isDictating = signal(false);
@@ -63,8 +76,19 @@ export class ChatPageComponent implements OnDestroy {
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());
readonly incomingVoiceCallPeer = computed(() => {
const peerId = this.session.incomingVoiceCallPeerId();
readonly callModalPeerId = computed(() =>
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;
});
@@ -73,13 +97,50 @@ export class ChatPageComponent implements OnDestroy {
.messages()
.filter((entry) => entry.peerId === this.peerId()),
);
readonly remoteVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.peerId()));
readonly remoteCallAudioStream = computed(() =>
this.session.remoteAudioStreamForPeer(this.session.activeVoiceCallPeerId() ?? ''),
);
readonly remoteVideoModalVisible = computed(
() => this.session.remoteVideoModalPeerId() === this.peerId() && !!this.remoteVideoStream(),
this.session.remoteAudioStreamForPeer(this.callModalPeerId() ?? ''),
);
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'>(() => {
const peerId = this.peerId();
@@ -160,6 +221,32 @@ export class ChatPageComponent implements OnDestroy {
this.remoteCallAudioStream();
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 {
@@ -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> {
const file = input.files?.item(0);
@@ -474,17 +584,14 @@ export class ChatPageComponent implements OnDestroy {
this.forwardingEntryId.set(null);
}
async toggleCameraStream(peerId: string): Promise<void> {
if (this.session.isStreamingCameraToPeer(peerId)) {
await this.session.stopCameraStream(peerId);
async sendGeneratedImage(entry: ChatEntry): Promise<void> {
const peerId = this.peerId();
if (!peerId) {
return;
}
await this.session.startCameraStream(peerId);
}
async startVoiceCall(peerId: string): Promise<void> {
await this.session.startVoiceCall(peerId);
await this.session.sendGeneratedImageToPeer(entry, peerId);
}
async endVoiceCall(peerId: string): Promise<void> {
@@ -496,10 +603,6 @@ export class ChatPageComponent implements OnDestroy {
return;
}
if (peerId !== this.peerId()) {
await this.router.navigate(['/chat', 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);
}
isGeneratedImageEntry(entry: ChatEntry): boolean {
return this.isImageEntry(entry) && entry.generatedByAi === true;
}
isVideoEntry(entry: ChatEntry): boolean {
if (entry.kind !== 'file' || !entry.downloadUrl) {
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 {
return this.session.typingPeerIds().includes(peerId);
}
@@ -561,22 +684,6 @@ export class ChatPageComponent implements OnDestroy {
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;
@@ -585,6 +692,7 @@ export class ChatPageComponent implements OnDestroy {
await this.stopDictation(true);
this.stopVoiceRecording(true);
this.forwardingEntryId.set(null);
this.callChoicePeerId.set(null);
this.emojiPickerOpen.set(false);
this.session.selectPeer(peerId);
await this.router.navigate(['/chat', peerId]);
@@ -764,4 +872,18 @@ export class ChatPageComponent implements OnDestroy {
audio.pause();
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,
AuthenticationOptionsResponse,
AuthResponse,
CallMode,
ChatEntry,
ConnectionState,
DataEnvelope,
@@ -26,11 +27,9 @@ type PeerBundle = {
pendingCandidates: RTCIceCandidateInit[];
pendingNegotiation: boolean;
announceConnectionEvents: boolean;
localCameraStream?: MediaStream;
cameraSenders: RTCRtpSender[];
localCallStream?: MediaStream;
mediaSenders: RTCRtpSender[];
remoteCameraStream?: MediaStream;
localAudioStream?: MediaStream;
audioSenders: RTCRtpSender[];
remoteAudioStream?: MediaStream;
};
@@ -58,12 +57,15 @@ type LegacyPersistedChatEntry = {
kind: Exclude<ChatEntry['kind'], 'system'>;
createdAt: number;
authorLabel: string;
generatedByAi?: boolean;
text?: string;
payload?: unknown;
fileName?: string;
fileSize?: number;
fileMimeType?: string;
fileBlob?: Blob;
previewMimeType?: string;
previewBlob?: Blob;
};
type EncryptedPersistedChatEntry = {
@@ -79,17 +81,26 @@ type EncryptedPersistedChatEntry = {
payloadIv: number[];
encryptedFileBlob?: PersistedBinary;
fileIv?: number[];
encryptedPreviewBlob?: PersistedBinary;
previewIv?: number[];
};
type PersistedChatEntry = LegacyPersistedChatEntry | EncryptedPersistedChatEntry;
type PersistedChatEntryContent = {
authorLabel: string;
generatedByAi?: boolean;
text?: string;
payload?: unknown;
fileName?: string;
fileSize?: number;
fileMimeType?: string;
previewMimeType?: string;
};
type DocumentPreviewImageResponse = {
mimeType: string;
imageBase64: string;
};
type RuntimeEnv = {
@@ -128,7 +139,6 @@ export class ChatSessionService {
readonly messages = signal<ChatEntry[]>([]);
readonly unreadPeerIds = signal<string[]>([]);
readonly typingPeerIds = signal<string[]>([]);
readonly remoteVideoModalPeerId = signal<string | null>(null);
readonly incomingVoiceCallPeerId = signal<string | null>(null);
readonly outgoingVoiceCallPeerId = signal<string | null>(null);
readonly activeVoiceCallPeerId = signal<string | null>(null);
@@ -174,10 +184,13 @@ export class ChatSessionService {
string,
{ 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 remoteAudioStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]);
private readonly activeCameraPeerId = signal<string | null>(null);
private readonly activeAudioPeerId = signal<string | null>(null);
private readonly localCallPeerId = signal<string | null>(null);
private sessionKeepaliveIntervalId: number | null = null;
private websocketHeartbeatIntervalId: number | null = null;
private websocketReconnectTimeoutId: number | null = null;
@@ -369,94 +382,15 @@ export class ChatSessionService {
await this.negotiatePeer(peerId, bundle);
}
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;
localCallStreamForPeer(peerId: string): MediaStream | null {
return this.localCallStreams().find((entry) => entry.peerId === peerId)?.stream ?? null;
}
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;
callModeForPeer(peerId: string): CallMode | null {
return this.activeCallModes().find((entry) => entry.peerId === peerId)?.mode
?? this.incomingCallModes().find((entry) => entry.peerId === peerId)?.mode
?? this.outgoingCallModes().find((entry) => entry.peerId === peerId)?.mode
?? null;
}
remoteVideoStreamForPeer(peerId: string): MediaStream | null {
@@ -467,29 +401,34 @@ export class ChatSessionService {
return this.remoteAudioStreams().find((entry) => entry.peerId === peerId)?.stream ?? null;
}
dismissRemoteVideoModal(peerId: string): void {
if (this.remoteVideoModalPeerId() === peerId) {
this.remoteVideoModalPeerId.set(null);
}
}
async startVoiceCall(peerId: string): Promise<void> {
async startVoiceCall(peerId: string, mode: CallMode): Promise<void> {
const channel = this.requireOpenChannel(peerId);
if (!channel) {
return;
}
if (this.hasVoiceCallConflict(peerId) || this.outgoingVoiceCallPeerId() === peerId || this.activeVoiceCallPeerId() === peerId) {
this.error.set('Finish the current voice call before starting another one.');
if (
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;
}
this.error.set(null);
this.incomingVoiceCallPeerId.set(null);
this.outgoingVoiceCallPeerId.set(peerId);
channel.send(JSON.stringify({ type: 'voice-call-offer' } satisfies DataEnvelope));
this.addSystemMessage(peerId, 'Calling peer.');
this.upsertCallMode(this.outgoingCallModes, peerId, mode);
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> {
@@ -497,7 +436,8 @@ export class ChatSessionService {
return;
}
const bundle = await this.ensureLocalAudioStream(peerId);
const mode = this.callModeForPeer(peerId) ?? 'video';
const bundle = await this.ensureLocalCallStream(peerId, mode);
if (!bundle) {
this.sendVoiceCallResponse(peerId, false);
@@ -507,10 +447,12 @@ export class ChatSessionService {
this.stopRingtone();
this.incomingVoiceCallPeerId.set(null);
this.clearCallMode(this.incomingCallModes, peerId);
this.outgoingVoiceCallPeerId.set(null);
this.activeVoiceCallPeerId.set(peerId);
this.upsertCallMode(this.activeCallModes, peerId, mode);
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);
}
@@ -520,15 +462,16 @@ export class ChatSessionService {
}
this.sendVoiceCallResponse(peerId, false);
void this.stopLocalCallStream(peerId, false);
this.clearVoiceCallSignals(peerId);
this.addSystemMessage(peerId, 'Voice call rejected.');
this.addSystemMessage(peerId, 'Call rejected.');
}
async endVoiceCall(peerId: string, notifyPeer = true): Promise<void> {
const hadVoiceCall = this.incomingVoiceCallPeerId() === peerId
|| this.outgoingVoiceCallPeerId() === peerId
|| this.activeVoiceCallPeerId() === peerId
|| this.activeAudioPeerId() === peerId;
|| this.localCallPeerId() === peerId;
if (!hadVoiceCall) {
return;
@@ -538,10 +481,11 @@ export class ChatSessionService {
this.sendVoiceCallEnded(peerId);
}
await this.stopLocalAudioStream(peerId, true);
await this.stopLocalCallStream(peerId, true);
this.clearRemoteVideoState(peerId);
this.clearRemoteAudioState(peerId);
this.clearVoiceCallSignals(peerId);
this.addSystemMessage(peerId, 'Voice call ended.');
this.addSystemMessage(peerId, 'Call ended.');
}
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> {
this.error.set(null);
this.notice.set(null);
@@ -1123,6 +1092,7 @@ export class ChatSessionService {
kind: 'file',
createdAt: event.createdAt,
authorLabel: 'You',
generatedByAi: true,
text: pendingRequest?.prompt ?? event.prompt,
fileName,
fileSize: imageBlob.size,
@@ -1394,8 +1364,7 @@ export class ChatSessionService {
pendingCandidates: [],
pendingNegotiation: false,
announceConnectionEvents: announce,
cameraSenders: [],
audioSenders: [],
mediaSenders: [],
};
bundle.pc.onicecandidate = (event) => {
@@ -1443,7 +1412,6 @@ export class ChatSessionService {
bundle.remoteCameraStream = remoteStream;
this.upsertRemoteVideoStream(peerId, remoteStream);
this.remoteVideoModalPeerId.set(peerId);
event.track.onended = () => {
if (!bundle.remoteCameraStream) {
@@ -1573,20 +1541,13 @@ export class ChatSessionService {
this.addSystemMessage(peerId, `Receiving file ${envelope.name}.`);
break;
case 'file-complete':
this.finalizeIncomingFile(peerId, envelope.id);
void this.finalizeIncomingFile(peerId, envelope.id);
break;
case 'typing':
this.setPeerTyping(peerId, envelope.active);
break;
case 'camera-state':
if (envelope.active) {
this.remoteVideoModalPeerId.set(peerId);
} else {
this.clearRemoteVideoState(peerId);
}
break;
case 'voice-call-offer':
this.handleIncomingVoiceCallOffer(peerId);
this.handleIncomingVoiceCallOffer(peerId, envelope.mode);
break;
case 'voice-call-response':
void this.handleVoiceCallResponse(peerId, envelope.accepted);
@@ -1610,15 +1571,30 @@ export class ChatSessionService {
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);
if (!transfer || transfer.id !== transferId) {
return;
}
this.incomingFiles.delete(peerId);
const blob = new Blob(transfer.chunks, { type: transfer.mimeType });
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({
id: transfer.id,
@@ -1631,9 +1607,9 @@ export class ChatSessionService {
fileSize: transfer.size,
fileMimeType: transfer.mimeType,
downloadUrl,
}, blob);
this.incomingFiles.delete(peerId);
previewMimeType,
previewDownloadUrl,
}, blob, previewBlob);
}
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 {
const channel = this.peerBundles.get(peerId)?.channel;
@@ -1760,35 +1726,37 @@ export class ChatSessionService {
}
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);
}
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') {
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;
}
if (this.activeAudioPeerId() && this.activeAudioPeerId() !== peerId) {
this.error.set('Finish the current voice call before starting another one.');
if (this.localCallPeerId() && this.localCallPeerId() !== peerId) {
this.error.set('Finish the current call before starting another one.');
return null;
}
const bundle = this.ensurePeerBundle(peerId, true);
if (bundle.localAudioStream) {
if (bundle.localCallStream) {
return bundle;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false,
video: mode === 'video',
});
bundle.localAudioStream = stream;
bundle.audioSenders = stream.getTracks().map((track) => {
bundle.localCallStream = stream;
bundle.mediaSenders = stream.getTracks().map((track) => {
track.onended = () => {
void this.endVoiceCall(peerId);
};
@@ -1796,49 +1764,54 @@ export class ChatSessionService {
return bundle.pc.addTrack(track, stream);
});
this.activeAudioPeerId.set(peerId);
this.localCallPeerId.set(peerId);
this.upsertLocalCallStream(peerId, stream);
return bundle;
} 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;
}
}
private async stopLocalAudioStream(peerId: string, renegotiate: boolean): Promise<void> {
private async stopLocalCallStream(peerId: string, renegotiate: boolean): Promise<void> {
const bundle = this.peerBundles.get(peerId);
if (!bundle?.localAudioStream && this.activeAudioPeerId() !== peerId) {
if (!bundle?.localCallStream && this.localCallPeerId() !== peerId) {
return;
}
if (bundle) {
for (const sender of bundle.audioSenders) {
for (const sender of bundle.mediaSenders) {
bundle.pc.removeTrack(sender);
}
bundle.audioSenders = [];
bundle.mediaSenders = [];
if (bundle.localAudioStream) {
for (const track of bundle.localAudioStream.getTracks()) {
if (bundle.localCallStream) {
for (const track of bundle.localCallStream.getTracks()) {
track.onended = null;
track.stop();
}
}
bundle.localAudioStream = undefined;
bundle.localCallStream = undefined;
}
if (this.activeAudioPeerId() === peerId) {
this.activeAudioPeerId.set(null);
if (this.localCallPeerId() === peerId) {
this.localCallPeerId.set(null);
}
this.clearLocalCallStream(peerId);
if (renegotiate && bundle) {
await this.negotiatePeer(peerId, bundle);
}
}
private handleIncomingVoiceCallOffer(peerId: string): void {
if (this.hasVoiceCallConflict(peerId) || this.activeAudioPeerId()) {
private handleIncomingVoiceCallOffer(peerId: string, mode: CallMode): void {
if (this.hasVoiceCallConflict(peerId) || this.localCallPeerId()) {
this.sendVoiceCallResponse(peerId, false);
return;
}
@@ -1846,8 +1819,9 @@ export class ChatSessionService {
this.outgoingVoiceCallPeerId.set(null);
this.activeVoiceCallPeerId.set(null);
this.incomingVoiceCallPeerId.set(peerId);
this.upsertCallMode(this.incomingCallModes, peerId, mode);
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> {
@@ -1858,19 +1832,24 @@ export class ChatSessionService {
this.outgoingVoiceCallPeerId.set(null);
if (!accepted) {
this.addSystemMessage(peerId, 'Voice call declined.');
this.clearCallMode(this.outgoingCallModes, peerId);
await this.stopLocalCallStream(peerId, true);
this.addSystemMessage(peerId, 'Call declined.');
return;
}
const mode = this.callModeForPeer(peerId) ?? 'video';
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) {
await this.endVoiceCall(peerId);
return;
}
this.addSystemMessage(peerId, 'Voice call connected.');
this.addSystemMessage(peerId, mode === 'video' ? 'Video call connected.' : 'Audio call connected.');
await this.negotiatePeer(peerId, bundle);
}
@@ -1878,14 +1857,15 @@ export class ChatSessionService {
const hadVoiceCall = this.incomingVoiceCallPeerId() === peerId
|| this.outgoingVoiceCallPeerId() === 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.clearVoiceCallSignals(peerId);
if (hadVoiceCall) {
this.addSystemMessage(peerId, 'Voice call ended.');
this.addSystemMessage(peerId, 'Call ended.');
}
}
@@ -1902,27 +1882,18 @@ export class ChatSessionService {
return;
}
if (bundle.localCameraStream) {
for (const track of bundle.localCameraStream.getTracks()) {
if (bundle.localCallStream) {
for (const track of bundle.localCallStream.getTracks()) {
track.onended = null;
track.stop();
}
}
if (bundle.localAudioStream) {
for (const track of bundle.localAudioStream.getTracks()) {
track.onended = null;
track.stop();
}
if (this.localCallPeerId() === peerId) {
this.localCallPeerId.set(null);
}
if (this.activeCameraPeerId() === peerId) {
this.activeCameraPeerId.set(null);
}
if (this.activeAudioPeerId() === peerId) {
this.activeAudioPeerId.set(null);
}
this.clearLocalCallStream(peerId);
bundle.channel?.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));
if (entry.direction === 'incoming' && entry.kind !== 'system' && this.activePeerId() !== entry.peerId) {
@@ -1957,7 +1928,7 @@ export class ChatSessionService {
}
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.pendingImageGenerationRequests.clear();
this.rejectPendingSpeechTranscriptions('Session ended during dictation.');
this.incomingCallModes.set([]);
this.outgoingCallModes.set([]);
this.activeCallModes.set([]);
this.localCallStreams.set([]);
this.remoteVideoStreams.set([]);
this.remoteAudioStreams.set([]);
this.remoteVideoModalPeerId.set(null);
this.activeCameraPeerId.set(null);
this.activeAudioPeerId.set(null);
this.localCallPeerId.set(null);
this.incomingVoiceCallPeerId.set(null);
this.outgoingVoiceCallPeerId.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 messageEncryptionKey = this.messageEncryptionKey;
@@ -2214,15 +2187,20 @@ export class ChatSessionService {
const storageKey = this.messageStorageKey(currentUserId, entry.peerId, entry.id);
const encryptedPayload = await this.encryptPersistedMessageContent(messageEncryptionKey, {
authorLabel: entry.authorLabel,
generatedByAi: entry.generatedByAi,
text: entry.text,
payload: entry.payload,
fileName: entry.fileName,
fileSize: entry.fileSize,
fileMimeType: entry.fileMimeType,
previewMimeType: entry.previewMimeType,
});
const encryptedFileBlob = fileBlob
? await this.encryptBinary(messageEncryptionKey, await fileBlob.arrayBuffer())
: null;
const encryptedPreviewBlob = previewBlob
? await this.encryptBinary(messageEncryptionKey, await previewBlob.arrayBuffer())
: null;
const persistedEntry: EncryptedPersistedChatEntry = {
storageKey,
ownerUserId: currentUserId,
@@ -2238,6 +2216,10 @@ export class ChatSessionService {
? this.serializePersistedBinary(encryptedFileBlob.ciphertext)
: 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 () => {
@@ -2284,6 +2266,7 @@ export class ChatSessionService {
try {
const content = await this.decryptPersistedMessageContent(messageEncryptionKey, entry);
let downloadUrl: string | undefined;
let previewDownloadUrl: string | undefined;
if (entry.encryptedFileBlob && entry.fileIv) {
const decryptedFile = await this.decryptBinary(
@@ -2297,6 +2280,18 @@ export class ChatSessionService {
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 {
id: entry.id,
peerId: entry.peerId,
@@ -2304,12 +2299,15 @@ export class ChatSessionService {
kind: entry.kind,
createdAt: entry.createdAt,
authorLabel: content.authorLabel,
generatedByAi: content.generatedByAi,
text: content.text,
payload: content.payload,
fileName: content.fileName,
fileSize: content.fileSize,
fileMimeType: content.fileMimeType,
downloadUrl,
previewMimeType: content.previewMimeType,
previewDownloadUrl,
};
} catch (error) {
console.warn('Could not decrypt persisted chat message.', error);
@@ -2325,12 +2323,15 @@ export class ChatSessionService {
kind: entry.kind,
createdAt: entry.createdAt,
authorLabel: entry.authorLabel,
generatedByAi: entry.generatedByAi,
text: entry.text,
payload: entry.payload,
fileName: entry.fileName,
fileSize: entry.fileSize,
fileMimeType: entry.fileMimeType,
previewMimeType: entry.previewMimeType,
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',
})
: 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> {
@@ -2373,6 +2379,10 @@ export class ChatSessionService {
if (entry.downloadUrl?.startsWith('blob:')) {
URL.revokeObjectURL(entry.downloadUrl);
}
if (entry.previewDownloadUrl?.startsWith('blob:')) {
URL.revokeObjectURL(entry.previewDownloadUrl);
}
}
}
@@ -2531,6 +2541,10 @@ export class ChatSessionService {
URL.revokeObjectURL(message.downloadUrl);
}
if (message.previewDownloadUrl?.startsWith('blob:')) {
URL.revokeObjectURL(message.previewDownloadUrl);
}
const timeoutId = this.systemMessageTimeouts.get(messageId);
if (typeof timeoutId !== 'undefined') {
@@ -2557,6 +2571,38 @@ export class ChatSessionService {
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 {
this.remoteVideoStreams.update((entries) => {
const existingIndex = entries.findIndex((entry) => entry.peerId === peerId);
@@ -2587,29 +2633,39 @@ export class ChatSessionService {
private clearRemoteVideoState(peerId: string): void {
this.remoteVideoStreams.update((entries) => entries.filter((entry) => entry.peerId !== peerId));
if (this.remoteVideoModalPeerId() === peerId) {
this.remoteVideoModalPeerId.set(null);
}
}
private clearRemoteAudioState(peerId: string): void {
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 {
if (this.incomingVoiceCallPeerId() === peerId) {
this.incomingVoiceCallPeerId.set(null);
this.stopRingtone();
}
this.clearCallMode(this.incomingCallModes, peerId);
if (this.outgoingVoiceCallPeerId() === peerId) {
this.outgoingVoiceCallPeerId.set(null);
}
this.clearCallMode(this.outgoingCallModes, peerId);
if (this.activeVoiceCallPeerId() === peerId) {
this.activeVoiceCallPeerId.set(null);
}
this.clearCallMode(this.activeCallModes, peerId);
}
private startRingtone(): void {
@@ -2875,6 +2931,67 @@ export class ChatSessionService {
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 {
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';
createdAt: number;
authorLabel: string;
generatedByAi?: boolean;
showSpinner?: boolean;
text?: string;
payload?: unknown;
@@ -104,8 +105,12 @@ export interface ChatEntry {
fileSize?: number;
fileMimeType?: string;
downloadUrl?: string;
previewMimeType?: string;
previewDownloadUrl?: string;
}
export type CallMode = 'audio' | 'video';
export type SignalPayload =
| { type: 'sdp'; description: RTCSessionDescriptionInit }
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
@@ -179,12 +184,9 @@ export type DataEnvelope =
type: 'typing';
active: boolean;
}
| {
type: 'camera-state';
active: boolean;
}
| {
type: 'voice-call-offer';
mode: CallMode;
}
| {
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 { execFile } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { TextEncoder } from 'node:util';
import { promisify, TextEncoder } from 'node:util';
import { DatabaseSync } from 'node:sqlite';
import cors from '@fastify/cors';
import jwt from '@fastify/jwt';
import fastifyStatic from '@fastify/static';
import websocket from '@fastify/websocket';
import dotenv from 'dotenv';
import libreOffice from 'libreoffice-convert';
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
import Fastify from 'fastify';
import { Redis } from 'ioredis';
@@ -47,6 +50,11 @@ const adminDeleteUserParamsSchema = z.object({
const webBundleFileParamsSchema = z.object({
'*': 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({
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 frontendIndexPath = path.join(frontendDistPath, 'index.html');
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
const execFileAsync = promisify(execFile);
const speechTranscriber = new SpeechTranscriber({
serviceUrl: speechTranscriptionServiceUrl,
language: speechTranscriptionLanguage,
@@ -462,6 +472,35 @@ app.get('/api/auth/session', async (request, reply) => {
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) => {
const authContext = await authenticateRequest(request, reply);
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) {
const createdAt = new Date().toISOString();
const user = {

View File

@@ -16,6 +16,7 @@
"dotenv": "^17.3.1",
"fastify": "^5.8.2",
"ioredis": "^5.10.0",
"libreoffice-convert": "^1.8.1",
"ws": "^8.19.0",
"zod": "^4.3.6"
},
@@ -1002,6 +1003,12 @@
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@@ -1536,6 +1543,19 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"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": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
@@ -2029,6 +2049,15 @@
"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": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",

View File

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

View File

@@ -1,8 +1,10 @@
import crypto from 'node:crypto';
import { execFile } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { TextEncoder } from 'node:util';
import { promisify, TextEncoder } from 'node:util';
import { DatabaseSync } from 'node:sqlite';
import cors from '@fastify/cors';
@@ -10,6 +12,7 @@ import jwt from '@fastify/jwt';
import fastifyStatic from '@fastify/static';
import websocket from '@fastify/websocket';
import dotenv from 'dotenv';
import libreOffice from 'libreoffice-convert';
import {
generateAuthenticationOptions,
generateRegistrationOptions,
@@ -271,6 +274,12 @@ const webBundleFileParamsSchema = z.object({
'*': 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({
token: z.string().min(1),
});
@@ -346,6 +355,8 @@ const webAuthnUserVerification = resolveWebAuthnUserVerification(
);
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
const execFileAsync = promisify(execFile);
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) => {
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: {
username: string;
displayName: string;