wayBetter for X
This commit is contained in:
commit
434f193217
720 changed files with 147465 additions and 0 deletions
252
apps/desktop/tests/fixtures/x/chat/miniDom.ts
vendored
Normal file
252
apps/desktop/tests/fixtures/x/chat/miniDom.ts
vendored
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
// A small DOM for tests/area-chat.test.ts: enough of Element (attributes, children, text, `closest`,
|
||||
// `querySelectorAll` over the selectors Chat+ uses, listeners with bubbling, focus, style) to run
|
||||
// the Chat+ modules over X-shaped chat markup without a browser (./x.ts builds that markup and plays
|
||||
// X's part). Not a DOM library: it implements only what these tests use, and a selector it does not
|
||||
// understand throws. The selector engine is the one of ../profile/miniDom.ts.
|
||||
|
||||
type Simple = { tag?: string; id?: string; classes: string[]; attrs: { name: string; op?: string; value?: string }[]; nots: Simple[] };
|
||||
type Step = { simple: Simple; child: boolean };
|
||||
|
||||
/** Splits at top-level separators (not inside brackets, parentheses or quotes). */
|
||||
function split(source: string, separators: string): { part: string; by: string }[] {
|
||||
const out: { part: string; by: string }[] = [];
|
||||
let depth = 0;
|
||||
let quote = '';
|
||||
let part = '';
|
||||
let by = '';
|
||||
for (const char of source) {
|
||||
if (quote) { part += char; if (char === quote) quote = ''; continue; }
|
||||
if (char === '"' || char === "'") { quote = char; part += char; continue; }
|
||||
if (char === '[' || char === '(') depth += 1;
|
||||
if (char === ']' || char === ')') depth -= 1;
|
||||
if (depth === 0 && separators.includes(char)) {
|
||||
if (part) { out.push({ part, by }); part = ''; by = ''; }
|
||||
if (char !== ' ') by = char;
|
||||
continue;
|
||||
}
|
||||
part += char;
|
||||
}
|
||||
if (part) out.push({ part, by });
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseSimple(source: string): Simple {
|
||||
const simple: Simple = { classes: [], attrs: [], nots: [] };
|
||||
let rest = source;
|
||||
const take = (pattern: RegExp) => { const match = pattern.exec(rest); if (match) rest = rest.slice(match[0].length); return match; };
|
||||
let match = take(/^[a-zA-Z][\w-]*/);
|
||||
if (match) simple.tag = match[0].toLowerCase();
|
||||
while (rest) {
|
||||
if ((match = take(/^#([\w-]+)/))) simple.id = match[1];
|
||||
else if ((match = take(/^\.([\w-]+)/))) simple.classes.push(match[1]!);
|
||||
else if ((match = take(/^\[([\w-]+)(?:([\^$*]?=)"([^"]*)")?\]/))) simple.attrs.push({ name: match[1]!, op: match[2], value: match[3] });
|
||||
else if ((match = take(/^:not\(([^()]*)\)/))) simple.nots.push(parseSimple(match[1]!));
|
||||
else throw new Error(`miniDom: selector not supported: ${source}`);
|
||||
}
|
||||
return simple;
|
||||
}
|
||||
|
||||
const parsed = new Map<string, Step[][]>();
|
||||
function parse(selector: string): Step[][] {
|
||||
let list = parsed.get(selector);
|
||||
if (!list) {
|
||||
list = split(selector, ',').map(({ part }) => split(part.trim(), ' >').map(step => ({ simple: parseSimple(step.part), child: step.by === '>' })));
|
||||
parsed.set(selector, list);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function matchesSimple(el: El, simple: Simple): boolean {
|
||||
if (simple.tag && el.tagName.toLowerCase() !== simple.tag) return false;
|
||||
if (simple.id && el.getAttribute('id') !== simple.id) return false;
|
||||
for (const name of simple.classes) if (!el.classList.contains(name)) return false;
|
||||
for (const { name, op, value } of simple.attrs) {
|
||||
const actual = el.getAttribute(name);
|
||||
if (actual === null) return false;
|
||||
if (op === '=' && actual !== value) return false;
|
||||
if (op === '^=' && !actual.startsWith(value!)) return false;
|
||||
if (op === '$=' && !actual.endsWith(value!)) return false;
|
||||
if (op === '*=' && !actual.includes(value!)) return false;
|
||||
}
|
||||
return !simple.nots.some(not => matchesSimple(el, not));
|
||||
}
|
||||
|
||||
function matchesSteps(el: El, steps: Step[], index = steps.length - 1): boolean {
|
||||
const step = steps[index]!;
|
||||
if (!matchesSimple(el, step.simple)) return false;
|
||||
if (index === 0) return true;
|
||||
if (step.child) return !!el.parentElement && matchesSteps(el.parentElement, steps, index - 1);
|
||||
for (let up = el.parentElement; up; up = up.parentElement) if (matchesSteps(up, steps, index - 1)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export class Txt {
|
||||
text: string;
|
||||
parentElement: El | null = null;
|
||||
constructor(text: string) { this.text = text; }
|
||||
}
|
||||
|
||||
/** An event as the code under test makes them (`new win.MouseEvent(type, init)`) and as X's part fires them. */
|
||||
export class Ev {
|
||||
type: string;
|
||||
target: El | null = null;
|
||||
currentTarget: unknown = null;
|
||||
defaultPrevented = false;
|
||||
stopped = false;
|
||||
[key: string]: unknown;
|
||||
constructor(type: string, init: Record<string, unknown> = {}) { this.type = type; Object.assign(this, init); }
|
||||
preventDefault() { this.defaultPrevented = true; }
|
||||
stopPropagation() { this.stopped = true; }
|
||||
}
|
||||
|
||||
type Listener = (event: Ev) => void;
|
||||
type DocLike = { activeElement: unknown; emit(type: string, event: unknown): void };
|
||||
|
||||
class Style {
|
||||
[key: string]: unknown;
|
||||
setProperty(name: string, value: string) { this[name] = value; }
|
||||
getPropertyValue(name: string) { return String(this[name] ?? ''); }
|
||||
}
|
||||
|
||||
export class El {
|
||||
tagName: string;
|
||||
parentElement: El | null = null;
|
||||
childNodes: (El | Txt)[] = [];
|
||||
style = new Style();
|
||||
value = '';
|
||||
title = '';
|
||||
type = '';
|
||||
disabled = false;
|
||||
scrollTop = 0;
|
||||
scrollHeight = 0;
|
||||
clientHeight = 0;
|
||||
/** Set by `mount` on `<html>`: where an event goes after the tree, and who knows the focus. */
|
||||
doc: DocLike | null = null;
|
||||
rect = { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 };
|
||||
private attributes = new Map<string, string>();
|
||||
private listeners = new Map<string, Listener[]>();
|
||||
|
||||
constructor(tag: string) { this.tagName = tag.toUpperCase(); }
|
||||
|
||||
get nodeName() { return this.tagName; }
|
||||
get children(): El[] { return this.childNodes.filter((node): node is El => node instanceof El); }
|
||||
get firstElementChild(): El | null { return this.children[0] ?? null; }
|
||||
get previousElementSibling(): El | null { const all = this.parentElement?.children ?? []; return all[all.indexOf(this) - 1] ?? null; }
|
||||
get nextElementSibling(): El | null { const all = this.parentElement?.children ?? []; return all[all.indexOf(this) + 1] ?? null; }
|
||||
private get root(): El { let up: El = this; while (up.parentElement) up = up.parentElement; return up; }
|
||||
get isConnected(): boolean { return this.root.tagName === 'HTML'; }
|
||||
|
||||
getAttribute(name: string) { return this.attributes.get(name) ?? null; }
|
||||
hasAttribute(name: string) { return this.attributes.has(name); }
|
||||
setAttribute(name: string, value: string) { this.attributes.set(name, String(value)); }
|
||||
removeAttribute(name: string) { this.attributes.delete(name); }
|
||||
toggleAttribute(name: string, on = !this.attributes.has(name)) { if (on) this.attributes.set(name, ''); else this.attributes.delete(name); return on; }
|
||||
get id() { return this.getAttribute('id') ?? ''; }
|
||||
get className() { return this.getAttribute('class') ?? ''; }
|
||||
set className(value: string) { this.setAttribute('class', value); }
|
||||
get classList() {
|
||||
const names = () => this.className.split(/\s+/).filter(Boolean);
|
||||
return { contains: (name: string) => names().includes(name) };
|
||||
}
|
||||
|
||||
get textContent(): string { return this.childNodes.map(node => (node instanceof El ? node.textContent : node.text)).join(''); }
|
||||
set textContent(value: string) {
|
||||
for (const node of this.childNodes) node.parentElement = null;
|
||||
this.childNodes = [];
|
||||
if (value !== '') this.append(String(value));
|
||||
}
|
||||
|
||||
private adopt(nodes: (El | Txt | string)[]): (El | Txt)[] {
|
||||
return nodes.map(node => {
|
||||
const own = typeof node === 'string' ? new Txt(node) : node;
|
||||
if (own instanceof El) own.remove();
|
||||
return own;
|
||||
});
|
||||
}
|
||||
private insert(at: number, nodes: (El | Txt)[]) {
|
||||
this.childNodes.splice(at, 0, ...nodes);
|
||||
for (const node of nodes) node.parentElement = this;
|
||||
}
|
||||
append(...nodes: (El | Txt | string)[]) { this.insert(this.childNodes.length, this.adopt(nodes)); }
|
||||
appendChild<T extends El>(node: T): T { this.append(node); return node; }
|
||||
insertBefore<T extends El>(node: T, before: El | null): T {
|
||||
const own = this.adopt([node]);
|
||||
this.insert(before ? this.childNodes.indexOf(before) : this.childNodes.length, own);
|
||||
return node;
|
||||
}
|
||||
replaceChildren(...nodes: (El | Txt | string)[]) { this.textContent = ''; this.append(...nodes); }
|
||||
remove() {
|
||||
const parent = this.parentElement;
|
||||
if (!parent) return;
|
||||
parent.childNodes.splice(parent.childNodes.indexOf(this), 1);
|
||||
this.parentElement = null;
|
||||
}
|
||||
contains(other: El | null): boolean { for (let up = other; up; up = up.parentElement) if (up === this) return true; return false; }
|
||||
|
||||
matches(selector: string): boolean { return parse(selector).some(steps => matchesSteps(this, steps)); }
|
||||
closest(selector: string): El | null {
|
||||
for (let up: El | null = this; up; up = up.parentElement) if (up.matches(selector)) return up;
|
||||
return null;
|
||||
}
|
||||
querySelectorAll(selector: string): El[] {
|
||||
const out: El[] = [];
|
||||
const walk = (el: El) => { for (const child of el.children) { if (child.matches(selector)) out.push(child); walk(child); } };
|
||||
walk(this);
|
||||
return out;
|
||||
}
|
||||
querySelector(selector: string): El | null { return this.querySelectorAll(selector)[0] ?? null; }
|
||||
|
||||
addEventListener(type: string, listener: Listener) { (this.listeners.get(type) ?? this.listeners.set(type, []).get(type)!).push(listener); }
|
||||
removeEventListener(type: string, listener: Listener) { this.listeners.set(type, (this.listeners.get(type) ?? []).filter(item => item !== listener)); }
|
||||
/** Runs the listeners of this element and, for an event that bubbles, of its ancestors and the document. */
|
||||
dispatchEvent(event: Ev): boolean {
|
||||
event.target ??= this;
|
||||
const root = this.root;
|
||||
for (let at: El | null = this; at && !event.stopped; at = event.bubbles === false ? null : at.parentElement) {
|
||||
event.currentTarget = at;
|
||||
for (const listener of [...(at.listeners.get(event.type) ?? [])]) listener(event);
|
||||
}
|
||||
if (!event.stopped && event.bubbles !== false) root.doc?.emit(event.type, event);
|
||||
return !event.defaultPrevented;
|
||||
}
|
||||
click() { this.dispatchEvent(new Ev('click', { bubbles: true })); }
|
||||
focus() { const doc = this.root.doc; if (doc) doc.activeElement = this; }
|
||||
select() {}
|
||||
scrollIntoView() {}
|
||||
scrollBy(options: { top: number }) { this.scrollTop = Math.max(0, this.scrollTop + options.top); }
|
||||
getBoundingClientRect() { return this.rect; }
|
||||
}
|
||||
|
||||
type Child = El | string | null | false | undefined;
|
||||
|
||||
/** `h('div', { 'data-testid': 'dm-container' }, h('span', null, 'Name'))`. */
|
||||
export function h(tag: string, attrs?: Record<string, string> | null, ...children: Child[]): El {
|
||||
const el = new El(tag);
|
||||
for (const [name, value] of Object.entries(attrs ?? {})) el.setAttribute(name, value);
|
||||
el.append(...children.filter((child): child is El | string => !!child));
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Shorthand for X's test ids: `t('dm-inbox-panel', ...)` is `<div data-testid="dm-inbox-panel">`. */
|
||||
export const t = (testid: string, ...children: Child[]) => h('div', { 'data-testid': testid }, ...children);
|
||||
|
||||
/**
|
||||
* Puts `<html><body>…children</body></html>` behind an `areaPage` document: its queries and the
|
||||
* elements it creates are this DOM's (a `<style>` stays the page's own, so `env.live()` still
|
||||
* lists the stylesheets), and an event that bubbles out of the tree reaches the document's
|
||||
* listeners. Returns the body.
|
||||
*/
|
||||
export function mount(doc: Record<string, unknown>, ...children: Child[]): El {
|
||||
const body = h('body', null, ...children);
|
||||
const html = h('html', { lang: 'en' }, h('head'), body);
|
||||
html.doc = doc as unknown as DocLike;
|
||||
const createStyle = (doc.createElement as (tag: string) => unknown).bind(doc);
|
||||
Object.assign(doc, {
|
||||
body,
|
||||
querySelectorAll: (selector: string) => html.querySelectorAll(selector),
|
||||
querySelector: (selector: string) => html.querySelector(selector),
|
||||
createElement: (tag: string) => (tag === 'style' ? createStyle(tag) : new El(tag)),
|
||||
createElementNS: (_: string, tag: string) => new El(tag),
|
||||
});
|
||||
return body;
|
||||
}
|
||||
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