notification sounds
This commit is contained in:
+17
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user