documents preview

This commit is contained in:
2026-03-11 09:09:15 +01:00
parent ffdea4fe62
commit 0e4c79b735
13 changed files with 558 additions and 13 deletions

View File

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

View File

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

View File

@@ -129,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.
@@ -145,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"
@@ -223,6 +234,20 @@
@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 (hasPdfPreview(entry)) {
<div class="bubble-preview">
<div class="bubble-preview-label">Preview</div>
@defer (on viewport) {
<app-office-pdf-preview
[src]="pdfPreviewUrl(entry)!"
[fileName]="entry.fileName ?? 'document.pdf'"
></app-office-pdf-preview>
} @placeholder {
<div class="bubble-preview-placeholder">Loading preview…</div>
}
</div>
}
</div> </div>
} }
@case ('voice') { @case ('voice') {

View File

@@ -527,6 +527,30 @@
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-placeholder {
display: grid;
place-items: center;
width: min(240px, 100%);
min-height: 320px;
padding: 1rem;
border: 1px dashed var(--input-border);
border-radius: 1rem;
color: var(--page-text-soft);
background: color-mix(in srgb, var(--surface-background) 85%, white);
text-align: center;
}
.bubble-image, .bubble-image,
.bubble-video { .bubble-video {
width: 200px; width: 200px;

View File

@@ -3,6 +3,7 @@ import { Component, computed, effect, ElementRef, inject, NgZone, OnDestroy, sig
import { toSignal } from '@angular/core/rxjs-interop'; 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 { OfficePdfPreviewComponent } from './office-pdf-preview.component';
import { PeerCallModalComponent } from './peer-call-modal.component'; import { PeerCallModalComponent } from './peer-call-modal.component';
import { ChatSessionService } from './chat-session.service'; import { ChatSessionService } from './chat-session.service';
@@ -11,7 +12,14 @@ import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models
@Component({ @Component({
selector: 'app-chat-page', selector: 'app-chat-page',
imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent, PeerCallModalComponent], imports: [
CommonModule,
FormsModule,
RouterLink,
JsonFileViewerComponent,
OfficePdfPreviewComponent,
PeerCallModalComponent,
],
templateUrl: './chat-page.component.html', templateUrl: './chat-page.component.html',
styleUrl: './chat-page.component.scss', styleUrl: './chat-page.component.scss',
}) })
@@ -37,12 +45,18 @@ 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);
@@ -209,6 +223,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 {
@@ -546,6 +586,16 @@ export class ChatPageComponent implements OnDestroy {
this.forwardingEntryId.set(null); this.forwardingEntryId.set(null);
} }
async sendGeneratedImage(entry: ChatEntry): Promise<void> {
const peerId = this.peerId();
if (!peerId) {
return;
}
await this.session.sendGeneratedImageToPeer(entry, peerId);
}
async endVoiceCall(peerId: string): Promise<void> { async endVoiceCall(peerId: string): Promise<void> {
await this.session.endVoiceCall(peerId); await this.session.endVoiceCall(peerId);
} }
@@ -570,6 +620,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;
@@ -592,6 +646,33 @@ export class ChatPageComponent implements OnDestroy {
); );
} }
hasPdfPreview(entry: ChatEntry): boolean {
return (
entry.kind === 'file' &&
(
(
entry.previewMimeType === 'application/pdf' &&
!!entry.previewDownloadUrl
) ||
(
!!entry.downloadUrl &&
(
entry.fileMimeType === 'application/pdf' ||
entry.fileName?.toLowerCase().endsWith('.pdf') === true
)
)
)
);
}
pdfPreviewUrl(entry: ChatEntry): string | null {
if (!this.hasPdfPreview(entry)) {
return null;
}
return entry.previewDownloadUrl ?? entry.downloadUrl ?? null;
}
isPeerTyping(peerId: string): boolean { isPeerTyping(peerId: string): boolean {
return this.session.typingPeerIds().includes(peerId); return this.session.typingPeerIds().includes(peerId);
} }
@@ -804,4 +885,18 @@ export class ChatPageComponent implements OnDestroy {
audio.pause(); audio.pause();
audio.srcObject = null; audio.srcObject = null;
} }
private scrollConversationToBottom(): void {
const container = this.conversationContainer?.nativeElement;
if (!container) {
return;
}
queueMicrotask(() => {
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
});
});
}
} }

View File

