Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11cc5350c8 | |||
| 0e4c79b735 | |||
| ffdea4fe62 |
14
client/package-lock.json
generated
14
client/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
155
client/src/app/peer-call-modal.component.scss
Normal file
155
client/src/app/peer-call-modal.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
168
client/src/app/peer-call-modal.component.ts
Normal file
168
client/src/app/peer-call-modal.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
110
server/dist/index.js
vendored
@@ -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 = {
|
||||
|
||||
29
server/package-lock.json
generated
29
server/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user