// Notifications+ (src/areas/notifications): the keyword alerts it holds, filters by kind, the // keyword highlight and "Open on Mentions". The table-level checks (every module starts and stops // clean, one observer, the command budget) are in areas.test.ts. import assert from 'node:assert/strict'; import test from 'node:test'; import { FEATURES, POWER } from '../src/areas/notifications/features.ts'; import { MODULES } from '../src/areas/notifications/index.ts'; import { HIGHLIGHT, KEYWORD_ATTR, keywordSpans, rowText } from '../src/areas/notifications/keywordHighlight.ts'; import { KIND_ATTR, KIND_ICONS, KIND_SCHEMA, KINDS, NOTIFICATION_ROWS, filtersApply, iconKind, kindCss, notificationKind } from '../src/areas/notifications/kindFilters.ts'; import { MENTIONS_TAB, isAllTab } from '../src/areas/notifications/openMentions.ts'; import { X_TABLE } from '../src/areas/table.ts'; import { alertKeywords, installKeywordAlerts, onAlertKeywords } from '../src/inject/alerts.ts'; import { normalizeSettings } from '../../../waybetter/packages/runtime/src/settingsForm.ts'; import { areaPage, flagsWith as tableFlags, messages } from './helpers/areaPage.ts'; /** The flags with only Notifications+ on, so what runs here is its alone (other areas have features that are on by default). */ const OTHER_AREAS_OFF = Object.fromEntries(X_TABLE.areas.filter(area => area.id !== 'notifications').map(area => [`area.${area.id}`, false])); const flagsWith = (patch: Record = {}) => tableFlags({ ...OTHER_AREAS_OFF, ...patch }); const STORE = 'bt-plugin:notifications-plus.kindFilters:settings'; // ---- X-shaped rows without a DOM library: elements that answer the selectors the modules use ---- type Attrs = Record; class El { parent: El | null = null; tag: string; attrs: Attrs; children: (El | string)[]; constructor(tag: string, attrs: Attrs = {}, ...children: (El | string)[]) { this.tag = tag; this.attrs = attrs; this.children = children; for (const child of children) if (child instanceof El) child.parent = this; } get textContent(): string { return this.children.map(child => (typeof child === 'string' ? child : child.textContent)).join(''); } getAttribute(name: string) { return this.attrs[name] ?? null; } hasAttribute(name: string) { return name in this.attrs; } setAttribute(name: string, value: string) { this.attrs[name] = value; } removeAttribute(name: string) { delete this.attrs[name]; } private one(compound: string): boolean { const tag = /^[a-z]+/.exec(compound)?.[0]; if (tag && tag !== this.tag) return false; for (const [, name, op, value] of compound.matchAll(/\[([\w-]+)(?:([\^*]?)="([^"]*)")?\]/g)) { const own = this.attrs[name!]; if (own === undefined) return false; if (value === undefined) continue; if (op === '^' ? !own.startsWith(value) : op === '*' ? !own.includes(value) : own !== value) return false; } return true; } matches(selector: string): boolean { return selector.split(',').some(part => { const chain = part.trim().split(/\s+/); if (!this.one(chain.pop()!)) return false; let up = this.parent; for (let i = chain.length - 1; i >= 0; i -= 1) { while (up && !up.one(chain[i]!)) up = up.parent; if (!up) return false; up = up.parent; } return true; }); } closest(selector: string): El | null { for (let node: El | null = this; node; node = node.parent) if (node.matches(selector)) return node; return null; } querySelectorAll(selector: string): El[] { const out: El[] = []; const walk = (node: El) => { for (const child of node.children) if (child instanceof El) { if (child.matches(selector)) out.push(child); walk(child); } }; walk(this); return out; } querySelector(selector: string) { return this.querySelectorAll(selector)[0] ?? null; } } const el = (tag: string, attrs: Attrs = {}, ...children: (El | string)[]) => new El(tag, attrs, ...children); const asRow = (row: El) => row as unknown as Element; const icon = (d: string) => el('svg', { viewBox: '0 0 24 24', 'aria-hidden': 'true' }, el('g', {}, el('path', { d }))); const userLink = (handle: string, name: string) => el('a', { href: `/${handle}`, role: 'link' }, name); /** One of X's grouped rows: the kind's icon on the left, avatars, a sentence in the page's language, the post's text. */ const grouped = (d: string, sentence: string, body = '') => el('div', { 'data-testid': 'cellInnerDiv' }, el('article', { 'data-testid': 'notification', role: 'article' }, el('div', {}, icon(d)), el('div', {}, userLink('person_one', ''), el('div', { dir: 'ltr' }, userLink('person_one', 'Person One'), sentence), el('div', { dir: 'ltr' }, body)), )).children[0] as El; /** A post row of the Notifications timeline. `replyingTo`: X's line above the text of a reply; `quoted`: a quoted post card. */ function post(options: { author: string; text: (El | string)[]; replyingTo?: string; quoted?: (El | string)[] }): El { const { author } = options; const cell = el('div', { 'data-testid': 'cellInnerDiv' }, el('article', { 'data-testid': 'tweet', role: 'article', tabindex: '0' }, el('div', { 'data-testid': 'Tweet-User-Avatar' }, el('a', { href: `/${author}`, role: 'link' })), el('div', { 'data-testid': 'User-Name' }, el('a', { href: `/${author}`, role: 'link' }, 'Author Name'), el('a', { href: `/${author}`, role: 'link' }, `@${author}`), el('a', { href: `/${author}/status/1`, role: 'link' }, el('time', { datetime: '2026-09-30T10:00:00.000Z' }, '2h'))), ...(options.replyingTo ? [el('div', {}, el('div', { dir: 'ltr' }, 'A cevap olarak ', el('div', {}, el('a', { href: `/${options.replyingTo}`, role: 'link' }, `@${options.replyingTo}`))))] : []), el('div', { 'data-testid': 'tweetText', dir: 'auto' }, ...options.text), ...(options.quoted ? [el('div', { role: 'link', tabindex: '0' }, el('div', { 'data-testid': 'User-Name' }, 'Quoted Person'), el('div', { 'data-testid': 'tweetText' }, ...options.quoted))] : []), el('div', { role: 'group' }, el('button', { 'data-testid': 'reply' }, icon('M1.751 10c0-4.42 3.584-8 8.005-8h4.366'))), )); return cell.children[0] as El; } const mention = (handle: string) => el('a', { href: `/${handle}`, role: 'link' }, `@${handle}`); const HEART = `${KIND_ICONS.like[0]}-4.001 5.12-8.379 7.67l-.503.3-.504-.3z`; const REPOST = `${KIND_ICONS.repost[0]}-1.706 1.82L6 8.38v7.37z`; const PERSON = `${KIND_ICONS.follow[0]} 2.366 3.8 2.632 6.46l.11 1.1H3.395z`; const BELL = 'M19.993 9.042C19.48 5.017 16.054 2 11.996 2s-7.49 3.021-7.999 7.051L2.866 18H7.1z'; // -------------------------------------------------------------------------------------------------- test('Notifications+: keyword alerts stay the app\'s, held by the card; three new features are modules on the Notifications page', () => { const byId = Object.fromEntries(FEATURES.map(feature => [feature.id, feature])); assert.deepEqual(Object.keys(byId), ['notifications.keywordAlerts', 'notifications.kindFilters', 'notifications.keywordHighlight', 'notifications.openMentions']); assert.equal(byId['notifications.keywordAlerts']!.backing, 'app'); assert.equal(byId['notifications.keywordAlerts']!.default, true, 'as phase 1 shipped it'); assert.deepEqual(MODULES.map(module => module.id), FEATURES.filter(feature => feature.backing === 'module').map(feature => feature.id)); for (const feature of FEATURES.filter(item => item.backing === 'module')) { assert.equal(feature.default, false, feature.id); assert.deepEqual(feature.surfaces, ['notifications'], feature.id); } // Power User adds the highlight; never the one that hides rows nor the one that clicks a tab. assert.deepEqual(POWER, ['notifications.keywordHighlight']); // The plugin off: no keyword is watched either. assert.equal(flagsWith({ 'area.notifications': false })['notifications.keywordAlerts'], false); const env = areaPage('/home'); env.registry.setFlags(flagsWith({ 'notifications.kindFilters': true, 'notifications.keywordHighlight': true, 'notifications.openMentions': true })); assert.deepEqual(env.registry.active(), [], 'nothing of it outside Notifications'); env.go('/notifications'); assert.deepEqual(env.registry.active().sort(), ['notifications.keywordHighlight', 'notifications.kindFilters', 'notifications.openMentions']); // One palette command for the whole area: its settings. assert.deepEqual(env.api.commands.list().map(command => [command.id, command.title]), [['bt-notifications-plus.plugin.js:settings', 'Notifications+ settings']]); env.go('/notifications/mentions'); assert.equal(env.registry.active().length, 3); env.go('/someone/status/1'); assert.deepEqual(env.registry.active(), []); assert.deepEqual(env.live(), []); env.registry.dispose(); }); test('Filters by kind: a grouped row is told by its icon, in any language', () => { assert.equal(iconKind(HEART), 'like'); assert.equal(iconKind(REPOST), 'repost'); assert.equal(iconKind(PERSON), 'follow'); assert.equal(iconKind(BELL), null, 'an icon that is not one of the three: unknown'); assert.equal(iconKind(''), null); assert.equal(iconKind(null), null); // The sentence plays no part: the same kinds in English, Turkish and Japanese. for (const sentence of [' liked your post', ' gönderini beğendi', 'さんがあなたのポストをいいねしました']) assert.equal(notificationKind(asRow(grouped(HEART, sentence)), 'me'), 'like', sentence); assert.equal(notificationKind(asRow(grouped(REPOST, ' gönderini yeniden yayınladı')), null), 'repost'); assert.equal(notificationKind(asRow(grouped(PERSON, ' seni takip etti')), null), 'follow'); // Unknown never hides: a new-post bell, a row without an icon, a row whose words say "liked". assert.equal(notificationKind(asRow(grouped(BELL, ' liked your post')), 'me'), null); assert.equal(notificationKind(asRow(el('article', { 'data-testid': 'notification' }, el('div', {}, 'liked your post'))), 'me'), null); assert.equal(notificationKind(asRow(el('article', { 'data-testid': 'something-new' }, icon(HEART))), 'me'), null); }); test('Filters by kind: a post row is a reply by X\'s "replying to" line, a mention by a link to you in its text', () => { const reply = post({ author: 'lena', replyingTo: 'me', text: ['Thanks, shipping it tonight.'] }); assert.equal(notificationKind(asRow(reply), 'me'), 'reply'); assert.equal(notificationKind(asRow(reply), null), 'reply', 'a reply is told without knowing the account'); const mentioned = post({ author: 'mira', text: ['Hot take from ', mention('Me'), ': hover cards should be opaque.'] }); assert.equal(notificationKind(asRow(mentioned), 'me'), 'mention', 'handles are compared without case'); assert.equal(notificationKind(asRow(mentioned), null), null, 'the account is not known yet: unknown'); // Someone else mentioned, a plain post, a quote of your post: not told, so never hidden. assert.equal(notificationKind(asRow(post({ author: 'mira', text: ['Ask ', mention('theo'), ' about it.'] })), 'me'), null); assert.equal(notificationKind(asRow(post({ author: 'mira', text: ['No links here.'] })), 'me'), null); assert.equal(notificationKind(asRow(post({ author: 'mira', text: ['Worth reading:'], quoted: ['Original by ', mention('me'), ' and ', mention('lena')] })), 'me'), null, 'links inside a quoted post are not the row\'s'); // A reply that also names you is a reply. assert.equal(notificationKind(asRow(post({ author: 'lena', replyingTo: 'theo', text: [mention('me'), ' look at this'] })), 'me'), 'reply'); }); test('Filters by kind: the rule hides only the chosen kinds, never on the Mentions tab', () => { const settings = normalizeSettings(KIND_SCHEMA, null); assert.deepEqual(settings, { like: true, repost: false, follow: false, mention: false, reply: false }, 'turned on, it hides likes; the rest is the owner\'s choice'); assert.deepEqual(KIND_SCHEMA.map(field => (field.type === 'divider' ? '' : field.key)), [...KINDS]); const css = kindCss(settings, '/notifications'); assert.match(css, new RegExp(`^\\[data-testid="cellInnerDiv"\\]:has\\(article\\[${KIND_ATTR}="like"\\]\\),\\narticle\\[${KIND_ATTR}="like"\\] \\{ display: none !important; \\}$`)); assert.equal(kindCss({ like: false, repost: false, follow: false, mention: false, reply: false }, '/notifications'), ''); const all = kindCss({ like: true, repost: true, follow: true, mention: true, reply: true }, '/notifications/verified'); for (const kind of KINDS) assert.ok(all.includes(`="${kind}"]`), kind); // Only rows this feature marked are hidden: the rule names nothing of X's own but the cell. assert.doesNotMatch(all, /aria-label|:has\([^)]*:has\(/); assert.equal(kindCss({ like: true, mention: true, reply: true }, '/notifications/mentions'), '', 'the Mentions tab would be empty'); assert.equal(filtersApply('/notifications'), true); assert.equal(filtersApply('/notifications/mentions/'), false); for (const field of KIND_SCHEMA) if (field.type !== 'divider') assert.ok(messages[`plugin.notifications-plus.kindFilters.${field.label}`], field.label); }); test('Filters by kind in the page: rows are marked as X draws them, settings change the rule, off leaves nothing', () => { const env = areaPage('/notifications'); const like = grouped(HEART, ' gönderini beğendi'); const bell = grouped(BELL, ' yeni gönderi'); const mentioned = post({ author: 'mira', text: ['Hi ', mention('me')] }); const marked = () => [like, bell, mentioned].map(row => row.getAttribute(KIND_ATTR)); env.matches.set(NOTIFICATION_ROWS, [like, bell, mentioned]); env.registry.setFlags(flagsWith({ 'notifications.kindFilters': true })); assert.deepEqual(env.live(), ['wbf-notifications.kindFilters']); // X has not drawn the account button yet: the mention is not told, and stays open to a second look. assert.deepEqual(marked(), ['like', null, null]); const button = el('button', { 'data-testid': 'SideNav_AccountSwitcher_Button' }, el('div', { 'data-testid': 'UserAvatar-Container-me' })); env.matches.set('[data-testid="SideNav_AccountSwitcher_Button"]', [button]); env.mutate(); assert.deepEqual(marked(), ['like', null, 'mention']); // The owner's choice is kept where the settings are, and the rule follows it at once. env.store.set(STORE, JSON.stringify({ like: false, repost: false, follow: false, mention: false, reply: false })); env.go('/notifications/verified'); assert.deepEqual(env.live(), [], 'nothing chosen: no rule'); env.store.set(STORE, JSON.stringify({ like: true, mention: true })); env.go('/notifications'); assert.deepEqual(env.live(), ['wbf-notifications.kindFilters']); env.go('/notifications/mentions'); assert.deepEqual(env.live(), [], 'the Mentions tab is never filtered'); // Off: the marks are taken back. env.matches.set(`[${KIND_ATTR}]`, [like, mentioned]); env.registry.setFlags(flagsWith({})); assert.deepEqual(marked(), [null, null, null]); assert.deepEqual(env.live(), []); assert.equal(env.observers.live, 0); env.registry.dispose(); }); test('Keyword highlight: where the owner\'s keywords are in a row', () => { assert.deepEqual(keywordSpans('Split panes are the real Split test', ['split']), [[0, 5], [25, 30]]); assert.deepEqual(keywordSpans('waybetter is way better', ['way', 'waybetter']), [[0, 9], [13, 16]], 'the longer keyword wins where two start together; none overlaps'); assert.deepEqual(keywordSpans('abcdefgh', ['abcdef', 'cd', 'fg']), [[0, 6]], 'nothing inside a longer match'); assert.deepEqual(keywordSpans('nothing here', ['split']), []); assert.deepEqual(keywordSpans('anything', []), []); // A post row is read by its own text only: not the author's name, not a quoted post's author. const row = post({ author: 'split_fan', text: ['Panes at 1275px'] }); assert.equal(rowText(asRow(row)), 'Panes at 1275px'); assert.equal(rowText(asRow(grouped(HEART, ' liked your post', 'Split panes are the real stress test.'))), 'Person One liked your postSplit panes are the real stress test.'); }); test('Keyword highlight in the page: uses the keywords the alerts watch, read-only, and follows them', () => { const env = areaPage('/notifications'); const win = env.win as unknown as Window & typeof globalThis; const hit = grouped(HEART, ' liked your post', 'Split panes at 1275px are the real stress test.'); const miss = post({ author: 'mira', text: ['Hover cards should be opaque.'] }); const replied = post({ author: 'lena', replyingTo: 'me', text: ['wayBetter themes never fight the layout'] }); const rows = [hit, miss, replied]; env.matches.set(NOTIFICATION_ROWS, rows); const marks = () => rows.map(row => row.getAttribute(KEYWORD_ATTR)); const before = [...env.store.keys()]; env.registry.setFlags(flagsWith({ 'notifications.keywordHighlight': true })); assert.deepEqual(env.live(), ['wbf-notifications.keywordHighlight']); assert.deepEqual(marks(), [null, null, null], 'no keyword is watched: nothing is marked'); // The app hands the page the owner's keywords (Settings, Keyword alerts): the rows follow in the next frame. // (The alerts are the app's own module on this page's window; here without a bridge to native code.) Object.assign(env.win, { fetch: async () => new Response('{}') }); const pageAlerts = installKeywordAlerts(win, undefined); let changes = 0; const off = onAlertKeywords(win, () => { changes += 1; }); pageAlerts.set(['Split Panes', 'WAYBETTER themes']); assert.deepEqual(alertKeywords(win), ['split panes', 'waybetter themes']); assert.deepEqual(marks(), ['split panes', null, 'waybetter themes'], 'matched as the alerts match: any case, any spacing'); // The alert itself off ("Keyword alerts"): the highlight stays, and nothing is reported. pageAlerts.enable(false); assert.deepEqual(alertKeywords(win), ['split panes', 'waybetter themes']); assert.deepEqual(marks(), ['split panes', null, 'waybetter themes']); pageAlerts.enable(true); // No keywords: the marks go. pageAlerts.set([]); assert.deepEqual(marks(), [null, null, null]); pageAlerts.set(['opaque']); assert.deepEqual(marks(), [null, 'opaque', null]); assert.equal(changes, 3); off(); // Read-only: nothing stored, no command, no listener on the page. assert.deepEqual([...env.store.keys()], before); assert.deepEqual(env.api.commands.list(), []); // The tint of the words is a CSS highlight: no node of X is changed for it. assert.match(HIGHLIGHT, /^bt-notifications-/); env.matches.set(`[${KEYWORD_ATTR}]`, [miss]); env.registry.setFlags(flagsWith({})); assert.deepEqual(marks(), [null, null, null]); pageAlerts.set(['split']); assert.deepEqual(marks(), [null, null, null], 'off: keyword changes reach nothing'); pageAlerts.dispose(); assert.deepEqual(alertKeywords(win), []); env.registry.dispose(); }); test('Open on Mentions: X\'s own tab is selected once when Notifications opens, never against the user', () => { assert.equal(isAllTab('/notifications'), true); assert.equal(isAllTab('/notifications/'), true); assert.equal(isAllTab('/notifications/mentions'), false); assert.equal(isAllTab('/notifications/verified'), false); // The tab is found by its address, not by its name: any language. assert.match(MENTIONS_TAB, /a\[role="tab"\]\[href="\/notifications\/mentions"\]$/); const env = areaPage('/home'); let clicks = 0; // X's Mentions tab: a click navigates to its page. const tab = () => ({ click() { clicks += 1; env.go('/notifications/mentions'); } }); env.registry.setFlags(flagsWith({ 'notifications.openMentions': true })); assert.deepEqual(env.registry.active(), []); // Notifications opens; the tabs are drawn a moment later. env.go('/notifications'); assert.equal(clicks, 0); env.matches.set(MENTIONS_TAB, [tab()]); env.mutate(); assert.equal(clicks, 1); assert.equal((env.win.location as { pathname: string }).pathname, '/notifications/mentions'); // Back to "All" by hand (X draws the tabs anew): it stays on All. env.matches.set(MENTIONS_TAB, [tab()]); env.go('/notifications'); env.mutate(); assert.equal(clicks, 1); // Leaving and opening Notifications again: once more. env.go('/home'); env.matches.set(MENTIONS_TAB, [tab()]); env.go('/notifications'); assert.equal(clicks, 2); // Opened on another tab directly: nothing to do. env.go('/home'); env.matches.set(MENTIONS_TAB, [tab()]); env.go('/notifications/verified'); env.mutate(); assert.equal(clicks, 2); // The user picked a tab before X drew the Mentions tab: their choice stands. env.go('/home'); env.matches.set(MENTIONS_TAB, []); env.go('/notifications'); env.go('/notifications/verified'); env.go('/notifications'); env.matches.set(MENTIONS_TAB, [tab()]); env.mutate(); assert.equal(clicks, 2); env.registry.setFlags(flagsWith({})); assert.equal(env.observers.live, 0); env.registry.dispose(); });