diff --git a/client/angular.json b/client/angular.json index 2ed89f6..938b977 100644 --- a/client/angular.json +++ b/client/angular.json @@ -49,6 +49,11 @@ { "glob": "**/*", "input": "public" + }, + { + "glob": "magick.wasm", + "input": "node_modules/@imagemagick/magick-wasm/dist", + "output": "/" } ], "styles": [ @@ -63,8 +68,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "700kB", - "maximumError": "1MB" + "maximumWarning": "1MB", + "maximumError": "2MB" }, { "type": "anyComponentStyle", diff --git a/client/package-lock.json b/client/package-lock.json index f302a8f..2826803 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -14,6 +14,7 @@ "@angular/forms": "^21.2.0", "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", + "@imagemagick/magick-wasm": "^0.0.39", "bootstrap": "^5.3.8", "ngx-extended-pdf-viewer": "^25.6.4", "rxjs": "~7.8.0", @@ -1400,6 +1401,12 @@ "hono": "^4" } }, + "node_modules/@imagemagick/magick-wasm": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@imagemagick/magick-wasm/-/magick-wasm-0.0.39.tgz", + "integrity": "sha512-vQm4MFNVh1LnECHsF/FfaWu4HqPVkb5oMVvKRvP+5Qkk0Vq9W+5UPzC+XgKU8ji0Kpeo3mWc+6Ap1Ps/frZ4ug==", + "license": "Apache-2.0" + }, "node_modules/@inquirer/ansi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", diff --git a/client/package.json b/client/package.json index ba41488..d1012de 100644 --- a/client/package.json +++ b/client/package.json @@ -18,6 +18,7 @@ "@angular/forms": "^21.2.0", "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", + "@imagemagick/magick-wasm": "^0.0.39", "bootstrap": "^5.3.8", "ngx-extended-pdf-viewer": "^25.6.4", "rxjs": "~7.8.0", diff --git a/client/public/notif.mp3 b/client/public/notif.mp3 new file mode 100644 index 0000000..6b1d24d Binary files /dev/null and b/client/public/notif.mp3 differ diff --git a/client/src/app/chat-page.component.html b/client/src/app/chat-page.component.html index 0748500..1331cff 100644 --- a/client/src/app/chat-page.component.html +++ b/client/src/app/chat-page.component.html @@ -448,7 +448,7 @@ @if (isImageEntry(entry)) { } diff --git a/client/src/app/chat-page.component.ts b/client/src/app/chat-page.component.ts index d441669..332f76a 100644 --- a/client/src/app/chat-page.component.ts +++ b/client/src/app/chat-page.component.ts @@ -772,13 +772,21 @@ export class ChatPageComponent implements OnDestroy { } isImageEntry(entry: ChatEntry): boolean { - return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false); + return entry.kind === 'file' && !!this.imageDisplayUrl(entry); } isGeneratedImageEntry(entry: ChatEntry): boolean { return this.isImageEntry(entry) && entry.generatedByAi === true; } + imageDisplayUrl(entry: ChatEntry): string | null { + if (entry.kind !== 'file' || !(entry.fileMimeType?.startsWith('image/') ?? false)) { + return null; + } + + return entry.previewDownloadUrl ?? entry.downloadUrl ?? null; + } + isVideoEntry(entry: ChatEntry): boolean { if (entry.kind !== 'file' || !entry.downloadUrl) { return false; @@ -804,6 +812,7 @@ export class ChatPageComponent implements OnDestroy { hasDocumentPreviewImage(entry: ChatEntry): boolean { return ( entry.kind === 'file' && + !(entry.fileMimeType?.startsWith('image/') ?? false) && !!entry.previewDownloadUrl && (entry.previewMimeType?.startsWith('image/') ?? false) ); diff --git a/client/src/app/chat-session.service.ts b/client/src/app/chat-session.service.ts index 6e6ac2a..a0cf1e9 100644 --- a/client/src/app/chat-session.service.ts +++ b/client/src/app/chat-session.service.ts @@ -1,5 +1,6 @@ import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { computed, Injectable, signal } from '@angular/core'; +import { ImageMagick, MagickFormat, initializeImageMagick } from '@imagemagick/magick-wasm'; import { firstValueFrom } from 'rxjs'; import { @@ -116,11 +117,15 @@ function readDefaultServerUrl(): string { return 'http://localhost:3000'; } +const browserDisplayImageMimeTypes = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/avif']); +const convertedPreviewImageMimeType = 'image/avif'; + @Injectable({ providedIn: 'root' }) export class ChatSessionService { private static readonly messageDatabaseName = 'privatechat'; private static readonly messageStoreName = 'conversation_messages'; private static readonly knownPeersStoragePrefix = 'privatechat.knownPeers'; + private static readonly incomingMessageSoundStorageKey = 'privatechat.incomingMessageSoundEnabled'; private static readonly messageRetentionLimit = 256; private static readonly sessionKeepaliveMs = 5 * 60 * 1000; private static readonly signalingHeartbeatMs = 25 * 1000; @@ -132,6 +137,7 @@ export class ChatSessionService { private static readonly typingIdleMs = 1200; private static readonly typingHeartbeatMs = 900; private static readonly incomingCallRingtoneFileName = 'SymphonyDing.mp3'; + private static readonly incomingMessageSoundFileName = 'notif.mp3'; readonly serverUrl = signal(readDefaultServerUrl()); readonly currentUser = signal(this.readUserStorage()); @@ -149,6 +155,9 @@ export class ChatSessionService { readonly error = signal(null); readonly notice = signal(null); readonly lastIncomingReceiveMetric = signal<{ peerId: string; mbps: number } | null>(null); + readonly incomingMessageSoundEnabled = signal( + this.readStorage(ChatSessionService.incomingMessageSoundStorageKey) !== '0', + ); readonly webAuthnSupported = signal( typeof window !== 'undefined' && typeof window.PublicKeyCredential !== 'undefined' && @@ -195,6 +204,7 @@ export class ChatSessionService { private readonly remoteVideoStreams = signal>([]); private readonly remoteAudioStreams = signal>([]); private readonly localCallPeerId = signal(null); + private imageMagickInitializationPromise: Promise | null = null; private sessionKeepaliveIntervalId: number | null = null; private websocketHeartbeatIntervalId: number | null = null; private websocketReconnectTimeoutId: number | null = null; @@ -204,6 +214,8 @@ export class ChatSessionService { private lastWebSocketPongAt = 0; private ringtoneAudio: HTMLAudioElement | null = null; private ringtoneAudioUrl: string = this.resolveIncomingCallRingtoneUrl(); + private notificationAudio: HTMLAudioElement | null = null; + private notificationAudioUrl: string = this.resolveIncomingMessageSoundUrl(); private ringtonePreloadPromise: Promise | null = null; private messageEncryptionKey: CryptoKey | null = null; private messageDatabasePromise: Promise | null = null; @@ -314,6 +326,11 @@ export class ChatSessionService { } } + setIncomingMessageSoundEnabled(enabled: boolean): void { + this.incomingMessageSoundEnabled.set(enabled); + this.writeStorage(ChatSessionService.incomingMessageSoundStorageKey, enabled ? '1' : '0'); + } + selectPeer(peerId: string): void { this.activePeerId.set(peerId); this.clearUnreadPeer(peerId); @@ -595,6 +612,21 @@ export class ChatSessionService { } async sendFile(peerId: string, file: File, attachmentKind: 'file' | 'voice' = 'file'): Promise { + const resolvedMimeType = file.type || 'application/octet-stream'; + let previewBlob: Blob | undefined; + let previewMimeType: string | undefined; + let previewDownloadUrl: string | undefined; + + if (attachmentKind === 'file') { + const imagePreview = await this.generateDisplayableImagePreview(file, resolvedMimeType); + + if (imagePreview) { + previewBlob = imagePreview.blob; + previewMimeType = imagePreview.mimeType; + previewDownloadUrl = URL.createObjectURL(imagePreview.blob); + } + } + this.pushMessage({ id: crypto.randomUUID(), peerId, @@ -605,9 +637,11 @@ export class ChatSessionService { deliveryState: 'pending', fileName: file.name, fileSize: file.size, - fileMimeType: file.type || 'application/octet-stream', + fileMimeType: resolvedMimeType, downloadUrl: URL.createObjectURL(file), - }, file); + previewMimeType, + previewDownloadUrl, + }, file, previewBlob); if (!this.canAttemptImmediatePeerDelivery(peerId)) { this.clearOutgoingTyping(peerId); @@ -1972,8 +2006,10 @@ export class ChatSessionService { 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 (transfer.kind === 'file') { + const imagePreview = this.isPreviewableDocumentFile(transfer.name, transfer.mimeType) + ? await this.generateDocumentPreviewImage(transfer.name, blob) + : await this.generateDisplayableImagePreview(blob, transfer.mimeType); if (imagePreview) { previewBlob = imagePreview.blob; @@ -2344,6 +2380,10 @@ export class ChatSessionService { this.markPeerUnread(entry.peerId); } + if (entry.direction === 'incoming' && entry.kind !== 'system') { + this.playIncomingMessageSound(); + } + if (entry.kind !== 'system') { void this.persistMessage(entry, fileBlob, previewBlob); } @@ -3323,6 +3363,32 @@ export class ChatSessionService { ).toString(); } + private playIncomingMessageSound(): void { + if (!this.incomingMessageSoundEnabled() || typeof Audio === 'undefined') { + return; + } + + if (!this.notificationAudio) { + const notificationAudio = new Audio(this.notificationAudioUrl); + notificationAudio.preload = 'auto'; + this.notificationAudio = notificationAudio; + } + + this.notificationAudio.pause(); + this.notificationAudio.currentTime = 0; + void this.notificationAudio.play().catch(() => { + // Playback may wait for a browser gesture. + }); + } + + private resolveIncomingMessageSoundUrl(): string { + if (typeof document === 'undefined') { + return `/${ChatSessionService.incomingMessageSoundFileName}`; + } + + return new URL(ChatSessionService.incomingMessageSoundFileName, document.baseURI).toString(); + } + private async fetchPreloadedRingtoneUrl(): Promise { for (const ringtoneUrl of this.incomingCallRingtoneCandidateUrls()) { try { @@ -3491,6 +3557,40 @@ export class ChatSessionService { return new Blob([bytes], { type: mimeType }); } + private async ensureImageMagickInitialized(): Promise { + this.imageMagickInitializationPromise ??= initializeImageMagick(this.resolveMagickWasmUrl()); + await this.imageMagickInitializationPromise; + } + + private async generateDisplayableImagePreview( + fileBlob: Blob, + mimeType?: string, + ): Promise<{ blob: Blob; mimeType: string } | null> { + const normalizedMimeType = this.normalizeMimeType(mimeType); + + if (!normalizedMimeType.startsWith('image/') || browserDisplayImageMimeTypes.has(normalizedMimeType)) { + return null; + } + + try { + await this.ensureImageMagickInitialized(); + const imageData = new Uint8Array(await fileBlob.arrayBuffer()); + const avifData = await ImageMagick.read(imageData, async (image) => { + image.quality = 60; + image.settings.setDefine(MagickFormat.Avif, 'speed', 6); + return image.write(MagickFormat.Avif, (data) => new Uint8Array(data)); + }); + + return { + blob: new Blob([avifData], { type: convertedPreviewImageMimeType }), + mimeType: convertedPreviewImageMimeType, + }; + } catch (error) { + console.warn('Could not convert image preview.', error); + return null; + } + } + private async generateDocumentPreviewImage( fileName: string, fileBlob: Blob, @@ -3547,11 +3647,23 @@ export class ChatSessionService { private isPdfFile(fileName?: string, mimeType?: string): boolean { const normalizedName = fileName?.trim().toLowerCase() ?? ''; - const normalizedMimeType = mimeType?.trim().toLowerCase() ?? ''; + const normalizedMimeType = this.normalizeMimeType(mimeType); return normalizedMimeType === 'application/pdf' || normalizedName.endsWith('.pdf'); } + private normalizeMimeType(mimeType?: string): string { + return mimeType?.split(';', 1)[0]?.trim().toLowerCase() ?? ''; + } + + private resolveMagickWasmUrl(): URL { + if (typeof document !== 'undefined') { + return new URL('magick.wasm', document.baseURI); + } + + return new URL('http://localhost:3000/magick.wasm'); + } + private fileExtensionForMimeType(mimeType: string): string { const normalizedMimeType = mimeType.split(';', 1)[0]?.trim().toLowerCase() || 'application/octet-stream'; diff --git a/client/src/app/home-page.component.html b/client/src/app/home-page.component.html index 4d54127..268547e 100644 --- a/client/src/app/home-page.component.html +++ b/client/src/app/home-page.component.html @@ -188,6 +188,27 @@
{{ session.notice() }}
} +
+
+
+

Notifications

+

Play a sound when any incoming message or file arrives.

+
+
+ + +
+
+
+
diff --git a/client/src/app/home-page.component.ts b/client/src/app/home-page.component.ts index bd96c9c..7ff29aa 100644 --- a/client/src/app/home-page.component.ts +++ b/client/src/app/home-page.component.ts @@ -198,4 +198,8 @@ export class HomePageComponent { cycleTheme(): void { this.theme.cycleMode(); } + + setIncomingMessageSound(enabled: boolean): void { + this.session.setIncomingMessageSoundEnabled(enabled); + } }