notification sound
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
7
client/package-lock.json
generated
7
client/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
BIN
client/public/notif.mp3
Normal file
Binary file not shown.
@@ -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'"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user