Commit c8d898e6eb59
tarball8 files changed+262-0
Binary file not shown.
@@ -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> {@@ -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
+ <button65
+ 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
+ <button80
+ type="button"81
+ class="ghost-btn"82
+ disabled={soundBusy || $messageSound.sound === null}83
+ on:click={() => void guarded(() => saveMessageSound({ ...$messageSound, sound: null }))}84
+ >85
+ Reset86
+ </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>@@ -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 : null20
+ };21
+}22
+23
+export function shouldPlaySound(24
+ mode: MessageSoundMode,25
+ room: Pick<RoomSummary, 'direct'>,26
+ arrival: { mention: boolean },27
+ lastPlayedAt: number,28
+ now: number29
+): boolean {30
+ if (mode === 'off' || now - lastPlayedAt < SOUND_THROTTLE_MS) return false;31
+ return mode === 'all' || arrival.mention || room.direct;32
+}@@ -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 MessageSoundSettings11
+} 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_URL70
+ );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
+}@@ -13,6 +13,7 @@ import {
1313
type NotificationPlan1414
} 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);@@ -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 = {};@@ -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
+});