diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2255173..531fbfc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -157,7 +157,13 @@ scripts/ Per-target extension build, manifest derivation, release, icons importing a site entry alone installs no listeners or observers. The background worker and popup remain separate extension entry points. -**YouTube is rules only.** The adapter reads public settings, sets +**YouTube is rules only, on both of its sites.** `www.youtube.com` and +`m.youtube.com` run the same adapter: it sets attributes and the stylesheet +decides what they mean, so the mobile site cost selectors rather than code. +`ytd-` elements are the desktop site, `ytm-` the mobile one, and the newer +`*-view-model` elements and `yt*ViewModel` classes are shared; a selector that +matches nothing on the site in front of it costs nothing, so the pairs sit +together per feature. The adapter reads public settings, sets `data-aitf-yt-shorts` and `data-aitf-yt-comments` on ``, plus `data-aitf-yt-thumbs="blurred"` or `"hidden"`, and follows storage changes. Hidden thumbnails give up their height; the badges that sat on the picture diff --git a/README.md b/README.md index f5e1ecd..2c3bd22 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ Describe what you want to see — or never see again — in plain language. - **Any OpenAI-compatible provider.** OpenRouter, OpenAI, Anthropic, or your own endpoint. - **YouTube, the quiet way.** Hide Shorts, hide comments, and show, blur or - drop thumbnails. Durations stay. No model involved. + drop thumbnails. Durations stay. No model involved. Works on the mobile site + too. - **Greyscale.** Wash the colour out of the chrome, the content, or both, on X and on YouTube. Less pull, same information. diff --git a/manifest.json b/manifest.json index 942ce71..a645cc9 100644 --- a/manifest.json +++ b/manifest.json @@ -8,6 +8,7 @@ "https://x.com/*", "https://twitter.com/*", "https://www.youtube.com/*", + "https://m.youtube.com/*", "https://openrouter.ai/*", "https://api.openai.com/*", "https://api.anthropic.com/*" @@ -31,7 +32,7 @@ "run_at": "document_start" }, { - "matches": ["https://www.youtube.com/*"], + "matches": ["https://www.youtube.com/*", "https://m.youtube.com/*"], "js": ["content.js"], "css": ["content.css"], "run_at": "document_start" diff --git a/privacy-policy.md b/privacy-policy.md index 814e2af..b9eb271 100644 --- a/privacy-policy.md +++ b/privacy-policy.md @@ -51,8 +51,9 @@ or [Anthropic](https://www.anthropic.com/legal/privacy). - **Storage** — to save your settings, key, and decision cache locally. - **Access to x.com and twitter.com** — to read posts on the page and hide the ones that match your criteria. -- **Access to www.youtube.com** — to apply the YouTube page rules you turn on. - Nothing on YouTube is read or sent anywhere; the rules are stylesheet rules. +- **Access to www.youtube.com and m.youtube.com** — to apply the YouTube page + rules you turn on, on the desktop and mobile sites alike. Nothing on YouTube + is read or sent anywhere; the rules are stylesheet rules. - **Access to your provider's API** (`openrouter.ai`, `api.openai.com`, `api.anthropic.com`, or a custom endpoint you enter) — to send classification requests. A custom endpoint asks for its own permission when you save it. diff --git a/src/background/index.ts b/src/background/index.ts index cfaef3f..7770085 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -131,7 +131,8 @@ chrome.runtime.onMessage.addListener((raw: unknown, sender, respond) => { const message = yield* Schema.decodeUnknown(Request)(raw); const privileged = fromExtensionPage(sender); const fromSite = - sender.tab && /^https:\/\/(?:x\.com|twitter\.com|www\.youtube\.com)\//.test(sender.url ?? ''); + sender.tab && + /^https:\/\/(?:x\.com|twitter\.com|(?:www|m)\.youtube\.com)\//.test(sender.url ?? ''); if (!privileged && !(fromSite && contentRequests.has(message.type))) { return yield* new OperationError({ message: 'This operation is not available to content scripts.', diff --git a/src/common/settings.ts b/src/common/settings.ts index 6a69f34..11011b2 100644 --- a/src/common/settings.ts +++ b/src/common/settings.ts @@ -41,6 +41,7 @@ export const Settings = Schema.Struct({ presets: Schema.Array(Preset), corrections: Schema.Array(Correction).pipe(Schema.maxItems(MAX_CORRECTIONS)), notInterested: Schema.Boolean, + filterComments: Schema.Boolean, analyzeImages: Schema.Boolean, maxImagesPerPost: boundedInt(1, 4), hideStyle: Schema.Literal('collapse', 'blur'), @@ -83,6 +84,7 @@ export const defaults: Settings = { presets: [], corrections: [], notInterested: false, + filterComments: false, analyzeImages: false, maxImagesPerPost: 2, hideStyle: 'collapse', @@ -138,6 +140,7 @@ export const siteOrigins = [ 'https://x.com/*', 'https://twitter.com/*', 'https://www.youtube.com/*', + 'https://m.youtube.com/*', ] as const; /** The origin the configured provider is reached at, if it has a usable one. A diff --git a/src/index.ts b/src/index.ts index ed8aa2e..38cfed0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export function startSite(site: Pick = locati case 'twitter.com': return startX(); case 'www.youtube.com': + case 'm.youtube.com': return startYouTube(); } } diff --git a/src/popup/App.tsx b/src/popup/App.tsx index d27ea74..536faad 100644 --- a/src/popup/App.tsx +++ b/src/popup/App.tsx @@ -9,7 +9,7 @@ import { ListSheet, type ListKey } from './ListSheet'; import { ModelBrowser } from './ModelBrowser'; import { Rail, type Section, type Site } from './Rail'; import { XPanel, type XTab } from './XPanel'; -import { YouTubePanel } from './YouTubePanel'; +import { YouTubePanel, type YouTubeTab } from './YouTubePanel'; import { Notice } from './ui'; type Form = { draft: Settings; saved: Settings }; @@ -18,7 +18,7 @@ const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b) const noop = () => {}; /** `https://x.com/*` reads as x.com to the person being asked about it. */ const host = (origin: string) => origin.replace(/^https:\/\//, '').replace(/\/\*$/, ''); -const filtered = /^https:\/\/(?:x\.com|twitter\.com|www\.youtube\.com)\//; +const filtered = /^https:\/\/(?:x\.com|twitter\.com|(?:www|m)\.youtube\.com)\//; /** The page the reader was looking at when they opened Sharp. Desktop Chrome * floats the popup above the page, so that is simply the active tab — @@ -65,6 +65,7 @@ export function App() { const [section, setSection] = useState
('x'); const [xTab, setXTab] = useState('filtering'); const [generalTab, setGeneralTab] = useState('connection'); + const [youtubeTab, setYoutubeTab] = useState('filtering'); const [overlay, setOverlay] = useState(null); const [stats, setStats] = useState(null); const [bytes, setBytes] = useState(0); @@ -116,7 +117,7 @@ export function App() { if (/^https:\/\/(?:x|twitter)\.com\//.test(url)) { setSite('x'); setThread(threadId(new URL(url).pathname)); - } else if (/^https:\/\/www\.youtube\.com\//.test(url)) { + } else if (/^https:\/\/(?:www|m)\.youtube\.com\//.test(url)) { setSite('youtube'); setSection('youtube'); } @@ -294,9 +295,13 @@ export function App() { ['filtering', 'Filtering'], ['rules', 'Rules'], ['activity', 'Activity'], + ['misc', 'Misc'], ] as const) : section === 'youtube' - ? ([['filtering', 'Filtering']] as const) + ? ([ + ['filtering', 'Filtering'], + ['misc', 'Misc'], + ] as const) : ([ ['connection', 'Connection'], ['appearance', 'Appearance'], @@ -309,11 +314,12 @@ export function App() { class="tab" role="tab" aria-selected={ - section === 'x' ? xTab === id : section === 'youtube' || generalTab === id + (section === 'x' ? xTab : section === 'youtube' ? youtubeTab : generalTab) === id } onClick={() => { if (section === 'x') setXTab(id as XTab); - else if (section === 'general') setGeneralTab(id as GeneralTab); + else if (section === 'youtube') setYoutubeTab(id as YouTubeTab); + else setGeneralTab(id as GeneralTab); }} > {label} @@ -394,7 +400,7 @@ export function App() { } /> ) : section === 'youtube' ? ( - + ) : (
+
+

Comments

+ update('filterComments', value)} + /> +
+ {settings.corrections.length > 0 && (

Corrections

@@ -411,20 +445,6 @@ export function XPanel({ onChange={(value) => update('analyzeImages', value)} />
- -
-

Misc

- update('greyscaleUi', value)} - /> - update('greyscaleContent', value)} - /> -
); } diff --git a/src/popup/YouTubePanel.tsx b/src/popup/YouTubePanel.tsx index cd1bc12..f7723e0 100644 --- a/src/popup/YouTubePanel.tsx +++ b/src/popup/YouTubePanel.tsx @@ -6,7 +6,29 @@ const thumbnailOptions = [ { value: 'hidden', label: 'Hidden' }, ] as const; -export function YouTubePanel({ settings, update }: SettingsEditor) { +export type YouTubeTab = 'filtering' | 'misc'; + +export function YouTubePanel({ settings, update, tab }: SettingsEditor & { tab: YouTubeTab }) { + if (tab === 'misc') { + return ( +
+
+

Greyscale

+ update('youtubeGreyscaleUi', value)} + /> + update('youtubeGreyscaleContent', value)} + /> +
+
+ ); + } + return (
@@ -38,19 +60,6 @@ export function YouTubePanel({ settings, update }: SettingsEditor) { onChange={(value) => update('thumbnails', value)} />
-
-

Misc

- update('youtubeGreyscaleUi', value)} - /> - update('youtubeGreyscaleContent', value)} - /> -
); } diff --git a/src/x/post-menu-dom.ts b/src/x/post-menu-dom.ts index 54fe189..b3399bb 100644 --- a/src/x/post-menu-dom.ts +++ b/src/x/post-menu-dom.ts @@ -1,7 +1,9 @@ -// Desktop X portals its post dropdown into #layers. No generated class names or -// translated labels are used for selection. Unknown layouts are left untouched. +// X portals its post menu into #layers: a dropdown at desktop widths, a bottom +// sheet at phone widths. No generated class names or translated labels are used +// for selection. Unknown layouts are left untouched. export const moreSelector = '[data-testid="caret"]'; -export const dropdownSelector = '[data-testid="Dropdown"]'; +export const sheetSelector = '[data-testid="sheetDialog"]'; +export const dropdownSelector = `[data-testid="Dropdown"], ${sheetSelector}`; const itemSelector = '[role="menuitem"]'; export function visible(element: HTMLElement): boolean { @@ -17,7 +19,11 @@ export function visible(element: HTMLElement): boolean { /** The rendered menu is the source of truth, including when its click was missed * or its originating article has been virtualized away. */ export function menuPost(dropdown: HTMLElement): { id: string; handle: string } | null { - if (!dropdown.closest('[role="menu"]') || !visible(dropdown)) return null; + // A sheet has no menu role around it. Other sheets (repost, share) use the + // same container, which is fine: none carries the engagements link below, + // and that link, not the container, is what identifies the post. + const menu = dropdown.closest('[role="menu"]') || dropdown.matches(sheetSelector); + if (!menu || !visible(dropdown)) return null; const links = dropdown.querySelectorAll('a[data-testid="tweetEngagements"]'); if (links.length !== 1) return null; let url: URL; diff --git a/src/x/post-menu.ts b/src/x/post-menu.ts index 6853cc8..b93af04 100644 --- a/src/x/post-menu.ts +++ b/src/x/post-menu.ts @@ -8,6 +8,7 @@ import { menuPost, moreSelector, nativeItems, + sheetSelector, visible, } from './post-menu-dom'; @@ -91,7 +92,9 @@ export function installPostMenu( root.setAttribute('aria-label', 'Sharp'); matchMenuStyle(root, sample); // Below X's own actions: the extension adds to the menu, it does not lead it. - dropdown.append(root); + // Directly after the last of them, so a sheet's Cancel button stays last. + if (sample.parentElement === dropdown) sample.after(root); + else dropdown.append(root); const path = location.pathname; const isCurrent = () => { const identity = menuPost(dropdown); @@ -127,6 +130,16 @@ export function installPostMenu( }), ); if (current?.root === root) cancel(); + // A sheet closes from its backdrop and need not listen for Escape. Only + // if it is still open a frame later, so it is never closed twice. + if (dropdown.matches(sheetSelector)) { + const mask = dropdown.parentElement?.querySelector( + ':scope > [data-testid="mask"]', + ); + requestAnimationFrame(() => { + if (mask?.isConnected && dropdown.isConnected && visible(dropdown)) mask.click(); + }); + } }, }); const removeKeyboard = installMenuKeyboard(dropdown, root); diff --git a/src/x/rules.ts b/src/x/rules.ts index dc1575e..ce97860 100644 --- a/src/x/rules.ts +++ b/src/x/rules.ts @@ -17,6 +17,10 @@ export function createRules(settings: PublicSettings) { if ( !settings.enabled || context || + // Every article on a thread page that is not the post itself or one of + // its ancestors is a reply, and replies are left alone unless the reader + // has asked for them. `thread` is empty everywhere but a thread page. + (Boolean(thread) && !settings.filterComments) || settings.bypassedThreads.includes(thread) || /^\/(i\/bookmarks|bookmarks|notifications|messages|settings)(?:\/|$)/.test(path) || actors.some((actor) => allowed.has(actor)) diff --git a/src/x/thread-control.tsx b/src/x/thread-control.tsx index 3e00cbf..87e8743 100644 --- a/src/x/thread-control.tsx +++ b/src/x/thread-control.tsx @@ -47,7 +47,38 @@ export function replySection(id: string): HTMLElement | null { if (!section) continue; if (!candidates.includes(section)) candidates.push(section); } - return candidates.length === 1 ? candidates[0]! : null; + if (candidates.length === 1) return candidates[0]!; + // More than one match means the composer was not identified confidently, and + // guessing is worse than showing nothing. None at all is the mobile layout. + return candidates.length === 0 ? firstReplyAnchor(id) : null; +} + +/** Where the control goes when there is no inline composer to sit above. + * Replying on mobile x.com opens a view of its own, so the equivalent place is + * the top of the replies: inside the first reply's cell, above its content. + * Inside, because a sibling of a virtualised cell is not measured — the same + * reason the desktop path descends into the cell it finds. */ +function firstReplyAnchor(id: string): HTMLElement | null { + const focal = focalArticle(id); + const cell = focal?.closest('[data-testid="cellInnerDiv"]'); + if (!cell || !focal?.closest('[data-testid="primaryColumn"]')) return null; + for (let next = cell.nextElementSibling; next; next = next.nextElementSibling) { + if (!(next instanceof HTMLElement) || next.matches('.aitf-thread-control')) continue; + // X separates entries with empty cells, one straight after the focal post. + if (!next.textContent?.trim() && !next.querySelector(articleSelector)) continue; + // Anchor on the first reply, and only on a reply: anything else after the + // focal post is X's own furniture and not somewhere to mount a control. + if (!next.matches('[data-testid="cellInnerDiv"]') || !next.querySelector(articleSelector)) { + return null; + } + return ( + [...next.children].find( + (child): child is HTMLElement => + child instanceof HTMLElement && !child.matches('.aitf-thread-control'), + ) ?? null + ); + } + return null; } type Props = { @@ -92,7 +123,7 @@ function ThreadButton({ id, bypassed, send, isCurrent }: Props) { > {active ? : } - {active ? 'Filter comments in this thread' : 'Show all comments in this thread'} + {active ? 'Showing all comments in this thread' : 'Show all comments in this thread'} {active && !error && ( @@ -119,6 +150,11 @@ export class ThreadControl { constructor(private readonly send: typeof request = request) {} update(settings: PublicSettings) { + // Nothing to switch off for one thread when no reply is judged anywhere. + if (!settings.filterComments) { + this.dispose(); + return; + } const id = currentThread(); const section = id ? replySection(id) : null; if (!section) { diff --git a/src/youtube/style.css b/src/youtube/style.css index 2e1be28..c0a3320 100644 --- a/src/youtube/style.css +++ b/src/youtube/style.css @@ -1,5 +1,10 @@ /* YouTube rules are plain CSS keyed on attributes the content script sets on - . Nothing here runs a model; each attribute is one popup toggle. */ + . Nothing here runs a model; each attribute is one popup toggle. + + Both YouTube sites are covered. `ytd-` elements are www.youtube.com, `ytm-` + are m.youtube.com, and the newer `*-view-model` elements and `yt*ViewModel` + classes are shared by both. A selector that matches nothing on the site in + front of it costs nothing, so the pairs live together, per feature. */ /* Shorts: the shelf, the sidebar entries, the Shorts tab on channels and any single Short that lands in a feed, search results or notifications. */ @@ -20,7 +25,18 @@ html[data-aitf-yt-shorts] ytd-guide-entry-renderer:has(a[href='/shorts']), html[data-aitf-yt-shorts] ytd-mini-guide-entry-renderer:has(a[title='Shorts']), html[data-aitf-yt-shorts] ytd-mini-guide-entry-renderer:has(a[href='/shorts']), html[data-aitf-yt-shorts] yt-tab-shape[tab-title='Shorts'], -html[data-aitf-yt-shorts] yt-chip-cloud-chip-renderer:has(yt-formatted-string[title='Shorts']) { +html[data-aitf-yt-shorts] yt-chip-cloud-chip-renderer:has(yt-formatted-string[title='Shorts']), +html[data-aitf-yt-shorts] ytm-reel-shelf-renderer, +html[data-aitf-yt-shorts] ytm-shorts-lockup-view-model, +html[data-aitf-yt-shorts] ytm-shorts-lockup-view-model-v2, +html[data-aitf-yt-shorts] ytm-item-section-renderer:has(> lazy-list > ytm-reel-shelf-renderer:only-child), +html[data-aitf-yt-shorts] ytm-rich-item-renderer:has(a[href^='/shorts/']), +html[data-aitf-yt-shorts] ytm-video-with-context-renderer:has(a[href^='/shorts/']), +html[data-aitf-yt-shorts] ytm-compact-video-renderer:has(a[href^='/shorts/']), +html[data-aitf-yt-shorts] ytm-chip-cloud-chip-renderer:has([title='Shorts']), +/* Mobile puts navigation in a bar along the bottom rather than a sidebar. */ +html[data-aitf-yt-shorts] ytm-pivot-bar-item-renderer:has(a[href^='/shorts']), +html[data-aitf-yt-shorts] ytm-pivot-bar-item-renderer:has(.pivot-shorts) { display: none !important; } @@ -28,7 +44,10 @@ html[data-aitf-yt-shorts] yt-chip-cloud-chip-renderer:has(yt-formatted-string[ti (duration, live, watched) is untouched, so cards still scan. */ html[data-aitf-yt-thumbs='blurred'] yt-thumbnail-view-model, html[data-aitf-yt-thumbs='blurred'] ytd-thumbnail, -html[data-aitf-yt-thumbs='blurred'] ytd-playlist-thumbnail { +html[data-aitf-yt-thumbs='blurred'] ytd-playlist-thumbnail, +html[data-aitf-yt-thumbs='blurred'] ytm-thumbnail-cover, +html[data-aitf-yt-thumbs='blurred'] .video-thumbnail-container, +html[data-aitf-yt-thumbs='blurred'] .video-thumbnail-container-fluid { overflow: hidden !important; border-radius: 12px; } @@ -36,7 +55,9 @@ html[data-aitf-yt-thumbs='blurred'] .ytThumbnailViewModelImage, html[data-aitf-yt-thumbs='blurred'] ytd-thumbnail yt-image, html[data-aitf-yt-thumbs='blurred'] ytd-thumbnail img, html[data-aitf-yt-thumbs='blurred'] ytd-playlist-thumbnail img, -html[data-aitf-yt-thumbs='blurred'] .shortsLockupViewModelHostThumbnailParentContainer img { +html[data-aitf-yt-thumbs='blurred'] .shortsLockupViewModelHostThumbnailParentContainer img, +html[data-aitf-yt-thumbs='blurred'] ytm-thumbnail-cover img, +html[data-aitf-yt-thumbs='blurred'] .video-thumbnail-container img { filter: blur(22px) saturate(0.7) !important; transform: scale(1.15); } @@ -46,7 +67,10 @@ html[data-aitf-yt-thumbs='blurred'] .shortsLockupViewModelHostThumbnailParentCon duration stays exactly where the eye expects it: bottom right. */ html[data-aitf-yt-thumbs='hidden'] yt-thumbnail-view-model, html[data-aitf-yt-thumbs='hidden'] ytd-thumbnail, -html[data-aitf-yt-thumbs='hidden'] ytd-playlist-thumbnail { +html[data-aitf-yt-thumbs='hidden'] ytd-playlist-thumbnail, +html[data-aitf-yt-thumbs='hidden'] ytm-thumbnail-cover, +html[data-aitf-yt-thumbs='hidden'] .video-thumbnail-container, +html[data-aitf-yt-thumbs='hidden'] .video-thumbnail-container-fluid { display: flex !important; flex-direction: column !important; align-items: flex-end !important; @@ -79,14 +103,19 @@ html[data-aitf-yt-thumbs='hidden'] ytd-thumbnail-overlay-resume-playback-rendere html[data-aitf-yt-thumbs='hidden'] ytd-thumbnail-overlay-toggle-button-renderer, html[data-aitf-yt-thumbs='hidden'] ytd-thumbnail-overlay-now-playing-renderer, html[data-aitf-yt-thumbs='hidden'] .ytThumbnailViewModelHostAdDisclosureBannerContainer, -html[data-aitf-yt-thumbs='hidden'] thumbnail-overlay-button-view-model { +html[data-aitf-yt-thumbs='hidden'] thumbnail-overlay-button-view-model, +html[data-aitf-yt-thumbs='hidden'] ytm-thumbnail-cover img, +html[data-aitf-yt-thumbs='hidden'] .video-thumbnail-container img, +html[data-aitf-yt-thumbs='hidden'] ytm-thumbnail-overlay-resume-playback-renderer { display: none !important; } html[data-aitf-yt-thumbs='hidden'] yt-thumbnail-bottom-overlay-view-model, html[data-aitf-yt-thumbs='hidden'] yt-thumbnail-overlay-badge-view-model, html[data-aitf-yt-thumbs='hidden'] .ytThumbnailBottomOverlayViewModelBadgeContainer, html[data-aitf-yt-thumbs='hidden'] ytd-thumbnail #overlays, -html[data-aitf-yt-thumbs='hidden'] ytd-thumbnail-overlay-time-status-renderer { +html[data-aitf-yt-thumbs='hidden'] ytd-thumbnail-overlay-time-status-renderer, +html[data-aitf-yt-thumbs='hidden'] ytm-thumbnail-overlay-time-status-renderer, +html[data-aitf-yt-thumbs='hidden'] .thumbnail-overlay-time-status-renderer { position: static !important; inset: auto !important; margin: 0 !important; @@ -119,7 +148,12 @@ html[data-aitf-yt-comments] ytd-comments#comments, html[data-aitf-yt-comments] ytd-comments-entry-point-header-renderer, html[data-aitf-yt-comments] ytd-engagement-panel-section-list-renderer[target-id='engagement-panel-comments-section'], -html[data-aitf-yt-comments] ytd-reel-player-overlay-renderer #comments-button { +html[data-aitf-yt-comments] ytd-reel-player-overlay-renderer #comments-button, +html[data-aitf-yt-comments] ytm-comments-entry-point-teaser-renderer, +html[data-aitf-yt-comments] ytm-comments-entry-point-header-renderer, +html[data-aitf-yt-comments] + ytm-engagement-panel[data-panel-identifier='engagement-panel-comments-section'], +html[data-aitf-yt-comments] .reel-comment-button { display: none !important; } @@ -148,7 +182,15 @@ html[data-aitf-yt-grey-ui] yt-touch-feedback-shape, html[data-aitf-yt-grey-ui] badge-shape, html[data-aitf-yt-grey-ui] yt-thumbnail-bottom-overlay-view-model, html[data-aitf-yt-grey-ui] yt-thumbnail-overlay-badge-view-model, -html[data-aitf-yt-grey-ui] ytd-thumbnail #overlays { +html[data-aitf-yt-grey-ui] ytd-thumbnail #overlays, +html[data-aitf-yt-grey-ui] ytm-mobile-topbar-renderer, +html[data-aitf-yt-grey-ui] ytm-pivot-bar-renderer, +html[data-aitf-yt-grey-ui] ytm-chip-cloud-renderer, +html[data-aitf-yt-grey-ui] ytm-menu-renderer, +html[data-aitf-yt-grey-ui] ytm-profile-icon, +html[data-aitf-yt-grey-ui] ytm-slim-owner-renderer, +html[data-aitf-yt-grey-ui] .ytm-channel-avatar, +html[data-aitf-yt-grey-ui] ytm-thumbnail-overlay-time-status-renderer { filter: grayscale(1) !important; } html[data-aitf-yt-grey-content] .ytThumbnailViewModelImage, @@ -163,6 +205,9 @@ html[data-aitf-yt-grey-content] #movie_player, html[data-aitf-yt-grey-content] ytd-reel-video-renderer #player-container, html[data-aitf-yt-grey-content] ytd-post-renderer #content-attachment, html[data-aitf-yt-grey-content] ytd-backstage-image-renderer, -html[data-aitf-yt-grey-content] ytd-video-preview { +html[data-aitf-yt-grey-content] ytd-video-preview, +html[data-aitf-yt-grey-content] ytm-thumbnail-cover img, +html[data-aitf-yt-grey-content] .video-thumbnail-container img, +html[data-aitf-yt-grey-content] #player { filter: grayscale(1) !important; } diff --git a/tests/comments.test.ts b/tests/comments.test.ts new file mode 100644 index 0000000..56c4886 --- /dev/null +++ b/tests/comments.test.ts @@ -0,0 +1,105 @@ +// @vitest-environment jsdom +import { afterEach, expect, it } from 'vitest'; +import { defaults, publicSettings, type Settings } from '../src/common/settings'; +import { createRules } from '../src/x/rules'; +import { ThreadControl } from '../src/x/thread-control'; + +const ID = '2099823495765017087'; +const settings = (patch: Partial = {}) => + publicSettings({ ...defaults, apiKeys: { openrouter: 'key' }, ...patch }); +const post = { handle: 'alice', text: 'a reply' }; + +afterEach(() => { + document.body.innerHTML = ''; + history.replaceState(null, '', '/'); +}); + +it('leaves replies alone by default', () => { + expect(defaults.filterComments).toBe(false); + expect(createRules(settings())(post, '', ID, false, `/bob/status/${ID}`)).toBe('show'); +}); + +it('judges replies once the reader turns comment filtering on', () => { + const rule = createRules(settings({ filterComments: true })); + expect(rule(post, '', ID, false, `/bob/status/${ID}`)).toBe('ai'); +}); + +it('keeps filtering the timeline, which has no thread, either way', () => { + expect(createRules(settings())(post, '', '', false, '/home')).toBe('ai'); +}); + +it('always shows the opened post and its ancestors', () => { + const rule = createRules(settings({ filterComments: true })); + expect(rule(post, '', ID, true, `/bob/status/${ID}`)).toBe('show'); +}); + +it('treats comment filtering off as absolute, like a bypassed thread', () => { + // Same precedent as "Show all comments in this thread": nothing under the + // post is hidden, blocked authors included. + const rule = createRules(settings({ blockedAuthors: ['alice'] })); + expect(rule(post, '', ID, false, `/bob/status/${ID}`)).toBe('show'); + expect(rule(post, '', '', false, '/home')).toBe('Blocked author'); +}); + +const article = (handle: string, id: string) => + ``; +const reply = `
${article('alice', '2')}
`; + +/** The shape of a desktop-width thread page, from a real capture: the inline + * reply box is a sibling of the opened post, inside the same cell. */ +function desktopThread() { + document.body.innerHTML = + `
` + + article('thenerd_be', ID) + + `
` + + `
` + + `
${reply}
`; + history.replaceState(null, '', `/thenerd_be/status/${ID}`); +} + +it('mounts above the inline reply box on the desktop layout', () => { + desktopThread(); + const control = new ThreadControl(async () => undefined as never); + control.update(settings({ filterComments: true })); + const root = document.querySelector('.aitf-thread-control'); + expect(root?.nextElementSibling?.getAttribute('data-testid')).toBe('inline_reply_offscreen'); + control.dispose(); +}); + +it('shows no thread button while comments are not filtered', () => { + desktopThread(); + const control = new ThreadControl(async () => undefined as never); + control.update(settings({ filterComments: true })); + expect(document.querySelector('.aitf-thread-control')).not.toBeNull(); + control.update(settings()); + expect(document.querySelector('.aitf-thread-control')).toBeNull(); +}); + +it('falls back to the top of the replies when there is no inline reply box', () => { + document.body.innerHTML = + `
` + + article('thenerd_be', ID) + + `
${reply}
`; + history.replaceState(null, '', `/thenerd_be/status/${ID}`); + const control = new ThreadControl(async () => undefined as never); + control.update(settings({ filterComments: true })); + const root = document.querySelector('.aitf-thread-control'); + expect(root?.nextElementSibling?.classList.contains('reply')).toBe(true); + control.dispose(); +}); + +it('steps over the empty cell X puts between the opened post and its replies', () => { + // As captured on Kiwi: an empty cell follows the focal post's. + const spacer = `
`; + document.body.innerHTML = + `
` + + article('thenerd_be', ID) + + `
${spacer}${reply}
`; + history.replaceState(null, '', `/thenerd_be/status/${ID}`); + const control = new ThreadControl(async () => undefined as never); + control.update(settings({ filterComments: true })); + const root = document.querySelector('.aitf-thread-control'); + expect(root?.nextElementSibling?.classList.contains('reply')).toBe(true); + control.dispose(); +}); diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index 60c6569..ec1c7d5 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -41,6 +41,13 @@ it('packages the same files whichever key names the background script', () => { it('asks for exactly the origins the manifest declares for the filtered sites', () => { for (const origin of siteOrigins) expect(base.host_permissions).toContain(origin); + // Both ways: a site added to the manifest but not here would be injected into + // and never asked for, which on Firefox means a content script that silently + // never runs. A site here but not in the manifest asks for nothing useful. + const injected = new Set( + base.content_scripts.flatMap((script: { matches: string[] }) => script.matches), + ); + expect([...injected].sort()).toEqual([...siteOrigins].sort()); }); it('derives a provider origin, and none from a URL that cannot be one', () => { diff --git a/tests/post-menu.test.ts b/tests/post-menu.test.ts index 73aa439..a3edee9 100644 --- a/tests/post-menu.test.ts +++ b/tests/post-menu.test.ts @@ -71,3 +71,60 @@ it('offers to teach the model only when a model is configured', () => { act(() => rows.at(-1)!.click()); expect(correct).toHaveBeenCalledWith('42', 'hide'); }); + +/** The phone-width menu from a Kiwi capture: a bottom sheet, no menu role, + * a backdrop beside it and X's own Cancel button last. */ +const sheet = (items: string) => `
+
+
+
Not interested in this post
+
Follow @cifilter
+
Block @cifilter
+ ${items} + + Request Community Note + + +
+
`; + +it('adds author rules to the bottom sheet X shows at phone widths, above Cancel', async () => { + const { chrome } = mockChrome(); + chrome.runtime.sendMessage.mockResolvedValue({ ok: true }); + document.body.innerHTML = sheet(`View post activity`); + const mask = document.querySelector('[data-testid="mask"]')!; + const dismissed = vi.fn(); + mask.addEventListener('click', dismissed); + act(() => { + dispose = installPostMenu(() => publicSettings(defaults)); + }); + const rows = [...document.querySelectorAll('.aitf-menu-action')]; + expect(rows.map((row) => row.textContent)).toEqual([ + 'Never filter @cifilter', + 'Always hide @cifilter', + ]); + const children = [...document.querySelector('[data-testid="sheetDialog"]')!.children]; + expect(children.at(-1)?.textContent).toBe('Cancel'); + expect(children.at(-2)?.classList.contains('aitf-post-menu')).toBe(true); + await act(async () => { + rows[0]!.click(); + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + type: 'SET_AUTHOR_RULE', + handle: 'cifilter', + rule: 'allow', + }); + // Nothing in the test answers Escape, so the sheet is closed from its backdrop. + expect(dismissed).toHaveBeenCalledOnce(); +}); + +it('leaves other sheets alone, since only the post menu identifies a post', () => { + mockChrome(); + document.body.innerHTML = sheet(''); + act(() => { + dispose = installPostMenu(() => publicSettings(defaults)); + }); + expect(document.querySelector('.aitf-post-menu')).toBeNull(); +}); diff --git a/tests/youtube.test.ts b/tests/youtube.test.ts index ac2e6c6..9c52272 100644 --- a/tests/youtube.test.ts +++ b/tests/youtube.test.ts @@ -64,11 +64,26 @@ it('reads settings on start, follows storage changes and cleans up on dispose', expect('aitfYtShorts' in document.documentElement.dataset).toBe(false); }); -it('starts the YouTube adapter for www.youtube.com only', () => { +it('starts the YouTube adapter for both YouTube hostnames, over HTTPS only', () => { mockChrome(); - expect(startSite({ protocol: 'https:', hostname: 'm.youtube.com' })).toBeUndefined(); + for (const hostname of ['www.youtube.com', 'm.youtube.com']) { + const dispose = startSite({ protocol: 'https:', hostname }); + expect(typeof dispose).toBe('function'); + dispose?.(); + } expect(startSite({ protocol: 'http:', hostname: 'www.youtube.com' })).toBeUndefined(); - const dispose = startSite({ protocol: 'https:', hostname: 'www.youtube.com' }); - expect(typeof dispose).toBe('function'); - dispose?.(); + expect(startSite({ protocol: 'http:', hostname: 'm.youtube.com' })).toBeUndefined(); +}); + +it('matches YouTube hostnames exactly, so a lookalike starts nothing', () => { + mockChrome(); + for (const hostname of [ + 'youtube.com', + 'music.youtube.com', + 'studio.youtube.com', + 'm.youtube.com.evil.test', + 'notm.youtube.com', + ]) { + expect(startSite({ protocol: 'https:', hostname })).toBeUndefined(); + } });