diff --git a/AGENTS.md b/AGENTS.md
deleted file mode 120000
index 681311eb..00000000
--- a/AGENTS.md
+++ /dev/null
@@ -1 +0,0 @@
-CLAUDE.md
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..66dedf50
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,198 @@
+# AGENTS.md
+
+This file provides guidance to coding agents when working with code in this repository.
+
+## Project Overview
+
+Open-source browser extension for Chrome, Firefox, and Safari that customizes the Twitter/X.com interface. Built by Typefully to provide a minimal, focused Twitter experience with customizable UI elements.
+
+Repository: https://github.com/typefully/minimal-twitter
+
+## Build and Development Commands
+
+### Building the Extension
+
+Requires [classic yarn](https://classic.yarnpkg.com/lang/en/docs/install/).
+
+- `yarn build` or `yarn bundle` - Builds and bundles the extension for all browsers (prompts for browser choice)
+- Builds both popup (Next.js) and content-scripts (Rollup) automatically
+- Creates bundled packages in `/bundle/` directory for Chrome, Firefox, and Safari
+
+### Content Scripts (content-scripts/)
+
+- `cd content-scripts && yarn watch` - Watch mode for content script development
+- `cd content-scripts && yarn build` - Build content scripts only
+
+### Popup UI (popup/)
+
+- `cd popup && yarn dev` - Development server for popup UI (Next.js)
+- `cd popup && yarn build` - Build popup for production
+- `cd popup && yarn lint` - Run ESLint
+- `cd popup && yarn check:prettier` - Check code formatting
+- `cd popup && yarn write:prettier` - Format code with Prettier
+
+### Development Workflow
+
+1. Run `yarn build` at root once to build everything initially
+2. For content-script changes: `cd content-scripts && yarn watch` (auto-rebuilds on save)
+3. For popup changes: `cd popup && yarn build` (no watch mode, must rebuild manually)
+
+**Important:** `yarn watch` builds to `content-scripts/dist/`, but the extension loads from `bundle/chrome/dist/`. To sync changes during development, either:
+
+- Run `yarn build` at root after changes (slow, rebuilds everything)
+- Or create a symlink once: `rm -rf bundle/chrome/dist && ln -s ../../content-scripts/dist bundle/chrome/dist`
+
+Then load the extension in your browser (see below).
+
+### Loading Extension for Testing
+
+- Chrome/Edge: Load `bundle/chrome` folder at `chrome://extensions` (enable Developer mode)
+- Firefox: Load `bundle/firefox/manifest.json` at `about:debugging#/runtime/this-firefox`
+
+After making changes, refresh the extension in `chrome://extensions` to reload.
+
+Always refresh the loaded extension after finishing extension changes so the browser runs the latest built bundle.
+
+## Architecture
+
+### Core Structure
+
+**Three main parts:**
+
+1. **content-scripts/**: Content scripts that run on x.com and apply customizations
+2. **popup/**: Next.js app for the extension settings popup UI
+3. **Root files**: Build scripts, manifests, shared utilities
+
+### Settings and Storage
+
+- **storage-keys.js** (root): Central registry of all feature keys and default preferences
+ - All settings keys must be added to both `allSettingsKeys` array and `defaultPreferences` object
+ - Keys use format `Key[FeatureName]` (e.g., `KeySidebarLogo`)
+
+### Content Script Flow
+
+**Initialization** (content-scripts/src/modules/initialize.js):
+
+1. Loads stylesheets (local + CDN in production)
+2. Applies static features once
+3. Runs dynamic features
+4. Sets up MutationObserver for DOM changes
+5. Extracts Twitter theme colors
+
+**Features are categorized as static or dynamic:**
+
+- **Static features** (content-scripts/src/modules/features/static.js):
+
+ - Applied once on load or when settings change
+ - Examples: timeline width, font changes, hide navigation buttons
+ - Organized by category: timeline, navigation, interface, sidebar, advanced
+
+- **Dynamic features** (content-scripts/src/modules/features/dynamic.js):
+ - Reapplied on DOM mutations via MutationObserver
+ - Examples: writer mode, view counts, Typefully integration buttons
+ - Throttled to run max every 50ms
+
+**Feature implementation files** (content-scripts/src/modules/options/):
+
+- Each category has its own file (timeline.js, navigation.js, interface.js, etc.)
+- Functions typically add/remove CSS classes or inject styles to enable/disable features
+
+**Selectors** (content-scripts/src/selectors.js): **Critical file** containing all CSS selectors for Twitter UI elements. When Twitter changes their DOM structure, selectors break and need updating here. Selectors primarily use `data-testid` attributes (most stable), ARIA attributes, and structural CSS selectors. When fixing broken features, check this file first.
+
+### Popup UI Structure
+
+**Next.js app** (popup/):
+
+- `components/sections/`: Settings sections (TimelineSection, NavigationSection, etc.)
+- `components/ui/`: Reusable UI components (switches, checkboxes, sliders)
+- Uses Radix UI primitives and Stitches for styling
+- Settings are saved to chrome.storage and synced to content scripts
+
+## Adding a New Feature
+
+To add a new feature toggle:
+
+1. **Define the key** in `storage-keys.js`:
+
+ - Add `export const KeyFeatureName = "featureName"`
+ - Add to `allSettingsKeys` array
+ - Add default value to `defaultPreferences` object
+
+2. **Implement the feature logic** in appropriate file in `content-scripts/src/modules/options/`:
+
+ - Create a function that applies the feature (usually adds/removes CSS classes)
+ - Import and use utilities from `content-scripts/src/modules/utilities/`
+
+3. **Register the feature**:
+
+ - If static: Add to `staticFeatures` in `content-scripts/src/modules/features/static.js`
+ - If dynamic: Add to `dynamicFeatures` in `content-scripts/src/modules/features/dynamic.js`
+ - Import the key from storage-keys.js
+
+4. **Add UI control** in appropriate section in `popup/components/sections/`:
+
+ - Import the key from storage-keys.js
+ - Add a toggle/switch/checkbox component that reads/writes to storage
+
+5. **SVG assets**: If new icons are needed, add to `content-scripts/src/modules/svgAssets.js`
+
+## CSS and Styling
+
+- Main styles: `/css/main.css` and `/css/typefully.css`
+- In production, extension loads cached versions from GitHub CDN
+- In development mode, only loads local CSS files
+- Content scripts inject styles dynamically via `addStyleSheet()` and `addStyles()` utilities
+
+## Browser Compatibility
+
+- Chrome: Manifest V3 with service worker background
+- Firefox: Manifest V2 with background scripts
+- Safari: Converted from Firefox build using xcrun safari-web-extension-converter
+- Manifests defined in `bundle-extension.js`
+
+## Releasing Updates
+
+### Version Bump
+
+1. Run `yarn bump-version` (prompts for patch/minor/major). This automatically updates:
+ - `bundle-extension.js` - main version number
+ - Xcode project (`project.pbxproj`) - MARKETING_VERSION and CURRENT_PROJECT_VERSION (build number incremented by 1)
+
+2. Run `yarn build` to create bundles for all browsers
+
+3. Commit and tag:
+ ```bash
+ git add . && git commit -m "Bump version to X.Y.Z"
+ git tag vX.Y.Z
+ git push && git push --tags
+ ```
+
+4. Submit bundles to browser stores (Chrome Web Store, Firefox Add-ons, App Store via Xcode)
+
+### Update Screen Behavior
+
+Controlled in `background.js`. By default, the welcome page only opens on fresh installs.
+
+To show an update screen for major releases, modify `background.js`:
+
+```js
+// Show welcome page on both install AND update
+if (object.reason !== "install" && object.reason !== "update") {
+ return;
+}
+
+const targetUrl = `https://typefully.com/minimal-twitter/welcome${
+ object.reason === "update" ? "?updated=true" : ""
+}`;
+```
+
+To disable update screen (default):
+
+```js
+// Only show welcome page on fresh install
+if (object.reason !== "install") {
+ return;
+}
+
+const targetUrl = `https://typefully.com/minimal-twitter/welcome`;
+```
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index 11063d8f..00000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,196 +0,0 @@
-# CLAUDE.md
-
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
-
-## Project Overview
-
-Open-source browser extension for Chrome, Firefox, and Safari that customizes the Twitter/X.com interface. Built by Typefully to provide a minimal, focused Twitter experience with customizable UI elements.
-
-Repository: https://github.com/typefully/minimal-twitter
-
-## Build and Development Commands
-
-### Building the Extension
-
-Requires [classic yarn](https://classic.yarnpkg.com/lang/en/docs/install/).
-
-- `yarn build` or `yarn bundle` - Builds and bundles the extension for all browsers (prompts for browser choice)
-- Builds both popup (Next.js) and content-scripts (Rollup) automatically
-- Creates bundled packages in `/bundle/` directory for Chrome, Firefox, and Safari
-
-### Content Scripts (content-scripts/)
-
-- `cd content-scripts && yarn watch` - Watch mode for content script development
-- `cd content-scripts && yarn build` - Build content scripts only
-
-### Popup UI (popup/)
-
-- `cd popup && yarn dev` - Development server for popup UI (Next.js)
-- `cd popup && yarn build` - Build popup for production
-- `cd popup && yarn lint` - Run ESLint
-- `cd popup && yarn check:prettier` - Check code formatting
-- `cd popup && yarn write:prettier` - Format code with Prettier
-
-### Development Workflow
-
-1. Run `yarn build` at root once to build everything initially
-2. For content-script changes: `cd content-scripts && yarn watch` (auto-rebuilds on save)
-3. For popup changes: `cd popup && yarn build` (no watch mode, must rebuild manually)
-
-**Important:** `yarn watch` builds to `content-scripts/dist/`, but the extension loads from `bundle/chrome/dist/`. To sync changes during development, either:
-
-- Run `yarn build` at root after changes (slow, rebuilds everything)
-- Or create a symlink once: `rm -rf bundle/chrome/dist && ln -s ../../content-scripts/dist bundle/chrome/dist`
-
-Then load the extension in your browser (see below).
-
-### Loading Extension for Testing
-
-- Chrome/Edge: Load `bundle/chrome` folder at `chrome://extensions` (enable Developer mode)
-- Firefox: Load `bundle/firefox/manifest.json` at `about:debugging#/runtime/this-firefox`
-
-After making changes, refresh the extension in `chrome://extensions` to reload.
-
-## Architecture
-
-### Core Structure
-
-**Three main parts:**
-
-1. **content-scripts/**: Content scripts that run on x.com and apply customizations
-2. **popup/**: Next.js app for the extension settings popup UI
-3. **Root files**: Build scripts, manifests, shared utilities
-
-### Settings and Storage
-
-- **storage-keys.js** (root): Central registry of all feature keys and default preferences
- - All settings keys must be added to both `allSettingsKeys` array and `defaultPreferences` object
- - Keys use format `Key[FeatureName]` (e.g., `KeySidebarLogo`)
-
-### Content Script Flow
-
-**Initialization** (content-scripts/src/modules/initialize.js):
-
-1. Loads stylesheets (local + CDN in production)
-2. Applies static features once
-3. Runs dynamic features
-4. Sets up MutationObserver for DOM changes
-5. Extracts Twitter theme colors
-
-**Features are categorized as static or dynamic:**
-
-- **Static features** (content-scripts/src/modules/features/static.js):
-
- - Applied once on load or when settings change
- - Examples: timeline width, font changes, hide navigation buttons
- - Organized by category: timeline, navigation, interface, sidebar, advanced
-
-- **Dynamic features** (content-scripts/src/modules/features/dynamic.js):
- - Reapplied on DOM mutations via MutationObserver
- - Examples: writer mode, view counts, Typefully integration buttons
- - Throttled to run max every 50ms
-
-**Feature implementation files** (content-scripts/src/modules/options/):
-
-- Each category has its own file (timeline.js, navigation.js, interface.js, etc.)
-- Functions typically add/remove CSS classes or inject styles to enable/disable features
-
-**Selectors** (content-scripts/src/selectors.js): **Critical file** containing all CSS selectors for Twitter UI elements. When Twitter changes their DOM structure, selectors break and need updating here. Selectors primarily use `data-testid` attributes (most stable), ARIA attributes, and structural CSS selectors. When fixing broken features, check this file first.
-
-### Popup UI Structure
-
-**Next.js app** (popup/):
-
-- `components/sections/`: Settings sections (TimelineSection, NavigationSection, etc.)
-- `components/ui/`: Reusable UI components (switches, checkboxes, sliders)
-- Uses Radix UI primitives and Stitches for styling
-- Settings are saved to chrome.storage and synced to content scripts
-
-## Adding a New Feature
-
-To add a new feature toggle:
-
-1. **Define the key** in `storage-keys.js`:
-
- - Add `export const KeyFeatureName = "featureName"`
- - Add to `allSettingsKeys` array
- - Add default value to `defaultPreferences` object
-
-2. **Implement the feature logic** in appropriate file in `content-scripts/src/modules/options/`:
-
- - Create a function that applies the feature (usually adds/removes CSS classes)
- - Import and use utilities from `content-scripts/src/modules/utilities/`
-
-3. **Register the feature**:
-
- - If static: Add to `staticFeatures` in `content-scripts/src/modules/features/static.js`
- - If dynamic: Add to `dynamicFeatures` in `content-scripts/src/modules/features/dynamic.js`
- - Import the key from storage-keys.js
-
-4. **Add UI control** in appropriate section in `popup/components/sections/`:
-
- - Import the key from storage-keys.js
- - Add a toggle/switch/checkbox component that reads/writes to storage
-
-5. **SVG assets**: If new icons are needed, add to `content-scripts/src/modules/svgAssets.js`
-
-## CSS and Styling
-
-- Main styles: `/css/main.css` and `/css/typefully.css`
-- In production, extension loads cached versions from GitHub CDN
-- In development mode, only loads local CSS files
-- Content scripts inject styles dynamically via `addStyleSheet()` and `addStyles()` utilities
-
-## Browser Compatibility
-
-- Chrome: Manifest V3 with service worker background
-- Firefox: Manifest V2 with background scripts
-- Safari: Converted from Firefox build using xcrun safari-web-extension-converter
-- Manifests defined in `bundle-extension.js`
-
-## Releasing Updates
-
-### Version Bump
-
-1. Run `yarn bump-version` (prompts for patch/minor/major). This automatically updates:
- - `bundle-extension.js` - main version number
- - Xcode project (`project.pbxproj`) - MARKETING_VERSION and CURRENT_PROJECT_VERSION (build number incremented by 1)
-
-2. Run `yarn build` to create bundles for all browsers
-
-3. Commit and tag:
- ```bash
- git add . && git commit -m "Bump version to X.Y.Z"
- git tag vX.Y.Z
- git push && git push --tags
- ```
-
-4. Submit bundles to browser stores (Chrome Web Store, Firefox Add-ons, App Store via Xcode)
-
-### Update Screen Behavior
-
-Controlled in `background.js`. By default, the welcome page only opens on fresh installs.
-
-To show an update screen for major releases, modify `background.js`:
-
-```js
-// Show welcome page on both install AND update
-if (object.reason !== "install" && object.reason !== "update") {
- return;
-}
-
-const targetUrl = `https://typefully.com/minimal-twitter/welcome${
- object.reason === "update" ? "?updated=true" : ""
-}`;
-```
-
-To disable update screen (default):
-
-```js
-// Only show welcome page on fresh install
-if (object.reason !== "install") {
- return;
-}
-
-const targetUrl = `https://typefully.com/minimal-twitter/welcome`;
-```
diff --git a/bundle-extension.js b/bundle-extension.js
index 056adceb..91dbe349 100644
--- a/bundle-extension.js
+++ b/bundle-extension.js
@@ -138,7 +138,7 @@ const bundle = async (manifest, bundleDirectory) => {
process.stdout.cursorTo(0);
spinner = P[P.indexOf(spinner) + 1] || P[0];
process.stdout.write(
- `${spinner} Building popup and content scripts...`
+ `${spinner} Building popup and content scripts...`,
);
}, 250);
};
@@ -152,7 +152,7 @@ const bundle = async (manifest, bundleDirectory) => {
} catch (error) {
clearInterval(intervalId);
console.error(
- `Error running build script for ${directory}: ${error}`
+ `Error running build script for ${directory}: ${error}`,
);
reject(error);
}
@@ -194,7 +194,7 @@ const bundle = async (manifest, bundleDirectory) => {
await writeFile(
`${bundleDirectory}/manifest.json`,
Buffer.from(JSON.stringify(manifest, null, 2)),
- "utf8"
+ "utf8",
);
// Done.
@@ -209,8 +209,8 @@ const bundle = async (manifest, bundleDirectory) => {
console.log(
`🧬 Zipped \`${bundleDirectory}\` to \`bundle/${bundleDirectory.replace(
"bundle/",
- ""
- )}.zip\`.`
+ "",
+ )}.zip\`.`,
);
} catch (error) {
console.error(error);
@@ -271,7 +271,7 @@ rl.question(
}
rl.close();
- }
+ },
);
rl.on("close", () => {
diff --git a/content-scripts/src/index.js b/content-scripts/src/index.js
index ae274fe0..a2694343 100644
--- a/content-scripts/src/index.js
+++ b/content-scripts/src/index.js
@@ -16,19 +16,31 @@ import { getStorage } from "./modules/utilities/storage";
* - Dynamic features auto-update on DOM changes
*/
+const hasStorageApi = () => Boolean(chrome?.storage?.local);
+
// Listen to settings changes
-chrome.storage.onChanged.addListener(async (changes) => {
- if (changes[KeyExtensionStatus]?.newValue !== changes[KeyExtensionStatus]?.oldValue) {
- window.location.reload();
- return;
- }
+try {
+ if (hasStorageApi()) {
+ chrome.storage.onChanged.addListener(async (changes) => {
+ try {
+ if (changes[KeyExtensionStatus]?.newValue !== changes[KeyExtensionStatus]?.oldValue) {
+ window.location.reload();
+ return;
+ }
- const status = await getStorage(KeyExtensionStatus);
- if (status === "off") return;
+ const status = await getStorage(KeyExtensionStatus);
+ if (status === "off") return;
- const newData = constructNewData(changes);
- applyStaticFeatures(newData);
-});
+ const newData = constructNewData(changes);
+ applyStaticFeatures(newData);
+ } catch {
+ // Existing tabs can keep stale content scripts after the extension reloads.
+ }
+ });
+ }
+} catch {
+ // Existing tabs can keep stale content scripts after the extension reloads.
+}
// Initialize extension
const init = async () => {
@@ -38,4 +50,6 @@ const init = async () => {
await initializeExtension();
};
-init();
+init().catch(() => {
+ // Existing tabs can keep stale content scripts after the extension reloads.
+});
diff --git a/content-scripts/src/modules/features/dynamic.js b/content-scripts/src/modules/features/dynamic.js
index 2e66bed7..b6a1dc82 100644
--- a/content-scripts/src/modules/features/dynamic.js
+++ b/content-scripts/src/modules/features/dynamic.js
@@ -9,22 +9,42 @@
*/
import {
+ KeyAiSlopButton,
KeyCommunitiesButton,
KeyFollowingTimeline,
KeyHideGrokDrawer,
KeyHideViewCount,
KeyListsButton,
KeyRemoveTimelineTabs,
+ KeyRemoveTopicsToFollow,
KeyTopicsButton,
KeyTrendsHomeTimeline,
KeyTypefullyGrowTab,
KeyWriterMode,
KeyXPremiumButton,
- KeyNavigationButtonsLabels
+ KeyNavigationButtonsLabels,
+ KeyZenWriterModeButton,
} from "../../../../storage-keys";
+import { changeAiSlopButton } from "../options/aiSlopButton";
import changeHideViewCounts from "../options/hideViewCount";
-import { addAnalyticsButton, addCommunitiesButton, addListsButton, addTopicsButton, addXPremiumButton, hideGrokDrawer, changeNavigationButtonsLabels } from "../options/navigation";
-import { changeFollowingTimeline, changeRecentMedia, changeTimelineTabs, changeTrendsHomeTimeline, enableGrokDrawerOnGrokButtonClick } from "../options/timeline";
+import {
+ addAnalyticsButton,
+ addCommunitiesButton,
+ addListsButton,
+ addTopicsButton,
+ addXPremiumButton,
+ addZenWriterModeButton,
+ hideGrokDrawer,
+ changeNavigationButtonsLabels,
+} from "../options/navigation";
+import {
+ changeFollowingTimeline,
+ changeRecentMedia,
+ changeTimelineTabs,
+ changeTopicsToFollow,
+ changeTrendsHomeTimeline,
+ enableGrokDrawerOnGrokButtonClick,
+} from "../options/timeline";
import { changeWriterMode } from "../options/writerMode";
import { addTypefullyComposerPlug, addTypefullyReplyPlug, saveCurrentReplyToLink, addTypefullySecurityAndAccountAccessPlug, addTypefullySchedulePlug } from "../typefullyPlugs";
import hideRightSidebar from "../utilities/hideRightSidebar";
@@ -35,9 +55,10 @@ import throttle from "../utilities/throttle";
export const dynamicFeatures = {
general: async () => {
- const data = await getStorage([KeyHideViewCount, KeyHideGrokDrawer]);
+ const data = await getStorage([KeyHideViewCount, KeyHideGrokDrawer, KeyAiSlopButton]);
changeHideViewCounts(data[KeyHideViewCount]);
+ changeAiSlopButton(data[KeyAiSlopButton]);
changeRecentMedia();
hideRightSidebar();
addSmallerSearchBarStyle();
@@ -54,8 +75,8 @@ export const dynamicFeatures = {
navigation: (data) => {
changeNavigationButtonsLabels(data[KeyNavigationButtonsLabels]);
},
- sidebarButtons: async () => {
- const data = await getStorage([KeyListsButton, KeyCommunitiesButton, KeyTopicsButton, KeyXPremiumButton, KeyTypefullyGrowTab]);
+ sidebarButtons: async (writerMode) => {
+ const data = await getStorage([KeyListsButton, KeyCommunitiesButton, KeyTopicsButton, KeyXPremiumButton, KeyTypefullyGrowTab, KeyZenWriterModeButton]);
if (!data) return;
@@ -64,12 +85,14 @@ export const dynamicFeatures = {
if (data[KeyTopicsButton] === "on") addTopicsButton();
if (data[KeyXPremiumButton] === "on") addXPremiumButton();
if (data[KeyTypefullyGrowTab] === "on") addAnalyticsButton();
+ if (data[KeyZenWriterModeButton] === "on") addZenWriterModeButton(writerMode);
},
writerMode: async (data) => {
if (data[KeyWriterMode] === "on") {
changeWriterMode(data[KeyWriterMode]);
} else {
changeTimelineTabs(data[KeyRemoveTimelineTabs], data[KeyWriterMode]);
+ changeTopicsToFollow(data[KeyRemoveTopicsToFollow]);
changeTrendsHomeTimeline(data[KeyTrendsHomeTimeline], data[KeyWriterMode]);
changeFollowingTimeline(data[KeyFollowingTimeline]);
}
@@ -77,12 +100,20 @@ export const dynamicFeatures = {
};
export const runDynamicFeatures = throttle(async () => {
- const data = await getStorage([KeyWriterMode, KeyFollowingTimeline, KeyTrendsHomeTimeline, KeyRemoveTimelineTabs, KeyHideGrokDrawer, KeyNavigationButtonsLabels]);
+ const data = await getStorage([
+ KeyWriterMode,
+ KeyFollowingTimeline,
+ KeyTrendsHomeTimeline,
+ KeyRemoveTimelineTabs,
+ KeyRemoveTopicsToFollow,
+ KeyHideGrokDrawer,
+ KeyNavigationButtonsLabels,
+ ]);
if (data) {
dynamicFeatures.general();
dynamicFeatures.typefullyPlugs();
- await dynamicFeatures.sidebarButtons();
+ await dynamicFeatures.sidebarButtons(data[KeyWriterMode]);
await dynamicFeatures.writerMode(data);
dynamicFeatures.navigation(data);
diff --git a/content-scripts/src/modules/features/static.js b/content-scripts/src/modules/features/static.js
index 1ad0f9ae..de19c0b8 100644
--- a/content-scripts/src/modules/features/static.js
+++ b/content-scripts/src/modules/features/static.js
@@ -6,6 +6,7 @@
*/
import {
+ KeyAiSlopButton,
KeyArticlesButton,
KeyBookmarksButton,
KeyCommunitiesButton,
@@ -49,7 +50,9 @@ import {
KeyVerifiedOrgsButton,
KeyWriterMode,
KeyXPremiumButton,
+ KeyZenWriterModeButton,
} from "../../../../storage-keys";
+import { changeAiSlopButton } from "../options/aiSlopButton";
import { changeCustomCss } from "../options/customCss";
import { changeFollowingAndFollowersCounts, changeLikeCount, changeReplyCount, changeRetweetCount } from "../options/hideVanityCounts";
import changeHideViewCounts from "../options/hideViewCount";
@@ -72,8 +75,10 @@ import {
changeSidebarLogo,
changeTopicsButton,
changeUnreadCountBadge,
+ updateZenWriterModeButtonState,
changeVerifiedOrgsButton,
changeXPremiumButton,
+ changeZenWriterModeButton,
hideGrokDrawer,
} from "../options/navigation";
import {
@@ -105,7 +110,9 @@ export const staticFeatures = {
changePromotedPosts(data[KeyRemovePromotedPosts]);
changeTopicsToFollow(data[KeyRemoveTopicsToFollow]);
changeTimelineTabs(data[KeyRemoveTimelineTabs], data[KeyWriterMode]);
+ changeAiSlopButton(data[KeyAiSlopButton]);
changeTypefullyEnhancementsButtons(data[KeyTypefullyEnhancementsButtons]);
+ updateZenWriterModeButtonState(data[KeyWriterMode]);
changeFollowingAndFollowersCounts(data[KeyFollowCount]);
changeReplyCount(data[KeyReplyCount]);
changeRetweetCount(data[KeyRetweetCount]);
@@ -141,6 +148,7 @@ export const staticFeatures = {
changeGrokButton(data[KeyGrokButton]);
changeVerifiedOrgsButton(data[KeyVerifiedOrgsButton]);
changeAnalyticsButton(data[KeyTypefullyGrowTab]);
+ changeZenWriterModeButton(data[KeyZenWriterModeButton]);
},
advanced: (data) => {
changeCustomCss(data[KeyCustomCss]);
diff --git a/content-scripts/src/modules/options/aiSlopButton.js b/content-scripts/src/modules/options/aiSlopButton.js
new file mode 100644
index 00000000..adb5f31a
--- /dev/null
+++ b/content-scripts/src/modules/options/aiSlopButton.js
@@ -0,0 +1,410 @@
+import selectors from "../../selectors";
+import addStyles, { removeStyles } from "../utilities/addStyles";
+
+const BUTTON_CLASS = "mt-ai-slop-button";
+const CONTROL_CLASS = "mt-ai-slop-control";
+const TWEET_CLASS = "mt-ai-slop-tweet";
+const STYLE_ID = "aiSlopButton";
+const DIALOG_SELECTOR = '[role="dialog"]';
+const MENU_SELECTOR = '[role="menu"]';
+const ACTION_SELECTOR = 'button, [role="button"], [role="menuitem"], [role="radio"], label, [tabindex="0"]';
+const AI_SLOP_ICON_PATHS = [
+ "M16.4833 22.2777C16.3184 23.3047 15.508 23.996 14.4943 23.9961C12.2459 23.9964 9.99752 23.9975 7.74914 24C6.56047 24.0013 5.69754 23.1776 5.73401 21.9847C5.75153 21.4113 5.61855 21.1064 5.06297 20.8144C3.65508 20.0743 2.93385 18.8233 2.80853 17.2352C2.80503 17.1908 2.79701 17.1468 2.79223 17.1111C1.35444 16.9004 0.415209 16.1096 0.133396 14.6992C-0.0997623 13.5323 -0.0896994 12.3537 0.677551 11.3239C1.20367 10.6177 1.90879 10.2316 2.777 10.1557C2.81573 9.77423 2.82899 9.39645 2.89571 9.02837C3.23145 7.17648 4.78465 5.67963 6.64471 5.50386C7.64359 5.40947 8.65598 5.4571 9.66231 5.44319C9.98418 5.43874 10.3062 5.44249 10.6565 5.44249C10.6565 4.87803 10.6618 4.33538 10.6489 3.79317C10.6475 3.73422 10.5518 3.6493 10.4826 3.62435C9.19952 3.16192 8.78866 1.6044 9.71268 0.600048C10.3591 -0.102542 11.5065 -0.252143 12.3543 0.496813C13.0623 1.12234 13.1825 2.30062 12.5438 3.01395C12.3197 3.26412 11.9843 3.41329 11.7054 3.6163C11.6282 3.67246 11.5162 3.75468 11.5139 3.82788C11.4974 4.35428 11.5051 4.88144 11.5051 5.4464C11.6146 5.4464 11.7128 5.44651 11.811 5.44638C12.8473 5.44501 13.8835 5.44344 14.9198 5.44222C17.0057 5.43976 18.8152 6.86773 19.27 8.90174C19.3596 9.30208 19.3602 9.7223 19.4037 10.1514C20.302 10.224 21.0324 10.6461 21.5613 11.3963C21.9359 11.9276 22.1268 12.528 22.1329 13.1823C22.1391 13.8453 22.1649 14.5103 21.9245 15.1475C21.5006 16.2709 20.6578 16.9274 19.4564 17.1011C19.3109 17.6507 19.2208 18.1997 19.0202 18.7048C18.5849 19.8015 17.794 20.5814 16.7051 21.0435C16.5419 21.1127 16.4843 21.1923 16.4923 21.3667C16.5058 21.6619 16.4923 21.9583 16.4833 22.2777ZM4.38193 7.76317C3.91565 8.37749 3.65534 9.07104 3.65163 9.84099C3.64156 11.9317 3.64874 14.0225 3.64927 16.1133C3.64936 16.4833 3.61835 16.8566 3.65628 17.2228C3.86038 19.1927 5.39731 20.5576 7.38412 20.5568C9.89118 20.5558 12.3982 20.5561 14.9053 20.5585C16.8818 20.5604 18.5304 18.9171 18.5289 16.947C18.5272 14.6342 18.5265 12.3214 18.5311 10.0086C18.5351 7.94848 16.98 6.33381 14.9127 6.31358C12.4151 6.28915 9.91716 6.30061 7.41936 6.30238C6.19237 6.30325 5.17264 6.75402 4.38193 7.76317ZM9.83428 21.4201C8.74953 21.4201 7.66478 21.4201 6.5589 21.4201C6.5589 21.5898 6.55923 21.7646 6.55884 21.9393C6.55725 22.6498 7.03916 23.1458 7.75049 23.1567C8.12949 23.1626 8.50869 23.155 8.8878 23.1552C10.7279 23.1558 12.568 23.1583 14.408 23.1572C15.0587 23.1568 15.5865 22.7194 15.646 22.1191C15.6702 21.8751 15.65 21.6266 15.65 21.4203C13.7113 21.4203 11.8006 21.4203 9.83428 21.4201ZM20.2185 15.9382C20.5878 15.7196 20.8453 15.407 21.0361 15.0234C21.3027 14.4875 21.283 13.916 21.2834 13.345C21.2837 12.8549 21.1659 12.395 20.9014 11.9794C20.55 11.4273 20.0507 11.1086 19.3859 11.0186C19.3859 12.7654 19.3859 14.4867 19.3859 16.2776C19.6796 16.1618 19.9325 16.062 20.2185 15.9382ZM2.0614 11.2596C1.68756 11.4656 1.39456 11.7503 1.1994 12.1336C0.754387 13.0074 0.773388 13.923 1.06141 14.8161C1.32132 15.622 1.92839 16.0944 2.79558 16.2257C2.79558 14.4817 2.79558 12.7591 2.79558 10.986C2.54136 11.0771 2.3188 11.157 2.0614 11.2596ZM11.4118 2.81407C11.4692 2.78495 11.5281 2.75844 11.5838 2.72629C12.0821 2.4385 12.2508 1.84441 11.9699 1.3692C11.6867 0.889917 11.0476 0.72657 10.5706 1.01151C10.1335 1.27256 9.96197 1.76158 10.1436 2.2286C10.326 2.69769 10.8287 2.94232 11.4118 2.81407Z",
+ "M13.5231 11.8741C14.3331 11.2084 15.4353 11.2987 16.0897 12.0706C16.7127 12.8055 16.6094 13.8818 15.8518 14.5504C15.1564 15.164 14.0498 15.1008 13.4004 14.4103C12.7217 13.6887 12.7676 12.6089 13.5231 11.8741ZM15.5295 13.6137C15.7225 13.2419 15.6661 12.8915 15.3945 12.5926C15.1501 12.3237 14.8355 12.2154 14.4689 12.3226C14.099 12.4308 13.8703 12.6726 13.794 13.049C13.7122 13.4522 13.9456 13.8682 14.336 14.0342C14.7789 14.2225 15.2055 14.0839 15.5295 13.6137Z",
+ "M8.11876 14.8622C7.18019 15.1569 6.27219 14.7823 5.88111 13.9556C5.51822 13.1886 5.78163 12.2155 6.47889 11.7474C7.35419 11.1597 8.51474 11.4106 9.02759 12.2983C9.56081 13.2212 9.20132 14.3382 8.21523 14.8217C8.19045 14.8338 8.16429 14.8432 8.11876 14.8622ZM7.50171 14.1102C7.97579 14.0694 8.28726 13.8175 8.39707 13.386C8.48375 13.0454 8.32145 12.6496 8.01266 12.4485C7.66266 12.2205 7.26277 12.2332 6.92941 12.483C6.64312 12.6975 6.50184 13.0876 6.59142 13.4162C6.70192 13.8216 6.99701 14.059 7.50171 14.1102Z",
+ "M10.2506 17.8965C9.82523 17.8968 9.42754 17.8999 9.02991 17.8965C8.72549 17.8938 8.55676 17.7407 8.55452 17.4766C8.55245 17.2328 8.74512 17.0554 9.03506 17.0549C10.413 17.0522 11.791 17.0522 13.1689 17.0561C13.4472 17.0569 13.6233 17.2253 13.6275 17.4681C13.6322 17.7318 13.463 17.8961 13.1638 17.897C12.202 17.9 11.2402 17.8971 10.2506 17.8965Z",
+];
+const BUTTON_COLOR = "rgb(113, 118, 123)";
+
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+const normalizeText = (text) => (text || "").replace(/\s+/g, " ").trim();
+
+const isVisible = (element) => {
+ if (!element || !(element instanceof HTMLElement)) return false;
+
+ const style = window.getComputedStyle(element);
+ const rect = element.getBoundingClientRect();
+
+ return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
+};
+
+const isActionable = (element) => isVisible(element) && !element.disabled && element.getAttribute("aria-disabled") !== "true";
+
+const waitFor = async (callback, timeout = 4000) => {
+ const start = Date.now();
+
+ while (Date.now() - start < timeout) {
+ const result = callback();
+ if (result) return result;
+ await sleep(100);
+ }
+
+ throw new Error("Timed out waiting for X action dialog");
+};
+
+const matchesAny = (text, patterns) => {
+ const normalized = normalizeText(text);
+ return patterns.some((pattern) => pattern.test(normalized));
+};
+
+const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+
+const findVisibleByText = (patterns, { root = document, selector = ACTION_SELECTOR, predicate = () => true } = {}) =>
+ Array.from(root.querySelectorAll(selector)).find(
+ (element) => isActionable(element) && matchesAny(element.textContent || element.getAttribute("aria-label"), patterns) && predicate(element)
+ );
+
+const clickVisibleByText = (patterns, options) => {
+ const element = findVisibleByText(patterns, options);
+ if (!element) return false;
+
+ element.click();
+ return true;
+};
+
+const getLatestDialog = () => {
+ const dialogs = Array.from(document.querySelectorAll(DIALOG_SELECTOR)).filter(isVisible);
+ return dialogs[dialogs.length - 1];
+};
+
+const clickDialogAction = (patterns) => {
+ const dialog = getLatestDialog();
+ if (!dialog) return false;
+
+ const element = findVisibleByText(patterns, {
+ root: dialog,
+ selector: 'button, [role="button"]',
+ });
+
+ if (!element) return false;
+
+ element.click();
+ return normalizeText(element.textContent || element.getAttribute("aria-label"));
+};
+
+const closeDialogIfPresent = () => {
+ const dialog = getLatestDialog();
+ if (!dialog) return;
+
+ const closeButton = dialog.querySelector('[aria-label="Close"]');
+ if (isVisible(closeButton)) closeButton.click();
+};
+
+const isSelectedChoice = (element) =>
+ element.getAttribute("aria-checked") === "true" || element.getAttribute("aria-selected") === "true" || Boolean(element.closest('[aria-checked="true"], [aria-selected="true"]'));
+
+const getTweetAuthorHandle = (tweet) => {
+ const statusLink = Array.from(tweet.querySelectorAll('a[href*="/status/"]')).find((link) => {
+ try {
+ const url = new URL(link.href);
+ return /^\/[^/]+\/status\/\d+/.test(url.pathname);
+ } catch {
+ return false;
+ }
+ });
+
+ if (!statusLink) return null;
+
+ try {
+ return new URL(statusLink.href).pathname.split("/")[1];
+ } catch {
+ return null;
+ }
+};
+
+const clickTweetMenuItem = async (tweet, patterns) => {
+ const caret = tweet.querySelector('[data-testid="caret"]');
+ if (!caret) throw new Error("Could not find X post menu button");
+
+ caret.click();
+
+ const menu = await waitFor(() => {
+ const menus = Array.from(document.querySelectorAll(MENU_SELECTOR)).filter(isVisible);
+ return menus[menus.length - 1];
+ });
+
+ const menuItem = findVisibleByText(patterns, {
+ root: menu,
+ selector: '[role="menuitem"], [role="menuitemradio"], [role="button"]',
+ });
+
+ if (!menuItem) throw new Error("Could not find X post menu action");
+
+ menuItem.click();
+};
+
+const completeSpamReport = async () => {
+ await waitFor(getLatestDialog, 6000);
+
+ let selectedSpam = false;
+ let blockedAuthor = false;
+
+ for (let step = 0; step < 10; step++) {
+ const dialog = getLatestDialog();
+ if (!dialog && selectedSpam) return blockedAuthor;
+ if (!dialog) {
+ await sleep(500);
+ continue;
+ }
+
+ if (clickDialogAction([/^block\b/i])) {
+ blockedAuthor = true;
+ await sleep(700);
+ continue;
+ }
+
+ if (clickDialogAction([/^done$/i])) {
+ await sleep(300);
+ return blockedAuthor;
+ }
+
+ if (clickDialogAction([/^next$/i, /^continue$/i, /^submit$/i, /^report$/i])) {
+ await sleep(700);
+ continue;
+ }
+
+ if (
+ clickVisibleByText([/\bspam\b/i], {
+ root: dialog,
+ selector: ACTION_SELECTOR,
+ predicate: (element) => !isSelectedChoice(element),
+ })
+ ) {
+ selectedSpam = true;
+ await sleep(500);
+ continue;
+ }
+
+ await sleep(500);
+ }
+
+ closeDialogIfPresent();
+ throw new Error("Could not complete X spam report flow");
+};
+
+const reportTweetAsSpam = async (tweet) => {
+ await clickTweetMenuItem(tweet, [/^report post$/i, /^report tweet$/i, /^report$/i]);
+ return completeSpamReport();
+};
+
+const blockTweetAuthor = async (tweet, authorHandle) => {
+ const blockPatterns = authorHandle ? [new RegExp(`^block\\s+@?${escapeRegExp(authorHandle)}$`, "i"), /^block\b/i] : [/^block\b/i];
+
+ await clickTweetMenuItem(tweet, blockPatterns);
+ await waitFor(getLatestDialog, 5000);
+
+ if (!clickDialogAction([/^block$/i])) {
+ throw new Error("Could not confirm X block dialog");
+ }
+};
+
+const setButtonState = (button, state, label) => {
+ button.dataset.state = state;
+ button.title = label;
+ button.setAttribute("aria-label", label);
+};
+
+const getDirectChild = (ancestor, descendant) => {
+ let child = descendant;
+
+ while (child?.parentElement && child.parentElement !== ancestor) {
+ child = child.parentElement;
+ }
+
+ return child?.parentElement === ancestor ? child : null;
+};
+
+const getTweetActionPlacement = (tweet) => {
+ const caret = tweet.querySelector('[data-testid="caret"]');
+ if (!caret) return null;
+
+ const grokButton = tweet.querySelector('button[aria-label="Grok actions"]');
+ let actionsContainer = null;
+
+ if (grokButton) {
+ let node = caret.parentElement;
+
+ while (node && node !== tweet) {
+ if (node.contains(grokButton) && node.contains(caret)) {
+ actionsContainer = node;
+ break;
+ }
+
+ node = node.parentElement;
+ }
+ }
+
+ if (!actionsContainer) {
+ actionsContainer = caret.parentElement?.parentElement;
+ }
+
+ const caretSlot = actionsContainer ? getDirectChild(actionsContainer, caret) : null;
+ const grokSlot = actionsContainer && grokButton ? getDirectChild(actionsContainer, grokButton) : null;
+
+ if (!actionsContainer || !caretSlot) return null;
+
+ return { actionsContainer, caretSlot, grokSlot };
+};
+
+const tintAiSlopButton = (button) => {
+ button.style.color = BUTTON_COLOR;
+
+ button.querySelectorAll("[style]").forEach((element) => {
+ element.style.color = BUTTON_COLOR;
+ });
+};
+
+const createAiSlopIcon = (className) => {
+ const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ icon.setAttribute("viewBox", "0 0 23 24");
+ icon.setAttribute("aria-hidden", "true");
+ if (className) icon.setAttribute("class", className);
+
+ AI_SLOP_ICON_PATHS.forEach((pathData) => {
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute("d", pathData);
+ path.setAttribute("fill", "currentColor");
+ icon.appendChild(path);
+ });
+
+ return icon;
+};
+
+const replaceButtonIcon = (button) => {
+ const existingIcon = button.querySelector("svg");
+ const icon = createAiSlopIcon(existingIcon?.getAttribute("class"));
+
+ if (existingIcon) {
+ existingIcon.replaceWith(icon);
+ } else {
+ button.appendChild(icon);
+ }
+};
+
+const createFallbackAiSlopControl = () => {
+ const wrapper = document.createElement("div");
+ wrapper.className = CONTROL_CLASS;
+
+ const button = document.createElement("button");
+ button.type = "button";
+ button.setAttribute("role", "button");
+ button.className = BUTTON_CLASS;
+ replaceButtonIcon(button);
+ wrapper.appendChild(button);
+
+ return wrapper;
+};
+
+const createAiSlopControl = (grokSlot) => {
+ const wrapper = grokSlot ? grokSlot.cloneNode(true) : createFallbackAiSlopControl();
+ wrapper.classList.add(CONTROL_CLASS);
+
+ const button = wrapper.querySelector("button") || wrapper;
+ button.classList.add(BUTTON_CLASS);
+ button.type = "button";
+ button.dataset.state = "idle";
+ button.removeAttribute("aria-expanded");
+ button.removeAttribute("aria-haspopup");
+ button.removeAttribute("data-testid");
+ setButtonState(button, "idle", "AI slop");
+ replaceButtonIcon(button);
+ tintAiSlopButton(button);
+ button.addEventListener("click", handleAiSlopClick);
+
+ return wrapper;
+};
+
+const handleAiSlopClick = async (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const button = event.currentTarget;
+ const tweet = button.closest(selectors.tweet);
+ const authorHandle = getTweetAuthorHandle(tweet);
+ const target = authorHandle ? `@${authorHandle}` : "this account";
+
+ if (button.dataset.state === "loading") return;
+ if (!window.confirm(`Report this post as spam and block ${target}?`)) return;
+
+ try {
+ setButtonState(button, "loading", "working");
+ const blockedFromReportFlow = await reportTweetAsSpam(tweet);
+ await sleep(300);
+ if (!blockedFromReportFlow) await blockTweetAuthor(tweet, authorHandle);
+ setButtonState(button, "done", "blocked");
+ } catch (error) {
+ console.warn("Minimal Twitter: AI Slop action failed", error);
+ setButtonState(button, "error", "failed");
+ } finally {
+ setTimeout(() => setButtonState(button, "idle", "ai slop"), 2500);
+ }
+};
+
+const addAiSlopButtonToTweet = (tweet) => {
+ if (tweet.querySelector(`.${BUTTON_CLASS}`)) return;
+
+ const placement = getTweetActionPlacement(tweet);
+ if (!placement) return;
+
+ tweet.classList.add(TWEET_CLASS);
+
+ const control = createAiSlopControl(placement.grokSlot);
+ placement.actionsContainer.insertBefore(control, placement.grokSlot || placement.caretSlot);
+};
+
+const removeAiSlopButtons = () => {
+ document.querySelectorAll(`.${CONTROL_CLASS}`).forEach((control) => control.remove());
+ document.querySelectorAll(`.${TWEET_CLASS}`).forEach((tweet) => tweet.classList.remove(TWEET_CLASS));
+};
+
+const addAiSlopStyles = () => {
+ addStyles(
+ STYLE_ID,
+ `
+ .${BUTTON_CLASS} {
+ color: ${BUTTON_COLOR} !important;
+ }
+
+ .${BUTTON_CLASS} [style] {
+ color: ${BUTTON_COLOR} !important;
+ }
+
+ .${BUTTON_CLASS} svg,
+ .${BUTTON_CLASS} path {
+ color: currentColor;
+ fill: currentColor;
+ }
+
+ .${BUTTON_CLASS} svg {
+ transform: scale(0.9);
+ transform-origin: center;
+ }
+
+ .${BUTTON_CLASS}:hover,
+ .${BUTTON_CLASS}:focus-visible {
+ outline: none;
+ }
+
+ .${BUTTON_CLASS}[data-state="loading"] {
+ cursor: wait;
+ opacity: 0.75;
+ }
+
+ `
+ );
+};
+
+export const changeAiSlopButton = (aiSlopButton) => {
+ switch (aiSlopButton) {
+ case "off":
+ removeAiSlopButtons();
+ removeStyles(STYLE_ID);
+ break;
+
+ case "on":
+ addAiSlopStyles();
+ document.querySelectorAll(selectors.tweet).forEach(addAiSlopButtonToTweet);
+ break;
+ }
+};
diff --git a/content-scripts/src/modules/options/interface.js b/content-scripts/src/modules/options/interface.js
index 9821aef3..3dbe5d31 100644
--- a/content-scripts/src/modules/options/interface.js
+++ b/content-scripts/src/modules/options/interface.js
@@ -3,45 +3,89 @@ import selectors from "../../selectors";
import addStyles, { removeStyles } from "../utilities/addStyles";
import { getStorage } from "../utilities/storage";
-// Function to change the title notification count
-let nt; // Title Notifications timeout
-export const changeTitleNotifications = (tf) => {
- const run = async () => {
- let setting = tf;
-
- if (!tf) {
- setting = await getStorage(KeyTitleNotifications);
- }
-
- const favicon = document.querySelector('link[rel="shortcut icon"]');
-
- if (setting === "on") {
- favicon.setAttribute("href", favicon.href.replace("twitter.ico", "twitter-pip.2.ico"));
- } else {
- if (document.title.charAt(0) === "(") {
- document.title = document.title.split(" ").slice(1).join(" ");
- }
-
- if (document.title.charAt(0) === "(") {
- document.title = document.title.split(" ").slice(1).join(" ");
- }
-
- clearTimeout(nt);
- nt = setTimeout(() => {
- favicon.setAttribute("href", favicon.href.replace("-pip.2", ""));
- });
- }
- };
-
- run();
-
- const observer = new MutationObserver(() => {
- run();
+// Function to change title and favicon notification indicators
+const titleNotificationRegex = /^(?:\(\d[\d,]*\+?\)\s*)+/;
+const notificationFaviconRegex = /(^|\/)twitter-pip(?:\.[^./?#]+)*\.ico(?:[?#].*)?$/;
+const codexFaviconBadgeMarker = "data-codex-favicon-badge";
+const fallbackXFavicon =
+ "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='black'/%3E%3Cpath fill='white' d='M18.7 14.1 28.1 3h-2.2l-8.1 9.6L11.3 3H3.8l9.9 14.4L3.8 29h2.2l8.7-10.2 6.9 10.2h7.5L18.7 14.1Zm-3.1 3.6-1-1.4L6.7 4.7h3.2l6.4 9.4 1 1.4 8.3 12.1h-3.2l-6.8-9.9Z'/%3E%3C/svg%3E";
+let titleNotificationsObserver;
+let titleNotificationsSetting;
+let titleNotificationsTimeout;
+let cleanFaviconHref;
+
+const getFavicons = () => document.querySelectorAll('link[rel~="icon"]');
+
+const isNotificationFavicon = (href) => notificationFaviconRegex.test(href);
+
+const isCodexFaviconBadge = (href) => href.includes(codexFaviconBadgeMarker);
+
+const rememberCleanFavicon = () => {
+ getFavicons().forEach((favicon) => {
+ const href = favicon.getAttribute("href");
+
+ if (!href || isNotificationFavicon(href) || isCodexFaviconBadge(href)) return;
+
+ cleanFaviconHref = href;
+ });
+};
+
+const stripTitleNotificationCount = () => {
+ const title = document.title.replace(titleNotificationRegex, "");
+
+ if (title !== document.title) {
+ document.title = title;
+ }
+};
+
+const updateFaviconNotificationState = (enabled) => {
+ rememberCleanFavicon();
+
+ if (enabled) return;
+
+ getFavicons().forEach((favicon) => {
+ const href = favicon.getAttribute("href");
+ if (!href || !isNotificationFavicon(href)) return;
+
+ favicon.setAttribute("href", cleanFaviconHref || fallbackXFavicon);
});
- const config = { subtree: true, characterData: true, childList: true };
- const target = document.querySelector("title");
+};
+
+const applyTitleNotificationsPreference = () => {
+ if (titleNotificationsSetting === "on") {
+ updateFaviconNotificationState(true);
+ return;
+ }
+
+ stripTitleNotificationCount();
+ updateFaviconNotificationState(false);
+
+ clearTimeout(titleNotificationsTimeout);
+ titleNotificationsTimeout = setTimeout(() => {
+ updateFaviconNotificationState(false);
+ });
+};
+
+const observeTitleNotifications = () => {
+ if (titleNotificationsObserver) return;
- if (target) observer.observe(target, config);
+ titleNotificationsObserver = new MutationObserver(() => {
+ applyTitleNotificationsPreference();
+ });
+
+ titleNotificationsObserver.observe(document.head || document.documentElement, {
+ attributes: true,
+ attributeFilter: ["href"],
+ characterData: true,
+ childList: true,
+ subtree: true,
+ });
+};
+
+export const changeTitleNotifications = async (tf) => {
+ titleNotificationsSetting = tf ?? (await getStorage(KeyTitleNotifications));
+ applyTitleNotificationsPreference();
+ observeTitleNotifications();
};
// Function to change to Inter Font
@@ -59,7 +103,7 @@ export const changeInterFont = (interFont) => {
div, span, input, textarea {
font-family: Inter, TwitterChirp, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif !important;
}
- `
+ `,
);
break;
@@ -79,7 +123,7 @@ export const changeTweetButton = (tweetButton) => {
${selectors.tweetButton} {
visibility: hidden;
}
- `
+ `,
);
break;
@@ -97,7 +141,7 @@ export const changeHideSearchBar = (searchBar) => {
`${selectors.searchBox} {
display: none;
visibility: hidden;
- }`
+ }`,
);
addStyles(
"trendsHomeTimeline-more",
@@ -108,7 +152,7 @@ export const changeHideSearchBar = (searchBar) => {
.mt-recentMedia-photoGrid {
top: 12px !important;
}
- }`
+ }`,
);
break;
@@ -123,7 +167,7 @@ export const changeHideSearchBar = (searchBar) => {
.mt-recentMedia-photoGrid {
top: unset !important;
}
- }`
+ }`,
);
break;
}
@@ -142,7 +186,7 @@ export const changeTransparentSearchBar = (transparentSearch) => {
transform: translateX(2ch);
margin-left: -2.5ch;
}
- `
+ `,
);
break;
diff --git a/content-scripts/src/modules/options/navigation.js b/content-scripts/src/modules/options/navigation.js
index 60a61a4e..4b0cca52 100644
--- a/content-scripts/src/modules/options/navigation.js
+++ b/content-scripts/src/modules/options/navigation.js
@@ -1,34 +1,51 @@
+import { KeyWriterMode } from "../../../../storage-keys";
import selectors from "../../selectors";
import svgAssets from "../svgAssets";
import addStyles, { removeStyles } from "../utilities/addStyles";
import { createTypefullyUrl } from "../utilities/createTypefullyUrl";
import { addSidebarButton } from "../utilities/sidebar";
+import { getStorage, setStorage } from "../utilities/storage";
// Utilities
export const changeSidebarSetting = (sidebarSelector, state, onAdd) => {
switch (state) {
case "off":
+ removeStyles(`${sidebarSelector}-force-show`);
addStyles(
sidebarSelector,
`${selectors.sidebarLinks[sidebarSelector]} {
display: none;
- }`
+ }`,
);
break;
case "on":
removeStyles(sidebarSelector);
+ removeStyles(`${sidebarSelector}-force-show`);
onAdd?.();
break;
}
};
+const changeExploreButtonSetting = (state) => {
+ changeSidebarSetting("explore", state);
+
+ if (state === "on") {
+ addStyles(
+ "explore-force-show",
+ `${selectors.sidebarLinks.explore} {
+ display: flex !important;
+ }`,
+ );
+ }
+};
+
// Functions
export const changeSidebarLogo = (state) => changeSidebarSetting("logo", state);
export const changeHomeButton = (state) => changeSidebarSetting("home", state);
-export const changeExploreButton = (state) => changeSidebarSetting("explore", state);
+export const changeExploreButton = (state) => changeExploreButtonSetting(state);
export const changeNotificationsButton = (state) => changeSidebarSetting("notifications", state);
export const changeMessagesButton = (state) => changeSidebarSetting("messages", state);
export const changeBookmarksButton = (state) => changeSidebarSetting("bookmarks", state);
@@ -42,6 +59,31 @@ export const changeTopicsButton = (state) => changeSidebarSetting("topics", stat
export const changeCommunitiesButton = (state) => changeSidebarSetting("communities", state, addCommunitiesButton);
export const changeListsButton = (state) => changeSidebarSetting("lists", state, addListsButton);
export const changeAnalyticsButton = (state) => changeSidebarSetting("analytics", state, addAnalyticsButton);
+export const changeZenWriterModeButton = (state) => changeSidebarSetting("zenWriterMode", state, addZenWriterModeButton);
+
+export const addZenWriterModeButton = (writerMode) => {
+ addSidebarButton({
+ name: "Zen Writer Mode",
+ svgAsset: svgAssets.zenWriterMode.normal,
+ onClick: async () => {
+ const writerMode = await getStorage(KeyWriterMode);
+ await setStorage({ [KeyWriterMode]: writerMode === "on" ? "off" : "on" });
+ updateZenWriterModeButtonState(writerMode === "on" ? "off" : "on");
+ },
+ });
+
+ updateZenWriterModeButtonState(writerMode);
+};
+
+export const updateZenWriterModeButtonState = async (writerMode) => {
+ const button = document.querySelector('nav[role="navigation"] > [aria-label="Zen Writer Mode"]');
+
+ if (!button) return;
+
+ const state = writerMode ?? (await getStorage(KeyWriterMode));
+ button.classList.add("mt-zen-writer-mode-button");
+ button.classList.toggle("mt-zen-writer-mode-button-active", state === "on");
+};
let tm1;
export const addXPremiumButton = () => {
@@ -70,7 +112,7 @@ export const addAnalyticsButton = () => {
utm_content: "sidebar-grow-button",
"mt-screen-name": screenName,
},
- "grow"
+ "grow",
);
if (screenName) window.open(url, "_blank");
@@ -116,7 +158,7 @@ export const changeUnreadCountBadge = (unreadCountBadge) => {
}
${selectors.accountSwitcherButton} > div > svg+div[aria-label] {
display: none;
- }`
+ }`,
);
break;
}
@@ -132,7 +174,7 @@ const addStyleToRemoveLabels = () => {
${selectors.accountSwitcherLabel} {
display: none;
}
- `
+ `,
);
};
@@ -146,7 +188,7 @@ const addStyleToShowLabelsOnHover = () => {
opacity: 0;
transition: 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
}
- `
+ `,
);
addStyles(
"showLabelsOnHover",
@@ -155,7 +197,7 @@ const addStyleToShowLabelsOnHover = () => {
${selectors.accountSwitcherLabel_hover} {
opacity: 1;
}
- `
+ `,
);
};
@@ -179,7 +221,7 @@ flex: 0.5 1 auto;
${selectors.mainWrapper} {
align-items: flex-start;
}
-`
+`,
);
} else {
removeStyles("customDMsAndSearchStyle");
@@ -215,7 +257,7 @@ export const changeNavigationCenter = (navigationCenter) => {
justify-content: center;
padding-top: 0;
}
- `
+ `,
);
break;
@@ -234,7 +276,7 @@ export const hideGrokDrawer = (state) => {
"grokDrawer",
`${selectors.grokDrawer}:not(.typefully-grok-drawer-enabled) {
display: none !important;
- }`
+ }`,
);
break;
case "off":
diff --git a/content-scripts/src/modules/options/timeline.js b/content-scripts/src/modules/options/timeline.js
index 1aef790d..ce67a979 100644
--- a/content-scripts/src/modules/options/timeline.js
+++ b/content-scripts/src/modules/options/timeline.js
@@ -162,6 +162,7 @@ export const changeTopicsToFollow = (removeTopicsToFollow) => {
switch (removeTopicsToFollow) {
case "off":
removeStyles("removeTopicsToFollow");
+ document.querySelectorAll(".mt-whoToFollow").forEach((section) => section.classList.remove("mt-whoToFollow"));
break;
case "on":
@@ -173,15 +174,33 @@ export const changeTopicsToFollow = (removeTopicsToFollow) => {
${selectors.mainColumn} a[href*="/i/topics/picker/home"] {
display: none;
}
+ .mt-whoToFollow {
+ display: none;
+ }
[aria-label="Lists timeline"] section[aria-labelledby^="accessible-list-"] > div[aria-label$="Carousel"] {
display: flex;
}
`
);
+ hideWhoToFollowSuggestions();
break;
}
};
+function hideWhoToFollowSuggestions() {
+ const suggestionSections = document.querySelectorAll(`${selectors.mainColumn} section[aria-labelledby^="accessible-list-"]`);
+
+ suggestionSections.forEach((section) => {
+ if (section.closest('[aria-label="Lists timeline"]')) return;
+
+ const headingText = section.querySelector('h2, [role="heading"]')?.textContent?.trim().toLowerCase();
+
+ if (headingText === "who to follow") {
+ section.classList.add("mt-whoToFollow");
+ }
+ });
+}
+
export const changeTimelineTabs = (removeTimelineTabs, writerMode) => {
if (writerMode === "on" || window.location.pathname.includes("compose/tweet") || !window.location.pathname.includes("/home") || !window.location.pathname === "/") {
removeStyles("removeTimelineTabs");
diff --git a/content-scripts/src/modules/options/writerMode.js b/content-scripts/src/modules/options/writerMode.js
index 9471ff20..5632b92e 100644
--- a/content-scripts/src/modules/options/writerMode.js
+++ b/content-scripts/src/modules/options/writerMode.js
@@ -50,7 +50,6 @@ export const changeWriterMode = (writerMode) => {
width: 100%;
max-width: 100%;
}
- ${selectors.leftSidebar},
${selectors.rightSidebar},
${selectors.mainColumn} > div > div:not(:nth-of-type(1)):not(:nth-of-type(2)):not(:nth-of-type(3)) {
overflow: hidden;
@@ -59,6 +58,15 @@ export const changeWriterMode = (writerMode) => {
width: 0;
height: 0;
}
+ ${selectors.leftSidebar} nav[role="navigation"] > *:not([aria-label="Zen Writer Mode"]),
+ ${selectors.accountSwitcherButton},
+ ${selectors.tweetButton} {
+ overflow: hidden;
+ visibility: hidden;
+ opacity: 0;
+ width: 0;
+ height: 0;
+ }
${selectors.topHeader} {
visibility: hidden;
}
diff --git a/content-scripts/src/modules/svgAssets.js b/content-scripts/src/modules/svgAssets.js
index 55929135..f783b2eb 100644
--- a/content-scripts/src/modules/svgAssets.js
+++ b/content-scripts/src/modules/svgAssets.js
@@ -3,6 +3,9 @@ const svgAssets = {
normal: `