notification sounds

This commit is contained in:
2026-04-11 18:50:57 -04:00
parent 782edffcaa
commit 5ceeb01319
6 changed files with 314 additions and 12 deletions
+17
View File
@@ -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;
+16
View File
@@ -148,6 +148,15 @@
<i class="fas {chat.isActiveChannelVoice ? 'fa-volume-up' : 'fa-hashtag'}"></i>
</span>
<h2 style="margin: 0; font-size: 1rem;">{chat.activeChannel?.name || "Select a channel"}</h2>
{#if chat.activeChannelId !== undefined}
<button
class="icon-btn notification-toggle {chat.isChannelNotificationsEnabled(chat.activeChannelId) ? 'subscribed' : ''}"
onclick={() => chat.toggleChannelNotifications(chat.activeChannelId!)}
title={chat.isChannelNotificationsEnabled(chat.activeChannelId) ? "Unsubscribe from Notifications" : "Subscribe to Notifications"}
>
<i class="fas {chat.isChannelNotificationsEnabled(chat.activeChannelId) ? 'fa-bell' : 'fa-bell-slash'}"></i>
</button>
{/if}
{:else if chat.activeChannelId}
{@const dm = chat.activeDms.find(d => d.channelId === chat.activeChannelId)}
{#if dm}
@@ -157,6 +166,13 @@
{#if recipient}
<Avatar user={recipient} size="tiny" />
<h2 style="margin: 0; font-size: 1rem;">{recipient.name || "Unknown User"}</h2>
<button
class="icon-btn notification-toggle {chat.isChannelNotificationsEnabled(chat.activeChannelId!) ? 'subscribed' : ''}"
onclick={() => chat.toggleChannelNotifications(chat.activeChannelId!)}
title={chat.isChannelNotificationsEnabled(chat.activeChannelId!) ? "Unsubscribe from Notifications" : "Subscribe to Notifications"}
>
<i class="fas {chat.isChannelNotificationsEnabled(chat.activeChannelId!) ? 'fa-bell' : 'fa-bell-slash'}"></i>
</button>
{#if chat.getRecipientPublicKey(otherIdentity)}
<div class="encryption-indicator" title="End-to-end encrypted">
<i class="fas fa-lock"></i>
@@ -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" },
];
</script>
<div class="section">
@@ -97,6 +105,30 @@
</div>
</div>
<div class="section" style="margin-top: 32px;">
<div class="section-header-description">
<h3>Notifications</h3>
<p>Select the sound that plays when you receive a message in an unmuted channel.</p>
</div>
<div class="notification-settings-box">
<div class="form-group">
<label for="sound-pack">Notification Sound</label>
<select
id="sound-pack"
class="settings-select"
value={chat.notificationSoundPack}
onchange={(e) => chat.setNotificationSoundPack((e.target as HTMLSelectElement).value)}
>
{#each notificationPacks as pack}
<option value={pack.id}>{pack.name}</option>
{/each}
</select>
<p class="help-text">Changing this will play a preview of the sound.</p>
</div>
</div>
</div>
<div class="section" style="margin-top: 32px;">
<div class="section-header-description">
<h3>Custom Emojis</h3>
@@ -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;
+133 -1
View File
@@ -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<Identity | null>(null);
#blobUrls = new Map<string, string>();
subscribedChannels = $state(new SvelteSet<string>());
mutedDms = $state(new SvelteSet<string>());
notificationSoundPack = $state("classic");
// These maps are for reactive UI access
#avatarUrls = new SvelteMap<string, string>();
@@ -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() {
+51 -11
View File
@@ -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<readonly Types.MyChannelSubscriptionRow[]>([]);
@@ -93,7 +98,38 @@ export class MessagingService {
let scrollbackMessages: readonly Types.VisibleMessageRow[] = [];
// Incremental update logic for visible messages
const seenMessageIds = new Set<bigint>();
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]);
});
+35
View File
@@ -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();