├── .gitignore
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── extension
├── .eslintrc.cjs
├── .gitignore
├── components.json
├── index.html
├── package-lock.json
├── package.json
├── pnpm-lock.yaml
├── postcss.config.cjs
├── public
│ ├── logos
│ │ ├── logo128.png
│ │ ├── logo16.png
│ │ └── logo48.png
│ └── manifest.json
├── settings.html
├── src
│ ├── components
│ │ ├── pages
│ │ │ ├── popup
│ │ │ │ └── index.tsx
│ │ │ └── settings
│ │ │ │ ├── api-keys.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ └── prompts.tsx
│ │ └── ui
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ ├── command.tsx
│ │ │ ├── dialog.tsx
│ │ │ ├── dropdown-menu.tsx
│ │ │ ├── form.tsx
│ │ │ ├── input.tsx
│ │ │ ├── label.tsx
│ │ │ ├── popover.tsx
│ │ │ ├── radio-group.tsx
│ │ │ └── switch.tsx
│ ├── content-script.ts
│ ├── index.css
│ ├── lib
│ │ ├── constants.ts
│ │ └── utils.ts
│ ├── popup.tsx
│ ├── service-worker.ts
│ ├── settings.tsx
│ ├── utils.ts
│ └── vite-env.d.ts
├── tailwind.config.cjs
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts
└── workflows
│ └── build.yml
└── landing
├── .gitignore
├── eslint.config.mjs
├── next.config.ts
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
├── android-chrome-192x192.png
├── android-chrome-512x512.png
├── apple-touch-icon.png
├── favicon-16x16.png
├── favicon-32x32.png
├── favicon.ico
├── logo128.png
└── site.webmanifest
├── src
├── app
│ ├── changelog
│ │ ├── changelog.md
│ │ └── page.tsx
│ ├── config
│ │ └── site.ts
│ ├── globals.css
│ ├── header.tsx
│ ├── layout.tsx
│ ├── page.tsx
│ └── privacy
│ │ └── page.tsx
├── components
│ └── tweet-embed.tsx
├── hooks
│ └── use-detect-browser.ts
└── types
│ └── mdx.d.ts
├── tailwind.config.ts
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | .env*
2 | .vscode
3 | .venv
4 | !.envrc
5 | ./extension/dist
6 | */dist
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Hi! Nice to have you here! :)
4 |
5 | First of all, huge thanks for your interest in contributing to unbaited!
6 |
7 | Please take a moment to review this document before submitting your first pull request. Please also check for open issues and pull requests to see if someone else is working on something similar.
8 |
9 | If you need any help, feel free to reach out to [daniel](https://x.com/nonzeroexitcode), or open an issue.
10 |
11 | ## Structure
12 |
13 | This repository consists of two main parts:
14 |
15 | ```
16 | .
17 | ├── landing # The landing page website
18 | │ ├── src
19 | │ │ ├── app # Next.js application
20 | │ └── ...
21 | └── extension # The browser extension
22 | ├── src # Extension source code
23 | └── ...
24 | ```
25 |
26 | ## Development
27 |
28 | ### Fork this repo
29 |
30 | You can fork this repo by clicking the fork button in the top right corner of this page.
31 |
32 | ### Clone on your local machine
33 |
34 | ```bash
35 | git clone https://github.com/your-username/unbaited.git
36 | ```
37 |
38 | ### Navigate to project directory
39 |
40 | ```bash
41 | cd unbaited
42 | ```
43 |
44 | ### Create a new Branch
45 |
46 | ```bash
47 | git checkout -b my-new-branch
48 | ```
49 |
50 | ### Landing Page Development
51 |
52 | Navigate to the landing directory and install dependencies:
53 |
54 | ```bash
55 | cd landing
56 | npm install
57 | ```
58 |
59 | Run the development server:
60 |
61 | ```bash
62 | npm run dev
63 | ```
64 |
65 | ### Extension Development
66 |
67 | Navigate to the extension directory and install dependencies:
68 |
69 | ```bash
70 | cd extension
71 | npm install
72 | ```
73 |
74 | Build the extension:
75 |
76 | ```bash
77 | npm run build
78 | ```
79 |
80 | To load the extension in your browser:
81 |
82 | 1. Chrome/Edge:
83 | - Open `chrome://extensions`
84 | - Enable "Developer mode"
85 | - Click "Load unpacked"
86 | - Select the `extension/dist` directory
87 |
88 | 2. Firefox:
89 | - Open `about:debugging#/runtime/this-firefox`
90 | - Click "Load Temporary Add-on"
91 | - Select any file in the `extension/dist` directory
92 |
93 | ## Commit Convention
94 |
95 | Before you create a Pull Request, please check whether your commits comply with
96 | the commit conventions used in this repository.
97 |
98 | When you create a commit we kindly ask you to follow the convention
99 | `category(scope or module): message` in your commit message while using one of
100 | the following categories:
101 |
102 | - `feat / feature`: all changes that introduce completely new code or new
103 | features
104 | - `fix`: changes that fix a bug (ideally you will additionally reference an
105 | issue if present)
106 | - `refactor`: any code related change that is not a fix nor a feature
107 | - `docs`: changing existing or creating new documentation (i.e. README, docs for
108 | usage of a lib or cli usage)
109 | - `build`: all changes regarding the build of the software, changes to
110 | dependencies or the addition of new dependencies
111 | - `test`: all changes regarding tests (adding new tests or changing existing
112 | ones)
113 | - `ci`: all changes regarding the configuration of continuous integration (i.e.
114 | github actions, ci system)
115 | - `chore`: all changes to the repository that do not fit into any of the above
116 | categories
117 |
118 | e.g. `feat(extension): added firefox support`
119 |
120 | If you are interested in the detailed specification you can visit
121 | https://www.conventionalcommits.org/ or check out the
122 | [Angular Commit Message Guidelines](https://github.com/angular/angular/blob/22b96b9/CONTRIBUTING.md#-commit-message-guidelines).
123 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Daniel Petho
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # unbaited
2 |
3 | Control your X feed with an LLM of your choice from Groq. A browser extension that helps you filter out engagement bait and inflammatory content from your X (formerly Twitter) feed.
4 |
5 | ## How it works
6 |
7 | The extension uses Groq's ultra-fast API to analyze tweets using a model of your choice. When you scroll through X, it:
8 |
9 | 1. Detects new tweets as they appear in your viewport
10 | 2. Sends the tweet content (only text as of now) to Groq's API for analysis
11 | 3. Blurs tweets that are identified as engagement bait, political tweets, etc.
12 | 4. Gives you the option to reveal hidden tweets with a single click
13 |
14 | ## Installation
15 |
16 | ### Chrome/Safari
17 | 1. Install the extension from the [Chrome Web Store](https://chromewebstore.google.com/detail/unbaited-prototype/bpbnggihcaknipcgbpgjgfhgmdgcokcg)
18 | 2. Get your API key from [Groq](https://console.groq.com)
19 | 3. Open the extension settings and enter your API key
20 | 4. Optionally customize the system prompt to adjust how tweets are analyzed
21 |
22 | ### Firefox
23 | 1. Install the extension from [Mozilla Add-ons](https://addons.mozilla.org/en-US/firefox/addon/unbaited-prototype/)
24 | 2. Get your API key from [Groq](https://console.groq.com)
25 | 3. Open the extension settings and enter your API key
26 | 4. Optionally customize the system prompt to adjust how tweets are analyzed
27 |
28 | ## Browser Support
29 |
30 | - Chrome: ✅ Full support
31 | - Firefox: ✅ Full support
32 | - Safari: ✅ Full support
33 |
34 | ## Development
35 |
36 | The project consists of two parts:
37 | - `extension/`: The Chrome extension
38 | - `landing/`: The landing page built with Next.js
39 |
40 | ### Extension Development
41 |
42 | ```bash
43 | cd extension
44 | npm i
45 | npm build
46 | ```
47 |
48 | #### Loading in browsers
49 |
50 | ##### Chrome/Safari
51 | Load the `extension/dist` directory as an unpacked extension:
52 | 1. Open Chrome/Safari
53 | 2. Go to Extensions page
54 | 3. Enable Developer Mode
55 | 4. Click "Load unpacked" and select the `extension/dist` directory
56 |
57 | ##### Firefox
58 | Load the extension temporarily:
59 | 1. Open Firefox
60 | 2. Go to `about:debugging`
61 | 3. Click "This Firefox"
62 | 4. Click "Load Temporary Add-on"
63 | 5. Select any file in the `extension/dist` directory
64 |
65 | ### Implementation Notes
66 |
67 | The extension uses different approaches for background processing:
68 | - Chrome/Safari: Uses Service Workers (MV3)
69 | - Firefox: Uses Background Scripts (MV3 with scripts fallback)
70 |
71 | This is handled automatically in the code, but when testing make sure to verify functionality in both environments.
72 |
--------------------------------------------------------------------------------
/extension/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react-hooks/recommended',
8 | ],
9 | ignorePatterns: ['dist', '.eslintrc.cjs'],
10 | parser: '@typescript-eslint/parser',
11 | plugins: ['react-refresh'],
12 | rules: {
13 | 'react-refresh/only-export-components': [
14 | 'warn',
15 | { allowConstantExport: true },
16 | ],
17 | },
18 | }
19 |
--------------------------------------------------------------------------------
/extension/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/extension/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.cjs",
8 | "css": "src/index.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/extension/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | unbaited
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/extension/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "unbaited",
3 | "private": true,
4 | "version": "0.0.5",
5 | "type": "module",
6 | "author": {
7 | "name": "daniel petho",
8 | "email": "hello@danielpetho.com",
9 | "url": "https://danielpetho.com"
10 | },
11 | "scripts": {
12 | "dev": "vite",
13 | "build": "tsc && vite build",
14 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
15 | "preview": "vite preview"
16 | },
17 | "dependencies": {
18 | "@hookform/resolvers": "^3.9.0",
19 | "@radix-ui/react-dialog": "^1.1.2",
20 | "@radix-ui/react-dropdown-menu": "^2.1.4",
21 | "@radix-ui/react-label": "^2.1.0",
22 | "@radix-ui/react-popover": "^1.1.2",
23 | "@radix-ui/react-radio-group": "^1.2.2",
24 | "@radix-ui/react-slot": "^1.1.0",
25 | "@radix-ui/react-switch": "^1.1.2",
26 | "class-variance-authority": "^0.7.0",
27 | "clsx": "^2.1.1",
28 | "cmdk": "1.0.0",
29 | "extension-cli": "^1.2.4",
30 | "groq-sdk": "^0.5.0",
31 | "lucide-react": "^0.424.0",
32 | "marked": "^13.0.2",
33 | "openai": "^4.55.0",
34 | "react": "^18.2.0",
35 | "react-dom": "^18.2.0",
36 | "react-hook-form": "^7.52.2",
37 | "tailwind-merge": "^2.4.0",
38 | "tailwindcss-animate": "^1.0.7",
39 | "zod": "^3.23.8",
40 | "zustand": "^5.0.1"
41 | },
42 | "devDependencies": {
43 | "@tailwindcss/typography": "^0.5.16",
44 | "@types/chrome": "^0.0.268",
45 | "@types/node": "^22.1.0",
46 | "@types/react": "^18.2.45",
47 | "@types/react-dom": "^18.2.18",
48 | "@typescript-eslint/eslint-plugin": "^6.14.0",
49 | "@typescript-eslint/parser": "^6.14.0",
50 | "@vitejs/plugin-react": "^4.2.1",
51 | "autoprefixer": "^10.4.20",
52 | "eslint": "^8.55.0",
53 | "eslint-plugin-react-hooks": "^4.6.0",
54 | "eslint-plugin-react-refresh": "^0.4.5",
55 | "postcss": "^8.4.41",
56 | "tailwindcss": "^3.4.7",
57 | "typescript": "^5.2.2",
58 | "vite": "^5.0.8",
59 | "vite-plugin-static-copy": "^1.0.6"
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/extension/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/extension/public/logos/logo128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danielpetho/unbaited/07438d74a4947959c85f04d8d1b75f4e4751084f/extension/public/logos/logo128.png
--------------------------------------------------------------------------------
/extension/public/logos/logo16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danielpetho/unbaited/07438d74a4947959c85f04d8d1b75f4e4751084f/extension/public/logos/logo16.png
--------------------------------------------------------------------------------
/extension/public/logos/logo48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danielpetho/unbaited/07438d74a4947959c85f04d8d1b75f4e4751084f/extension/public/logos/logo48.png
--------------------------------------------------------------------------------
/extension/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "unbaited (prototype)",
3 | "description": "Control your X feed with an LLM",
4 | "version": "0.0.5",
5 | "manifest_version": 3,
6 | "icons": {
7 | "16": "logos/logo16.png",
8 | "48": "logos/logo48.png",
9 | "128": "logos/logo128.png"
10 | },
11 | "action": {
12 | "default_popup": "index.html",
13 | "default_icon": {
14 | "16": "logos/logo16.png",
15 | "48": "logos/logo48.png",
16 | "128": "logos/logo128.png"
17 | }
18 | },
19 | "options_page": "settings.html",
20 | "permissions": ["storage"],
21 | "host_permissions": [
22 | "https://*.twitter.com/*",
23 | "https://*.x.com/*",
24 | "https://api.groq.com/*"
25 | ],
26 | "homepage_url": "https://unbaited.danielpetho.com",
27 | "background": {
28 | "service_worker": "serviceWorker.js",
29 | "scripts": ["serviceWorker.js"]
30 | },
31 | "content_scripts": [
32 | {
33 | "matches": ["*://*.x.com/*", "*://x.com/*"],
34 | "js": ["contentScript.js"]
35 | }
36 | ],
37 | "web_accessible_resources": [
38 | {
39 | "resources": [
40 | "icons/*",
41 | "fonts/*",
42 | "font.css",
43 | "index.css",
44 | "locale.json"
45 | ],
46 | "matches": ["*://*.x.com/*", "*://x.com/*"]
47 | }
48 | ],
49 | "browser_specific_settings": {
50 | "gecko": {
51 | "id": "unbaited@danielpetho.com",
52 | "strict_min_version": "58.0"
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/extension/settings.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Settings
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/extension/src/components/pages/popup/index.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { Switch } from "@/components/ui/switch";
3 | import React, { useEffect, useState } from 'react';
4 |
5 | function Popup() {
6 | const [hasApiKey, setHasApiKey] = useState(null);
7 | const [isEnabled, setIsEnabled] = useState(true);
8 |
9 | useEffect(() => {
10 | // Load both API key and enabled state
11 | chrome.storage.sync.get(['groqApiKey', 'isEnabled'], (result) => {
12 | setHasApiKey(!!result.groqApiKey);
13 | // Default to true if not set
14 | setIsEnabled(result.isEnabled ?? true);
15 | });
16 | }, []);
17 |
18 | const toggleExtension = async (checked: boolean) => {
19 | setIsEnabled(checked);
20 | await chrome.storage.sync.set({ isEnabled: checked });
21 |
22 | // Notify content script of the change
23 | const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
24 | tabs.forEach(tab => {
25 | if (tab.id) {
26 | chrome.tabs.sendMessage(tab.id, {
27 | action: "toggleExtension",
28 | isEnabled: checked
29 | });
30 | }
31 | });
32 | };
33 |
34 | const openSettings = () => {
35 | chrome.runtime.openOptionsPage();
36 | };
37 |
38 | return (
39 |
40 |
41 |
42 |
47 |
48 | unbaited
49 |
50 |
51 |
52 |
53 | {isEnabled ? 'on' : 'off'}
54 |
55 |
60 |
61 |
62 |
63 |
64 | Control your feed with LLMs on X
65 |
66 |
67 | {hasApiKey === false && (
68 |
69 |
70 | ⚠️ API key not set
71 |
72 |
73 | Please set your Groq API key in the settings to use this extension.
74 |
75 |
76 | )}
77 |
78 |
79 |
80 | To use this extension, you need to:
81 |
82 |
83 | Set up your API keys
84 | Customize system prompts (optional)
85 |
86 |
87 |
88 |
93 | Open Settings
94 |
95 |
96 | );
97 | }
98 |
99 | export default Popup;
100 |
--------------------------------------------------------------------------------
/extension/src/components/pages/settings/api-keys.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | export function ApiKeys() {
4 | const [groqKey, setGroqKey] = useState('');
5 | const [isSaved, setIsSaved] = useState(false);
6 |
7 | useEffect(() => {
8 | // Load saved API key when component mounts
9 | chrome.storage.sync.get(['groqApiKey'], (result) => {
10 | if (result.groqApiKey) {
11 | setGroqKey(result.groqApiKey);
12 | }
13 | });
14 | }, []);
15 |
16 | const handleSave = async () => {
17 | await chrome.storage.sync.set({ groqApiKey: groqKey });
18 | setIsSaved(true);
19 | setTimeout(() => setIsSaved(false), 2000);
20 | };
21 |
22 | const handleClear = () => {
23 | setGroqKey('');
24 | chrome.storage.local.remove('groqApiKey');
25 | setIsSaved(true);
26 | setTimeout(() => setIsSaved(false), 2000);
27 | };
28 |
29 | return (
30 |
31 |
32 |
API Keys
33 |
34 |
38 | Clear
39 |
40 |
44 | Save Changes
45 |
46 |
47 |
48 |
49 | {isSaved && (
50 |
51 | ✓ Changes saved successfully
52 |
53 | )}
54 |
55 |
56 |
57 |
58 | Groq API Key
59 |
60 | setGroqKey(e.target.value)}
65 | className="w-full p-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-black"
66 | placeholder="Enter your Groq API key"
67 | />
68 |
69 |
70 |
71 |
82 |
83 | );
84 | }
85 |
--------------------------------------------------------------------------------
/extension/src/components/pages/settings/index.tsx:
--------------------------------------------------------------------------------
1 | import { ApiKeys } from './api-keys';
2 | import { PromptsSettings } from './prompts';
3 | import { useState, useEffect } from 'react';
4 | import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
5 | import { Label } from "@/components/ui/label"
6 | import {
7 | DropdownMenu,
8 | DropdownMenuContent,
9 | DropdownMenuItem,
10 | DropdownMenuLabel,
11 | DropdownMenuSeparator,
12 | DropdownMenuTrigger,
13 | } from "@/components/ui/dropdown-menu"
14 |
15 | const models = [
16 | { id: 'gemma2-9b-it', name: 'Gemma 2 9B', provider: 'Google' },
17 | { id: 'llama-3.3-70b-versatile', name: 'Llama 3.3 70B Versatile', provider: 'Meta' },
18 | { id: 'llama-3.1-8b-instant', name: 'Llama 3.1 8B Instant', provider: 'Meta' },
19 | { id: 'llama-guard-3-8b', name: 'Llama Guard 3 8B', provider: 'Meta' },
20 | { id: 'llama3-70b-8192', name: 'Llama 3 70B', provider: 'Meta' },
21 | { id: 'llama3-8b-8192', name: 'Llama 3 8B', provider: 'Meta' },
22 | ];
23 |
24 | export default function Settings() {
25 | const [displayMode, setDisplayMode] = useState<'blur' | 'hide'>('blur');
26 | const [selectedModel, setSelectedModel] = useState(models[0].id);
27 |
28 | useEffect(() => {
29 | // Load saved settings
30 | chrome.storage.sync.get(['displayMode', 'selectedModel'], (result) => {
31 | if (result.displayMode) {
32 | setDisplayMode(result.displayMode);
33 | }
34 | if (result.selectedModel) {
35 | setSelectedModel(result.selectedModel);
36 | }
37 | });
38 | }, []);
39 |
40 | const handleDisplayModeChange = (value: string) => {
41 | const mode = value as 'blur' | 'hide';
42 | setDisplayMode(mode);
43 | chrome.storage.sync.set({ displayMode: mode });
44 | };
45 |
46 | const handleModelChange = (modelId: string) => {
47 | setSelectedModel(modelId);
48 | chrome.storage.sync.set({ selectedModel: modelId });
49 | };
50 |
51 | const selectedModelName = models.find(m => m.id === selectedModel)?.id || 'Select Model';
52 |
53 | return (
54 |
55 |
Settings
56 |
57 |
60 |
61 |
62 | Model Selection
63 |
64 | Choose Model
65 |
66 |
67 | {selectedModelName}
68 |
69 |
70 | Models
71 |
72 | {models.map((model) => (
73 | handleModelChange(model.id)}
76 | className="flex justify-between font-mono"
77 | >
78 | {model.id}
79 | {model.provider}
80 |
81 | ))}
82 |
83 |
84 |
85 |
86 |
87 |
88 | Display Settings
89 |
94 |
95 |
96 | Blur tweets
97 |
98 |
99 |
100 | Hide tweets
101 |
102 |
103 |
104 |
105 |
108 |
109 | );
110 | }
111 |
--------------------------------------------------------------------------------
/extension/src/components/pages/settings/prompts.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { Label } from '@/components/ui/label';
3 | import {
4 | DEFAULT_CRITERIA,
5 | SYSTEM_PROMPT_PREFIX,
6 | SYSTEM_PROMPT_SUFFIX
7 | } from '@/lib/constants';
8 |
9 | export function PromptsSettings() {
10 | const [criteria, setCriteria] = useState(DEFAULT_CRITERIA);
11 | const [isDefault, setIsDefault] = useState(true);
12 |
13 | useEffect(() => {
14 | chrome.storage.sync.get(['promptCriteria'], (result) => {
15 | if (result.promptCriteria) {
16 | setCriteria(result.promptCriteria);
17 | setIsDefault(false);
18 | }
19 | });
20 | }, []);
21 |
22 | const handleCriteriaChange = (value: string) => {
23 | setCriteria(value);
24 | setIsDefault(value === DEFAULT_CRITERIA);
25 | chrome.storage.sync.set({ promptCriteria: value });
26 | };
27 |
28 | const resetToDefault = () => {
29 | handleCriteriaChange(DEFAULT_CRITERIA);
30 | };
31 |
32 | return (
33 |
34 |
35 |
Prompt Settings
36 | {!isDefault && (
37 |
41 | Reset to Default
42 |
43 | )}
44 |
45 |
46 |
47 |
48 |
49 | System Prompt
50 | (fixed)
51 |
52 |
53 | {SYSTEM_PROMPT_PREFIX}
54 |
55 |
56 |
57 |
58 |
59 | Detection Criteria
60 | (editable)
61 |
62 |
69 |
70 |
71 |
72 | System Prompt
73 | (fixed)
74 |
75 |
76 | {SYSTEM_PROMPT_SUFFIX}
77 |
78 |
79 |
80 |
81 |
82 | Note: The system will always return true/false based on these criteria.
83 |
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/extension/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-white hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button"
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = "Button"
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/extension/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ))
45 | CardTitle.displayName = "CardTitle"
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | CardDescription.displayName = "CardDescription"
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ))
65 | CardContent.displayName = "CardContent"
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ))
77 | CardFooter.displayName = "CardFooter"
78 |
79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
80 |
--------------------------------------------------------------------------------
/extension/src/components/ui/command.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { type DialogProps } from "@radix-ui/react-dialog"
3 | import { Command as CommandPrimitive } from "cmdk"
4 | import { Search } from "lucide-react"
5 |
6 | import { cn } from "@/lib/utils"
7 | import { Dialog, DialogContent } from "@/components/ui/dialog"
8 |
9 | const Command = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 | ))
22 | Command.displayName = CommandPrimitive.displayName
23 |
24 | const CommandDialog = ({ children, ...props }: DialogProps) => {
25 | return (
26 |
27 |
28 |
29 | {children}
30 |
31 |
32 |
33 | )
34 | }
35 |
36 | const CommandInput = React.forwardRef<
37 | React.ElementRef,
38 | React.ComponentPropsWithoutRef
39 | >(({ className, ...props }, ref) => (
40 |
41 |
42 |
50 |
51 | ))
52 |
53 | CommandInput.displayName = CommandPrimitive.Input.displayName
54 |
55 | const CommandList = React.forwardRef<
56 | React.ElementRef,
57 | React.ComponentPropsWithoutRef
58 | >(({ className, ...props }, ref) => (
59 |
64 | ))
65 |
66 | CommandList.displayName = CommandPrimitive.List.displayName
67 |
68 | const CommandEmpty = React.forwardRef<
69 | React.ElementRef,
70 | React.ComponentPropsWithoutRef
71 | >((props, ref) => (
72 |
77 | ))
78 |
79 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName
80 |
81 | const CommandGroup = React.forwardRef<
82 | React.ElementRef,
83 | React.ComponentPropsWithoutRef
84 | >(({ className, ...props }, ref) => (
85 |
93 | ))
94 |
95 | CommandGroup.displayName = CommandPrimitive.Group.displayName
96 |
97 | const CommandSeparator = React.forwardRef<
98 | React.ElementRef,
99 | React.ComponentPropsWithoutRef
100 | >(({ className, ...props }, ref) => (
101 |
106 | ))
107 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName
108 |
109 | const CommandItem = React.forwardRef<
110 | React.ElementRef,
111 | React.ComponentPropsWithoutRef
112 | >(({ className, ...props }, ref) => (
113 |
121 | ))
122 |
123 | CommandItem.displayName = CommandPrimitive.Item.displayName
124 |
125 | const CommandShortcut = ({
126 | className,
127 | ...props
128 | }: React.HTMLAttributes) => {
129 | return (
130 |
137 | )
138 | }
139 | CommandShortcut.displayName = "CommandShortcut"
140 |
141 | export {
142 | Command,
143 | CommandDialog,
144 | CommandInput,
145 | CommandList,
146 | CommandEmpty,
147 | CommandGroup,
148 | CommandItem,
149 | CommandShortcut,
150 | CommandSeparator,
151 | }
152 |
--------------------------------------------------------------------------------
/extension/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { X } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogClose,
116 | DialogTrigger,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/extension/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import { Check, ChevronRight, Circle } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ))
40 | DropdownMenuSubTrigger.displayName =
41 | DropdownMenuPrimitive.SubTrigger.displayName
42 |
43 | const DropdownMenuSubContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, ...props }, ref) => (
47 |
55 | ))
56 | DropdownMenuSubContent.displayName =
57 | DropdownMenuPrimitive.SubContent.displayName
58 |
59 | const DropdownMenuContent = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, sideOffset = 4, ...props }, ref) => (
63 |
64 |
74 |
75 | ))
76 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
77 |
78 | const DropdownMenuItem = React.forwardRef<
79 | React.ElementRef,
80 | React.ComponentPropsWithoutRef & {
81 | inset?: boolean
82 | }
83 | >(({ className, inset, ...props }, ref) => (
84 | svg]:size-4 [&>svg]:shrink-0",
88 | inset && "pl-8",
89 | className
90 | )}
91 | {...props}
92 | />
93 | ))
94 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
95 |
96 | const DropdownMenuCheckboxItem = React.forwardRef<
97 | React.ElementRef,
98 | React.ComponentPropsWithoutRef
99 | >(({ className, children, checked, ...props }, ref) => (
100 |
109 |
110 |
111 |
112 |
113 |
114 | {children}
115 |
116 | ))
117 | DropdownMenuCheckboxItem.displayName =
118 | DropdownMenuPrimitive.CheckboxItem.displayName
119 |
120 | const DropdownMenuRadioItem = React.forwardRef<
121 | React.ElementRef,
122 | React.ComponentPropsWithoutRef
123 | >(({ className, children, ...props }, ref) => (
124 |
132 |
133 |
134 |
135 |
136 |
137 | {children}
138 |
139 | ))
140 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
141 |
142 | const DropdownMenuLabel = React.forwardRef<
143 | React.ElementRef,
144 | React.ComponentPropsWithoutRef & {
145 | inset?: boolean
146 | }
147 | >(({ className, inset, ...props }, ref) => (
148 |
157 | ))
158 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
159 |
160 | const DropdownMenuSeparator = React.forwardRef<
161 | React.ElementRef,
162 | React.ComponentPropsWithoutRef
163 | >(({ className, ...props }, ref) => (
164 |
169 | ))
170 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
171 |
172 | const DropdownMenuShortcut = ({
173 | className,
174 | ...props
175 | }: React.HTMLAttributes) => {
176 | return (
177 |
181 | )
182 | }
183 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
184 |
185 | export {
186 | DropdownMenu,
187 | DropdownMenuTrigger,
188 | DropdownMenuContent,
189 | DropdownMenuItem,
190 | DropdownMenuCheckboxItem,
191 | DropdownMenuRadioItem,
192 | DropdownMenuLabel,
193 | DropdownMenuSeparator,
194 | DropdownMenuShortcut,
195 | DropdownMenuGroup,
196 | DropdownMenuPortal,
197 | DropdownMenuSub,
198 | DropdownMenuSubContent,
199 | DropdownMenuSubTrigger,
200 | DropdownMenuRadioGroup,
201 | }
202 |
--------------------------------------------------------------------------------
/extension/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { Slot } from "@radix-ui/react-slot"
4 | import {
5 | Controller,
6 | ControllerProps,
7 | FieldPath,
8 | FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | } from "react-hook-form";
12 |
13 | import { cn } from "@/lib/utils"
14 | import { Label } from "@/components/ui/label"
15 |
16 | const Form = FormProvider
17 |
18 | type FormFieldContextValue<
19 | TFieldValues extends FieldValues = FieldValues,
20 | TName extends FieldPath = FieldPath
21 | > = {
22 | name: TName
23 | }
24 |
25 | const FormFieldContext = React.createContext(
26 | {} as FormFieldContextValue
27 | )
28 |
29 | const FormField = <
30 | TFieldValues extends FieldValues = FieldValues,
31 | TName extends FieldPath = FieldPath
32 | >({
33 | ...props
34 | }: ControllerProps) => {
35 | return (
36 |
37 |
38 |
39 | )
40 | }
41 |
42 | const useFormField = () => {
43 | const fieldContext = React.useContext(FormFieldContext)
44 | const itemContext = React.useContext(FormItemContext)
45 | const { getFieldState, formState } = useFormContext()
46 |
47 | const fieldState = getFieldState(fieldContext.name, formState)
48 |
49 | if (!fieldContext) {
50 | throw new Error("useFormField should be used within ")
51 | }
52 |
53 | const { id } = itemContext
54 |
55 | return {
56 | id,
57 | name: fieldContext.name,
58 | formItemId: `${id}-form-item`,
59 | formDescriptionId: `${id}-form-item-description`,
60 | formMessageId: `${id}-form-item-message`,
61 | ...fieldState,
62 | }
63 | }
64 |
65 | type FormItemContextValue = {
66 | id: string
67 | }
68 |
69 | const FormItemContext = React.createContext(
70 | {} as FormItemContextValue
71 | )
72 |
73 | const FormItem = React.forwardRef<
74 | HTMLDivElement,
75 | React.HTMLAttributes
76 | >(({ className, ...props }, ref) => {
77 | const id = React.useId()
78 |
79 | return (
80 |
81 |
82 |
83 | )
84 | })
85 | FormItem.displayName = "FormItem"
86 |
87 | const FormLabel = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => {
91 | const { error, formItemId } = useFormField()
92 |
93 | return (
94 |
100 | )
101 | })
102 | FormLabel.displayName = "FormLabel"
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | })
124 | FormControl.displayName = "FormControl"
125 |
126 | const FormDescription = React.forwardRef<
127 | HTMLParagraphElement,
128 | React.HTMLAttributes
129 | >(({ className, ...props }, ref) => {
130 | const { formDescriptionId } = useFormField()
131 |
132 | return (
133 |
139 | )
140 | })
141 | FormDescription.displayName = "FormDescription"
142 |
143 | const FormMessage = React.forwardRef<
144 | HTMLParagraphElement,
145 | React.HTMLAttributes
146 | >(({ className, children, ...props }, ref) => {
147 | const { error, formMessageId } = useFormField()
148 | const body = error ? String(error?.message) : children
149 |
150 | if (!body) {
151 | return null
152 | }
153 |
154 | return (
155 |
161 | {body}
162 |
163 | )
164 | })
165 | FormMessage.displayName = "FormMessage"
166 |
167 | export {
168 | useFormField,
169 | Form,
170 | FormItem,
171 | FormLabel,
172 | FormControl,
173 | FormDescription,
174 | FormMessage,
175 | FormField,
176 | }
177 |
--------------------------------------------------------------------------------
/extension/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/extension/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium font-mono lowercase leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/extension/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as PopoverPrimitive from "@radix-ui/react-popover"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Popover = PopoverPrimitive.Root
7 |
8 | const PopoverTrigger = PopoverPrimitive.Trigger
9 |
10 | const PopoverContent = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
14 |
15 |
25 |
26 | ))
27 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
28 |
29 | export { Popover, PopoverTrigger, PopoverContent }
30 |
--------------------------------------------------------------------------------
/extension/src/components/ui/radio-group.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
5 | import { Circle } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const RadioGroup = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => {
13 | return (
14 |
19 | )
20 | })
21 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
22 |
23 | const RadioGroupItem = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => {
27 | return (
28 |
36 |
37 |
38 |
39 |
40 | )
41 | })
42 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
43 |
44 | export { RadioGroup, RadioGroupItem }
45 |
--------------------------------------------------------------------------------
/extension/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SwitchPrimitives from "@radix-ui/react-switch"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ))
27 | Switch.displayName = SwitchPrimitives.Root.displayName
28 |
29 | export { Switch }
30 |
--------------------------------------------------------------------------------
/extension/src/content-script.ts:
--------------------------------------------------------------------------------
1 | import './index.css';
2 |
3 | const style = document.createElement('style');
4 | style.textContent = `
5 | #unbaited * {
6 | all: unset;
7 | }
8 |
9 | #unbaited ul {
10 | list-style-type: disc !important;
11 | margin: 0 !important;
12 | padding-left: 20px !important;
13 | }
14 |
15 | #unbaited li {
16 | display: list-item !important;
17 | margin: 0.5em 0 !important;
18 | }
19 |
20 | #unbaited a {
21 | cursor: pointer;
22 | }
23 |
24 | #unbaited hr {
25 | height: 1px !important;
26 | border: none !important;
27 | padding: 0 !important;
28 | background-color: var(--black) !important;
29 | }
30 | `;
31 | document.head.appendChild(style);
32 |
33 | const link = document.createElement('link');
34 | link.type = 'text/css';
35 | link.rel = 'stylesheet';
36 | document.head.appendChild(link);
37 |
38 | const showButtonStyle = document.createElement('style');
39 | showButtonStyle.textContent = `
40 | .unbaited-controls {
41 | position: absolute;
42 | top: 50%;
43 | left: 50%;
44 | transform: translate(-50%, -50%);
45 | display: flex;
46 | flex-direction: column;
47 | align-items: center;
48 | gap: 8px;
49 | z-index: 1000;
50 | }
51 |
52 | .unbaited-show-tweet-button {
53 | background-color: white;
54 | color: black;
55 | padding: 8px 16px;
56 | border-radius: 20px;
57 | border: 1px solid rgba(0, 0, 0, 0.1);
58 | cursor: pointer;
59 | font-family: system-ui;
60 | }
61 |
62 | .unbaited-reasons {
63 | color: #666;
64 | font-size: 12px;
65 | text-align: center;
66 | white-space: nowrap;
67 | font-family: system-ui;
68 | background: rgba(255, 255, 255, 0.9);
69 | padding: 4px 8px;
70 | border-radius: 4px;
71 | }
72 |
73 | .unbaited-tweet-container {
74 | position: relative;
75 | }
76 |
77 | .unbaited-tweet.hidden-tweet {
78 | display: none !important;
79 | }
80 | `;
81 | document.head.appendChild(showButtonStyle);
82 |
83 | // Function to extract tweet content
84 | function getTweetContent(tweetElement: Element): {
85 | text: string;
86 | author: string;
87 | images: string[];
88 | videos: string[];
89 | urls: string[];
90 | timestamp: string;
91 | metrics: {
92 | replies: string;
93 | reposts: string;
94 | likes: string;
95 | views: string;
96 | };
97 | id: string;
98 | } {
99 | // console.log("Getting content for tweet element:", tweetElement);
100 |
101 | // Helper function to recursively find elements by data-testid
102 | function findElementsByTestId(element: Element, testId: string): Element[] {
103 | const results: Element[] = [];
104 |
105 | // Check current element
106 | if (element.getAttribute('data-testid') === testId) {
107 | results.push(element);
108 | }
109 |
110 | // Check children recursively
111 | element.childNodes.forEach((child) => {
112 | if (child instanceof Element) {
113 | results.push(...findElementsByTestId(child, testId));
114 | }
115 | });
116 |
117 | return results;
118 | }
119 |
120 | // Helper function to get text content from an element and its children
121 | function getTextContent(element: Element): string {
122 | let text = '';
123 | element.childNodes.forEach((node) => {
124 | if (node.nodeType === Node.TEXT_NODE) {
125 | text += node.textContent?.trim() + ' ';
126 | } else if (node instanceof Element) {
127 | text += getTextContent(node) + ' ';
128 | }
129 | });
130 | return text.trim();
131 | }
132 |
133 | // Find text content
134 | const textElements = findElementsByTestId(tweetElement, 'tweetText');
135 | const text = textElements
136 | .map((el) => getTextContent(el))
137 | .join(' ')
138 | .trim();
139 |
140 | // Find author
141 | const authorElements = findElementsByTestId(tweetElement, 'User-Name');
142 | const authorText = authorElements.map((el) => getTextContent(el)).join(' ');
143 | const author =
144 | authorText
145 | .split(/[·\n]/)
146 | .map((part) => part.trim())
147 | .filter((part) => part)[0] || '';
148 |
149 | // Find images - look for tweetPhoto containers first
150 | const images: string[] = [];
151 | const photoContainers = findElementsByTestId(tweetElement, 'tweetPhoto');
152 | // console.log("Found photo containers:", photoContainers);
153 |
154 | photoContainers.forEach((container) => {
155 | // Recursively find all img elements within the photo container
156 | const findImages = (element: Element) => {
157 | element.childNodes.forEach((child) => {
158 | if (child instanceof Element) {
159 | if (child.tagName === 'IMG') {
160 | const src = child.getAttribute('src');
161 | // console.log("Found image in photo container:", { src });
162 | if (src && !src.includes('profile')) {
163 | const highQualitySrc = src.replace(
164 | /\?format=\w+&name=\w+/,
165 | '?format=jpg&name=large'
166 | );
167 | images.push(highQualitySrc);
168 | }
169 | }
170 | findImages(child);
171 | }
172 | });
173 | };
174 |
175 | findImages(container);
176 | });
177 |
178 | // Find videos - look for video player containers
179 | const videos: string[] = [];
180 | const videoContainers = findElementsByTestId(tweetElement, 'videoPlayer');
181 | // console.log("Found video containers:", videoContainers);
182 |
183 | videoContainers.forEach((container) => {
184 | // Recursively find all video elements and sources
185 | const findVideos = (element: Element) => {
186 | element.childNodes.forEach((child) => {
187 | if (child instanceof Element) {
188 | if (child.tagName === 'VIDEO') {
189 | const src = child.getAttribute('src');
190 | // console.log("Found video source:", { src });
191 | if (src) videos.push(src);
192 | }
193 | // Also check for source elements within video
194 | if (child.tagName === 'SOURCE') {
195 | const src = child.getAttribute('src');
196 | // console.log("Found video source element:", { src });
197 | if (src) videos.push(src);
198 | }
199 | findVideos(child);
200 | }
201 | });
202 | };
203 |
204 | findVideos(container);
205 | });
206 |
207 | // Find URLs
208 | const urls: string[] = [];
209 | const allLinks = tweetElement.getElementsByTagName('a');
210 | Array.from(allLinks).forEach((link) => {
211 | const href = link.getAttribute('href');
212 | if (href?.startsWith('https://') && !href.includes('twitter.com')) {
213 | urls.push(href);
214 | }
215 | });
216 |
217 | // Find timestamp
218 | const timeElements = tweetElement.getElementsByTagName('time');
219 | const timestamp = timeElements[0]?.getAttribute('datetime') || '';
220 |
221 | // Find metrics
222 | const metrics = {
223 | replies: '0',
224 | reposts: '0',
225 | likes: '0',
226 | views: '0',
227 | };
228 |
229 | // Process metrics recursively
230 | const metricsMap = {
231 | reply: 'replies',
232 | retweet: 'reposts',
233 | like: 'likes',
234 | analytics: 'views',
235 | };
236 |
237 | Object.entries(metricsMap).forEach(([testId, metricKey]) => {
238 | const elements = findElementsByTestId(tweetElement, testId);
239 | if (elements.length > 0) {
240 | const value = getTextContent(elements[0]);
241 | if (value) {
242 | metrics[metricKey as keyof typeof metrics] = value;
243 | }
244 | }
245 | });
246 |
247 | const id = Math.random().toString(36).substring(7);
248 | tweetElement.setAttribute('data-tweet-id', id);
249 |
250 | const result = {
251 | text,
252 | author,
253 | images,
254 | videos,
255 | urls,
256 | timestamp,
257 | metrics,
258 | id,
259 | };
260 |
261 | // console.log("Final extracted content:", result);
262 | return result;
263 | }
264 |
265 | const tweetObserver = new IntersectionObserver(
266 | (entries) => {
267 | entries.forEach((entry) => {
268 | const tweetElement = entry.target;
269 |
270 | // Only process tweets that are becoming visible
271 | if (entry.isIntersecting) {
272 | // Check if we've already processed this tweet
273 | if (!tweetElement.hasAttribute('data-processed')) {
274 | const tweetContent = getTweetContent(tweetElement);
275 |
276 | chrome.runtime.sendMessage({
277 | action: 'newTweet',
278 | content: tweetContent,
279 | });
280 |
281 | // Mark as processed to avoid duplicate analysis
282 | tweetElement.setAttribute('data-processed', 'true');
283 | }
284 |
285 | // Optionally, stop observing once processed
286 | tweetObserver.unobserve(tweetElement);
287 | }
288 | });
289 | },
290 | {
291 | // Configure the observer:
292 | threshold: 0.3, // Trigger when at least 30% of the tweet is visible
293 | rootMargin: '100px', // Start loading slightly before the tweet enters viewport
294 | }
295 | );
296 |
297 | const mutationObserver = new MutationObserver((mutations) => {
298 | mutations.forEach((mutation) => {
299 | mutation.addedNodes.forEach((node) => {
300 | if (node instanceof HTMLElement) {
301 | const tweets = node.querySelectorAll(
302 | '[data-testid="tweet"]:not([data-processed])'
303 | );
304 | tweets.forEach((tweet) => {
305 | // Instead of analyzing immediately, start observing for visibility
306 | tweetObserver.observe(tweet);
307 | });
308 | }
309 | });
310 | });
311 | });
312 |
313 | if (
314 | window.location.hostname === 'twitter.com' ||
315 | window.location.hostname === 'x.com'
316 | ) {
317 | mutationObserver.observe(document.body, {
318 | childList: true,
319 | subtree: true,
320 | });
321 |
322 | // Handle existing tweets
323 | const existingTweets = document.querySelectorAll(
324 | '[data-testid="tweet"]:not([data-processed])'
325 | );
326 | existingTweets.forEach((tweet) => {
327 | tweetObserver.observe(tweet);
328 | });
329 | }
330 |
331 | // Listen for responses from background script
332 | chrome.runtime.onMessage.addListener((message) => {
333 | if (message.action === 'result') {
334 | }
335 | });
336 |
337 | chrome.runtime.onMessage.addListener((message) => {
338 | if (message.action === 'analysisResult') {
339 | const { tweetId, isBait, error } = message.result;
340 |
341 | if (error) {
342 | console.error(`Error analyzing tweet ${tweetId}:`, error);
343 | return;
344 | }
345 |
346 | const tweetElement = document.querySelector(
347 | `[data-tweet-id="${tweetId}"]`
348 | );
349 |
350 | if (tweetElement && isBait) {
351 | chrome.storage.sync.get(['displayMode'], (result) => {
352 | const displayMode = result.displayMode || 'blur';
353 |
354 | if (displayMode === 'blur') {
355 | // Add container for relative positioning
356 | const container = document.createElement('div');
357 | container.className = 'unbaited-tweet-container';
358 | tweetElement.parentNode?.insertBefore(
359 | container,
360 | tweetElement
361 | );
362 | container.appendChild(tweetElement);
363 |
364 | // Create controls container
365 | const controlsContainer = document.createElement('div');
366 | controlsContainer.className = 'unbaited-controls';
367 | container.appendChild(controlsContainer);
368 |
369 | // Add the show button
370 | const showButton = document.createElement('button');
371 | showButton.className = 'unbaited-show-tweet-button';
372 | showButton.textContent = 'Show';
373 | controlsContainer.appendChild(showButton);
374 |
375 | // Add filter reasons if available
376 | if (message.result.reasons?.length > 0) {
377 | const reasons = document.createElement('div');
378 | reasons.className = 'unbaited-reasons';
379 | reasons.textContent = `Filtered: ${message.result.reasons.join(
380 | ', '
381 | )}`;
382 | controlsContainer.appendChild(reasons);
383 | }
384 |
385 | // Apply blur effect
386 | tweetElement.classList.add('unbaited-tweet');
387 | (tweetElement as HTMLElement).style.filter = 'blur(12px)';
388 |
389 | // Add click handler for the show button
390 | showButton.addEventListener('click', (e) => {
391 | e.preventDefault();
392 | e.stopPropagation();
393 |
394 | // Remove blur effect
395 | (tweetElement as HTMLElement).style.filter = 'none';
396 | tweetElement.classList.remove('unbaited-tweet');
397 |
398 | // Remove the controls container
399 | controlsContainer.remove();
400 | });
401 | } else {
402 | // Just hide the tweet completely
403 | tweetElement.classList.add(
404 | 'unbaited-tweet',
405 | 'hidden-tweet'
406 | );
407 | }
408 | });
409 | }
410 | }
411 | });
412 |
413 | chrome.runtime.onMessage.addListener((message) => {
414 | if (message.action === 'toggleExtension') {
415 | if (!message.isEnabled) {
416 | // Remove all blur effects and show buttons when disabled
417 | document.querySelectorAll('.unbaited-tweet').forEach((tweet) => {
418 | (tweet as HTMLElement).style.filter = 'none';
419 | tweet.classList.remove('unbaited-tweet');
420 | });
421 | document
422 | .querySelectorAll('.unbaited-show-tweet-button')
423 | .forEach((button) => {
424 | button.remove();
425 | });
426 | }
427 | }
428 | });
429 |
--------------------------------------------------------------------------------
/extension/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --primary: 272, 50%, 54%;
8 | --background: 0, 100%, 100%;
9 | --foreground: 0, 0%, 2%;
10 |
11 | --card: 0 0% 100%;
12 | --card-foreground: 240 10% 3.9%;
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 240 10% 3.9%;
15 | --secondary: 240 4.8% 95.9%;
16 | --secondary-foreground: 240 5.9% 10%;
17 | --muted: 240 4.8% 95.9%;
18 | --muted-foreground: 240 3.8% 46.1%;
19 | --accent: 240 4.8% 95.9%;
20 | --accent-foreground: 240 5.9% 10%;
21 | --destructive: 0 84.2% 60.2%;
22 | --destructive-foreground: 0 0% 98%;
23 | --border: 75, 22%, 15%;
24 | --input: 240 5.9% 90%;
25 | --ring: 240 10% 3.9%;
26 | --radius: 0.5rem;
27 | --chart-1: 12 76% 61%;
28 | --chart-2: 173 58% 39%;
29 | --chart-3: 197 37% 24%;
30 | --chart-4: 43 74% 66%;
31 | --chart-5: 27 87% 67%;
32 | }
33 |
34 | .dark {
35 | --background: 240 10% 3.9%;
36 | --foreground: 0 0% 98%;
37 | --card: 240 10% 3.9%;
38 | --card-foreground: 0 0% 98%;
39 | --popover: 240 10% 3.9%;
40 | --popover-foreground: 0 0% 98%;
41 | --primary: 0 0% 98%;
42 | --primary-foreground: 240 5.9% 10%;
43 | --secondary: 240 3.7% 15.9%;
44 | --secondary-foreground: 0 0% 98%;
45 | --muted: 240 3.7% 15.9%;
46 | --muted-foreground: 240 5% 64.9%;
47 | --accent: 240 3.7% 15.9%;
48 | --accent-foreground: 0 0% 98%;
49 | --destructive: 0 62.8% 30.6%;
50 | --destructive-foreground: 0 0% 98%;
51 | --border: 240 3.7% 15.9%;
52 | --input: 240 3.7% 15.9%;
53 | --ring: 240 4.9% 83.9%;
54 | --chart-1: 220 70% 50%;
55 | --chart-2: 160 60% 45%;
56 | --chart-3: 30 80% 55%;
57 | --chart-4: 280 65% 60%;
58 | --chart-5: 340 75% 55%;
59 | }
60 | }
61 |
62 | @layer base {
63 | * {
64 | @apply border-border;
65 | }
66 | body {
67 | @apply bg-background text-foreground;
68 | }
69 | }
--------------------------------------------------------------------------------
/extension/src/lib/constants.ts:
--------------------------------------------------------------------------------
1 | export const DEFAULT_CRITERIA = `- The tweet is designed to provoke a negative emotional response, such as anger, fear
2 | - The tweet contains inflammatory or controversial statements
3 | - The tweet uses sensationalized language or exaggeration
4 | - The tweet appears to be intentionally divisive
5 | - The tweet makes extreme or absolute claims
6 | - The tweet uses manipulative tactics to gain engagement
7 | - The tweet is political in nature. It discusses politics, government, political issues, parties, candidates, elections, or any other political topic, be it related to any country or region.
8 | - The tweet discusses ideologies in relation of politics. Topics such as racism, communism, fascism, nationalism, immigration, anti-immigration, DEI, woke-ism, far-left, far-right, etc.
9 | - The tweet contains misleading or out-of-context information`;
10 |
11 | export const SYSTEM_PROMPT_PREFIX = `You are a tweet analyzer. Your job is to decide if the content of a tweet is met with the following criteria:`;
12 |
13 | export const SYSTEM_PROMPT_SUFFIX = `
14 | If any of the above criteria are met, the tweet should be considered bait.
15 | Respond EXCLUSIVELY using one of these formats:
16 | - "true: reason1, reason2, reason3" (if bait)
17 | - "false" (if not bait)
18 |
19 | Where reasons are 1-3 lowercase keywords from the criteria. Example responses:
20 | "true: political, divisive"
21 | "true: sensationalized, manipulative"
22 | "false"`;
23 |
24 | export function constructFullPrompt(criteria: string): string {
25 | return `${SYSTEM_PROMPT_PREFIX}
26 |
27 | ${criteria}
28 |
29 | ${SYSTEM_PROMPT_SUFFIX}`;
30 | }
31 |
--------------------------------------------------------------------------------
/extension/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/extension/src/popup.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import "./index.css";
4 | import Popup from "./components/pages/popup/index.tsx";
5 |
6 | ReactDOM.createRoot(document.getElementById("root")!).render(
7 |
8 |
11 |
12 | );
13 |
--------------------------------------------------------------------------------
/extension/src/service-worker.ts:
--------------------------------------------------------------------------------
1 | // NOTE: these DEFAULT_CRITERIA, SYSTEM_PROMPT_PREFIX, SYSTEM_PROMPT_SUFFIX, and constructFullPrompt
2 | // are duplicated in src/lib/constants.ts since we cannot import them from the service worker.
3 |
4 | export const DEFAULT_CRITERIA = `- The tweet is designed to provoke a negative emotional response, such as anger, fear
5 | - The tweet contains inflammatory or controversial statements
6 | - The tweet uses sensationalized language or exaggeration
7 | - The tweet appears to be intentionally divisive
8 | - The tweet makes extreme or absolute claims
9 | - The tweet uses manipulative tactics to gain engagement
10 | - The tweet is political in nature. It discusses politics, government, political issues, parties, candidates, elections, or any other political topic, be it related to any country or region.
11 | - The tweet discusses ideologies in relation of politics. Topics such as racism, communism, fascism, nationalism, immigration, anti-immigration, DEI, woke-ism, far-left, far-right, etc.
12 | - The tweet contains misleading or out-of-context information`;
13 |
14 | export const SYSTEM_PROMPT_PREFIX = `You are a tweet analyzer. Your job is to decide if the content of a tweet is met with the following criteria:`;
15 |
16 | export const SYSTEM_PROMPT_SUFFIX = `
17 | If any of the above criteria are met, the tweet should be considered bait.
18 | Respond EXCLUSIVELY using one of these formats:
19 | - "true: reason1, reason2, reason3" (if bait)
20 | - "false" (if not bait)
21 |
22 | Where reasons are 1-3 lowercase keywords from the criteria. Example responses:
23 | "true: political, divisive"
24 | "true: sensationalized, manipulative"
25 | "false"`;
26 |
27 | export function constructFullPrompt(criteria: string): string {
28 | return `${SYSTEM_PROMPT_PREFIX}
29 |
30 | ${criteria}
31 |
32 | ${SYSTEM_PROMPT_SUFFIX}`;
33 | }
34 |
35 | // Polyfill for Firefox
36 | if (typeof browser === "undefined") {
37 | (globalThis as any).browser = chrome;
38 | }
39 |
40 | async function analyzeWithGroq(
41 | text: string,
42 | tweetId: string
43 | ): Promise<{
44 | tweetId: string;
45 | isBait: boolean;
46 | reasons?: string[];
47 | error?: string;
48 | }> {
49 | try {
50 | console.log("Analyzing tweet:", { tweetId, text });
51 |
52 | // Get all settings from sync storage
53 | const { groqApiKey, promptCriteria, selectedModel, isEnabled } =
54 | await browser.storage.sync.get([
55 | "groqApiKey",
56 | "promptCriteria",
57 | "selectedModel",
58 | "isEnabled",
59 | ]);
60 |
61 | console.log("Retrieved settings:", {
62 | hasApiKey: !!groqApiKey,
63 | hasCriteria: !!promptCriteria,
64 | model: selectedModel,
65 | isEnabled,
66 | });
67 |
68 | if (!groqApiKey) {
69 | throw new Error(
70 | "Groq API key not found. Please set it in the extension settings."
71 | );
72 | }
73 |
74 | // Use the stored criteria or fall back to default
75 | const criteria = promptCriteria || DEFAULT_CRITERIA;
76 | const fullPrompt = constructFullPrompt(criteria);
77 |
78 | // Use selected model or fall back to default
79 | const model = selectedModel || "gemma2-9b-it";
80 |
81 | const response = await fetch(
82 | "https://api.groq.com/openai/v1/chat/completions",
83 | {
84 | method: "POST",
85 | headers: {
86 | Authorization: `Bearer ${groqApiKey}`,
87 | "Content-Type": "application/json",
88 | },
89 | body: JSON.stringify({
90 | model,
91 | messages: [
92 | {
93 | role: "system",
94 | content: fullPrompt,
95 | },
96 | {
97 | role: "user",
98 | content: text,
99 | },
100 | ],
101 | temperature: 0,
102 | max_tokens: 10,
103 | }),
104 | }
105 | );
106 |
107 | const data = await response.json();
108 | console.log("Groq API response:", data);
109 |
110 | if (
111 | !data ||
112 | !data.choices ||
113 | !data.choices[0] ||
114 | !data.choices[0].message
115 | ) {
116 | console.error("Invalid response from Groq:", data);
117 | return {
118 | tweetId,
119 | isBait: false,
120 | error: "Invalid API response",
121 | };
122 | }
123 |
124 | const responseContent = data.choices[0].message.content
125 | .toLowerCase()
126 | .trim();
127 | const isBait = responseContent.startsWith("true");
128 | let reasons: string[] = [];
129 |
130 | if (isBait) {
131 | const parts = responseContent.split(":");
132 | if (parts.length > 1) {
133 | reasons = parts[1]
134 | .split(",")
135 | .map((r: string) => r.trim())
136 | .filter(Boolean);
137 | }
138 | }
139 | // console.log("Analysis result:", { tweetId, isPolitical, responseContent });
140 |
141 | return {
142 | tweetId,
143 | isBait,
144 | reasons
145 | };
146 | } catch (error) {
147 | console.error("Error analyzing tweet:", error);
148 | return {
149 | tweetId,
150 | isBait: false,
151 | error: (error as Error).message || "Unknown error",
152 | };
153 | }
154 | }
155 |
156 | browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
157 | if (request.action === "newTweet") {
158 | // Check if extension is enabled from sync storage
159 | browser.storage.sync.get(["isEnabled"]).then(async (result) => {
160 | // Default to enabled if not set
161 | const isEnabled = result.isEnabled ?? true;
162 |
163 | if (!isEnabled) {
164 | return; // Don't analyze if disabled
165 | }
166 |
167 | const tweetId = request.content.id;
168 |
169 | // Continue with analysis...
170 | analyzeWithGroq(request.content.text, tweetId).then((result) => {
171 | console.log("Analysis result:", result);
172 | if (sender.tab && sender.tab.id) {
173 | browser.tabs.sendMessage(sender.tab.id, {
174 | action: "analysisResult",
175 | result: {
176 | tweetId,
177 | isBait: result.isBait,
178 | reasons: result.reasons as string[],
179 | error: null,
180 | },
181 | });
182 | }
183 | });
184 | });
185 | }
186 | // Required for Firefox to keep message port open
187 | return true;
188 | });
189 |
190 | // When the service worker starts, ensure defaults are set
191 | browser.runtime.onInstalled.addListener(async () => {
192 | const { promptCriteria, selectedModel } = await browser.storage.sync.get([
193 | "promptCriteria",
194 | "selectedModel",
195 | ]);
196 | const defaults = {
197 | ...(promptCriteria ? {} : { promptCriteria: DEFAULT_CRITERIA }),
198 | ...(selectedModel ? {} : { selectedModel: "gemma2-9b-it" }),
199 | isEnabled: true,
200 | };
201 |
202 | if (Object.keys(defaults).length > 0) {
203 | await browser.storage.sync.set(defaults);
204 | console.log("Default settings set:", defaults);
205 | }
206 | });
207 |
208 | // TypeScript declarations for Firefox WebExtension API
209 | declare namespace browser {
210 | export const runtime: typeof chrome.runtime;
211 | export const storage: typeof chrome.storage;
212 | export const tabs: typeof chrome.tabs;
213 | }
214 |
215 | /*
216 | * Browser Compatibility Notes:
217 | * - Chrome/Safari: Uses service workers via chrome.* API
218 | * - Firefox: Uses background scripts via browser.* API
219 | *
220 | * This script handles both environments by:
221 | * 1. Polyfilling the browser API for Chrome/Safari
222 | * 2. Using browser.* API consistently throughout the code
223 | * 3. Keeping message ports open for Firefox (return true in listeners)
224 | */
225 |
--------------------------------------------------------------------------------
/extension/src/settings.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import Settings from "./components/pages/settings/index.tsx"
4 | import "./index.css";
5 |
6 | ReactDOM.createRoot(document.getElementById("root")!).render(
7 |
8 |
9 |
10 |
11 |
12 | );
13 |
--------------------------------------------------------------------------------
/extension/src/utils.ts:
--------------------------------------------------------------------------------
1 | export function createElementWithClass(
2 | tag: string,
3 | className: string
4 | ): HTMLElement {
5 | const element = document.createElement(tag);
6 | element.className = className;
7 | return element;
8 | }
9 |
10 | export function createElementWithStyles(
11 | tag: string,
12 | styles: string
13 | ): HTMLElement {
14 | const element = document.createElement(tag);
15 | applyStyles(element, styles);
16 | return element;
17 | }
18 |
19 | export function applyStyles(
20 | element: HTMLElement,
21 | styles: string
22 | ): void {
23 | element.style.cssText = styles
24 | }
25 |
26 | export function setStyles(
27 | element: HTMLElement,
28 | styles: Partial
29 | ): void {
30 | Object.assign(element.style, styles);
31 | }
32 |
33 | export function appendChildren(
34 | parent: HTMLElement,
35 | children: HTMLElement[]
36 | ): void {
37 | children.forEach((child) => parent.appendChild(child));
38 | }
39 |
--------------------------------------------------------------------------------
/extension/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/extension/tailwind.config.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | './pages/**/*.{ts,tsx}',
6 | './components/**/*.{ts,tsx}',
7 | './app/**/*.{ts,tsx}',
8 | './src/**/*.{ts,tsx}',
9 | ],
10 | prefix: "",
11 | theme: {
12 | container: {
13 | center: true,
14 | padding: "2rem",
15 | screens: {
16 | "2xl": "1400px",
17 | },
18 | },
19 | extend: {
20 | colors: {
21 | border: "hsl(var(--border))",
22 | input: "hsl(var(--input))",
23 | ring: "hsl(var(--ring))",
24 | background: "hsl(var(--background))",
25 | foreground: "hsl(var(--foreground))",
26 | primary: {
27 | DEFAULT: "hsl(var(--primary))",
28 | foreground: "hsl(var(--primary-foreground))",
29 | },
30 | secondary: {
31 | DEFAULT: "hsl(var(--secondary))",
32 | foreground: "hsl(var(--secondary-foreground))",
33 | },
34 | destructive: {
35 | DEFAULT: "hsl(var(--destructive))",
36 | foreground: "hsl(var(--destructive-foreground))",
37 | },
38 | muted: {
39 | DEFAULT: "hsl(var(--muted))",
40 | foreground: "hsl(var(--muted-foreground))",
41 | },
42 | accent: {
43 | DEFAULT: "hsl(var(--accent))",
44 | foreground: "hsl(var(--accent-foreground))",
45 | },
46 | popover: {
47 | DEFAULT: "hsl(var(--popover))",
48 | foreground: "hsl(var(--popover-foreground))",
49 | },
50 | card: {
51 | DEFAULT: "hsl(var(--card))",
52 | foreground: "hsl(var(--card-foreground))",
53 | },
54 | },
55 | borderRadius: {
56 | lg: "var(--radius)",
57 | md: "calc(var(--radius) - 2px)",
58 | sm: "calc(var(--radius) - 4px)",
59 | },
60 | keyframes: {
61 | "accordion-down": {
62 | from: { height: "0" },
63 | to: { height: "var(--radix-accordion-content-height)" },
64 | },
65 | "accordion-up": {
66 | from: { height: "var(--radix-accordion-content-height)" },
67 | to: { height: "0" },
68 | },
69 | },
70 | animation: {
71 | "accordion-down": "accordion-down 0.2s ease-out",
72 | "accordion-up": "accordion-up 0.2s ease-out",
73 | },
74 | },
75 | },
76 | plugins: [require("tailwindcss-animate")],
77 | }
--------------------------------------------------------------------------------
/extension/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noFallthroughCasesInSwitch": true,
20 |
21 | /* Paths */
22 | "baseUrl": ".",
23 | "paths": {
24 | "@/*": ["./src/*"]
25 | }
26 | },
27 | "include": ["src"],
28 | "references": [{ "path": "./tsconfig.node.json" }]
29 | }
30 |
--------------------------------------------------------------------------------
/extension/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"],
10 | "exclude": ["node_modules", "dist"]
11 | }
12 |
--------------------------------------------------------------------------------
/extension/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import path from "path"
4 | import { viteStaticCopy } from "vite-plugin-static-copy";
5 |
6 | export default defineConfig({
7 | plugins: [
8 | react(),
9 | viteStaticCopy({
10 | targets: [
11 | { src: "src/index.css", dest: "." },
12 | ],
13 | }),
14 | ],
15 | build: {
16 | rollupOptions: {
17 | input: {
18 | main: "index.html",
19 | settings: "settings.html",
20 | contentScript: "src/content-script.ts",
21 | serviceWorker: "src/service-worker.ts",
22 | },
23 | output: {
24 | entryFileNames: (chunkInfo) => {
25 | return chunkInfo.name === "contentScript" ||
26 | chunkInfo.name === "serviceWorker"
27 | ? "[name].js"
28 | : "assets/[name]-[hash].js";
29 | },
30 | },
31 | },
32 | },
33 |
34 | resolve: {
35 | alias: {
36 | "@": path.resolve(__dirname, "./src"),
37 | },
38 | },
39 | });
40 |
--------------------------------------------------------------------------------
/extension/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: build
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: Checkout Repository
14 | uses: actions/checkout@v2
15 |
16 | - name: Setup Node.js
17 | uses: actions/setup-node@v4
18 | with:
19 | node-version: "18"
20 |
21 | - name: Install Dependencies
22 | run: npm install
23 |
24 | - name: Build Extension
25 | run: npm run build
26 |
--------------------------------------------------------------------------------
/landing/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # env files (can opt-in for committing if needed)
34 | .env*
35 |
36 | # vercel
37 | .vercel
38 |
39 | # typescript
40 | *.tsbuildinfo
41 | next-env.d.ts
42 |
--------------------------------------------------------------------------------
/landing/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { dirname } from "path";
2 | import { fileURLToPath } from "url";
3 | import { FlatCompat } from "@eslint/eslintrc";
4 |
5 | const __filename = fileURLToPath(import.meta.url);
6 | const __dirname = dirname(__filename);
7 |
8 | const compat = new FlatCompat({
9 | baseDirectory: __dirname,
10 | });
11 |
12 | const eslintConfig = [
13 | ...compat.extends("next/core-web-vitals", "next/typescript"),
14 | ];
15 |
16 | export default eslintConfig;
17 |
--------------------------------------------------------------------------------
/landing/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next";
2 |
3 | const nextConfig: NextConfig = {
4 | /* config options here */
5 | };
6 |
7 | export default nextConfig;
8 |
--------------------------------------------------------------------------------
/landing/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "unbaited-landing",
3 | "version": "0.0.5",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev --turbopack",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "marked": "^15.0.6",
13 | "next": "15.1.3",
14 | "react": "^19.0.0",
15 | "react-dom": "^19.0.0"
16 | },
17 | "devDependencies": {
18 | "@eslint/eslintrc": "^3",
19 | "@types/node": "^20",
20 | "@types/react": "^19",
21 | "@types/react-dom": "^19",
22 | "eslint": "^9",
23 | "eslint-config-next": "15.1.3",
24 | "postcss": "^8",
25 | "tailwindcss": "^3.4.1",
26 | "typescript": "^5"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/landing/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/landing/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danielpetho/unbaited/07438d74a4947959c85f04d8d1b75f4e4751084f/landing/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/landing/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danielpetho/unbaited/07438d74a4947959c85f04d8d1b75f4e4751084f/landing/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/landing/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danielpetho/unbaited/07438d74a4947959c85f04d8d1b75f4e4751084f/landing/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/landing/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danielpetho/unbaited/07438d74a4947959c85f04d8d1b75f4e4751084f/landing/public/favicon-16x16.png
--------------------------------------------------------------------------------
/landing/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danielpetho/unbaited/07438d74a4947959c85f04d8d1b75f4e4751084f/landing/public/favicon-32x32.png
--------------------------------------------------------------------------------
/landing/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danielpetho/unbaited/07438d74a4947959c85f04d8d1b75f4e4751084f/landing/public/favicon.ico
--------------------------------------------------------------------------------
/landing/public/logo128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/danielpetho/unbaited/07438d74a4947959c85f04d8d1b75f4e4751084f/landing/public/logo128.png
--------------------------------------------------------------------------------
/landing/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
--------------------------------------------------------------------------------
/landing/src/app/changelog/changelog.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 7 May 2025
4 | - Updated model list
5 |
6 | ## 29 January 2025
7 |
8 | ### Firefox listing
9 | - Extension is now listed on Firefox Add-ons
10 |
11 | ### Design updates
12 | - Show button and reasons are properly aligned to center
13 |
14 | ## 24 January 2025
15 |
16 | ### Firefox support
17 | - Added Firefox support (only loading as a temporary add-on as of now)
18 |
19 | ### Reasons
20 | - LLM returns reason keywords why it classified a tweet as bait
21 |
22 | ## 12 January 2025
23 |
24 | ### System Prompts & Model Updates
25 | - Refined system prompts for more accurate tweet classification
26 | - Improved prompt editing interface with better validation
27 | - Reduced likelihood of unprocessable model responses
28 | - Added model selection with support for multiple LLMs
29 | - Switched to more cost-effective default model (Gemma-2-9B)
30 |
31 | ### Display Options
32 | - New display mode options: blur or hide detected tweets
33 | - Blur mode shows tweets with a blur effect and reveal button
34 | - Hide mode completely removes detected tweets from view
--------------------------------------------------------------------------------
/landing/src/app/changelog/page.tsx:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import path from 'path'
3 | import { marked } from 'marked'
4 |
5 | export default async function Changelog() {
6 | const markdownContent = fs.readFileSync(
7 | path.join(process.cwd(), 'src/app/changelog/changelog.md'),
8 | 'utf-8'
9 | )
10 | const content = marked(markdownContent)
11 |
12 | return (
13 |
14 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/landing/src/app/config/site.ts:
--------------------------------------------------------------------------------
1 | export const siteConfig = {
2 | name: "unbaited - control your x feed with llms",
3 | url: "https://unbaited.danielpetho.com",
4 | ogImage: "https://danielpetho.com/og.jpg",
5 | description:
6 | "a browser extension that helps you filter out engagement bait from x/twitter using ai",
7 | links: {
8 | twitter: "https://twitter.com/nonzeroexitcode",
9 | github: "https://github.com/danielpetho/unbaited",
10 | },
11 | };
12 |
13 | export type SiteConfig = typeof siteConfig;
14 |
--------------------------------------------------------------------------------
/landing/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --background: #ffffff;
7 | --foreground: #171717;
8 | }
9 |
10 | @media (prefers-color-scheme: dark) {
11 | :root {
12 | --background: #0a0a0a;
13 | --foreground: #ededed;
14 | }
15 | }
16 |
17 | body {
18 | color: var(--foreground);
19 | background: var(--background);
20 | font-family: Arial, Helvetica, sans-serif;
21 | }
22 |
--------------------------------------------------------------------------------
/landing/src/app/header.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 |
3 | export function Header() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 | about
11 |
12 |
13 |
14 |
15 | changelog
16 |
17 |
18 |
19 |
20 | privacy
21 |
22 |
23 |
24 |
25 | github
26 |
27 |
28 |
29 |
30 |
31 | );
32 | }
--------------------------------------------------------------------------------
/landing/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Header } from "@/app/header";
2 | import type { Metadata } from "next";
3 | import { Geist_Mono } from "next/font/google";
4 | import { siteConfig } from "@/app/config/site";
5 | import "./globals.css";
6 |
7 | const geistMono = Geist_Mono({
8 | subsets: ["latin"],
9 | });
10 |
11 | export const metadata: Metadata = {
12 | title: {
13 | default: siteConfig.name,
14 | template: `%s - ${siteConfig.name}`,
15 | },
16 | metadataBase: new URL(siteConfig.url),
17 | description: siteConfig.description,
18 | authors: [
19 | {
20 | name: "Daniel Petho",
21 | url: "https://danielpetho.com",
22 | },
23 | ],
24 | creator: "danielpetho",
25 | openGraph: {
26 | type: "website",
27 | locale: "en_US",
28 | url: siteConfig.url,
29 | title: siteConfig.name,
30 | description: siteConfig.description,
31 | siteName: siteConfig.name,
32 | images: [
33 | {
34 | url: siteConfig.ogImage,
35 | width: 1200,
36 | height: 630,
37 | alt: siteConfig.name,
38 | },
39 | ],
40 | },
41 | twitter: {
42 | card: "summary_large_image",
43 | title: siteConfig.name,
44 | description: siteConfig.description,
45 | images: [siteConfig.ogImage],
46 | creator: "@nonzeroexitcode",
47 | },
48 | icons: {
49 | icon: "/favicon.ico",
50 | shortcut: "/favicon-16x16.png",
51 | apple: "/apple-touch-icon.png",
52 | },
53 | manifest: `${siteConfig.url}/site.webmanifest`,
54 | };
55 |
56 | export default function RootLayout({
57 | children,
58 | }: Readonly<{
59 | children: React.ReactNode;
60 | }>) {
61 | return (
62 |
63 |
64 |
65 |
66 |
67 |
68 | {children}
69 |
70 |
71 | );
72 | }
--------------------------------------------------------------------------------
/landing/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Image from "next/image";
4 | import Link from "next/link";
5 | import TweetEmbed from "@/components/tweet-embed";
6 | import useDetectBrowser from "@/hooks/use-detect-browser";
7 |
8 | const CHROME_STORE_URL = "https://chromewebstore.google.com/detail/unbaited-prototype/bpbnggihcaknipcgbpgjgfhgmdgcokcg";
9 | const FIREFOX_STORE_URL = "https://addons.mozilla.org/en-US/firefox/addon/unbaited-prototype";
10 |
11 | export default function Home() {
12 | const browserName = useDetectBrowser()
13 | const isFirefox = browserName === 'Firefox'
14 |
15 | return (
16 |
17 |
18 |
19 |
26 |
unbaited
27 |
28 |
29 | Control your feed with LLMs on X
30 |
31 |
32 |
33 |
43 |
44 |
45 |
46 | What is this?
47 |
48 | Unbaited is a browser extension that helps you filter out engagement
49 | bait and inflammatory content from your X (formerly Twitter) feed.
50 | It uses AI to analyze tweets in real-time and hides content designed
51 | to provoke emotional responses or increase engagement through
52 | controversial topics.
53 |
54 |
55 |
56 |
57 | How it works
58 |
59 |
60 |
61 |
62 |
63 | The extension uses Groq's ultra-fast API to analyze tweets using
64 | an llm of your choice. When you scroll through X, it:
65 |
66 |
67 | Detects new tweets as they appear in your viewport
68 | Sends the tweet content to Groq's API for analysis
69 | Blurs tweets that are identified as engagement bait
70 |
71 | Gives you the option to reveal hidden tweets with a single click
72 |
73 |
74 |
75 |
76 |
77 |
78 | Important Notes
79 |
80 |
81 | This is a prototype and thought-provoker. The goal is to
82 | demonstrate how social media platforms could integrate more user
83 | controls natively, giving people more agency over their feed
84 | content.
85 |
86 |
To use the extension, you'll need:
87 |
100 |
101 | You can customize the system prompts to adjust how tweets are
102 | analyzed, making the extension work according to your preferences.
103 |
104 |
105 |
106 |
107 |
108 | Read the{" "}
109 |
110 | privacy policy
111 | {" "}
112 | to learn how your data is handled.
113 |
114 |
115 |
116 | );
117 | }
118 |
--------------------------------------------------------------------------------
/landing/src/app/privacy/page.tsx:
--------------------------------------------------------------------------------
1 | export default function Privacy() {
2 | return (
3 |
4 | Privacy Policy
5 |
6 |
7 |
8 | Data Collection
9 |
10 | Unbaited does not collect or store any personal data. All tweet analysis happens in real-time, and no content is saved on our servers.
11 |
12 |
13 |
14 |
15 | API Usage
16 |
17 | When you use Unbaited:
18 |
19 |
20 | Tweet content is sent to Groq's API for analysis
21 | Your API key is stored locally in your browser
22 | No data is retained after analysis
23 |
24 |
25 |
26 |
27 | Third-Party Services
28 |
29 | We use Groq's API for tweet analysis. Please refer to Groq's privacy policy for information about how they handle data.
30 |
31 |
32 |
33 |
34 | Contact
35 |
36 | For privacy concerns or questions, please reach out to the GitHub repository .
37 |
38 |
39 |
40 |
41 | );
42 | }
--------------------------------------------------------------------------------
/landing/src/components/tweet-embed.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | export default function TweetEmbed() {
4 | return (
5 |
6 |
7 | i'm tired of engagement bait & politics on my x feed.
8 |
9 |
10 | so i prototyped a chrome extension which uses llama 3.3 to process and
11 | filter out "negative" content.
12 |
13 |
14 | link, repo & some info in 🧵{" "}
15 | pic.twitter.com/Bdo9jPlTQ4
16 |
17 | — daniel petho (@nonzeroexitcode){" "}
18 |
19 | January 6, 2025
20 |
21 |
22 | );
23 | }
--------------------------------------------------------------------------------
/landing/src/hooks/use-detect-browser.ts:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default function useDetectBrowser() {
4 | const [browserName, setBrowserName] = React.useState("");
5 |
6 | React.useEffect(() => {
7 | if (typeof window === 'undefined') return;
8 |
9 | const sUsrAg = navigator.userAgent;
10 | let sBrowser;
11 |
12 | if (sUsrAg.indexOf("Firefox") > -1) {
13 | sBrowser = "Firefox";
14 | // "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0"
15 | } else if (sUsrAg.indexOf("SamsungBrowser") > -1) {
16 | sBrowser = "Samsung Internet";
17 | // "Mozilla/5.0 (Linux; Android 9; SAMSUNG SM-G955F Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/9.4 Chrome/67.0.3396.87 Mobile Safari/537.36
18 | } else if (sUsrAg.indexOf("Opera") > -1 || sUsrAg.indexOf("OPR") > -1) {
19 | sBrowser = "Opera";
20 | // "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 OPR/57.0.3098.106"
21 | } else if (sUsrAg.indexOf("Trident") > -1) {
22 | sBrowser = "IE";
23 | // "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; Zoom 3.6.0; wbx 1.0.0; rv:11.0) like Gecko"
24 | } else if (sUsrAg.indexOf("Edge") > -1) {
25 | sBrowser = "Edge";
26 | // "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 Edge/16.16299"
27 | } else if (sUsrAg.indexOf("Chrome") > -1) {
28 | sBrowser = "Chrome";
29 | // "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/66.0.3359.181 Chrome/66.0.3359.181 Safari/537.36"
30 | } else if (sUsrAg.indexOf("Safari") > -1) {
31 | sBrowser = "Safari";
32 | // "Mozilla/5.0 (iPhone; CPU iPhone OS 11_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.0 Mobile/15E148 Safari/604.1 980x1306"
33 | } else {
34 | sBrowser = "unknown";
35 | }
36 | setBrowserName(sBrowser);
37 | }, []);
38 |
39 | return browserName;
40 | }
--------------------------------------------------------------------------------
/landing/src/types/mdx.d.ts:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/landing/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | export default {
4 | content: [
5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | extend: {
11 | colors: {
12 | background: "var(--background)",
13 | foreground: "var(--foreground)",
14 | },
15 | },
16 | },
17 | plugins: [],
18 | } satisfies Config;
19 |
--------------------------------------------------------------------------------
/landing/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------