wayBetter for X
This commit is contained in:
commit
434f193217
720 changed files with 147465 additions and 0 deletions
351
apps/desktop/tests/fixtures/x/chat/x.ts
vendored
Normal file
351
apps/desktop/tests/fixtures/x/chat/x.ts
vendored
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
// X's chat for tests/area-chat.test.ts, built from the live survey of /i/chat (structure only): the
|
||||
// test ids, the virtualized message list (a window of rows in the DOM out of a longer loaded
|
||||
// history), the record X's page keeps per row (obfuscated field names, a sequence object with a
|
||||
// bigint `num` whose class has `fromString`, a sender with `userId`), X's two ways to position on a
|
||||
// message (the `scrollToMessage` window event; router state on opening a conversation) and its
|
||||
// message menu (hover, "More", Reply). `FakeChat` plays X's part. Every name and every line of text
|
||||
// is invented.
|
||||
|
||||
import { El, Ev, h, mount, t } from './miniDom.ts';
|
||||
|
||||
const EPOCH = 1288834974657n;
|
||||
/** A sequence number for a message sent at `ms` (a snowflake: time in the high bits). */
|
||||
export const seqAt = (ms: number, n = 0) => (((BigInt(ms) - EPOCH) << 22n) + BigInt(n)).toString();
|
||||
|
||||
/** X's sequence class: text is not accepted where an instance is expected. */
|
||||
export class Seq {
|
||||
num: bigint;
|
||||
constructor(num: bigint) { this.num = num; }
|
||||
static fromString(text: string) { return new Seq(BigInt(text)); }
|
||||
}
|
||||
|
||||
export type Person = { id: string; name: string; handle?: string };
|
||||
export type Msg = { uuid: string; seq: string; from: Person; text: string; own?: boolean; clock?: string; replyTo?: Msg; media?: 'video' | 'image' };
|
||||
export type Conversation = { id: string; title: string; history: Msg[] };
|
||||
|
||||
export const ADA: Person = { id: '1001', name: 'Ada Quill', handle: 'adaquill' };
|
||||
export const TOMAS: Person = { id: '1002', name: 'Tomas Reed', handle: 'tomasreed' };
|
||||
export const MIRA: Person = { id: '1003', name: 'Mira Holt' };
|
||||
export const ME: Person = { id: '1000', name: 'Sam Birch', handle: 'sambirch' };
|
||||
|
||||
const LINES = [
|
||||
'Lunch at noon?', 'The ferry leaves at six', 'I found the blue notebook', 'Bring the spare cable', 'Rain again tomorrow', 'The demo went fine',
|
||||
'Call me after the match', 'Two tickets left', 'The bakery closes early', 'Send the map later', 'We moved the desk', 'Coffee is on me',
|
||||
];
|
||||
const uuid = (n: number) => `${String(n).padStart(8, '0')}-aaaa-4bbb-8ccc-${String(n).padStart(12, '0')}`;
|
||||
|
||||
/** `count` messages, one a minute from `start`, by `people` in turn. */
|
||||
export function history(count: number, people: Person[], start = Date.UTC(2026, 5, 1, 9, 0), offset = 0): Msg[] {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const from = people[index % people.length]!;
|
||||
const n = offset + index;
|
||||
return { uuid: uuid(n), seq: seqAt(start + index * 60000, n % 4096), from, own: from === ME, text: `${LINES[index % LINES.length]} (${n})`, clock: `09:${String(index % 60).padStart(2, '0')}` };
|
||||
});
|
||||
}
|
||||
|
||||
/** The record X's page keeps for a message, with build-specific field names. */
|
||||
export function record(msg: Msg, readableUser = true): Record<string, unknown> {
|
||||
return {
|
||||
aaa_1: msg.uuid,
|
||||
cfe_1: new Seq(BigInt(msg.seq)),
|
||||
pfd_1: { userId: msg.from.id, user: readableUser ? { displayName: msg.from.name, screenName: msg.from.handle } : { zz_1: msg.from.name } },
|
||||
bbb_1: { ccc_1: msg.text, ddd_1: 3 },
|
||||
...(msg.replyTo ? { replyPreview: { id: msg.replyTo.uuid, sequenceNumber: new Seq(BigInt(msg.replyTo.seq)), senderId: msg.replyTo.from.id, senderIsCurrentUser: !!msg.replyTo.own, senderDisplayName: msg.replyTo.from.name, previewText: msg.replyTo.text } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
type Env = {
|
||||
doc: Record<string, unknown> & { emit(type: string, event?: unknown): void };
|
||||
win: Record<string, unknown> & { emit(type: string, event?: unknown): void; addEventListener(type: string, fn: (event: unknown) => void): void };
|
||||
mutate(): void;
|
||||
};
|
||||
|
||||
export type ChatOptions = {
|
||||
/** Rows X keeps in the DOM at once (default 8). */
|
||||
rows?: number;
|
||||
/** X's page exposes its loaded list in a prop above the rows (default true). */
|
||||
exposeList?: boolean;
|
||||
/** The sender's name and handle are readable in X's record (default true). */
|
||||
readableUser?: boolean;
|
||||
/** The menu's items carry icons (default true). */
|
||||
icons?: boolean;
|
||||
/** "More" opens on press only, not on a bare click (default false). */
|
||||
menuOnPress?: boolean;
|
||||
/** ms X needs to render after a jump (default 500). */
|
||||
delay?: number;
|
||||
/**
|
||||
* X's chat inside the open shadow root of its embed host (XChatEmbed, seen live 2026-10-01): the
|
||||
* page's own queries see only the host; everything else is in `host.shadowRoot` (default false).
|
||||
*/
|
||||
embed?: boolean;
|
||||
};
|
||||
|
||||
/** A clock for the page: `tick(ms)` runs the timers that come due, letting promises settle in between. */
|
||||
export function fakeClock(win: Record<string, unknown>) {
|
||||
let now = 0;
|
||||
let next = 1;
|
||||
const pending = new Map<number, { at: number; fn: () => void }>();
|
||||
win.setTimeout = (fn: () => void, ms = 0) => { const id = next++; pending.set(id, { at: now + ms, fn }); return id; };
|
||||
win.clearTimeout = (id: number) => { pending.delete(id); };
|
||||
const settle = () => new Promise<void>(resolve => setImmediate(resolve));
|
||||
return {
|
||||
pending: () => pending.size,
|
||||
async tick(ms: number) {
|
||||
const end = now + ms;
|
||||
for (;;) {
|
||||
await settle();
|
||||
const due = [...pending].filter(([, timer]) => timer.at <= end).sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!due) break;
|
||||
pending.delete(due[0]);
|
||||
now = due[1].at;
|
||||
due[1].fn();
|
||||
}
|
||||
now = end;
|
||||
await settle();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class FakeChat {
|
||||
env: Env;
|
||||
options: Required<ChatOptions>;
|
||||
conversations = new Map<string, Conversation>();
|
||||
clock: ReturnType<typeof fakeClock>;
|
||||
body: El;
|
||||
inboxList: El;
|
||||
panelSlot: El;
|
||||
/** The open conversation and the window of its history that is in the DOM. */
|
||||
open: Conversation | null = null;
|
||||
first = 0;
|
||||
/** Index of the oldest message X has loaded (its local list). */
|
||||
loadedFrom = 0;
|
||||
/** What X's own UI did, to check what Chat+ caused. */
|
||||
replyingTo: string | null = null;
|
||||
sent = 0;
|
||||
deleted: string[] = [];
|
||||
forwarded: string[] = [];
|
||||
reactions = 0;
|
||||
jumps: string[] = [];
|
||||
opened: { id: string; at: string | null }[] = [];
|
||||
linkClicks: string[] = [];
|
||||
|
||||
constructor(env: Env, conversations: Conversation[], options: ChatOptions = {}) {
|
||||
this.env = env;
|
||||
this.options = { rows: 8, exposeList: true, readableUser: true, icons: true, menuOnPress: false, delay: 500, embed: false, ...options };
|
||||
for (const conversation of conversations) this.conversations.set(conversation.id, conversation);
|
||||
const { win } = env;
|
||||
this.clock = fakeClock(win);
|
||||
const location = win.location as { pathname: string; href: string };
|
||||
Object.assign(win, {
|
||||
innerWidth: 1280,
|
||||
CustomEvent: Ev, PopStateEvent: Ev, MouseEvent: Ev, PointerEvent: Ev, KeyboardEvent: Ev, WheelEvent: Ev,
|
||||
dispatchEvent: (event: Ev) => { win.emit(event.type, event); return true; },
|
||||
history: {
|
||||
state: null as unknown,
|
||||
pushState(state: unknown, _title: string, url: string) { this.state = state; location.pathname = url; location.href = `https://x.com${url}`; },
|
||||
},
|
||||
});
|
||||
this.inboxList = h('ul');
|
||||
this.panelSlot = h('div');
|
||||
const tree = t('dm-container',
|
||||
t('dm-inbox-panel',
|
||||
t('dm-inbox-header', t('dm-inbox-title', 'Chat'), h('button', { 'data-testid': 'dm-new-chat-button' })),
|
||||
h('div', null, h('div', { 'data-testid': 'dm-conversation-scroller', role: 'listbox' }, this.inboxList))),
|
||||
this.panelSlot);
|
||||
if (this.options.embed) {
|
||||
// The shadow root is not a child of its host: the page's querySelectorAll never reaches it.
|
||||
const shadow = h('div', { 'data-fake-shadow-root': '' }, tree);
|
||||
const host = h('div', { 'data-testid': 'xchatEmbedRoute' });
|
||||
(host as unknown as { shadowRoot: El }).shadowRoot = shadow;
|
||||
// Like a real shadow root: it knows its document, and composed events and focus reach the page.
|
||||
Object.assign(shadow, { ownerDocument: env.doc });
|
||||
shadow.doc = env.doc as unknown as El['doc'];
|
||||
mount(env.doc, host);
|
||||
this.body = shadow;
|
||||
} else this.body = mount(env.doc, tree);
|
||||
this.renderInbox();
|
||||
// X's router: a popstate reads the address and the state that came with it.
|
||||
win.addEventListener('popstate', () => {
|
||||
const match = /^\/i\/chat\/([^/]+)$/.exec(location.pathname);
|
||||
const state = (win.history as { state: { state?: { scrollToMessage?: string } } | null }).state;
|
||||
if (match) this.show(match[1]!, state?.state?.scrollToMessage ?? null);
|
||||
else this.close();
|
||||
});
|
||||
// X's message search: positions the open conversation on a sequence object of its own class.
|
||||
win.addEventListener('scrollToMessage', event => {
|
||||
const sequence = (event as { detail?: { sequenceNumber?: unknown } }).detail?.sequenceNumber;
|
||||
if (!this.open || !(sequence instanceof Seq)) return;
|
||||
this.jumps.push(sequence.num.toString());
|
||||
const at = this.open.history.findIndex(msg => msg.seq === sequence.num.toString());
|
||||
if (at >= 0) (win.setTimeout as (fn: () => void, ms: number) => unknown)(() => this.position(at), this.options.delay);
|
||||
});
|
||||
}
|
||||
|
||||
renderInbox() {
|
||||
this.inboxList.replaceChildren(...[...this.conversations.values()].map((conversation, index) => {
|
||||
const link = h('a', { href: `/i/chat/${conversation.id}` }, h('span', { dir: 'auto' }, conversation.title), h('span', { dir: 'auto' }, 'A preview line'));
|
||||
// X's own link: its router takes the address and tells the page.
|
||||
link.addEventListener('click', () => {
|
||||
this.linkClicks.push(conversation.id);
|
||||
(this.env.win.history as { pushState(state: unknown, title: string, url: string): void }).pushState(null, '', `/i/chat/${conversation.id}`);
|
||||
this.env.win.emit('popstate');
|
||||
});
|
||||
return h('li', { 'data-index': String(index) }, h('div', { role: 'option', 'data-testid': `dm-conversation-item-${conversation.id.replace('-', ':')}` }, link));
|
||||
}));
|
||||
}
|
||||
|
||||
/** X opens a conversation (from its list, or through its router), optionally positioned on a message. */
|
||||
show(id: string, at: string | null) {
|
||||
const conversation = this.conversations.get(id);
|
||||
if (!conversation) return;
|
||||
this.opened.push({ id, at });
|
||||
this.open = conversation;
|
||||
this.replyingTo = null;
|
||||
const last = Math.max(0, conversation.history.length - this.options.rows);
|
||||
this.loadedFrom = Math.max(0, conversation.history.length - this.options.rows * 3);
|
||||
const target = at ? conversation.history.findIndex(msg => msg.seq === at) : -1;
|
||||
this.panelSlot.replaceChildren(t('dm-conversation-panel', t('dm-conversation-content',
|
||||
t('dm-conversation-header', h('button', { 'data-testid': 'dm-conversation-back-button' }), t('dm-conversation-username', conversation.title), t('dm-conversation-header-item')),
|
||||
t('dm-message-list-container', h('div', { 'data-testid': 'dm-message-list', 'data-dm-message-list-scope': id },
|
||||
h('div', { 'data-testid': 'dm-message-scroller', 'data-virtualizer': 'tanstack', role: 'log' }))),
|
||||
t('dm-composer-container', h('form', { 'data-testid': 'dm-composer-form' },
|
||||
h('button', { 'data-testid': 'dm-composer-attachment-button' }),
|
||||
t('dm-composer-input-container', h('textarea', { 'data-testid': 'dm-composer-textarea', placeholder: 'Message' })),
|
||||
this.sendButton())))));
|
||||
const header = this.body.querySelector('[data-testid="dm-conversation-header"]')!;
|
||||
header.rect = { left: 400, top: 0, right: 1200, bottom: 56, width: 800, height: 56 };
|
||||
this.scroller.addEventListener('wheel', () => this.wheelUp());
|
||||
this.first = last;
|
||||
this.render();
|
||||
if (target >= 0) this.position(target);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.open = null;
|
||||
this.panelSlot.replaceChildren();
|
||||
this.env.mutate();
|
||||
}
|
||||
|
||||
private sendButton() {
|
||||
// X's Send carries no test id; nothing of Chat+ may ever click it.
|
||||
const button = h('button', { 'aria-label': 'Send' });
|
||||
button.addEventListener('click', () => { this.sent += 1; });
|
||||
return button;
|
||||
}
|
||||
|
||||
get scroller(): El { return this.body.querySelector('[data-testid="dm-message-scroller"]')!; }
|
||||
get composer(): El { return this.body.querySelector('[data-testid="dm-composer-container"]')!; }
|
||||
get textarea(): El { return this.body.querySelector('[data-testid="dm-composer-textarea"]')!; }
|
||||
row(id: string): El | null { return this.body.querySelector(`[data-dm-message-row="${id}"]`); }
|
||||
rendered(): string[] { return this.scroller.querySelectorAll('[data-dm-message-row]').map(row => row.getAttribute('data-dm-message-row')!); }
|
||||
|
||||
/** X positions the list on message `at`: it loads what lies between and renders the rows around it. */
|
||||
position(at: number) {
|
||||
if (!this.open) return;
|
||||
this.loadedFrom = Math.min(this.loadedFrom, Math.max(0, at - this.options.rows));
|
||||
this.first = Math.max(0, Math.min(at - Math.floor(this.options.rows / 2), this.open.history.length - this.options.rows));
|
||||
this.render();
|
||||
}
|
||||
|
||||
/** A wheel step up: X loads a page of older history and shows older rows. */
|
||||
wheelUp() {
|
||||
if (!this.open) return;
|
||||
const before = this.loadedFrom;
|
||||
this.loadedFrom = Math.max(0, this.loadedFrom - this.options.rows);
|
||||
this.first = Math.max(0, this.first - this.options.rows);
|
||||
if (this.loadedFrom < before) this.scroller.scrollHeight += 1000;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/** Renders the window: the virtualizer drops every row and makes new ones (nothing survives a scroll). */
|
||||
render() {
|
||||
const conversation = this.open!;
|
||||
const scroller = this.scroller;
|
||||
const group = conversation.id.startsWith('g');
|
||||
const loaded = conversation.history.slice(this.loadedFrom);
|
||||
const listFiber = { memoizedProps: this.options.exposeList ? { count: loaded.length, items: [{ kind: 'date' }, ...loaded.map(msg => ({ key: msg.uuid, messageItem: this.records.get(msg) ?? this.remember(msg) }))] } : { count: loaded.length }, return: null };
|
||||
scroller.scrollHeight ||= 4000;
|
||||
scroller.clientHeight = 800;
|
||||
scroller.replaceChildren(
|
||||
t('dm-message-list-spinner-slot'),
|
||||
h('div', { 'data-index': '0' }, h('span', null, 'Monday')),
|
||||
...conversation.history.slice(this.first, this.first + this.options.rows).map((msg, index) => this.rowOf(msg, index, group, listFiber)),
|
||||
t('dm-message-list-composer-spacer'));
|
||||
this.env.mutate();
|
||||
}
|
||||
private records = new Map<Msg, Record<string, unknown>>();
|
||||
private remember(msg: Msg) { const made = record(msg, this.options.readableUser); this.records.set(msg, made); return made; }
|
||||
|
||||
private rowOf(msg: Msg, index: number, group: boolean, listFiber: unknown): El {
|
||||
const id = `r${this.first + index}`;
|
||||
const row = h('div', { 'data-dm-message-row': msg.uuid, 'data-testid': `message-${msg.uuid}`, 'data-send-status': 'sent', class: msg.own ? 'flex w-full justify-end' : 'flex w-full' },
|
||||
group && !msg.own ? h('div', { 'aria-hidden': 'true' }, h('img', { src: 'https://pbs.twimg.com/profile_images/1/face_normal.jpg' })) : null,
|
||||
h('span', { id: `${id}-sender`, class: 'sr-only' }, msg.own ? 'You' : msg.from.name),
|
||||
h('span', { id: `${id}-time`, class: 'sr-only' }, msg.clock ?? '09:41'),
|
||||
h('div', { role: 'article', tabindex: '-1', 'data-dm-message-focus-target': `${msg.uuid}:content`, 'aria-labelledby': `${id}-sender ${id}-time`, 'aria-describedby': `${id}-content` },
|
||||
msg.replyTo ? h('div', null, h('svg', { 'data-icon': 'icon-reply-alt' }), h('div', null, msg.replyTo.text)) : null,
|
||||
msg.media === 'video' ? h('video', { poster: 'https://ton.x.com/i/ton/data/xchat_media/c1/m1.jpg', src: 'blob:https://x.com/1' }) : null,
|
||||
msg.media === 'image' ? h('img', { src: 'https://ton.x.com/i/ton/data/xchat_media/c1/p1.jpg' }) : null,
|
||||
msg.text ? h('div', { id: `${id}-content`, 'data-testid': `message-text-${msg.uuid}` }, h('span', { dir: 'auto' }, msg.text), h('span', null, msg.clock ?? '09:41')) : null));
|
||||
// React's handle on the row: its own props two components up, X's loaded list further up.
|
||||
(row as unknown as Record<string, unknown>)['__reactFiber$k3y'] = {
|
||||
memoizedProps: { className: 'flex' },
|
||||
return: { memoizedProps: {}, return: { memoizedProps: { messageItem: this.records.get(msg) ?? this.remember(msg), isOneToOne: !group }, return: { memoizedProps: {}, return: listFiber } } },
|
||||
};
|
||||
// X shows a row's buttons while the pointer is over it.
|
||||
row.addEventListener('mouseover', () => this.hover(msg, row));
|
||||
return h('div', { 'data-index': String(index + 1) }, h('div', null, h('div', null, row)));
|
||||
}
|
||||
|
||||
private hover(msg: Msg, row: El) {
|
||||
if (row.querySelector(`[data-testid="message-overflow-button-${msg.uuid}"]`)) return;
|
||||
const react = h('button', { 'data-testid': `message-reaction-button-${msg.uuid}`, 'aria-label': 'React' });
|
||||
react.addEventListener('click', () => { this.reactions += 1; });
|
||||
const more = h('button', { 'data-testid': `message-overflow-button-${msg.uuid}`, 'aria-label': 'More' });
|
||||
let pressed = false;
|
||||
more.addEventListener('pointerdown', () => { pressed = true; });
|
||||
more.addEventListener('mousedown', () => { if (this.options.menuOnPress && pressed) this.menu(msg); });
|
||||
more.addEventListener('click', () => { if (!this.options.menuOnPress) this.menu(msg); pressed = false; });
|
||||
row.append(h('div', { class: 'flex items-center' }, react, more));
|
||||
this.env.mutate();
|
||||
}
|
||||
|
||||
private menu(msg: Msg) {
|
||||
const item = (label: string, iconName: string, run: () => void) => {
|
||||
const node = h('div', { role: 'menuitem', 'data-slot': 'xds-menu-item' }, this.options.icons ? h('svg', { 'data-icon': iconName }) : null, label);
|
||||
node.addEventListener('click', () => { menu.remove(); run(); this.env.mutate(); });
|
||||
return node;
|
||||
};
|
||||
const menu = h('div', { role: 'menu', 'data-slot': 'xds-menu-content' },
|
||||
item('Reply', 'icon-reply', () => this.reply(msg)),
|
||||
item('Forward', 'icon-forward', () => { this.forwarded.push(msg.uuid); }),
|
||||
item('Copy message text', 'icon-copy', () => {}),
|
||||
item('Info', 'icon-info', () => {}),
|
||||
item('Delete for me', 'icon-trash', () => { this.deleted.push(msg.uuid); }));
|
||||
menu.addEventListener('keydown', event => { if (event.key === 'Escape') menu.remove(); });
|
||||
this.body.append(menu);
|
||||
this.env.mutate();
|
||||
}
|
||||
|
||||
private reply(msg: Msg) {
|
||||
this.replyingTo = msg.uuid;
|
||||
const cancel = h('button', { 'aria-label': 'Cancel reply' });
|
||||
const bar = h('div', null, h('span', null, msg.from.name), h('span', null, msg.text), cancel);
|
||||
cancel.addEventListener('click', () => { this.replyingTo = null; bar.remove(); });
|
||||
this.composer.insertBefore(bar, this.composer.firstElementChild);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whatever Chat+ added to the page: its nodes and its attributes on X's elements. */
|
||||
export function traces(body: El): string[] {
|
||||
const out: string[] = [];
|
||||
const walk = (el: El) => {
|
||||
for (const child of el.children) {
|
||||
if (/(^|\s)bt-chat-/.test(child.className)) out.push(`.${child.className}`);
|
||||
for (const name of ['data-bt-chat-starred', 'data-bt-chat-dim', 'data-bt-chat-kw', 'data-bt-chat-kw-label', 'data-bt-chat-flash', 'data-bt-chat-button']) if (child.hasAttribute(name)) out.push(`[${name}]`);
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
walk(body);
|
||||
return out;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue