wayBetter for X

This commit is contained in:
CRTkafa 2026-10-01 06:01:13 +07:00
commit 434f193217
720 changed files with 147465 additions and 0 deletions

View file

@ -0,0 +1,344 @@
// 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<string, boolean> = {}) => 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<string, string>;
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();
});