notification sound
This commit is contained in:
@@ -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",
|
||||
|
||||
7
client/package-lock.json
generated
7
client/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
client/public/notif.mp3
Normal file
BIN
client/public/notif.mp3
Normal file
Binary file not shown.
@@ -448,7 +448,7 @@
|
||||
@if (isImageEntry(entry)) {
|
||||
<img
|
||||
class="bubble-image"
|
||||
[src]="entry.downloadUrl"
|
||||
[src]="imageDisplayUrl(entry)"
|
||||
[alt]="entry.fileName || 'Shared image'"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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<UserProfile | null>(this.readUserStorage());
|
||||
@@ -149,6 +155,9 @@ export class ChatSessionService {
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly notice = signal<string | null>(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<Array<{ peerId: string; stream: MediaStream }>>([]);
|
||||
private readonly remoteAudioStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]);
|
||||
private readonly localCallPeerId = signal<string | null>(null);
|
||||
private imageMagickInitializationPromise: Promise<void> | 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<void> | null = null;
|
||||
private messageEncryptionKey: CryptoKey | 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 {
|
||||
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<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({
|
||||
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<string | null> {
|
||||
for (const ringtoneUrl of this.incomingCallRingtoneCandidateUrls()) {
|
||||
try {
|
||||
@@ -3491,6 +3557,40 @@ export class ChatSessionService {
|
||||
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(
|
||||
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';
|
||||
|
||||
|
||||
@@ -188,6 +188,27 @@
|
||||
<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">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||
<div>
|
||||
|
||||
@@ -198,4 +198,8 @@ export class HomePageComponent {
|
||||
cycleTheme(): void {
|
||||
this.theme.cycleMode();
|
||||
}
|
||||
|
||||
setIncomingMessageSound(enabled: boolean): void {
|
||||
this.session.setIncomingMessageSoundEnabled(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user