documents preview
This commit is contained in:
14
client/package-lock.json
generated
14
client/package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"ngx-extended-pdf-viewer": "^25.6.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -5960,6 +5961,19 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ngx-extended-pdf-viewer": {
|
||||
"version": "25.6.4",
|
||||
"resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-25.6.4.tgz",
|
||||
"integrity": "sha512-eYIiWzatcupB7HKDtcOOZN7gcLFjqAkeIAlZOMIO6XyUJnTe+PUZLZGit/19mtO/8fAaH41lMyyh8MAcU8NAhA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": ">=17.0.0 <22.0.0",
|
||||
"@angular/core": ">=17.0.0 <22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"ngx-extended-pdf-viewer": "^25.6.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
</aside>
|
||||
|
||||
<div class="chat-main">
|
||||
<div class="conversation">
|
||||
<div #conversationContainer class="conversation">
|
||||
@if (conversation().length === 0) {
|
||||
<div class="empty-chat">
|
||||
No text messages yet. The chat page is ready as soon as the peer channel opens.
|
||||
@@ -145,6 +145,17 @@
|
||||
>
|
||||
@if (entry.direction !== 'system') {
|
||||
<div class="bubble-actions">
|
||||
@if (isGeneratedImageEntry(entry)) {
|
||||
<button
|
||||
class="bubble-action"
|
||||
type="button"
|
||||
(click)="sendGeneratedImage(entry)"
|
||||
title="Send image to peer"
|
||||
aria-label="Send image to peer"
|
||||
>
|
||||
📤
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
class="bubble-action"
|
||||
type="button"
|
||||
@@ -223,6 +234,20 @@
|
||||
@if (entry.downloadUrl) {
|
||||
<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>
|
||||
}
|
||||
@case ('voice') {
|
||||
|
||||
@@ -527,6 +527,30 @@
|
||||
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-video {
|
||||
width: 200px;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Component, computed, effect, ElementRef, inject, NgZone, OnDestroy, sig
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { OfficePdfPreviewComponent } from './office-pdf-preview.component';
|
||||
|
||||
import { PeerCallModalComponent } from './peer-call-modal.component';
|
||||
import { ChatSessionService } from './chat-session.service';
|
||||
@@ -11,7 +12,14 @@ import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-page',
|
||||
imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent, PeerCallModalComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
JsonFileViewerComponent,
|
||||
OfficePdfPreviewComponent,
|
||||
PeerCallModalComponent,
|
||||
],
|
||||
templateUrl: './chat-page.component.html',
|
||||
styleUrl: './chat-page.component.scss',
|
||||
})
|
||||
@@ -37,12 +45,18 @@ export class ChatPageComponent implements OnDestroy {
|
||||
private dictationCompletionPromise: Promise<void> | null = null;
|
||||
private resolveDictationCompletion: (() => void) | null = null;
|
||||
private dictationApplyToken = 0;
|
||||
private lastConversationSnapshot: { peerId: string; length: number; lastEntryId: string | null } | null = null;
|
||||
@ViewChild('callAudioElement')
|
||||
set callAudioElementRef(value: ElementRef<HTMLAudioElement> | undefined) {
|
||||
this.callAudioElement = value;
|
||||
this.syncCallAudioSource();
|
||||
}
|
||||
private callAudioElement?: ElementRef<HTMLAudioElement>;
|
||||
@ViewChild('conversationContainer')
|
||||
set conversationContainerRef(value: ElementRef<HTMLDivElement> | undefined) {
|
||||
this.conversationContainer = value;
|
||||
}
|
||||
private conversationContainer?: ElementRef<HTMLDivElement>;
|
||||
|
||||
messageText = '';
|
||||
readonly forwardingEntryId = signal<string | null>(null);
|
||||
@@ -209,6 +223,32 @@ export class ChatPageComponent implements OnDestroy {
|
||||
this.remoteCallAudioStream();
|
||||
this.syncCallAudioSource();
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const peerId = this.peerId();
|
||||
const entries = this.conversation();
|
||||
const snapshot = {
|
||||
peerId,
|
||||
length: entries.length,
|
||||
lastEntryId: entries.at(-1)?.id ?? null,
|
||||
};
|
||||
const previousSnapshot = this.lastConversationSnapshot;
|
||||
|
||||
this.lastConversationSnapshot = snapshot;
|
||||
|
||||
if (!peerId || !previousSnapshot || previousSnapshot.peerId !== peerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasNewTailEntry = snapshot.length > previousSnapshot.length
|
||||
|| (snapshot.length > 0 && snapshot.lastEntryId !== previousSnapshot.lastEntryId);
|
||||
|
||||
if (!hasNewTailEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scrollConversationToBottom();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@@ -546,6 +586,16 @@ export class ChatPageComponent implements OnDestroy {
|
||||
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> {
|
||||
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);
|
||||
}
|
||||
|
||||
isGeneratedImageEntry(entry: ChatEntry): boolean {
|
||||
return this.isImageEntry(entry) && entry.generatedByAi === true;
|
||||
}
|
||||
|
||||
isVideoEntry(entry: ChatEntry): boolean {
|
||||
if (entry.kind !== 'file' || !entry.downloadUrl) {
|
||||
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 {
|
||||
return this.session.typingPeerIds().includes(peerId);
|
||||
}
|
||||
@@ -804,4 +885,18 @@ export class ChatPageComponent implements OnDestroy {
|
||||
audio.pause();
|
||||
audio.srcObject = null;
|
||||
}
|
||||
|
||||
private scrollConversationToBottom(): void {
|
||||
const container = this.conversationContainer?.nativeElement;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
requestAnimationFrame(() => {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,12 +57,15 @@ type LegacyPersistedChatEntry = {
|
||||
kind: Exclude<ChatEntry['kind'], 'system'>;
|
||||
createdAt: number;
|
||||
authorLabel: string;
|
||||
generatedByAi?: boolean;
|
||||
text?: string;
|
||||
payload?: unknown;
|
||||
fileName?: string;
|
||||
fileSize?: number;
|
||||
fileMimeType?: string;
|
||||
fileBlob?: Blob;
|
||||
previewMimeType?: string;
|
||||
previewBlob?: Blob;
|
||||
};
|
||||
|
||||
type EncryptedPersistedChatEntry = {
|
||||
@@ -78,17 +81,26 @@ type EncryptedPersistedChatEntry = {
|
||||
payloadIv: number[];
|
||||
encryptedFileBlob?: PersistedBinary;
|
||||
fileIv?: number[];
|
||||
encryptedPreviewBlob?: PersistedBinary;
|
||||
previewIv?: number[];
|
||||
};
|
||||
|
||||
type PersistedChatEntry = LegacyPersistedChatEntry | EncryptedPersistedChatEntry;
|
||||
|
||||
type PersistedChatEntryContent = {
|
||||
authorLabel: string;
|
||||
generatedByAi?: boolean;
|
||||
text?: string;
|
||||
payload?: unknown;
|
||||
fileName?: string;
|
||||
fileSize?: number;
|
||||
fileMimeType?: string;
|
||||
previewMimeType?: string;
|
||||
};
|
||||
|
||||
type OfficePreviewResponse = {
|
||||
mimeType: string;
|
||||
pdfBase64: string;
|
||||
};
|
||||
|
||||
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> {
|
||||
this.error.set(null);
|
||||
this.notice.set(null);
|
||||
@@ -1055,6 +1092,7 @@ export class ChatSessionService {
|
||||
kind: 'file',
|
||||
createdAt: event.createdAt,
|
||||
authorLabel: 'You',
|
||||
generatedByAi: true,
|
||||
text: pendingRequest?.prompt ?? event.prompt,
|
||||
fileName,
|
||||
fileSize: imageBlob.size,
|
||||
@@ -1503,7 +1541,7 @@ export class ChatSessionService {
|
||||
this.addSystemMessage(peerId, `Receiving file ${envelope.name}.`);
|
||||
break;
|
||||
case 'file-complete':
|
||||
this.finalizeIncomingFile(peerId, envelope.id);
|
||||
void this.finalizeIncomingFile(peerId, envelope.id);
|
||||
break;
|
||||
case 'typing':
|
||||
this.setPeerTyping(peerId, envelope.active);
|
||||
@@ -1533,15 +1571,30 @@ export class ChatSessionService {
|
||||
transfer.receivedBytes += arrayBuffer.byteLength;
|
||||
}
|
||||
|
||||
private finalizeIncomingFile(peerId: string, transferId: string): void {
|
||||
private async finalizeIncomingFile(peerId: string, transferId: string): Promise<void> {
|
||||
const transfer = this.incomingFiles.get(peerId);
|
||||
|
||||
if (!transfer || transfer.id !== transferId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.incomingFiles.delete(peerId);
|
||||
|
||||
const blob = new Blob(transfer.chunks, { type: transfer.mimeType });
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
let previewBlob: Blob | undefined;
|
||||
let previewMimeType: string | undefined;
|
||||
let previewDownloadUrl: string | undefined;
|
||||
|
||||
if (transfer.kind === 'file' && this.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({
|
||||
id: transfer.id,
|
||||
@@ -1554,9 +1607,9 @@ export class ChatSessionService {
|
||||
fileSize: transfer.size,
|
||||
fileMimeType: transfer.mimeType,
|
||||
downloadUrl,
|
||||
}, blob);
|
||||
|
||||
this.incomingFiles.delete(peerId);
|
||||
previewMimeType,
|
||||
previewDownloadUrl,
|
||||
}, blob, previewBlob);
|
||||
}
|
||||
|
||||
private async flushPendingCandidates(bundle: PeerBundle): Promise<void> {
|
||||
@@ -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));
|
||||
|
||||
if (entry.direction === 'incoming' && entry.kind !== 'system' && this.activePeerId() !== entry.peerId) {
|
||||
@@ -1875,7 +1928,7 @@ export class ChatSessionService {
|
||||
}
|
||||
|
||||
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 messageEncryptionKey = this.messageEncryptionKey;
|
||||
|
||||
@@ -2134,15 +2187,20 @@ export class ChatSessionService {
|
||||
const storageKey = this.messageStorageKey(currentUserId, entry.peerId, entry.id);
|
||||
const encryptedPayload = await this.encryptPersistedMessageContent(messageEncryptionKey, {
|
||||
authorLabel: entry.authorLabel,
|
||||
generatedByAi: entry.generatedByAi,
|
||||
text: entry.text,
|
||||
payload: entry.payload,
|
||||
fileName: entry.fileName,
|
||||
fileSize: entry.fileSize,
|
||||
fileMimeType: entry.fileMimeType,
|
||||
previewMimeType: entry.previewMimeType,
|
||||
});
|
||||
const encryptedFileBlob = fileBlob
|
||||
? await this.encryptBinary(messageEncryptionKey, await fileBlob.arrayBuffer())
|
||||
: null;
|
||||
const encryptedPreviewBlob = previewBlob
|
||||
? await this.encryptBinary(messageEncryptionKey, await previewBlob.arrayBuffer())
|
||||
: null;
|
||||
const persistedEntry: EncryptedPersistedChatEntry = {
|
||||
storageKey,
|
||||
ownerUserId: currentUserId,
|
||||
@@ -2158,6 +2216,10 @@ export class ChatSessionService {
|
||||
? this.serializePersistedBinary(encryptedFileBlob.ciphertext)
|
||||
: undefined,
|
||||
fileIv: encryptedFileBlob ? Array.from(encryptedFileBlob.iv) : undefined,
|
||||
encryptedPreviewBlob: encryptedPreviewBlob
|
||||
? this.serializePersistedBinary(encryptedPreviewBlob.ciphertext)
|
||||
: undefined,
|
||||
previewIv: encryptedPreviewBlob ? Array.from(encryptedPreviewBlob.iv) : undefined,
|
||||
};
|
||||
|
||||
await this.queueMessageStoreOperation(storageKey, async () => {
|
||||
@@ -2204,6 +2266,7 @@ export class ChatSessionService {
|
||||
try {
|
||||
const content = await this.decryptPersistedMessageContent(messageEncryptionKey, entry);
|
||||
let downloadUrl: string | undefined;
|
||||
let previewDownloadUrl: string | undefined;
|
||||
|
||||
if (entry.encryptedFileBlob && entry.fileIv) {
|
||||
const decryptedFile = await this.decryptBinary(
|
||||
@@ -2217,6 +2280,18 @@ export class ChatSessionService {
|
||||
downloadUrl = URL.createObjectURL(fileBlob);
|
||||
}
|
||||
|
||||
if (entry.encryptedPreviewBlob && entry.previewIv) {
|
||||
const decryptedPreview = await this.decryptBinary(
|
||||
messageEncryptionKey,
|
||||
this.deserializePersistedBinary(entry.encryptedPreviewBlob),
|
||||
Uint8Array.from(entry.previewIv).buffer,
|
||||
);
|
||||
const previewBlob = new Blob([decryptedPreview], {
|
||||
type: content.previewMimeType || 'application/pdf',
|
||||
});
|
||||
previewDownloadUrl = URL.createObjectURL(previewBlob);
|
||||
}
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
peerId: entry.peerId,
|
||||
@@ -2224,12 +2299,15 @@ export class ChatSessionService {
|
||||
kind: entry.kind,
|
||||
createdAt: entry.createdAt,
|
||||
authorLabel: content.authorLabel,
|
||||
generatedByAi: content.generatedByAi,
|
||||
text: content.text,
|
||||
payload: content.payload,
|
||||
fileName: content.fileName,
|
||||
fileSize: content.fileSize,
|
||||
fileMimeType: content.fileMimeType,
|
||||
downloadUrl,
|
||||
previewMimeType: content.previewMimeType,
|
||||
previewDownloadUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Could not decrypt persisted chat message.', error);
|
||||
@@ -2245,12 +2323,15 @@ export class ChatSessionService {
|
||||
kind: entry.kind,
|
||||
createdAt: entry.createdAt,
|
||||
authorLabel: entry.authorLabel,
|
||||
generatedByAi: entry.generatedByAi,
|
||||
text: entry.text,
|
||||
payload: entry.payload,
|
||||
fileName: entry.fileName,
|
||||
fileSize: entry.fileSize,
|
||||
fileMimeType: entry.fileMimeType,
|
||||
previewMimeType: entry.previewMimeType,
|
||||
downloadUrl: entry.fileBlob ? URL.createObjectURL(entry.fileBlob) : undefined,
|
||||
previewDownloadUrl: entry.previewBlob ? URL.createObjectURL(entry.previewBlob) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2261,8 +2342,13 @@ export class ChatSessionService {
|
||||
type: entry.fileMimeType || 'application/octet-stream',
|
||||
})
|
||||
: undefined;
|
||||
const previewBlob = entry.previewBlob
|
||||
? new Blob([await entry.previewBlob.arrayBuffer()], {
|
||||
type: entry.previewMimeType || 'application/pdf',
|
||||
})
|
||||
: undefined;
|
||||
|
||||
await this.persistMessage(hydratedEntry, fileBlob);
|
||||
await this.persistMessage(hydratedEntry, fileBlob, previewBlob);
|
||||
}
|
||||
|
||||
private async migrateEncryptedPersistedMessage(entry: EncryptedPersistedChatEntry): Promise<void> {
|
||||
@@ -2293,6 +2379,10 @@ export class ChatSessionService {
|
||||
if (entry.downloadUrl?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(entry.downloadUrl);
|
||||
}
|
||||
|
||||
if (entry.previewDownloadUrl?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(entry.previewDownloadUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2451,6 +2541,10 @@ export class ChatSessionService {
|
||||
URL.revokeObjectURL(message.downloadUrl);
|
||||
}
|
||||
|
||||
if (message.previewDownloadUrl?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(message.previewDownloadUrl);
|
||||
}
|
||||
|
||||
const timeoutId = this.systemMessageTimeouts.get(messageId);
|
||||
|
||||
if (typeof timeoutId !== 'undefined') {
|
||||
@@ -2837,6 +2931,56 @@ export class ChatSessionService {
|
||||
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 {
|
||||
const normalizedMimeType = mimeType.split(';', 1)[0]?.trim().toLowerCase() || 'application/octet-stream';
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ export interface ChatEntry {
|
||||
kind: 'text' | 'json' | 'file' | 'voice' | 'system';
|
||||
createdAt: number;
|
||||
authorLabel: string;
|
||||
generatedByAi?: boolean;
|
||||
showSpinner?: boolean;
|
||||
text?: string;
|
||||
payload?: unknown;
|
||||
@@ -104,6 +105,8 @@ export interface ChatEntry {
|
||||
fileSize?: number;
|
||||
fileMimeType?: string;
|
||||
downloadUrl?: string;
|
||||
previewMimeType?: string;
|
||||
previewDownloadUrl?: string;
|
||||
}
|
||||
|
||||
export type CallMode = 'audio' | 'video';
|
||||
|
||||
13
client/src/app/office-pdf-preview.component.scss
Normal file
13
client/src/app/office-pdf-preview.component.scss
Normal 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);
|
||||
}
|
||||
45
client/src/app/office-pdf-preview.component.ts
Normal file
45
client/src/app/office-pdf-preview.component.ts
Normal 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';
|
||||
}
|
||||
Reference in New Issue
Block a user