// Compose+ (src/areas/compose): Alt-text Reminder and Reply Guard as feature modules, compared with // the plugins they replace; the Drafts button and the Emoji Picker where they stay for now; the new // "Posting as" chip, length meter and snippets on a small composer DOM. The table-level checks // (every module starts and stops clean, one observer, the budget) are in areas.test.ts. import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; import { GENERIC_ALT, missingAlt } from '../src/areas/compose/altTextReminder.ts'; import { insertText, targetComposer } from '../src/areas/compose/composer.ts'; import { FEATURES, POWER } from '../src/areas/compose/features.ts'; import { MODULES } from '../src/areas/compose/index.ts'; import { LENGTH_SCHEMA, measure, weightedLength } from '../src/areas/compose/lengthMeter.ts'; import { accountHue } from '../src/areas/compose/postingAs.ts'; import { REPLY_GUARD_SCHEMA, formatAge, replyReason, type ReplyGuardSettings } from '../src/areas/compose/replyGuard.ts'; import { SNIPPETS_SCHEMA, SNIPPET_LIMITS, expansionAt, parseSnippets, snippetLabel } from '../src/areas/compose/snippets.ts'; import { X_TABLE } from '../src/areas/table.ts'; import { normalizeSettings } from '../../../waybetter/packages/runtime/src/settingsForm.ts'; import { areaPage, flagsWith, messages } from './helpers/areaPage.ts'; const BUNDLED = new URL('../src-tauri/bundled-plugins/', import.meta.url); const OWNER = 'bt-compose-plus.plugin.js'; /** The old plugin, loaded the way native code loads it, to compare the port against. */ function oldPlugin(file: string): Record { const module = { exports: {} as unknown }; new Function('module', 'exports', readFileSync(new URL(file, BUNDLED), 'utf8'))(module, module.exports); return module.exports as Record; } /** A module's word, as `ctx.text` gives it in English (plurals resolved for the counts used here). */ const words = (namespace: string) => (key: string, params: Record = {}) => (messages[`plugin.${namespace}.${key}`] ?? key) .replace(/\{(\w+), plural, one \{([^{}]*)\} other \{([^{}]*)\}\}/g, (_, name: string, one: string, other: string) => (Number(params[name]) === 1 ? one : other).replace('#', String(params[name]))) .replace(/\{(\w+)\}/g, (match, name: string) => (name in params ? String(params[name]) : match)); /** The flags with every other area switched off: what runs is Compose+'s alone, whatever the other areas turn on by default. */ const only = (patch: Record = {}) => flagsWith({ ...Object.fromEntries(X_TABLE.areas.filter(area => area.id !== 'compose').map(area => [`area.${area.id}`, false])), ...patch }); const silently =(run: () => T): T => { const original = console.error; console.error = () => {}; try { return run(); } finally { console.error = original; } }; test('Compose+: the Drafts button stays in the runtime, the Emoji Picker in its file, the rest are modules', () => { const byId = Object.fromEntries(FEATURES.map(feature => [feature.id, feature])); assert.deepEqual([byId.composerDrafts!.backing, byId.composerDrafts!.default], ['app', true], 'the flag and its default are unchanged'); assert.deepEqual([byId['compose.emojiPicker']!.backing, byId['compose.emojiPicker']!.file], ['plugin', 'bt-emoji-picker.plugin.js']); assert.deepEqual(MODULES.map(module => module.id).sort(), FEATURES.filter(feature => feature.backing === 'module').map(feature => feature.id).sort()); for (const [flag, file] of [['compose.altTextReminder', 'bt-alt-text-reminder.plugin.js'], ['compose.replyGuard', 'bt-reply-guard.plugin.js']] as const) { assert.equal(byId[flag]!.supersedes, file); assert.equal(byId[flag]!.default, false, 'what was an opt-in plugin stays off'); } // Power User never turns on something that holds a click back. assert.ok(!POWER.includes('compose.replyGuard')); // The area's switch takes the runtime-backed Drafts button with it. assert.equal(flagsWith({})['composerDrafts'], true); assert.equal(flagsWith({ 'area.compose': false })['composerDrafts'], false); }); test('Alt-text Reminder: counts the same images as the plugin it replaces', () => { const Old = oldPlugin('alt-text-reminder.plugin.js') as unknown as { prototype: { missing(root: unknown): number } }; const img = (alt: string | null) => ({ getAttribute: () => alt }); const root = (alts: Array) => ({ querySelectorAll: (selector: string) => (selector === '[data-testid="attachments"] img' ? alts.map(img) : []) }); for (const alts of [[], [null], ['Image'], [' image ', 'A cat on a sofa'], ['Görsel', '画像', 'photo', 'Photo of the harbour'], ['', 'media', 'Bild', 'x']]) { const expected = Old.prototype.missing(root(alts)); assert.equal(missingAlt(root(alts) as unknown as ParentNode), expected, JSON.stringify(alts)); } assert.equal(missingAlt(root(['Image', 'image', 'A described one']) as unknown as ParentNode), 2); assert.ok(!GENERIC_ALT.test('Image of a boat'), 'a description that starts with a generic word counts as one'); // Its words are the plugin's own keys. assert.equal(words('alt-text-reminder')('missing', { count: 1 }), '1 image has no description (alt text)'); assert.equal(words('alt-text-reminder')('headsUp', { count: 2 }), 'Heads up: 2 images without alt text'); }); test('Alt-text Reminder: only where a composer can be; reads a click on Post and never stops it', () => { const env = areaPage('/home'); const idle = env.listeners(); env.registry.setFlags(only({ 'compose.altTextReminder': true })); assert.deepEqual(env.registry.active(), ['compose.altTextReminder']); assert.deepEqual(env.live(), ['wbf-compose.altTextReminder']); assert.deepEqual(env.api.commands.list(), [], 'no settings, no command'); // Post with two undescribed images in the open dialog: one toast, the click is left alone. const images = [{ getAttribute: () => 'Image' }, { getAttribute: () => 'A described one' }, { getAttribute: () => '' }]; const dialog = { querySelectorAll: () => images }; const button = { closest: (selector: string) => (selector === '[role="dialog"]' ? dialog : null) }; let touched = 0; const click = (target: unknown) => env.doc.emit('click', { target, preventDefault() { touched += 1; }, stopPropagation() { touched += 1; }, stopImmediatePropagation() { touched += 1; } }); click({ closest: (selector: string) => (selector.includes('tweetButton') ? button : null) }); assert.deepEqual(env.toasts, ['Heads up: 2 images without alt text']); click({ closest: () => null }); assert.equal(env.toasts.length, 1, 'a click elsewhere says nothing'); assert.equal(touched, 0, 'the click on Post goes on to X untouched'); // X's composer page keeps it running; notifications and settings have no composer. env.go('/compose/post'); assert.deepEqual(env.registry.active(), ['compose.altTextReminder']); env.go('/notifications'); assert.deepEqual(env.registry.active(), []); assert.deepEqual(env.live(), []); assert.equal(env.listeners(), idle); env.registry.dispose(); }); test('Reply Guard: the same settings and the same question as the plugin it replaces', () => { const Old = oldPlugin('reply-guard.plugin.js') as unknown as { DEFAULTS: ReplyGuardSettings; normalize(saved: unknown): ReplyGuardSettings; formatAge(days: number): string; reason(settings: ReplyGuardSettings, context: unknown): string | null; }; const text = words('reply-guard'); assert.deepEqual(normalizeSettings(REPLY_GUARD_SCHEMA, null), Old.DEFAULTS, 'someone who enabled the plugin and never opened its settings sees no change'); // Everything the plugin could have stored reads back the same; a damaged store takes the defaults. for (const saved of [{ oldDays: 0, strangers: true }, { oldDays: 3650, strangers: false }, { oldDays: 7 }, { strangers: true }, { oldDays: 99999, strangers: 'yes' }, { oldDays: -4 }, 'broken', null]) { assert.deepEqual(normalizeSettings(REPLY_GUARD_SCHEMA, saved), Old.normalize(saved), JSON.stringify(saved)); } for (const days of [0.2, 1, 45, 59.9, 60, 364, 365, 800, 4000]) assert.equal(formatAge(days, text), Old.formatAge(days), `${days} days`); const now = Date.parse('2026-09-30T12:00:00Z'); const ago = (days: number) => new Date(now - days * 24 * 60 * 60 * 1000).toISOString(); let asked = 0; for (const settings of [{ oldDays: 30, strangers: false }, { oldDays: 30, strangers: true }, { oldDays: 0, strangers: true }, { oldDays: 0, strangers: false }]) { for (const time of [ago(1), ago(29.9), ago(30), ago(400), null, 'not a date']) { for (const following of [true, false, null]) { for (const own of [false, true]) { const context = { time, now, handle: 'someone', following, own }; const why = replyReason(settings, context, text); assert.equal(why, Old.reason(settings, context), JSON.stringify({ settings, context })); if (why) asked += 1; if (own || (following !== false && (!time || time === 'not a date'))) assert.equal(why, null, 'your own post, or nothing known: never asks'); } } } } assert.ok(asked > 0); assert.equal(replyReason({ oldDays: 30, strangers: true }, { time: ago(400), now, handle: 'someone', following: false, own: false }, text), 'This post is 1 year old. You don\'t follow @someone.'); // Its words are the plugin's own keys (`words: 'reply-guard'`): every label and hint of the form exists. for (const field of REPLY_GUARD_SCHEMA) if (field.type !== 'divider') { assert.ok(messages[`plugin.reply-guard.${field.label}`], field.label); if (field.hint) assert.ok(messages[`plugin.reply-guard.${field.hint}`], field.hint); } }); /** A rendered post as `api.tweet` reads it, with its reply button. */ function post(handle: string, time: string) { const link = { getAttribute: () => `/${handle}/status/1` }; const stamp = { closest: (selector: string) => (selector.includes('/status/') ? link : null), getAttribute: () => time }; let clicks = 0; const article = { closest: () => article, contains: () => false, querySelector: (selector: string) => (selector === '[data-testid="reply"]' ? button : null), querySelectorAll: (selector: string) => (selector === 'time' ? [stamp] : []), }; const button = { isConnected: true, click() { clicks += 1; }, closest: (selector: string) => (selector.includes('article') ? (selector.includes('reply') ? button : article) : null) }; return { article, button, clicks: () => clicks }; } test('Reply Guard: leaves a fresh post alone, never blocks a reply it cannot ask about, never clicks by itself', () => { const env = areaPage('/home'); const idle = env.listeners(); env.store.set('bt-plugin:reply-guard:settings', JSON.stringify({ oldDays: 10, strangers: false })); env.registry.setFlags(only({ 'compose.replyGuard': true })); assert.deepEqual(env.registry.active(), ['compose.replyGuard']); assert.deepEqual(env.api.commands.list().map(command => [command.id, command.title]).sort(), [[`${OWNER}:replyGuard.settings`, 'Reply Guard'], [`${OWNER}:settings`, 'Compose+ settings']]); const day = 24 * 60 * 60 * 1000; const click = (target: unknown) => { let held = 0; silently(() => env.win.emit('click', { target, preventDefault() { held += 1; }, stopImmediatePropagation() { held += 1; } })); return held; }; const fresh = post('someone', new Date(Date.now() - 2 * day).toISOString()); assert.equal(click(fresh.button), 0, 'a post from two days ago: X opens its reply box as usual'); // An old post asks (the decision is compared with the plugin's above). This page has no real DOM, // so the question cannot be drawn: then the click goes on to X, never a reply blocked without a question. const old = post('someone', new Date(Date.now() - 45 * day).toISOString()); assert.equal(click(old.button), 0); // X's "r" on the focused post takes the same path; typing never does. Object.assign(env.doc, { activeElement: fresh.article }); let prevented = 0; silently(() => env.win.emit('keydown', { key: 'r', target: fresh.article, preventDefault() { prevented += 1; }, stopImmediatePropagation() { prevented += 1; } })); assert.equal(prevented, 0); // Nothing is ever clicked for you unless you answered "Reply anyway". assert.equal(fresh.clicks() + old.clicks(), 0); // Anything that is not a reply button is not looked at. assert.equal(click({ closest: () => null }), 0); // The settings stay where the plugin kept them. assert.deepEqual(JSON.parse(env.store.get('bt-plugin:reply-guard:settings')!), { oldDays: 10, strangers: false }); env.registry.setFlags(only({})); assert.deepEqual(env.api.commands.list(), []); assert.equal(env.listeners(), idle); env.registry.dispose(); }); // ---- The new features: a small DOM for X's composer -------------------------------------------- type Compound = { tag: string; attrs: Array<[string, string, string | null]> }; const compound = (source: string): Compound => ({ tag: /^[a-z]+/i.exec(source)?.[0].toLowerCase() ?? '', attrs: [...source.matchAll(/\[([\w-]+)(?:(\^?=)"([^"]*)")?\]/g)].map(match => [match[1]!, match[2] ?? '', match[3] ?? null]), }); class Txt { nodeType = 3; parentElement: El | null = null; nodeValue: string; constructor(text: string) { this.nodeValue = text; } get textContent() { return this.nodeValue; } get isConnected(): boolean { return !!this.parentElement?.isConnected; } } /** An element with what the Compose+ modules use of one: attributes, children, simple selectors. */ class El { nodeType = 1; parentElement: El | null = null; children: Array = []; attrs = new Map(); title = ''; root = false; clicks = 0; style = { cssText: '', props: {} as Record, setProperty(name: string, value: string) { this.props[name] = value; } }; tag: string; constructor(tag: string, attrs: Record = {}, ...children: Array) { this.tag = tag; for (const [name, value] of Object.entries(attrs)) this.attrs.set(name, value); this.append(...children); } get isConnected(): boolean { return this.root || !!this.parentElement?.isConnected; } get elements() { return this.children.filter((child): child is El => child instanceof El); } get firstElementChild() { return this.elements[0] ?? null; } get previousElementSibling() { const all = this.parentElement?.elements ?? []; return all[all.indexOf(this) - 1] ?? null; } get nextElementSibling() { const all = this.parentElement?.elements ?? []; return all[all.indexOf(this) + 1] ?? null; } get textContent(): string { return this.children.map(child => child.textContent).join(''); } set textContent(text: string) { this.replaceChildren(...(text ? [text] : [])); } getAttribute(name: string) { return this.attrs.get(name) ?? null; } hasAttribute(name: string) { return this.attrs.has(name); } setAttribute(name: string, value: string) { this.attrs.set(name, String(value)); } removeAttribute(name: string) { this.attrs.delete(name); } private adopt(nodes: Array) { return nodes.map(node => { const child = typeof node === 'string' ? new Txt(node) : node; if (child instanceof El) child.remove(); child.parentElement = this; return child; }); } append(...nodes: Array) { this.children.push(...this.adopt(nodes)); } prepend(...nodes: Array) { this.children.unshift(...this.adopt(nodes)); } replaceChildren(...nodes: Array) { for (const child of this.children) child.parentElement = null; this.children = []; this.append(...nodes); } before(node: El) { const parent = this.parentElement!; node.remove(); node.parentElement = parent; parent.children.splice(parent.children.indexOf(this), 0, node); } remove() { const parent = this.parentElement; if (!parent) return; parent.children.splice(parent.children.indexOf(this), 1); this.parentElement = null; } contains(node: El | Txt | null): boolean { for (let at: El | Txt | null = node; at; at = at.parentElement) if (at === this) return true; return false; } focus() {} click() { this.clicks += 1; } private is(part: Compound) { return (!part.tag || part.tag === this.tag) && part.attrs.every(([name, op, value]) => { const mine = this.attrs.get(name); return mine !== undefined && (op === '' || (op === '=' ? mine === value : mine.startsWith(value!))); }); } matches(selector: string): boolean { return selector.split(',').some(alternative => { const parts = alternative.trim().split(/\s+/).map(compound); if (!this.is(parts.pop()!)) return false; let at = this.parentElement; for (const part of parts.reverse()) { while (at && !at.is(part)) at = at.parentElement; if (!at) return false; at = at.parentElement; } return true; }); } closest(selector: string): El | null { for (let at: El | null = this; at; at = at.parentElement) if (at.matches(selector)) return at; return null; } querySelectorAll(selector: string): El[] { const out: El[] = []; const walk = (node: El) => { for (const child of node.elements) { if (child.matches(selector)) out.push(child); walk(child); } }; walk(this); return out; } querySelector(selector: string) { return this.querySelectorAll(selector)[0] ?? null; } /** The subtree as markup, to compare before and after. */ html(): string { const attrs = [...this.attrs].map(([name, value]) => ` ${name}="${value}"`).join(''); return `<${this.tag}${attrs}>${this.children.map(child => (child instanceof El ? child.html() : child.nodeValue)).join('')}`; } } const EDITOR = { 'data-testid': 'tweetTextarea_0', role: 'textbox', contenteditable: 'true' }; /** X's page with the account button and one composer (editor, toolbar with Post). */ function composerPage(handle = 'sample') { const block = new El('div', { 'data-block': 'true' }); const editor = new El('div', EDITOR, new El('div', { 'data-contents': 'true' }, block)); const postButton = new El('button', { 'data-testid': 'tweetButtonInline' }, 'Post'); const toolbar = new El('div', { 'data-testid': 'toolBar' }, new El('div', { role: 'tablist' }), postButton); const area = new El('div', {}, new El('div', {}, editor), toolbar); const avatar = new El('div', { 'data-testid': `UserAvatar-Container-${handle}` }); const account = new El('button', { 'data-testid': 'SideNav_AccountSwitcher_Button' }, avatar); const body = new El('body', {}, new El('header', {}, account), new El('main', {}, new El('div', { 'data-testid': 'primaryColumn' }, area))); body.root = true; return { body, editor, block, toolbar, postButton, area, avatar, account }; } type FakeRange = { start?: [unknown, number]; end?: [unknown, number]; contents?: unknown; collapsed?: boolean; commonAncestorContainer?: unknown }; type FakeSelection = { rangeCount: number; isCollapsed: boolean; anchorNode: unknown; anchorOffset: number; ranges: FakeRange[]; getRangeAt(index: number): unknown; removeAllRanges(): void; addRange(range: FakeRange): void; }; /** `areaPage` whose document is `page` (styles still go to the recorded list). */ function composeEnv(page: ReturnType, path = '/home') { const env = areaPage(path); const style = env.doc.createElement as (tag: string) => unknown; const executed: Array<{ text: string; range: FakeRange }> = []; let selection: FakeSelection | null = null; Object.assign(env.doc, { body: page.body, createElement: (tag: string) => (tag === 'style' ? style(tag) : new El(tag)), querySelectorAll: (selector: string) => page.body.querySelectorAll(selector), querySelector: (selector: string) => page.body.querySelector(selector), createRange: () => { const range: FakeRange & Record = { setStart(node: unknown, offset: number) { range.start = [node, offset]; range.commonAncestorContainer = node; }, setEnd(node: unknown, offset: number) { range.end = [node, offset]; }, selectNodeContents(node: unknown) { range.contents = node; range.commonAncestorContainer = node; }, collapse() { range.collapsed = true; }, }; return range; }, execCommand: (command: string, _ui: boolean, text: string) => { assert.equal(command, 'insertText'); executed.push({ text, range: selection!.ranges[0]! }); return true; }, }); Object.assign(env.win, { getSelection: () => selection }); return { ...env, executed, /** A plain caret after `offset` characters of `node`. */ caret(node: Txt, offset: number) { const ranges: FakeRange[] = []; selection = { rangeCount: 1, isCollapsed: true, anchorNode: node, anchorOffset: offset, ranges, getRangeAt: () => ({ cloneRange: () => ({ commonAncestorContainer: node, start: [node, offset] }) }), removeAllRanges() { ranges.length = 0; }, addRange(range) { ranges.push(range); }, }; }, }; } test('length meter: counts the way X counts', () => { const cases: Array<[string, number]> = [ ['', 0], ['hello', 5], ['héllo wörld', 11], ['line one\nline two', 17], ['日本語', 6], ['한국어', 6], ['مرحبا', 5], ['привет', 6], ['👍', 2], ['👍🏽', 2], ['👨‍👩‍👧‍👦', 2], ['🇹🇷', 2], ['#️⃣', 2], ['ok 👍 ok', 8], ['é', 1], ['https://example.com', 23], ['http://a.co', 23], ['see https://example.com/a/very/long/path?with=a&query=string#and-a-fragment now', 4 + 23 + 4], ['example.com', 23], ['read example.org/page.', 5 + 23 + 1], ['(https://example.com)', 1 + 23 + 1], ['two https://a.com https://b.com', 4 + 23 + 1 + 23], ['mail me@example.com', 19], ['v1.2 and a.b are not links', 26], ['end of sentence.Next one', 24], ]; for (const [text, expected] of cases) assert.equal(weightedLength(text), expected, JSON.stringify(text)); assert.equal(weightedLength('a'.repeat(280)), 280); assert.deepEqual(measure('a'.repeat(200), 280), { count: 200, limit: 280, state: 'ok', posts: 1 }); assert.equal(measure('a'.repeat(260), 280).state, 'near'); assert.equal(measure('a'.repeat(280), 280).state, 'near', 'exactly at the limit still fits'); assert.deepEqual(measure('a'.repeat(281), 280), { count: 281, limit: 280, state: 'over', posts: 2 }); assert.equal(measure('日'.repeat(300), 280).posts, 3); assert.equal(measure('a'.repeat(281), 25000).state, 'ok', 'a longer limit is the owner\'s setting'); assert.deepEqual(normalizeSettings(LENGTH_SCHEMA, null), { limit: 280 }); }); test('"Posting as": one colour per account, whatever the case of the handle', () => { assert.equal(accountHue('Sample'), accountHue('sample')); assert.notEqual(accountHue('sample'), accountHue('someone')); for (const handle of ['a', 'sample', 'someone_else', 'A_15_char_handl']) { const hue = accountHue(handle); assert.ok(Number.isInteger(hue) && hue >= 0 && hue < 360, handle); } }); test('"Posting as" and the length meter: one line above the toolbar, kept current, gone without a trace', () => { const page = composerPage('sample'); const before = page.body.html(); const env = composeEnv(page); const idle = env.listeners(); const both = { 'compose.postingAs': true, 'compose.lengthMeter': true }; env.registry.setFlags(only(both)); assert.deepEqual(env.registry.active().sort(), ['compose.lengthMeter', 'compose.postingAs']); const bar = page.toolbar.previousElementSibling!; assert.ok(bar.hasAttribute('data-bt-compose-bar'), 'the line is right above X\'s toolbar'); const chip = bar.querySelector('[data-bt-compose-chip]')!; assert.equal(chip.textContent, 'Posting as @sample'); assert.equal(chip.getAttribute('data-bt-compose-chip'), 'sample'); assert.equal(chip.style.props['--bt-compose-hue'], String(accountHue('sample'))); assert.match(chip.title, /@sample/); assert.equal(bar.querySelector('[data-bt-compose-meter]'), null, 'an empty composer has nothing to measure'); // Typing: the meter follows, in the same line. page.block.textContent = 'hello https://example.com/a/long/link'; env.doc.emit('input', {}); assert.equal(page.body.querySelectorAll('[data-bt-compose-bar]').length, 1, 'both features share the line'); const meter = bar.querySelector('[data-bt-compose-meter]')!; assert.deepEqual([meter.textContent, meter.getAttribute('data-bt-compose-meter')], ['29 / 280', 'ok']); const settled = page.body.html(); env.mutate(); env.mutate(); assert.equal(page.body.html(), settled, 'a re-render of X changes nothing that is already right'); page.block.textContent = 'a'.repeat(300); env.mutate(); assert.deepEqual([meter.textContent, meter.getAttribute('data-bt-compose-meter')], ['300 / 280 · at least 2 posts as a thread', 'over']); // A second line of X's editor counts its line break. page.block.textContent = 'one'; const second = new El('div', { 'data-block': 'true' }, 'two'); page.block.parentElement!.append(second); env.mutate(); assert.equal(meter.textContent, '7 / 280'); // The limit is the owner's setting, stored under the area's namespace. env.store.set('bt-plugin:compose-plus.lengthMeter:settings', JSON.stringify({ limit: 5 })); env.registry.setFlags(only({ 'compose.postingAs': true })); assert.equal(bar.querySelector('[data-bt-compose-meter]'), null, 'the meter off: the chip keeps the line'); env.registry.setFlags(only(both)); assert.equal(bar.querySelector('[data-bt-compose-meter]')!.textContent, '7 / 5 · at least 2 posts as a thread'); // X switched the account in this tab: the chip follows. No account button: no chip, no guess. page.avatar.setAttribute('data-testid', 'UserAvatar-Container-someone'); env.mutate(); assert.equal(bar.querySelector('[data-bt-compose-chip]')!.textContent, 'Posting as @someone'); page.account.remove(); env.mutate(); assert.equal(bar.querySelector('[data-bt-compose-chip]'), null); assert.ok(bar.isConnected, 'the meter still has its line'); // X took the toolbar away: the line goes with it; back again, so is the line. const parent = page.toolbar.parentElement!; page.toolbar.remove(); env.mutate(); assert.equal(page.body.querySelectorAll('[data-bt-compose-bar]').length, 0); parent.append(page.toolbar); page.body.firstElementChild!.append(page.account); env.mutate(); assert.equal(page.body.querySelectorAll('[data-bt-compose-chip], [data-bt-compose-meter]').length, 2); // Only where a composer can be. env.go('/notifications'); assert.deepEqual(env.registry.active(), []); assert.equal(page.body.querySelectorAll('[data-bt-compose-bar]').length, 0); env.go('/compose/post'); assert.equal(page.body.querySelectorAll('[data-bt-compose-chip], [data-bt-compose-meter]').length, 2); // Off: X's page is what it was, and Post was never touched. env.registry.setFlags(only({})); second.remove(); page.block.textContent = ''; page.avatar.setAttribute('data-testid', 'UserAvatar-Container-sample'); assert.equal(page.body.html(), before); assert.deepEqual(env.live(), []); assert.equal(env.listeners(), idle); assert.equal(page.postButton.clicks, 0); env.registry.dispose(); }); test('snippets: lines to snippets, and the shortcut that was just typed', () => { assert.deepEqual(parseSnippets(['sig = Best regards,\\nRobin', 'site=https://waybetter.fyi', 'A line without a name', ' ', 'empty =', 'a name = with spaces is text', `${'x'.repeat(30)} = too long a name`]), [ { name: 'sig', text: 'Best regards,\nRobin' }, { name: 'site', text: 'https://waybetter.fyi' }, { name: '', text: 'A line without a name' }, { name: '', text: 'a name = with spaces is text' }, { name: '', text: `${'x'.repeat(30)} = too long a name` }, ]); const list = parseSnippets(['sig = Best regards', 'Hi-2 = Hello there']); assert.deepEqual(expansionAt(';sig ', list), { back: 5, text: 'Best regards ' }); assert.deepEqual(expansionAt('Thanks! ;SIG ', list), { back: 5, text: 'Best regards ' }); assert.deepEqual(expansionAt('ok ;hi-2 ', list), { back: 6, text: 'Hello there ' }); for (const typed of [';sig', 'sig ', ';nope ', 'a;sig ', ';sig ', ';sig x', '; sig ', '']) assert.equal(expansionAt(typed, list), null, JSON.stringify(typed)); assert.equal(expansionAt(';sig ', []), null); assert.equal(snippetLabel({ name: 'sig', text: 'Best regards,\nRobin' }), ';sig Best regards, Robin'); assert.equal(snippetLabel({ name: '', text: 'x'.repeat(100) }).length, 72); assert.deepEqual(normalizeSettings(SNIPPETS_SCHEMA, null), { items: [], expand: true }); assert.equal((normalizeSettings(SNIPPETS_SCHEMA, { items: Array.from({ length: 300 }, (_, i) => `n${i} = t`) }).items as string[]).length, SNIPPET_LIMITS.items); }); test('snippets: inserted as typed text at the caret, only when asked, never posted', () => { const page = composerPage(); const env = composeEnv(page); const idle = env.listeners(); const win = env.win as unknown as Window & typeof globalThis; env.store.set('bt-plugin:compose-plus.snippets:settings', JSON.stringify({ items: ['sig = Best regards'], expand: true })); env.registry.setFlags(only({ 'compose.snippets': true })); assert.deepEqual(env.api.commands.list().map(command => [command.id, command.title]).sort(), [[`${OWNER}:settings`, 'Compose+ settings'], [`${OWNER}:snippets.insert`, 'Insert a snippet']]); // Typing the shortcut and a space replaces exactly those characters, in the text node the caret is in. const typed = new Txt('Thanks ;sig '); page.block.append(typed); env.caret(typed, 12); env.doc.emit('input', {}); assert.equal(env.executed.length, 1); assert.equal(env.executed[0]!.text, 'Best regards '); assert.deepEqual([env.executed[0]!.range.start, env.executed[0]!.range.end], [[typed, 7], [typed, 12]]); // Anything else typed is left alone; so is a caret outside a composer. typed.nodeValue = 'Thanks ;sigx '; env.caret(typed, 13); env.doc.emit('input', {}); const outside = new Txt(';sig '); page.account.append(outside); env.caret(outside, 5); env.doc.emit('input', {}); assert.equal(env.executed.length, 1); // Turned off in its settings: the list stays for the palette, typing expands nothing. env.store.set('bt-plugin:compose-plus.snippets:settings', JSON.stringify({ items: ['sig = Best regards'], expand: false })); env.registry.setFlags(only({})); env.registry.setFlags(only({ 'compose.snippets': true })); typed.nodeValue = ';sig '; env.caret(typed, 5); env.doc.emit('input', {}); assert.equal(env.executed.length, 1); // The palette's insert goes to the composer the selection is in, at that selection. const target = targetComposer(win)!; assert.equal(target.editor, page.editor as unknown); assert.ok(insertText(win, target, 'Hello')); assert.deepEqual([env.executed[1]!.text, env.executed[1]!.range.start], ['Hello', [typed, 5]]); // No selection in a composer: the only composer on the page, at the end of its text. env.caret(outside, 0); const fallback = targetComposer(win)!; assert.deepEqual([fallback.editor, fallback.range], [page.editor, null]); insertText(win, fallback, 'At the end'); assert.deepEqual([env.executed[2]!.text, env.executed[2]!.range.contents, env.executed[2]!.range.collapsed], ['At the end', page.editor, true]); // Two composers and no caret in either: no guess. const other = new El('div', {}, new El('div', EDITOR)); page.area.parentElement!.append(other); assert.equal(targetComposer(win), null); other.remove(); // A composer that X removed meanwhile takes nothing. page.editor.remove(); assert.equal(insertText(win, fallback, 'late'), false); assert.equal(env.executed.length, 3); assert.equal(page.postButton.clicks, 0, 'Post is never pressed'); env.registry.setFlags(only({})); assert.deepEqual(env.api.commands.list(), []); assert.equal(env.listeners(), idle); env.registry.dispose(); }); test('Compose+: the new features are off until turned on, Power User turns on the calm ones, the palette budget holds', () => { const byId = Object.fromEntries(FEATURES.map(feature => [feature.id, feature])); for (const flag of ['compose.postingAs', 'compose.lengthMeter', 'compose.snippets']) { assert.equal(byId[flag]!.default, false, flag); assert.deepEqual(byId[flag]!.surfaces, ['timeline', 'composer'], flag); assert.ok(POWER.includes(flag), flag); assert.ok(messages[byId[flag]!.label!] && messages[byId[flag]!.description!], flag); } // Every label and hint of the generated settings forms has its word. for (const [name, schema] of [['lengthMeter', LENGTH_SCHEMA], ['snippets', SNIPPETS_SCHEMA]] as const) { for (const field of schema) if (field.type !== 'divider') { assert.ok(messages[`plugin.compose-plus.${name}.${field.label}`], `${name}.${field.label}`); if (field.hint) assert.ok(messages[`plugin.compose-plus.${name}.${field.hint}`], `${name}.${field.hint}`); } } for (const key of ['postingAs.label', 'postingAs.title', 'lengthMeter.count', 'lengthMeter.over', 'snippets.pick', 'snippets.edit', 'snippets.none', 'snippets.noComposer']) assert.ok(messages[`plugin.compose-plus.${key}`], key); // Everything of the area on at once, on a post page: three of the five palette commands. const all = Object.fromEntries(MODULES.map(module => [module.id, true])); const env = areaPage('/someone/status/1'); env.registry.setFlags(only(all)); assert.deepEqual(env.registry.active().sort(), MODULES.map(module => module.id).sort()); assert.deepEqual(env.api.commands.list().map(command => command.id).sort(), [`${OWNER}:replyGuard.settings`, `${OWNER}:settings`, `${OWNER}:snippets.insert`]); env.registry.setFlags(only({ ...all, 'area.compose': false })); assert.deepEqual(env.registry.active(), [], 'the area\'s switch stops all of them'); env.registry.dispose(); // No module makes a request, observes on its own or reaches for the globals. for (const file of ['altTextReminder', 'replyGuard', 'postingAs', 'lengthMeter', 'snippets', 'composer']) { const source = readFileSync(new URL(`../src/areas/compose/${file}.ts`, import.meta.url), 'utf8'); assert.doesNotMatch(source, /\bfetch\(|XMLHttpRequest|sendBeacon|WebSocket|EventSource|\.src\s*=/, `${file}: no request`); assert.doesNotMatch(source, /new MutationObserver|(?