notification sound

This commit is contained in:
2026-03-25 23:41:38 +01:00
parent b27656bb43
commit cc14b4d1b7
9 changed files with 168 additions and 9 deletions

View File

@@ -49,6 +49,11 @@
{ {
"glob": "**/*", "glob": "**/*",
"input": "public" "input": "public"
},
{
"glob": "magick.wasm",
"input": "node_modules/@imagemagick/magick-wasm/dist",
"output": "/"
} }
], ],
"styles": [ "styles": [
@@ -63,8 +68,8 @@
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "700kB", "maximumWarning": "1MB",
"maximumError": "1MB" "maximumError": "2MB"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",

View File

@@ -14,6 +14,7 @@
"@angular/forms": "^21.2.0", "@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0", "@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0", "@angular/router": "^21.2.0",
"@imagemagick/magick-wasm": "^0.0.39",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"ngx-extended-pdf-viewer": "^25.6.4", "ngx-extended-pdf-viewer": "^25.6.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
@@ -1400,6 +1401,12 @@
"hono": "^4" "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": { "node_modules/@inquirer/ansi": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",

View File

@@ -18,6 +18,7 @@
"@angular/forms": "^21.2.0", "@angular/forms": "^21.2.0",
"@angular/platform-browser": "^21.2.0", "@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0", "@angular/router": "^21.2.0",
"@imagemagick/magick-wasm": "^0.0.39",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"ngx-extended-pdf-viewer": "^25.6.4", "ngx-extended-pdf-viewer": "^25.6.4",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",

BIN
client/public/notif.mp3 Normal file

Binary file not shown.

View File

@@ -448,7 +448,7 @@
@if (isImageEntry(entry)) { @if (isImageEntry(entry)) {
<img <img
class="bubble-image" class="bubble-image"
[src]="entry.downloadUrl" [src]="imageDisplayUrl(entry)"
[alt]="entry.fileName || 'Shared image'" [alt]="entry.fileName || 'Shared image'"
/> />
} }

View File

@@ -772,13 +772,21 @@ export class ChatPageComponent implements OnDestroy {
} }
isImageEntry(entry: ChatEntry): boolean { 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 { isGeneratedImageEntry(entry: ChatEntry): boolean {
return this.isImageEntry(entry) && entry.generatedByAi === true; 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 { isVideoEntry(entry: ChatEntry): boolean {
if (entry.kind !== 'file' || !entry.downloadUrl) { if (entry.kind !== 'file' || !entry.downloadUrl) {
return false; return false;
@@ -804,6 +812,7 @@ export class ChatPageComponent implements OnDestroy {
hasDocumentPreviewImage(entry: ChatEntry): boolean { hasDocumentPreviewImage(entry: ChatEntry): boolean {
return ( return (
entry.kind === 'file' && entry.kind === 'file' &&
!(entry.fileMimeType?.startsWith('image/') ?? false) &&
!!entry.previewDownloadUrl && !!entry.previewDownloadUrl &&
(entry.previewMimeType?.startsWith('image/') ?? false) (entry.previewMimeType?.startsWith('image/') ?? false)
); );

View File

@@ -1,5 +1,6 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { computed, Injectable, signal } from '@angular/core'; import { computed, Injectable, signal } from '@angular/core';
import { ImageMagick, MagickFormat, initializeImageMagick } from '@imagemagick/magick-wasm';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { import {
@@ -116,11 +117,15 @@ function readDefaultServerUrl(): string {
return 'http://localhost:3000'; return 'http://localhost:3000';
} }
const browserDisplayImageMimeTypes = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/avif']);
const convertedPreviewImageMimeType = 'image/avif';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ChatSessionService { export class ChatSessionService {
private static readonly messageDatabaseName = 'privatechat'; private static readonly messageDatabaseName = 'privatechat';
private static readonly messageStoreName = 'conversation_messages'; private static readonly messageStoreName = 'conversation_messages';
private static readonly knownPeersStoragePrefix = 'privatechat.knownPeers'; private static readonly knownPeersStoragePrefix = 'privatechat.knownPeers';
private static readonly incomingMessageSoundStorageKey = 'privatechat.incomingMessageSoundEnabled';
private static readonly messageRetentionLimit = 256; private static readonly messageRetentionLimit = 256;
private static readonly sessionKeepaliveMs = 5 * 60 * 1000; private static readonly sessionKeepaliveMs = 5 * 60 * 1000;
private static readonly signalingHeartbeatMs = 25 * 1000; private static readonly signalingHeartbeatMs = 25 * 1000;
@@ -132,6 +137,7 @@ export class ChatSessionService {
private static readonly typingIdleMs = 1200; private static readonly typingIdleMs = 1200;
private static readonly typingHeartbeatMs = 900; private static readonly typingHeartbeatMs = 900;
private static readonly incomingCallRingtoneFileName = 'SymphonyDing.mp3'; private static readonly incomingCallRingtoneFileName = 'SymphonyDing.mp3';
private static readonly incomingMessageSoundFileName = 'notif.mp3';
readonly serverUrl = signal(readDefaultServerUrl()); readonly serverUrl = signal(readDefaultServerUrl());
readonly currentUser = signal<UserProfile | null>(this.readUserStorage()); readonly currentUser = signal<UserProfile | null>(this.readUserStorage());
@@ -149,6 +155,9 @@ export class ChatSessionService {
readonly error = signal<string | null>(null); readonly error = signal<string | null>(null);
readonly notice = signal<string | null>(null); readonly notice = signal<string | null>(null);
readonly lastIncomingReceiveMetric = signal<{ peerId: string; mbps: number } | null>(null); readonly lastIncomingReceiveMetric = signal<{ peerId: string; mbps: number } | null>(null);
readonly incomingMessageSoundEnabled = signal(
this.readStorage(ChatSessionService.incomingMessageSoundStorageKey) !== '0',
);
readonly webAuthnSupported = signal( readonly webAuthnSupported = signal(
typeof window !== 'undefined' && typeof window !== 'undefined' &&
typeof window.PublicKeyCredential !== 'undefined' && typeof window.PublicKeyCredential !== 'undefined' &&
@@ -195,6 +204,7 @@ export class ChatSessionService {
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 localCallPeerId = signal<string | null>(null); private readonly localCallPeerId = signal<string | null>(null);
private imageMagickInitializationPromise: Promise<void> | 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;
@@ -204,6 +214,8 @@ export class ChatSessionService {
private lastWebSocketPongAt = 0; private lastWebSocketPongAt = 0;
private ringtoneAudio: HTMLAudioElement | null = null; private ringtoneAudio: HTMLAudioElement | null = null;
private ringtoneAudioUrl: string = this.resolveIncomingCallRingtoneUrl(); private ringtoneAudioUrl: string = this.resolveIncomingCallRingtoneUrl();
private notificationAudio: HTMLAudioElement | null = null;
private notificationAudioUrl: string = this.resolveIncomingMessageSoundUrl();
private ringtonePreloadPromise: Promise<void> | null = null; private ringtonePreloadPromise: Promise<void> | null = null;
private messageEncryptionKey: CryptoKey | null = null; private messageEncryptionKey: CryptoKey | null = null;
private messageDatabasePromise: Promise<IDBDatabase | null> | null = null; private messageDatabasePromise: Promise<IDBDatabase | null> | 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 { selectPeer(peerId: string): void {
this.activePeerId.set(peerId); this.activePeerId.set(peerId);
this.clearUnreadPeer(peerId); this.clearUnreadPeer(peerId);
@@ -595,6 +612,21 @@ export class ChatSessionService {
} }
async sendFile(peerId: string, file: File, attachmentKind: 'file' | 'voice' = 'file'): Promise<void> { async sendFile(peerId: string, file: File, attachmentKind: 'file' | 'voice' = 'file'): Promise<void> {
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({ this.pushMessage({
id: crypto.randomUUID(), id: crypto.randomUUID(),
peerId, peerId,
@@ -605,9 +637,11 @@ export class ChatSessionService {
deliveryState: 'pending', deliveryState: 'pending',
fileName: file.name, fileName: file.name,
fileSize: file.size, fileSize: file.size,
fileMimeType: file.type || 'application/octet-stream', fileMimeType: resolvedMimeType,
downloadUrl: URL.createObjectURL(file), downloadUrl: URL.createObjectURL(file),
}, file); previewMimeType,
previewDownloadUrl,
}, file, previewBlob);
if (!this.canAttemptImmediatePeerDelivery(peerId)) { if (!this.canAttemptImmediatePeerDelivery(peerId)) {
this.clearOutgoingTyping(peerId); this.clearOutgoingTyping(peerId);
@@ -1972,8 +2006,10 @@ export class ChatSessionService {
let previewMimeType: string | undefined; let previewMimeType: string | undefined;
let previewDownloadUrl: string | undefined; let previewDownloadUrl: string | undefined;
if (transfer.kind === 'file' && this.isPreviewableDocumentFile(transfer.name, transfer.mimeType)) { if (transfer.kind === 'file') {
const imagePreview = await this.generateDocumentPreviewImage(transfer.name, blob); const imagePreview = this.isPreviewableDocumentFile(transfer.name, transfer.mimeType)
? await this.generateDocumentPreviewImage(transfer.name, blob)
: await this.generateDisplayableImagePreview(blob, transfer.mimeType);
if (imagePreview) { if (imagePreview) {
previewBlob = imagePreview.blob; previewBlob = imagePreview.blob;
@@ -2344,6 +2380,10 @@ export class ChatSessionService {
this.markPeerUnread(entry.peerId); this.markPeerUnread(entry.peerId);
} }
if (entry.direction === 'incoming' && entry.kind !== 'system') {
this.playIncomingMessageSound();
}
if (entry.kind !== 'system') { if (entry.kind !== 'system') {
void this.persistMessage(entry, fileBlob, previewBlob); void this.persistMessage(entry, fileBlob, previewBlob);
} }
@@ -3323,6 +3363,32 @@ export class ChatSessionService {
).toString(); ).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<string | null> { private async fetchPreloadedRingtoneUrl(): Promise<string | null> {
for (const ringtoneUrl of this.incomingCallRingtoneCandidateUrls()) { for (const ringtoneUrl of this.incomingCallRingtoneCandidateUrls()) {
try { try {
@@ -3491,6 +3557,40 @@ export class ChatSessionService {
return new Blob([bytes], { type: mimeType }); return new Blob([bytes], { type: mimeType });
} }
private async ensureImageMagickInitialized(): Promise<void> {
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( private async generateDocumentPreviewImage(
fileName: string, fileName: string,
fileBlob: Blob, fileBlob: Blob,
@@ -3547,11 +3647,23 @@ export class ChatSessionService {
private isPdfFile(fileName?: string, mimeType?: string): boolean { private isPdfFile(fileName?: string, mimeType?: string): boolean {
const normalizedName = fileName?.trim().toLowerCase() ?? ''; const normalizedName = fileName?.trim().toLowerCase() ?? '';
const normalizedMimeType = mimeType?.trim().toLowerCase() ?? ''; const normalizedMimeType = this.normalizeMimeType(mimeType);
return normalizedMimeType === 'application/pdf' || normalizedName.endsWith('.pdf'); 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 { 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

@@ -188,6 +188,27 @@
<div class="alert alert-success mb-4">{{ session.notice() }}</div> <div class="alert alert-success mb-4">{{ session.notice() }}</div>
} }
<section class="access-key-panel mb-4">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<h3 class="h5 mb-1">Notifications</h3>
<p class="small text-secondary mb-0">Play a sound when any incoming message or file arrives.</p>
</div>
<div class="form-check form-switch m-0">
<input
id="incomingMessageSoundEnabled"
class="form-check-input"
type="checkbox"
[ngModel]="session.incomingMessageSoundEnabled()"
(ngModelChange)="setIncomingMessageSound($event)"
/>
<label class="form-check-label small" for="incomingMessageSoundEnabled">
{{ session.incomingMessageSoundEnabled() ? 'On' : 'Off' }}
</label>
</div>
</div>
</section>
<section class="access-key-panel user-search-panel mb-4"> <section class="access-key-panel user-search-panel mb-4">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3"> <div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div> <div>

View File

@@ -198,4 +198,8 @@ export class HomePageComponent {
cycleTheme(): void { cycleTheme(): void {
this.theme.cycleMode(); this.theme.cycleMode();
} }
setIncomingMessageSound(enabled: boolean): void {
this.session.setIncomingMessageSoundEnabled(enabled);
}
} }