// 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 = {}) => 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(); children: El[] = []; parentElement: El | null = null; ownerDocument: unknown = null; className = ''; textContent = ''; title = ''; type = ''; hidden = false; tabIndex = 0; root = false; handlers = new Map(); [extra: string]: unknown; constructor(tag: string, attrs: Record = {}, 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 = {}) => 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).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).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 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; 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).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(); });