// Reader+ (src/areas/reader): the timeline and post rules that were Tweaks, the seven plugins it // replaces (compared with their files), the three fixes of the port (the visited profile and the // opened post are never collapsed, a bookmark can be removed on the Bookmarks page, nothing depends // on an English label) and its new features (read marker, language filter, quick mute). The // table-level checks (every module starts and stops clean, one observer) are in areas.test.ts. import assert from 'node:assert/strict'; import { readFileSync, readdirSync } from 'node:fs'; import test, { afterEach } from 'node:test'; import { cleanDestination } from '../src/areas/reader/cleanLinks.ts'; import { COLLAPSE_SCHEMA, isUnknown, parseAllowlist, shouldCollapse, type CollapseSettings } from '../src/areas/reader/collapseVerified.ts'; import { postMarkdown, postPlainText } from '../src/areas/reader/copyAsMarkdown.ts'; import { GROK_GLYPH, READER_CSS, readerCss } from '../src/areas/reader/css.ts'; import { timePlacement } from '../src/areas/reader/exactTimestamps.ts'; import { FEATURES, POWER } from '../src/areas/reader/features.ts'; import { countFromLabel } from '../src/areas/reader/fullNumbers.ts'; import { MODULES } from '../src/areas/reader/index.ts'; import { LANGUAGE_SCHEMA, languageOf, readLanguages, shouldCollapseLanguage } from '../src/areas/reader/languageFilter.ts'; import { mutedHaystack, parsePatterns } from '../src/areas/reader/mutedRegex.ts'; import { focalPost, visitedProfile } from '../src/areas/reader/pages.ts'; import { appendPattern, quickMuteLine } from '../src/areas/reader/quickMute.ts'; import { READ_MARKER_SCHEMA, maySave, readMarkers, readingPost, tabKey } from '../src/areas/reader/readMarker.ts'; import { readingMinutes } from '../src/areas/reader/readingTime.ts'; import { focalIdOf, threadMarkdown, threadOrder } from '../src/areas/reader/threadReader.ts'; import { surfacesOf } from '../src/areas/surfaces.ts'; import { X_TABLE } from '../src/areas/table.ts'; import { FEATURE_DEFAULTS } from '../src/inject/features.ts'; import { createPluginApi, type PluginApi } from '../src/inject/pluginApi.ts'; import type { UserInfo, UsersApi } from '../src/inject/userInfo.ts'; import { createFeatureRegistry } from '../../../waybetter/packages/runtime/src/featureModules.ts'; import { normalizeSettings } from '../../../waybetter/packages/runtime/src/settingsForm.ts'; import { areaPage as plainPage, flagsWith as tableFlags, messages } from './helpers/areaPage.ts'; /** The flags with only Reader+ on (other areas have modules that are on by default). */ const flagsWith = (patch: Record = {}) => tableFlags({ ...Object.fromEntries(X_TABLE.areas.filter(area => area.id !== 'reader').map(area => [`area.${area.id}`, false])), ...patch }); const BUNDLED = new URL('../src-tauri/bundled-plugins/', import.meta.url); const SOURCES = new URL('../src/areas/reader/', import.meta.url); const OWNER = 'bt-reader-plus.plugin.js'; // A registry polls the route with a timer: one a failed test left running would keep the process alive. const running: Array<{ dispose(): void }> = []; afterEach(() => { for (const registry of running.splice(0)) registry.dispose(); }); const areaPage = (path: string) => { const env = plainPage(path); running.push(env.registry); return env; }; /** The old plugin, loaded the way native code loads it, to compare the port against. */ function oldPlugin(file: string): T { const module = { exports: {} as unknown }; new Function('module', 'exports', readFileSync(new URL(file, BUNDLED), 'utf8'))(module, module.exports); return module.exports as T; } // ---- A small DOM: elements, text, attributes and the selectors these modules use ---------------- type Compound = { tag: string | null; scope: boolean; classes: string[]; attrs: { name: string; op: string; value: string; insensitive: boolean }[] }; type Step = { compound: Compound; combinator: ' ' | '>' | null }; function splitTop(selector: string, separators: string): string[] { const out: string[] = []; let depth = 0; let quoted = false; let current = ''; for (const char of selector) { if (char === '"') quoted = !quoted; if (!quoted && char === '[') depth += 1; if (!quoted && char === ']') depth -= 1; if (!quoted && depth === 0 && separators.includes(char)) { out.push(current); out.push(char); current = ''; continue; } current += char; } out.push(current); return out; } function parseCompound(source: string): Compound { const compound: Compound = { tag: null, scope: false, classes: [], attrs: [] }; let rest = source; const tag = /^[a-zA-Z][\w-]*/.exec(rest); if (tag) { compound.tag = tag[0].toUpperCase(); rest = rest.slice(tag[0].length); } while (rest) { const attr = /^\[([\w-]+)(?:([\^$*]?=)"([^"]*)"(\s+i)?)?\]/.exec(rest); const cls = /^\.([\w-]+)/.exec(rest); if (attr) { compound.attrs.push({ name: attr[1]!, op: attr[2] ?? '', value: attr[3] ?? '', insensitive: !!attr[4] }); rest = rest.slice(attr[0].length); } else if (cls) { compound.classes.push(cls[1]!); rest = rest.slice(cls[0].length); } else if (rest.startsWith(':scope')) { compound.scope = true; rest = rest.slice(6); } else throw new Error(`test DOM: cannot parse ${JSON.stringify(source)}`); } return compound; } const parsed = new Map(); function parseSelector(selector: string): Step[][] { let list = parsed.get(selector); if (!list) { list = splitTop(selector, ',').filter(part => part !== ',').map(complex => { const steps: Step[] = []; let combinator: ' ' | '>' | null = null; for (const token of splitTop(complex.trim(), ' >')) { if (token === '>') { combinator = '>'; continue; } if (token === ' ' || token === '') { combinator ??= steps.length ? ' ' : null; continue; } steps.push({ compound: parseCompound(token), combinator: steps.length ? combinator ?? ' ' : null }); combinator = null; } return steps; }); parsed.set(selector, list); } return list; } class Text { nodeType = 3; parentElement: El | null = null; nodeValue: string; constructor(value: string) { this.nodeValue = value; } } type Kid = El | Text | string | null; class El { nodeType = 1; tagName: string; nodeName: string; parentElement: El | null = null; childNodes: (El | Text)[] = []; attrs = new Map(); listeners = new Map) => void>>(); /** What getComputedStyle and getBoundingClientRect say about it. */ css: Record = { display: 'flex', flexDirection: 'column' }; rect = { top: 0, bottom: 0 }; scrolled = 0; [key: string]: unknown; constructor(tag: string, attrs: Record = {}, ...kids: Kid[]) { this.tagName = this.nodeName = tag.toUpperCase(); for (const [name, value] of Object.entries(attrs)) this.attrs.set(name, value); this.append(...kids); } get children() { return this.childNodes.filter((node): node is El => node instanceof El); } get childElementCount() { return this.children.length; } get firstElementChild() { return this.children[0] ?? null; } get parentNode() { return this.parentElement; } get isConnected(): boolean { return this.tagName === 'BODY' || !!this.parentElement?.isConnected; } get textContent(): string { return this.childNodes.map(node => (node instanceof El ? node.textContent : node.nodeValue)).join(''); } set textContent(value: string) { for (const node of this.childNodes) node.parentElement = null; this.childNodes = []; if (value) this.append(value); } get className() { return this.attrs.get('class') ?? ''; } set className(value: string) { this.attrs.set('class', value); } get classList() { const names = () => this.className.split(/\s+/).filter(Boolean); return { contains: (name: string) => names().includes(name) }; } getAttribute(name: string) { return this.attrs.get(name) ?? null; } setAttribute(name: string, value: string) { this.attrs.set(name, String(value)); } hasAttribute(name: string) { return this.attrs.has(name); } removeAttribute(name: string) { this.attrs.delete(name); } append(...kids: Kid[]) { for (const kid of kids) { if (kid == null) continue; const node = typeof kid === 'string' ? new Text(kid) : kid; if (node instanceof El) node.remove(); node.parentElement = this; this.childNodes.push(node); } } appendChild(node: T) { this.append(node); return node; } replaceChildren(...kids: Kid[]) { this.textContent = ''; this.append(...kids); } insertBefore(node: El, reference: El) { node.remove(); node.parentElement = this; this.childNodes.splice(this.childNodes.indexOf(reference), 0, node); return node; } before(node: El) { this.parentElement!.insertBefore(node, this); } after(node: El) { const parent = this.parentElement!; node.remove(); node.parentElement = parent; parent.childNodes.splice(parent.childNodes.indexOf(this) + 1, 0, node); } remove() { const parent = this.parentElement; if (!parent) return; parent.childNodes.splice(parent.childNodes.indexOf(this), 1); this.parentElement = null; } get previousElementSibling(): El | null { const all = this.parentElement?.children ?? []; return all[all.indexOf(this) - 1] ?? null; } get nextElementSibling(): El | null { const all = this.parentElement?.children ?? []; const at = all.indexOf(this); return at < 0 ? null : all[at + 1] ?? null; } contains(node: El | null): boolean { for (let at = node; at; at = at.parentElement) if (at === this) return true; return false; } private fits(compound: Compound, scope: El | null): boolean { if (compound.scope && this !== scope) return false; if (compound.tag && compound.tag !== this.tagName) return false; if (!compound.classes.every(name => this.classList.contains(name))) return false; return compound.attrs.every(({ name, op, value, insensitive }) => { const raw = this.attrs.get(name); if (raw === undefined) return false; const [have, want] = insensitive ? [raw.toLowerCase(), value.toLowerCase()] : [raw, value]; return op === '' || (op === '=' && have === want) || (op === '^=' && have.startsWith(want)) || (op === '$=' && have.endsWith(want)) || (op === '*=' && have.includes(want)); }); } private fitsSteps(steps: Step[], index: number, scope: El | null): boolean { const step = steps[index]!; if (!this.fits(step.compound, scope)) return false; if (index === 0) return true; if (step.combinator === '>') return !!this.parentElement?.fitsSteps(steps, index - 1, scope); for (let at = this.parentElement; at; at = at.parentElement) if (at.fitsSteps(steps, index - 1, scope)) return true; return false; } matches(selector: string, scope: El | null = null): boolean { return parseSelector(selector).some(steps => this.fitsSteps(steps, steps.length - 1, scope)); } closest(selector: string): El | null { for (let at: El | null = this; at; at = at.parentElement) if (at.matches(selector)) return at; return null; } descendants(): El[] { return this.children.flatMap(child => [child, ...child.descendants()]); } querySelectorAll(selector: string): El[] { return this.descendants().filter(node => node.matches(selector, this)); } querySelector(selector: string): El | null { return this.querySelectorAll(selector)[0] ?? null; } addEventListener(type: string, fn: (event: Record) => void) { (this.listeners.get(type) ?? this.listeners.set(type, []).get(type)!).push(fn); } click() { for (const fn of this.listeners.get('click') ?? []) fn({ preventDefault() {}, stopPropagation() {}, shiftKey: false }); this.clicks = ((this.clicks as number) ?? 0) + 1; } getBoundingClientRect() { return this.rect; } scrollIntoView() { this.scrolled += 1; } } const h = (tag: string, attrs: Record = {}, ...kids: Kid[]) => new El(tag, attrs, ...kids); const asElement = (node: El) => node as unknown as Element; type PostSpec = { id: string; handle: string; name?: string; text?: string; lang?: string; quoted?: string; reposted?: boolean; focal?: boolean; promoted?: boolean }; /** A timeline cell with a post the way X draws one: header (name, time, Grok, ···), text, an optional quote card, the action bar. */ function post(spec: PostSpec): El { const time = (owner: string, id: string, label: string) => h('a', { href: `/${owner}/status/${id}` }, h('time', { datetime: '2026-09-27T14:03:00.000Z' }, label)); const header = h('div', {}, h('div', { 'data-testid': 'User-Name' }, h('a', { href: `/${spec.handle}` }, spec.name ?? spec.handle), h('div', {}, time(spec.handle, spec.id, '2h'))), h('div', {}, h('button', { 'aria-label': 'Grok actions' }), h('button', { 'data-testid': 'caret' })), ); header.css = { display: 'flex', flexDirection: 'row' }; const article = h('article', { 'data-testid': 'tweet', tabindex: spec.focal ? '-1' : '0' }, spec.reposted ? h('span', { 'data-testid': 'socialContext' }, 'reposted') : null, header, spec.text === '' ? null : h('div', { 'data-testid': 'tweetText', lang: spec.lang ?? 'en' }, spec.text ?? 'A post about nothing in particular.'), spec.quoted ? h('div', { role: 'link' }, time(spec.quoted, `9${spec.id}`, '1d'), h('div', { 'data-testid': 'tweetText', lang: 'en' }, 'The quoted words.')) : null, h('div', { role: 'group' }, h('div', {}, h('button', { 'data-testid': 'like', 'aria-label': '1234 Likes. Like' }, h('span', {}, '1.2K')))), ); const wrapped = spec.promoted ? h('div', { 'data-testid': 'placementTracking' }, article) : article; return h('div', { 'data-testid': 'cellInnerDiv' }, wrapped); } const articleOf = (cell: El) => cell.querySelector('article')!; /** X's page around a timeline: the account button (who is signed in), the Home tabs, the posts. */ function body(viewer: string | null, cells: El[], tabs: boolean[] = []): El { return h('body', {}, viewer ? h('button', { 'data-testid': 'SideNav_AccountSwitcher_Button' }, h('div', { 'data-testid': `UserAvatar-Container-${viewer}` })) : null, h('div', { 'data-testid': 'primaryColumn' }, h('div', { role: 'tablist' }, ...tabs.map(selected => h('div', {}, h('a', { role: 'tab', 'aria-selected': String(selected) }, 'a tab')))), h('section', {}, ...cells), ), ); } type Users = Record>>; /** * The area page (helpers/areaPage.ts) over a DOM: the same fake window, with `root` as its body * and a registry of Reader+'s modules whose API knows `users` (what X's responses told about them). */ function readerPage(path: string, root: El, users: Users = {}) { const env = areaPage(path); env.registry.dispose(); const makeStyle = env.doc.createElement as (tag: string) => unknown; let selection = ''; Object.assign(env.doc, { body: root, querySelectorAll: (selector: string) => root.querySelectorAll(selector), querySelector: (selector: string) => root.querySelector(selector), createElement: (tag: string) => (tag === 'style' ? makeStyle(tag) : new El(tag)), createTextNode: (value: string) => new Text(value), dispatchEvent: (event: { type: string }) => { env.doc.emit(event.type, event); return true; }, }); Object.assign(env.win, { innerHeight: 800, scrollY: 0, getComputedStyle: (element: El) => element.css, getSelection: () => ({ toString: () => selection }), CustomEvent: class { type: string; detail: unknown; constructor(type: string, init: { detail?: unknown } = {}) { this.type = type; this.detail = init.detail; } }, }); const listeners = new Set<(handles: string[]) => void>(); const info = (handle: string): UserInfo | null => { const known = users[handle.toLowerCase()]; return known ? { xUserId: '1', handle, blue: null, verifiedType: null, following: null, ...known } as UserInfo : null; }; const usersApi: UsersApi = { get: info, onUpdate: callback => { listeners.add(callback); return () => { listeners.delete(callback); }; } }; const english = { locale: () => 'en', message: (key: string) => messages[key], onChange: () => () => {} }; const toasts: string[] = []; const base = createPluginApi(env.win as unknown as Window & typeof globalThis, usersApi, undefined, english); const api = Object.freeze({ ...base, toast: (message: string) => { toasts.push(message); } }) as PluginApi; const registry = createFeatureRegistry({ win: env.win as unknown as Window & typeof globalThis, table: X_TABLE, api, label: 'wayBetter for X', surfaces: () => surfacesOf((env.win.location as { pathname: string }).pathname) }); for (const module of MODULES) registry.define(module); running.push(registry); return { ...env, registry, api, toasts, root, /** Only these flags (the one that is on by default, "Hide promoted posts", is off unless named). */ on(flags: Record) { registry.setFlags(flagsWith({ hidePromotedPosts: false, ...flags })); }, /** X's responses told more about these users. */ learn(more: Users) { Object.assign(users, more); for (const listener of [...listeners]) listener(Object.keys(more)); }, select(text: string) { selection = text; }, /** The kit's toasts and dialogs need a real DOM: they report that on the console, which is not what these tests are about. */ quietly(run: () => T): T { const original = console.error; console.error = () => {}; try { return run(); } finally { console.error = original; } }, }; } const collapsed = (cell: El, attr: string) => articleOf(cell).hasAttribute(attr); // ---- The table ----------------------------------------------------------------------------------- test('Reader+: twenty-one features, all modules; the eleven flags that were Tweaks keep their names and defaults', () => { assert.equal(FEATURES.length, 21); const moduleRows = FEATURES.filter(feature => feature.backing === 'module'); assert.deepEqual(MODULES.map(module => module.id).sort(), moduleRows.map(feature => feature.id).sort()); // "Sort replies by Recent" rewrites X's own request: a module that stays in place on every page. assert.deepEqual(FEATURES.filter(feature => feature.backing !== 'module'), []); assert.equal(FEATURES.find(feature => feature.id === 'sortRepliesRecent')!.heavy, true); const tweaks = ['hidePromotedPosts', 'followingTimelineDefault', 'hideForYouTab', 'hideGrok', 'hideViewCounts', 'hideEngagementCounts', 'hideBookmarkButton', 'disableTweetTextFormatting', 'sortRepliesRecent', 'fullNumbers', 'hideVerifiedBadges']; for (const flag of tweaks) assert.equal(FEATURES.find(feature => feature.id === flag)?.default, FEATURE_DEFAULTS[flag as keyof typeof FEATURE_DEFAULTS], flag); // Everything else is off until someone turns it on, the new features included. for (const feature of FEATURES) if (!tweaks.includes(feature.id)) assert.equal(feature.default, false, feature.id); // The seven plugins: each flag replaces its file and keeps the plugin's words. const ports = { 'reader.cleanLinks': 'clean-links', 'reader.copyAsMarkdown': 'copy-as-markdown', 'reader.exactTimestamps': 'exact-timestamps', 'reader.collapseVerified': 'hide-blue-checks', 'reader.mutedRegex': 'muted-regex', 'reader.readingTime': 'reading-time', 'reader.threadReader': 'thread-reader' }; for (const [flag, name] of Object.entries(ports)) { assert.equal(FEATURES.find(feature => feature.id === flag)?.supersedes, `bt-${name}.plugin.js`, flag); assert.equal(MODULES.find(module => module.id === flag)?.words, name, flag); } assert.equal(messages[FEATURES.find(feature => feature.id === 'reader.collapseVerified')!.label!], 'Collapse posts from verified accounts'); // Power User adds reading tools only: nothing that hides or collapses. for (const flag of POWER) assert.doesNotMatch(flag, /hide|collapse|muted|languageFilter|quickMute/i, flag); }); test('Reader+: no module makes a request, writes markup, observes on its own or uses the page globals; its only clicks are navigation', () => { const clicks: string[] = []; for (const file of readdirSync(SOURCES).filter(name => name.endsWith('.ts'))) { const source = readFileSync(new URL(file, SOURCES), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); assert.doesNotMatch(source, /\bfetch\(|XMLHttpRequest|sendBeacon|WebSocket|EventSource|__TAURI|invoke\(/, `${file} makes no request`); assert.doesNotMatch(source, /innerHTML|insertAdjacentHTML|eval\(|new MutationObserver/, `${file} builds DOM from text only, through the shared observer`); assert.doesNotMatch(source, /(? { for (const [id, schema] of [['readMarker', READ_MARKER_SCHEMA], ['languageFilter', LANGUAGE_SCHEMA]] as const) { for (const field of schema) if (field.type !== 'divider') { assert.ok(messages[`plugin.reader-plus.${id}.${field.label}`], `${id}.${field.label}`); if (field.hint) assert.ok(messages[`plugin.reader-plus.${id}.${field.hint}`], `${id}.${field.hint}`); } } for (const field of COLLAPSE_SCHEMA) if (field.type !== 'divider') { assert.ok(messages[`plugin.hide-blue-checks.${field.label}`], field.label); if (field.hint) assert.ok(messages[`plugin.hide-blue-checks.${field.hint}`], field.hint); } for (const key of ['readMarker.line', 'readMarker.found', 'readMarker.jump', 'readMarker.notLoaded', 'readMarker.none', 'languageFilter.stub', 'mutedRegex.edit', 'quickMute.add', 'quickMute.none', 'quickMute.length', 'quickMute.exists', 'quickMute.failed', 'quickMute.added', 'quickMute.addedOff', 'quickMute.undo']) { assert.ok(messages[`plugin.reader-plus.${key}`], key); } }); // ---- The rules that were Tweaks ------------------------------------------------------------------ test('Reader+ rules: each flag names only its own part of X, and a rule is there only while its flag is on', () => { const markers: Record = { hidePromotedPosts: '[data-testid="placementTracking"]:has(article)', hideForYouTab: 'html[data-bt-home]', hideGrok: 'grokImgGen', hideViewCounts: '/analytics', hideEngagementCounts: 'visibility: hidden', hideBookmarkButton: 'ookmark', hideVerifiedBadges: 'icon-verified', disableTweetTextFormatting: 'font-weight: normal', }; assert.equal(readerCss({}), ''); for (const [flag, marker] of Object.entries(markers)) { const css = readerCss({ [flag]: true }); assert.ok(css.includes(marker), flag); // No :has() inside :has() (invalid CSS: the whole rule would be dropped). assert.doesNotMatch(css, /:has\([^)]*:has\(/, flag); for (const [other, otherMarker] of Object.entries(markers)) if (other !== flag) assert.ok(!css.includes(otherMarker), `${flag} does not carry ${other}`); } // An ordinary video's placementTracking wrapper is never hidden (live, 2026-09-27). assert.doesNotMatch(READER_CSS.hidePromotedPosts(), /\[data-testid="placementTracking"\](?!:has\(article\)| article)/); // Grok inside posts is this flag's; the rail link is Navigation+'s hideGrokNav. assert.doesNotMatch(READER_CSS.hideGrok(), /header nav/); }); test('fix: no rule depends on an English label alone (Grok is found by its name and its glyph in any X language)', () => { const grok = READER_CSS.hideGrok(); assert.ok(grok.includes('[data-testid="tweet"] button[aria-label*="Grok" i]'), 'the brand name, whatever surrounds it and however it is cased'); assert.ok(grok.includes(`[data-testid="tweet"] button:has(svg path[d^="${GROK_GLYPH}"])`), 'the glyph on a post'); assert.ok(grok.includes(`html[data-bt-route="profile"] [data-testid="primaryColumn"] button:has(svg path[d^="${GROK_GLYPH}"])`), 'the summary button on a profile'); // X's own label is "Grok actions": the old exact-match rule ("Grok Actions") never matched it. const actions = h('button', { 'aria-label': 'Grok actions' }); const german = h('button', { 'aria-label': 'Grok-Aktionen' }); h('article', { 'data-testid': 'tweet' }, actions, german); assert.ok(!actions.matches('button[aria-label="Grok Actions"]')); for (const button of [actions, german]) assert.ok(button.matches('[data-testid="tweet"] button[aria-label*="Grok" i]')); // Every other selector of the area is a testid, a role, a link target or a position. for (const [flag, css] of Object.entries(READER_CSS)) if (flag !== 'hideGrok') assert.doesNotMatch(css(), /aria-label/, flag); for (const file of readdirSync(SOURCES).filter(name => name.endsWith('.ts') && name !== 'css.ts')) { const labels = readFileSync(new URL(file, SOURCES), 'utf8').match(/aria-label[*^$]?="[^"]*"/g) ?? []; assert.deepEqual(labels.filter(label => label !== 'aria-label*="Grok"'), [], file); } }); test('fix: "Hide verified badges" keeps the badge in the header of the profile being visited', () => { const css = READER_CSS.hideVerifiedBadges(); assert.match(css, /^svg\[data-testid="icon-verified"\]:not\(\[data-testid="UserName"\] svg\):not\(html\[data-bt-route="profile"\] \[data-testid="primaryColumn"\] h2 svg\) \{/); // Posts and lists name their author with "User-Name": those badges still go. assert.ok(!css.includes('User-Name')); }); test('fix: the bookmark button stays on the Bookmarks page, where it is the way to remove a bookmark', () => { assert.doesNotMatch(READER_CSS.hideBookmarkButton(), /body/, 'no body class the app never sets'); const env = areaPage('/home'); env.registry.setFlags(flagsWith({ hideBookmarkButton: true, hidePromotedPosts: false })); assert.deepEqual(env.live(), ['wbf-hideBookmarkButton']); env.go('/i/bookmarks'); assert.deepEqual(env.registry.active(), ['hideBookmarkButton']); assert.deepEqual(env.live(), [], 'on Bookmarks: no rule, the button is there'); env.go('/someone/status/1'); assert.deepEqual(env.live(), ['wbf-hideBookmarkButton'], 'and back on every other page'); env.registry.dispose(); }); test('Reader+ surfaces: "For you" and the read marker only on Home, Thread Reader only on a post; on chat only the two rules that reach it', () => { const env = areaPage('/someone'); env.registry.setFlags(flagsWith({ hideForYouTab: true, 'reader.readMarker': true, 'reader.threadReader': true, hideGrok: true, hideVerifiedBadges: true, hideEngagementCounts: true, hidePromotedPosts: false })); assert.deepEqual(env.registry.active().sort(), ['hideEngagementCounts', 'hideGrok', 'hideVerifiedBadges']); env.go('/home'); assert.deepEqual(env.registry.active().sort(), ['hideEngagementCounts', 'hideForYouTab', 'hideGrok', 'hideVerifiedBadges', 'reader.readMarker']); env.go('/someone/status/1'); assert.deepEqual(env.registry.active().sort(), ['hideEngagementCounts', 'hideGrok', 'hideVerifiedBadges', 'reader.threadReader']); env.go('/i/chat'); assert.deepEqual(env.registry.active().sort(), ['hideGrok', 'hideVerifiedBadges'], 'names and the Grok drawer are on chat pages too; both are CSS only'); env.registry.dispose(); }); test('Reader+ commands: four actions and the settings entry, inside the budget of five', () => { const env = areaPage('/someone/status/1'); env.registry.setFlags(flagsWith(Object.fromEntries(MODULES.map(module => [module.id, true])))); const commands = () => env.api.commands.list().filter(command => command.id.startsWith(`${OWNER}:`)).map(command => command.id.slice(OWNER.length + 1)).sort(); assert.deepEqual(commands(), ['mutedRegex.edit', 'quickMute.add', 'settings', 'threadReader.open']); env.go('/home'); assert.deepEqual(commands(), ['mutedRegex.edit', 'quickMute.add', 'readMarker.jump', 'settings']); const title = (id: string) => env.api.commands.list().find(command => command.id === `${OWNER}:${id}`)!.title; assert.deepEqual(['mutedRegex.edit', 'quickMute.add', 'readMarker.jump', 'settings'].map(title), ['Edit Muted Regex patterns', 'Mute the selected text', 'Jump to where you left off', 'Reader+ settings']); env.registry.setFlags(flagsWith({ 'area.reader': false })); assert.deepEqual(env.api.commands.list(), [], 'the area off: nothing of it left'); assert.deepEqual(env.registry.active(), []); env.registry.dispose(); }); test('Default to Following: one click on X\'s second Home tab, by position, once per page load', () => { const page = (selected: boolean[], path = '/home') => { const root = body('me', [], selected); const env = readerPage(path, root); return { env, tabs: root.querySelectorAll('[role="tab"]') }; }; // Labels are not read: the tabs could say anything, in any language. const forYou = page([true, false]); forYou.env.on({ followingTimelineDefault: true }); forYou.env.mutate(); assert.deepEqual(forYou.tabs.map(tab => tab.clicks ?? 0), [0, 1]); forYou.env.mutate(); forYou.env.go('/someone'); forYou.env.go('/home'); forYou.env.mutate(); assert.deepEqual(forYou.tabs.map(tab => tab.clicks ?? 0), [0, 1], 'never a second time: the reader may have gone back to "For you"'); forYou.env.registry.dispose(); const following = page([false, true]); following.env.on({ followingTimelineDefault: true }); following.env.mutate(); assert.deepEqual(following.tabs.map(tab => tab.clicks ?? 0), [0, 0], 'already on Following'); following.env.registry.dispose(); const elsewhere = page([true, false], '/someone'); elsewhere.env.on({ followingTimelineDefault: true }); elsewhere.env.mutate(); assert.deepEqual(elsewhere.tabs.map(tab => tab.clicks ?? 0), [0, 0], 'a profile has tabs too: only Home counts'); elsewhere.env.registry.dispose(); }); test('Full numbers: the exact count from the label in any language, written over X\'s text and put back', () => { assert.equal(countFromLabel('1234 Likes. Like'), 1234); assert.equal(countFromLabel('12.345 „Gefällt mir“-Angaben. Gefällt mir'), 12345); assert.equal(countFromLabel('좋아요 1,234개'), 1234, 'a language that puts the word first'); assert.equal(countFromLabel('Beğen'), null); assert.equal(countFromLabel('3 of 12 replies'), 3, 'a leading number wins'); assert.equal(countFromLabel('Replies 3 of 12'), null, 'several numbers and none leading: left as X wrote it'); assert.equal(countFromLabel(null), null); const cell = post({ id: '1', handle: 'someone' }); const span = cell.querySelector('[role="group"] span')!; const node = span.childNodes[0] as Text; const env = readerPage('/home', body('me', [cell])); env.on({ fullNumbers: true }); env.mutate(); assert.equal(node.nodeValue, '1,234'); assert.equal(span.childNodes[0], node, 'the text node React owns is kept'); env.on({}); assert.equal(node.nodeValue, '1.2K'); assert.ok(!span.hasAttribute('data-bt-count')); env.registry.dispose(); }); test('Hide view counts: the views unit of an opened post is marked with its separator, and unmarked when off', () => { const count = h('span', { 'data-testid': 'app-text-transition-container' }, h('span', {}, '1,234')); const views = h('div', {}, count, h('span', {}, ' Views')); const separator = h('div', { 'aria-hidden': 'true' }, h('span', {}, '·')); const line = h('div', {}, h('div', {}, h('a', { href: '/someone/status/1' }, h('time', { datetime: '2026-09-28T19:06:00.000Z' }, '10:06 PM · Sep 28, 2026'))), separator, views); const env = readerPage('/someone/status/1', body('me', [h('article', { 'data-testid': 'tweet' }, h('div', {}, line))])); env.on({ hideViewCounts: true }); assert.ok(views.hasAttribute('data-bt-views') && separator.hasAttribute('data-bt-views-sep')); assert.deepEqual(env.live(), ['wbf-hideViewCounts']); env.on({}); assert.ok(!views.hasAttribute('data-bt-views') && !separator.hasAttribute('data-bt-views-sep')); env.registry.dispose(); }); // ---- The seven plugins --------------------------------------------------------------------------- test('Clean Links: the same destination as the plugin; links point there while it is on and back at t.co when off', () => { const Old = oldPlugin<{ prototype: { destination(link: { textContent: string }): string | null } }>('clean-links.plugin.js'); for (const text of ['https://example.com/a?utm_source=x&id=7&fbclid=abc', 'https://example.com/long/path…', 'example.com/a', 'not a link', 'http://example.com/?ref_src=twsrc', '', 'https://exa mple.com']) { assert.equal(cleanDestination(text), Old.prototype.destination({ textContent: text }), text); } assert.equal(cleanDestination('https://example.com/a?utm_source=x&id=7'), 'https://example.com/a?id=7'); const link = h('a', { href: 'https://t.co/abc' }, 'https://example.com/a?utm_medium=social…'); const plain = h('a', { href: 'https://t.co/def' }, 'a title, not a URL'); const cell = post({ id: '1', handle: 'someone' }); cell.querySelector('[data-testid="tweetText"]')!.append(link, plain); const env = readerPage('/home', body('me', [cell])); env.on({ 'reader.cleanLinks': true }); assert.equal(link.getAttribute('href'), 'https://example.com/a'); assert.equal(link.getAttribute('title'), 'https://example.com/a'); assert.equal(plain.getAttribute('href'), 'https://t.co/def', 'a link whose text is not its destination is left alone'); env.on({}); assert.equal(link.getAttribute('href'), 'https://t.co/abc'); assert.deepEqual([...link.attrs.keys()], ['href']); env.registry.dispose(); }); test('Copy as Markdown: the same Markdown and plain text as the plugin; one button per post, gone when off', () => { type OldCopy = { prototype: { markdown(tweet: unknown): string; plainText(tweet: unknown): string } }; const Old = oldPlugin('copy-as-markdown.plugin.js'); const tweets = [ { text: 'one line', name: 'Some One', handle: 'someone', url: 'https://x.com/someone/status/1', media: [] as string[], time: '2026-09-27T14:03:00.000Z' }, { text: 'two\n\nlines ', name: 'Some One', handle: 'someone', url: 'https://x.com/someone/status/2', media: ['https://pbs.twimg.com/media/a?name=orig', 'https://pbs.twimg.com/media/b?name=orig'], time: null }, { text: '', name: 'someone', handle: 'someone', url: 'https://x.com/someone/status/3', media: ['https://pbs.twimg.com/media/c?name=orig'], time: null }, ]; for (const tweet of tweets) for (const date of ['Sep 27, 2026, 2:03 PM', '']) { const self = { date: () => date }; assert.equal(postMarkdown(tweet, date, 'link'), Old.prototype.markdown.call(self, tweet)); assert.equal(postPlainText(tweet, date), Old.prototype.plainText.call(self, tweet)); } const cells = [post({ id: '1', handle: 'someone' }), post({ id: '2', handle: 'other' })]; const env = readerPage('/home', body('me', cells)); env.on({ 'reader.copyAsMarkdown': true }); for (const cell of cells) { const buttons = cell.querySelectorAll('.bt-copy-md'); assert.equal(buttons.length, 1); assert.equal(buttons[0]!.nextElementSibling, cell.querySelector('[data-testid="caret"]'), 'right before X\'s ··· button'); assert.equal(buttons[0]!.getAttribute('aria-label'), 'Copy as Markdown (Shift+click: plain text)'); } env.mutate(); assert.equal(env.root.querySelectorAll('.bt-copy-md').length, 2, 'never a second one'); env.on({}); assert.equal(env.root.querySelectorAll('.bt-copy-md').length, 0); env.registry.dispose(); }); test('Exact Timestamps: the date goes where the plugin put it, once per post, and leaves with the feature', () => { const Old = oldPlugin<{ placement(time: unknown): { after: unknown; header: boolean } }>('exact-timestamps.plugin.js'); const style = (element: Element) => (element as unknown as El).css as { flexDirection: string }; const g = globalThis as Record; const saved = g.getComputedStyle; g.getComputedStyle = style; try { const plain = post({ id: '1', handle: 'someone' }); const quoting = post({ id: '2', handle: 'someone', quoted: 'other' }); const column = post({ id: '3', handle: 'someone' }); column.querySelector('[data-testid="User-Name"]')!.parentElement!.css = { display: 'flex', flexDirection: 'column' }; body('me', [plain, quoting, column]); for (const time of [plain, quoting, column].flatMap(cell => cell.querySelectorAll('time'))) { const mine = timePlacement(asElement(time), style); const old = Old.placement(time); assert.equal(mine.after, old.after); assert.equal(mine.header, old.header); } assert.equal(timePlacement(asElement(plain.querySelector('time')!), style).header, true); assert.equal(timePlacement(asElement(quoting.querySelectorAll('time')[1]!), style).header, false, 'a quoted post has no header row of its own'); } finally { g.getComputedStyle = saved; } const cell = post({ id: '1', handle: 'someone' }); const env = readerPage('/home', body('me', [cell])); env.on({ 'reader.exactTimestamps': true }); const time = cell.querySelector('time')!; const full = new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date('2026-09-27T14:03:00.000Z')); assert.equal(time.getAttribute('title'), full); const dates = cell.querySelectorAll('.bt-exact-time'); assert.deepEqual(dates.map(date => date.textContent), [`· ${full}`]); assert.equal(dates[0]!.previousElementSibling, cell.querySelector('[data-testid="User-Name"]'), 'after the name block, before X\'s buttons'); env.on({}); assert.equal(cell.querySelectorAll('.bt-exact-time').length, 0); assert.ok(!time.hasAttribute('title') && !time.hasAttribute('data-bt-exact')); env.registry.dispose(); }); test('Reading Time: the same minutes as the plugin; a pill on long posts only', () => { const Old = oldPlugin<{ minutes(text: string): number }>('reading-time.plugin.js'); const words = (count: number) => Array.from({ length: count }, (_, index) => `word${index}`).join(' '); for (const text of ['', 'short', words(119), words(120), words(230), words(700), '語'.repeat(119), '語'.repeat(500), `${words(100)} ${'한'.repeat(40)}`]) { assert.equal(readingMinutes(text), Old.minutes(text), `${text.length} characters`); } assert.deepEqual([readingMinutes(words(119)), readingMinutes(words(120)), readingMinutes(words(700))], [0, 1, 3]); const long = post({ id: '1', handle: 'someone', text: words(700) }); const short = post({ id: '2', handle: 'someone' }); const env = readerPage('/someone/status/1', body('me', [long, short])); env.on({ 'reader.readingTime': true }); assert.deepEqual(env.root.querySelectorAll('.bt-reading-time').map(pill => pill.textContent), ['3 min read']); assert.equal(long.querySelector('.bt-reading-time')!.nextElementSibling, long.querySelector('[data-testid="tweetText"]')); env.on({}); assert.equal(env.root.querySelectorAll('.bt-reading-time').length, 0); env.registry.dispose(); }); test('Muted Regex: the plugin\'s patterns and its stored list; a match collapses into a stub that shows the post again', () => { const Old = oldPlugin<{ parsePatterns(source: string): { patterns: { source: string; pattern: RegExp }[]; invalid: string[] } }>('muted-regex.plugin.js'); const source = '# comment\n\ncrypto\n/^@spam_bot$/m\n/Exact/g\n(unclosed\n'; const mine = parsePatterns(source); const old = Old.parsePatterns(source); assert.deepEqual(mine.invalid, old.invalid); assert.deepEqual(mine.patterns.map(item => [item.source, item.pattern.source, item.pattern.flags]), old.patterns.map(item => [item.source, item.pattern.source, item.pattern.flags])); assert.equal(mutedHaystack({ handle: 'a', name: 'B', text: 'c' }, 'd'), '@a\nB\nc\nd'); const spam = post({ id: '1', handle: 'someone', text: 'Huge CRYPTO giveaway today' }); const bot = post({ id: '2', handle: 'spam_bot', text: 'hello there everyone' }); const fine = post({ id: '3', handle: 'other', text: 'a walk in the park' }); const quoting = post({ id: '4', handle: 'other', text: 'look at this', quoted: 'someone' }); quoting.querySelector('[role="link"] [data-testid="tweetText"]')!.textContent = 'free crypto'; const env = readerPage('/home', body('me', [spam, bot, fine, quoting])); // What the plugin stored, where it stored it. env.store.set('bt-plugin:muted-regex:patterns', JSON.stringify('crypto\n/^@spam_bot$/m')); env.on({ 'reader.mutedRegex': true }); assert.deepEqual([spam, bot, fine, quoting].map(cell => collapsed(cell, 'data-bt-muted')), [true, true, false, true]); const stubs = env.root.querySelectorAll('.bt-muted-stub'); assert.deepEqual(stubs.map(stub => stub.textContent), ['Post hidden by Muted Regex (crypto). Click to show.', 'Post hidden by Muted Regex (/^@spam_bot$/m). Click to show.', 'Post hidden by Muted Regex (crypto). Click to show.']); assert.equal(stubs[0]!.nextElementSibling, articleOf(spam)); stubs[0]!.click(); assert.ok(!collapsed(spam, 'data-bt-muted') && articleOf(spam).hasAttribute('data-bt-muted-shown')); assert.equal(env.root.querySelectorAll('.bt-muted-stub').length, 2); env.on({}); assert.equal(env.root.querySelectorAll('.bt-muted-stub, article[data-bt-muted], article[data-bt-muted-shown]').length, 0, 'off: every post back, no mark left'); assert.equal(env.store.get('bt-plugin:muted-regex:patterns'), JSON.stringify('crypto\n/^@spam_bot$/m'), 'the list is untouched'); env.registry.dispose(); }); test('Thread Reader: the plugin\'s post page rule and Markdown; its button only while the author\'s thread is on the page', () => { type OldReader = { focalIdOf(path: string): string | null; prototype: { markdown(): string } }; const Old = oldPlugin('thread-reader.plugin.js'); for (const path of ['/jack/status/123', '/jack/status/123/', '/jack/status/123/photo/1', '/jack', '/home', '/i/lists/1', '']) assert.equal(focalIdOf(path), Old.focalIdOf(path), path); const posts = [ { id: '30', name: 'Some One', handle: 'someone', url: 'https://x.com/someone/status/30', text: 'third', media: [] as string[] }, { id: '9', name: 'Some One', handle: 'someone', url: 'https://x.com/someone/status/9', text: 'first', media: ['https://pbs.twimg.com/media/a?name=orig'] }, { id: '10', name: 'Some One', handle: 'someone', url: 'https://x.com/someone/status/10', text: 'second\nline', media: [] as string[] }, ]; assert.deepEqual(threadOrder(posts).map(item => item.id), ['9', '10', '30'], 'by id as a number, not as text'); assert.equal(threadMarkdown(threadOrder(posts)), Old.prototype.markdown.call({ ordered: () => threadOrder(posts) })); assert.equal(threadMarkdown([]), Old.prototype.markdown.call({ ordered: () => [] })); const back = h('div', {}, h('div', {}, h('button', { 'data-testid': 'app-bar-back' })), h('h2', {}, 'Post')); back.css = { display: 'flex', flexDirection: 'row' }; const root = body('me', [post({ id: '1', handle: 'someone', focal: true }), post({ id: '2', handle: 'other' })]); root.querySelector('[data-testid="primaryColumn"]')!.append(back); const env = readerPage('/someone/status/1', root); env.on({ 'reader.threadReader': true }); assert.equal(root.querySelectorAll('.bt-tr-open').length, 0, 'one post by the author is not a thread'); root.querySelector('section')!.append(post({ id: '3', handle: 'Someone' })); env.mutate(); const button = root.querySelector('.bt-tr-open')!; assert.equal(button.textContent, 'Read thread'); assert.equal(button.parentElement, back, 'at the end of X\'s own header row'); assert.ok(button.classList.contains('bt-tr-bar')); assert.deepEqual(env.api.commands.list().map(command => [command.id, command.title]), [[`${OWNER}:threadReader.open`, 'Read this thread']]); env.go('/home'); assert.equal(root.querySelectorAll('.bt-tr-open').length, 0, 'not on a post page: the button and the command are gone'); assert.deepEqual(env.api.commands.list(), []); env.registry.dispose(); }); // ---- Collapse posts from verified accounts (was Hide Blue Checks) --------------------------------- type OldChecks = { DEFAULTS: CollapseSettings; shouldHide(settings: CollapseSettings, ctx: Record): boolean; isUnknown(settings: CollapseSettings, user: unknown): boolean; parseAllowlist(source: string): string[]; }; test('Collapse verified: the plugin\'s settings and, away from a profile or an opened post, exactly its decisions', () => { const Old = oldPlugin('hide-blue-checks.plugin.js'); assert.deepEqual(normalizeSettings(COLLAPSE_SCHEMA, null), Old.DEFAULTS, 'someone who enabled the plugin and never opened its settings sees no change'); const stored = { hideBlue: false, hideBusiness: true, hideGovernment: true, keepFollowed: false, hideQuotes: true, allowlist: ['jack', 'someone'] }; assert.deepEqual(normalizeSettings(COLLAPSE_SCHEMA, stored), stored, 'what the plugin stored is read as it is'); assert.equal(MODULES.find(module => module.id === 'reader.collapseVerified')!.storage, 'hide-blue-checks'); const toggles = ['hideBlue', 'hideBusiness', 'hideGovernment', 'keepFollowed', 'hideQuotes'] as const; let compared = 0; for (let bits = 0; bits < 2 ** toggles.length; bits += 1) { for (const allowlist of [[], ['Someone']]) { const settings = { ...Object.fromEntries(toggles.map((key, index) => [key, !!(bits & (1 << index))])), allowlist } as CollapseSettings; for (const blue of [true, false, null]) for (const verifiedType of ['None', 'Business', 'Government', null]) for (const following of [true, false, null]) for (const viewer of [null, 'me', 'SomeOne']) { const ctx = { handle: 'someone', blue, verifiedType, following, viewer }; assert.equal(shouldCollapse(settings, ctx), Old.shouldHide(settings, ctx), JSON.stringify({ settings, ctx })); // On someone else's profile, and for a post that is not the opened one, nothing changes either. assert.equal(shouldCollapse(settings, { ...ctx, profile: 'other', focal: false }), Old.shouldHide(settings, ctx)); compared += 1; } for (const user of [null, { blue: true, verifiedType: 'None', following: false }, { blue: null, verifiedType: 'None', following: false }, { blue: true, verifiedType: null, following: true }, { blue: false, verifiedType: 'None', following: null }]) { assert.equal(isUnknown(settings, user as never), Old.isUnknown(settings, user)); } } } assert.equal(compared, 32 * 2 * 3 * 4 * 3 * 3); const list = '@Jack\n\ntoo-many-symbols!\njack\n bad handle \n@someone_else'; assert.deepEqual(parseAllowlist(list), Old.parseAllowlist(list)); }); test('fix: the owner of the visited profile and the opened post are never collapsed (pure)', () => { const settings = normalizeSettings(COLLAPSE_SCHEMA, null) as CollapseSettings; const verified = { handle: 'SomeOne', blue: true, verifiedType: 'None', following: false, viewer: 'me' }; assert.ok(shouldCollapse(settings, verified), 'in a timeline: collapsed'); assert.ok(!shouldCollapse(settings, { ...verified, profile: 'someone' }), 'on their own profile, whatever the case of the handle'); assert.ok(!shouldCollapse(settings, { ...verified, profile: 'SOMEONE' })); assert.ok(shouldCollapse(settings, { ...verified, profile: 'other' }), 'on another profile (a repost): the normal rule'); assert.ok(!shouldCollapse(settings, { ...verified, focal: true }), 'the post the page is about'); // Every tab of a profile is that profile; a post page and X's own pages are not. for (const path of ['/SomeOne', '/someone/', '/someone/with_replies', '/someone/media', '/someone/highlights', '/someone/articles', '/someone/likes']) assert.equal(visitedProfile(path), 'someone', path); for (const path of ['/home', '/explore', '/i/bookmarks', '/someone/status/1', '/search', '/notifications', '/settings/account', '/someone/followers']) assert.equal(visitedProfile(path), null, path); assert.deepEqual(['/someone/status/42', '/someone/status/42/', '/someone/status/42/photo/1', '/someone', '/home'].map(focalPost), ['42', '42', '42', null, null]); }); const VERIFIED: Users = { verified: { blue: true, verifiedType: 'None', following: false }, other: { blue: true, verifiedType: 'None', following: false }, plain: { blue: false, verifiedType: 'None', following: false } }; test('Collapse verified in a timeline: a known verified stranger collapses into a stub; you, the unknown and the unverified stay', () => { const cells = [post({ id: '1', handle: 'verified' }), post({ id: '2', handle: 'plain' }), post({ id: '3', handle: 'stranger' }), post({ id: '4', handle: 'me' })]; const env = readerPage('/home', body('me', cells), { ...VERIFIED, me: { blue: true, verifiedType: 'None', following: false } }); env.on({ 'reader.collapseVerified': true }); assert.deepEqual(cells.map(cell => collapsed(cell, 'data-bt-hidden-check')), [true, false, false, false]); const stub = env.root.querySelector('.bt-hbc-stub')!; assert.equal(stub.textContent, 'Post hidden by Hide Blue Checks. Click to show.'); assert.equal(stub.nextElementSibling, articleOf(cells[0]!)); // X's responses describe the stranger: a verified account you do not follow. env.learn({ stranger: { blue: true, verifiedType: 'None', following: false } }); assert.deepEqual(cells.map(cell => collapsed(cell, 'data-bt-hidden-check')), [true, false, true, false], 'checked again once X described the author'); stub.click(); assert.ok(!collapsed(cells[0]!, 'data-bt-hidden-check') && articleOf(cells[0]!).hasAttribute('data-bt-hidden-check-shown')); env.on({}); assert.equal(env.root.querySelectorAll('.bt-hbc-stub, article[data-bt-hidden-check], article[data-bt-hidden-check-shown]').length, 0, 'off: every post back'); env.registry.dispose(); // While the signed-in account is unknown nothing is collapsed. const unknown = readerPage('/home', body(null, [post({ id: '1', handle: 'verified' })]), VERIFIED); unknown.on({ 'reader.collapseVerified': true }); assert.equal(unknown.root.querySelectorAll('article[data-bt-hidden-check]').length, 0); unknown.registry.dispose(); }); test('fix: on a profile its owner\'s posts and quotes stay, on every tab; a repost follows the normal rule; leaving judges again', () => { const own = post({ id: '1', handle: 'Verified' }); const quote = post({ id: '2', handle: 'verified', quoted: 'other' }); const repost = post({ id: '3', handle: 'other', reposted: true }); const cells = [own, quote, repost]; const env = readerPage('/verified', body('me', cells), VERIFIED); env.store.set('bt-plugin:hide-blue-checks:settings', JSON.stringify({ hideBlue: true, hideBusiness: false, hideGovernment: false, keepFollowed: true, hideQuotes: true, allowlist: [] })); env.on({ 'reader.collapseVerified': true }); const state = () => cells.map(cell => collapsed(cell, 'data-bt-hidden-check')); assert.deepEqual(state(), [false, false, true], 'the owner and their quote of a verified account stay; what they reposted from one is collapsed'); for (const tab of ['/verified/with_replies', '/VERIFIED/media', '/verified/highlights', '/verified/articles']) { env.go(tab); assert.deepEqual(state(), [false, false, true], tab); } // X keeps these posts on screen while the page becomes Home: they are judged for the new page. env.go('/home'); assert.deepEqual(state(), [true, true, true], 'away from the profile: the normal rule'); assert.equal(env.root.querySelectorAll('.bt-hbc-stub').length, 3); env.go('/verified'); assert.deepEqual(state(), [false, false, true], 'and back'); assert.equal(env.root.querySelectorAll('.bt-hbc-stub').length, 1); env.registry.dispose(); }); test('fix: on a post page the opened post stays; the same author\'s other posts and the replies follow the normal rule', () => { const parent = post({ id: '5', handle: 'verified' }); const focal = post({ id: '7', handle: 'verified', focal: true, quoted: 'other' }); const reply = post({ id: '8', handle: 'other' }); const cells = [parent, focal, reply, post({ id: '9', handle: 'plain' })]; const env = readerPage('/verified/status/7', body('me', cells), VERIFIED); env.store.set('bt-plugin:hide-blue-checks:settings', JSON.stringify({ hideBlue: true, hideBusiness: false, hideGovernment: false, keepFollowed: true, hideQuotes: true, allowlist: [] })); env.on({ 'reader.collapseVerified': true }); const state = () => cells.map(cell => collapsed(cell, 'data-bt-hidden-check')); assert.deepEqual(state(), [true, false, true, false]); env.go('/verified/status/7/photo/1'); assert.deepEqual(state(), [true, false, true, false], 'its photo viewer is the same post'); env.go('/verified/status/5'); assert.deepEqual(state(), [false, true, true, false], 'another post opened: that one stays instead'); env.registry.dispose(); }); // ---- New with Reader+ ---------------------------------------------------------------------------- test('Read marker: what is stored, which post you are at, and when a position may replace the marker (pure)', () => { assert.deepEqual(readMarkers({ tab0: { id: '12', time: 5 }, tab1: { id: 'x', time: 5 }, other: { id: '1', time: 1 }, tab2: { id: '3' }, tab3: null }), { tab0: { id: '12', time: 5 } }); for (const damaged of [null, 'text', 7, [], undefined]) assert.deepEqual(readMarkers(damaged), {}); assert.deepEqual([tabKey([true, false]), tabKey([false, true]), tabKey([false, false, true]), tabKey([false, false]), tabKey([])], ['tab0', 'tab1', 'tab2', null, null]); const posts = [{ id: '1', bottom: -200 }, { id: '2', bottom: 40 }, { id: '3', bottom: 300 }, { id: '4', bottom: 700 }]; assert.equal(readingPost(posts), '3', 'the first post that reaches below X\'s header'); assert.equal(readingPost([{ id: '1', bottom: -5 }]), null); const marker = { id: '9', time: 1 }; assert.ok(maySave(900, marker, false), 'somewhere in the timeline'); assert.ok(!maySave(0, marker, false), 'at the top, before the marker was reached: it is kept'); assert.ok(maySave(0, marker, true), 'caught up'); assert.ok(maySave(0, null, false), 'no marker yet'); }); test('Read marker: the post you left off at carries the line, a scroll saves where you are per Home tab, and off leaves nothing', () => { const cells = [post({ id: '30', handle: 'a' }), post({ id: '20', handle: 'b', promoted: true }), post({ id: '10', handle: 'c' })]; const env = readerPage('/home', body('me', cells, [false, true])); env.store.set('bt-plugin:reader-plus.readMarker:markers', JSON.stringify({ tab0: { id: '30', time: 1 }, tab1: { id: '10', time: 1 } })); articleOf(cells[2]!).rect = { top: 2000, bottom: 2200 }; env.quietly(() => env.on({ 'reader.readMarker': true })); assert.equal(articleOf(cells[2]!).getAttribute('data-bt-reader-marker'), 'You left off here', 'the marker of the selected tab (Following)'); assert.ok(!articleOf(cells[0]!).hasAttribute('data-bt-reader-marker'), 'the other tab\'s marker is not this timeline\'s'); env.api.commands.run(`${OWNER}:readMarker.jump`); assert.equal(articleOf(cells[2]!).scrolled, 1); // At the top of Home the marker is kept until it has been reached. const timers: Array<() => void> = []; Object.assign(env.win, { setTimeout: (fn: () => void) => { timers.push(fn); return timers.length; } }); const scroll = (y: number) => { Object.assign(env.win, { scrollY: y }); env.win.emit('scroll'); timers.splice(0).forEach(fn => fn()); }; const markers = () => JSON.parse(env.store.get('bt-plugin:reader-plus.readMarker:markers')!); articleOf(cells[0]!).rect = { top: 60, bottom: 300 }; scroll(0); assert.deepEqual(markers().tab1, { id: '10', time: 1 }); // Further down: the first post under X's header is where you are. An ad is never a place. articleOf(cells[0]!).rect = { top: -400, bottom: -100 }; articleOf(cells[1]!).rect = { top: -100, bottom: 200 }; articleOf(cells[2]!).rect = { top: 200, bottom: 500 }; scroll(900); assert.equal(markers().tab1.id, '10'); assert.deepEqual(markers().tab0, { id: '30', time: 1 }, 'the other tab keeps its own'); articleOf(cells[0]!).rect = { top: -100, bottom: 200 }; scroll(400); assert.equal(markers().tab1.id, '30'); assert.equal(articleOf(cells[2]!).getAttribute('data-bt-reader-marker'), 'You left off here', 'the line stays where this visit began'); env.on({}); assert.equal(env.root.querySelectorAll('article[data-bt-reader-marker]').length, 0); assert.deepEqual(env.live(), []); env.registry.dispose(); }); test('Language filter: only a language X named, that you do not read, on a text long enough to judge (pure)', () => { assert.deepEqual(['pt-BR', 'EN', 'zh_Hant', 'in', 'iw', 'und', 'qme', 'zxx', '', 'x-klingon-long', null].map(languageOf), ['pt', 'en', 'zh', 'id', 'he', '', '', '', '', '', '']); assert.deepEqual([...readLanguages(['EN', 'tr-TR', 'nonsense!'])], ['en', 'tr']); // No list, no guess (live: the engine's en-US accept-language folded a Turkish reader's posts). assert.deepEqual([...readLanguages([])], [], 'an empty list: an empty set, so nothing is collapsed'); const read = new Set(['en', 'tr']); const long = 'これは日本語で書かれた投稿です。'; assert.ok(shouldCollapseLanguage('ja', long, read)); assert.ok(!shouldCollapseLanguage('en', 'An English post of some length.', read)); assert.ok(!shouldCollapseLanguage('tr', 'Türkçe yazılmış bir gönderi.', read)); for (const unknown of ['und', 'qme', 'zxx', 'qht', '', null]) assert.ok(!shouldCollapseLanguage(unknown, long, read), `unknown never hides: ${unknown}`); assert.ok(!shouldCollapseLanguage('ja', 'すごい', read), 'too short to trust the language X guessed'); assert.ok(!shouldCollapseLanguage('ja', long, new Set()), 'no language known to be read: nothing is collapsed'); }); test('Language filter: foreign posts collapse into a stub; yours, the opened post and the visited profile stay', () => { const japanese = 'これは日本語で書かれた投稿です。'; const cells = [ post({ id: '1', handle: 'a', text: japanese, lang: 'ja' }), post({ id: '2', handle: 'b' }), post({ id: '3', handle: 'c', text: japanese, lang: 'und' }), post({ id: '4', handle: 'me', text: japanese, lang: 'ja' }), post({ id: '5', handle: 'd', text: '' }), post({ id: '6', handle: 'e', quoted: 'a' }), ]; cells[5]!.querySelector('[role="link"] [data-testid="tweetText"]')!.setAttribute('lang', 'ja'); cells[5]!.querySelector('[role="link"] [data-testid="tweetText"]')!.textContent = japanese; const env = readerPage('/home', body('me', cells)); const state = () => cells.map(cell => collapsed(cell, 'data-bt-reader-lang')); // Nothing listed: nothing is folded, whatever X's or the engine's language says (the page says // "en", the engine's accept-language "de": neither stands in for the reader). (env.win as unknown as { navigator: unknown }).navigator = { languages: ['de'] }; env.on({ 'reader.languageFilter': true }); assert.deepEqual(state(), [false, false, false, false, false, false], 'an empty list folds nothing'); env.on({}); // A reader of English: the Japanese post folds. env.store.set('bt-plugin:reader-plus.languageFilter:settings', JSON.stringify({ languages: ['en'] })); env.on({ 'reader.languageFilter': true }); assert.deepEqual(state(), [true, false, false, false, false, false], 'not: no language, your own, no text, an English post quoting a Japanese one'); const stub = env.root.querySelector('.bt-reader-lang-stub')!; assert.equal(stub.textContent, 'Post in Japanese hidden by the language filter. Click to show.'); stub.click(); assert.deepEqual(state(), [false, false, false, false, false, false]); assert.ok(articleOf(cells[0]!).hasAttribute('data-bt-reader-lang-shown')); env.on({}); assert.equal(env.root.querySelectorAll('.bt-reader-lang-stub, article[data-bt-reader-lang], article[data-bt-reader-lang-shown]').length, 0); // A reader of Japanese only: the list in its settings decides. env.store.set('bt-plugin:reader-plus.languageFilter:settings', JSON.stringify({ languages: ['ja'] })); env.on({ 'reader.languageFilter': true }); assert.deepEqual(state(), [false, true, false, false, false, true]); env.go('/b/status/2'); assert.deepEqual(state(), [false, false, false, false, false, true], 'the post you opened stays'); env.go('/E/media'); assert.deepEqual(state(), [false, true, false, false, false, false], 'the profile you are visiting stays'); env.registry.dispose(); const signedOut = readerPage('/home', body(null, [post({ id: '1', handle: 'a', text: japanese, lang: 'ja' })])); signedOut.on({ 'reader.languageFilter': true }); assert.equal(signedOut.root.querySelectorAll('article[data-bt-reader-lang]').length, 0, 'while the account is unknown nothing is collapsed'); signedOut.registry.dispose(); }); test('Quick mute: a selection becomes one literal line of the Muted Regex list (pure)', () => { assert.equal(quickMuteLine(' crypto giveaway \n'), 'crypto\\s+giveaway'); assert.equal(quickMuteLine('#ad'), '\\#ad', 'a leading # would be a comment'); assert.equal(quickMuteLine('/win/'), '\\/win\\/', 'never read as /pattern/flags'); assert.equal(quickMuteLine('1+1=2 (really?)'), '1\\+1=2\\s+\\(really\\?\\)'); for (const bad of ['', ' ', 'a', 'x'.repeat(121), null, undefined]) assert.equal(quickMuteLine(bad), null); // Each line matches its own selection, as text, and nothing it did not say. for (const text of ['crypto giveaway', '#ad', '/win/', '1+1=2 (really?)', 'a.b', '[x]']) { const { patterns, invalid } = parsePatterns(quickMuteLine(text)!); assert.deepEqual(invalid, []); assert.equal(patterns.length, 1, text); assert.ok(patterns[0]!.pattern.test(`before ${text.toUpperCase()} after`), text); } assert.ok(!parsePatterns(quickMuteLine('a.b')!).patterns[0]!.pattern.test('axb')); assert.equal(appendPattern('', 'crypto'), 'crypto\n'); assert.equal(appendPattern('# mine\nspam', 'crypto'), '# mine\nspam\ncrypto\n'); assert.equal(appendPattern('spam\n', 'crypto'), 'spam\ncrypto\n'); assert.equal(appendPattern('spam\n crypto \n', 'crypto'), null, 'already on the list'); }); test('Quick mute: Alt+Shift+U adds the selection to the list the plugin kept, and a running Muted Regex collapses at once', () => { const spam = post({ id: '1', handle: 'someone', text: 'Huge crypto giveaway today' }); const fine = post({ id: '2', handle: 'other', text: 'a walk in the park' }); const env = readerPage('/home', body('me', [spam, fine])); env.store.set('bt-plugin:muted-regex:patterns', JSON.stringify('# mine\nspam')); const list = () => JSON.parse(env.store.get('bt-plugin:muted-regex:patterns')!) as string; const press = () => { let prevented = false; env.doc.emit('keydown', { key: 'U', code: 'KeyU', altKey: true, shiftKey: true, ctrlKey: false, metaKey: false, target: null, preventDefault() { prevented = true; }, stopPropagation() {} }); return prevented; }; env.quietly(() => { env.on({ 'reader.quickMute': true, 'reader.mutedRegex': true }); assert.ok(press()); assert.deepEqual(env.toasts, ['Select a word or a phrase first.']); assert.equal(list(), '# mine\nspam', 'nothing selected: nothing added'); env.select('crypto giveaway'); assert.ok(press()); assert.equal(list(), '# mine\nspam\ncrypto\\s+giveaway\n'); assert.deepEqual([spam, fine].map(cell => collapsed(cell, 'data-bt-muted')), [true, false], 'Muted Regex read the list again'); env.api.commands.run(`${OWNER}:quickMute.add`); assert.equal(list(), '# mine\nspam\ncrypto\\s+giveaway\n', 'not twice'); assert.equal(env.toasts.at(-1), '“crypto giveaway” is already on the Muted Regex list.'); // Muted Regex off: the line is still saved, for when it is turned on. env.on({ 'reader.quickMute': true }); assert.ok(!collapsed(spam, 'data-bt-muted')); env.select('park'); env.api.commands.run(`${OWNER}:quickMute.add`); assert.equal(list(), '# mine\nspam\ncrypto\\s+giveaway\npark\n'); env.on({ 'reader.mutedRegex': true }); assert.deepEqual([spam, fine].map(cell => collapsed(cell, 'data-bt-muted')), [true, true]); }); env.registry.dispose(); });