wayBetter for X
This commit is contained in:
commit
df5dff619e
720 changed files with 147465 additions and 0 deletions
573
apps/desktop/tests/area-media.test.ts
Normal file
573
apps/desktop/tests/area-media.test.ts
Normal 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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue