From 5ceeb01319d041e06648fa1e283c092478466439 Mon Sep 17 00:00:00 2001 From: Adam Lamers Date: Sat, 11 Apr 2026 18:50:57 -0400 Subject: [PATCH] notification sounds --- src/App.css | 17 +++ src/chat/ChatContainer.svelte | 16 +++ .../settings/CustomizationSettings.svelte | 62 ++++++++ src/chat/services/chat.svelte.ts | 134 +++++++++++++++++- src/chat/services/messaging.svelte.ts | 62 ++++++-- src/chat/services/sound.svelte.ts | 35 +++++ 6 files changed, 314 insertions(+), 12 deletions(-) diff --git a/src/App.css b/src/App.css index 05cdec3..5294955 100644 --- a/src/App.css +++ b/src/App.css @@ -601,6 +601,23 @@ body { z-index: 1; } +.notification-toggle { + padding: 4px; + margin-left: 4px; + color: var(--interactive-normal); + opacity: 0.6; + font-size: 0.9rem !important; +} + +.notification-toggle:hover { + opacity: 1; +} + +.notification-toggle.subscribed { + color: var(--status-positive); + opacity: 1; +} + .chat-header { height: 48px; padding: 0 16px; diff --git a/src/chat/ChatContainer.svelte b/src/chat/ChatContainer.svelte index 2b4eb73..8b52f3b 100644 --- a/src/chat/ChatContainer.svelte +++ b/src/chat/ChatContainer.svelte @@ -148,6 +148,15 @@

{chat.activeChannel?.name || "Select a channel"}

+ {#if chat.activeChannelId !== undefined} + + {/if} {:else if chat.activeChannelId} {@const dm = chat.activeDms.find(d => d.channelId === chat.activeChannelId)} {#if dm} @@ -157,6 +166,13 @@ {#if recipient}

{recipient.name || "Unknown User"}

+ {#if chat.getRecipientPublicKey(otherIdentity)}
diff --git a/src/chat/components/settings/CustomizationSettings.svelte b/src/chat/components/settings/CustomizationSettings.svelte index e4e315d..e5cf1c4 100644 --- a/src/chat/components/settings/CustomizationSettings.svelte +++ b/src/chat/components/settings/CustomizationSettings.svelte @@ -62,6 +62,14 @@ } } } + + const notificationPacks = [ + { id: "classic", name: "Classic Ping (Default)" }, + { id: "zep", name: "Zep Signature" }, + { id: "electronic", name: "Electronic Chime" }, + { id: "organic", name: "Organic Marimba" }, + { id: "alert", name: "Urgent Alert" }, + ];
@@ -97,6 +105,30 @@
+
+
+

Notifications

+

Select the sound that plays when you receive a message in an unmuted channel.

+
+ +
+
+ + +

Changing this will play a preview of the sound.

+
+
+
+

Custom Emojis