@@ -57,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 = {
@@ -78,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 OfficePreviewResponse = {
mimeType: string;
pdfBase64: string;
}; };
type RuntimeEnv = { type RuntimeEnv = {
@@ -669,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);
@@ -1055,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,
@@ -1503,7 +1541,7 @@ 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);
@@ -1533,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.isOfficeDocumentFile(transfer.name, transfer.mimeType)) {
const officePreview = await this.generateOfficeDocumentPreview(transfer.name, blob);
if (officePreview) {
previewBlob = officePreview.blob;
previewMimeType = officePreview.mimeType;
previewDownloadUrl = URL.createObjectURL(officePreview.blob);
}
}
this.pushMessage({ this.pushMessage({
id: transfer.id, id: transfer.id,
@@ -1554,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> {
@@ -1867,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) {
@@ -1875,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);
} }
} }
@@ -2121,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;
@@ -2134,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,
@@ -2158,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 () => {
@@ -2204,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(
@@ -2217,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 || 'application/pdf',
});
previewDownloadUrl = URL.createObjectURL(previewBlob);
}
return { return {
id: entry.id, id: entry.id,
peerId: entry.peerId, peerId: entry.peerId,
@@ -2224,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);
@@ -2245,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,
}; };
} }
@@ -2261,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 || 'application/pdf',
})
: 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> {
@@ -2293,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);
}
} }
} }
@@ -2451,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') {
@@ -2837,6 +2931,56 @@ export class ChatSessionService {
return new Blob([bytes], { type: mimeType }); return new Blob([bytes], { type: mimeType });
} }
private async generateOfficeDocumentPreview(
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<OfficePreviewResponse>(
`${this.serverUrl()}/api/files/office-preview`,
{
fileName,
mimeType: fileBlob.type || 'application/octet-stream',
fileBase64: await this.blobToBase64(fileBlob),
},
{
headers: { Authorization: `Bearer ${token}` },
},
),
);
return {
blob: this.base64ToBlob(response.pdfBase64, response.mimeType),
mimeType: response.mimeType,
};
} catch (error) {
console.warn('Could not generate office document preview.', error);
return null;
}
}
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 fileExtensionForMimeType(mimeType: string): string { private fileExtensionForMimeType(mimeType: string): string {
const normalizedMimeType = mimeType.split(';', 1)[0]?.trim().toLowerCase() || 'application/octet-stream'; const normalizedMimeType = mimeType.split(';', 1)[0]?.trim().toLowerCase() || 'application/octet-stream';

View File

@@ -97,6 +97,7 @@ export interface ChatEntry {
kind: 'text' | 'json' | 'file' | 'voice' | 'system'; kind: 'text' | 'json' | 'file' | 'voice' | 'system';
createdAt: number; createdAt: number;
authorLabel: string; authorLabel: string;
generatedByAi?: boolean;
showSpinner?: boolean; showSpinner?: boolean;
text?: string; text?: string;
payload?: unknown; payload?: unknown;
@@ -104,6 +105,8 @@ 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 CallMode = 'audio' | 'video';

View File

@@ -0,0 +1,13 @@
:host {
display: block;
width: min(240px, 100%);
}
.office-pdf-preview-shell {
width: min(240px, 100%);
overflow: hidden;
border: 1px solid var(--surface-border);
border-radius: 1rem;
background: #fff;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
}

View File

@@ -0,0 +1,45 @@
import { Component, Input } from '@angular/core';
import { NgxExtendedPdfViewerModule } from 'ngx-extended-pdf-viewer';
@Component({
selector: 'app-office-pdf-preview',
imports: [NgxExtendedPdfViewerModule],
template: `
<div class="office-pdf-preview-shell">
<ngx-extended-pdf-viewer
[src]="src"
[filenameForDownload]="fileName"
[height]="'320px'"
[page]="1"
[zoom]="'page-width'"
[showToolbar]="false"
[showSidebarButton]="false"
[showFindButton]="false"
[showPagingButtons]="false"
[showPageNumber]="false"
[showPageLabel]="false"
[showZoomButtons]="false"
[showZoomDropdown]="false"
[showPresentationModeButton]="false"
[showOpenFileButton]="false"
[showPrintButton]="false"
[showDownloadButton]="false"
[showSecondaryToolbarButton]="false"
[showRotateButton]="false"
[showRotateCwButton]="false"
[showRotateCcwButton]="false"
[showScrollingButtons]="false"
[showSpreadButton]="false"
[showPropertiesButton]="false"
[showHandToolButton]="false"
[showBorders]="false"
[textLayer]="false"
></ngx-extended-pdf-viewer>
</div>
`,
styleUrl: './office-pdf-preview.component.scss',
})
export class OfficePdfPreviewComponent {
@Input({ required: true }) src!: string;
@Input() fileName = 'document.pdf';
}

68
server/dist/index.js vendored
View File

@@ -2,13 +2,14 @@ import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
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 +48,11 @@ const adminDeleteUserParamsSchema = z.object({
const webBundleFileParamsSchema = z.object({ const webBundleFileParamsSchema = z.object({
'*': z.string().min(1), '*': z.string().min(1),
}); });
const officePreviewSchema = 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 +117,7 @@ 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 speechTranscriber = new SpeechTranscriber({ const speechTranscriber = new SpeechTranscriber({
serviceUrl: speechTranscriptionServiceUrl, serviceUrl: speechTranscriptionServiceUrl,
language: speechTranscriptionLanguage, language: speechTranscriptionLanguage,
@@ -462,6 +469,35 @@ app.get('/api/auth/session', async (request, reply) => {
messageEncryptionKey: authContext.user.messageEncryptionKey, messageEncryptionKey: authContext.user.messageEncryptionKey,
}; };
}); });
app.post('/api/files/office-preview', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
if (!authContext) {
return;
}
const parsed = officePreviewSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({
message: 'Invalid office preview payload.',
issues: parsed.error.flatten(),
});
}
if (!isSupportedOfficeDocument(parsed.data.fileName, parsed.data.mimeType)) {
return reply.code(400).send({ message: 'Only DOCX, XLSX, and PPTX files can be previewed.' });
}
try {
const pdfBuffer = await convertOfficeDocumentToPdf(parsed.data.fileName, parsed.data.fileBase64);
return {
mimeType: 'application/pdf',
pdfBase64: pdfBuffer.toString('base64'),
};
}
catch (error) {
app.log.warn({ err: error, userId: authContext.user.id }, 'Office preview generation failed');
return reply.code(422).send({
message: describeOfficePreviewFailure(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 +871,36 @@ 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 });
}
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 normalizeOfficeDocumentFileName(fileName) {
return fileName.trim().replace(/\.xslx$/i, '.xlsx');
}
function describeOfficePreviewFailure(error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return 'Office preview generation failed because LibreOffice is not installed on the server.';
}
if (error instanceof Error && error.message.trim()) {
return `Office preview generation failed: ${error.message}`;
}
return 'Office preview generation failed.';
}
function createUser(input) { function createUser(input) {
const createdAt = new Date().toISOString(); const createdAt = new Date().toISOString();
const user = { const user = {

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
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 +10,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 +272,12 @@ const webBundleFileParamsSchema = z.object({
'*': z.string().min(1), '*': z.string().min(1),
}); });
const officePreviewSchema = 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 +353,7 @@ 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 speechTranscriber = new SpeechTranscriber( const speechTranscriber = new SpeechTranscriber(
{ {
@@ -795,6 +803,41 @@ app.get('/api/auth/session', async (request, reply) => {
}; };
}); });
app.post('/api/files/office-preview', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
if (!authContext) {
return;
}
const parsed = officePreviewSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({
message: 'Invalid office preview payload.',
issues: parsed.error.flatten(),
});
}
if (!isSupportedOfficeDocument(parsed.data.fileName, parsed.data.mimeType)) {
return reply.code(400).send({ message: 'Only DOCX, XLSX, and PPTX files can be previewed.' });
}
try {
const pdfBuffer = await convertOfficeDocumentToPdf(parsed.data.fileName, parsed.data.fileBase64);
return {
mimeType: 'application/pdf',
pdfBase64: pdfBuffer.toString('base64'),
};
} catch (error) {
app.log.warn({ err: error, userId: authContext.user.id }, 'Office preview generation failed');
return reply.code(422).send({
message: describeOfficePreviewFailure(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 +1337,48 @@ 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 });
}
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 normalizeOfficeDocumentFileName(fileName: string): string {
return fileName.trim().replace(/\.xslx$/i, '.xlsx');
}
function describeOfficePreviewFailure(error: unknown): string {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return 'Office preview generation failed because LibreOffice is not installed on the server.';
}
if (error instanceof Error && error.message.trim()) {
return `Office preview generation failed: ${error.message}`;
}
return 'Office preview generation failed.';
}
function createUser(input: { function createUser(input: {
username: string; username: string;
displayName: string; displayName: string;