|
|
|
|
@@ -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';
|
|
|
|
|
|
|
|
|
|
|