From c34d3209ab85676ec0a647ab2aeff785f6faa939 Mon Sep 17 00:00:00 2001 From: tshmieldev Date: Fri, 18 Sep 2026 16:56:55 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20filter=20comments=20toggle,=20and?= =?UTF-8?q?=20X's=20=E2=8B=AF=20menu=20on=20phones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filter comments, off by default. Replies under a post are left alone unless the reader asks for them to be judged; the timeline is filtered either way, and the opened post and its ancestors always show. Off is absolute, like a bypassed thread: blocked authors in replies show too. The "Show all comments in this thread" control only appears while comments are filtered, since otherwise there is nothing for it to switch off. At phone widths X shows the post menu as a bottom sheet (sheetDialog) with no menu role around it and its own Cancel button last. Sharp now recognises it and places its rows above Cancel. The post is still identified by the engagements link, which X's other sheets do not carry, so they stay untouched. A sheet may ignore Escape, so if it is still open a frame after an action, its backdrop is tapped to close it. Where a thread page has no inline reply box, the thread control goes at the top of the replies, inside the first reply's cell so X still measures it. Structure for the sheet and thread fixtures comes from a Kiwi capture. Not yet checked on a device: tapping a row, the sheet's scroll, and the reply fallback. --- src/common/settings.ts | 2 + src/popup/XPanel.tsx | 18 ++++++++ src/x/post-menu-dom.ts | 14 +++++-- src/x/post-menu.ts | 15 ++++++- src/x/rules.ts | 4 ++ src/x/thread-control.tsx | 36 +++++++++++++++- tests/comments.test.ts | 90 ++++++++++++++++++++++++++++++++++++++++ tests/post-menu.test.ts | 57 +++++++++++++++++++++++++ 8 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 tests/comments.test.ts diff --git a/src/common/settings.ts b/src/common/settings.ts index 6a69f34..e779b2e 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', diff --git a/src/popup/XPanel.tsx b/src/popup/XPanel.tsx index cd9a432..053499f 100644 --- a/src/popup/XPanel.tsx +++ b/src/popup/XPanel.tsx @@ -332,6 +332,24 @@ export function XPanel({ )} +
+

Comments

+ update('filterComments', value)} + /> +

+ The post you opened and everything it replies to always show, either way. With this on, a + control above the replies turns filtering off for one thread. +

+
+ {settings.corrections.length > 0 && (

Corrections

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..88ef628 100644 --- a/src/x/thread-control.tsx +++ b/src/x/thread-control.tsx @@ -47,7 +47,36 @@ 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; + // 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 = { @@ -119,6 +148,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/tests/comments.test.ts b/tests/comments.test.ts new file mode 100644 index 0000000..ca649e3 --- /dev/null +++ b/tests/comments.test.ts @@ -0,0 +1,90 @@ +// @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(); +}); 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(); +}); From 049e0fdcd586c442d3a71415ec859c85e5bc5f0b Mon Sep 17 00:00:00 2001 From: tshmieldev Date: Fri, 18 Sep 2026 17:46:00 +0200 Subject: [PATCH 2/3] feat: thread button on phones, Misc tabs, clearer toggle copy The thread control's phone fallback stopped at the empty cell X places right after the opened post, so on layouts without an inline reply box the button never appeared. Empty cells are now stepped over on the way to the first reply. Greyscale moves out of the filtering tabs into a Misc tab of its own, for X and for YouTube. The Filter comments toggle keeps only its short description. The thread button, when on, now says what it is doing rather than what tapping it again would do: "Showing all comments in this thread". --- src/popup/App.tsx | 16 ++++++++++----- src/popup/XPanel.tsx | 40 ++++++++++++++++++++------------------ src/popup/YouTubePanel.tsx | 37 ++++++++++++++++++++++------------- src/x/thread-control.tsx | 4 +++- tests/comments.test.ts | 15 ++++++++++++++ 5 files changed, 73 insertions(+), 39 deletions(-) diff --git a/src/popup/App.tsx b/src/popup/App.tsx index d27ea74..c69c5eb 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 }; @@ -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); @@ -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' ? ( - + ) : (
{settings.corrections.length > 0 && ( @@ -429,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/thread-control.tsx b/src/x/thread-control.tsx index 88ef628..87e8743 100644 --- a/src/x/thread-control.tsx +++ b/src/x/thread-control.tsx @@ -64,6 +64,8 @@ function firstReplyAnchor(id: string): HTMLElement | null { 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)) { @@ -121,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 && ( diff --git a/tests/comments.test.ts b/tests/comments.test.ts index ca649e3..56c4886 100644 --- a/tests/comments.test.ts +++ b/tests/comments.test.ts @@ -88,3 +88,18 @@ it('falls back to the top of the replies when there is no inline reply box', () 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(); +}); From 0cf865d8e1c9a8f102f5f73a033dd5ece7e0c2fc Mon Sep 17 00:00:00 2001 From: tshmieldev Date: Tue, 15 Sep 2026 18:23:09 +0200 Subject: [PATCH 3/3] feat: apply the YouTube rules on m.youtube.com too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The YouTube adapter never looked at the page: it sets attributes on and the stylesheet decides what they mean. So the mobile site cost selectors rather than code, plus the hostname in the five places that list where Sharp runs — the manifest's permissions and matches, the content-script dispatcher, the worker's trusted senders, and the popup's site detection and origin list. Mobile serves `ytm-` Polymer elements where the desktop site serves `ytd-`, and puts navigation in a bar along the bottom rather than a sidebar. The newer `*-view-model` elements and `yt*ViewModel` classes are shared and were already covered. A selector matching nothing on the site in front of it costs nothing, so each feature's desktop and mobile selectors sit together. The origin list and the manifest are now checked against each other in both directions. A site injected into but not listed is a content script that never runs on Firefox, where host permissions are granted one origin at a time, and the popup would have no way to ask for it. Hostnames are still matched exactly, so music.youtube.com and a lookalike like m.youtube.com.evil.test start nothing. That is now tested. The mobile selectors are written from the shape of the mobile site, not from a session against it; they want checking on a real device before release. --- CONTRIBUTING.md | 8 ++++- README.md | 3 +- manifest.json | 3 +- privacy-policy.md | 5 ++-- src/background/index.ts | 3 +- src/common/settings.ts | 1 + src/index.ts | 1 + src/popup/App.tsx | 4 +-- src/youtube/style.css | 65 ++++++++++++++++++++++++++++++++++------- tests/manifest.test.ts | 7 +++++ tests/youtube.test.ts | 25 ++++++++++++---- 11 files changed, 102 insertions(+), 23 deletions(-) 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 e779b2e..11011b2 100644 --- a/src/common/settings.ts +++ b/src/common/settings.ts @@ -140,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 c69c5eb..536faad 100644 --- a/src/popup/App.tsx +++ b/src/popup/App.tsx @@ -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 — @@ -117,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'); } 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/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/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(); + } });