Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<html>`, 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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/*"
Expand All @@ -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"
Expand Down
5 changes: 3 additions & 2 deletions privacy-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
3 changes: 3 additions & 0 deletions src/common/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -83,6 +84,7 @@ export const defaults: Settings = {
presets: [],
corrections: [],
notInterested: false,
filterComments: false,
analyzeImages: false,
maxImagesPerPost: 2,
hideStyle: 'collapse',
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function startSite(site: Pick<Location, 'protocol' | 'hostname'> = locati
case 'twitter.com':
return startX();
case 'www.youtube.com':
case 'm.youtube.com':
return startYouTube();
}
}
Expand Down
20 changes: 13 additions & 7 deletions src/popup/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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 —
Expand Down Expand Up @@ -65,6 +65,7 @@ export function App() {
const [section, setSection] = useState<Section>('x');
const [xTab, setXTab] = useState<XTab>('filtering');
const [generalTab, setGeneralTab] = useState<GeneralTab>('connection');
const [youtubeTab, setYoutubeTab] = useState<YouTubeTab>('filtering');
const [overlay, setOverlay] = useState<Overlay | null>(null);
const [stats, setStats] = useState<Stats | null>(null);
const [bytes, setBytes] = useState(0);
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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'],
Expand All @@ -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}
Expand Down Expand Up @@ -394,7 +400,7 @@ export function App() {
}
/>
) : section === 'youtube' ? (
<YouTubePanel {...editor} />
<YouTubePanel {...editor} tab={youtubeTab} />
) : (
<GeneralPanel
{...editor}
Expand Down
50 changes: 35 additions & 15 deletions src/popup/XPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as fmt from './format';
import { lists, type ListKey } from './ListSheet';
import { RangeField, ToggleRow, type SettingsEditor } from './ui';

export type XTab = 'filtering' | 'rules' | 'activity';
export type XTab = 'filtering' | 'rules' | 'activity' | 'misc';

// A hidden post is one the reader did not have to read past. This is roughly
// the time a post holds the eye on the way by, and the number is labelled as
Expand Down Expand Up @@ -91,6 +91,26 @@ export function XPanel({
onSavePreset(name);
};

if (tab === 'misc') {
return (
<div class="panel">
<section class="group">
<h2>Greyscale</h2>
<ToggleRow
label="Greyscale UI"
checked={settings.greyscaleUi}
onChange={(value) => update('greyscaleUi', value)}
/>
<ToggleRow
label="Greyscale content"
checked={settings.greyscaleContent}
onChange={(value) => update('greyscaleContent', value)}
/>
</section>
</div>
);
}

if (tab === 'rules') {
return (
<div class="panel">
Expand Down Expand Up @@ -332,6 +352,20 @@ export function XPanel({
)}
</section>

<section class="group">
<h2>Comments</h2>
<ToggleRow
label="Filter comments"
hint={
settings.filterComments
? 'Replies under a post are judged like any other post.'
: 'Replies under a post are left alone. Only the timeline is filtered.'
}
checked={settings.filterComments}
onChange={(value) => update('filterComments', value)}
/>
</section>

{settings.corrections.length > 0 && (
<section class="group">
<h2>Corrections</h2>
Expand Down Expand Up @@ -411,20 +445,6 @@ export function XPanel({
onChange={(value) => update('analyzeImages', value)}
/>
</section>

<section class="group">
<h2>Misc</h2>
<ToggleRow
label="Greyscale UI"
checked={settings.greyscaleUi}
onChange={(value) => update('greyscaleUi', value)}
/>
<ToggleRow
label="Greyscale content"
checked={settings.greyscaleContent}
onChange={(value) => update('greyscaleContent', value)}
/>
</section>
</div>
);
}
37 changes: 23 additions & 14 deletions src/popup/YouTubePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div class="panel">
<section class="group">
<h2>Greyscale</h2>
<ToggleRow
label="Greyscale UI"
checked={settings.youtubeGreyscaleUi}
onChange={(value) => update('youtubeGreyscaleUi', value)}
/>
<ToggleRow
label="Greyscale content"
checked={settings.youtubeGreyscaleContent}
onChange={(value) => update('youtubeGreyscaleContent', value)}
/>
</section>
</div>
);
}

return (
<div class="panel">
<section class="group">
Expand Down Expand Up @@ -38,19 +60,6 @@ export function YouTubePanel({ settings, update }: SettingsEditor) {
onChange={(value) => update('thumbnails', value)}
/>
</section>
<section class="group">
<h2>Misc</h2>
<ToggleRow
label="Greyscale UI"
checked={settings.youtubeGreyscaleUi}
onChange={(value) => update('youtubeGreyscaleUi', value)}
/>
<ToggleRow
label="Greyscale content"
checked={settings.youtubeGreyscaleContent}
onChange={(value) => update('youtubeGreyscaleContent', value)}
/>
</section>
</div>
);
}
14 changes: 10 additions & 4 deletions src/x/post-menu-dom.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<HTMLAnchorElement>('a[data-testid="tweetEngagements"]');
if (links.length !== 1) return null;
let url: URL;
Expand Down
15 changes: 14 additions & 1 deletion src/x/post-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
menuPost,
moreSelector,
nativeItems,
sheetSelector,
visible,
} from './post-menu-dom';

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<HTMLElement>(
':scope > [data-testid="mask"]',
);
requestAnimationFrame(() => {
if (mask?.isConnected && dropdown.isConnected && visible(dropdown)) mask.click();
});
}
},
});
const removeKeyboard = installMenuKeyboard(dropdown, root);
Expand Down
4 changes: 4 additions & 0 deletions src/x/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading
Loading