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/platform-browser": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/router": "^21.2.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
|
"ngx-extended-pdf-viewer": "^25.6.4",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
@@ -5960,6 +5961,19 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ngx-extended-pdf-viewer": {
|
||||||
|
"version": "25.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-25.6.4.tgz",
|
||||||
|
"integrity": "sha512-eYIiWzatcupB7HKDtcOOZN7gcLFjqAkeIAlZOMIO6XyUJnTe+PUZLZGit/19mtO/8fAaH41lMyyh8MAcU8NAhA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@angular/common": ">=17.0.0 <22.0.0",
|
||||||
|
"@angular/core": ">=17.0.0 <22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-addon-api": {
|
"node_modules/node-addon-api": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/router": "^21.2.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
|
"ngx-extended-pdf-viewer": "^25.6.4",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,37 +1,39 @@
|
|||||||
<main class="chat-shell py-4">
|
<main class="chat-shell py-4">
|
||||||
<div class="container-lg">
|
<div class="container-lg">
|
||||||
<section class="chat-page panel p-3 p-lg-4">
|
<section class="chat-page panel p-3 p-lg-4">
|
||||||
<app-peer-video-modal
|
<app-peer-call-modal
|
||||||
[visible]="remoteVideoModalVisible()"
|
[visible]="callModalVisible()"
|
||||||
[stream]="remoteVideoStream()"
|
[peerName]="callModalPeer()?.displayName ?? 'Peer'"
|
||||||
[title]="(peer()?.displayName ?? 'Peer') + ' webcam'"
|
[callState]="callModalState()"
|
||||||
(closeRequested)="closeRemoteVideoModal()"
|
[callMode]="callModalMode()"
|
||||||
></app-peer-video-modal>
|
[statusText]="callModalStatusText()"
|
||||||
|
[localStream]="localCallStream()"
|
||||||
|
[remoteStream]="remoteCallVideoStream()"
|
||||||
|
(acceptRequested)="callModalPeer() && acceptIncomingVoiceCall(callModalPeer()!.id)"
|
||||||
|
(rejectRequested)="callModalPeer() && rejectIncomingVoiceCall(callModalPeer()!.id)"
|
||||||
|
(hangupRequested)="callModalPeer() && endVoiceCall(callModalPeer()!.id)"
|
||||||
|
></app-peer-call-modal>
|
||||||
<audio #callAudioElement hidden autoplay playsinline></audio>
|
<audio #callAudioElement hidden autoplay playsinline></audio>
|
||||||
|
|
||||||
@if (incomingVoiceCallPeer(); as callingPeer) {
|
@if (callChoicePeer(); as selectedCallPeer) {
|
||||||
<div class="call-modal-backdrop">
|
<div class="call-choice-backdrop" (click)="closeCallChoice()">
|
||||||
<section class="panel p-4" style="width:min(100%,24rem)" (click)="$event.stopPropagation()">
|
<section class="call-choice-card panel p-4" (click)="$event.stopPropagation()">
|
||||||
<div class="mb-3">
|
<p class="call-choice-eyebrow">Start a call</p>
|
||||||
<div>
|
<h2 class="h5 mb-2">{{ selectedCallPeer.displayName }}</h2>
|
||||||
<h2 class="h5 mb-1">Incoming voice call</h2>
|
<p class="small mb-3">Choose whether to place a full video call or audio only.</p>
|
||||||
<p class="small mb-0">{{ callingPeer.displayName }} is calling you.</p>
|
<div class="call-choice-actions">
|
||||||
</div>
|
<button class="call-choice-button" type="button" (click)="startSelectedCall('video')">
|
||||||
</div>
|
<span class="call-choice-icon">📹</span>
|
||||||
<div class="d-flex flex-wrap gap-2 justify-content-end">
|
<span>Video call</span>
|
||||||
<button
|
|
||||||
class="btn btn-success"
|
|
||||||
type="button"
|
|
||||||
(click)="acceptIncomingVoiceCall(callingPeer.id)"
|
|
||||||
>
|
|
||||||
Accept
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button class="call-choice-button" type="button" (click)="startSelectedCall('audio')">
|
||||||
class="btn btn-outline-secondary"
|
<span class="call-choice-icon">🎙️</span>
|
||||||
type="button"
|
<span>Audio only</span>
|
||||||
(click)="rejectIncomingVoiceCall(callingPeer.id)"
|
</button>
|
||||||
>
|
</div>
|
||||||
Reject
|
<div class="d-flex justify-content-end mt-3">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" (click)="closeCallChoice()">
|
||||||
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -127,7 +129,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="chat-main">
|
<div class="chat-main">
|
||||||
<div class="conversation">
|
<div #conversationContainer class="conversation">
|
||||||
@if (conversation().length === 0) {
|
@if (conversation().length === 0) {
|
||||||
<div class="empty-chat">
|
<div class="empty-chat">
|
||||||
No text messages yet. The chat page is ready as soon as the peer channel opens.
|
No text messages yet. The chat page is ready as soon as the peer channel opens.
|
||||||
@@ -143,6 +145,17 @@
|
|||||||
>
|
>
|
||||||
@if (entry.direction !== 'system') {
|
@if (entry.direction !== 'system') {
|
||||||
<div class="bubble-actions">
|
<div class="bubble-actions">
|
||||||
|
@if (isGeneratedImageEntry(entry)) {
|
||||||
|
<button
|
||||||
|
class="bubble-action"
|
||||||
|
type="button"
|
||||||
|
(click)="sendGeneratedImage(entry)"
|
||||||
|
title="Send image to peer"
|
||||||
|
aria-label="Send image to peer"
|
||||||
|
>
|
||||||
|
📤
|
||||||
|
</button>
|
||||||
|
}
|
||||||
<button
|
<button
|
||||||
class="bubble-action"
|
class="bubble-action"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -221,6 +234,17 @@
|
|||||||
@if (entry.downloadUrl) {
|
@if (entry.downloadUrl) {
|
||||||
<a class="bubble-download" [href]="entry.downloadUrl" [download]="entry.fileName">Download</a>
|
<a class="bubble-download" [href]="entry.downloadUrl" [download]="entry.fileName">Download</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (hasDocumentPreviewImage(entry)) {
|
||||||
|
<div class="bubble-preview">
|
||||||
|
<div class="bubble-preview-label">Preview</div>
|
||||||
|
<img
|
||||||
|
class="bubble-preview-image"
|
||||||
|
[src]="documentPreviewImageUrl(entry)"
|
||||||
|
[alt]="entry.fileName || 'Document preview'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@case ('voice') {
|
@case ('voice') {
|
||||||
@@ -272,9 +296,9 @@
|
|||||||
class="composer-call"
|
class="composer-call"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="!canStartSelectedVoiceCall()"
|
[disabled]="!canStartSelectedVoiceCall()"
|
||||||
(click)="startVoiceCall(selectedPeer.id)"
|
(click)="openCallChoice(selectedPeer.id)"
|
||||||
title="Start voice call"
|
title="Start call"
|
||||||
aria-label="Start voice call"
|
aria-label="Start call"
|
||||||
>
|
>
|
||||||
📞
|
📞
|
||||||
</button>
|
</button>
|
||||||
@@ -284,24 +308,13 @@
|
|||||||
class="composer-hangup"
|
class="composer-hangup"
|
||||||
type="button"
|
type="button"
|
||||||
(click)="endVoiceCall(selectedPeer.id)"
|
(click)="endVoiceCall(selectedPeer.id)"
|
||||||
title="End voice call"
|
title="End call"
|
||||||
aria-label="End voice call"
|
aria-label="End call"
|
||||||
>
|
>
|
||||||
🛑
|
🛑
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
<button
|
|
||||||
class="composer-camera"
|
|
||||||
type="button"
|
|
||||||
[disabled]="selectedPeer.channelState !== 'open' && !isStreamingCameraToSelectedPeer()"
|
|
||||||
(click)="toggleCameraStream(selectedPeer.id)"
|
|
||||||
[title]="isStreamingCameraToSelectedPeer() ? 'Stop webcam' : 'Start webcam'"
|
|
||||||
[attr.aria-label]="isStreamingCameraToSelectedPeer() ? 'Stop webcam' : 'Start webcam'"
|
|
||||||
>
|
|
||||||
{{ isStreamingCameraToSelectedPeer() ? '🛑' : '📹' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="composer-voice"
|
class="composer-voice"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -33,6 +33,63 @@
|
|||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.call-choice-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1240;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(3, 8, 14, 0.46);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-choice-card {
|
||||||
|
width: min(100%, 25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-choice-eyebrow {
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--page-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-choice-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-choice-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
color: var(--page-text);
|
||||||
|
background: var(--surface-background);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-choice-button:hover,
|
||||||
|
.call-choice-button:focus-visible {
|
||||||
|
border-color: color-mix(in srgb, var(--accent-color) 35%, transparent);
|
||||||
|
background: var(--surface-hover-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-choice-icon {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--badge-background);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.back-link {
|
.back-link {
|
||||||
color: var(--link-color);
|
color: var(--link-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -470,6 +527,27 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bubble-preview {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-preview-label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-preview-image {
|
||||||
|
display: block;
|
||||||
|
width: min(240px, 100%);
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.bubble-image,
|
.bubble-image,
|
||||||
.bubble-video {
|
.bubble-video {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
|
|||||||
@@ -4,14 +4,20 @@ import { toSignal } from '@angular/core/rxjs-interop';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
|
||||||
import { PeerVideoModalComponent } from './peer-video-modal.component';
|
import { PeerCallModalComponent } from './peer-call-modal.component';
|
||||||
import { ChatSessionService } from './chat-session.service';
|
import { ChatSessionService } from './chat-session.service';
|
||||||
import { JsonFileViewerComponent } from './json-file-viewer.component';
|
import { JsonFileViewerComponent } from './json-file-viewer.component';
|
||||||
import type { ChatEntry, ConnectionState, PeerSummary } from './models';
|
import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chat-page',
|
selector: 'app-chat-page',
|
||||||
imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent, PeerVideoModalComponent],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterLink,
|
||||||
|
JsonFileViewerComponent,
|
||||||
|
PeerCallModalComponent,
|
||||||
|
],
|
||||||
templateUrl: './chat-page.component.html',
|
templateUrl: './chat-page.component.html',
|
||||||
styleUrl: './chat-page.component.scss',
|
styleUrl: './chat-page.component.scss',
|
||||||
})
|
})
|
||||||
@@ -37,15 +43,22 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
private dictationCompletionPromise: Promise<void> | null = null;
|
private dictationCompletionPromise: Promise<void> | null = null;
|
||||||
private resolveDictationCompletion: (() => void) | null = null;
|
private resolveDictationCompletion: (() => void) | null = null;
|
||||||
private dictationApplyToken = 0;
|
private dictationApplyToken = 0;
|
||||||
|
private lastConversationSnapshot: { peerId: string; length: number; lastEntryId: string | null } | null = null;
|
||||||
@ViewChild('callAudioElement')
|
@ViewChild('callAudioElement')
|
||||||
set callAudioElementRef(value: ElementRef<HTMLAudioElement> | undefined) {
|
set callAudioElementRef(value: ElementRef<HTMLAudioElement> | undefined) {
|
||||||
this.callAudioElement = value;
|
this.callAudioElement = value;
|
||||||
this.syncCallAudioSource();
|
this.syncCallAudioSource();
|
||||||
}
|
}
|
||||||
private callAudioElement?: ElementRef<HTMLAudioElement>;
|
private callAudioElement?: ElementRef<HTMLAudioElement>;
|
||||||
|
@ViewChild('conversationContainer')
|
||||||
|
set conversationContainerRef(value: ElementRef<HTMLDivElement> | undefined) {
|
||||||
|
this.conversationContainer = value;
|
||||||
|
}
|
||||||
|
private conversationContainer?: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
messageText = '';
|
messageText = '';
|
||||||
readonly forwardingEntryId = signal<string | null>(null);
|
readonly forwardingEntryId = signal<string | null>(null);
|
||||||
|
readonly callChoicePeerId = signal<string | null>(null);
|
||||||
readonly emojiPickerOpen = signal(false);
|
readonly emojiPickerOpen = signal(false);
|
||||||
readonly isRecordingVoice = signal(false);
|
readonly isRecordingVoice = signal(false);
|
||||||
readonly isDictating = signal(false);
|
readonly isDictating = signal(false);
|
||||||
@@ -63,8 +76,19 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
|
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
|
||||||
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
|
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
|
||||||
readonly currentUser = computed(() => this.session.currentUser());
|
readonly currentUser = computed(() => this.session.currentUser());
|
||||||
readonly incomingVoiceCallPeer = computed(() => {
|
readonly callModalPeerId = computed(() =>
|
||||||
const peerId = this.session.incomingVoiceCallPeerId();
|
this.session.activeVoiceCallPeerId()
|
||||||
|
?? this.session.incomingVoiceCallPeerId()
|
||||||
|
?? this.session.outgoingVoiceCallPeerId()
|
||||||
|
?? null,
|
||||||
|
);
|
||||||
|
readonly callModalPeer = computed(() => {
|
||||||
|
const peerId = this.callModalPeerId();
|
||||||
|
|
||||||
|
return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null;
|
||||||
|
});
|
||||||
|
readonly callChoicePeer = computed(() => {
|
||||||
|
const peerId = this.callChoicePeerId();
|
||||||
|
|
||||||
return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null;
|
return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null;
|
||||||
});
|
});
|
||||||
@@ -73,13 +97,50 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
.messages()
|
.messages()
|
||||||
.filter((entry) => entry.peerId === this.peerId()),
|
.filter((entry) => entry.peerId === this.peerId()),
|
||||||
);
|
);
|
||||||
readonly remoteVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.peerId()));
|
|
||||||
readonly remoteCallAudioStream = computed(() =>
|
readonly remoteCallAudioStream = computed(() =>
|
||||||
this.session.remoteAudioStreamForPeer(this.session.activeVoiceCallPeerId() ?? ''),
|
this.session.remoteAudioStreamForPeer(this.callModalPeerId() ?? ''),
|
||||||
);
|
|
||||||
readonly remoteVideoModalVisible = computed(
|
|
||||||
() => this.session.remoteVideoModalPeerId() === this.peerId() && !!this.remoteVideoStream(),
|
|
||||||
);
|
);
|
||||||
|
readonly callModalMode = computed<CallMode>(() => this.session.callModeForPeer(this.callModalPeerId() ?? '') ?? 'video');
|
||||||
|
readonly localCallStream = computed(() => this.session.localCallStreamForPeer(this.callModalPeerId() ?? ''));
|
||||||
|
readonly remoteCallVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.callModalPeerId() ?? ''));
|
||||||
|
readonly callModalVisible = computed(() => !!this.callModalPeer());
|
||||||
|
readonly callModalState = computed<'incoming' | 'outgoing' | 'active'>(() => {
|
||||||
|
const peerId = this.callModalPeerId();
|
||||||
|
|
||||||
|
if (!peerId) {
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.session.incomingVoiceCallPeerId() === peerId) {
|
||||||
|
return 'incoming';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.session.outgoingVoiceCallPeerId() === peerId) {
|
||||||
|
return 'outgoing';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'active';
|
||||||
|
});
|
||||||
|
readonly callModalStatusText = computed(() => {
|
||||||
|
const peer = this.callModalPeer();
|
||||||
|
|
||||||
|
if (!peer) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (this.callModalState()) {
|
||||||
|
case 'incoming':
|
||||||
|
return `${peer.displayName} is calling you${this.callModalMode() === 'audio' ? ' with audio only.' : '.'}`;
|
||||||
|
case 'outgoing':
|
||||||
|
return this.callModalMode() === 'audio'
|
||||||
|
? 'Calling… your microphone is ready.'
|
||||||
|
: 'Calling… your camera and microphone are ready.';
|
||||||
|
default:
|
||||||
|
return this.callModalMode() === 'audio'
|
||||||
|
? 'Connected with live audio.'
|
||||||
|
: 'Connected with live video and audio.';
|
||||||
|
}
|
||||||
|
});
|
||||||
readonly selectedPeerVoiceCallState = computed<'idle' | 'incoming' | 'outgoing' | 'active'>(() => {
|
readonly selectedPeerVoiceCallState = computed<'idle' | 'incoming' | 'outgoing' | 'active'>(() => {
|
||||||
const peerId = this.peerId();
|
const peerId = this.peerId();
|
||||||
|
|
||||||
@@ -160,6 +221,32 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
this.remoteCallAudioStream();
|
this.remoteCallAudioStream();
|
||||||
this.syncCallAudioSource();
|
this.syncCallAudioSource();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const peerId = this.peerId();
|
||||||
|
const entries = this.conversation();
|
||||||
|
const snapshot = {
|
||||||
|
peerId,
|
||||||
|
length: entries.length,
|
||||||
|
lastEntryId: entries.at(-1)?.id ?? null,
|
||||||
|
};
|
||||||
|
const previousSnapshot = this.lastConversationSnapshot;
|
||||||
|
|
||||||
|
this.lastConversationSnapshot = snapshot;
|
||||||
|
|
||||||
|
if (!peerId || !previousSnapshot || previousSnapshot.peerId !== peerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasNewTailEntry = snapshot.length > previousSnapshot.length
|
||||||
|
|| (snapshot.length > 0 && snapshot.lastEntryId !== previousSnapshot.lastEntryId);
|
||||||
|
|
||||||
|
if (!hasNewTailEntry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scrollConversationToBottom();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
@@ -265,6 +352,29 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openCallChoice(peerId: string): void {
|
||||||
|
if (!peerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.callChoicePeerId.set(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeCallChoice(): void {
|
||||||
|
this.callChoicePeerId.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async startSelectedCall(mode: CallMode): Promise<void> {
|
||||||
|
const peerId = this.callChoicePeerId() ?? this.peerId();
|
||||||
|
|
||||||
|
if (!peerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.callChoicePeerId.set(null);
|
||||||
|
await this.session.startVoiceCall(peerId, mode);
|
||||||
|
}
|
||||||
|
|
||||||
async sendFile(peerId: string, input: HTMLInputElement): Promise<void> {
|
async sendFile(peerId: string, input: HTMLInputElement): Promise<void> {
|
||||||
const file = input.files?.item(0);
|
const file = input.files?.item(0);
|
||||||
|
|
||||||
@@ -474,17 +584,14 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
this.forwardingEntryId.set(null);
|
this.forwardingEntryId.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleCameraStream(peerId: string): Promise<void> {
|
async sendGeneratedImage(entry: ChatEntry): Promise<void> {
|
||||||
if (this.session.isStreamingCameraToPeer(peerId)) {
|
const peerId = this.peerId();
|
||||||
await this.session.stopCameraStream(peerId);
|
|
||||||
|
if (!peerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.session.startCameraStream(peerId);
|
await this.session.sendGeneratedImageToPeer(entry, peerId);
|
||||||
}
|
|
||||||
|
|
||||||
async startVoiceCall(peerId: string): Promise<void> {
|
|
||||||
await this.session.startVoiceCall(peerId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async endVoiceCall(peerId: string): Promise<void> {
|
async endVoiceCall(peerId: string): Promise<void> {
|
||||||
@@ -496,10 +603,6 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (peerId !== this.peerId()) {
|
|
||||||
await this.router.navigate(['/chat', peerId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.session.acceptVoiceCall(peerId);
|
await this.session.acceptVoiceCall(peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,6 +618,10 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false);
|
return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isGeneratedImageEntry(entry: ChatEntry): boolean {
|
||||||
|
return this.isImageEntry(entry) && entry.generatedByAi === true;
|
||||||
|
}
|
||||||
|
|
||||||
isVideoEntry(entry: ChatEntry): boolean {
|
isVideoEntry(entry: ChatEntry): boolean {
|
||||||
if (entry.kind !== 'file' || !entry.downloadUrl) {
|
if (entry.kind !== 'file' || !entry.downloadUrl) {
|
||||||
return false;
|
return false;
|
||||||
@@ -537,6 +644,22 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasDocumentPreviewImage(entry: ChatEntry): boolean {
|
||||||
|
return (
|
||||||
|
entry.kind === 'file' &&
|
||||||
|
!!entry.previewDownloadUrl &&
|
||||||
|
(entry.previewMimeType?.startsWith('image/') ?? false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
documentPreviewImageUrl(entry: ChatEntry): string | null {
|
||||||
|
if (!this.hasDocumentPreviewImage(entry)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.previewDownloadUrl ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
isPeerTyping(peerId: string): boolean {
|
isPeerTyping(peerId: string): boolean {
|
||||||
return this.session.typingPeerIds().includes(peerId);
|
return this.session.typingPeerIds().includes(peerId);
|
||||||
}
|
}
|
||||||
@@ -561,22 +684,6 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
return this.indicatorTone(this.webRtcState()) === 'offline';
|
return this.indicatorTone(this.webRtcState()) === 'offline';
|
||||||
}
|
}
|
||||||
|
|
||||||
isStreamingCameraToSelectedPeer(): boolean {
|
|
||||||
const peerId = this.peerId();
|
|
||||||
|
|
||||||
return !!peerId && this.session.isStreamingCameraToPeer(peerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeRemoteVideoModal(): void {
|
|
||||||
const peerId = this.peerId();
|
|
||||||
|
|
||||||
if (!peerId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.session.dismissRemoteVideoModal(peerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async switchPeer(peerId: string): Promise<void> {
|
async switchPeer(peerId: string): Promise<void> {
|
||||||
if (!peerId || peerId === this.peerId()) {
|
if (!peerId || peerId === this.peerId()) {
|
||||||
return;
|
return;
|
||||||
@@ -585,6 +692,7 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
await this.stopDictation(true);
|
await this.stopDictation(true);
|
||||||
this.stopVoiceRecording(true);
|
this.stopVoiceRecording(true);
|
||||||
this.forwardingEntryId.set(null);
|
this.forwardingEntryId.set(null);
|
||||||
|
this.callChoicePeerId.set(null);
|
||||||
this.emojiPickerOpen.set(false);
|
this.emojiPickerOpen.set(false);
|
||||||
this.session.selectPeer(peerId);
|
this.session.selectPeer(peerId);
|
||||||
await this.router.navigate(['/chat', peerId]);
|
await this.router.navigate(['/chat', peerId]);
|
||||||
@@ -764,4 +872,18 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
audio.pause();
|
audio.pause();
|
||||||
audio.srcObject = null;
|
audio.srcObject = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scrollConversationToBottom(): void {
|
||||||
|
const container = this.conversationContainer?.nativeElement;
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
AdminUserSummary,
|
AdminUserSummary,
|
||||||
AuthenticationOptionsResponse,
|
AuthenticationOptionsResponse,
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
|
CallMode,
|
||||||
ChatEntry,
|
ChatEntry,
|
||||||
ConnectionState,
|
ConnectionState,
|
||||||
DataEnvelope,
|
DataEnvelope,
|
||||||
@@ -26,11 +27,9 @@ type PeerBundle = {
|
|||||||
pendingCandidates: RTCIceCandidateInit[];
|
pendingCandidates: RTCIceCandidateInit[];
|
||||||
pendingNegotiation: boolean;
|
pendingNegotiation: boolean;
|
||||||
announceConnectionEvents: boolean;
|
announceConnectionEvents: boolean;
|
||||||
localCameraStream?: MediaStream;
|
localCallStream?: MediaStream;
|
||||||
cameraSenders: RTCRtpSender[];
|
mediaSenders: RTCRtpSender[];
|
||||||
remoteCameraStream?: MediaStream;
|
remoteCameraStream?: MediaStream;
|
||||||
localAudioStream?: MediaStream;
|
|
||||||
audioSenders: RTCRtpSender[];
|
|
||||||
remoteAudioStream?: MediaStream;
|
remoteAudioStream?: MediaStream;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,12 +57,15 @@ type LegacyPersistedChatEntry = {
|
|||||||
kind: Exclude<ChatEntry['kind'], 'system'>;
|
kind: Exclude<ChatEntry['kind'], 'system'>;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
authorLabel: string;
|
authorLabel: string;
|
||||||
|
generatedByAi?: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
payload?: unknown;
|
payload?: unknown;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
fileMimeType?: string;
|
fileMimeType?: string;
|
||||||
fileBlob?: Blob;
|
fileBlob?: Blob;
|
||||||
|
previewMimeType?: string;
|
||||||
|
previewBlob?: Blob;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EncryptedPersistedChatEntry = {
|
type EncryptedPersistedChatEntry = {
|
||||||
@@ -79,17 +81,26 @@ type EncryptedPersistedChatEntry = {
|
|||||||
payloadIv: number[];
|
payloadIv: number[];
|
||||||
encryptedFileBlob?: PersistedBinary;
|
encryptedFileBlob?: PersistedBinary;
|
||||||
fileIv?: number[];
|
fileIv?: number[];
|
||||||
|
encryptedPreviewBlob?: PersistedBinary;
|
||||||
|
previewIv?: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type PersistedChatEntry = LegacyPersistedChatEntry | EncryptedPersistedChatEntry;
|
type PersistedChatEntry = LegacyPersistedChatEntry | EncryptedPersistedChatEntry;
|
||||||
|
|
||||||
type PersistedChatEntryContent = {
|
type PersistedChatEntryContent = {
|
||||||
authorLabel: string;
|
authorLabel: string;
|
||||||
|
generatedByAi?: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
payload?: unknown;
|
payload?: unknown;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
fileMimeType?: string;
|
fileMimeType?: string;
|
||||||
|
previewMimeType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DocumentPreviewImageResponse = {
|
||||||
|
mimeType: string;
|
||||||
|
imageBase64: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RuntimeEnv = {
|
type RuntimeEnv = {
|
||||||
@@ -128,7 +139,6 @@ export class ChatSessionService {
|
|||||||
readonly messages = signal<ChatEntry[]>([]);
|
readonly messages = signal<ChatEntry[]>([]);
|
||||||
readonly unreadPeerIds = signal<string[]>([]);
|
readonly unreadPeerIds = signal<string[]>([]);
|
||||||
readonly typingPeerIds = signal<string[]>([]);
|
readonly typingPeerIds = signal<string[]>([]);
|
||||||
readonly remoteVideoModalPeerId = signal<string | null>(null);
|
|
||||||
readonly incomingVoiceCallPeerId = signal<string | null>(null);
|
readonly incomingVoiceCallPeerId = signal<string | null>(null);
|
||||||
readonly outgoingVoiceCallPeerId = signal<string | null>(null);
|
readonly outgoingVoiceCallPeerId = signal<string | null>(null);
|
||||||
readonly activeVoiceCallPeerId = signal<string | null>(null);
|
readonly activeVoiceCallPeerId = signal<string | null>(null);
|
||||||
@@ -174,10 +184,13 @@ export class ChatSessionService {
|
|||||||
string,
|
string,
|
||||||
{ resolve: (text: string) => void; reject: (reason?: unknown) => void }
|
{ resolve: (text: string) => void; reject: (reason?: unknown) => void }
|
||||||
>();
|
>();
|
||||||
|
private readonly incomingCallModes = signal<Array<{ peerId: string; mode: CallMode }>>([]);
|
||||||
|
private readonly outgoingCallModes = signal<Array<{ peerId: string; mode: CallMode }>>([]);
|
||||||
|
private readonly activeCallModes = signal<Array<{ peerId: string; mode: CallMode }>>([]);
|
||||||
|
private readonly localCallStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]);
|
||||||
private readonly remoteVideoStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]);
|
private readonly remoteVideoStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]);
|
||||||
private readonly remoteAudioStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]);
|
private readonly remoteAudioStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]);
|
||||||
private readonly activeCameraPeerId = signal<string | null>(null);
|
private readonly localCallPeerId = signal<string | null>(null);
|
||||||
private readonly activeAudioPeerId = signal<string | null>(null);
|
|
||||||
private sessionKeepaliveIntervalId: number | null = null;
|
private sessionKeepaliveIntervalId: number | null = null;
|
||||||
private websocketHeartbeatIntervalId: number | null = null;
|
private websocketHeartbeatIntervalId: number | null = null;
|
||||||
private websocketReconnectTimeoutId: number | null = null;
|
private websocketReconnectTimeoutId: number | null = null;
|
||||||
@@ -369,94 +382,15 @@ export class ChatSessionService {
|
|||||||
await this.negotiatePeer(peerId, bundle);
|
await this.negotiatePeer(peerId, bundle);
|
||||||
}
|
}
|
||||||
|
|
||||||
async startCameraStream(peerId: string): Promise<void> {
|
localCallStreamForPeer(peerId: string): MediaStream | null {
|
||||||
if (typeof navigator === 'undefined' || typeof navigator.mediaDevices?.getUserMedia !== 'function') {
|
return this.localCallStreams().find((entry) => entry.peerId === peerId)?.stream ?? null;
|
||||||
this.error.set('This browser does not support webcam capture.');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
|
callModeForPeer(peerId: string): CallMode | null {
|
||||||
this.error.set('You must be connected to signaling before starting webcam capture.');
|
return this.activeCallModes().find((entry) => entry.peerId === peerId)?.mode
|
||||||
return;
|
?? this.incomingCallModes().find((entry) => entry.peerId === peerId)?.mode
|
||||||
}
|
?? this.outgoingCallModes().find((entry) => entry.peerId === peerId)?.mode
|
||||||
|
?? null;
|
||||||
const activeCameraPeerId = this.activeCameraPeerId();
|
|
||||||
|
|
||||||
if (activeCameraPeerId && activeCameraPeerId !== peerId) {
|
|
||||||
await this.stopCameraStream(activeCameraPeerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const bundle = this.ensurePeerBundle(peerId, true);
|
|
||||||
|
|
||||||
if (bundle.localCameraStream) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
video: true,
|
|
||||||
audio: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
bundle.localCameraStream = stream;
|
|
||||||
bundle.cameraSenders = stream.getTracks().map((track) => {
|
|
||||||
track.onended = () => {
|
|
||||||
void this.stopCameraStream(peerId, false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return bundle.pc.addTrack(track, stream);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.activeCameraPeerId.set(peerId);
|
|
||||||
this.sendCameraState(peerId, true);
|
|
||||||
this.addSystemMessage(peerId, 'Sharing webcam capture.');
|
|
||||||
await this.negotiatePeer(peerId, bundle);
|
|
||||||
} catch {
|
|
||||||
this.error.set('Could not start webcam capture.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async stopCameraStream(peerId: string, notifyPeer = true): Promise<void> {
|
|
||||||
const bundle = this.peerBundles.get(peerId);
|
|
||||||
|
|
||||||
if (!bundle?.localCameraStream && this.activeCameraPeerId() !== peerId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bundle) {
|
|
||||||
for (const sender of bundle.cameraSenders) {
|
|
||||||
bundle.pc.removeTrack(sender);
|
|
||||||
}
|
|
||||||
|
|
||||||
bundle.cameraSenders = [];
|
|
||||||
|
|
||||||
if (bundle.localCameraStream) {
|
|
||||||
for (const track of bundle.localCameraStream.getTracks()) {
|
|
||||||
track.onended = null;
|
|
||||||
track.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bundle.localCameraStream = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.activeCameraPeerId() === peerId) {
|
|
||||||
this.activeCameraPeerId.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notifyPeer) {
|
|
||||||
this.sendCameraState(peerId, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.addSystemMessage(peerId, 'Stopped webcam capture.');
|
|
||||||
|
|
||||||
if (bundle) {
|
|
||||||
await this.negotiatePeer(peerId, bundle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isStreamingCameraToPeer(peerId: string): boolean {
|
|
||||||
return this.activeCameraPeerId() === peerId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
remoteVideoStreamForPeer(peerId: string): MediaStream | null {
|
remoteVideoStreamForPeer(peerId: string): MediaStream | null {
|
||||||
@@ -467,29 +401,34 @@ export class ChatSessionService {
|
|||||||
return this.remoteAudioStreams().find((entry) => entry.peerId === peerId)?.stream ?? null;
|
return this.remoteAudioStreams().find((entry) => entry.peerId === peerId)?.stream ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
dismissRemoteVideoModal(peerId: string): void {
|
async startVoiceCall(peerId: string, mode: CallMode): Promise<void> {
|
||||||
if (this.remoteVideoModalPeerId() === peerId) {
|
|
||||||
this.remoteVideoModalPeerId.set(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async startVoiceCall(peerId: string): Promise<void> {
|
|
||||||
const channel = this.requireOpenChannel(peerId);
|
const channel = this.requireOpenChannel(peerId);
|
||||||
|
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.hasVoiceCallConflict(peerId) || this.outgoingVoiceCallPeerId() === peerId || this.activeVoiceCallPeerId() === peerId) {
|
if (
|
||||||
this.error.set('Finish the current voice call before starting another one.');
|
this.hasVoiceCallConflict(peerId)
|
||||||
|
|| this.outgoingVoiceCallPeerId() === peerId
|
||||||
|
|| this.activeVoiceCallPeerId() === peerId
|
||||||
|
) {
|
||||||
|
this.error.set('Finish the current call before starting another one.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundle = await this.ensureLocalCallStream(peerId, mode);
|
||||||
|
|
||||||
|
if (!bundle) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
this.incomingVoiceCallPeerId.set(null);
|
this.incomingVoiceCallPeerId.set(null);
|
||||||
this.outgoingVoiceCallPeerId.set(peerId);
|
this.outgoingVoiceCallPeerId.set(peerId);
|
||||||
channel.send(JSON.stringify({ type: 'voice-call-offer' } satisfies DataEnvelope));
|
this.upsertCallMode(this.outgoingCallModes, peerId, mode);
|
||||||
this.addSystemMessage(peerId, 'Calling peer.');
|
channel.send(JSON.stringify({ type: 'voice-call-offer', mode } satisfies DataEnvelope));
|
||||||
|
this.addSystemMessage(peerId, mode === 'video' ? 'Calling peer with video.' : 'Calling peer with audio only.');
|
||||||
}
|
}
|
||||||
|
|
||||||
async acceptVoiceCall(peerId: string): Promise<void> {
|
async acceptVoiceCall(peerId: string): Promise<void> {
|
||||||
@@ -497,7 +436,8 @@ export class ChatSessionService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bundle = await this.ensureLocalAudioStream(peerId);
|
const mode = this.callModeForPeer(peerId) ?? 'video';
|
||||||
|
const bundle = await this.ensureLocalCallStream(peerId, mode);
|
||||||
|
|
||||||
if (!bundle) {
|
if (!bundle) {
|
||||||
this.sendVoiceCallResponse(peerId, false);
|
this.sendVoiceCallResponse(peerId, false);
|
||||||
@@ -507,10 +447,12 @@ export class ChatSessionService {
|
|||||||
|
|
||||||
this.stopRingtone();
|
this.stopRingtone();
|
||||||
this.incomingVoiceCallPeerId.set(null);
|
this.incomingVoiceCallPeerId.set(null);
|
||||||
|
this.clearCallMode(this.incomingCallModes, peerId);
|
||||||
this.outgoingVoiceCallPeerId.set(null);
|
this.outgoingVoiceCallPeerId.set(null);
|
||||||
this.activeVoiceCallPeerId.set(peerId);
|
this.activeVoiceCallPeerId.set(peerId);
|
||||||
|
this.upsertCallMode(this.activeCallModes, peerId, mode);
|
||||||
this.sendVoiceCallResponse(peerId, true);
|
this.sendVoiceCallResponse(peerId, true);
|
||||||
this.addSystemMessage(peerId, 'Voice call connected.');
|
this.addSystemMessage(peerId, mode === 'video' ? 'Video call connected.' : 'Audio call connected.');
|
||||||
await this.negotiatePeer(peerId, bundle);
|
await this.negotiatePeer(peerId, bundle);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,15 +462,16 @@ export class ChatSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.sendVoiceCallResponse(peerId, false);
|
this.sendVoiceCallResponse(peerId, false);
|
||||||
|
void this.stopLocalCallStream(peerId, false);
|
||||||
this.clearVoiceCallSignals(peerId);
|
this.clearVoiceCallSignals(peerId);
|
||||||
this.addSystemMessage(peerId, 'Voice call rejected.');
|
this.addSystemMessage(peerId, 'Call rejected.');
|
||||||
}
|
}
|
||||||
|
|
||||||
async endVoiceCall(peerId: string, notifyPeer = true): Promise<void> {
|
async endVoiceCall(peerId: string, notifyPeer = true): Promise<void> {
|
||||||
const hadVoiceCall = this.incomingVoiceCallPeerId() === peerId
|
const hadVoiceCall = this.incomingVoiceCallPeerId() === peerId
|
||||||
|| this.outgoingVoiceCallPeerId() === peerId
|
|| this.outgoingVoiceCallPeerId() === peerId
|
||||||
|| this.activeVoiceCallPeerId() === peerId
|
|| this.activeVoiceCallPeerId() === peerId
|
||||||
|| this.activeAudioPeerId() === peerId;
|
|| this.localCallPeerId() === peerId;
|
||||||
|
|
||||||
if (!hadVoiceCall) {
|
if (!hadVoiceCall) {
|
||||||
return;
|
return;
|
||||||
@@ -538,10 +481,11 @@ export class ChatSessionService {
|
|||||||
this.sendVoiceCallEnded(peerId);
|
this.sendVoiceCallEnded(peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.stopLocalAudioStream(peerId, true);
|
await this.stopLocalCallStream(peerId, true);
|
||||||
|
this.clearRemoteVideoState(peerId);
|
||||||
this.clearRemoteAudioState(peerId);
|
this.clearRemoteAudioState(peerId);
|
||||||
this.clearVoiceCallSignals(peerId);
|
this.clearVoiceCallSignals(peerId);
|
||||||
this.addSystemMessage(peerId, 'Voice call ended.');
|
this.addSystemMessage(peerId, 'Call ended.');
|
||||||
}
|
}
|
||||||
|
|
||||||
async registerAccessKey(label: string): Promise<void> {
|
async registerAccessKey(label: string): Promise<void> {
|
||||||
@@ -737,6 +681,31 @@ export class ChatSessionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendGeneratedImageToPeer(entry: ChatEntry, targetPeerId: string): Promise<void> {
|
||||||
|
if (entry.kind !== 'file' || !entry.generatedByAi || !entry.downloadUrl) {
|
||||||
|
this.error.set('This image is not available to send.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await this.ensureOpenChannel(targetPeerId);
|
||||||
|
|
||||||
|
if (!channel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(entry.downloadUrl);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const file = new File([blob], entry.fileName || 'generated-image', {
|
||||||
|
type: entry.fileMimeType || blob.type || 'application/octet-stream',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sendFile(targetPeerId, file, 'file');
|
||||||
|
} catch {
|
||||||
|
this.error.set('Could not send this generated image.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async authenticate(path: string, payload: Record<string, unknown>): Promise<void> {
|
private async authenticate(path: string, payload: Record<string, unknown>): Promise<void> {
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
this.notice.set(null);
|
this.notice.set(null);
|
||||||
@@ -1123,6 +1092,7 @@ export class ChatSessionService {
|
|||||||
kind: 'file',
|
kind: 'file',
|
||||||
createdAt: event.createdAt,
|
createdAt: event.createdAt,
|
||||||
authorLabel: 'You',
|
authorLabel: 'You',
|
||||||
|
generatedByAi: true,
|
||||||
text: pendingRequest?.prompt ?? event.prompt,
|
text: pendingRequest?.prompt ?? event.prompt,
|
||||||
fileName,
|
fileName,
|
||||||
fileSize: imageBlob.size,
|
fileSize: imageBlob.size,
|
||||||
@@ -1394,8 +1364,7 @@ export class ChatSessionService {
|
|||||||
pendingCandidates: [],
|
pendingCandidates: [],
|
||||||
pendingNegotiation: false,
|
pendingNegotiation: false,
|
||||||
announceConnectionEvents: announce,
|
announceConnectionEvents: announce,
|
||||||
cameraSenders: [],
|
mediaSenders: [],
|
||||||
audioSenders: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
bundle.pc.onicecandidate = (event) => {
|
bundle.pc.onicecandidate = (event) => {
|
||||||
@@ -1443,7 +1412,6 @@ export class ChatSessionService {
|
|||||||
|
|
||||||
bundle.remoteCameraStream = remoteStream;
|
bundle.remoteCameraStream = remoteStream;
|
||||||
this.upsertRemoteVideoStream(peerId, remoteStream);
|
this.upsertRemoteVideoStream(peerId, remoteStream);
|
||||||
this.remoteVideoModalPeerId.set(peerId);
|
|
||||||
|
|
||||||
event.track.onended = () => {
|
event.track.onended = () => {
|
||||||
if (!bundle.remoteCameraStream) {
|
if (!bundle.remoteCameraStream) {
|
||||||
@@ -1573,20 +1541,13 @@ export class ChatSessionService {
|
|||||||
this.addSystemMessage(peerId, `Receiving file ${envelope.name}.`);
|
this.addSystemMessage(peerId, `Receiving file ${envelope.name}.`);
|
||||||
break;
|
break;
|
||||||
case 'file-complete':
|
case 'file-complete':
|
||||||
this.finalizeIncomingFile(peerId, envelope.id);
|
void this.finalizeIncomingFile(peerId, envelope.id);
|
||||||
break;
|
break;
|
||||||
case 'typing':
|
case 'typing':
|
||||||
this.setPeerTyping(peerId, envelope.active);
|
this.setPeerTyping(peerId, envelope.active);
|
||||||
break;
|
break;
|
||||||
case 'camera-state':
|
|
||||||
if (envelope.active) {
|
|
||||||
this.remoteVideoModalPeerId.set(peerId);
|
|
||||||
} else {
|
|
||||||
this.clearRemoteVideoState(peerId);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'voice-call-offer':
|
case 'voice-call-offer':
|
||||||
this.handleIncomingVoiceCallOffer(peerId);
|
this.handleIncomingVoiceCallOffer(peerId, envelope.mode);
|
||||||
break;
|
break;
|
||||||
case 'voice-call-response':
|
case 'voice-call-response':
|
||||||
void this.handleVoiceCallResponse(peerId, envelope.accepted);
|
void this.handleVoiceCallResponse(peerId, envelope.accepted);
|
||||||
@@ -1610,15 +1571,30 @@ export class ChatSessionService {
|
|||||||
transfer.receivedBytes += arrayBuffer.byteLength;
|
transfer.receivedBytes += arrayBuffer.byteLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
private finalizeIncomingFile(peerId: string, transferId: string): void {
|
private async finalizeIncomingFile(peerId: string, transferId: string): Promise<void> {
|
||||||
const transfer = this.incomingFiles.get(peerId);
|
const transfer = this.incomingFiles.get(peerId);
|
||||||
|
|
||||||
if (!transfer || transfer.id !== transferId) {
|
if (!transfer || transfer.id !== transferId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.incomingFiles.delete(peerId);
|
||||||
|
|
||||||
const blob = new Blob(transfer.chunks, { type: transfer.mimeType });
|
const blob = new Blob(transfer.chunks, { type: transfer.mimeType });
|
||||||
const downloadUrl = URL.createObjectURL(blob);
|
const downloadUrl = URL.createObjectURL(blob);
|
||||||
|
let previewBlob: Blob | undefined;
|
||||||
|
let previewMimeType: string | undefined;
|
||||||
|
let previewDownloadUrl: string | undefined;
|
||||||
|
|
||||||
|
if (transfer.kind === 'file' && this.isPreviewableDocumentFile(transfer.name, transfer.mimeType)) {
|
||||||
|
const imagePreview = await this.generateDocumentPreviewImage(transfer.name, blob);
|
||||||
|
|
||||||
|
if (imagePreview) {
|
||||||
|
previewBlob = imagePreview.blob;
|
||||||
|
previewMimeType = imagePreview.mimeType;
|
||||||
|
previewDownloadUrl = URL.createObjectURL(imagePreview.blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.pushMessage({
|
this.pushMessage({
|
||||||
id: transfer.id,
|
id: transfer.id,
|
||||||
@@ -1631,9 +1607,9 @@ export class ChatSessionService {
|
|||||||
fileSize: transfer.size,
|
fileSize: transfer.size,
|
||||||
fileMimeType: transfer.mimeType,
|
fileMimeType: transfer.mimeType,
|
||||||
downloadUrl,
|
downloadUrl,
|
||||||
}, blob);
|
previewMimeType,
|
||||||
|
previewDownloadUrl,
|
||||||
this.incomingFiles.delete(peerId);
|
}, blob, previewBlob);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async flushPendingCandidates(bundle: PeerBundle): Promise<void> {
|
private async flushPendingCandidates(bundle: PeerBundle): Promise<void> {
|
||||||
@@ -1665,16 +1641,6 @@ export class ChatSessionService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendCameraState(peerId: string, active: boolean): void {
|
|
||||||
const channel = this.peerBundles.get(peerId)?.channel;
|
|
||||||
|
|
||||||
if (!channel || channel.readyState !== 'open') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
channel.send(JSON.stringify({ type: 'camera-state', active } satisfies DataEnvelope));
|
|
||||||
}
|
|
||||||
|
|
||||||
private sendVoiceCallResponse(peerId: string, accepted: boolean): void {
|
private sendVoiceCallResponse(peerId: string, accepted: boolean): void {
|
||||||
const channel = this.peerBundles.get(peerId)?.channel;
|
const channel = this.peerBundles.get(peerId)?.channel;
|
||||||
|
|
||||||
@@ -1760,35 +1726,37 @@ export class ChatSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private hasVoiceCallConflict(peerId: string): boolean {
|
private hasVoiceCallConflict(peerId: string): boolean {
|
||||||
return [this.incomingVoiceCallPeerId(), this.outgoingVoiceCallPeerId(), this.activeVoiceCallPeerId()]
|
return [this.incomingVoiceCallPeerId(), this.outgoingVoiceCallPeerId(), this.activeVoiceCallPeerId(), this.localCallPeerId()]
|
||||||
.some((candidatePeerId) => !!candidatePeerId && candidatePeerId !== peerId);
|
.some((candidatePeerId) => !!candidatePeerId && candidatePeerId !== peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureLocalAudioStream(peerId: string): Promise<PeerBundle | null> {
|
private async ensureLocalCallStream(peerId: string, mode: CallMode): Promise<PeerBundle | null> {
|
||||||
if (typeof navigator === 'undefined' || typeof navigator.mediaDevices?.getUserMedia !== 'function') {
|
if (typeof navigator === 'undefined' || typeof navigator.mediaDevices?.getUserMedia !== 'function') {
|
||||||
this.error.set('This browser does not support microphone capture.');
|
this.error.set(mode === 'video'
|
||||||
|
? 'This browser does not support camera and microphone capture.'
|
||||||
|
: 'This browser does not support microphone capture.');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.activeAudioPeerId() && this.activeAudioPeerId() !== peerId) {
|
if (this.localCallPeerId() && this.localCallPeerId() !== peerId) {
|
||||||
this.error.set('Finish the current voice call before starting another one.');
|
this.error.set('Finish the current call before starting another one.');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bundle = this.ensurePeerBundle(peerId, true);
|
const bundle = this.ensurePeerBundle(peerId, true);
|
||||||
|
|
||||||
if (bundle.localAudioStream) {
|
if (bundle.localCallStream) {
|
||||||
return bundle;
|
return bundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: true,
|
audio: true,
|
||||||
video: false,
|
video: mode === 'video',
|
||||||
});
|
});
|
||||||
|
|
||||||
bundle.localAudioStream = stream;
|
bundle.localCallStream = stream;
|
||||||
bundle.audioSenders = stream.getTracks().map((track) => {
|
bundle.mediaSenders = stream.getTracks().map((track) => {
|
||||||
track.onended = () => {
|
track.onended = () => {
|
||||||
void this.endVoiceCall(peerId);
|
void this.endVoiceCall(peerId);
|
||||||
};
|
};
|
||||||
@@ -1796,49 +1764,54 @@ export class ChatSessionService {
|
|||||||
return bundle.pc.addTrack(track, stream);
|
return bundle.pc.addTrack(track, stream);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.activeAudioPeerId.set(peerId);
|
this.localCallPeerId.set(peerId);
|
||||||
|
this.upsertLocalCallStream(peerId, stream);
|
||||||
return bundle;
|
return bundle;
|
||||||
} catch {
|
} catch {
|
||||||
this.error.set('Could not start microphone capture for the voice call.');
|
this.error.set(mode === 'video'
|
||||||
|
? 'Could not start camera and microphone capture for the call.'
|
||||||
|
: 'Could not start microphone capture for the call.');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async stopLocalAudioStream(peerId: string, renegotiate: boolean): Promise<void> {
|
private async stopLocalCallStream(peerId: string, renegotiate: boolean): Promise<void> {
|
||||||
const bundle = this.peerBundles.get(peerId);
|
const bundle = this.peerBundles.get(peerId);
|
||||||
|
|
||||||
if (!bundle?.localAudioStream && this.activeAudioPeerId() !== peerId) {
|
if (!bundle?.localCallStream && this.localCallPeerId() !== peerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bundle) {
|
if (bundle) {
|
||||||
for (const sender of bundle.audioSenders) {
|
for (const sender of bundle.mediaSenders) {
|
||||||
bundle.pc.removeTrack(sender);
|
bundle.pc.removeTrack(sender);
|
||||||
}
|
}
|
||||||
|
|
||||||
bundle.audioSenders = [];
|
bundle.mediaSenders = [];
|
||||||
|
|
||||||
if (bundle.localAudioStream) {
|
if (bundle.localCallStream) {
|
||||||
for (const track of bundle.localAudioStream.getTracks()) {
|
for (const track of bundle.localCallStream.getTracks()) {
|
||||||
track.onended = null;
|
track.onended = null;
|
||||||
track.stop();
|
track.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bundle.localAudioStream = undefined;
|
bundle.localCallStream = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.activeAudioPeerId() === peerId) {
|
if (this.localCallPeerId() === peerId) {
|
||||||
this.activeAudioPeerId.set(null);
|
this.localCallPeerId.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.clearLocalCallStream(peerId);
|
||||||
|
|
||||||
if (renegotiate && bundle) {
|
if (renegotiate && bundle) {
|
||||||
await this.negotiatePeer(peerId, bundle);
|
await this.negotiatePeer(peerId, bundle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleIncomingVoiceCallOffer(peerId: string): void {
|
private handleIncomingVoiceCallOffer(peerId: string, mode: CallMode): void {
|
||||||
if (this.hasVoiceCallConflict(peerId) || this.activeAudioPeerId()) {
|
if (this.hasVoiceCallConflict(peerId) || this.localCallPeerId()) {
|
||||||
this.sendVoiceCallResponse(peerId, false);
|
this.sendVoiceCallResponse(peerId, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1846,8 +1819,9 @@ export class ChatSessionService {
|
|||||||
this.outgoingVoiceCallPeerId.set(null);
|
this.outgoingVoiceCallPeerId.set(null);
|
||||||
this.activeVoiceCallPeerId.set(null);
|
this.activeVoiceCallPeerId.set(null);
|
||||||
this.incomingVoiceCallPeerId.set(peerId);
|
this.incomingVoiceCallPeerId.set(peerId);
|
||||||
|
this.upsertCallMode(this.incomingCallModes, peerId, mode);
|
||||||
this.startRingtone();
|
this.startRingtone();
|
||||||
this.addSystemMessage(peerId, 'Incoming voice call.');
|
this.addSystemMessage(peerId, mode === 'video' ? 'Incoming video call.' : 'Incoming audio call.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleVoiceCallResponse(peerId: string, accepted: boolean): Promise<void> {
|
private async handleVoiceCallResponse(peerId: string, accepted: boolean): Promise<void> {
|
||||||
@@ -1858,19 +1832,24 @@ export class ChatSessionService {
|
|||||||
this.outgoingVoiceCallPeerId.set(null);
|
this.outgoingVoiceCallPeerId.set(null);
|
||||||
|
|
||||||
if (!accepted) {
|
if (!accepted) {
|
||||||
this.addSystemMessage(peerId, 'Voice call declined.');
|
this.clearCallMode(this.outgoingCallModes, peerId);
|
||||||
|
await this.stopLocalCallStream(peerId, true);
|
||||||
|
this.addSystemMessage(peerId, 'Call declined.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mode = this.callModeForPeer(peerId) ?? 'video';
|
||||||
this.activeVoiceCallPeerId.set(peerId);
|
this.activeVoiceCallPeerId.set(peerId);
|
||||||
const bundle = await this.ensureLocalAudioStream(peerId);
|
this.clearCallMode(this.outgoingCallModes, peerId);
|
||||||
|
this.upsertCallMode(this.activeCallModes, peerId, mode);
|
||||||
|
const bundle = await this.ensureLocalCallStream(peerId, mode);
|
||||||
|
|
||||||
if (!bundle) {
|
if (!bundle) {
|
||||||
await this.endVoiceCall(peerId);
|
await this.endVoiceCall(peerId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.addSystemMessage(peerId, 'Voice call connected.');
|
this.addSystemMessage(peerId, mode === 'video' ? 'Video call connected.' : 'Audio call connected.');
|
||||||
await this.negotiatePeer(peerId, bundle);
|
await this.negotiatePeer(peerId, bundle);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1878,14 +1857,15 @@ export class ChatSessionService {
|
|||||||
const hadVoiceCall = this.incomingVoiceCallPeerId() === peerId
|
const hadVoiceCall = this.incomingVoiceCallPeerId() === peerId
|
||||||
|| this.outgoingVoiceCallPeerId() === peerId
|
|| this.outgoingVoiceCallPeerId() === peerId
|
||||||
|| this.activeVoiceCallPeerId() === peerId
|
|| this.activeVoiceCallPeerId() === peerId
|
||||||
|| this.activeAudioPeerId() === peerId;
|
|| this.localCallPeerId() === peerId;
|
||||||
|
|
||||||
await this.stopLocalAudioStream(peerId, true);
|
await this.stopLocalCallStream(peerId, true);
|
||||||
|
this.clearRemoteVideoState(peerId);
|
||||||
this.clearRemoteAudioState(peerId);
|
this.clearRemoteAudioState(peerId);
|
||||||
this.clearVoiceCallSignals(peerId);
|
this.clearVoiceCallSignals(peerId);
|
||||||
|
|
||||||
if (hadVoiceCall) {
|
if (hadVoiceCall) {
|
||||||
this.addSystemMessage(peerId, 'Voice call ended.');
|
this.addSystemMessage(peerId, 'Call ended.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1902,27 +1882,18 @@ export class ChatSessionService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bundle.localCameraStream) {
|
if (bundle.localCallStream) {
|
||||||
for (const track of bundle.localCameraStream.getTracks()) {
|
for (const track of bundle.localCallStream.getTracks()) {
|
||||||
track.onended = null;
|
track.onended = null;
|
||||||
track.stop();
|
track.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bundle.localAudioStream) {
|
if (this.localCallPeerId() === peerId) {
|
||||||
for (const track of bundle.localAudioStream.getTracks()) {
|
this.localCallPeerId.set(null);
|
||||||
track.onended = null;
|
|
||||||
track.stop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.activeCameraPeerId() === peerId) {
|
this.clearLocalCallStream(peerId);
|
||||||
this.activeCameraPeerId.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.activeAudioPeerId() === peerId) {
|
|
||||||
this.activeAudioPeerId.set(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
bundle.channel?.close();
|
bundle.channel?.close();
|
||||||
bundle.pc.close();
|
bundle.pc.close();
|
||||||
@@ -1949,7 +1920,7 @@ export class ChatSessionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private pushMessage(entry: ChatEntry, fileBlob?: Blob): void {
|
private pushMessage(entry: ChatEntry, fileBlob?: Blob, previewBlob?: Blob): void {
|
||||||
this.messages.update((messages) => [...messages, entry].sort((left, right) => left.createdAt - right.createdAt));
|
this.messages.update((messages) => [...messages, entry].sort((left, right) => left.createdAt - right.createdAt));
|
||||||
|
|
||||||
if (entry.direction === 'incoming' && entry.kind !== 'system' && this.activePeerId() !== entry.peerId) {
|
if (entry.direction === 'incoming' && entry.kind !== 'system' && this.activePeerId() !== entry.peerId) {
|
||||||
@@ -1957,7 +1928,7 @@ export class ChatSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (entry.kind !== 'system') {
|
if (entry.kind !== 'system') {
|
||||||
void this.persistMessage(entry, fileBlob);
|
void this.persistMessage(entry, fileBlob, previewBlob);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2107,11 +2078,13 @@ export class ChatSessionService {
|
|||||||
this.releasePreloadedRingtone();
|
this.releasePreloadedRingtone();
|
||||||
this.pendingImageGenerationRequests.clear();
|
this.pendingImageGenerationRequests.clear();
|
||||||
this.rejectPendingSpeechTranscriptions('Session ended during dictation.');
|
this.rejectPendingSpeechTranscriptions('Session ended during dictation.');
|
||||||
|
this.incomingCallModes.set([]);
|
||||||
|
this.outgoingCallModes.set([]);
|
||||||
|
this.activeCallModes.set([]);
|
||||||
|
this.localCallStreams.set([]);
|
||||||
this.remoteVideoStreams.set([]);
|
this.remoteVideoStreams.set([]);
|
||||||
this.remoteAudioStreams.set([]);
|
this.remoteAudioStreams.set([]);
|
||||||
this.remoteVideoModalPeerId.set(null);
|
this.localCallPeerId.set(null);
|
||||||
this.activeCameraPeerId.set(null);
|
|
||||||
this.activeAudioPeerId.set(null);
|
|
||||||
this.incomingVoiceCallPeerId.set(null);
|
this.incomingVoiceCallPeerId.set(null);
|
||||||
this.outgoingVoiceCallPeerId.set(null);
|
this.outgoingVoiceCallPeerId.set(null);
|
||||||
this.activeVoiceCallPeerId.set(null);
|
this.activeVoiceCallPeerId.set(null);
|
||||||
@@ -2201,7 +2174,7 @@ export class ChatSessionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persistMessage(entry: ChatEntry, fileBlob?: Blob): Promise<void> {
|
private async persistMessage(entry: ChatEntry, fileBlob?: Blob, previewBlob?: Blob): Promise<void> {
|
||||||
const currentUserId = this.currentUser()?.id;
|
const currentUserId = this.currentUser()?.id;
|
||||||
const messageEncryptionKey = this.messageEncryptionKey;
|
const messageEncryptionKey = this.messageEncryptionKey;
|
||||||
|
|
||||||
@@ -2214,15 +2187,20 @@ export class ChatSessionService {
|
|||||||
const storageKey = this.messageStorageKey(currentUserId, entry.peerId, entry.id);
|
const storageKey = this.messageStorageKey(currentUserId, entry.peerId, entry.id);
|
||||||
const encryptedPayload = await this.encryptPersistedMessageContent(messageEncryptionKey, {
|
const encryptedPayload = await this.encryptPersistedMessageContent(messageEncryptionKey, {
|
||||||
authorLabel: entry.authorLabel,
|
authorLabel: entry.authorLabel,
|
||||||
|
generatedByAi: entry.generatedByAi,
|
||||||
text: entry.text,
|
text: entry.text,
|
||||||
payload: entry.payload,
|
payload: entry.payload,
|
||||||
fileName: entry.fileName,
|
fileName: entry.fileName,
|
||||||
fileSize: entry.fileSize,
|
fileSize: entry.fileSize,
|
||||||
fileMimeType: entry.fileMimeType,
|
fileMimeType: entry.fileMimeType,
|
||||||
|
previewMimeType: entry.previewMimeType,
|
||||||
});
|
});
|
||||||
const encryptedFileBlob = fileBlob
|
const encryptedFileBlob = fileBlob
|
||||||
? await this.encryptBinary(messageEncryptionKey, await fileBlob.arrayBuffer())
|
? await this.encryptBinary(messageEncryptionKey, await fileBlob.arrayBuffer())
|
||||||
: null;
|
: null;
|
||||||
|
const encryptedPreviewBlob = previewBlob
|
||||||
|
? await this.encryptBinary(messageEncryptionKey, await previewBlob.arrayBuffer())
|
||||||
|
: null;
|
||||||
const persistedEntry: EncryptedPersistedChatEntry = {
|
const persistedEntry: EncryptedPersistedChatEntry = {
|
||||||
storageKey,
|
storageKey,
|
||||||
ownerUserId: currentUserId,
|
ownerUserId: currentUserId,
|
||||||
@@ -2238,6 +2216,10 @@ export class ChatSessionService {
|
|||||||
? this.serializePersistedBinary(encryptedFileBlob.ciphertext)
|
? this.serializePersistedBinary(encryptedFileBlob.ciphertext)
|
||||||
: undefined,
|
: undefined,
|
||||||
fileIv: encryptedFileBlob ? Array.from(encryptedFileBlob.iv) : undefined,
|
fileIv: encryptedFileBlob ? Array.from(encryptedFileBlob.iv) : undefined,
|
||||||
|
encryptedPreviewBlob: encryptedPreviewBlob
|
||||||
|
? this.serializePersistedBinary(encryptedPreviewBlob.ciphertext)
|
||||||
|
: undefined,
|
||||||
|
previewIv: encryptedPreviewBlob ? Array.from(encryptedPreviewBlob.iv) : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.queueMessageStoreOperation(storageKey, async () => {
|
await this.queueMessageStoreOperation(storageKey, async () => {
|
||||||
@@ -2284,6 +2266,7 @@ export class ChatSessionService {
|
|||||||
try {
|
try {
|
||||||
const content = await this.decryptPersistedMessageContent(messageEncryptionKey, entry);
|
const content = await this.decryptPersistedMessageContent(messageEncryptionKey, entry);
|
||||||
let downloadUrl: string | undefined;
|
let downloadUrl: string | undefined;
|
||||||
|
let previewDownloadUrl: string | undefined;
|
||||||
|
|
||||||
if (entry.encryptedFileBlob && entry.fileIv) {
|
if (entry.encryptedFileBlob && entry.fileIv) {
|
||||||
const decryptedFile = await this.decryptBinary(
|
const decryptedFile = await this.decryptBinary(
|
||||||
@@ -2297,6 +2280,18 @@ export class ChatSessionService {
|
|||||||
downloadUrl = URL.createObjectURL(fileBlob);
|
downloadUrl = URL.createObjectURL(fileBlob);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entry.encryptedPreviewBlob && entry.previewIv) {
|
||||||
|
const decryptedPreview = await this.decryptBinary(
|
||||||
|
messageEncryptionKey,
|
||||||
|
this.deserializePersistedBinary(entry.encryptedPreviewBlob),
|
||||||
|
Uint8Array.from(entry.previewIv).buffer,
|
||||||
|
);
|
||||||
|
const previewBlob = new Blob([decryptedPreview], {
|
||||||
|
type: content.previewMimeType || 'image/png',
|
||||||
|
});
|
||||||
|
previewDownloadUrl = URL.createObjectURL(previewBlob);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
peerId: entry.peerId,
|
peerId: entry.peerId,
|
||||||
@@ -2304,12 +2299,15 @@ export class ChatSessionService {
|
|||||||
kind: entry.kind,
|
kind: entry.kind,
|
||||||
createdAt: entry.createdAt,
|
createdAt: entry.createdAt,
|
||||||
authorLabel: content.authorLabel,
|
authorLabel: content.authorLabel,
|
||||||
|
generatedByAi: content.generatedByAi,
|
||||||
text: content.text,
|
text: content.text,
|
||||||
payload: content.payload,
|
payload: content.payload,
|
||||||
fileName: content.fileName,
|
fileName: content.fileName,
|
||||||
fileSize: content.fileSize,
|
fileSize: content.fileSize,
|
||||||
fileMimeType: content.fileMimeType,
|
fileMimeType: content.fileMimeType,
|
||||||
downloadUrl,
|
downloadUrl,
|
||||||
|
previewMimeType: content.previewMimeType,
|
||||||
|
previewDownloadUrl,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Could not decrypt persisted chat message.', error);
|
console.warn('Could not decrypt persisted chat message.', error);
|
||||||
@@ -2325,12 +2323,15 @@ export class ChatSessionService {
|
|||||||
kind: entry.kind,
|
kind: entry.kind,
|
||||||
createdAt: entry.createdAt,
|
createdAt: entry.createdAt,
|
||||||
authorLabel: entry.authorLabel,
|
authorLabel: entry.authorLabel,
|
||||||
|
generatedByAi: entry.generatedByAi,
|
||||||
text: entry.text,
|
text: entry.text,
|
||||||
payload: entry.payload,
|
payload: entry.payload,
|
||||||
fileName: entry.fileName,
|
fileName: entry.fileName,
|
||||||
fileSize: entry.fileSize,
|
fileSize: entry.fileSize,
|
||||||
fileMimeType: entry.fileMimeType,
|
fileMimeType: entry.fileMimeType,
|
||||||
|
previewMimeType: entry.previewMimeType,
|
||||||
downloadUrl: entry.fileBlob ? URL.createObjectURL(entry.fileBlob) : undefined,
|
downloadUrl: entry.fileBlob ? URL.createObjectURL(entry.fileBlob) : undefined,
|
||||||
|
previewDownloadUrl: entry.previewBlob ? URL.createObjectURL(entry.previewBlob) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2341,8 +2342,13 @@ export class ChatSessionService {
|
|||||||
type: entry.fileMimeType || 'application/octet-stream',
|
type: entry.fileMimeType || 'application/octet-stream',
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const previewBlob = entry.previewBlob
|
||||||
|
? new Blob([await entry.previewBlob.arrayBuffer()], {
|
||||||
|
type: entry.previewMimeType || 'image/png',
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
await this.persistMessage(hydratedEntry, fileBlob);
|
await this.persistMessage(hydratedEntry, fileBlob, previewBlob);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async migrateEncryptedPersistedMessage(entry: EncryptedPersistedChatEntry): Promise<void> {
|
private async migrateEncryptedPersistedMessage(entry: EncryptedPersistedChatEntry): Promise<void> {
|
||||||
@@ -2373,6 +2379,10 @@ export class ChatSessionService {
|
|||||||
if (entry.downloadUrl?.startsWith('blob:')) {
|
if (entry.downloadUrl?.startsWith('blob:')) {
|
||||||
URL.revokeObjectURL(entry.downloadUrl);
|
URL.revokeObjectURL(entry.downloadUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entry.previewDownloadUrl?.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(entry.previewDownloadUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2531,6 +2541,10 @@ export class ChatSessionService {
|
|||||||
URL.revokeObjectURL(message.downloadUrl);
|
URL.revokeObjectURL(message.downloadUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.previewDownloadUrl?.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(message.previewDownloadUrl);
|
||||||
|
}
|
||||||
|
|
||||||
const timeoutId = this.systemMessageTimeouts.get(messageId);
|
const timeoutId = this.systemMessageTimeouts.get(messageId);
|
||||||
|
|
||||||
if (typeof timeoutId !== 'undefined') {
|
if (typeof timeoutId !== 'undefined') {
|
||||||
@@ -2557,6 +2571,38 @@ export class ChatSessionService {
|
|||||||
this.unreadPeerIds.update((peerIds) => peerIds.filter((id) => id !== peerId));
|
this.unreadPeerIds.update((peerIds) => peerIds.filter((id) => id !== peerId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private upsertLocalCallStream(peerId: string, stream: MediaStream): void {
|
||||||
|
this.localCallStreams.update((entries) => {
|
||||||
|
const existingIndex = entries.findIndex((entry) => entry.peerId === peerId);
|
||||||
|
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
return [...entries, { peerId, stream }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextEntries = [...entries];
|
||||||
|
nextEntries[existingIndex] = { peerId, stream };
|
||||||
|
return nextEntries;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private upsertCallMode(
|
||||||
|
store: { update: (updater: (entries: Array<{ peerId: string; mode: CallMode }>) => Array<{ peerId: string; mode: CallMode }>) => void },
|
||||||
|
peerId: string,
|
||||||
|
mode: CallMode,
|
||||||
|
): void {
|
||||||
|
store.update((entries) => {
|
||||||
|
const existingIndex = entries.findIndex((entry) => entry.peerId === peerId);
|
||||||
|
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
return [...entries, { peerId, mode }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextEntries = [...entries];
|
||||||
|
nextEntries[existingIndex] = { peerId, mode };
|
||||||
|
return nextEntries;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private upsertRemoteVideoStream(peerId: string, stream: MediaStream): void {
|
private upsertRemoteVideoStream(peerId: string, stream: MediaStream): void {
|
||||||
this.remoteVideoStreams.update((entries) => {
|
this.remoteVideoStreams.update((entries) => {
|
||||||
const existingIndex = entries.findIndex((entry) => entry.peerId === peerId);
|
const existingIndex = entries.findIndex((entry) => entry.peerId === peerId);
|
||||||
@@ -2587,29 +2633,39 @@ export class ChatSessionService {
|
|||||||
|
|
||||||
private clearRemoteVideoState(peerId: string): void {
|
private clearRemoteVideoState(peerId: string): void {
|
||||||
this.remoteVideoStreams.update((entries) => entries.filter((entry) => entry.peerId !== peerId));
|
this.remoteVideoStreams.update((entries) => entries.filter((entry) => entry.peerId !== peerId));
|
||||||
|
|
||||||
if (this.remoteVideoModalPeerId() === peerId) {
|
|
||||||
this.remoteVideoModalPeerId.set(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearRemoteAudioState(peerId: string): void {
|
private clearRemoteAudioState(peerId: string): void {
|
||||||
this.remoteAudioStreams.update((entries) => entries.filter((entry) => entry.peerId !== peerId));
|
this.remoteAudioStreams.update((entries) => entries.filter((entry) => entry.peerId !== peerId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private clearLocalCallStream(peerId: string): void {
|
||||||
|
this.localCallStreams.update((entries) => entries.filter((entry) => entry.peerId !== peerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearCallMode(
|
||||||
|
store: { update: (updater: (entries: Array<{ peerId: string; mode: CallMode }>) => Array<{ peerId: string; mode: CallMode }>) => void },
|
||||||
|
peerId: string,
|
||||||
|
): void {
|
||||||
|
store.update((entries) => entries.filter((entry) => entry.peerId !== peerId));
|
||||||
|
}
|
||||||
|
|
||||||
private clearVoiceCallSignals(peerId: string): void {
|
private clearVoiceCallSignals(peerId: string): void {
|
||||||
if (this.incomingVoiceCallPeerId() === peerId) {
|
if (this.incomingVoiceCallPeerId() === peerId) {
|
||||||
this.incomingVoiceCallPeerId.set(null);
|
this.incomingVoiceCallPeerId.set(null);
|
||||||
this.stopRingtone();
|
this.stopRingtone();
|
||||||
}
|
}
|
||||||
|
this.clearCallMode(this.incomingCallModes, peerId);
|
||||||
|
|
||||||
if (this.outgoingVoiceCallPeerId() === peerId) {
|
if (this.outgoingVoiceCallPeerId() === peerId) {
|
||||||
this.outgoingVoiceCallPeerId.set(null);
|
this.outgoingVoiceCallPeerId.set(null);
|
||||||
}
|
}
|
||||||
|
this.clearCallMode(this.outgoingCallModes, peerId);
|
||||||
|
|
||||||
if (this.activeVoiceCallPeerId() === peerId) {
|
if (this.activeVoiceCallPeerId() === peerId) {
|
||||||
this.activeVoiceCallPeerId.set(null);
|
this.activeVoiceCallPeerId.set(null);
|
||||||
}
|
}
|
||||||
|
this.clearCallMode(this.activeCallModes, peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private startRingtone(): void {
|
private startRingtone(): void {
|
||||||
@@ -2875,6 +2931,67 @@ export class ChatSessionService {
|
|||||||
return new Blob([bytes], { type: mimeType });
|
return new Blob([bytes], { type: mimeType });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async generateDocumentPreviewImage(
|
||||||
|
fileName: string,
|
||||||
|
fileBlob: Blob,
|
||||||
|
): Promise<{ blob: Blob; mimeType: string } | null> {
|
||||||
|
const token = this.token();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.post<DocumentPreviewImageResponse>(
|
||||||
|
`${this.serverUrl()}/api/files/document-preview-image`,
|
||||||
|
{
|
||||||
|
fileName,
|
||||||
|
mimeType: fileBlob.type || 'application/octet-stream',
|
||||||
|
fileBase64: await this.blobToBase64(fileBlob),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
blob: this.base64ToBlob(response.imageBase64, response.mimeType),
|
||||||
|
mimeType: response.mimeType,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not generate document preview image.', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPreviewableDocumentFile(fileName?: string, mimeType?: string): boolean {
|
||||||
|
return this.isOfficeDocumentFile(fileName, mimeType) || this.isPdfFile(fileName, mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isOfficeDocumentFile(fileName?: string, mimeType?: string): boolean {
|
||||||
|
const normalizedName = fileName?.trim().toLowerCase() ?? '';
|
||||||
|
const normalizedMimeType = mimeType?.trim().toLowerCase() ?? '';
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedMimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||||
|
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPdfFile(fileName?: string, mimeType?: string): boolean {
|
||||||
|
const normalizedName = fileName?.trim().toLowerCase() ?? '';
|
||||||
|
const normalizedMimeType = mimeType?.trim().toLowerCase() ?? '';
|
||||||
|
|
||||||
|
return normalizedMimeType === 'application/pdf' || normalizedName.endsWith('.pdf');
|
||||||
|
}
|
||||||
|
|
||||||
private fileExtensionForMimeType(mimeType: string): string {
|
private fileExtensionForMimeType(mimeType: string): string {
|
||||||
const normalizedMimeType = mimeType.split(';', 1)[0]?.trim().toLowerCase() || 'application/octet-stream';
|
const normalizedMimeType = mimeType.split(';', 1)[0]?.trim().toLowerCase() || 'application/octet-stream';
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export interface ChatEntry {
|
|||||||
kind: 'text' | 'json' | 'file' | 'voice' | 'system';
|
kind: 'text' | 'json' | 'file' | 'voice' | 'system';
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
authorLabel: string;
|
authorLabel: string;
|
||||||
|
generatedByAi?: boolean;
|
||||||
showSpinner?: boolean;
|
showSpinner?: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
payload?: unknown;
|
payload?: unknown;
|
||||||
@@ -104,8 +105,12 @@ export interface ChatEntry {
|
|||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
fileMimeType?: string;
|
fileMimeType?: string;
|
||||||
downloadUrl?: string;
|
downloadUrl?: string;
|
||||||
|
previewMimeType?: string;
|
||||||
|
previewDownloadUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CallMode = 'audio' | 'video';
|
||||||
|
|
||||||
export type SignalPayload =
|
export type SignalPayload =
|
||||||
| { type: 'sdp'; description: RTCSessionDescriptionInit }
|
| { type: 'sdp'; description: RTCSessionDescriptionInit }
|
||||||
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
|
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
|
||||||
@@ -179,12 +184,9 @@ export type DataEnvelope =
|
|||||||
type: 'typing';
|
type: 'typing';
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
type: 'camera-state';
|
|
||||||
active: boolean;
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
type: 'voice-call-offer';
|
type: 'voice-call-offer';
|
||||||
|
mode: CallMode;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'voice-call-response';
|
type: 'voice-call-response';
|
||||||
|
|||||||
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 crypto from 'node:crypto';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { TextEncoder } from 'node:util';
|
import { promisify, TextEncoder } from 'node:util';
|
||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import jwt from '@fastify/jwt';
|
import jwt from '@fastify/jwt';
|
||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import websocket from '@fastify/websocket';
|
import websocket from '@fastify/websocket';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
import libreOffice from 'libreoffice-convert';
|
||||||
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
|
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
|
||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
@@ -47,6 +50,11 @@ const adminDeleteUserParamsSchema = z.object({
|
|||||||
const webBundleFileParamsSchema = z.object({
|
const webBundleFileParamsSchema = z.object({
|
||||||
'*': z.string().min(1),
|
'*': z.string().min(1),
|
||||||
});
|
});
|
||||||
|
const documentPreviewSchema = z.object({
|
||||||
|
fileName: z.string().trim().min(1).max(256),
|
||||||
|
mimeType: z.string().trim().min(1).max(256),
|
||||||
|
fileBase64: z.string().min(1).max(96_000_000),
|
||||||
|
});
|
||||||
const wsQuerySchema = z.object({
|
const wsQuerySchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
});
|
});
|
||||||
@@ -111,6 +119,8 @@ const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat';
|
|||||||
const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEBAUTHN_USER_VERIFICATION);
|
const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEBAUTHN_USER_VERIFICATION);
|
||||||
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
||||||
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
||||||
|
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
const speechTranscriber = new SpeechTranscriber({
|
const speechTranscriber = new SpeechTranscriber({
|
||||||
serviceUrl: speechTranscriptionServiceUrl,
|
serviceUrl: speechTranscriptionServiceUrl,
|
||||||
language: speechTranscriptionLanguage,
|
language: speechTranscriptionLanguage,
|
||||||
@@ -462,6 +472,35 @@ app.get('/api/auth/session', async (request, reply) => {
|
|||||||
messageEncryptionKey: authContext.user.messageEncryptionKey,
|
messageEncryptionKey: authContext.user.messageEncryptionKey,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
app.post('/api/files/document-preview-image', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => {
|
||||||
|
const authContext = await authenticateRequest(request, reply);
|
||||||
|
if (!authContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = documentPreviewSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
message: 'Invalid document preview payload.',
|
||||||
|
issues: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!isSupportedPreviewDocument(parsed.data.fileName, parsed.data.mimeType)) {
|
||||||
|
return reply.code(400).send({ message: 'Only PDF, DOCX, XLSX, and PPTX files can be previewed.' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const previewImageBuffer = await createDocumentPreviewImage(parsed.data.fileName, parsed.data.mimeType, parsed.data.fileBase64);
|
||||||
|
return {
|
||||||
|
mimeType: 'image/png',
|
||||||
|
imageBase64: previewImageBuffer.toString('base64'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
app.log.warn({ err: error, userId: authContext.user.id }, 'Document preview generation failed');
|
||||||
|
return reply.code(422).send({
|
||||||
|
message: describeDocumentPreviewFailure(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
app.get('/api/admin/pending-users', async (request, reply) => {
|
app.get('/api/admin/pending-users', async (request, reply) => {
|
||||||
const authContext = await authenticateRequest(request, reply);
|
const authContext = await authenticateRequest(request, reply);
|
||||||
if (!authContext) {
|
if (!authContext) {
|
||||||
@@ -835,6 +874,75 @@ async function authenticateTokenFromSession(userId, sessionId, decoded) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
async function convertOfficeDocumentToPdf(fileName, fileBase64) {
|
||||||
|
const inputBuffer = Buffer.from(fileBase64, 'base64');
|
||||||
|
if (inputBuffer.byteLength === 0) {
|
||||||
|
throw new Error('The uploaded office document is empty.');
|
||||||
|
}
|
||||||
|
const normalizedFileName = normalizeOfficeDocumentFileName(fileName);
|
||||||
|
return convertOfficeDocument(inputBuffer, '.pdf', undefined, { fileName: normalizedFileName });
|
||||||
|
}
|
||||||
|
async function createDocumentPreviewImage(fileName, mimeType, fileBase64) {
|
||||||
|
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||||
|
const pdfBuffer = normalizedMimeType === 'application/pdf'
|
||||||
|
? decodeBase64File(fileBase64, 'The uploaded PDF is empty.')
|
||||||
|
: await convertOfficeDocumentToPdf(fileName, fileBase64);
|
||||||
|
return renderPdfFirstPageToPng(pdfBuffer);
|
||||||
|
}
|
||||||
|
async function renderPdfFirstPageToPng(pdfBuffer) {
|
||||||
|
const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'privatechat-preview-'));
|
||||||
|
const pdfPath = path.join(tempDirectory, 'source.pdf');
|
||||||
|
const outputBasePath = path.join(tempDirectory, 'page-preview');
|
||||||
|
const imagePath = `${outputBasePath}.png`;
|
||||||
|
try {
|
||||||
|
await fs.promises.writeFile(pdfPath, pdfBuffer);
|
||||||
|
await execFileAsync('pdftoppm', ['-png', '-f', '1', '-singlefile', pdfPath, outputBasePath]);
|
||||||
|
return await fs.promises.readFile(imagePath);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
await fs.promises.rm(tempDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function decodeBase64File(fileBase64, emptyMessage) {
|
||||||
|
const inputBuffer = Buffer.from(fileBase64, 'base64');
|
||||||
|
if (inputBuffer.byteLength === 0) {
|
||||||
|
throw new Error(emptyMessage);
|
||||||
|
}
|
||||||
|
return inputBuffer;
|
||||||
|
}
|
||||||
|
function isSupportedPreviewDocument(fileName, mimeType) {
|
||||||
|
if (isPdfFile(fileName, mimeType)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return isSupportedOfficeDocument(fileName, mimeType);
|
||||||
|
}
|
||||||
|
function isSupportedOfficeDocument(fileName, mimeType) {
|
||||||
|
const normalizedFileName = fileName.trim().toLowerCase();
|
||||||
|
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||||
|
if (normalizedMimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||||
|
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedFileName);
|
||||||
|
}
|
||||||
|
function isPdfFile(fileName, mimeType) {
|
||||||
|
const normalizedFileName = fileName.trim().toLowerCase();
|
||||||
|
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||||
|
return normalizedMimeType === 'application/pdf' || normalizedFileName.endsWith('.pdf');
|
||||||
|
}
|
||||||
|
function normalizeOfficeDocumentFileName(fileName) {
|
||||||
|
return fileName.trim().replace(/\.xslx$/i, '.xlsx');
|
||||||
|
}
|
||||||
|
function describeDocumentPreviewFailure(error) {
|
||||||
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||||
|
return 'Document preview generation failed because a required conversion tool is missing on the server.';
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message.trim()) {
|
||||||
|
return `Document preview generation failed: ${error.message}`;
|
||||||
|
}
|
||||||
|
return 'Document preview generation failed.';
|
||||||
|
}
|
||||||
function createUser(input) {
|
function createUser(input) {
|
||||||
const createdAt = new Date().toISOString();
|
const createdAt = new Date().toISOString();
|
||||||
const user = {
|
const user = {
|
||||||
|
|||||||
29
server/package-lock.json
generated
29
server/package-lock.json
generated
@@ -16,6 +16,7 @@
|
|||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"fastify": "^5.8.2",
|
"fastify": "^5.8.2",
|
||||||
"ioredis": "^5.10.0",
|
"ioredis": "^5.10.0",
|
||||||
|
"libreoffice-convert": "^1.8.1",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
@@ -1002,6 +1003,12 @@
|
|||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/async": {
|
||||||
|
"version": "3.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||||
|
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/atomic-sleep": {
|
"node_modules/atomic-sleep": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||||
@@ -1536,6 +1543,19 @@
|
|||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/libreoffice-convert": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/libreoffice-convert/-/libreoffice-convert-1.8.1.tgz",
|
||||||
|
"integrity": "sha512-iZ1DD/EMTlPvol8G++QQ/0w4pVecSwRuhMLXRm7nRim/gcaSscSXuTO9Tgbkieyw5UdJg7UXD+lkFT8SCi51Dw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"async": "^3.2.3",
|
||||||
|
"tmp": "^0.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/light-my-request": {
|
"node_modules/light-my-request": {
|
||||||
"version": "6.6.0",
|
"version": "6.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||||
@@ -2029,6 +2049,15 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tmp": {
|
||||||
|
"version": "0.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||||
|
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/toad-cache": {
|
"node_modules/toad-cache": {
|
||||||
"version": "3.7.0",
|
"version": "3.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"fastify": "^5.8.2",
|
"fastify": "^5.8.2",
|
||||||
"ioredis": "^5.10.0",
|
"ioredis": "^5.10.0",
|
||||||
|
"libreoffice-convert": "^1.8.1",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { TextEncoder } from 'node:util';
|
import { promisify, TextEncoder } from 'node:util';
|
||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
@@ -10,6 +12,7 @@ import jwt from '@fastify/jwt';
|
|||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import websocket from '@fastify/websocket';
|
import websocket from '@fastify/websocket';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
import libreOffice from 'libreoffice-convert';
|
||||||
import {
|
import {
|
||||||
generateAuthenticationOptions,
|
generateAuthenticationOptions,
|
||||||
generateRegistrationOptions,
|
generateRegistrationOptions,
|
||||||
@@ -271,6 +274,12 @@ const webBundleFileParamsSchema = z.object({
|
|||||||
'*': z.string().min(1),
|
'*': z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const documentPreviewSchema = z.object({
|
||||||
|
fileName: z.string().trim().min(1).max(256),
|
||||||
|
mimeType: z.string().trim().min(1).max(256),
|
||||||
|
fileBase64: z.string().min(1).max(96_000_000),
|
||||||
|
});
|
||||||
|
|
||||||
const wsQuerySchema = z.object({
|
const wsQuerySchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
});
|
});
|
||||||
@@ -346,6 +355,8 @@ const webAuthnUserVerification = resolveWebAuthnUserVerification(
|
|||||||
);
|
);
|
||||||
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
||||||
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
||||||
|
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
const speechTranscriber = new SpeechTranscriber(
|
const speechTranscriber = new SpeechTranscriber(
|
||||||
{
|
{
|
||||||
@@ -795,6 +806,45 @@ app.get('/api/auth/session', async (request, reply) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/files/document-preview-image', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => {
|
||||||
|
const authContext = await authenticateRequest(request, reply);
|
||||||
|
|
||||||
|
if (!authContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = documentPreviewSchema.safeParse(request.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
message: 'Invalid document preview payload.',
|
||||||
|
issues: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSupportedPreviewDocument(parsed.data.fileName, parsed.data.mimeType)) {
|
||||||
|
return reply.code(400).send({ message: 'Only PDF, DOCX, XLSX, and PPTX files can be previewed.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const previewImageBuffer = await createDocumentPreviewImage(
|
||||||
|
parsed.data.fileName,
|
||||||
|
parsed.data.mimeType,
|
||||||
|
parsed.data.fileBase64,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mimeType: 'image/png',
|
||||||
|
imageBase64: previewImageBuffer.toString('base64'),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
app.log.warn({ err: error, userId: authContext.user.id }, 'Document preview generation failed');
|
||||||
|
return reply.code(422).send({
|
||||||
|
message: describeDocumentPreviewFailure(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/admin/pending-users', async (request, reply) => {
|
app.get('/api/admin/pending-users', async (request, reply) => {
|
||||||
const authContext = await authenticateRequest(request, reply);
|
const authContext = await authenticateRequest(request, reply);
|
||||||
|
|
||||||
@@ -1294,6 +1344,101 @@ async function authenticateTokenFromSession(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function convertOfficeDocumentToPdf(fileName: string, fileBase64: string): Promise<Buffer> {
|
||||||
|
const inputBuffer = Buffer.from(fileBase64, 'base64');
|
||||||
|
|
||||||
|
if (inputBuffer.byteLength === 0) {
|
||||||
|
throw new Error('The uploaded office document is empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedFileName = normalizeOfficeDocumentFileName(fileName);
|
||||||
|
return convertOfficeDocument(inputBuffer, '.pdf', undefined, { fileName: normalizedFileName });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDocumentPreviewImage(
|
||||||
|
fileName: string,
|
||||||
|
mimeType: string,
|
||||||
|
fileBase64: string,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||||
|
const pdfBuffer = normalizedMimeType === 'application/pdf'
|
||||||
|
? decodeBase64File(fileBase64, 'The uploaded PDF is empty.')
|
||||||
|
: await convertOfficeDocumentToPdf(fileName, fileBase64);
|
||||||
|
|
||||||
|
return renderPdfFirstPageToPng(pdfBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPdfFirstPageToPng(pdfBuffer: Buffer): Promise<Buffer> {
|
||||||
|
const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'privatechat-preview-'));
|
||||||
|
const pdfPath = path.join(tempDirectory, 'source.pdf');
|
||||||
|
const outputBasePath = path.join(tempDirectory, 'page-preview');
|
||||||
|
const imagePath = `${outputBasePath}.png`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.promises.writeFile(pdfPath, pdfBuffer);
|
||||||
|
await execFileAsync('pdftoppm', ['-png', '-f', '1', '-singlefile', pdfPath, outputBasePath]);
|
||||||
|
return await fs.promises.readFile(imagePath);
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(tempDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64File(fileBase64: string, emptyMessage: string): Buffer {
|
||||||
|
const inputBuffer = Buffer.from(fileBase64, 'base64');
|
||||||
|
|
||||||
|
if (inputBuffer.byteLength === 0) {
|
||||||
|
throw new Error(emptyMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedPreviewDocument(fileName: string, mimeType: string): boolean {
|
||||||
|
if (isPdfFile(fileName, mimeType)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isSupportedOfficeDocument(fileName, mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedOfficeDocument(fileName: string, mimeType: string): boolean {
|
||||||
|
const normalizedFileName = fileName.trim().toLowerCase();
|
||||||
|
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedMimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||||
|
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPdfFile(fileName: string, mimeType: string): boolean {
|
||||||
|
const normalizedFileName = fileName.trim().toLowerCase();
|
||||||
|
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||||
|
|
||||||
|
return normalizedMimeType === 'application/pdf' || normalizedFileName.endsWith('.pdf');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOfficeDocumentFileName(fileName: string): string {
|
||||||
|
return fileName.trim().replace(/\.xslx$/i, '.xlsx');
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeDocumentPreviewFailure(error: unknown): string {
|
||||||
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||||
|
return 'Document preview generation failed because a required conversion tool is missing on the server.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message.trim()) {
|
||||||
|
return `Document preview generation failed: ${error.message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Document preview generation failed.';
|
||||||
|
}
|
||||||
|
|
||||||
function createUser(input: {
|
function createUser(input: {
|
||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user