wayBetter for X

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

View file

@ -0,0 +1,642 @@
// Chat+ (src/areas/chat): starred messages with "open at message" and X's own reply, find in
// conversation, pinned chats, the person filter and keywords, over X-shaped chat markup
// (fixtures/x/chat: the survey's test ids, a virtualized list, X's record per row, its two ways to
// position on a message and its message menu; every name and line invented). The table-level checks
// (every module starts and stops clean, one observer, the budget) are in areas.test.ts. Nothing here
// is a live check of x.com.
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import test from 'node:test';
import {
MAX_STARRED, exportStarred, filterStarred, importStarred, isStarred, mergeStarred, normalizeStarred, openStarred, quoteOf, starOf, starredConversations, withStar, withoutStar, type Starred,
} from '../src/areas/chat/bookmarks.ts';
import { FEATURES, POWER } from '../src/areas/chat/features.ts';
import { MODULES } from '../src/areas/chat/index.ts';
import { KEYWORDS_SCHEMA, verdictOf, wordMatcher } from '../src/areas/chat/keywords.ts';
import en from '../locales/en.json' with { type: 'json' };
import words from '../src/areas/chat/messages.en.json' with { type: 'json' };
import { dims, participants } from '../src/areas/chat/personFilter.ts';
import { MAX_PINS, normalizePins, withPin, withoutPin, type Pin } from '../src/areas/chat/pins.ts';
import { searchMessages, stepMatch } from '../src/areas/chat/searchLoaded.ts';
import {
CHAT, chatRoot, conversationKey, conversationOf, findRow, fireScrollToMessage, isGroup, jumpToMessage, loadedMessages, openReply, readRow, replyItem, sequenceTime,
type ChatSelectors,
} from '../src/areas/chat/x.ts';
import { normalizeSettings } from '../../../waybetter/packages/runtime/src/settingsForm.ts';
import { X_TABLE } from '../src/areas/table.ts';
import { areaPage, flagsWith as tableFlags } from './helpers/areaPage.ts';
import { El, Ev, h } from './fixtures/x/chat/miniDom.ts';
import { ADA, FakeChat, ME, MIRA, TOMAS, history, seqAt, traces, type ChatOptions, type Conversation, type Msg } from './fixtures/x/chat/x.ts';
const SRC = new URL('../src/areas/chat/', import.meta.url);
const SEL = CHAT as ChatSelectors;
const STARS = 'bt-plugin:chat-plus.bookmarks:items';
const PINS = 'bt-plugin:chat-plus.pins:pins';
const OWNER = 'bt-chat-plus.plugin.js';
const ALL = Object.fromEntries(FEATURES.map(feature => [feature.id, true]));
const GROUP = 'g1900000000000000001';
const DIRECT = '1000-1001';
const conversations = (): Conversation[] => [
{ id: GROUP, title: 'Harbor Quiz Team', history: history(60, [ADA, TOMAS, ME, MIRA]) },
{ id: DIRECT, title: 'Ada Quill', history: history(12, [ADA, ME], Date.UTC(2026, 6, 2, 8, 0), 500) },
];
/** The flags with only Chat+ on (other areas have features that are on by default and run on chat pages). */
const flagsWith = (patch: Record<string, boolean> = {}) =>
tableFlags({ ...Object.fromEntries(X_TABLE.areas.filter(area => area.id !== 'chat').map(area => [`area.${area.id}`, false])), ...patch });
/** A chat page with X's part played by the fixture; `flags` are the Chat+ features that are on. */
function page(path: string, flags: Record<string, boolean>, options: ChatOptions = {}) {
const env = areaPage(path);
const chat = new FakeChat(env, conversations(), options);
const open = conversationOf(path);
if (open) chat.show(open, null);
env.registry.setFlags(flagsWith(flags));
const win = env.win as unknown as Window & typeof globalThis;
const doc = env.doc as unknown as Document;
return { env, chat, win, doc, stored: <T>(key: string): T => JSON.parse(env.store.get(key) ?? 'null') as T };
}
const pace = (chat: FakeChat) => ({ sleep: (ms: number) => new Promise<void>(resolve => { (chat.env.win.setTimeout as (fn: () => void, ms: number) => unknown)(resolve, ms); }) });
const star = (row: El) => row.querySelector('.bt-chat-star')!;
const group = (chat: FakeChat) => chat.conversations.get(GROUP)!.history;
test('Chat+: five features, each its own flag, all off by default, all allowed on X\'s chat pages', () => {
assert.deepEqual(FEATURES.map(feature => feature.id), ['chat.bookmarks', 'chat.searchLoaded', 'chat.pins', 'chat.personFilter', 'chat.keywords']);
assert.deepEqual(MODULES.map(module => module.id).sort(), FEATURES.map(feature => feature.id).sort());
for (const feature of FEATURES) {
assert.equal(feature.default, false, feature.id);
assert.equal(feature.heavy, true, `${feature.id} runs on the heavy chat pages`);
assert.ok(feature.permissions && !feature.permissions.includes('network'), `${feature.id}: no network`);
}
// The list of starred messages opens from any page; everything else only exists in chats.
assert.deepEqual(FEATURES.filter(feature => !feature.surfaces).map(feature => feature.id), ['chat.bookmarks']);
for (const flag of POWER) assert.ok(FEATURES.some(feature => feature.id === flag), flag);
assert.deepEqual(normalizeSettings(KEYWORDS_SCHEMA, null), { highlight: [], mute: [] }, 'no word list: nothing is highlighted or folded');
});
test('Chat+: every word it shows is in the catalog (or, until the next words step, in messages.en.json); nothing finds X\'s elements by a label', () => {
const catalog = { ...(en.messages as Record<string, string>), ...(words as Record<string, string>) };
for (const file of readdirSync(SRC).filter(name => name.endsWith('.ts'))) {
const source = readFileSync(new URL(file, SRC), 'utf8');
const name = file.replace(/\.ts$/, '');
for (const match of source.matchAll(/ctx\.text\('([A-Za-z]+)'/g)) assert.ok(catalog[`plugin.chat-plus.${name}.${match[1]}`], `${file}: ${match[1]}`);
// X's labels are in X's language: a selector may never read one.
assert.doesNotMatch(source, /\[(?:aria-label|title|placeholder)[*^$~|]?=/, `${file}: no selector on a label`);
// No request, and no MutationObserver of its own (the shared hub's watch is the only one).
assert.doesNotMatch(source, /\bfetch\(|XMLHttpRequest|WebSocket|sendBeacon|new [A-Za-z.]*MutationObserver/, file);
}
for (const color of ['None', 'Blue', 'Green', 'Yellow', 'Orange', 'Red', 'Purple']) assert.ok(catalog[`plugin.chat-plus.pins.color${color}`], color);
for (const key of Object.keys(words)) assert.match(key, /^plugin\.chat-plus\./, key);
});
test('conversations and sequence numbers: ids from the address, time from the sequence', () => {
assert.equal(conversationOf('/i/chat/1000-1001'), '1000-1001');
assert.equal(conversationOf('/messages/1000-1001'), '1000-1001');
assert.equal(conversationOf(`/i/chat/${GROUP}`), GROUP);
assert.equal(conversationOf('/i/chat/1000%3A1001'), '1000-1001');
for (const path of ['/i/chat', '/i/chat/settings', '/i/chat/requests', '/messages/compose', '/home', `/i/chat/${GROUP}/info`]) assert.equal(conversationOf(path), null, path);
assert.equal(conversationKey('1000:1001'), '1000-1001', 'the inbox row\'s id and the address name the same conversation');
assert.equal(conversationKey('javascript:alert(1)'), null);
assert.ok(isGroup(GROUP) && !isGroup(DIRECT));
const at = Date.UTC(2026, 5, 1, 9, 30);
assert.equal(sequenceTime(seqAt(at, 7)), at);
assert.equal(sequenceTime('12'), null);
assert.equal(sequenceTime(null), null);
});
test('a rendered row: id, sequence, sender, text, time, media and what it replies to, from the DOM and X\'s own record', () => {
const first = history(1, [ADA])[0]!;
const reply: Msg = { uuid: 'aaaaaaaa-0000-4000-8000-000000000001', seq: seqAt(Date.UTC(2026, 5, 2, 10, 0), 1), from: TOMAS, text: 'Save me a seat', replyTo: first, media: 'video', clock: '10:00' };
const plain: Msg = { uuid: 'aaaaaaaa-0000-4000-8000-000000000002', seq: seqAt(Date.UTC(2026, 5, 2, 10, 1), 2), from: MIRA, text: '', media: 'image' };
const env = areaPage(`/i/chat/${GROUP}`);
const chat = new FakeChat(env, [{ id: GROUP, title: 'Harbor Quiz Team', history: [first, reply, plain] }]);
chat.show(GROUP, null);
const info = readRow(chat.row(reply.uuid)! as unknown as Element, SEL)!;
assert.deepEqual(info, {
uuid: reply.uuid, seq: reply.seq, senderId: '1002', senderName: 'Tomas Reed', senderHandle: 'tomasreed', text: 'Save me a seat',
time: Date.UTC(2026, 5, 2, 10, 0), timeLabel: '10:00',
// The video's poster is kept (a blob address would be useless later); the sender's picture is not media.
media: [{ type: 'video', url: 'https://ton.x.com/i/ton/data/xchat_media/c1/m1.jpg' }],
reply: { uuid: first.uuid, seq: first.seq, senderId: '1001', senderName: 'Ada Quill', text: first.text },
});
const media = readRow(chat.row(plain.uuid)! as unknown as Element, SEL)!;
assert.deepEqual([media.text, media.media, media.senderHandle, media.reply], ['', [{ type: 'image', url: 'https://ton.x.com/i/ton/data/xchat_media/c1/p1.jpg' }], null, null]);
// X's record of the row unreadable (another build of X): the DOM's part remains, the rest is unknown, not guessed.
const bare = h('div', { 'data-dm-message-row': 'u-1' }, h('span', { id: 'r-sender' }, 'Ada Quill'), h('div', { 'data-testid': 'message-text-u-1' }, h('span', { dir: 'auto' }, 'Hello there')));
const unread = readRow(bare as unknown as Element, SEL)!;
assert.deepEqual([unread.seq, unread.senderId, unread.senderName, unread.text, unread.time], [null, null, 'Ada Quill', 'Hello there', null]);
env.registry.dispose();
});
test('the loaded history: X\'s whole loaded list when its page exposes it, else only the rows on screen', () => {
const { env, chat, doc } = page(`/i/chat/${GROUP}`, {});
assert.equal(chat.rendered().length, 8, 'a window of rows is in the DOM');
const loaded = loadedMessages(doc, SEL);
assert.equal(loaded.complete, true);
assert.equal(loaded.messages.length, 24, 'what X loaded, not just what it rendered');
const all = group(chat);
assert.deepEqual(loaded.messages.map(message => message.uuid), all.slice(36).map(msg => msg.uuid), 'oldest first, each with its id');
assert.deepEqual([loaded.messages[0]!.text, loaded.messages[0]!.senderId, loaded.messages[0]!.seq], [all[36]!.text, all[36]!.from.id, all[36]!.seq]);
env.registry.dispose();
const hidden = page(`/i/chat/${GROUP}`, {}, { exposeList: false });
const partial = loadedMessages(hidden.doc, SEL);
assert.deepEqual([partial.complete, partial.messages.length], [false, 8]);
hidden.env.registry.dispose();
});
test('starred messages: stored in full, valid ones only, each once; the list filters by chat and words', () => {
const info = { uuid: 'u-1', seq: seqAt(Date.UTC(2026, 5, 1, 9, 0)), senderId: '1001', senderName: 'Ada Quill', senderHandle: 'adaquill', text: 'The ferry leaves at six', time: Date.UTC(2026, 5, 1, 9, 0), timeLabel: '09:00', media: [{ type: 'video' as const, url: null }], reply: null };
const one = starOf(info, GROUP, 'Harbor Quiz Team', 5);
assert.deepEqual(one, {
uuid: 'u-1', conversation: GROUP, title: 'Harbor Quiz Team', seq: info.seq, sender: { id: '1001', name: 'Ada Quill', handle: 'adaquill' },
text: 'The ferry leaves at six', time: info.time, timeLabel: '09:00', media: [{ type: 'video', url: null }], reply: null, starredAt: 5,
});
const two = starOf({ ...info, uuid: 'u-2', senderName: 'Tomas Reed', senderHandle: null, text: 'Bring the spare cable' }, DIRECT, 'Tomas Reed', 9);
const list = withStar(withStar([], one), two);
assert.deepEqual(list.map(item => item.uuid), ['u-2', 'u-1'], 'newest star first');
assert.equal(withStar(list, { ...one, starredAt: 20 }).length, 2, 'a message is starred once');
assert.ok(isStarred(list, 'u-1') && !isStarred(withoutStar(list, 'u-1'), 'u-1'));
assert.deepEqual(normalizeStarred([one, null, { uuid: 'x' }, { ...one, conversation: 'not a chat' }, 'text', { ...two, seq: 'abc' }]).map(item => [item.uuid, item.seq]), [['u-2', null], ['u-1', info.seq]], 'damaged entries are dropped, a bad sequence is no sequence');
assert.equal(normalizeStarred(Array.from({ length: MAX_STARRED + 50 }, (_, index) => ({ ...one, uuid: `u-${index}` }))).length, MAX_STARRED);
assert.deepEqual(filterStarred(list, 'ferry', '').map(item => item.uuid), ['u-1']);
assert.deepEqual(filterStarred(list, 'TOMAS cable', '').map(item => item.uuid), ['u-2'], 'every word, any case, text or sender');
assert.deepEqual(filterStarred(list, '', GROUP).map(item => item.uuid), ['u-1']);
assert.deepEqual(filterStarred(list, 'ferry', DIRECT), []);
assert.deepEqual(starredConversations(list), [{ id: DIRECT, title: 'Tomas Reed' }, { id: GROUP, title: 'Harbor Quiz Team' }]);
assert.equal(quoteOf(one, 'Jun 1, 2026, 9:00 AM'), 'Ada Quill (@adaquill), Jun 1, 2026, 9:00 AM:\n> The ferry leaves at six');
assert.equal(quoteOf({ ...one, text: '', sender: { id: null, name: '', handle: null } }, ''), '> [video]');
// Starred messages are in no backup yet: Export is a text the owner keeps, Import takes it back, validated.
assert.deepEqual(importStarred(exportStarred(list)), list);
assert.deepEqual(importStarred(JSON.stringify([one, { uuid: 'bad' }])), [one], 'a bare list works too; what is not a starred message is dropped');
for (const text of ['', 'not json', '{}', '{"kind":"something-else","items":[]}', '"text"', '42']) assert.equal(importStarred(text), null, text);
assert.deepEqual(mergeStarred([one], [{ ...one, text: 'changed' }, two]).map(item => [item.uuid, item.text]), [['u-2', two.text], ['u-1', one.text]], 'a message starred already stays as it is');
});
test('Starred messages: a star on every rendered row; a click stores exactly that message, in full; off leaves nothing', () => {
const { env, chat, stored } = page(`/i/chat/${GROUP}`, { 'chat.bookmarks': true });
assert.deepEqual(env.registry.active(), ['chat.bookmarks']);
const all = group(chat);
const rows = chat.rendered();
assert.equal(chat.body.querySelectorAll('.bt-chat-star').length, rows.length, 'one star per rendered row');
assert.equal(env.store.has(STARS), false, 'reading a chat stores nothing');
const target = all[55]!;
const button = star(chat.row(target.uuid)!);
assert.equal(button.tagName, 'BUTTON', 'a real button: reachable with the keyboard');
assert.equal(button.getAttribute('aria-label'), 'Star this message');
button.click();
const saved = stored<Starred[]>(STARS);
assert.equal(saved.length, 1);
assert.deepEqual({ ...saved[0]!, starredAt: 0 }, {
uuid: target.uuid, conversation: GROUP, title: 'Harbor Quiz Team', seq: target.seq, sender: { id: target.from.id, name: target.from.name, handle: null },
text: target.text, time: sequenceTime(target.seq), timeLabel: target.clock, media: [], reply: null, starredAt: 0,
});
assert.deepEqual(env.toasts, ['Message starred']);
assert.equal(star(chat.row(target.uuid)!).getAttribute('aria-pressed'), 'true');
assert.ok(chat.row(target.uuid)!.hasAttribute('data-bt-chat-starred'));
// The header of the chat and of the inbox each get the list's button.
assert.equal(chat.body.querySelectorAll('[data-bt-chat-button="bookmarks"]').length, 2);
// X's own React and More appear on hover: the star moves beside them, it never sits on top of them.
const row = chat.row(target.uuid)!;
const more = () => row.querySelector(`[data-testid="message-overflow-button-${target.uuid}"]`)!;
assert.equal(star(row).getAttribute('data-bt-chat-side'), 'end', 'until then it sits on the free side of the row, out of the layout');
assert.equal(star(chat.row(all[54]!.uuid)!).getAttribute('data-bt-chat-side'), 'start', 'your own messages are at the end: their star is at the start');
row.dispatchEvent(new Ev('mouseover', { bubbles: true }));
assert.equal(star(row).parentElement, more().parentElement);
assert.equal(star(row).getAttribute('data-bt-chat-side'), 'inline');
assert.equal(more().nextElementSibling, star(row));
// The list is virtualized: a scroll replaces every row, and the rows that come back are marked again.
chat.wheelUp();
assert.equal(chat.row(target.uuid), null, 'the starred row left the DOM');
assert.equal(chat.body.querySelectorAll('.bt-chat-star').length, chat.rendered().length);
chat.position(55);
assert.equal(star(chat.row(target.uuid)!).getAttribute('aria-pressed'), 'true');
// A second click takes the star back.
star(chat.row(target.uuid)!).click();
assert.deepEqual(stored<Starred[]>(STARS), []);
assert.equal(chat.row(target.uuid)!.hasAttribute('data-bt-chat-starred'), false);
// Nothing of X's was used for it: no reaction, no menu, no send.
assert.deepEqual([chat.reactions, chat.sent, chat.deleted, chat.forwarded, chat.replyingTo, chat.textarea.value], [0, 0, [], [], null, '']);
env.registry.setFlags(flagsWith({}));
assert.deepEqual(traces(chat.body), [], 'off: no node and no attribute left');
env.registry.dispose();
});
test('Starred messages: the list\'s command is there on every page of X, the stars only in chats', () => {
const env = areaPage('/home');
env.registry.setFlags(flagsWith({ 'chat.bookmarks': true }));
try {
assert.deepEqual(env.registry.active().filter(id => id.startsWith('chat.')), ['chat.bookmarks']);
assert.deepEqual(env.api.commands.list().filter(command => command.id.startsWith(OWNER)).map(command => [command.id, command.title]), [[`${OWNER}:bookmarks.open`, 'Starred messages']]);
} finally { env.registry.dispose(); }
});
test('open at message: from anywhere through X\'s router, in the open chat through X\'s scroll event; the row is waited for', async () => {
// From the inbox, a message far outside what X would load on opening the chat.
const far = page('/i/chat', {});
const old = group(far.chat)[3]!;
let landed = 0;
const run = openStarred(far.win, SEL, { conversation: GROUP, uuid: old.uuid, seq: old.seq }, false, pace(far.chat), () => { landed += 1; });
await far.chat.clock.tick(3000);
assert.equal(await run, 'opened');
assert.deepEqual(far.chat.opened, [{ id: GROUP, at: old.seq }], 'X\'s router opened the conversation at the message');
assert.deepEqual(far.chat.jumps, [], 'no second mechanism was needed');
assert.ok(far.chat.row(old.uuid), 'the row is in the DOM');
assert.equal(landed, 1);
far.env.registry.dispose();
// In the open conversation: the window event, with a sequence object of X's own class.
const here = page(`/i/chat/${GROUP}`, {});
const target = group(here.chat)[10]!;
assert.equal(here.chat.row(target.uuid), null);
const jump = jumpToMessage(here.win, SEL, { conversation: GROUP, uuid: target.uuid, seq: target.seq }, pace(here.chat));
await here.chat.clock.tick(3000);
assert.equal(await jump, true);
assert.deepEqual(here.chat.jumps, [target.seq]);
assert.deepEqual(here.chat.opened.length, 1, 'no navigation');
assert.ok(here.chat.row(target.uuid));
// Without the id (a find result X's list did not name): the row is found by its sequence number.
assert.equal(findRow(here.doc, SEL, { uuid: null, seq: target.seq }), here.chat.row(target.uuid));
// A sequence X does not know (a deleted message) does nothing: it is waited for, then reported.
const before = here.chat.rendered();
const gone = openStarred(here.win, SEL, { conversation: GROUP, uuid: 'no-such-message', seq: seqAt(Date.UTC(2020, 0, 1)) }, true, pace(here.chat));
await here.chat.clock.tick(12000);
assert.equal(await gone, 'gone');
assert.deepEqual(here.chat.rendered(), before, 'the user stays where they were');
assert.deepEqual([here.chat.replyingTo, here.chat.sent], [null, 0]);
// A star without a position (X's record was unreadable when it was starred) opens the conversation and says so.
assert.equal(await openStarred(here.win, SEL, { conversation: DIRECT, uuid: 'u', seq: null }, true, pace(here.chat)), 'no-position');
assert.equal(conversationOf(here.win.location.pathname), DIRECT);
here.env.registry.dispose();
});
test('open at message: a chat that is open on another conversation, or whose router did not position, still lands', async () => {
const { env, chat, win } = page(`/i/chat/${DIRECT}`, {});
const target = group(chat)[20]!;
// X's router opens the conversation but ignores the position this time.
const show = chat.show.bind(chat);
chat.show = (id: string) => show(id, null);
const run = jumpToMessage(win, SEL, { conversation: GROUP, uuid: target.uuid, seq: target.seq }, pace(chat));
await chat.clock.tick(6000);
assert.equal(await run, true);
assert.deepEqual(chat.jumps, [target.seq], 'the scroll event was the second try');
assert.ok(chat.row(target.uuid));
// Leaving the conversation while it looks ends the wait; nothing is forced.
const other = group(chat)[2]!;
const left = jumpToMessage(win, SEL, { conversation: GROUP, uuid: other.uuid, seq: seqAt(Date.UTC(2020, 0, 1)) }, pace(chat));
await chat.clock.tick(1000);
chat.show = show;
env.go('/home');
await chat.clock.tick(12000);
assert.equal(await left, false);
assert.equal(win.location.pathname, '/home');
env.registry.dispose();
});
test('reply to a starred message: X\'s own menu is opened for that row and Reply chosen; nothing is typed, nothing is sent', async () => {
for (const options of [{}, { icons: false }, { menuOnPress: true }] as ChatOptions[]) {
const { env, chat, win } = page('/i/chat', {}, options);
const old = group(chat)[5]!;
const run = openStarred(win, SEL, { conversation: GROUP, uuid: old.uuid, seq: old.seq }, true, pace(chat));
await chat.clock.tick(8000);
assert.equal(await run, 'replied', JSON.stringify(options));
assert.equal(chat.replyingTo, old.uuid, 'X\'s composer shows its reply preview for exactly that message');
assert.equal(env.doc.activeElement, chat.textarea, 'the text box has the focus');
assert.equal(chat.textarea.value, '', 'nothing typed');
assert.deepEqual([chat.sent, chat.deleted, chat.forwarded, chat.reactions], [0, [], [], 0], 'nothing sent, deleted, forwarded or reacted to');
assert.equal(chat.body.querySelectorAll('[role="menu"]').length, 0, 'X\'s menu closed');
env.registry.dispose();
}
});
test('reply: X\'s Reply item is found without reading a label; when it cannot be, nothing is clicked', async () => {
const item = (icon: string | null) => h('div', { role: 'menuitem' }, icon ? h('svg', { 'data-icon': icon }) : null, 'x');
const withIcons = h('div', { role: 'menu' }, item('icon-forward'), item('icon-reply'), item('icon-trash'));
assert.equal(replyItem(withIcons as unknown as Element), withIcons.children[1], 'by X\'s reply icon, wherever it is');
const bare = h('div', { role: 'menu' }, item(null), item(null), item(null));
assert.equal(replyItem(bare as unknown as Element), bare.children[0], 'else the first item of a full message menu');
assert.equal(replyItem(h('div', { role: 'menu' }, item(null), item(null)) as unknown as Element), null, 'a short menu is not a message menu: no guess');
assert.equal(replyItem(h('div', { role: 'menu' }) as unknown as Element), null);
// The row never shows X's buttons: the reply is given up at the message, with nothing touched.
const { env, chat, win } = page(`/i/chat/${GROUP}`, {});
const target = group(chat)[58]!;
const row = chat.row(target.uuid)!;
const stuck = h('div', { 'data-dm-message-row': 'stuck-1' });
row.parentElement!.append(stuck);
const none = openReply(win, SEL, 'stuck-1', pace(chat));
await chat.clock.tick(4000);
assert.equal(await none, 'no-button');
assert.equal(await openReply(win, SEL, 'not-rendered', pace(chat)), 'no-row');
assert.deepEqual([chat.replyingTo, chat.sent, chat.deleted, chat.textarea.value], [null, 0, [], '']);
env.registry.dispose();
});
test('X\'s scroll event needs X\'s own sequence object: made by the class of a rendered row\'s sequence', () => {
const { env, chat, win } = page(`/i/chat/${GROUP}`, {});
const target = group(chat)[0]!;
assert.equal(fireScrollToMessage(win, SEL, target.seq), true);
assert.deepEqual(chat.jumps, [target.seq], 'X accepted it (a string or a bigint would have been ignored)');
chat.close();
assert.equal(fireScrollToMessage(win, SEL, target.seq), false, 'no row rendered: no class to make it with');
env.registry.dispose();
});
test('person filter: who to offer, and only a known other sender fades', () => {
const loaded = [
{ uuid: 'a', seq: '1', senderId: '1', senderName: 'Tomas Reed', text: '' },
{ uuid: 'b', seq: '2', senderId: '2', senderName: null, text: '' },
{ uuid: 'c', seq: '3', senderId: '1', senderName: 'Tomas Reed', text: '' },
];
const starred = [{ conversation: GROUP, sender: { id: '3', name: 'Ada Quill', handle: null } }, { conversation: DIRECT, sender: { id: '4', name: 'Mira Holt', handle: null } }] as Starred[];
assert.deepEqual(participants(loaded, starred, GROUP, new Map([['2', 'Mira Holt']])), [{ id: '3', name: 'Ada Quill' }, { id: '2', name: 'Mira Holt' }, { id: '1', name: 'Tomas Reed' }]);
assert.deepEqual(participants(loaded, starred, GROUP), [{ id: '3', name: 'Ada Quill' }, { id: '1', name: 'Tomas Reed' }], 'a sender without a name is not offered');
assert.deepEqual([dims('1', '2'), dims('2', '2'), dims(null, '2'), dims('1', null)], [true, false, false, false], 'unknown never fades');
});
test('Person filter: in a group, everyone else fades and stays faded as rows recycle; Esc or one click clears it', () => {
const { env, chat } = page(`/i/chat/${GROUP}`, { 'chat.personFilter': true });
const header = () => chat.body.querySelector('[data-bt-chat-button="personFilter"]');
assert.equal(header()!.getAttribute('aria-label'), 'Show one person\'s messages');
assert.equal(chat.body.querySelectorAll('[data-bt-chat-dim]').length, 0);
const dimmed = () => chat.body.querySelectorAll('[data-bt-chat-dim]').map(row => row.getAttribute('data-dm-message-row'));
const expect = () => group(chat).slice(chat.first, chat.first + 8).filter(msg => msg.from !== ADA).map(msg => msg.uuid);
// The header button opens a small list: the senders of what X has loaded, by the name X shows ("You" is X's word).
(header() as El).click();
assert.deepEqual(chat.body.querySelector('.bt-chat-people')!.querySelectorAll('button').map(node => node.textContent), ['Ada Quill', 'Mira Holt', 'Tomas Reed', 'You']);
env.doc.emit('keydown', { key: 'Escape' });
assert.equal(chat.body.querySelector('.bt-chat-people'), null, 'Esc closes the list');
pickPerson(env, chat, ADA.name);
assert.deepEqual(dimmed(), expect());
assert.equal(header()!.getAttribute('aria-pressed'), 'true');
assert.equal(header()!.textContent, 'Ada Quill');
chat.wheelUp();
assert.deepEqual(dimmed(), expect(), 'rows that came in with the scroll are judged too');
assert.equal(dimmed().length, 6);
// A row whose sender X's record does not give stays as it is.
const unknown = h('div', { 'data-dm-message-row': 'unknown-1' });
chat.scroller.append(unknown);
env.mutate();
assert.equal(unknown.hasAttribute('data-bt-chat-dim'), false);
env.doc.emit('keydown', { key: 'Escape' });
assert.deepEqual(dimmed(), []);
pickPerson(env, chat, TOMAS.name);
assert.equal(dimmed().length > 0, true);
(header() as El).click();
assert.deepEqual(dimmed(), [], 'one click on the chip');
// Another conversation starts without a filter; a 1:1 has no button at all.
pickPerson(env, chat, TOMAS.name);
chat.inboxList.querySelectorAll('a')[1]!.click();
assert.equal(conversationOf((env.win.location as { pathname: string }).pathname), DIRECT);
assert.deepEqual([dimmed(), header()], [[], null]);
env.registry.setFlags(flagsWith({}));
assert.deepEqual(traces(chat.body), []);
env.registry.dispose();
});
/** Picks `name` in the small list under the header button (opened from the palette here). */
function pickPerson(env: ReturnType<typeof areaPage>, chat: FakeChat, name: string) {
assert.equal(env.api.commands.run(`${OWNER}:personFilter.pick`), true);
const list = chat.body.querySelector('.bt-chat-people');
assert.ok(list, 'the list of participants');
const button = list!.querySelectorAll('button').find(node => node.textContent === name);
assert.ok(button, `${name} is offered`);
button!.click();
assert.equal(chat.body.querySelector('.bt-chat-people'), null, 'picking closes the list');
}
test('keywords: whole words and phrases, any case, any script; muted wins; no text, no verdict', () => {
const hit = wordMatcher(['ferry', 'spare cable', 'çay', ' ']);
assert.ok(hit('The Ferry leaves at six') && hit('bring the spare cable') && hit('Bir çay lütfen'));
assert.ok(!hit('ferryman') && !hit('a spare, cable') && !hit(''), 'not inside another word');
assert.equal(wordMatcher([])('anything'), false);
assert.ok(wordMatcher(['c++', 'a.b'])('I like c++ and a.b') && !wordMatcher(['a.b'])('axb'), 'a word is text, not a pattern');
const mute = wordMatcher(['spoiler']);
assert.deepEqual([verdictOf('ferry spoiler', hit, mute), verdictOf('the ferry', hit, mute), verdictOf('hello', hit, mute), verdictOf('', hit, mute)], ['muted', 'hit', null, null]);
});
test('Keywords: a highlight accent and a folded line that opens on a click, as attributes; the message text is never edited', () => {
const env = areaPage(`/i/chat/${GROUP}`);
env.store.set('bt-plugin:chat-plus.keywords:settings', JSON.stringify({ highlight: ['ferry'], mute: ['notebook'] }));
const chat = new FakeChat(env, conversations());
chat.show(GROUP, null);
chat.position(2);
const before = chat.scroller.querySelectorAll('[data-testid^="message-text-"]').map(node => node.textContent);
env.registry.setFlags(flagsWith({ 'chat.keywords': true }));
const all = group(chat);
const marks = () => Object.fromEntries(chat.scroller.querySelectorAll('[data-bt-chat-kw]').map(row => [row.getAttribute('data-dm-message-row'), row.getAttribute('data-bt-chat-kw')]));
assert.deepEqual(marks(), { [all[1]!.uuid]: 'hit', [all[2]!.uuid]: 'muted' });
assert.equal(chat.row(all[2]!.uuid)!.getAttribute('data-bt-chat-kw-label'), 'Hidden by Chat+ (click to show)');
assert.deepEqual(chat.scroller.querySelectorAll('[data-testid^="message-text-"]').map(node => node.textContent), before, 'X\'s text nodes are untouched');
// A click on the folded line opens that message, and it stays open while the row comes and goes.
chat.row(all[2]!.uuid)!.querySelector('[dir="auto"]')!.click();
assert.deepEqual(marks(), { [all[1]!.uuid]: 'hit' });
chat.position(30);
chat.position(2);
assert.deepEqual(marks(), { [all[1]!.uuid]: 'hit' });
// New word lists apply at once; with none, nothing is marked.
env.store.set('bt-plugin:chat-plus.keywords:settings', JSON.stringify({ highlight: [], mute: [] }));
env.registry.setFlags(flagsWith({}));
env.registry.setFlags(flagsWith({ 'chat.keywords': true }));
assert.deepEqual(marks(), {});
assert.equal(Object.keys(Object.fromEntries(env.store)).some(key => key.includes('bookmarks')), false, 'nothing about a message is stored');
env.registry.setFlags(flagsWith({}));
assert.deepEqual(traces(chat.body), []);
env.registry.dispose();
});
test('pinned chats: valid conversations only, each once, in the order they were pinned', () => {
const pin = (id: string, at: number, extra: Partial<Pin> = {}): Pin => ({ id, title: id, label: '', color: '', at, ...extra });
const list = withPin(withPin([], pin(GROUP, 2, { title: ' Harbor Quiz ', label: 'x'.repeat(40), color: 'green' })), pin(DIRECT, 1));
assert.deepEqual(list.map(item => [item.id, item.title, item.label.length, item.color]), [[DIRECT, DIRECT, 0, ''], [GROUP, 'Harbor Quiz', 24, 'green']]);
const edited = withPin(list, pin(GROUP, 99, { title: 'Quiz', color: 'red' }));
assert.deepEqual(edited.map(item => [item.id, item.title, item.color, item.at]), [[DIRECT, DIRECT, '', 1], [GROUP, 'Quiz', 'red', 2]], 'an edit keeps its place');
assert.deepEqual(withoutPin(edited, GROUP).map(item => item.id), [DIRECT]);
assert.deepEqual(normalizePins([{ id: 'nope' }, null, { id: '1000:1001', color: 'pink', title: '' }, { id: DIRECT }]), [{ id: DIRECT, title: DIRECT, label: '', color: '', at: 0 }]);
assert.equal(normalizePins(Array.from({ length: MAX_PINS + 5 }, (_, index) => ({ id: `${index + 1}-9999`, at: index }))).length, MAX_PINS);
});
test('Pinned chats: a strip above X\'s list, X\'s rows left in X\'s order; a chip opens the chat through X\'s own link or router', () => {
const env = areaPage('/i/chat');
env.store.set(PINS, JSON.stringify([{ id: DIRECT, title: 'Ada', label: 'work', color: 'blue', at: 1 }, { id: '2000-3000', title: 'Far away', label: '', color: '', at: 2 }]));
const chat = new FakeChat(env, conversations());
const order = () => chat.inboxList.children.map(row => row.querySelector('[data-testid^="dm-conversation-item-"]')!.getAttribute('data-testid'));
const before = order();
env.registry.setFlags(flagsWith({ 'chat.pins': true }));
const scroller = chat.body.querySelector('[data-testid="dm-conversation-scroller"]')!;
const strip = scroller.previousElementSibling!;
assert.equal(strip.className, 'bt-chat-pins');
assert.deepEqual(strip.querySelectorAll('.bt-chat-pin-name').map(node => node.textContent), ['Ada', 'Far away']);
assert.deepEqual(strip.querySelectorAll('.bt-chat-pin-label').map(node => node.textContent), ['work']);
assert.deepEqual(order(), before, 'X\'s list is not reordered');
assert.equal(scroller.contains(strip), false, 'the strip is outside X\'s virtualized list');
// Every conversation row X rendered gets the pin button; the pinned one shows it.
assert.deepEqual(chat.inboxList.querySelectorAll('.bt-chat-pin').map(node => [node.getAttribute('data-bt-chat-pin'), node.getAttribute('aria-pressed'), node.getAttribute('aria-label')]),
[[GROUP, 'false', 'Pin this chat'], [DIRECT, 'true', 'Edit pinned chat']]);
// A chip whose row is in X's list clicks X's own link; one that is not goes through X's router.
const chips = strip.querySelectorAll('.bt-chat-pin-chip');
chips[0]!.children[0]!.click();
assert.deepEqual(chat.linkClicks, [DIRECT]);
assert.equal(conversationOf((env.win.location as { pathname: string }).pathname), DIRECT);
assert.ok(chat.body.querySelector('.bt-chat-pins')!.querySelector('[data-bt-chat-current]'), 'the open chat\'s chip is marked');
chat.body.querySelector('.bt-chat-pins')!.querySelectorAll('.bt-chat-pin-chip')[1]!.children[0]!.click();
assert.equal((env.win.location as { pathname: string }).pathname, '/i/chat/2000-3000');
assert.deepEqual(chat.linkClicks, [DIRECT]);
// Another tab pinned a chat: the strip follows the stored list (the pin dialog itself is the UI kit's).
env.store.set(PINS, JSON.stringify([{ id: GROUP, title: 'Quiz', label: '', color: 'red', at: 3 }]));
env.win.emit('storage');
assert.deepEqual(chat.body.querySelector('.bt-chat-pins')!.querySelectorAll('.bt-chat-pin-name').map(node => node.textContent), ['Quiz']);
assert.deepEqual(chat.inboxList.querySelectorAll('.bt-chat-pin').map(node => node.getAttribute('aria-pressed')), ['true', 'false']);
env.store.set(PINS, '[]');
env.win.emit('storage');
assert.equal(chat.body.querySelector('.bt-chat-pins'), null, 'no pins: no strip');
assert.deepEqual(order(), before);
assert.deepEqual([chat.sent, chat.reactions, chat.deleted], [0, 0, []]);
env.registry.setFlags(flagsWith({}));
assert.deepEqual(traces(chat.body), []);
env.registry.dispose();
});
test('find: every word, any case, in the text or the sender; stepping wraps around', () => {
const messages = [
{ uuid: 'a', seq: '1', senderId: '1', senderName: 'Ada Quill', text: 'The ferry leaves at six' },
{ uuid: 'b', seq: '2', senderId: '2', senderName: 'Tomas Reed', text: 'Ferry tickets: two left' },
{ uuid: 'c', seq: '3', senderId: '1', senderName: null, text: 'Rain again' },
];
assert.deepEqual(searchMessages(messages, 'FERRY').map(message => message.uuid), ['a', 'b']);
assert.deepEqual(searchMessages(messages, 'ferry tomas').map(message => message.uuid), ['b']);
assert.deepEqual(searchMessages(messages, ' '), []);
assert.deepEqual([stepMatch(0, -1, 3), stepMatch(2, 1, 3), stepMatch(1, 0, 3), stepMatch(-1, -1, 0)], [2, 0, 1, -1]);
});
test('Find in conversation: searches what X loaded (not just what is on screen), jumps to a match, loads older on request', async () => {
const { env, chat } = page(`/i/chat/${GROUP}`, { 'chat.searchLoaded': true });
assert.ok(chat.body.querySelector('[data-bt-chat-button="searchLoaded"]'));
assert.deepEqual(env.api.commands.list().map(command => command.id), [`${OWNER}:searchLoaded.open`]);
// Ctrl+Shift+F while typing in X's message box opens the bar too (the palette's shortcut rests while typing).
const key = new Ev('keydown', { key: 'F', code: 'KeyF', ctrlKey: true, shiftKey: true, bubbles: true });
chat.textarea.dispatchEvent(key);
assert.equal(key.defaultPrevented, true);
const bar = chat.body.querySelector('.bt-chat-find')!;
assert.ok(bar, 'the find bar');
const input = bar.querySelector('input')!;
const note = () => bar.querySelector('.bt-chat-find-note')!.children[0]!.textContent;
assert.match(note(), /^Searches only what is loaded: 24 messages, back to /, 'it says what it searches');
const all = group(chat);
// "(40)" is loaded but not rendered: found from X's list, reached with X's jump.
assert.equal(chat.row(all[40]!.uuid), null);
input.value = '(40)';
input.dispatchEvent(new Ev('input', { bubbles: true }));
assert.equal(bar.querySelector('.bt-chat-find-count')!.textContent, '1 of 1');
input.dispatchEvent(new Ev('keydown', { key: 'Enter', bubbles: true }));
await chat.clock.tick(2000);
assert.deepEqual(chat.jumps, [all[40]!.seq]);
assert.ok(chat.row(all[40]!.uuid)!.hasAttribute('data-bt-chat-flash'), 'the match lights up');
// Not loaded yet: no match, said plainly. "Load older" scrolls X's list until X has it.
input.value = '(7)';
input.dispatchEvent(new Ev('input', { bubbles: true }));
assert.equal(bar.querySelector('.bt-chat-find-count')!.textContent, 'No match');
const older = bar.querySelectorAll('button').find(node => node.textContent === 'Load older')!;
older.click();
assert.equal(older.textContent, 'Stop');
await chat.clock.tick(3000);
assert.equal(bar.querySelector('.bt-chat-find-count')!.textContent, '1 of 1', 'found once X loaded it');
older.click();
await chat.clock.tick(1000);
assert.equal(older.textContent, 'Load older', 'stopped on request');
const loaded = chat.loadedFrom;
await chat.clock.tick(3000);
assert.equal(chat.loadedFrom, loaded, 'and it stays stopped');
// Left running, it ends by itself when X has nothing older.
older.click();
await chat.clock.tick(20000);
assert.equal(chat.loadedFrom, 0);
assert.equal(older.textContent, 'Load older');
assert.match(note(), /60 messages.*X loaded nothing older\.$/);
// Esc closes the bar; the index is gone with it.
input.dispatchEvent(new Ev('keydown', { key: 'Escape', bubbles: true }));
assert.equal(chat.body.querySelector('.bt-chat-find'), null);
assert.deepEqual([chat.sent, chat.textarea.value, chat.reactions, chat.deleted], [0, '', 0, []]);
assert.equal([...env.store.keys()].some(name => name.includes('chat-plus')), false, 'nothing of the search is stored');
env.registry.setFlags(flagsWith({}));
assert.deepEqual(traces(chat.body), []);
env.registry.dispose();
});
test('Find in conversation: when X\'s page does not expose its list, the rows seen while the bar is open are what it searches', async () => {
const { env, chat } = page(`/i/chat/${GROUP}`, { 'chat.searchLoaded': true }, { exposeList: false });
assert.equal(env.api.commands.run(`${OWNER}:searchLoaded.open`), true);
const bar = chat.body.querySelector('.bt-chat-find')!;
assert.match(bar.querySelector('.bt-chat-find-note')!.children[0]!.textContent, /: 8 messages,/);
chat.wheelUp();
assert.match(bar.querySelector('.bt-chat-find-note')!.children[0]!.textContent, /: 16 messages,/);
// Another conversation: the bar and its index close.
chat.inboxList.querySelectorAll('a')[1]!.click();
assert.equal(chat.body.querySelector('.bt-chat-find'), null);
// No chat open: the command says so instead of opening.
env.go('/i/chat');
chat.close();
env.api.commands.run(`${OWNER}:searchLoaded.open`);
assert.deepEqual(env.toasts, ['Open a chat first']);
await chat.clock.tick(100);
env.registry.dispose();
});
test('all of Chat+ on: within the palette budget, one observer, the probes hold on X\'s markup, and nothing stored without a star or a pin', async () => {
const errors: unknown[] = [];
const warnings: unknown[] = [];
const original = { error: console.error, warn: console.warn };
console.error = (...args: unknown[]) => { errors.push(args.join(' ')); };
console.warn = (...args: unknown[]) => { warnings.push(args.join(' ')); };
try {
const { env, chat } = page(`/i/chat/${GROUP}`, ALL);
assert.deepEqual(env.registry.active().sort(), Object.keys(ALL).sort());
assert.deepEqual(env.api.commands.list().map(command => command.id.replace(`${OWNER}:`, '')).sort(), ['bookmarks.open', 'personFilter.pick', 'pins.edit', 'searchLoaded.open', 'settings']);
assert.equal(env.observers.live, 1, 'one shared observer for all five');
// A visit long enough for the health check, three times over, with scrolling and a chat switch.
for (let visit = 0; visit < 3; visit += 1) {
chat.wheelUp();
chat.inboxList.querySelectorAll('a')[visit % 2]!.click();
await chat.clock.tick(5000);
}
assert.deepEqual(env.registry.health(), [], 'no probe misses on X\'s chat markup');
assert.deepEqual(env.registry.active().sort(), Object.keys(ALL).sort());
assert.deepEqual([...env.store.keys()].filter(name => name.includes('chat-plus')), [], 'reading chats stores nothing');
assert.deepEqual([chat.sent, chat.reactions, chat.deleted, chat.forwarded, chat.replyingTo], [0, 0, [], [], null]);
// The area's switch off: everything of it is gone, without a reload.
env.registry.setFlags(flagsWith({ ...ALL, 'area.chat': false }));
assert.deepEqual(env.registry.active(), []);
assert.deepEqual(traces(chat.body), []);
assert.deepEqual(env.live(), []);
assert.deepEqual(env.api.commands.list(), []);
env.registry.dispose();
assert.deepEqual(errors, []);
assert.deepEqual(warnings, []);
} finally { Object.assign(console, original); }
});
test('X\'s chat inside its embed\'s shadow root (live 2026-10-01): stars, the list, reply and find work there too', async () => {
const { env, chat, win, doc, stored } = page(`/i/chat/${GROUP}`, { 'chat.bookmarks': true, 'chat.searchLoaded': true }, { embed: true });
// The page itself sees only the host: everything Chat+ needs is in the shadow root.
assert.equal(doc.querySelectorAll('[data-dm-message-row]').length, 0);
assert.equal(chatRoot(doc), chat.body as unknown as ShadowRoot);
env.mutate();
assert.equal(chat.body.querySelectorAll('.bt-chat-star').length, chat.rendered().length, 'one star per rendered row, inside the root');
assert.equal(chat.body.querySelectorAll('[data-bt-chat-button="bookmarks"]').length, 2, 'the list\'s button in the chat\'s and the inbox\'s header');
assert.equal(chat.body.querySelectorAll('[data-bt-chat-button="searchLoaded"]').length, 1);
const target = group(chat)[55]!;
star(chat.row(target.uuid)!).click();
assert.deepEqual(stored<Starred[]>(STARS).map(item => [item.uuid, item.seq, item.conversation]), [[target.uuid, target.seq, GROUP]]);
assert.equal(loadedMessages(chatRoot(doc), SEL).messages.length, 24, 'find reads X\'s loaded list in the root');
// Reply to an old message: X's own menu in the root, Reply chosen, the text box focused, nothing sent.
const old = group(chat)[5]!;
const run = openStarred(win, SEL, { conversation: GROUP, uuid: old.uuid, seq: old.seq }, true, pace(chat));
await chat.clock.tick(8000);
assert.equal(await run, 'replied');
assert.equal(chat.replyingTo, old.uuid);
assert.deepEqual([chat.sent, chat.textarea.value], [0, '']);
env.registry.setFlags(flagsWith({}));
assert.deepEqual(traces(chat.body), [], 'off: nothing left in the root');
env.registry.dispose();
});