atagen

malice

public
Star0
Watch0

Commit c8d898e6eb59

tarball

atagen <boss@atagen.co>2026-10-27 03:55 UTC

8 files changed+262-0
web: play a sound when a message arrives

The mode (all, mentions and DMs, off) and an uploaded sound live in dev.malice.message_sound account data so they follow the account. Custom sounds load by mxc through the attachment media path, since the avatar route only serves images. The bundled chime is a placeholder.
Binary file: web/public/sounds/message.wavbinary

Binary file not shown.

Modified file: web/src/App.svelte+4 additions
@@ -106,6 +106,7 @@
106106 import { isRoomMuted, mutingSpace, type MutedSpaces } from './lib/mute-model';
107107 import { paneBadges } from './lib/pane-badges';
108108 import { loadMutedSpaces, mutedSpaces } from './lib/muted-spaces';
109+ import { loadMessageSound } from './lib/message-sound';
109110 import {
110111 linkPreviews,
111112 loadUrlPreviewSettings,
@@ -204,6 +205,7 @@
204205 void loadPackPreferences();
205206 void loadMutedSpaces();
206207 void loadUrlPreviewSettings();
208+ void loadMessageSound();
207209 }
208210 wasOnline = online;
209211 }
@@ -378,6 +380,7 @@
378380 void loadPackPreferences();
379381 void loadMutedSpaces();
380382 void loadUrlPreviewSettings();
383+ void loadMessageSound();
381384 } else {
382385 softLogout.set(session.soft_logout ?? null);
383386 }
@@ -438,6 +441,7 @@
438441 void loadPackPreferences();
439442 void loadMutedSpaces();
440443 void loadUrlPreviewSettings();
444+ void loadMessageSound();
441445 }
442446
443447 async function doLogout(): Promise<void> {
Modified file: web/src/lib/NotificationSettings.svelte+69 additions
@@ -1,7 +1,39 @@
11 <script lang="ts">
22 import { disableNotifications, enableNotifications, notificationPermission, notificationsEnabled } from './notifications';
33
4+ import type { MessageSoundMode } from './message-sound-model';
5+ import { messageSound, playMessageSound, saveMessageSound, uploadMessageSound } from './message-sound';
6+
47 let busy = false;
8+ let soundBusy = false;
9+ let soundError = '';
10+ let fileInput: HTMLInputElement;
11+
12+ const SOUND_MODES: { id: MessageSoundMode; label: string }[] = [
13+ { id: 'all', label: 'All messages' },
14+ { id: 'mentions_dms', label: 'Mentions and DMs' },
15+ { id: 'off', label: 'Off' }
16+ ];
17+
18+ async function guarded(task: () => Promise<void>): Promise<void> {
19+ if (soundBusy) return;
20+ soundBusy = true;
21+ soundError = '';
22+ try {
23+ await task();
24+ } catch (cause) {
25+ soundError = cause instanceof Error ? cause.message : 'Could not save the sound settings.';
26+ } finally {
27+ soundBusy = false;
28+ }
29+ }
30+
31+ async function upload(event: Event): Promise<void> {
32+ const input = event.currentTarget as HTMLInputElement;
33+ const file = input.files?.[0];
34+ input.value = '';
35+ if (file) await guarded(() => uploadMessageSound(file));
36+ }
537
638 $: denied = $notificationPermission === 'denied';
739 $: unsupported = $notificationPermission === 'unsupported';
@@ -27,6 +59,36 @@
2759 <button type="button" class="ghost-btn" disabled={busy || denied || unsupported} aria-pressed={on} on:click={() => void toggle()}>
2860 {on ? 'Turn off' : 'Turn on'}
2961 </button>
62+ <div class="sound-modes" role="radiogroup" aria-label="Message sound">
63+ {#each SOUND_MODES as option (option.id)}
64+ <button
65+ type="button"
66+ role="radio"
67+ aria-checked={$messageSound.mode === option.id}
68+ class:active={$messageSound.mode === option.id}
69+ disabled={soundBusy}
70+ on:click={() => void guarded(() => saveMessageSound({ ...$messageSound, mode: option.id }))}
71+ >
72+ {option.label}
73+ </button>
74+ {/each}
75+ </div>
76+ <div class="sound-actions">
77+ <button type="button" class="ghost-btn" on:click={() => void playMessageSound()}>Play</button>
78+ <button type="button" class="ghost-btn" disabled={soundBusy} on:click={() => fileInput.click()}>Upload sound</button>
79+ <button
80+ type="button"
81+ class="ghost-btn"
82+ disabled={soundBusy || $messageSound.sound === null}
83+ on:click={() => void guarded(() => saveMessageSound({ ...$messageSound, sound: null }))}
84+ >
85+ Reset
86+ </button>
87+ <input bind:this={fileInput} type="file" accept="audio/*" hidden on:change={(e) => void upload(e)} />
88+ </div>
89+ {#if soundError}
90+ <p class="sound-error" role="alert">{soundError}</p>
91+ {/if}
3092 </section>
3193
3294 <style>
@@ -35,4 +97,11 @@
3597 .sec-h { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 0; }
3698 .state { font-size: 12px; color: var(--muted); }
3799 .state.ok { color: var(--accent); }
100+ .sound-modes { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
101+ .sound-modes button { border: 1px solid var(--border); background: transparent; color: var(--muted); border-radius: var(--r-sm); padding: 3px 10px; font: inherit; font-size: 12.5px; cursor: pointer; transition: color var(--pop), border-color var(--pop), background var(--pop); }
102+ .sound-modes button:hover:not(:disabled) { color: var(--text); }
103+ .sound-modes button.active { color: var(--text); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
104+ .sound-modes button:disabled { cursor: wait; }
105+ .sound-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
106+ .sound-error { margin: 8px 0 0; color: var(--danger); font-size: 13px; }
38107 </style>
Added file: web/src/lib/message-sound-model.ts+32 additions
@@ -0,0 +1,32 @@
1+import type { RoomSummary } from './types';
2+
3+export type MessageSoundMode = 'all' | 'mentions_dms' | 'off';
4+
5+export interface MessageSoundSettings {
6+ mode: MessageSoundMode;
7+ sound: string | null;
8+}
9+
10+export const DEFAULT_MESSAGE_SOUND: MessageSoundSettings = { mode: 'mentions_dms', sound: null };
11+export const SOUND_MAX_BYTES = 1024 * 1024;
12+export const SOUND_THROTTLE_MS = 1500;
13+
14+export function parseMessageSound(raw: unknown): MessageSoundSettings {
15+ if (typeof raw !== 'object' || raw === null) return DEFAULT_MESSAGE_SOUND;
16+ const { mode, sound } = raw as Record<string, unknown>;
17+ return {
18+ mode: mode === 'all' || mode === 'mentions_dms' || mode === 'off' ? mode : DEFAULT_MESSAGE_SOUND.mode,
19+ sound: typeof sound === 'string' && sound.startsWith('mxc://') ? sound : null
20+ };
21+}
22+
23+export function shouldPlaySound(
24+ mode: MessageSoundMode,
25+ room: Pick<RoomSummary, 'direct'>,
26+ arrival: { mention: boolean },
27+ lastPlayedAt: number,
28+ now: number
29+): boolean {
30+ if (mode === 'off' || now - lastPlayedAt < SOUND_THROTTLE_MS) return false;
31+ return mode === 'all' || arrival.mention || room.direct;
32+}
Added file: web/src/lib/message-sound.ts+126 additions
@@ -0,0 +1,126 @@
1+import { get, writable } from 'svelte/store';
2+import { getAccountData, messageMediaUrl, setAccountData, uploadMedia } from './api';
3+import { effectiveRooms } from './mute';
4+import { arrivalIn, isRoomWatched } from './notification-model';
5+import {
6+ DEFAULT_MESSAGE_SOUND,
7+ SOUND_MAX_BYTES,
8+ parseMessageSound,
9+ shouldPlaySound,
10+ type MessageSoundSettings
11+} from './message-sound-model';
12+import { selectedRoomId } from './store';
13+import type { RoomSummary } from './types';
14+
15+const EVENT_TYPE = 'dev.malice.message_sound';
16+const DEFAULT_URL = '/sounds/message.wav';
17+const CACHE_NAME = 'malice-sounds';
18+
19+export const messageSound = writable<MessageSoundSettings>(DEFAULT_MESSAGE_SOUND);
20+
21+const resolved = new Map<string, Promise<string>>();
22+let lastPlayedAt = 0;
23+
24+export async function loadMessageSound(): Promise<void> {
25+ try {
26+ messageSound.set(parseMessageSound(await getAccountData(EVENT_TYPE)));
27+ } catch {
28+ messageSound.set(DEFAULT_MESSAGE_SOUND);
29+ }
30+}
31+
32+export async function saveMessageSound(settings: MessageSoundSettings): Promise<void> {
33+ await setAccountData(EVENT_TYPE, { mode: settings.mode, sound: settings.sound });
34+ messageSound.set(settings);
35+}
36+
37+export function resetMessageSound(): void {
38+ messageSound.set(DEFAULT_MESSAGE_SOUND);
39+ for (const url of resolved.values()) void url.then((u) => URL.revokeObjectURL(u), () => undefined);
40+ resolved.clear();
41+}
42+
43+const cacheKey = (mxc: string) => `/__sounds/${encodeURIComponent(mxc)}`;
44+
45+async function remember(mxc: string, blob: Blob): Promise<void> {
46+ try {
47+ await (await caches.open(CACHE_NAME)).put(cacheKey(mxc), new Response(blob, { headers: { 'content-type': blob.type } }));
48+ } catch {}
49+}
50+
51+async function fetchSound(mxc: string): Promise<Blob> {
52+ try {
53+ const hit = await (await caches.open(CACHE_NAME)).match(cacheKey(mxc));
54+ if (hit) return await hit.blob();
55+ } catch {}
56+ const res = await fetch(messageMediaUrl(mxc, 'Full'), { credentials: 'same-origin' });
57+ if (!res.ok) throw new Error('Sound unavailable');
58+ const blob = await res.blob();
59+ await remember(mxc, blob);
60+ return blob;
61+}
62+
63+function resolve(mxc: string | null): Promise<string> {
64+ if (!mxc) return Promise.resolve(DEFAULT_URL);
65+ let url = resolved.get(mxc);
66+ if (!url) {
67+ url = fetchSound(mxc).then(
68+ (blob) => URL.createObjectURL(blob),
69+ () => DEFAULT_URL
70+ );
71+ resolved.set(mxc, url);
72+ }
73+ return url;
74+}
75+
76+export async function playMessageSound(): Promise<void> {
77+ try {
78+ const audio = new Audio(await resolve(get(messageSound).sound));
79+ await audio.play();
80+ } catch {}
81+}
82+
83+function decodes(file: File): Promise<boolean> {
84+ return new Promise((done) => {
85+ const url = URL.createObjectURL(file);
86+ const audio = new Audio();
87+ const finish = (ok: boolean) => {
88+ clearTimeout(timer);
89+ URL.revokeObjectURL(url);
90+ done(ok);
91+ };
92+ const timer = setTimeout(() => finish(false), 5000);
93+ audio.oncanplaythrough = () => finish(true);
94+ audio.onerror = () => finish(false);
95+ audio.preload = 'auto';
96+ audio.src = url;
97+ });
98+}
99+
100+export async function uploadMessageSound(file: File): Promise<void> {
101+ if (!file.type.startsWith('audio/')) throw new Error('Choose an audio file.');
102+ if (file.size > SOUND_MAX_BYTES) throw new Error('Sounds must be smaller than 1 MB.');
103+ if (!(await decodes(file))) throw new Error('That audio file could not be played.');
104+ const { mxc } = await uploadMedia(file);
105+ await remember(mxc, file);
106+ await saveMessageSound({ ...get(messageSound), sound: mxc });
107+}
108+
109+export function startMessageSound(): () => void {
110+ let previous = new Map<string, RoomSummary>();
111+ const unsubscribe = effectiveRooms.subscribe((list) => {
112+ const prior = previous;
113+ previous = new Map(list.map((room) => [room.room_id, room]));
114+ const now = Date.now();
115+ const mode = get(messageSound).mode;
116+ for (const room of list) {
117+ const arrival = arrivalIn(prior.get(room.room_id), room, now);
118+ if (!arrival) continue;
119+ if (isRoomWatched(room.room_id, get(selectedRoomId), document.visibilityState === 'visible', document.hasFocus())) continue;
120+ if (!shouldPlaySound(mode, room, arrival, lastPlayedAt, now)) continue;
121+ lastPlayedAt = now;
122+ void playMessageSound();
123+ }
124+ });
125+ return unsubscribe;
126+}
Modified file: web/src/lib/notifications.ts+3 additions
@@ -13,6 +13,7 @@ import {
1313 type NotificationPlan
1414 } from './notification-model';
1515 import { effectiveRooms } from './mute';
16+import { startMessageSound } from './message-sound';
1617 import { PUSH_CHANGED_MESSAGE } from './push-model';
1718 import { disablePush, enablePush, pushActive, syncPush } from './push';
1819 import { selectedRoomId, sessionUser, timelines } from './store';
@@ -179,6 +180,7 @@ export function startNotifications(openRoom: (roomId: string) => void): () => vo
179180 clearRead(list);
180181 };
181182
183+ const unsubscribeSound = startMessageSound();
182184 const unsubscribeRooms = effectiveRooms.subscribe(onRooms);
183185 const unsubscribeEnabled = notificationsEnabled.subscribe(() => onRooms(get(effectiveRooms)));
184186 const onAttention = () => {
@@ -202,6 +204,7 @@ export function startNotifications(openRoom: (roomId: string) => void): () => vo
202204 return () => {
203205 unsubscribeSession();
204206 unsubscribeRooms();
207+ unsubscribeSound();
205208 unsubscribeEnabled();
206209 unsubscribeSelected();
207210 for (const { timer } of waiting.values()) clearTimeout(timer);
Modified file: web/src/lib/store.ts+2 additions
@@ -10,6 +10,7 @@ import { newShareCache, parseShared, sameList } from './sharing';
1010 import type { ShareCache } from './sharing';
1111 import { loadKeywords, resetKeywords } from './keywords';
1212 import { resetLinkPreviews } from './link-previews';
13+import { resetMessageSound } from './message-sound';
1314 import { resetQuickReactions } from './quick-reactions';
1415 import { resetPackPreferences } from './pack-preferences';
1516 import { resetMutedSpaces } from './muted-spaces';
@@ -151,6 +152,7 @@ export function resetClientState(): void {
151152 resetPackPreferences();
152153 resetMutedSpaces();
153154 resetLinkPreviews();
155+ resetMessageSound();
154156 resetKeywords();
155157 onlineSince = 0;
156158 liveSince = {};
Added file: web/test/message-sound.test.ts+26 additions
@@ -0,0 +1,26 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { parseMessageSound, shouldPlaySound, SOUND_THROTTLE_MS } from '../src/lib/message-sound-model.ts';
4+
5+test('parse falls back to defaults', () => {
6+ assert.deepEqual(parseMessageSound(null), { mode: 'mentions_dms', sound: null });
7+ assert.deepEqual(parseMessageSound({ mode: 'bogus', sound: 'http://x' }), { mode: 'mentions_dms', sound: null });
8+ assert.deepEqual(parseMessageSound({ mode: 'all', sound: 'mxc://h/a' }), { mode: 'all', sound: 'mxc://h/a' });
9+});
10+
11+test('mode decides which arrivals play', () => {
12+ const room = { direct: false };
13+ const dm = { direct: true };
14+ const plain = { mention: false };
15+ assert.equal(shouldPlaySound('off', dm, { mention: true }, 0, 10_000), false);
16+ assert.equal(shouldPlaySound('all', room, plain, 0, 10_000), true);
17+ assert.equal(shouldPlaySound('mentions_dms', room, plain, 0, 10_000), false);
18+ assert.equal(shouldPlaySound('mentions_dms', room, { mention: true }, 0, 10_000), true);
19+ assert.equal(shouldPlaySound('mentions_dms', dm, plain, 0, 10_000), true);
20+});
21+
22+test('throttles repeated plays', () => {
23+ const room = { direct: true };
24+ assert.equal(shouldPlaySound('all', room, { mention: false }, 10_000, 10_000 + SOUND_THROTTLE_MS - 1), false);
25+ assert.equal(shouldPlaySound('all', room, { mention: false }, 10_000, 10_000 + SOUND_THROTTLE_MS), true);
26+});