wayBetter for X

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

View file

@ -0,0 +1,573 @@
// Media+ (src/areas/media): Media Grid Focus as a feature module compared with the plugin it
// replaces, the video rules the card owns, and the area's new features (video speed,
// picture-in-picture, alt text viewer, blur until hover). The table-level checks (every module
// starts and stops clean, one observer, the budget) are in areas.test.ts.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { ALT_SCHEMA, altItems, readAlt } from '../src/areas/media/altText.ts';
import { BLUR_SCHEMA, blurCss, blursAuthor, type BlurSettings } from '../src/areas/media/blurMedia.ts';
import { FEATURES, POWER } from '../src/areas/media/features.ts';
import { GRID_FOCUS_CSS, isMediaTab } from '../src/areas/media/gridFocus.ts';
import { MODULES } from '../src/areas/media/index.ts';
import { togglePictureInPicture } from '../src/areas/media/pictureInPicture.ts';
import { BAR_CLASS, OPEN_ATTR } from '../src/areas/media/playerControls.ts';
import { SPEED_SCHEMA, SPEEDS, formatSpeed, parseSpeed, pickVideo, stepSpeed } from '../src/areas/media/videoSpeed.ts';
import { X_TABLE } from '../src/areas/table.ts';
import { installVideoAutoplayBlock, installVideoLoopStop, installVideoVolumeMemory } from '../src/fixes/video.ts';
import { FEATURE_DEFAULTS } from '../src/inject/features.ts';
import { AREA_COMMAND_BUDGET } from '../../../waybetter/packages/runtime/src/featureModules.ts';
import { normalizeSettings, type SettingsSchema } from '../../../waybetter/packages/runtime/src/settingsForm.ts';
import { areaPage, flagsWith, messages } from './helpers/areaPage.ts';
const BUNDLED = new URL('../src-tauri/bundled-plugins/', import.meta.url);
const OWNER = 'bt-media-plus.plugin.js';
const NEW = ['media.videoSpeed', 'media.pictureInPicture', 'media.altText', 'media.blurMedia'];
/** Media+ alone: every other area off, and its one module that is on by default ("Remember video volume") off unless named. */
const mediaOnly = (patch: Record<string, boolean> = {}) =>
flagsWith({ ...Object.fromEntries(X_TABLE.areas.filter(area => area.id !== 'media').map(area => [`area.${area.id}`, false])), rememberVideoVolume: false, ...patch });
// ---- A small DOM: just what the modules use (elements, attributes, bubbling events, simple selectors) ----
type Handler = (event: FakeEvent) => void;
type FakeEvent = { type: string; target: El; key?: string; stopped: boolean; prevented: boolean; stopPropagation(): void; preventDefault(): void };
class El {
nodeName: string;
attrs = new Map<string, string>();
children: El[] = [];
parentElement: El | null = null;
ownerDocument: unknown = null;
className = '';
textContent = '';
title = '';
type = '';
hidden = false;
tabIndex = 0;
root = false;
handlers = new Map<string, Handler[]>();
[extra: string]: unknown;
constructor(tag: string, attrs: Record<string, string> = {}, children: El[] = []) {
this.nodeName = tag.toUpperCase();
for (const [name, value] of Object.entries(attrs)) this.attrs.set(name, value);
this.append(...children);
}
get isConnected(): boolean { return this.root || !!this.parentElement?.isConnected; }
get firstElementChild(): El | null { return this.children[0] ?? null; }
append(...nodes: El[]) { for (const node of nodes) { node.remove(); node.parentElement = this; node.ownerDocument = this.ownerDocument; this.children.push(node); } }
insertBefore(node: El, before: El) { node.remove(); node.parentElement = this; this.children.splice(this.children.indexOf(before), 0, node); return node; }
remove() { const list = this.parentElement?.children; if (list) list.splice(list.indexOf(this), 1); this.parentElement = null; }
setAttribute(name: string, value: string) { this.attrs.set(name, String(value)); }
getAttribute(name: string) { return name === 'class' ? this.className : this.attrs.get(name) ?? null; }
hasAttribute(name: string) { return this.attrs.has(name); }
removeAttribute(name: string) { this.attrs.delete(name); }
toggleAttribute(name: string, force?: boolean) { if (force ?? !this.attrs.has(name)) this.attrs.set(name, ''); else this.attrs.delete(name); }
addEventListener(type: string, handler: Handler) { (this.handlers.get(type) ?? this.handlers.set(type, []).get(type)!).push(handler); }
/** Dispatches a bubbling event from this element; returns it (was it stopped, was it prevented). */
fire(type: string, init: { key?: string } = {}): FakeEvent {
const event: FakeEvent = { type, target: this, ...init, stopped: false, prevented: false, stopPropagation() { event.stopped = true; }, preventDefault() { event.prevented = true; } };
for (let node: El | null = this; node && !event.stopped; node = node.parentElement) for (const handler of node.handlers.get(type) ?? []) handler(event);
return event;
}
click() { this.fire('click'); }
contains(node: El | null): boolean { for (let at = node; at; at = at.parentElement) if (at === this) return true; return false; }
private is(compound: string): boolean {
const match = /^([a-z]+)?((?:\[[^\]]+\]|\.[\w-]+)*)$/.exec(compound);
if (!match) return false;
if (match[1] && match[1].toUpperCase() !== this.nodeName) return false;
for (const part of match[2]!.match(/\[[^\]]+\]|\.[\w-]+/g) ?? []) {
if (part.startsWith('.')) { if (!this.className.split(' ').includes(part.slice(1))) return false; continue; }
const attr = /^\[([\w-]+)(?:(\*?=)"([^"]*)")?\]$/.exec(part);
if (!attr) return false;
const value = this.attrs.get(attr[1]!);
if (value === undefined) return false;
if (attr[2] === '=' && value !== attr[3]) return false;
if (attr[2] === '*=' && !value.includes(attr[3]!)) return false;
}
return true;
}
matches(selector: string): boolean {
return selector.split(',').some(one => {
const chain = one.trim().split(/\s+/);
if (!this.is(chain.pop()!)) return false;
let at = this.parentElement;
for (const compound of chain.reverse()) {
while (at && !at.is(compound)) at = at.parentElement;
if (!at) return false;
at = at.parentElement;
}
return true;
});
}
closest(selector: string): El | null { for (let at: El | null = this; at; at = at.parentElement) if (at.matches(selector)) return at; return null; }
querySelectorAll(selector: string): El[] { return this.children.flatMap(child => [...(child.matches(selector) ? [child] : []), ...child.querySelectorAll(selector)]); }
querySelector(selector: string): El | null { return this.querySelectorAll(selector)[0] ?? null; }
/** Everything a person would read in it. */
get text(): string { return this.textContent || this.children.map(child => child.text).join(' '); }
}
const VIDEO_SRC = 'blob:https://x.com/0001';
const video = (props: Record<string, unknown> = {}) => Object.assign(new El('video'), { paused: false, ended: false, playbackRate: 1, src: '', currentSrc: VIDEO_SRC, ...props });
const player = (inner: El) => new El('div', { 'data-testid': 'videoPlayer' }, [inner]);
const post = (...inner: El[]) => new El('article', { 'data-testid': 'tweet' }, [new El('div', {}, [...inner, new El('div', { role: 'group' })])]);
const photo = (alt: string) => new El('div', { 'data-testid': 'tweetPhoto' }, [new El('img', { alt, src: 'https://pbs.twimg.com/media/example.jpg' })]);
/** The test page with `body` as its content: the modules find, observe and build real (fake) elements. */
function domPage(path: string, ...content: El[]) {
const env = areaPage(path);
const body = new El('body', {}, content);
body.root = true;
const style = env.doc.createElement as () => unknown;
const own = (node: El) => { node.ownerDocument = env.doc; node.children.forEach(own); };
own(body);
Object.assign(env.doc, {
createElement: (tag: string) => (tag === 'style' ? style() : Object.assign(new El(tag), { ownerDocument: env.doc })),
querySelectorAll: (selector: string) => body.querySelectorAll(selector),
querySelector: (selector: string) => body.querySelector(selector),
});
const copied: string[] = [];
(env.win as Record<string, unknown>).navigator = { clipboard: { writeText: async (text: string) => { copied.push(text); } } };
return { ...env, body, copied, add(...nodes: El[]) { body.append(...nodes); nodes.forEach(own); env.mutate(); } };
}
const key = (code: string, shown: string) => {
const event = { key: shown, code, altKey: true, shiftKey: true, ctrlKey: false, metaKey: false, target: null, prevented: false, preventDefault() { event.prevented = true; }, stopPropagation() {} };
return event;
};
const tick = () => new Promise(resolve => setImmediate(resolve));
// ---- The area ----
test('Media+: the download stays in the app, the rest are modules (the three video rules too, on chat pages as before); flags and defaults are the old ones', () => {
const byId = new Map(FEATURES.map(feature => [feature.id, feature]));
assert.deepEqual(MODULES.map(module => module.id).sort(), FEATURES.filter(feature => feature.backing === 'module').map(feature => feature.id).sort());
for (const flag of ['mediaDownload', 'rememberVideoVolume', 'disableVideoAutoplay', 'stopVideoLoop'] as const) {
assert.equal(byId.get(flag)!.backing, flag === 'mediaDownload' ? 'app' : 'module', flag);
assert.equal(!!byId.get(flag)!.heavy, flag !== 'mediaDownload', flag);
assert.equal(byId.get(flag)!.default, FEATURE_DEFAULTS[flag], flag);
assert.equal(byId.get(flag)!.area, 'media', flag);
}
assert.equal(byId.get('media.gridFocus')!.supersedes, 'bt-media-grid-focus.plugin.js');
assert.ok(!FEATURES.some(feature => feature.backing === 'plugin'), 'nothing of Media+ is an old plugin file any more');
// What is new or came from an opt-in plugin starts off; Power User adds the calm ones, never the blur.
for (const id of ['media.gridFocus', ...NEW]) assert.equal(byId.get(id)!.default, false, id);
assert.deepEqual([...POWER].sort(), ['media.altText', 'media.pictureInPicture', 'media.videoSpeed']);
// Each new feature has its own row with its words, runs only where posts are, and never on chats.
for (const id of NEW) {
const row = byId.get(id)!;
assert.deepEqual(row.surfaces, ['timeline', 'explore', 'notifications'], id);
assert.ok(!row.heavy, id);
assert.ok(messages[row.label!] && messages[row.description!], id);
}
// Every label, hint and option of the settings forms has its English word.
const forms: Array<[string, SettingsSchema]> = [['videoSpeed', SPEED_SCHEMA], ['altText', ALT_SCHEMA], ['blurMedia', BLUR_SCHEMA]];
for (const [name, schema] of forms) for (const field of schema) if (field.type !== 'divider') {
const keys = [field.label, field.hint, ...(field.type === 'select' ? field.options.map(option => option.label) : [])];
for (const word of keys) if (word) assert.ok(messages[`plugin.media-plus.${name}.${word}`], `${name}.${word}`);
}
});
test('Media+: its switch turns off everything it owns, the app-backed ones included, and gives it back', () => {
const all = Object.fromEntries(FEATURES.map(feature => [feature.id, true]));
const off = flagsWith({ ...all, 'area.media': false });
for (const feature of FEATURES) assert.equal(off[feature.id], false, feature.id);
const env = areaPage('/someone/media');
const modules = MODULES.map(module => module.id).sort();
env.registry.setFlags(mediaOnly(all));
assert.deepEqual(env.registry.active().sort(), modules);
// All of them on: the area stays inside its palette budget (its settings entry included).
const commands = env.api.commands.list().map(command => command.id).sort();
assert.deepEqual(commands, [`${OWNER}:blurMedia.toggle`, `${OWNER}:settings`, `${OWNER}:videoSpeed.faster`, `${OWNER}:videoSpeed.slower`]);
assert.ok(commands.length <= AREA_COMMAND_BUDGET);
env.registry.setFlags(mediaOnly({ ...all, 'area.media': false }));
assert.deepEqual(env.registry.active(), []);
assert.deepEqual(env.live(), []);
assert.deepEqual(env.api.commands.list(), []);
env.registry.setFlags(mediaOnly(all));
assert.deepEqual(env.registry.active().sort(), modules, 'back without a reload');
// Chats are not theirs, except the three video rules (a video in a chat follows them, as it always did).
env.go('/i/chat');
assert.deepEqual(env.registry.active().sort(), ['disableVideoAutoplay', 'rememberVideoVolume', 'stopVideoLoop']);
env.registry.dispose();
});
// ---- The ports ----
test('video rules: autoplay is paused, a loop stops at the end, the volume is remembered; disposed leaves no listener', () => {
const env = areaPage();
class FakeVideo {
paused = false; muted = false; volume = 1; currentTime = 0; duration = 30; currentSrc = ''; parentElement = null;
src = 'https://video.twimg.com/ext_tw_video/1/pu/vid/avc1/720x1280/a.mp4';
pause() { this.paused = true; }
closest() { return null; }
}
(env.win as Record<string, unknown>).HTMLVideoElement = FakeVideo;
const win = env.win as unknown as Window;
const idle = env.listeners();
// Disable autoplay: a video that starts without the user touching its player is paused.
const block = installVideoAutoplayBlock(win);
const auto = new FakeVideo();
env.doc.emit('play', { target: auto });
env.doc.emit('play', { target: {} });
assert.equal(auto.paused, true);
block.dispose();
const free = new FakeVideo();
env.doc.emit('play', { target: free });
assert.equal(free.paused, false, 'off: X plays as X plays');
assert.equal(env.listeners(), idle);
// Stop at the end: the jump from the last moment to the start pauses a video, never a GIF.
const stop = installVideoLoopStop(win);
const loop = new FakeVideo();
const gif = Object.assign(new FakeVideo(), { src: 'https://video.twimg.com/tweet_video/AbC.mp4' });
for (const item of [loop, gif]) {
item.currentTime = 29.8;
env.doc.emit('timeupdate', { target: item });
item.currentTime = 0.1;
env.doc.emit('timeupdate', { target: item });
}
assert.deepEqual([loop.paused, gif.paused], [true, false]);
stop.dispose();
assert.equal(env.listeners(), idle);
// Remember volume: the stored level, under the key it always had, goes to the next video.
env.store.set('bt:videoVolume', '0.35');
const memory = installVideoVolumeMemory(win);
const next = new FakeVideo();
env.doc.emit('play', { target: next });
assert.equal(next.volume, 0.35);
memory.dispose();
assert.equal(env.listeners(), idle);
env.registry.dispose();
});
test('Media Grid Focus: the plugin\'s rules, only on a profile\'s Media tab', () => {
// The old plugin, started the way native code starts it, with a scope that records its stylesheet.
let oldCss = '';
const scope = { css(_id: string, css: string) { oldCss = css; }, onRouteChange() {}, defer() {} };
const module = { exports: {} as unknown };
new Function('module', 'exports', 'window', 'document', 'location', readFileSync(new URL('media-grid-focus.plugin.js', BUNDLED), 'utf8'))(
module, module.exports, { wayBetter: { api: { scope: () => scope } } }, { documentElement: { toggleAttribute() {} } }, { pathname: '/someone/media' },
);
const Old = module.exports as { new(): { start(): void }; isMediaTab(pathname: string): boolean };
new Old().start();
// The plugin scoped every rule with an attribute on <html> that it set on the Media tab; the
// module's sheet is only there on the Media tab. Apart from that prefix the rules are the same.
const rules = (css: string) => css.split('\n').map(line => line.trim()).filter(Boolean).join('\n');
assert.equal(rules(GRID_FOCUS_CSS), rules(oldCss.replaceAll('html[data-bt-media-grid] ', '')));
assert.ok(oldCss.includes('html[data-bt-media-grid] '));
for (const path of ['/someone/media', '/someone/media/', '/someone', '/someone/with_replies', '/home', '/i/bookmarks', '/someone/status/1/photo/1', '/a_very_long_handle_name/media']) {
assert.equal(isMediaTab(path), Old.isMediaTab(path), path);
}
// Its words are the plugin's own (`words: 'media-grid-focus'`).
assert.equal(MODULES.find(item => item.id === 'media.gridFocus')!.words, 'media-grid-focus');
const env = areaPage('/home');
env.registry.setFlags(mediaOnly({ 'media.gridFocus': true }));
assert.deepEqual(env.registry.active(), [], 'not on a profile: not even started');
env.go('/someone');
assert.deepEqual(env.registry.active(), ['media.gridFocus']);
assert.deepEqual(env.live(), [], 'a profile\'s Posts tab is unchanged');
env.go('/someone/media');
assert.deepEqual(env.live(), ['wbf-media.gridFocus']);
env.go('/someone/with_replies');
assert.deepEqual(env.live(), []);
env.go('/someone/media');
env.registry.setFlags(mediaOnly({}));
assert.deepEqual(env.live(), [], 'off: no rule');
env.registry.dispose();
});
// ---- Video speed ----
test('video speed: the steps, what is a speed, and which video the keys act on', () => {
assert.deepEqual(SPEEDS, [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 3]);
assert.deepEqual([stepSpeed(1, 1), stepSpeed(1, -1), stepSpeed(3, 1), stepSpeed(0.5, -1)], [1.25, 0.75, 3, 0.5]);
// A speed X's own menu set that is not on the list steps to its neighbours.
assert.deepEqual([stepSpeed(1.1, 1), stepSpeed(1.1, -1), stepSpeed(0.25, 1), stepSpeed(4, -1)], [1.25, 1, 0.5, 3]);
assert.deepEqual([1.5, 0.5, 3, 1].map(parseSpeed), [1.5, 0.5, 3, 1]);
for (const bad of [0, -1, 16, NaN, Infinity, '1.5', null, undefined, {}]) assert.equal(parseSpeed(bad), null, String(bad));
assert.deepEqual([formatSpeed(1), formatSpeed(1.25), formatSpeed(0.5)], ['1×', '1.25×', '0.5×']);
const item = (patch: object = {}) => ({ paused: true, ended: false, isConnected: true, src: '', currentSrc: VIDEO_SRC, ...patch });
const idle = item();
const playing = item({ paused: false });
const other = item({ paused: false });
const gif = item({ paused: false, currentSrc: 'https://video.twimg.com/tweet_video/AbC.mp4' });
assert.equal(pickVideo([idle, playing], null), playing, 'the one playing');
assert.equal(pickVideo([playing, other], other), other, 'of two playing, the one started last');
assert.equal(pickVideo([idle, gif], idle), idle, 'a paused video you started, not a GIF');
assert.equal(pickVideo([gif], null), null, 'never a GIF');
assert.equal(pickVideo([idle], null), null, 'a video you never started is not guessed');
assert.equal(pickVideo([], item({ isConnected: false })), null, 'a video X took out of the page');
});
test('video speed: a button on X\'s player, a menu, two keys; the speed is remembered and X\'s player is not disturbed', () => {
const first = video();
const box = player(first);
let reached = 0;
box.addEventListener('click', () => { reached += 1; });
const env = domPage('/home', post(box));
const idle = env.listeners();
env.registry.setFlags(mediaOnly({ 'media.videoSpeed': true }));
const bar = box.children.find(child => child.className === BAR_CLASS)!;
assert.ok(bar, 'the bar is on the player');
const pill = bar.querySelector('.bt-media-control')!;
const menu = bar.querySelector('.bt-media-speed-menu')!;
assert.deepEqual([pill.text, pill.title, menu.hidden], ['1×', 'Playback speed', true]);
assert.deepEqual(menu.children.map(item => item.text), SPEEDS.map(formatSpeed));
// Never in the tab order, a press does not move the focus and does not reach the player.
for (const button of [pill, ...menu.children]) assert.equal(button.tabIndex, -1);
assert.equal(pill.fire('mousedown').prevented, true);
pill.click();
assert.deepEqual([menu.hidden, bar.hasAttribute(OPEN_ATTR)], [false, true]);
menu.children[4]!.click();
assert.deepEqual([first.playbackRate, pill.text, menu.hidden, bar.hasAttribute(OPEN_ATTR)], [1.5, '1.5×', true, false]);
assert.equal(menu.children[4]!.getAttribute('aria-checked'), 'true');
assert.equal(reached, 0, 'X\'s player saw none of the presses');
assert.equal(env.store.get('bt-plugin:media-plus.videoSpeed:speed'), '1.5');
// A press elsewhere closes an open menu.
pill.click();
env.doc.emit('pointerdown', { target: box });
assert.equal(menu.hidden, true);
// The next video starts at that speed, once: a speed picked in X's own menu afterwards is kept and shown.
const second = video({ currentSrc: 'blob:https://x.com/0002' });
const secondBox = player(second);
env.add(post(secondBox));
env.doc.emit('play', { type: 'play', target: second });
assert.equal(second.playbackRate, 1.5);
second.playbackRate = 2;
env.doc.emit('ratechange', { type: 'ratechange', target: second });
env.doc.emit('play', { type: 'play', target: second });
assert.equal(second.playbackRate, 2);
assert.equal(secondBox.querySelector('.bt-media-control')!.text, '2×');
assert.equal(env.store.get('bt-plugin:media-plus.videoSpeed:speed'), '1.5', 'X\'s own changes are not remembered as yours');
// GIFs play at their own speed.
const gif = video({ currentSrc: 'https://video.twimg.com/tweet_video/AbC.mp4' });
env.add(post(player(gif)));
env.doc.emit('play', { type: 'play', target: gif });
assert.equal(gif.playbackRate, 1);
// The keys and the palette act on the video that is playing (the last one started).
first.paused = true;
const up = key('ArrowUp', 'ArrowUp');
env.doc.emit('keydown', up);
assert.ok(up.prevented);
assert.equal(second.playbackRate, 3);
env.api.commands.run(`${OWNER}:videoSpeed.slower`);
env.api.commands.run(`${OWNER}:videoSpeed.slower`);
assert.equal(second.playbackRate, 1.75);
assert.deepEqual(env.toasts, ['Speed 3×', 'Speed 2×', 'Speed 1.75×']);
assert.equal(first.playbackRate, 1.5, 'the paused one is left alone');
// Off: the bar, the sheet, the commands and the listeners are gone; videos keep playing as they are.
env.registry.setFlags(mediaOnly({}));
assert.ok(!box.children.some(child => child.className === BAR_CLASS));
assert.equal(box.children.length, 1);
assert.deepEqual([env.live(), env.api.commands.list(), env.listeners()], [[], [], idle]);
assert.equal(second.playbackRate, 1.75);
env.registry.dispose();
});
test('video speed: without "remember" every video starts normal; without the button only the keys remain; no video, a note', () => {
const clip = video();
const box = player(clip);
const env = domPage('/home', post(box));
env.store.set('bt-plugin:media-plus.videoSpeed:speed', '2');
env.store.set('bt-plugin:media-plus.videoSpeed:settings', JSON.stringify({ remember: false, button: false }));
env.registry.setFlags(mediaOnly({ 'media.videoSpeed': true }));
assert.equal(box.children.length, 1, 'no button');
env.doc.emit('play', { type: 'play', target: clip });
assert.equal(clip.playbackRate, 1);
env.api.commands.run(`${OWNER}:videoSpeed.faster`);
assert.equal(clip.playbackRate, 1.25);
assert.equal(env.store.get('bt-plugin:media-plus.videoSpeed:speed'), '2', 'nothing is stored while "remember" is off');
clip.remove();
env.api.commands.run(`${OWNER}:videoSpeed.faster`);
assert.equal(env.toasts.at(-1), 'No video to change the speed of');
env.registry.dispose();
// A stored speed that is not one is ignored.
assert.deepEqual(normalizeSettings(SPEED_SCHEMA, { remember: 'yes' }), { remember: true, button: true });
});
// ---- Picture-in-picture ----
test('picture-in-picture: asks the webview on the click, brings the video back, and says when it could not', async () => {
const calls: string[] = [];
const doc = { pictureInPictureElement: null as unknown, exitPictureInPicture: async () => { calls.push('exit'); } };
const clip = { disablePictureInPicture: true, requestPictureInPicture: async () => { calls.push('request'); } };
type Args = Parameters<typeof togglePictureInPicture>;
const toggle = (document: unknown, element: unknown) => togglePictureInPicture(document as Args[0], element as Args[1]);
assert.equal(await toggle(doc, clip), true);
assert.equal(clip.disablePictureInPicture, false, 'X\'s "not poppable" mark is lifted for the video you clicked');
doc.pictureInPictureElement = clip;
assert.equal(await toggle(doc, clip), true);
assert.deepEqual(calls, ['request', 'exit']);
assert.equal(await toggle({}, { requestPictureInPicture: async () => { throw new Error('NotAllowedError'); } }), false);
assert.equal(await toggle({}, {}), false, 'a webview without it');
// In the page: one bar for both video features; each takes only its own control away.
const requested: El[] = [];
const clipEl = video({ requestPictureInPicture: async () => { requested.push(clipEl); } });
const box = player(clipEl);
const env = domPage('/home', post(box));
const idle = env.listeners();
(env.doc as Record<string, unknown>).pictureInPictureEnabled = true;
env.registry.setFlags(mediaOnly({ 'media.videoSpeed': true, 'media.pictureInPicture': true }));
const bars = box.children.filter(child => child.className === BAR_CLASS);
assert.equal(bars.length, 1);
assert.equal(bars[0]!.children.length, 2);
const button = bars[0]!.querySelector('[data-bt-media-pip]')!;
assert.deepEqual([button.title, button.tabIndex, button.getAttribute('aria-pressed')], ['Picture-in-picture', -1, 'false']);
button.click();
await tick();
assert.deepEqual(requested, [clipEl]);
env.doc.emit('enterpictureinpicture', { type: 'enterpictureinpicture', target: clipEl });
assert.equal(button.getAttribute('aria-pressed'), 'true');
env.doc.emit('leavepictureinpicture', { type: 'leavepictureinpicture', target: clipEl });
assert.equal(button.getAttribute('aria-pressed'), 'false');
// The webview refuses: one note, nothing else happens.
clipEl.requestPictureInPicture = async () => { throw new Error('NotAllowedError'); };
button.click();
await tick();
assert.deepEqual(env.toasts, ['Picture-in-picture did not open']);
env.registry.setFlags(mediaOnly({ 'media.pictureInPicture': true }));
assert.deepEqual(box.children.find(child => child.className === BAR_CLASS)!.children.map(child => child.hasAttribute('data-bt-media-pip')), [true], 'speed off: its control is gone, the bar stays');
env.registry.setFlags(mediaOnly({}));
assert.equal(box.children.length, 1, 'both off: the bar is gone');
assert.deepEqual([env.live(), env.listeners()], [[], idle]);
env.registry.dispose();
// A webview without picture-in-picture: no button, and it says so once, not on every page.
const plainBox = player(video());
const plain = domPage('/home', post(plainBox));
plain.registry.setFlags(mediaOnly({ 'media.pictureInPicture': true }));
plain.go('/notifications');
plain.go('/i/chat');
plain.go('/home');
assert.equal(plainBox.children.length, 1);
assert.deepEqual(plain.toasts, ['Picture-in-picture is not available on this system']);
plain.registry.dispose();
});
// ---- Alt text viewer ----
test('alt text: what X\'s alt says, and which photos get a row', () => {
assert.equal(readAlt('A red bicycle against a wall', true), 'A red bicycle against a wall');
assert.equal(readAlt(' padded ', true), 'padded');
for (const none of ['', ' ', null, undefined]) assert.equal(readAlt(none, true), null);
assert.equal(readAlt('Image', true), null, 'X\'s own label for an undescribed photo');
assert.equal(readAlt('Image', false), 'Image', 'on a page in another language it cannot be told from a description: shown');
assert.equal(readAlt('Bild', false), 'Bild');
assert.equal(readAlt('Image of a cat', true), 'Image of a cat');
assert.deepEqual(altItems(['A cat', 'Image', ''], true, false), [{ index: 1, text: 'A cat' }]);
assert.deepEqual(altItems(['A cat', 'Image', ''], true, true), [{ index: 1, text: 'A cat' }, { index: 2, text: null }, { index: 3, text: null }]);
// Not English: only a photo with no text at all is certainly undescribed.
assert.deepEqual(altItems(['Bild', ''], false, true), [{ index: 1, text: 'Bild' }, { index: 2, text: null }]);
assert.deepEqual(altItems([], true, true), []);
});
test('alt text: an ALT button under the photos shows the description as text to select and copy; nothing opens the post', async () => {
const article = post(photo('A red bicycle against a wall'), photo('Image'));
let opened = 0;
article.addEventListener('click', () => { opened += 1; });
const env = domPage('/home', article, post(photo('Image')), post(new El('div', { 'data-testid': 'tweetText' })));
env.registry.setFlags(mediaOnly({ 'media.altText': true }));
const panels = () => env.body.querySelectorAll('.bt-media-alt');
assert.equal(panels().length, 1, 'only the post with a described photo');
const panel = panels()[0]!;
const column = article.children[0]!;
assert.equal(column.children.indexOf(panel), column.children.length - 2, 'right above the post\'s action bar');
assert.equal(panel.children.length, 1);
const chip = panel.querySelector('.bt-media-alt-chip')!;
const body = panel.querySelector('.bt-media-alt-body')!;
assert.deepEqual([chip.text, chip.getAttribute('aria-expanded'), body.hidden], ['ALT 1', 'false', true]);
chip.click();
assert.deepEqual([chip.getAttribute('aria-expanded'), body.hidden], ['true', false]);
assert.equal(panel.querySelector('.bt-media-alt-text')!.text, 'A red bicycle against a wall');
panel.querySelector('.bt-media-alt-copy')!.click();
await tick();
assert.deepEqual([env.copied, env.toasts], [['A red bicycle against a wall'], ['Description copied']]);
assert.equal(opened, 0, 'clicks in the panel never open the post');
assert.equal(panel.fire('keydown', { key: 'Enter' }).stopped, true);
assert.equal(panel.fire('keydown', { key: 'l' }).stopped, false, 'X\'s own keys pass');
// X renders the same photos again: what you opened stays open.
env.mutate();
assert.equal(panels()[0], panel);
assert.equal(body.hidden, false);
// Settings: open without a click, and a mark on photos without a description.
env.api.storage('media-plus.altText').set('settings', { open: true, missing: true });
env.registry.setFlags(mediaOnly({}));
assert.equal(panels().length, 0, 'off: every panel is gone');
env.registry.setFlags(mediaOnly({ 'media.altText': true }));
assert.deepEqual(panels().map(item => item.text), ['ALT 1 A red bicycle against a wall Copy No description (2)', 'No description']);
assert.equal(panels()[0]!.querySelector('.bt-media-alt-body')!.hidden, false);
// A post that scrolls in later gets its panel too.
env.add(post(photo('Two people at a table')));
assert.equal(panels().length, 3);
assert.equal(panels()[2]!.querySelector('.bt-media-alt-chip')!.text, 'ALT');
env.registry.dispose();
assert.equal(panels().length, 0);
});
// ---- Blur media until hover ----
test('blur media: the rules per setting; "only accounts I don\'t follow" never blurs what is unknown', () => {
const defaults = normalizeSettings(BLUR_SCHEMA, null) as BlurSettings;
assert.deepEqual(defaults, { enabled: true, scope: 'all', strength: 24 });
assert.deepEqual(normalizeSettings(BLUR_SCHEMA, { scope: 'everyone', strength: 500 }), { enabled: true, scope: 'all', strength: 60 });
const all = blurCss(defaults);
for (const media of ['tweetPhoto', 'videoPlayer', 'card.layoutLarge.media', 'card.layoutSmall.media']) assert.ok(all.includes(`article[data-testid="tweet"] [data-testid="${media}"]`), media);
assert.ok(all.includes('filter: blur(24px)'));
assert.ok(all.includes('[data-testid="tweetPhoto"]:hover') && all.includes('filter: none'), 'the pointer on it shows it');
assert.ok(all.includes('section li a[href*="/photo/"]'), 'a profile\'s Media tab too');
assert.doesNotMatch(all, /data-bt-media-blur|UserAvatar|:has\(/);
const some = blurCss({ ...defaults, scope: 'notFollowed', strength: 10 });
assert.ok(some.includes('article[data-testid="tweet"][data-bt-media-blur] [data-testid="tweetPhoto"]'));
assert.ok(some.includes('filter: blur(10px)'));
// Every rule of that mode is inside a marked post: nothing unmarked is ever blurred.
for (const line of some.split('\n')) if (line.includes('data-testid')) assert.ok(line.includes('[data-bt-media-blur]'), line);
assert.equal(blurCss({ ...defaults, enabled: false }), '');
assert.equal(blursAuthor(false, false), true, 'X said you do not follow them');
assert.equal(blursAuthor(true, false), false);
assert.equal(blursAuthor(null, false), false, 'X has not said: unknown, not blurred');
assert.equal(blursAuthor(undefined, false), false, 'an account never seen: unknown, not blurred');
assert.equal(blursAuthor(false, true), false, 'your own posts');
});
test('blur media: on, off with its command and Alt+Shift+B, and in the narrow mode no post is marked while X has not said', () => {
const article = post(photo('Image'));
const env = domPage('/home', article);
const idle = env.listeners();
env.registry.setFlags(mediaOnly({ 'media.blurMedia': true }));
assert.deepEqual(env.live(), ['wbf-media.blurMedia']);
assert.equal(env.api.commands.list().find(command => command.id === `${OWNER}:blurMedia.toggle`)!.title, 'Toggle media blur');
const press = key('KeyB', 'B');
env.doc.emit('keydown', press);
assert.ok(press.prevented);
assert.deepEqual(env.live(), [], 'paused: no rule');
assert.equal(JSON.parse(env.store.get('bt-plugin:media-plus.blurMedia:settings')!).enabled, false);
env.api.commands.run(`${OWNER}:blurMedia.toggle`);
assert.deepEqual(env.live(), ['wbf-media.blurMedia']);
assert.deepEqual(env.toasts, ['Media blur off', 'Media blur on']);
// Only accounts I don't follow: this page has no answer from X about the author (the test page
// has no user data at all), so the post is not marked and nothing of it is blurred.
env.api.storage('media-plus.blurMedia').set('settings', { enabled: true, scope: 'notFollowed', strength: 24 });
env.registry.setFlags(mediaOnly({}));
env.registry.setFlags(mediaOnly({ 'media.blurMedia': true }));
env.mutate();
assert.equal(article.hasAttribute('data-bt-media-blur'), false);
// A mark left from before is taken away when the feature stops.
article.setAttribute('data-bt-media-blur', '');
env.registry.setFlags(mediaOnly({}));
assert.equal(article.hasAttribute('data-bt-media-blur'), false);
assert.deepEqual([env.live(), env.api.commands.list(), env.listeners()], [[], [], idle]);
env.registry.dispose();
});