@@ -265,6 +297,36 @@ font-size: 1.1rem; } + .notification-settings-box { + background-color: var(--background-secondary); + padding: 20px; + border-radius: 8px; + border: 1px solid var(--background-modifier-accent); + } + + .settings-select { + width: 100%; + padding: 10px; + background-color: var(--background-tertiary); + color: var(--text-normal); + border: 1px solid var(--background-modifier-accent); + border-radius: 4px; + font-size: 0.95rem; + outline: none; + cursor: pointer; + transition: border-color 0.2s; + } + + .settings-select:hover { + border-color: var(--brand); + } + + .help-text { + margin-top: 8px; + font-size: 0.75rem; + color: var(--text-muted); + } + .emoji-management-container { background-color: var(--background-secondary); padding: 16px; diff --git a/src/chat/services/chat.svelte.ts b/src/chat/services/chat.svelte.ts index 9f786d0..bf28d5a 100644 --- a/src/chat/services/chat.svelte.ts +++ b/src/chat/services/chat.svelte.ts @@ -1,5 +1,5 @@ import { Identity } from "spacetimedb"; -import { SvelteMap } from "svelte/reactivity"; +import { SvelteMap, SvelteSet } from "svelte/reactivity"; import * as Types from "../../module_bindings/types"; import { reducers } from "../../module_bindings"; import { getUsername, formatTime } from "../utils"; @@ -13,6 +13,7 @@ import { AccountService } from "./account.svelte"; import { ServerManagementService } from "./server-management.svelte"; import { DirectMessagingService } from "./direct-messaging.svelte"; import { EncryptionService } from "./encryption.svelte"; +import { sounds } from "./sound.svelte"; export class ChatService { #db: DatabaseService; @@ -28,6 +29,9 @@ export class ChatService { identity = $state(null); #blobUrls = new Map(); + subscribedChannels = $state(new SvelteSet()); + mutedDms = $state(new SvelteSet()); + notificationSoundPack = $state("classic"); // These maps are for reactive UI access #avatarUrls = new SvelteMap(); @@ -49,6 +53,35 @@ export class ChatService { this.#dm = new DirectMessagingService(this.#db, this.#nav, () => this.identity); this.#encryption = new EncryptionService(this.#db, () => this.identity); + // Load notification preferences from localStorage + $effect(() => { + const myId = this.identity?.toHexString(); + if (!myId) return; + + const storedSubs = localStorage.getItem(`zep_subscribed_channels_${myId}`); + if (storedSubs) { + try { + this.subscribedChannels = new SvelteSet(JSON.parse(storedSubs)); + } catch (e) { + console.error("Failed to parse subscribed channels", e); + } + } + + const storedMutes = localStorage.getItem(`zep_muted_dms_${myId}`); + if (storedMutes) { + try { + this.mutedDms = new SvelteSet(JSON.parse(storedMutes)); + } catch (e) { + console.error("Failed to parse muted DMs", e); + } + } + + const storedPack = localStorage.getItem(`zep_notification_pack_${myId}`); + if (storedPack) { + this.notificationSoundPack = storedPack; + } + }); + this.#msg.onSendMessage = async (text, channelId) => { // Check if user has manually enabled encryption for this message/channel if (!this.#msg.encryptionOptIn.has(channelId.toString())) { @@ -75,6 +108,32 @@ export class ChatService { return { text, isEncrypted: false }; }; + this.#msg.onMessageReceived = ({ channelId, senderIdentity, text, isEncrypted }) => { + // 1. Play sound + this.playMessageSound(channelId, senderIdentity); + + // 2. System Notification (only if not from us and not muted) + if (!senderIdentity.isEqual(this.identity || Identity.zero()) && !this.isChannelMuted(channelId)) { + if ("Notification" in window && Notification.permission === "granted") { + const isDm = this.#db.directMessages.some(d => d.channelId === channelId); + const isMention = text.includes(`<@${this.identity?.toHexString()}>`); + + if (isDm || isMention) { + const senderName = this.getUsername(senderIdentity); + const title = isDm ? `New message from ${senderName}` : `Mentioned by ${senderName}`; + const body = isEncrypted ? "Encrypted message" : text; + + new Notification(title, { + body, + icon: "/zep.svg" + }); + } + } else if ("Notification" in window && Notification.permission === "default") { + Notification.requestPermission(); + } + } + }; + // Session-only image processing: creates Blob URLs directly from Database data. // This ditched the persistent IndexedDB cache to prevent stale data between reloads. $effect(() => { @@ -343,7 +402,80 @@ export class ChatService { get userStates() { return this.#db.userStates; } + + isChannelNotificationsEnabled = (channelId: bigint) => { + const idStr = channelId.toString(); + const isDm = this.#db.directMessages.some(d => d.channelId === channelId); + + if (isDm) { + // DMs: On by default, check if muted + return !this.mutedDms.has(idStr); + } else { + // Server Channels: Off by default, check if explicitly subscribed + return this.subscribedChannels.has(idStr); + } + }; + + toggleChannelNotifications = (channelId: bigint) => { + // Resume audio context on user gesture + sounds.getAudioContext(); + + const idStr = channelId.toString(); + const isDm = this.#db.directMessages.some(d => d.channelId === channelId); + const myId = this.identity?.toHexString(); + + if (isDm) { + if (this.mutedDms.has(idStr)) { + this.mutedDms.delete(idStr); + } else { + this.mutedDms.add(idStr); + } + if (myId) { + localStorage.setItem(`zep_muted_dms_${myId}`, JSON.stringify(Array.from(this.mutedDms))); + } + } else { + if (this.subscribedChannels.has(idStr)) { + this.subscribedChannels.delete(idStr); + } else { + this.subscribedChannels.add(idStr); + } + if (myId) { + localStorage.setItem(`zep_subscribed_channels_${myId}`, JSON.stringify(Array.from(this.subscribedChannels))); + } + } + }; + + playMessageSound = (channelId: bigint, senderIdentity: Identity) => { + // 1. Don't play if it's our own message + const myId = this.identity || Identity.zero(); + if (senderIdentity.isEqual(myId)) { + console.log(`[ChatService] Sound suppressed: message is from self.`); + return; + } + + // 2. Don't play if notifications are disabled + if (!this.isChannelNotificationsEnabled(channelId)) { + console.log(`[ChatService] Suppression: notifications disabled for channel ${channelId}.`); + return; + } + + // 3. Play the sound + console.log(`[ChatService] Triggering notification sound (${this.notificationSoundPack}) for channel ${channelId}`); + sounds.playNotification(this.notificationSoundPack); + }; + + setNotificationSoundPack = (pack: string) => { + this.notificationSoundPack = pack; + const myId = this.identity?.toHexString(); + if (myId) { + localStorage.setItem(`zep_notification_pack_${myId}`, pack); + } + // Play a preview + sounds.playNotification(pack); + }; + get typingActivity() { + return this.#db.typingActivity; } get isUsersReady() { diff --git a/src/chat/services/messaging.svelte.ts b/src/chat/services/messaging.svelte.ts index cd0e278..cb472c5 100644 --- a/src/chat/services/messaging.svelte.ts +++ b/src/chat/services/messaging.svelte.ts @@ -31,6 +31,11 @@ export class MessagingService { */ onSendMessage?: (text: string, channelId: bigint) => Promise<{ text: string, isEncrypted: boolean }>; + /** + * Hook triggered when a new message arrives from the server. + */ + onMessageReceived?: (params: { channelId: bigint, senderIdentity: Identity, id: bigint, text: string, isEncrypted: boolean }) => void; + // Internal reactive state from SpacetimeDB #mySubscriptions = $state([]); @@ -93,7 +98,38 @@ export class MessagingService { let scrollbackMessages: readonly Types.VisibleMessageRow[] = []; // Incremental update logic for visible messages + const seenMessageIds = new Set(); + let isFirstEmit = true; + visibleMessagesStore.subscribe((v) => { + if (v.length > 0) { + console.log(`[MessagingService] Received batch of ${v.length} messages. First emit: ${isFirstEmit}`); + } + + // If this is a new batch, identify truly new messages + for (const msg of v) { + if (!seenMessageIds.has(msg.id)) { + // Only trigger notifications if this isn't the very first time we see data + // and the service is generally ready. + if (!isFirstEmit && this.onMessageReceived) { + console.log(`[MessagingService] Dispatching notification for message ${msg.id}`); + this.onMessageReceived({ + channelId: msg.channelId, + senderIdentity: msg.sender, + id: msg.id, + text: msg.text, + isEncrypted: msg.isEncrypted + }); + } + seenMessageIds.add(msg.id); + } + } + + if (v.length > 0 && isFirstEmit) { + console.log(`[MessagingService] Initial message load complete. Disabling first emit suppression.`); + isFirstEmit = false; + } + recentMessages = v; this.#updateBuckets([...recentMessages, ...scrollbackMessages]); @@ -104,20 +140,24 @@ export class MessagingService { }); visibleMessagesReadyStore.subscribe((v) => { - if (v) { - this.isGlobalSyncDone = true; - const cid = untrack(() => this.#nav.activeChannelId); - if (cid) { - this.#readyChannels.add(cid); - } - // Also mark all channels currently in our recentMessages as ready - for (const m of recentMessages) { - this.#readyChannels.add(m.channelId); - } - } + console.log(`[MessagingService] Global sync status: ${v}`); + this.isGlobalSyncDone = v; + if (v) { + const cid = untrack(() => this.#nav.activeChannelId); + if (cid) { + this.#readyChannels.add(cid); + } + // Also mark all channels currently in our recentMessages as ready + for (const m of recentMessages) { + this.#readyChannels.add(m.channelId); + } + } }); visibleScrollbackStore.subscribe((v) => { + // Add scrollback messages to seen set so they NEVER trigger notifications + for (const msg of v) seenMessageIds.add(msg.id); + scrollbackMessages = v; this.#updateBuckets([...recentMessages, ...scrollbackMessages]); }); diff --git a/src/chat/services/sound.svelte.ts b/src/chat/services/sound.svelte.ts index 8e37ac5..0944a4d 100644 --- a/src/chat/services/sound.svelte.ts +++ b/src/chat/services/sound.svelte.ts @@ -1,5 +1,6 @@ export class SoundService { #audioContext: AudioContext | null = null; + #isPlayingNotification = false; getAudioContext() { if (!this.#audioContext) { @@ -83,6 +84,40 @@ export class SoundService { playWatcherLeft() { this.playTone([783.99, 659.25, 523.25], 0.06, "sine", 0.08); // G5, E5, C5 } + + playNotification(pack: string = "classic") { + if (this.#isPlayingNotification) return; + this.#isPlayingNotification = true; + + // Reset flag after a short delay (approx max tone length) + setTimeout(() => { + this.#isPlayingNotification = false; + }, 500); + + switch (pack) { + case "classic": + // A simple, clean ping + this.playTone([880], 0.15, "sine", 0.1); + break; + case "electronic": + // Digital, sci-fi chime + this.playTone([1318.51, 1567.98, 1046.5], 0.05, "square", 0.05); + break; + case "organic": + // Soft, percussive marimba feel + this.playTone([523.25, 659.25], 0.1, "triangle", 0.15); + break; + case "alert": + // Brighter, more urgent + this.playTone([1046.5, 1046.5], 0.08, "sine", 0.1); + break; + case "zep": + default: + // The signature Zep two-tone + this.playTone([587.33, 783.99], 0.07, "sine", 0.1); + break; + } + } } export const sounds = new SoundService();