Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3eb9d018b | ||
|
|
8368c6e5c3 | ||
|
|
9a5218f9b8 |
6 changed files with 321 additions and 3 deletions
12
public/dings/README.md
Normal file
12
public/dings/README.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Dings
|
||||
|
||||
Pre-rendered earcons, served at `public/dings/` and read at runtime by `src/app/features/dings`.
|
||||
Aqua renders the clips; nothing here is synthesised in the browser.
|
||||
|
||||
dings/manifest.json
|
||||
dings/<mxid-localpart>/<motif>.ogg
|
||||
|
||||
`manifest.json` is `{ "neutral": { <motif>: "<path>" }, "senders": { <localpart>: { <motif>: "<path>" } } }`
|
||||
with motif one of `mention`, `dm`, `message`, `invite`; paths are relative to this folder, `.ogg` or `.mp3`.
|
||||
A sender's own clip wins, else the neutral clip, else silence. Without a `manifest.json` the client keeps
|
||||
its stock notification sounds; once one exists, a motif with no clip is silent.
|
||||
144
src/app/features/dings/dings.test.ts
Normal file
144
src/app/features/dings/dings.test.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DING_VOLUME, createDingPlayer, motifFor, parseManifest, pickDing } from './dings';
|
||||
|
||||
const manifest = parseManifest({
|
||||
neutral: { mention: 'neutral/mention.ogg', message: 'neutral/message.ogg' },
|
||||
senders: {
|
||||
eureka: { mention: 'eureka/mention.ogg', message: 'eureka/message.ogg', dm: 'eureka/dm.ogg' },
|
||||
nibu: { message: 'nibu/message.ogg' },
|
||||
},
|
||||
})!;
|
||||
|
||||
describe('dings manifest', () => {
|
||||
it('is read defensively: junk is no manifest, bad paths are dropped', () => {
|
||||
expect(parseManifest(undefined)).toBeUndefined();
|
||||
expect(parseManifest('x')).toBeUndefined();
|
||||
expect(parseManifest({ neutral: 3, senders: [] })).toBeUndefined();
|
||||
const m = parseManifest({
|
||||
neutral: { message: '../../etc/passwd.ogg', mention: 'https://evil.example/a.ogg' },
|
||||
senders: { void: { message: 'void/message.wav', dm: 'void/dm.ogg', mention: '/abs.ogg' } },
|
||||
})!;
|
||||
expect(m.neutral).toEqual({});
|
||||
expect(m.senders.void).toEqual({ dm: 'void/dm.ogg' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('manifest guards', () => {
|
||||
it('rejects a dot segment in the middle of a path', () => {
|
||||
const m = parseManifest({
|
||||
neutral: { message: 'a/../b.ogg', mention: 'a/b.ogg' },
|
||||
senders: {},
|
||||
})!;
|
||||
expect(m.neutral).toEqual({ mention: 'a/b.ogg' });
|
||||
});
|
||||
|
||||
it('is not fooled by an array of senders', () => {
|
||||
expect(parseManifest({ neutral: { message: 'a.ogg' }, senders: [] })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores motifs a clip set only inherits', () => {
|
||||
const inherited = Object.create({ message: 'x/message.ogg' });
|
||||
const m = parseManifest({ neutral: inherited, senders: {} })!;
|
||||
expect(m.neutral).toEqual({});
|
||||
});
|
||||
|
||||
it('gives object-prototype names no clip of their own', () => {
|
||||
const m = parseManifest({ neutral: { message: 'n/message.ogg' }, senders: {} })!;
|
||||
['__proto__', 'constructor', 'toString', 'hasOwnProperty'].forEach((who) => {
|
||||
expect(pickDing(m, who, 'message')?.path).toBe('n/message.ogg');
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the motif volume in the pick itself', () => {
|
||||
expect(pickDing(manifest, 'eureka', 'message')?.volume).toBe(DING_VOLUME.message);
|
||||
expect(pickDing(manifest, 'stranger', 'mention')?.volume).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('which ding plays', () => {
|
||||
it('uses the sender instrument for the motif', () => {
|
||||
expect(pickDing(manifest, 'eureka', 'mention')).toEqual({
|
||||
path: 'eureka/mention.ogg',
|
||||
volume: DING_VOLUME.mention,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the neutral ding for an unknown sender', () => {
|
||||
expect(pickDing(manifest, 'stranger', 'message')?.path).toBe('neutral/message.ogg');
|
||||
});
|
||||
|
||||
it('falls back to neutral when the sender has no clip for that motif', () => {
|
||||
expect(pickDing(manifest, 'nibu', 'mention')?.path).toBe('neutral/mention.ogg');
|
||||
});
|
||||
|
||||
it('is silent when neither the sender nor neutral has a clip', () => {
|
||||
expect(pickDing(manifest, 'eureka', 'invite')).toBeUndefined();
|
||||
expect(pickDing(manifest, 'stranger', 'dm')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is loud for mentions and DMs and quiet for ordinary messages', () => {
|
||||
expect(DING_VOLUME.mention).toBe(1);
|
||||
expect(DING_VOLUME.dm).toBe(1);
|
||||
expect(DING_VOLUME.message).toBeLessThan(DING_VOLUME.invite);
|
||||
expect(DING_VOLUME.invite).toBeLessThan(DING_VOLUME.mention);
|
||||
});
|
||||
|
||||
it('names the motif: invite over mention over DM over message', () => {
|
||||
expect(motifFor({ invite: true, highlight: true, direct: true })).toBe('invite');
|
||||
expect(motifFor({ highlight: true, direct: true })).toBe('mention');
|
||||
expect(motifFor({ highlight: false, direct: true })).toBe('dm');
|
||||
expect(motifFor({ highlight: false, direct: false })).toBe('message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('audio unlock', () => {
|
||||
const make = () => {
|
||||
const listeners = new Map<string, () => void>();
|
||||
const target = {
|
||||
addEventListener: (t: string, f: () => void) => listeners.set(t, f),
|
||||
removeEventListener: (t: string) => listeners.delete(t),
|
||||
};
|
||||
const played: Array<{ url: string; volume: number }> = [];
|
||||
const player = createDingPlayer((url) => {
|
||||
const clip = { volume: 1, play: vi.fn(async () => undefined) };
|
||||
played.push({
|
||||
url,
|
||||
get volume() {
|
||||
return clip.volume;
|
||||
},
|
||||
} as never);
|
||||
return clip;
|
||||
}, target);
|
||||
return { listeners, player, played };
|
||||
};
|
||||
|
||||
it('does not play before the first user gesture, and plays after it', () => {
|
||||
const { listeners, player, played } = make();
|
||||
expect(player.play('a.ogg', 1)).toBe(false);
|
||||
expect(played).toHaveLength(0);
|
||||
listeners.get('pointerdown')!();
|
||||
expect(player.play('a.ogg', 0.3)).toBe(true);
|
||||
expect(played).toHaveLength(1);
|
||||
expect(played[0].volume).toBe(0.3);
|
||||
});
|
||||
|
||||
it('stops listening once unlocked', () => {
|
||||
const { listeners, player } = make();
|
||||
listeners.get('keydown')!();
|
||||
expect(listeners.size).toBe(0);
|
||||
expect(player.unlocked()).toBe(true);
|
||||
});
|
||||
|
||||
it('a clip the browser refuses does not throw', async () => {
|
||||
const target = {
|
||||
addEventListener: (_t: string, f: () => void) => f(),
|
||||
removeEventListener: () => undefined,
|
||||
};
|
||||
const player = createDingPlayer(
|
||||
() => ({ volume: 1, play: () => Promise.reject(new Error('NotAllowedError')) }),
|
||||
target
|
||||
);
|
||||
expect(() => player.play('a.ogg', 1)).not.toThrow();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
96
src/app/features/dings/dings.ts
Normal file
96
src/app/features/dings/dings.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// Dings: pre-rendered earcons shipped as static assets, `dings/<mxid-localpart>/<motif>.ogg` plus
|
||||
// `dings/manifest.json` (docs/gamecult/ratatoskr-target.md, Agreements). The sender picks the
|
||||
// instrument, the event picks the motif. This file is the whole decision; the client only asks it.
|
||||
|
||||
export type Motif = 'mention' | 'dm' | 'message' | 'invite';
|
||||
type Clips = Partial<Record<Motif, string>>;
|
||||
export type DingManifest = { neutral: Clips; senders: Record<string, Clips> };
|
||||
|
||||
const MOTIFS: Motif[] = ['mention', 'dm', 'message', 'invite'];
|
||||
|
||||
// Mentions and DMs loud, an ordinary message quiet.
|
||||
export const DING_VOLUME: Record<Motif, number> = {
|
||||
mention: 1,
|
||||
dm: 1,
|
||||
invite: 0.7,
|
||||
message: 0.3,
|
||||
};
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
|
||||
// A clip path stays inside dings/: relative, ogg or mp3 (not wav), no dot segments, no scheme.
|
||||
const isClipPath = (v: unknown): v is string =>
|
||||
typeof v === 'string' &&
|
||||
/^[A-Za-z0-9_-]+(\/[A-Za-z0-9_.-]+)*\.(ogg|mp3)$/.test(v) &&
|
||||
!v.split('/').includes('..');
|
||||
|
||||
const readClips = (v: unknown): Clips => {
|
||||
const clips: Clips = {};
|
||||
if (!isRecord(v)) return clips;
|
||||
MOTIFS.forEach((m) => {
|
||||
if (Object.prototype.hasOwnProperty.call(v, m) && isClipPath(v[m])) clips[m] = v[m] as string;
|
||||
});
|
||||
return clips;
|
||||
};
|
||||
|
||||
/** The manifest is JSON from outside the typed world: read it defensively, undefined if unusable. */
|
||||
export const parseManifest = (json: unknown): DingManifest | undefined => {
|
||||
if (!isRecord(json) || !isRecord(json.neutral) || !isRecord(json.senders)) return undefined;
|
||||
const senders: Record<string, Clips> = Object.create(null);
|
||||
Object.keys(json.senders).forEach((localpart) => {
|
||||
senders[localpart] = readClips(
|
||||
json.senders && (json.senders as Record<string, unknown>)[localpart]
|
||||
);
|
||||
});
|
||||
return { neutral: readClips(json.neutral), senders };
|
||||
};
|
||||
|
||||
/** The sender's own clip, else the neutral one, else nothing: silence. */
|
||||
export const pickDing = (
|
||||
manifest: DingManifest,
|
||||
localpart: string,
|
||||
motif: Motif
|
||||
): { path: string; volume: number } | undefined => {
|
||||
const own = Object.prototype.hasOwnProperty.call(manifest.senders, localpart)
|
||||
? manifest.senders[localpart][motif]
|
||||
: undefined;
|
||||
const path = own ?? manifest.neutral[motif];
|
||||
return path === undefined ? undefined : { path, volume: DING_VOLUME[motif] };
|
||||
};
|
||||
|
||||
export const motifFor = (e: { invite?: boolean; highlight: boolean; direct: boolean }): Motif => {
|
||||
if (e.invite) return 'invite';
|
||||
if (e.highlight) return 'mention';
|
||||
return e.direct ? 'dm' : 'message';
|
||||
};
|
||||
|
||||
type Clip = { volume: number; play: () => Promise<void> };
|
||||
type GestureTarget = {
|
||||
addEventListener: (type: string, listener: () => void) => void;
|
||||
removeEventListener: (type: string, listener: () => void) => void;
|
||||
};
|
||||
const GESTURES = ['pointerdown', 'keydown', 'touchend'];
|
||||
|
||||
/**
|
||||
* Browsers refuse audio until the first user gesture. The player stays silent until one has
|
||||
* happened, then plays; a clip the browser still refuses is dropped, never thrown.
|
||||
*/
|
||||
export const createDingPlayer = (make: (url: string) => Clip, target: GestureTarget) => {
|
||||
let unlocked = false;
|
||||
const unlock = () => {
|
||||
unlocked = true;
|
||||
GESTURES.forEach((g) => target.removeEventListener(g, unlock));
|
||||
};
|
||||
GESTURES.forEach((g) => target.addEventListener(g, unlock));
|
||||
return {
|
||||
unlocked: () => unlocked,
|
||||
play(url: string, volume: number): boolean {
|
||||
if (!unlocked) return false;
|
||||
const clip = make(url);
|
||||
clip.volume = volume;
|
||||
clip.play().catch(() => undefined);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
};
|
||||
40
src/app/features/dings/useDings.ts
Normal file
40
src/app/features/dings/useDings.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { trimTrailingSlash } from '../../utils/common';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { DingManifest, Motif, createDingPlayer, parseManifest, pickDing } from './dings';
|
||||
|
||||
const DINGS_ROOT = `${trimTrailingSlash(import.meta.env.BASE_URL)}/public/dings`;
|
||||
|
||||
/**
|
||||
* Plays a ding for a sender and motif. The manifest is read once at runtime. Returns false when
|
||||
* there is no manifest (nothing rendered yet), so the caller keeps the stock sound; once there is
|
||||
* one, a sender or motif with no clip is silence, as the design says, and the return is true.
|
||||
*/
|
||||
export const useDings = () => {
|
||||
const manifestRef = useRef<DingManifest>();
|
||||
const player = useMemo(() => createDingPlayer((url) => new Audio(url), window), []);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
fetch(`${DINGS_ROOT}/manifest.json`)
|
||||
.then((r) => (r.ok ? r.json() : undefined))
|
||||
.then((json) => {
|
||||
if (live) manifestRef.current = parseManifest(json);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useCallback(
|
||||
(sender: string, motif: Motif): boolean => {
|
||||
const manifest = manifestRef.current;
|
||||
if (!manifest) return false;
|
||||
const ding = pickDing(manifest, getMxIdLocalPart(sender) ?? sender, motif);
|
||||
if (ding) player.play(`${DINGS_ROOT}/${ding.path}`, ding.volume);
|
||||
return true;
|
||||
},
|
||||
[player]
|
||||
);
|
||||
};
|
||||
|
|
@ -26,6 +26,9 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
|||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { motifFor } from '../../features/dings/dings';
|
||||
import { useDings } from '../../features/dings/useDings';
|
||||
|
||||
function SystemEmojiFeature() {
|
||||
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
||||
|
|
@ -85,6 +88,7 @@ function InviteNotifications() {
|
|||
const navigate = useNavigate();
|
||||
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
||||
const playDing = useDings();
|
||||
|
||||
const notify = useCallback(
|
||||
(count: number) => {
|
||||
|
|
@ -115,10 +119,20 @@ function InviteNotifications() {
|
|||
}
|
||||
|
||||
if (notificationSound) {
|
||||
playSound();
|
||||
// The sender of an invite is not in the invite list, so it gets the neutral invite ding.
|
||||
if (!playDing('', 'invite')) playSound();
|
||||
}
|
||||
}
|
||||
}, [mx, invites, perviousInviteLen, showNotifications, notificationSound, notify, playSound]);
|
||||
}, [
|
||||
mx,
|
||||
invites,
|
||||
perviousInviteLen,
|
||||
showNotifications,
|
||||
notificationSound,
|
||||
notify,
|
||||
playSound,
|
||||
playDing,
|
||||
]);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
|
|
@ -136,6 +150,8 @@ function MessageNotifications() {
|
|||
const useAuthentication = useMediaAuthentication();
|
||||
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||
const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds');
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const playDing = useDings();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const notificationSelected = useInboxNotificationsSelected();
|
||||
|
|
@ -227,7 +243,11 @@ function MessageNotifications() {
|
|||
}
|
||||
|
||||
if (notificationSound) {
|
||||
playSound();
|
||||
const motif = motifFor({
|
||||
highlight: unreadInfo.highlight > 0,
|
||||
direct: mDirects.has(room.roomId),
|
||||
});
|
||||
if (!playDing(sender, motif)) playSound();
|
||||
}
|
||||
};
|
||||
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||
|
|
@ -240,6 +260,8 @@ function MessageNotifications() {
|
|||
notificationSelected,
|
||||
showNotifications,
|
||||
playSound,
|
||||
playDing,
|
||||
mDirects,
|
||||
notify,
|
||||
selectedRoomId,
|
||||
useAuthentication,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ const copyFiles = {
|
|||
src: 'public/locales',
|
||||
dest: 'public/',
|
||||
},
|
||||
{
|
||||
src: 'public/dings',
|
||||
dest: 'public/',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue