// 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 = {}) => 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, 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: (key: string): T => JSON.parse(env.store.get(key) ?? 'null') as T }; } const pace = (chat: FakeChat) => ({ sleep: (ms: number) => new Promise(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), ...(words as Record) }; 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(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(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, 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 => ({ 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(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(); });