diff --git a/packages/web/src/utils/notificationFilters.test.ts b/packages/web/src/utils/notificationFilters.test.ts new file mode 100644 index 00000000..593f9bfe --- /dev/null +++ b/packages/web/src/utils/notificationFilters.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'vitest'; +import { shouldPlayMessageSound } from './notificationFilters'; + +describe('shouldPlayMessageSound', () => { + const myIds = new Set(['local-snowflake', 'home-uid-42']); + + it('suppresses messages authored by self (local id)', () => { + expect( + shouldPlayMessageSound({ + authorUserId: 'local-snowflake', + myIds, + isDmChannel: true, + content: 'hi', + allChannels: false, + }), + ).toBe(false); + }); + + it('suppresses messages authored by self (home id)', () => { + expect( + shouldPlayMessageSound({ + authorUserId: 'home-uid-42', + myIds, + isDmChannel: true, + content: 'hi', + allChannels: false, + }), + ).toBe(false); + }); + + it('plays for DM messages from others', () => { + expect( + shouldPlayMessageSound({ + authorUserId: 'someone-else', + myIds, + isDmChannel: true, + content: 'yo', + allChannels: false, + }), + ).toBe(true); + }); + + it('suppresses non-DM, non-mention messages from others', () => { + expect( + shouldPlayMessageSound({ + authorUserId: 'someone-else', + myIds, + isDmChannel: false, + content: 'general chatter', + allChannels: false, + }), + ).toBe(false); + }); + + it('plays when content mentions me by local id', () => { + expect( + shouldPlayMessageSound({ + authorUserId: 'someone-else', + myIds, + isDmChannel: false, + content: 'hey <@local-snowflake> look', + allChannels: false, + }), + ).toBe(true); + }); + + it('plays when content mentions me by home id (federated)', () => { + expect( + shouldPlayMessageSound({ + authorUserId: 'someone-else', + myIds, + isDmChannel: false, + content: 'cc <@home-uid-42>', + allChannels: false, + }), + ).toBe(true); + }); + + it('does not play for mentions of someone else', () => { + expect( + shouldPlayMessageSound({ + authorUserId: 'someone-else', + myIds, + isDmChannel: false, + content: 'pinging <@third-party>', + allChannels: false, + }), + ).toBe(false); + }); + + it('plays for any non-self message when allChannels=true', () => { + expect( + shouldPlayMessageSound({ + authorUserId: 'someone-else', + myIds, + isDmChannel: false, + content: 'general chatter', + allChannels: true, + }), + ).toBe(true); + }); + + it('still suppresses self-authored even when allChannels=true', () => { + expect( + shouldPlayMessageSound({ + authorUserId: 'local-snowflake', + myIds, + isDmChannel: false, + content: 'my own message', + allChannels: true, + }), + ).toBe(false); + }); + + it('handles null content (attachment-only) gracefully', () => { + expect( + shouldPlayMessageSound({ + authorUserId: 'someone-else', + myIds, + isDmChannel: true, + content: null, + allChannels: false, + }), + ).toBe(true); + expect( + shouldPlayMessageSound({ + authorUserId: 'someone-else', + myIds, + isDmChannel: false, + content: null, + allChannels: false, + }), + ).toBe(false); + }); +}); diff --git a/packages/web/src/utils/notificationFilters.ts b/packages/web/src/utils/notificationFilters.ts new file mode 100644 index 00000000..073d61f8 --- /dev/null +++ b/packages/web/src/utils/notificationFilters.ts @@ -0,0 +1,29 @@ +/** + * Decides whether a freshly-arrived chat message should fire the in-app + * `message.mp3` cue. Pure, federation-aware (matches against any of the + * caller's known self-ids). + * + * Rule (Discord-default): + * - Suppress messages authored by self (any id in myIds). + * - When allChannels=true, fire for every non-self message. + * - Otherwise, fire only if the channel is a DM, or if the content contains + * a `<@${id}>` mention for any id in myIds. + */ +export interface ShouldPlayMessageSoundInput { + authorUserId: string; + myIds: Set; + isDmChannel: boolean; + content: string | null; + allChannels: boolean; +} + +export function shouldPlayMessageSound(input: ShouldPlayMessageSoundInput): boolean { + if (input.myIds.has(input.authorUserId)) return false; + if (input.allChannels) return true; + if (input.isDmChannel) return true; + if (!input.content) return false; + for (const id of input.myIds) { + if (input.content.includes(`<@${id}>`)) return true; + } + return false; +}