Compare commits

..

7 Commits
8.0.5 ... main

130 changed files with 16341 additions and 8863 deletions

4
.gitignore vendored
View File

@ -1,9 +1,7 @@
_metadata/
!.yarn/plugins
!.yarn/releases
.DS_Store
.env
.plasmo/
.pnp.*
.yarn/*
build/
node_modules/

View File

@ -1,4 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn commitlint --edit "$1"
pnpm commitlint --edit "$1"

View File

@ -1,4 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn lint-staged
pnpm lint-staged

View File

@ -1,3 +1,3 @@
{
"packages/**/*.{js,ts}": ["prettier --loglevel=silent --write", "bash -c 'yarn lint'"]
"packages/**/*.{js,ts}": ["prettier --loglevel=silent --write", "bash -c 'pnpm lint'"]
}

View File

@ -1,2 +0,0 @@
package.json
.yarnrc.yml

View File

@ -1,4 +1,12 @@
{
"overrides": [
{
"files": ["*.jsonc", ".eslintrc", "tsconfig*.json"],
"options": {
"trailingComma": "none"
}
}
],
"printWidth": 100,
"semi": true,
"singleQuote": true,

View File

@ -1,4 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"files.associations": {
".commitlintrc": "json",
".lintstagedrc": "json"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,9 +0,0 @@
enableGlobalCache: true
nodeLinker: node-modules
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-outdated.cjs
spec: "https://mskelton.dev/yarn-outdated/v2"
yarnPath: .yarn/releases/yarn-4.2.1.cjs

View File

@ -1,25 +1,7 @@
> AN IMPORTANT UPDATE
> <br><br>
> On the morning of October 11, 2024, GitHub unexpectedly hid our repository without any prior notification. This sudden action immediately disrupted all of our API services, as they relied on files from the repository that were no longer accessible. Our team reached out to GitHub support to understand the situation, but we have not yet received any response. Given the critical impact on our services and the lack of communication from GitHub, we decided to migrate the entire repository to an alternative platform to ensure continuity and reliability.
> <br><br>
> We initially migrated the repository, releases, and open issues (excluding discussions) to GitLab. However, during the migration of issues, GitLab's own spam detection mechanism mistakenly identified its bot activity as spam, leading to our issues being hidden. Despite reaching out to GitLab support, we have not yet received a resolution for this incident. Faced with these ongoing platform limitations and communication delays, we have opted to move forward with self-hosting our own Git system using Gitea to ensure full control over our repository and services.
> <br><br>
> We will no longer support platforms that overlook the contributions of our users and the significant work invested over the last five years. Once our GitHub account is reinstated, we will set up a redirect to guide users to our new, self-hosted repository.
> <br><br>
> Thank you to our community for your patience. Your contributions remain vital to this project, and we are committed to ensuring its stability and growth in a more secure environment.
# Cookie Dialog Monster
Cookie Dialog Monster is a browser extension that hides cookie consent dialogs without changing user preferences. By default, we do NOT accept cookies (except in [a few cases](https://git.wanhose.dev/wanhose/cookie-dialog-monster/src/branch/main/database.json) where the pages do not function without accepting them). You can report broken sites with a single click, which will create an issue in this repository to be fixed promptly.
## Contributing
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
## License
[MIT](https://choosealicense.com/licenses/mit/)
## Repositories
- [API](/wanhose/cookie-dialog-monster/src/branch/main/packages/api)
@ -34,3 +16,11 @@ Pull requests are welcome. For major changes, please open an issue first to disc
- [Pull requests](https://git.wanhose.dev/wanhose/cookie-dialog-monster/pulls)
- [Releases](https://git.wanhose.dev/wanhose/cookie-dialog-monster/releases)
- [Wiki](https://git.wanhose.dev/wanhose/cookie-dialog-monster/wiki/Help-or-issues%3F)
## Contributing
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
## License
[MIT](https://choosealicense.com/licenses/mit/)

View File

@ -3,8 +3,9 @@
"private": true,
"version": "1.0.0",
"scripts": {
"build": "yarn workspaces foreach --all -p run build",
"lint": "yarn workspaces foreach --all -p run lint",
"build": "pnpm -r run build",
"lint": "pnpm -r run lint",
"preinstall": "npx only-allow pnpm",
"prepare": "husky"
},
"devDependencies": {
@ -14,12 +15,8 @@
"lint-staged": "^15.2.2",
"prettier": "^3.2.5"
},
"workspaces": [
"packages/*"
],
"engines": {
"node": "20.x"
},
"packageManager": "yarn@4.2.1",
"license": "MIT"
}

View File

@ -2,26 +2,26 @@
## Installation
Make sure you have [Node.js](https://nodejs.org/) (version 20.x) and [Yarn](https://yarnpkg.com/) installed.
Make sure you have [Node.js](https://nodejs.org/) (version 20.x) and [pnpm](https://pnpm.io/) installed.
```bash
yarn install
pnpm i
```
## Scripts
### `yarn build`
### `pnpm build`
Removes the build directory and compiles TypeScript files.
### `yarn dev`
### `pnpm dev`
Starts the server in development mode with nodemon.
### `yarn lint`
### `pnpm lint`
Lints the codebase using ESLint.
### `yarn start`
### `pnpm start`
Starts the API server instance in production mode.

View File

@ -33,6 +33,5 @@
},
"engines": {
"node": "20.x"
},
"packageManager": "yarn@4.2.1"
}
}

View File

@ -17,6 +17,7 @@ import v6IssuesRoutes from 'routes/v6/issues';
import v6ReportRoutes from 'routes/v6/report';
import v6VersionRoutes from 'routes/v6/version';
import environment from 'services/environment';
import { keyGenerator } from 'services/rateLimit';
const server = fastify({ logger: true });
@ -30,6 +31,7 @@ server.register(cors, {
server.register(rateLimit, {
global: false,
keyGenerator,
});
server.register(v1EntriesRoutes, { prefix: '/rest/v1' });

View File

@ -1,5 +1,5 @@
import { FastifyInstance, RouteShorthandOptions } from 'fastify';
import { formatMessage } from 'services/format';
import { formatMessage, formatDomainFromURL } from 'services/format';
import { createIssue, createIssueComment, getIssue, updateIssue } from 'services/git';
import { RATE_LIMIT_1_PER_MIN } from 'services/rateLimit';
import { validatorCompiler } from 'services/validation';
@ -34,8 +34,8 @@ export default (server: FastifyInstance, _options: RouteShorthandOptions, done:
async (request, reply) => {
try {
const { reason, url, userAgent, version } = request.body;
const hostname = new URL(url).hostname.split('.').slice(-3).join('.').replace('www.', '');
const issue = await getIssue({ title: hostname });
const domain = formatDomainFromURL(new URL(url));
const issue = await getIssue({ title: domain });
const ua = new UAParser(userAgent ?? '').getResult();
if (issue) {
@ -61,7 +61,7 @@ export default (server: FastifyInstance, _options: RouteShorthandOptions, done:
await createIssue({
description: formatMessage({ reason, ua, url, version }),
labels: ['bug'],
title: hostname,
title: domain,
});
reply.send({

View File

@ -1,5 +1,5 @@
import { FastifyInstance, RouteShorthandOptions } from 'fastify';
import { formatMessage } from 'services/format';
import { formatMessage, formatDomainFromURL } from 'services/format';
import { createIssue, createIssueComment, getIssue, updateIssue } from 'services/git';
import { RATE_LIMIT_1_PER_MIN } from 'services/rateLimit';
import { validatorCompiler } from 'services/validation';
@ -34,8 +34,8 @@ export default (server: FastifyInstance, _options: RouteShorthandOptions, done:
async (request, reply) => {
try {
const { reason, url, userAgent, version } = request.body;
const hostname = new URL(url).hostname.split('.').slice(-3).join('.').replace('www.', '');
const issue = await getIssue({ title: hostname });
const domain = formatDomainFromURL(new URL(url));
const issue = await getIssue({ title: domain });
const ua = new UAParser(userAgent ?? '').getResult();
if (issue) {
@ -62,7 +62,7 @@ export default (server: FastifyInstance, _options: RouteShorthandOptions, done:
const newIssue = await createIssue({
description: formatMessage({ reason, ua, url, version }),
labels: ['bug'],
title: hostname,
title: domain,
});
reply.send({

View File

@ -5,14 +5,14 @@ import { validatorCompiler } from 'services/validation';
import * as yup from 'yup';
const GetIssuesParamsSchema = yup.object().shape({
hostname: yup.string().required(),
domain: yup.string().required(),
});
type GetIssuesParams = yup.InferType<typeof GetIssuesParamsSchema>;
export default (server: FastifyInstance, _options: RouteShorthandOptions, done: () => void) => {
server.get<{ Params: GetIssuesParams }>(
'/issues/:hostname/',
'/issues/:domain/',
{
config: {
rateLimit: RATE_LIMIT_10_PER_MIN,
@ -24,8 +24,8 @@ export default (server: FastifyInstance, _options: RouteShorthandOptions, done:
},
async (request, reply) => {
try {
const { hostname } = request.params;
const issue = await getIssue({ title: hostname });
const { domain } = request.params;
const issue = await getIssue({ title: domain });
if (
issue &&

View File

@ -1,5 +1,5 @@
import { FastifyInstance, RouteShorthandOptions } from 'fastify';
import { formatMessage } from 'services/format';
import { formatMessage, formatDomainFromURL } from 'services/format';
import { createIssue, getIssue, updateIssue } from 'services/git';
import { RATE_LIMIT_1_PER_MIN } from 'services/rateLimit';
import { validatorCompiler } from 'services/validation';
@ -34,8 +34,8 @@ export default (server: FastifyInstance, _options: RouteShorthandOptions, done:
async (request, reply) => {
try {
const { reason, url, userAgent, version } = request.body;
const hostname = new URL(url).hostname.split('.').slice(-3).join('.').replace('www.', '');
const issue = await getIssue({ title: hostname });
const domain = formatDomainFromURL(new URL(url));
const issue = await getIssue({ title: domain });
const ua = new UAParser(userAgent ?? '').getResult();
if (issue) {
@ -75,7 +75,7 @@ export default (server: FastifyInstance, _options: RouteShorthandOptions, done:
const newIssue = await createIssue({
description: formatMessage({ reason, ua, url, version }),
labels: ['bug'],
title: hostname,
title: domain,
});
reply.send({

View File

@ -20,6 +20,16 @@ export function formatMessage(params: FormatMessageParams): string {
].join('\n');
}
export function formatDomainFromURL(value: URL): string {
let result: string = value.hostname;
if (result.startsWith('www.')) {
result = result.replace('www.', '');
}
return result;
}
export interface FormatMessageParams {
readonly reason?: string;
readonly ua: UAParserResult;

View File

@ -1,3 +1,5 @@
import type { FastifyRequest } from 'fastify';
export const RATE_LIMIT_1_PER_HOUR = {
max: 1,
timeWindow: '1 hour',
@ -17,3 +19,9 @@ export const RATE_LIMIT_3_PER_MIN = {
max: 3,
timeWindow: '1 minute',
};
export function keyGenerator(req: FastifyRequest): string {
const userIdentifier = req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.ip;
return `${userIdentifier}:${req.routerPath}`;
}

View File

@ -1,9 +1,41 @@
{
"env": {
"browser": true,
"node": true,
"es6": true
},
"extends": ["plugin:prettier/recommended"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:jest-dom/recommended",
"plugin:jsx-a11y/recommended",
"plugin:prettier/recommended",
"plugin:react/jsx-runtime",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:testing-library/dom"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest"
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["import", "simple-import-sort"],
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"import/first": "error",
"import/newline-after-import": "error",
"import/no-duplicates": "error",
"react/react-in-jsx-scope": "off",
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error"
},
"settings": {
"react": {
"version": "detect"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,25 @@
import { createRequire } from 'module';
import { pathsToModuleNameMapper } from 'ts-jest';
const require = createRequire(import.meta.url);
const tsconfig = require('./tsconfig.json');
/**
* @type {import('@jest/types').Config.InitialOptions}
*/
const config = {
extensionsToTreatAsEsm: ['.ts', '.tsx'],
moduleNameMapper: {
...pathsToModuleNameMapper(tsconfig.compilerOptions.paths, { prefix: '<rootDir>/' }),
'^url:~assets/(.+).png$': '<rootDir>/mocks/assets/$1.mock.ts',
},
preset: 'ts-jest/presets/default-esm',
setupFiles: ['./jest.setup.ts'],
testEnvironment: 'jsdom',
testRegex: ['^.+\\.test.tsx?$'],
transform: {
'^.+.tsx?$': ['ts-jest', { isolatedModules: true, useESM: true }],
},
};
export default config;

View File

@ -0,0 +1,5 @@
import 'jest-webextension-mock';
import './mocks/chrome.mock';
import './mocks/plasmo.mock';
import './mocks/utils/domain.mock';
import './mocks/utils/storage.mock';

View File

@ -20,6 +20,9 @@
"options_clearButton": {
"message": "مسح القائمة"
},
"options_empty": {
"message": "لم يتم العثور على استثناءات"
},
"options_exclusionListTitle": {
"message": "قائمة الاستبعاد"
},

View File

@ -20,6 +20,9 @@
"options_clearButton": {
"message": "Liste leeren"
},
"options_empty": {
"message": "Keine Ausschlüsse gefunden"
},
"options_exclusionListTitle": {
"message": "Ausschlussliste"
},

View File

@ -20,6 +20,9 @@
"options_clearButton": {
"message": "Clear list"
},
"options_empty": {
"message": "No exclusions found"
},
"options_exclusionListTitle": {
"message": "Exclusion list"
},

View File

@ -20,6 +20,9 @@
"options_clearButton": {
"message": "Borrar lista"
},
"options_empty": {
"message": "No se encontraron exclusiones"
},
"options_exclusionListTitle": {
"message": "Lista de exclusión"
},

View File

@ -20,6 +20,9 @@
"options_clearButton": {
"message": "Vider la liste"
},
"options_empty": {
"message": "Aucune exclusion trouvée"
},
"options_exclusionListTitle": {
"message": "Liste d'exclusion"
},

View File

@ -20,6 +20,9 @@
"options_clearButton": {
"message": "सूची साफ़ करें"
},
"options_empty": {
"message": "कोई बहिष्कार नहीं मिला।"
},
"options_exclusionListTitle": {
"message": "बहिष्करण सूची"
},

View File

@ -20,6 +20,9 @@
"options_clearButton": {
"message": "Bersihkan daftar"
},
"options_empty": {
"message": "Tidak ada pengecualian ditemukan"
},
"options_exclusionListTitle": {
"message": "Daftar pengecualian"
},

View File

@ -20,6 +20,9 @@
"options_clearButton": {
"message": "Cancella lista"
},
"options_empty": {
"message": "Nessuna esclusione trovata"
},
"options_exclusionListTitle": {
"message": "Lista di esclusione"
},

View File

@ -20,6 +20,9 @@
"options_clearButton": {
"message": "リストをクリア"
},
"options_empty": {
"message": "除外項目が見つかりませんでした"
},
"options_exclusionListTitle": {
"message": "除外リスト"
},

View File

@ -20,6 +20,9 @@
"options_clearButton": {
"message": "목록 지우기"
},
"options_empty": {
"message": "제외 항목을 찾을 수 없습니다"
},
"options_exclusionListTitle": {
"message": "제외 목록"
},

View File

@ -20,6 +20,9 @@
"options_clearButton": {
"message": "Wyczyść listę"
},
"options_empty": {
"message": "Nie znaleziono wykluczeń"
},
"options_exclusionListTitle": {
"message": "Lista wykluczeń"
},

View File

@ -20,6 +20,9 @@
"options_clearButton": {
"message": "Limpar lista"
},
"options_empty": {
"message": "Nenhuma exclusão encontrada"
},
"options_exclusionListTitle": {
"message": "Lista de exclusão"
},

View File

@ -20,6 +20,9 @@
"options_clearButton": {
"message": "Limpar lista"
},
"options_empty": {
"message": "Nenhuma exclusão encontrada"
},
"options_exclusionListTitle": {
"message": "Lista de exclusão"
},

View File

@ -20,6 +20,9 @@
"options_clearButton": {
"message": "Golește lista"
},
"options_empty": {
"message": "Nu au fost găsite excluderi"
},
"options_exclusionListTitle": {
"message": "Lista de excludere"
},

View File

@ -20,6 +20,9 @@
"options_clearButton": {
"message": "Очистить список"
},
"options_empty": {
"message": "Исключения не найдены"
},
"options_exclusionListTitle": {
"message": "Список исключений"
},

View File

@ -20,6 +20,9 @@
"options_clearButton": {
"message": "Listeyi temizle"
},
"options_empty": {
"message": "Hariç tutma bulunamadı"
},
"options_exclusionListTitle": {
"message": "Hariç tutma listesi"
},

View File

@ -0,0 +1 @@
export default 'off-icon';

View File

@ -0,0 +1 @@
export default 'on-icon';

View File

@ -0,0 +1 @@
export default 'warn-icon';

View File

@ -0,0 +1,56 @@
import { jest } from '@jest/globals';
chrome.action = {
...chrome.action,
openPopup: jest.fn(),
setBadgeBackgroundColor: jest.fn(),
setBadgeText: jest.fn(),
setIcon: jest.fn(),
} as typeof chrome.action;
chrome.contextMenus = {
...chrome.contextMenus,
create: jest.fn(),
onClicked: {
...(chrome.contextMenus ?? {}).onClicked,
addListener: jest.fn(),
},
removeAll: jest.fn(),
} as typeof chrome.contextMenus;
chrome.declarativeNetRequest = {
...chrome.declarativeNetRequest,
updateSessionRules: jest.fn(),
} as typeof chrome.declarativeNetRequest;
chrome.runtime = {
...chrome.runtime,
getManifest: jest.fn(
() =>
({
content_scripts: [{ matches: ['https://example.com/*'] }],
version: '1.0.0',
}) as chrome.runtime.ManifestV3
),
onInstalled: {
...(chrome.runtime ?? {}).onInstalled,
addListener: jest.fn(),
},
onStartup: {
...(chrome.runtime ?? {}).onStartup,
addListener: jest.fn(),
},
openOptionsPage: jest.fn(),
} as typeof chrome.runtime;
chrome.webRequest = {
...chrome.webRequest,
onBeforeRequest: {
...(chrome.webRequest ?? {}).onBeforeRequest,
addListener: jest.fn(),
},
onErrorOccurred: {
...(chrome.webRequest ?? {}).onErrorOccurred,
addListener: jest.fn(),
},
} as typeof chrome.webRequest;

View File

@ -0,0 +1,5 @@
import { jest } from '@jest/globals';
jest.mock('@plasmohq/messaging', () => ({
sendToContentScript: jest.fn(),
}));

View File

@ -0,0 +1,6 @@
import { jest } from '@jest/globals';
jest.mock('~utils/domain', () => ({
formatDomainFromURL: jest.fn(() => 'example.com'),
validateSupport: jest.fn(),
}));

View File

@ -0,0 +1,9 @@
import { jest } from '@jest/globals';
jest.mock('~utils/storage', () => ({
storage: {
get: async (key: string) => (await chrome.storage.local.get(key))?.[key],
remove: (key: string) => chrome.storage.local.remove(key),
set: (key: string, value: any) => chrome.storage.local.set({ [key]: value }),
},
}));

View File

@ -2,18 +2,84 @@
"name": "browser-extension",
"version": "1.0.0",
"scripts": {
"build": "rimraf build; sh scripts/build.sh; sh scripts/pack.sh",
"lint": "eslint --fix"
"dev": "plasmo dev",
"build": "plasmo build",
"lint": "eslint --fix",
"package": "plasmo package",
"test": "jest"
},
"dependencies": {
"@plasmohq/messaging": "^0.6.2",
"@plasmohq/storage": "^1.13.0",
"plasmo": "^0.89.4",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/firefox-webext-browser": "^120.0.3",
"eslint": "^8.57.0",
"@jest/globals": "29.7.0",
"@jest/types": "29.6.3",
"@testing-library/react": "^16.0.1",
"@types/chrome": "^0.0.283",
"@types/node": "^20.11.5",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^8.14.0",
"@typescript-eslint/parser": "^8.14.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"rimraf": "^5.0.5"
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jest-dom": "^5.4.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-testing-library": "^6.4.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-webextension-mock": "^3.9.0",
"postcss": "^8.4.49",
"postcss-modules": "^6.0.1",
"prettier": "^3.3.3",
"ts-jest": "^29.2.5",
"typescript": "^5.6.3"
},
"engines": {
"node": "20.x"
},
"packageManager": "yarn@4.2.1"
"manifest": {
"author": "wanhose",
"browser_specific_settings": {
"gecko": {
"id": "{77e2c00b-e173-4604-863d-01645d8d2826}",
"strict_min_version": "126.0",
"update_url": "https://www.cookie-dialog-monster.com/releases/mozilla/updates.json"
}
},
"default_locale": "en",
"description": "__MSG_appDesc__",
"host_permissions": [
"http://*/*",
"https://*/*"
],
"name": "Cookie Dialog Monster",
"permissions": [
"contextMenus",
"declarativeNetRequest",
"storage",
"webRequest"
],
"version": "8.0.5",
"web_accessible_resources": [
{
"matches": [
"http://*/*",
"https://*/*"
],
"resources": [
"https://fonts.googleapis.com/css?family=Inter"
]
}
]
}
}

View File

@ -1,16 +0,0 @@
#!/bin/bash
input="./src"
output="./build"
mkdir -p "$output"
for file in $(find "$input" -name "*.css" -o -name "*.html" -o -name "*.js" | sed "s|^$input/||"); do
input_file="$input/$file"
output_file="$output/$file"
mkdir -p "${output_file%/*}"
cp "$input_file" "$output_file"
done
cp -nR "$input/." "$output"

View File

@ -1,7 +0,0 @@
#!/usr/bin/env bash
path=$(pwd)
version=$(jq -r '.version' "$path/build/manifest.json")
cd "$path/build" || exit
zip -r "$path/$version.zip" . -x */\.* *.git* \.* *.md *.sh *.zip

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 641 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,11 @@
import onClicked from './utils/contextMenus/onClicked';
import onInstalled from './utils/runtime/onInstalled';
import onStartup from './utils/runtime/onStartup';
import onBeforeRequest from './utils/webRequest/onBeforeRequest';
import onErrorOccurred from './utils/webRequest/onErrorOccurred';
chrome.contextMenus.onClicked.addListener(onClicked);
chrome.runtime.onInstalled.addListener(onInstalled);
chrome.runtime.onStartup.addListener(onStartup);
chrome.webRequest.onBeforeRequest.addListener(onBeforeRequest as any, { urls: ['<all_urls>'] });
chrome.webRequest.onErrorOccurred.addListener(onErrorOccurred, { urls: ['<all_urls>'] });

View File

@ -0,0 +1,19 @@
import type { PlasmoMessaging } from '@plasmohq/messaging';
import { DEFAULT_EXTENSION_DATA } from '~utils/constants';
import { storage } from '~utils/storage';
import type { ExtensionData } from '~utils/types';
const handler: PlasmoMessaging.MessageHandler<never, Response> = async (req, res) => {
const data = (await storage.get<ExtensionData>('data')) || DEFAULT_EXTENSION_DATA;
res.send({ data, success: true });
return;
};
interface Response {
readonly data?: ExtensionData;
readonly success: boolean;
}
export default handler;

View File

@ -0,0 +1,29 @@
import type { PlasmoMessaging } from '@plasmohq/messaging';
import { API_URL } from '~utils/constants';
import { storage } from '~utils/storage';
import type { ExtensionData } from '~utils/types';
const handler: PlasmoMessaging.MessageHandler<never, Response> = async (req, res) => {
try {
const response = await fetch(`${API_URL}/data/`);
const { data } = await response.json();
if (data) {
await storage.set('data', data);
res.send({ data, success: true });
return;
}
res.send({ success: false });
} catch {
res.send({ success: false });
}
};
interface Response {
readonly data?: ExtensionData;
readonly success: boolean;
}
export default handler;

View File

@ -0,0 +1,63 @@
import type { PlasmoMessaging } from '@plasmohq/messaging';
import { API_URL, DEFAULT_DOMAIN_CONFIG } from '~utils/constants';
import { noop } from '~utils/error';
import { storage } from '~utils/storage';
import type { DomainConfig } from '~utils/types';
import updateIconHandler from '../extension/updateIcon';
const handler: PlasmoMessaging.MessageHandler<Request, Response> = async (req, res) => {
const { domain } = req.body ?? {};
const { tab } = req.sender || {};
if (domain) {
let data = (await storage.get<DomainConfig>(domain)) || DEFAULT_DOMAIN_CONFIG;
const now = Date.now();
if ((data.issue?.expiresAt && now > data.issue.expiresAt) || !data.issue?.expiresAt) {
const response = await fetch(`${API_URL}/issues/${domain}/`);
if (response.status === 429) {
res.send({ data, success: true });
return;
}
const issue = await response.json();
if (issue.success) {
data = { ...data, issue: { ...issue.data, expiresAt: now + 8 * 60 * 60 * 1000 } };
await storage.set(domain, data);
if (tab?.id !== undefined) {
await updateIconHandler(
{ body: { domain }, name: 'extension/updateIcon', sender: { tab } },
{ send: noop }
);
}
res.send({ data, success: true });
return;
}
data = { ...data, issue: { expiresAt: now + 24 * 60 * 60 * 1000 } };
await storage.set(domain, data);
}
res.send({ data, success: true });
return;
}
res.send({ success: false });
};
interface Request {
readonly domain: string;
}
interface Response {
readonly data?: DomainConfig;
readonly success: boolean;
}
export default handler;

View File

@ -0,0 +1,35 @@
import type { PlasmoMessaging } from '@plasmohq/messaging';
import { API_URL } from '~utils/constants';
import { storage } from '~utils/storage';
const handler: PlasmoMessaging.MessageHandler<never, Response> = async (req, res) => {
try {
const response = await fetch(`${API_URL}/version/`);
if (response.status === 429) {
res.send({ success: false });
return;
}
const { data } = await response.json();
const { version } = chrome.runtime.getManifest();
if (data !== version) {
await storage.set('updateAvailable', data);
res.send({ success: true });
return;
}
await storage.remove('updateAvailable');
res.send({ success: false });
} catch {
res.send({ success: false });
}
};
interface Response {
readonly success: boolean;
}
export default handler;

View File

@ -0,0 +1,25 @@
import type { PlasmoMessaging } from '@plasmohq/messaging';
const handler: PlasmoMessaging.MessageHandler<Request, Response> = async (req, res) => {
const { value } = req.body || {};
const { frameId, tab } = req.sender || {};
if (frameId === 0 && tab?.id !== undefined) {
await chrome.action.setBadgeBackgroundColor({ color: '#6b7280' });
await chrome.action.setBadgeText({ tabId: tab.id, text: value ? `${value}` : '' });
res.send({ success: true });
return;
}
res.send({ success: false });
};
interface Request {
readonly value: number;
}
interface Response {
readonly success: boolean;
}
export default handler;

View File

@ -0,0 +1,49 @@
import type { PlasmoMessaging } from '@plasmohq/messaging';
import offIcon from 'url:~assets/off.png';
import onIcon from 'url:~assets/on.png';
import warnIcon from 'url:~assets/warn.png';
import { DEFAULT_DOMAIN_CONFIG, DEFAULT_EXTENSION_DATA } from '~utils/constants';
import { validateSupport } from '~utils/domain';
import { storage } from '~utils/storage';
import type { DomainConfig, ExtensionData } from '~utils/types';
const handler: PlasmoMessaging.MessageHandler<Request, Response> = async (req, res) => {
const { domain } = req.body || {};
const { frameId, tab } = req.sender || {};
if (domain && frameId === 0 && tab?.id !== undefined) {
const config = (await storage.get<DomainConfig>(domain)) || DEFAULT_DOMAIN_CONFIG;
const data = (await storage.get<ExtensionData>('data')) || DEFAULT_EXTENSION_DATA;
if (
config.on &&
tab.url &&
validateSupport(new URL(tab.url).hostname, data.exclusions.domains)
) {
if (config.issue?.url) {
await chrome.action.setIcon({ path: warnIcon, tabId: tab.id });
res.send({ success: true });
return;
}
await chrome.action.setIcon({ path: onIcon, tabId: tab.id });
res.send({ success: true });
} else {
await chrome.action.setIcon({ path: offIcon, tabId: tab.id });
res.send({ success: true });
}
}
res.send({ success: false });
};
interface Request {
readonly domain: string;
}
interface Response {
readonly success: boolean;
}
export default handler;

View File

@ -0,0 +1,15 @@
import { REPORT_MENU_ITEM_ID, SETTINGS_MENU_ITEM_ID } from '~utils/constants';
import { suppressLastError } from '~utils/error';
export default function onClicked(data: chrome.contextMenus.OnClickData) {
switch (data.menuItemId) {
case REPORT_MENU_ITEM_ID:
chrome.action.openPopup(suppressLastError);
break;
case SETTINGS_MENU_ITEM_ID:
chrome.runtime.openOptionsPage(suppressLastError);
break;
default:
break;
}
}

View File

@ -0,0 +1,38 @@
import databaseRefreshHandler from '~background/messages/database/refresh';
import {
EXTENSION_MENU_ITEM_ID,
REPORT_MENU_ITEM_ID,
SETTINGS_MENU_ITEM_ID,
} from '~utils/constants';
import { noop } from '~utils/error';
import { storage } from '~utils/storage';
export default async function onInstalled() {
await chrome.contextMenus.removeAll();
const documentUrlPatterns = chrome.runtime.getManifest().content_scripts?.[0].matches;
await chrome.contextMenus.create({
contexts: ['all'],
documentUrlPatterns,
id: EXTENSION_MENU_ITEM_ID,
title: 'Cookie Dialog Monster',
});
await chrome.contextMenus.create({
contexts: ['all'],
documentUrlPatterns,
id: SETTINGS_MENU_ITEM_ID,
parentId: EXTENSION_MENU_ITEM_ID,
title: chrome.i18n.getMessage('contextMenu_settingsOption'),
});
await chrome.contextMenus.create({
contexts: ['all'],
documentUrlPatterns,
id: REPORT_MENU_ITEM_ID,
parentId: EXTENSION_MENU_ITEM_ID,
title: chrome.i18n.getMessage('contextMenu_reportOption'),
});
await storage.remove('updateAvailable');
await databaseRefreshHandler({ name: 'database/refresh' }, { send: noop });
}

View File

@ -0,0 +1,10 @@
import databaseRefreshHandler from '~background/messages/database/refresh';
import extensionUpdateAvailableHandler from '~background/messages/extension/updateAvailable';
import { noop } from '~utils/error';
import { storage } from '~utils/storage';
export default async function onStartup() {
await storage.remove('updateAvailable');
await databaseRefreshHandler({ name: 'database/refresh' }, { send: noop });
await extensionUpdateAvailableHandler({ name: 'extension/updateAvailable' }, { send: noop });
}

View File

@ -0,0 +1,32 @@
import { DEFAULT_DOMAIN_CONFIG, DEFAULT_EXTENSION_DATA } from '~utils/constants';
import { formatDomainFromURL, validateSupport } from '~utils/domain';
import { storage } from '~utils/storage';
import type { DomainConfig, ExtensionData } from '~utils/types';
export default async function onBeforeRequest(details: chrome.webRequest.WebRequestBodyDetails) {
const { tabId, type, url } = details;
const location = new URL(url);
const domain = formatDomainFromURL(location);
if (tabId > -1 && type === 'main_frame') {
const data = (await storage.get<ExtensionData>('data')) || DEFAULT_EXTENSION_DATA;
if (!validateSupport(location.hostname, data.exclusions.domains)) {
return;
}
const config = (await storage.get<DomainConfig>(domain)) || DEFAULT_DOMAIN_CONFIG;
if (data.rules.length) {
const rulesWithTabId = data.rules.map((rule) => ({
...rule,
condition: { ...rule.condition, tabIds: [tabId] },
}));
chrome.declarativeNetRequest.updateSessionRules({
addRules: config.on ? rulesWithTabId : undefined,
removeRuleIds: data.rules.map((rule) => rule.id),
});
}
}
}

View File

@ -0,0 +1,15 @@
import { sendToContentScript } from '@plasmohq/messaging';
export default async function onErrorOccurred(details: chrome.webRequest.WebResponseErrorDetails) {
const { error, tabId } = details;
if (error === 'net::ERR_BLOCKED_BY_CLIENT' && tabId > -1) {
await sendToContentScript({
body: {
value: error,
},
name: 'INCREASE_LOG_COUNT',
tabId,
});
}
}

View File

@ -0,0 +1,368 @@
import { sendToBackground } from '@plasmohq/messaging';
import type { PlasmoCSConfig } from 'plasmo';
import { DEFAULT_DOMAIN_CONFIG, DEFAULT_EXTENSION_DATA } from '~utils/constants';
import { formatDomainFromURL, validateSupport } from '~utils/domain';
import type { DomainConfig, ExtensionData } from '~utils/types';
export const config: PlasmoCSConfig = {
all_frames: true,
matches: ['http://*/*', 'https://*/*'],
run_at: 'document_start',
};
class NotifiableSet extends Set {
constructor(...args: any[]) {
super(...args);
}
add(value: any): this {
super.add(value);
sendToBackground({ body: { value: super.size }, name: 'extension/updateBadge' });
return this;
}
}
let { actions, exclusions, keywords, tokens }: ExtensionData = DEFAULT_EXTENSION_DATA;
let domainConfig: DomainConfig = DEFAULT_DOMAIN_CONFIG;
let initiallyVisible: boolean = false;
const domain = formatDomainFromURL(new URL(location.href));
const log = new NotifiableSet();
const observer = new MutationObserver(mutationHandler);
const options: MutationObserverInit = { childList: true, subtree: true };
const seen = new Set<HTMLElement>();
document.addEventListener('visibilitychange', setUpAfterWaitForBody);
window.addEventListener('pageshow', setUpAfterWaitForBody);
setUpAfterWaitForBody();
function clean(elements: readonly HTMLElement[], skipMatch?: boolean): void {
let index = 0;
const size = 50;
function chunk() {
const end = Math.min(index + size, elements.length);
for (; index < end; index++) {
const element = elements[index];
if (match(element, skipMatch)) {
if (element instanceof HTMLDialogElement) element.close();
hide(element);
log.add(`${Date.now()}`);
}
seen.add(element);
}
if (index < elements.length) {
requestAnimationFrame(chunk);
}
}
requestAnimationFrame(chunk);
}
function forceClean(from: HTMLElement): void {
const elements = getElements(tokens.selectors, { filterEarly: true, from });
if (elements.length) {
fix();
clean(elements, true);
}
}
function hasKeyword(element: HTMLElement): boolean {
return !!keywords.length && !!element.outerHTML.match(new RegExp(keywords.join('|')));
}
function filterNodeEarly(node: Node, stopRecursion?: boolean): readonly HTMLElement[] {
if (node.nodeType !== Node.ELEMENT_NODE || !(node instanceof HTMLElement)) {
return [];
}
if (hasKeyword(node) && !stopRecursion) {
return [node, ...[...node.children].flatMap((node) => filterNodeEarly(node, true))];
}
return [node];
}
function fix(): void {
for (const action of actions) {
const { name, property, selector } = action;
if (domain.match(action.domain.replaceAll(/\*/g, '[^ ]*'))) {
switch (name) {
case 'click': {
const element = document.querySelector(selector);
if (element instanceof HTMLElement) {
element.click();
log.add(name);
}
break;
}
case 'remove': {
const element = document.querySelector(selector);
if (element instanceof HTMLElement && property) {
element.style.removeProperty(property);
log.add(name);
}
break;
}
case 'reload': {
window.location.reload();
break;
}
case 'reset': {
const element = document.querySelector(selector);
if (element instanceof HTMLElement && property) {
element.style.setProperty(property, 'initial', 'important');
log.add(name);
}
break;
}
case 'resetAll': {
const elements = getElements(selector);
if (property) {
elements.forEach((e) => e?.style?.setProperty(property, 'initial', 'important'));
log.add(name);
}
break;
}
}
}
}
const backdrops = getElements(tokens.backdrops);
for (const backdrop of backdrops) {
if (backdrop.children.length === 0 && !seen.has(backdrop)) {
log.add(`${Date.now()}`);
seen.add(backdrop);
hide(backdrop);
}
}
const skips = exclusions.overflows.map((x) => (x.split('.').length < 3 ? `*${x}` : x));
if (!skips.some((x) => domain.match(x.replaceAll(/\*/g, '[^ ]*')))) {
for (const element of [document.body, document.documentElement]) {
element?.classList.remove(...(tokens.classes ?? []));
element?.style.setProperty('position', 'initial', 'important');
element?.style.setProperty('overflow-y', 'initial', 'important');
}
}
const ionRouterOutlet = document.getElementsByTagName('ion-router-outlet')[0];
if (ionRouterOutlet) {
// 2024-08-02: fix #644 temporarily
ionRouterOutlet.removeAttribute('inert');
log.add('ion-router-outlet');
}
const t4Wrapper = document.getElementsByClassName('t4-wrapper')[0];
if (t4Wrapper) {
log.add('t4-wrapper');
// 2024-09-12: fix #945 temporarily
t4Wrapper.removeAttribute('inert');
}
}
function getElements(selector: Selector, params: GetElementsParams = {}): readonly HTMLElement[] {
const { filterEarly, from } = params;
let result: HTMLElement[] = [];
if (selector.length) {
result = [
...(from ?? document).querySelectorAll(selector as string),
] as unknown as HTMLElement[];
if (filterEarly) {
result = result.flatMap((node) => filterNodeEarly(node));
}
}
return result;
}
function getElementsWithChildren(
selector: Selector,
params: GetElementsParams = {}
): readonly HTMLElement[] {
const elements = getElements(selector, params);
const elementsWithChildren = elements.flatMap((element) => [element, ...element.children]);
return elementsWithChildren as unknown as readonly HTMLElement[];
}
function hide(element: HTMLElement) {
element.style.setProperty('clip-path', 'circle(0px)', 'important');
element.style.setProperty('display', 'none', 'important');
element.style.setProperty('height', '0px', 'important');
element.style.setProperty('overflow', 'hidden', 'important');
element.style.setProperty('transform', 'scale(0)', 'important');
}
function isInViewport(element: HTMLElement): boolean {
const styles = window.getComputedStyle(element);
const height = window.innerHeight || document.documentElement.clientHeight;
const position = element.getBoundingClientRect();
const scroll = window.scrollY;
return (
position.bottom === position.top ||
(scroll + position.top <= scroll + height && scroll + position.bottom >= scroll) ||
styles.animationDuration !== '0s' ||
styles.transitionDuration !== '0s'
);
}
function match(element: HTMLElement, skipMatch?: boolean): boolean {
if (!exclusions.tags.length || !tokens.selectors.length) {
return false;
}
if (!(element instanceof HTMLElement) || !element.tagName) {
return false;
}
if (seen.has(element)) {
return false;
}
const tagName = element.tagName.toUpperCase();
if (exclusions.tags.includes(tagName)) {
return false;
}
const hasAttributes = !!element.getAttributeNames().filter((x) => x !== 'data-nosnippet').length;
if (!hasAttributes && !tagName.includes('-')) {
forceClean(element);
}
// 2023-06-10: fix #113 temporarily
if (element.classList.contains('chat-line__message')) {
return false;
}
// 2024-08-03: fix #701 temporarily
if (element.classList.contains('sellos')) {
return false;
}
const isDialog = tagName === 'DIALOG' && element.getAttribute('open') === 'true';
const isFakeDialog = tagName === 'DIV' && element.className.includes('cmp');
return (
(isDialog || isFakeDialog || isInViewport(element)) &&
(skipMatch || element.matches(tokens.selectors as unknown as string))
);
}
function mutationHandler(mutations: readonly MutationRecord[]): void {
if (!domainConfig.on || !tokens.selectors.length) {
return;
}
const nodes = mutations.flatMap((mutation) => [...mutation.addedNodes]);
const elements = nodes.flatMap((node) => filterNodeEarly(node));
run({ elements });
}
function run(params: RunParams = {}): void {
const { containers, elements, skipMatch } = params;
if (document.body?.children.length && domainConfig.on && tokens.selectors.length) {
fix();
if (elements?.length) {
clean(elements, skipMatch);
}
if (elements === undefined && containers?.length) {
clean(containers.flatMap((x) => getElementsWithChildren(x, { filterEarly: true })));
}
}
}
function runtimeMessageHandler(message: RuntimeMessage): void {
switch (message.name) {
case 'INCREASE_LOG_COUNT': {
log.add(message.value);
break;
}
default:
break;
}
}
async function setUp(params: SetUpParams = {}): Promise<void> {
const { data } = await sendToBackground({ name: 'database/get' });
exclusions = data?.exclusions ?? exclusions;
sendToBackground({ body: { domain }, name: 'extension/updateIcon' });
if (!validateSupport(location.hostname, exclusions.domains)) {
observer.disconnect();
return;
}
domainConfig = (await sendToBackground({ body: { domain }, name: 'domain/config' }))?.data;
if (domainConfig.on) {
chrome.runtime.onMessage.addListener(runtimeMessageHandler);
actions = data?.actions ?? actions;
keywords = data?.keywords ?? keywords;
tokens = data?.tokens ?? tokens;
observer.observe(document.body ?? document.documentElement, options);
if (!params.skipRunFn) run({ containers: tokens.containers });
}
}
async function setUpAfterWaitForBody(): Promise<void> {
if (document.visibilityState === 'visible' && !initiallyVisible) {
if (document.body) {
initiallyVisible = true;
await setUp();
return;
}
setTimeout(setUpAfterWaitForBody, 50);
}
}
interface GetElementsParams {
readonly filterEarly?: boolean;
readonly from?: HTMLElement;
}
interface RunParams {
readonly containers?: readonly string[];
readonly elements?: readonly HTMLElement[];
readonly skipMatch?: boolean;
}
interface RuntimeMessage {
readonly name: string;
readonly value?: string;
}
type Selector = string | readonly string[];
interface SetUpParams {
readonly skipRunFn?: boolean;
}

View File

@ -0,0 +1,17 @@
export function useBrowser(): UseBrowserResult {
const isChromium = navigator.userAgent.indexOf('Chrome') !== -1;
const isEdge = navigator.userAgent.indexOf('Edg') !== -1;
const isFirefox = navigator.userAgent.indexOf('Firefox') !== -1;
return {
isChromium,
isEdge,
isFirefox,
};
}
export interface UseBrowserResult {
readonly isChromium: boolean;
readonly isEdge: boolean;
readonly isFirefox: boolean;
}

View File

@ -0,0 +1,41 @@
import { sendToBackground } from '@plasmohq/messaging';
import { useStorage } from '@plasmohq/storage/hook';
import { useCallback, useEffect, useRef, useState } from 'react';
import { storage } from '~utils/storage';
import type { ExtensionData } from '~utils/types';
export function useData(): UseDataResult {
const timeoutId = useRef<number | undefined>(undefined);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isRefetched, setIsRefetched] = useState<boolean>(false);
const [data] = useStorage<ExtensionData>({ instance: storage, key: 'data' });
const refetch = useCallback(async () => {
setIsLoading(true);
await sendToBackground({ name: 'database/refresh' });
setIsLoading(false);
setIsRefetched(true);
timeoutId.current = window.setTimeout(() => setIsRefetched(false), 60000);
}, []);
useEffect(() => {
return () => {
window.clearTimeout(timeoutId.current);
};
}, []);
return {
data,
isLoading,
isRefetched,
refetch,
};
}
export interface UseDataResult {
readonly data?: ExtensionData;
readonly isLoading: boolean;
readonly isRefetched: boolean;
readonly refetch: () => Promise<void>;
}

View File

@ -0,0 +1,52 @@
import { useStorage } from '@plasmohq/storage/hook';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { DEFAULT_DOMAIN_CONFIG } from '~utils/constants';
import { formatDomainFromURL, validateSupport } from '~utils/domain';
import { storage } from '~utils/storage';
import type { DomainConfig, ExtensionData } from '~utils/types';
import { useTab } from './useTab';
export function useDomain(): UseDomainResult {
const [domain, setDomain] = useState<string>('');
const [config, setConfig] = useStorage<DomainConfig>({ instance: storage, key: domain });
const [data] = useStorage<ExtensionData>({ instance: storage, key: 'data' });
const tab = useTab();
const tabId = tab ? (tab.id ?? -1) : -1;
const isSupported = useMemo(
() =>
!!data?.exclusions.domains.length &&
!!tab?.url &&
validateSupport(new URL(tab.url).hostname, data.exclusions.domains),
[data, tab]
);
const toggleStatus = useCallback(async () => {
if (tabId > -1) {
await setConfig((prev = DEFAULT_DOMAIN_CONFIG) => ({ ...prev, on: !prev.on }));
await chrome.tabs.reload(tabId, { bypassCache: true });
}
}, [domain, tabId]);
useEffect(() => {
if (tab?.url) {
setDomain(formatDomainFromURL(new URL(tab.url)));
}
}, [tab]);
return {
config: config || DEFAULT_DOMAIN_CONFIG,
domain,
isSupported,
toggleStatus,
};
}
export interface UseDomainResult {
readonly config: DomainConfig;
readonly domain: string;
readonly isSupported: boolean;
readonly toggleStatus: () => Promise<void>;
}

View File

@ -0,0 +1,90 @@
import { useCallback, useState } from 'react';
import { API_URL } from '~utils/constants';
import type { ReportParams, ReportResult } from '~utils/types';
import { useExtension } from './useExtension';
export function useDomainReport(): UseDomainReportResult {
const extension = useExtension();
const [errors, setErrors] = useState<{ readonly [key: string]: string }>({});
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const report = useCallback(
async (params: ReportParams): Promise<ReportResult | undefined> => {
try {
setIsSubmitting(true);
const reason = params.reason;
const url = params.url;
const errors = validateForm(params);
if (errors) {
setErrors(errors);
setIsSubmitting(false);
return;
}
const userAgent = navigator.userAgent;
const version = extension.version;
const body = JSON.stringify({ reason, url, userAgent, version });
const headers = { 'Cache-Control': 'no-cache', 'Content-type': 'application/json' };
const requestInit = { body, headers, method: 'POST' };
const response = await fetch(`${API_URL}/report/`, requestInit);
if (response.ok) {
return await response.json();
}
if (response.status === 429) {
throw new Error(chrome.i18n.getMessage('report_rateLimitError'));
}
throw new Error(chrome.i18n.getMessage('report_unknownError'));
} catch (error) {
if (error instanceof Error) {
setErrors({ url: error.message });
}
} finally {
setIsSubmitting(false);
}
},
[extension.version]
);
return {
errors,
isSubmitting,
report,
};
}
function validateForm(params: ReportParams): Partial<ReportParams> | undefined {
const { reason, url } = params;
let errors: Partial<ReportParams> | undefined = undefined;
if (!reason || reason.length < 10 || reason.length > 1000) {
errors = {
...(errors ?? {}),
reason: chrome.i18n.getMessage('report_reasonInputError'),
};
}
try {
if (/\s/.test(url)) throw new Error();
new URL(url);
} catch {
errors = {
...(errors ?? {}),
url: chrome.i18n.getMessage('report_urlInputError'),
};
}
return errors;
}
export interface UseDomainReportResult {
readonly errors: { readonly [key: string]: string };
readonly isSubmitting: boolean;
readonly report: (params: ReportParams) => Promise<ReportResult | undefined>;
}

View File

@ -0,0 +1,52 @@
import { useCallback, useEffect, useState } from 'react';
import { storage } from '~utils/storage';
async function getExclusions(): Promise<readonly Exclusion[]> {
const result = await storage.rawGetAll();
return Object.keys(result).flatMap((key) => (result[key]?.on === false ? [{ name: key }] : []));
}
export function useExclusions(): readonly Exclusion[] {
const [exclusions, setExclusions] = useState<readonly Exclusion[]>([]);
const handleStorageChange = useCallback(
(
changes: { [key: string]: chrome.storage.StorageChange },
areaName: chrome.storage.AreaName
) => {
if (areaName === 'local') {
for (const key in changes) {
if (key === 'data') {
continue;
}
setExclusions((prev) => {
if (changes[key].newValue?.on === false) {
return [...prev, { name: key }];
}
return prev.filter((exclusion) => exclusion.name !== key);
});
}
}
},
[]
);
useEffect(() => {
chrome.storage.onChanged.addListener(handleStorageChange);
getExclusions().then(setExclusions);
return () => {
chrome.storage.onChanged.removeListener(handleStorageChange);
};
}, [handleStorageChange]);
return exclusions;
}
export interface Exclusion {
readonly name: string;
}

View File

@ -0,0 +1,18 @@
import { useStorage } from '@plasmohq/storage/hook';
import { storage } from '~utils/storage';
export function useExtension(): UseExtensionResult {
const [updateAvailable] = useStorage({ instance: storage, key: 'updateAvailable' });
const { version } = chrome.runtime.getManifest();
return {
updateAvailable,
version,
};
}
export interface UseExtensionResult {
readonly updateAvailable?: string;
readonly version: string;
}

View File

@ -0,0 +1,11 @@
import { useEffect, useState } from 'react';
export function useTab(): chrome.tabs.Tab | undefined {
const [tab, setTab] = useState<chrome.tabs.Tab | undefined>(undefined);
useEffect(() => {
chrome.tabs.query({ active: true, currentWindow: true }).then((tabs) => setTab(tabs[0]));
}, []);
return tab;
}

View File

@ -1,46 +0,0 @@
{
"manifest_version": 3,
"name": "Cookie Dialog Monster",
"version": "8.0.5",
"default_locale": "en",
"description": "__MSG_appDesc__",
"icons": {
"16": "assets/icons/16.png",
"48": "assets/icons/48.png",
"128": "assets/icons/128.png"
},
"action": {
"default_icon": "assets/icons/off.png",
"default_popup": "popup.html",
"default_title": "Cookie Dialog Monster"
},
"options_page": "options.html",
"author": "wanhose",
"background": {
"scripts": ["scripts/background.js"],
"service_worker": "scripts/background.js"
},
"browser_specific_settings": {
"gecko": {
"id": "{77e2c00b-e173-4604-863d-01645d8d2826}",
"strict_min_version": "126.0",
"update_url": "https://www.cookie-dialog-monster.com/releases/mozilla/updates.json"
}
},
"content_scripts": [
{
"all_frames": true,
"js": ["scripts/content.js"],
"matches": ["http://*/*", "https://*/*"],
"run_at": "document_start"
}
],
"host_permissions": ["http://*/*", "https://*/*"],
"permissions": ["contextMenus", "declarativeNetRequest", "scripting", "storage", "webRequest"],
"web_accessible_resources": [
{
"matches": ["http://*/*", "https://*/*"],
"resources": ["https://fonts.googleapis.com/css?family=Inter"]
}
]
}

View File

@ -1,127 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cookie Dialog Monster > Exclusion List</title>
<link rel="stylesheet" href="/styles/reset.css" />
<link rel="stylesheet" href="/styles/options.css" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Inter" />
<script src="/scripts/options.js"></script>
</head>
<body>
<header>
<div>
<h1 class="header-title">
Cookie Dialog Monster > <span data-i18n="options_exclusionListTitle"></span>
</h1>
</div>
</header>
<main>
<div class="button-group">
<button data-variant="large" id="add-button">
<span data-i18n="options_addButton"></span>
<svg
fill="none"
height="14"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="14"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
<button data-variant="large" id="clear-button">
<span data-i18n="options_clearButton"></span>
<svg
aria-hidden="true"
fill="none"
height="14"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="14"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
<button data-variant="large" id="import-button">
<span data-i18n="options_importButton"></span>
<svg
aria-hidden="true"
fill="none"
height="14"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="14"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<input accept=".cdm" id="file-input" style="display: none" type="file" />
</button>
<button data-variant="large" id="export-button">
<span data-i18n="options_exportButton"></span>
<svg
aria-hidden="true"
fill="none"
height="14"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="14"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</button>
</div>
<input id="filter-input" data-i18n-placeholder="options_filterPlaceholder" />
<ul id="exclusion-list">
<li id="exclusion-list-item-template" style="display: none">
<span></span>
<button>
<svg
aria-hidden="true"
fill="none"
height="14"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="14"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</li>
<li id="exclusion-list-item-empty" style="display: none"></li>
</ul>
</main>
<footer></footer>
</body>
</html>

View File

@ -0,0 +1,13 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cookie Dialog Monster > Exclusion List</title>
<link rel="stylesheet" href="~src/styles/reset.css" />
<link rel="stylesheet" href="~src/styles/common.css" />
<link rel="stylesheet" href="~src/styles/options.css" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Inter" />
</head>
<body></body>
</html>

View File

@ -0,0 +1,227 @@
import { type ChangeEvent, type JSX, type MouseEvent, useCallback, useRef, useState } from 'react';
import { useExclusions } from '~hooks/useExclusions';
import { DOMAIN_REG_EXP } from '~utils/domain';
import { storage } from '~utils/storage';
import type { DomainConfig } from '~utils/types';
export default function Options(): JSX.Element {
const exclusions = useExclusions();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [filterValue, setFilterValue] = useState<string>('');
const handleAddClick = useCallback(async () => {
const message = chrome.i18n.getMessage('options_addPrompt');
const domain = window.prompt(message)?.trim().replace('www.', '');
if (domain && (DOMAIN_REG_EXP.test(domain) || domain === 'localhost')) {
const prev = await storage.get<DomainConfig>(domain);
await storage.set(domain, { ...prev, on: false });
}
}, []);
const handleClearClick = useCallback(async () => {
for (const domain of exclusions) {
const prev = await storage.get<DomainConfig>(domain.name);
if (prev?.issue?.url) await storage.set(domain.name, { ...prev, on: true });
else await storage.remove(domain.name);
}
}, [exclusions]);
const handleDeleteClick = useCallback(async (event: MouseEvent<HTMLButtonElement>) => {
const { value: domain } = event.currentTarget.dataset;
if (domain) {
const prev = await storage.get<DomainConfig>(domain);
if (prev?.issue?.url) await storage.set(domain, { ...prev, on: true });
else await storage.remove(domain);
}
}, []);
const handleExportClick = useCallback(() => {
const anchor = document.createElement('a');
const now = new Date();
const day = now.getDate().toString().padStart(2, '0');
const month = now.getMonth().toString().padStart(2, '0');
const year = now.getUTCFullYear();
const text = exclusions.reduce((prev, curr) => `${prev}\n${curr.name}`, '');
const defaultTitle = `${year}${month}${day}`;
const customTitle = window.prompt('Enter a file name', defaultTitle);
if (customTitle) {
const blob = new Blob([text], { type: 'octet/stream' });
const url = window.URL.createObjectURL(blob);
anchor.href = url;
anchor.download = `${customTitle || defaultTitle}.cdm`;
anchor.click();
window.URL.revokeObjectURL(url);
}
}, [exclusions]);
const handleFileChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
const file = event.currentTarget.files?.[0];
const reader = new FileReader();
reader.addEventListener('load', async (event) => {
const input =
event.currentTarget && 'result' in event.currentTarget
? (event.currentTarget.result as string)?.split('\n')
: [];
for (const value of input) {
const domain = value.replace('www.', '').trim();
if (domain && (DOMAIN_REG_EXP.test(domain) || domain === 'localhost')) {
const prev = await storage.get<DomainConfig>(domain);
await storage.set(domain, { ...prev, on: false });
}
}
});
if (file) {
event.currentTarget.value = '';
reader.readAsText(file);
}
}, []);
const handleFilterChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setFilterValue(event.currentTarget.value);
}, []);
const handleImportClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
return (
<>
<header className="header">
<div>
<h1>Cookie Dialog Monster &gt; {chrome.i18n.getMessage('options_exclusionListTitle')}</h1>
</div>
</header>
<main className="main">
<div className="button-group">
<button className="button" data-variant="large" onClick={handleAddClick}>
<span>{chrome.i18n.getMessage('options_addButton')}</span>
<svg
fill="none"
height="14"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="14"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
<button className="button" data-variant="large" onClick={handleClearClick}>
<span>{chrome.i18n.getMessage('options_clearButton')}</span>
<svg
aria-hidden="true"
fill="none"
height="14"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="14"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
</button>
<button className="button" data-variant="large" onClick={handleImportClick}>
<span>{chrome.i18n.getMessage('options_importButton')}</span>
<svg
aria-hidden="true"
fill="none"
height="14"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="14"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<input
accept=".cdm"
onChange={handleFileChange}
ref={fileInputRef}
style={{ display: 'none' }}
type="file"
/>
</button>
<button className="button" data-variant="large" onClick={handleExportClick}>
<span>{chrome.i18n.getMessage('options_exportButton')}</span>
<svg
aria-hidden="true"
fill="none"
height="14"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="14"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
</div>
<input
className="input"
onChange={handleFilterChange}
placeholder={chrome.i18n.getMessage('options_filterPlaceholder')}
value={filterValue}
/>
<ul className="exclusion-list">
{exclusions.length ? (
exclusions.map((exclusion) => (
<li key={exclusion.name}>
<span>{exclusion.name}</span>
<button className="button" data-value={exclusion.name} onClick={handleDeleteClick}>
<svg
aria-hidden="true"
fill="none"
height="14"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="14"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
</button>
</li>
))
) : (
<li>{chrome.i18n.getMessage('options_empty')}</li>
)}
</ul>
</main>
<footer className="footer" />
</>
);
}

View File

@ -1,338 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/styles/reset.css" />
<link rel="stylesheet" href="/styles/popup.css" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Inter" />
<script src="/scripts/popup.js"></script>
</head>
<body>
<header>
<h1 class="header-title">Cookie Dialog Monster</h1>
<div class="header-actions">
<button
class="header-button"
data-href="https://git.wanhose.dev/wanhose/cookie-dialog-monster/wiki/Help-or-issues%3F"
id="help-button"
>
<svg
aria-hidden="true"
fill="none"
height="18"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="18"
>
<circle cx="12" cy="12" r="10"></circle>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
</button>
<button class="header-button" id="settings-button">
<svg
aria-hidden="true"
fill="none"
height="18"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="18"
>
<circle cx="12" cy="12" r="3"></circle>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
</button>
</div>
</header>
<main>
<p aria-hidden="true" class="banner" data-variant="warning" id="issue-banner" role="alert">
<span id="issue-banner-text"></span>
<a id="issue-banner-url" target="_blank">
<svg
aria-hidden="true"
fill="none"
height="12"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="12"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
</a>
</p>
<p aria-hidden="true" class="banner" data-variant="error" id="support-banner" role="alert">
<span data-i18n="popup_bannerSupport"></span>
<a
href="https://git.wanhose.dev/wanhose/cookie-dialog-monster/wiki/List-of-unsupported-sites"
target="_blank"
>
<svg
aria-hidden="true"
fill="none"
height="12"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="12"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
</a>
</p>
<p aria-hidden="true" class="banner" data-variant="notice" id="update-banner" role="alert">
<span data-i18n="popup_bannerUpdateAvailable"></span>
<a
href="https://git.wanhose.dev/wanhose/cookie-dialog-monster/releases"
id="update-banner-url"
target="_blank"
>
<svg
aria-hidden="true"
fill="none"
height="12"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="12"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
</a>
</p>
<div class="content">
<button class="popup-button" disabled id="power-option">
<svg
aria-hidden="true"
fill="none"
height="32"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="32"
>
<path d="M18.36 6.64a9 9 0 1 1-12.73 0" />
<line x1="12" y1="2" x2="12" y2="12" />
</svg>
<span id="host"></span>
</button>
<button class="popup-button" disabled id="report-option" role="link">
<svg
aria-hidden="true"
fill="none"
height="32"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="32"
>
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"></path>
<line x1="4" y1="22" x2="4" y2="15"></line>
</svg>
<span data-i18n="contextMenu_reportOption"></span>
</button>
<button
class="popup-button"
data-href="https://git.wanhose.dev/wanhose/cookie-dialog-monster"
id="contribute-option"
role="link"
>
<svg
aria-hidden="true"
fill="none"
height="32"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="32"
>
<circle cx="18" cy="18" r="3"></circle>
<circle cx="6" cy="6" r="3"></circle>
<path d="M13 6h3a2 2 0 0 1 2 2v7"></path>
<line x1="6" y1="9" x2="6" y2="21"></line>
</svg>
<span data-i18n="popup_contributeOption"></span>
</button>
<button class="popup-button" data-href="#" id="rate-option" role="link">
<svg
aria-hidden="true"
fill="none"
height="32"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="32"
>
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
/>
</svg>
<span data-i18n="popup_rateOption"></span>
</button>
<div class="popup-data-container">
<div class="popup-data">
<b data-i18n="popup_databaseVersion"></b>
<span id="database-version"></span>
<button class="popup-data-button" data-animation="flip" id="refresh-database-button">
<svg
aria-hidden="true"
fill="none"
height="12"
id="refresh-database-spinner"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="12"
>
<polyline points="1 4 1 10 7 10"></polyline>
<polyline points="23 20 23 14 17 14"></polyline>
<path
d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"
></path>
</svg>
<svg
aria-hidden="true"
fill="none"
height="12"
id="refresh-database-check"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
stroke="var(--color-success)"
viewBox="0 0 24 24"
width="12"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</button>
</div>
<div class="popup-data">
<b data-i18n="popup_extensionVersion"></b>
<span id="extension-version"></span>
</div>
</div>
</div>
<div class="report" style="display: none">
<div class="report-form-view">
<div class="report-body-text" data-i18n="report_bodyText"></div>
<div class="report-form">
<div class="report-input-group">
<div class="report-input-label" id="report-label-url">
<span data-i18n="report_urlInputLabel"></span>
<span class="report-input-label-required">*</span>
</div>
<input
aria-labelledby="report-label-url"
aria-required="true"
class="report-input"
id="report-input-url"
/>
<div
class="report-input-error"
data-i18n="report_urlInputError"
id="report-input-url-error"
></div>
</div>
<div class="report-input-group">
<div class="report-input-label" id="report-label-reason">
<span data-i18n="report_reasonInputLabel"></span>
<span class="report-input-label-required">*</span>
</div>
<textarea
aria-labelledby="report-label-reason"
aria-required="true"
class="report-input"
data-i18n="report_reasonInputPlaceholder"
id="report-input-reason"
rows="4"
></textarea>
<div
class="report-input-error"
data-i18n="report_reasonInputError"
id="report-input-reason-error"
></div>
</div>
<div class="report-buttons">
<button class="report-submit-button" data-i18n="contextMenu_reportOption"></button>
<button class="report-cancel-button" data-i18n="report_cancelButtonText"></button>
</div>
</div>
</div>
<div class="report-submit-error-view" hidden>
<svg
aria-hidden="true"
fill="none"
height="48"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
stroke="var(--color-error)"
viewBox="0 0 24 24"
width="48"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<div class="report-submit-text" data-i18n="report_submitErrorText"></div>
<div class="report-submit-extra-text" data-i18n="report_submitErrorExtraText"></div>
<div class="report-issue-button" data-i18n="contextMenu_issueOption"></div>
</div>
<div class="report-submit-success-view" hidden>
<svg
aria-hidden="true"
fill="none"
height="48"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
stroke="var(--color-success)"
viewBox="0 0 24 24"
width="48"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<div class="report-submit-text" data-i18n="report_submitSuccessText"></div>
<div class="report-submit-extra-text" data-i18n="report_submitSuccessExtraText"></div>
<div class="report-issue-button" data-i18n="contextMenu_issueOption"></div>
</div>
</div>
<div id="loader">
<span></span>
</div>
</main>
<footer></footer>
</body>
</html>

View File

@ -0,0 +1,12 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="~src/styles/reset.css" />
<link rel="stylesheet" href="~src/styles/common.css" />
<link rel="stylesheet" href="~src/styles/popup.css" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Inter" />
</head>
<body></body>
</html>

View File

@ -0,0 +1,472 @@
import { type FormEvent, type JSX, type KeyboardEvent, useCallback, useState } from 'react';
import { useBrowser } from '~hooks/useBrowser';
import { useData } from '~hooks/useData';
import { useDomain } from '~hooks/useDomain';
import { useDomainReport } from '~hooks/useDomainReport';
import { useExtension } from '~hooks/useExtension';
import { CHROME_STORE_URL, EDGE_STORE_URL, FIREFOX_STORE_URL } from '~utils/constants';
import { suppressLastError } from '~utils/error';
import type { ReportParams } from '~utils/types';
export default function Popup(): JSX.Element {
const { isChromium, isEdge, isFirefox } = useBrowser();
const { data, isLoading, isRefetched, refetch } = useData();
const { config, domain, isSupported, toggleStatus } = useDomain();
const { errors, isSubmitting, report } = useDomainReport();
const extension = useExtension();
const [issueExists, setIssueExists] = useState<boolean>(false);
const [issueURL, setIssueURL] = useState<string>('');
const [view, setView] = useState<'main' | 'report'>('main');
const handleCancelClick = useCallback(() => {
setIssueExists(false);
setIssueURL('');
setView('main');
}, []);
const handleInputKeyDown = useCallback(
(event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
event.currentTarget.blur();
}
},
[]
);
const handlePowerClick = useCallback(async () => {
await toggleStatus();
window.close();
}, [toggleStatus]);
const handleRefreshClick = useCallback(async () => {
await refetch();
}, [refetch]);
const handleReportClick = useCallback(() => {
setView('report');
}, []);
const handleSettingsClick = useCallback(async () => {
chrome.runtime.openOptionsPage(suppressLastError);
window.close();
}, []);
const handleSubmit = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const params = Object.fromEntries(Object.entries(new FormData(event.currentTarget)));
const result = await report(params as ReportParams);
if (result) {
setIssueExists(!result.success);
setIssueURL(result.data);
}
},
[report]
);
return (
<>
<header className="header">
<h1>Cookie Dialog Monster</h1>
<div className="header-actions">
<a
className="header-button"
href="https://git.wanhose.dev/wanhose/cookie-dialog-monster/wiki/Help-or-issues%3F"
target="_blank"
rel="noreferrer"
>
<svg
aria-hidden="true"
fill="none"
height="18"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="18"
>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</a>
<button className="header-button" onClick={handleSettingsClick}>
<svg
aria-hidden="true"
fill="none"
height="18"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="18"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button>
</div>
</header>
<main>
{config.issue?.url ? (
<p className="banner" data-variant="warning" role="alert">
<span>
{chrome.i18n.getMessage(
config.issue.flags?.includes('wontfix')
? 'popup_bannerIssueWontFix'
: 'popup_bannerIssueOpen'
)}
</span>
&nbsp;
<a href={config.issue.url} target="_blank" rel="noreferrer">
<svg
aria-hidden="true"
fill="none"
height="12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="12"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
</a>
</p>
) : null}
{extension.updateAvailable ? (
<p className="banner" data-variant="notice" role="alert">
<span>{chrome.i18n.getMessage('popup_bannerUpdateAvailable')}</span>
&nbsp;
<a
href={`https://git.wanhose.dev/wanhose/cookie-dialog-monster/releases/${extension.updateAvailable}`}
target="_blank"
rel="noreferrer"
>
<svg
aria-hidden="true"
fill="none"
height="12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="12"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
</a>
</p>
) : null}
{isSupported ? null : (
<p className="banner" data-variant="error" role="alert">
<span>{chrome.i18n.getMessage('popup_bannerSupport')}</span>
&nbsp;
<a
href="https://git.wanhose.dev/wanhose/cookie-dialog-monster/wiki/List-of-unsupported-sites"
target="_blank"
rel="noreferrer"
>
<svg
aria-hidden="true"
fill="none"
height="12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="12"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
</a>
</p>
)}
{view === 'main' ? (
<div className="content">
<button
className="popup-button power-option"
data-value={config.on ? 'on' : 'off'}
disabled={!!config.issue || !!extension.updateAvailable || !isSupported}
onClick={handlePowerClick}
>
<svg
aria-hidden="true"
fill="none"
height="32"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="32"
>
<path d="M18.36 6.64a9 9 0 1 1-12.73 0" />
<line x1="12" y1="2" x2="12" y2="12" />
</svg>
<span>{domain}</span>
</button>
<button
className="popup-button"
disabled={!!config.issue || !!extension.updateAvailable || !isSupported}
onClick={handleReportClick}
>
<svg
aria-hidden="true"
fill="none"
height="32"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="32"
>
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
<line x1="4" y1="22" x2="4" y2="15" />
</svg>
<span>{chrome.i18n.getMessage('contextMenu_reportOption')}</span>
</button>
<a
className="popup-button"
href="https://git.wanhose.dev/wanhose/cookie-dialog-monster"
target="_blank"
rel="noreferrer"
>
<svg
aria-hidden="true"
fill="none"
height="32"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="32"
>
<circle cx="18" cy="18" r="3" />
<circle cx="6" cy="6" r="3" />
<path d="M13 6h3a2 2 0 0 1 2 2v7" />
<line x1="6" y1="9" x2="6" y2="21" />
</svg>
<span>{chrome.i18n.getMessage('popup_contributeOption')}</span>
</a>
<a
className="popup-button rate-option"
href={
isEdge
? EDGE_STORE_URL
: isChromium
? CHROME_STORE_URL
: isFirefox
? FIREFOX_STORE_URL
: '#'
}
target="_blank"
rel="noreferrer"
>
<svg
aria-hidden="true"
fill="none"
height="32"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="32"
>
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
<span>{chrome.i18n.getMessage('popup_rateOption')}</span>
</a>
<div className="popup-data-container">
<div className="popup-data">
<b>{chrome.i18n.getMessage('popup_databaseVersion')}</b>
<span>{data?.version || '?'}</span>
<button
className="popup-data-button"
data-animation="flip"
data-refreshing={isLoading && !isRefetched}
data-refreshed={isRefetched}
disabled={isLoading || isRefetched}
onClick={handleRefreshClick}
>
{isRefetched ? (
<svg
aria-hidden="true"
fill="none"
height="12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
stroke="var(--color-success)"
viewBox="0 0 24 24"
width="12"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
aria-hidden="true"
fill="none"
height="12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
stroke="currentColor"
viewBox="0 0 24 24"
width="12"
>
<polyline points="1 4 1 10 7 10" />
<polyline points="23 20 23 14 17 14" />
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
</svg>
)}
</button>
</div>
<div className="popup-data">
<b>{chrome.i18n.getMessage('popup_extensionVersion')}</b>
<span>{extension.version}</span>
</div>
</div>
</div>
) : (
<div className="report">
{issueURL ? (
<>
{issueExists ? (
<div className="report-submit-error-view" hidden>
<svg
aria-hidden="true"
fill="none"
height="48"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
stroke="var(--color-error)"
viewBox="0 0 24 24"
width="48"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<div className="report-submit-text">
{chrome.i18n.getMessage('report_submitErrorText')}
</div>
<div className="report-submit-extra-text">
{chrome.i18n.getMessage('report_submitErrorExtraText')}
</div>
<div className="report-issue-button">
{chrome.i18n.getMessage('contextMenu_issueOption')}
</div>
</div>
) : (
<div className="report-submit-success-view" hidden>
<svg
aria-hidden="true"
fill="none"
height="48"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
stroke="var(--color-success)"
viewBox="0 0 24 24"
width="48"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<div className="report-submit-text">
{chrome.i18n.getMessage('report_submitSuccessText')}
</div>
<div className="report-submit-extra-text">
{chrome.i18n.getMessage('report_submitSuccessExtraText')}
</div>
<div className="report-issue-button">
{chrome.i18n.getMessage('contextMenu_issueOption')}
</div>
</div>
)}
</>
) : (
<div className="report-form-view">
<div className="report-body-text">{chrome.i18n.getMessage('report_bodyText')}</div>
<form className="report-form" noValidate onSubmit={handleSubmit}>
<div className="report-input-group">
<div className="report-input-label" id="report-label-url">
<span>{chrome.i18n.getMessage('report_urlInputLabel')}</span>
<span className="report-input-label-required">*</span>
</div>
<input
aria-invalid={!!errors['url']}
aria-labelledby="report-label-url"
aria-required="true"
className="report-input"
disabled={isSubmitting}
name="url"
onKeyDown={handleInputKeyDown}
placeholder="https://www.example.com/"
/>
<div aria-hidden={!errors['url']} className="report-input-error">
{chrome.i18n.getMessage('report_urlInputError')}
</div>
</div>
<div className="report-input-group">
<div className="report-input-label" id="report-label-reason">
<span>{chrome.i18n.getMessage('report_reasonInputLabel')}</span>
<span className="report-input-label-required">*</span>
</div>
<textarea
aria-invalid={!!errors['reason']}
aria-labelledby="report-label-reason"
aria-required="true"
className="report-input"
disabled={isSubmitting}
name="reason"
onKeyDown={handleInputKeyDown}
placeholder={chrome.i18n.getMessage('report_reasonInputPlaceholder')}
rows={4}
/>
<div aria-hidden={!errors['reason']} className="report-input-error">
{chrome.i18n.getMessage('report_reasonInputError')}
</div>
</div>
<div className="report-buttons">
<button className="report-submit-button" disabled={isSubmitting} type="submit">
{chrome.i18n.getMessage('contextMenu_reportOption')}
</button>
<button
className="report-cancel-button"
disabled={isSubmitting}
onClick={handleCancelClick}
>
{chrome.i18n.getMessage('report_cancelButtonText')}
</button>
</div>
</form>
</div>
)}
</div>
)}
</main>
<footer />
</>
);
}

View File

@ -1,427 +0,0 @@
/**
* @typedef {Object} ExtensionIssue
* @property {string[]} [flags]
* @property {string} [url]
*/
/**
* @typedef {Object} ExtensionState
* @property {ExtensionIssue} [issue]
* @property {boolean} on
*/
if (typeof browser === 'undefined') {
browser = chrome;
}
/**
* @description Class for request batching
*/
class RequestManager {
constructor() {
this.requests = new Map();
}
/**
* @description Fetch wrapper to play with the request map
* @param {string} input
* @param {RequestInit} [init]
* @returns {Promise<any>}
*/
fetch(input, init) {
if (this.requests.has(input)) {
return this.requests.get(input);
}
const promise = fetch(input, init)
.then((response) => response.json())
.finally(() => this.requests.delete(input));
this.requests.set(input, promise);
return promise;
}
}
/**
* @description API URL
* @type {string}
*/
const apiUrl = 'https://api.cookie-dialog-monster.com/rest/v6';
/**
* @description Context menu identifier
* @type {string}
*/
const extensionMenuItemId = 'CDM-MENU';
/**
* @description Context menu identifier
* @type {string}
*/
const reportMenuItemId = 'CDM-REPORT';
/**
* @description Request manager instance
*/
const requestManager = new RequestManager();
/**
* @description Context menu identifier
* @type {string}
*/
const settingsMenuItemId = 'CDM-SETTINGS';
/**
* @description A shortcut for browser.scripting
* @type {browser.scripting}
*/
const script = browser.scripting;
/**
* @description Default value for extension state
* @type {ExtensionState}
*/
const stateByDefault = { issue: undefined, on: true };
/**
* @description The storage to use
* @type {browser.storage.StorageArea}
*/
const storage = browser.storage.local;
/**
* @description Supress `browser.runtime.lastError`
*/
const suppressLastError = () => void browser.runtime.lastError;
/**
* @async
* @description Enable extension icon
* @param {number} tabId
* @returns {Promise<void>}
*/
async function enableIcon(hostname, tabId) {
const state = await getState(hostname);
const path = state.issue?.url ? '/assets/icons/warn.png' : '/assets/icons/on.png';
await browser.action.setIcon({ path, tabId }, suppressLastError);
}
/**
* @async
* @description Get database
* @returns {Promise<Object>}
*/
async function getData() {
const { data } = await storage.get('data');
if (!data) {
return await refreshData();
}
return data;
}
/**
* @description Get current hostname
* @param {string} url
* @returns {string}
*/
function getHostname(url) {
return new URL(url).hostname.split('.').slice(-3).join('.').replace('www.', '');
}
/**
* @async
* @description Get current active tab
* @returns {Promise<browser.tabs.Tab>}
*/
async function getTab() {
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
return tabs[0];
}
/**
* @async
* @description Get state for the given hostname
* @param {string} hostname
* @returns {Promise<ExtensionState>}
*/
async function getState(hostname) {
const { [hostname]: state = stateByDefault } = await storage.get(hostname);
state.issue = await refreshIssue(hostname);
return { ...stateByDefault, ...state };
}
/**
* @async
* @description Get latest version available for this extension
* @returns {Promise<string>}
*/
async function getLatestVersion() {
try {
const { data } = await requestManager.fetch(`${apiUrl}/version/`);
return data;
} catch {
return '';
}
}
/**
* @async
* @description Refresh data
* @param {number} [attempt]
* @returns {Promise<void>}
*/
async function refreshData(attempt = 1) {
if (attempt <= 3) {
try {
const { data } = await requestManager.fetch(`${apiUrl}/data/`);
await updateStore('data', data);
return data;
} catch {
return await refreshData(attempt + 1);
}
}
}
/**
* @async
* @description Refresh issues for the given hostname
* @param {string} hostname
* @param {number} [attempt]
* @returns {Promise<ExtensionIssue | undefined>}
*/
async function refreshIssue(hostname, attempt = 1) {
if (attempt <= 3) {
try {
const { data = {} } = await requestManager.fetch(`${apiUrl}/issues/${hostname}/`);
await updateStore(hostname, { issue: { flags: data.flags, url: data.url } });
return data;
} catch {
return await refreshIssue(hostname, attempt + 1);
}
}
}
/**
* @async
* @description Report given page
* @param {any} message
* @param {browser.tabs.Tab} tab
* @param {void?} callback
* @returns {Promise<void>}
*/
async function report(message) {
try {
const reason = message.reason;
const url = message.url;
const userAgent = message.userAgent;
const version = browser.runtime.getManifest().version;
const body = JSON.stringify({ reason, url, userAgent, version });
const headers = { 'Cache-Control': 'no-cache', 'Content-type': 'application/json' };
const requestInit = { body, headers, method: 'POST' };
return await requestManager.fetch(`${apiUrl}/report/`, requestInit);
} catch {
console.error("Can't send report");
}
}
/**
* @async
* @description Update extension store for a given key
* @param {string} [key]
* @param {Object} value
* @returns {Promise<void>}
*/
async function updateStore(key, value) {
if (key) {
const { [key]: prev } = await storage.get(key);
await storage.set({ [key]: { ...prev, ...value } }, suppressLastError);
}
}
/**
* @description Listen to context menus clicked
*/
browser.contextMenus.onClicked.addListener((info) => {
switch (info.menuItemId) {
case reportMenuItemId:
browser.action.openPopup();
break;
case settingsMenuItemId:
browser.runtime.openOptionsPage();
break;
default:
break;
}
});
/**
* @description Listens to messages
*/
browser.runtime.onMessage.addListener((message, sender, callback) => {
const hostname = message.hostname;
const isPage = sender.frameId === 0;
const tabId = sender.tab?.id;
switch (message.type) {
case 'DISABLE_ICON':
if (isPage && tabId !== undefined) {
browser.action.setIcon({ path: '/assets/icons/off.png', tabId }, suppressLastError);
}
break;
case 'ENABLE_ICON':
if (isPage && tabId !== undefined) {
enableIcon(hostname, tabId).then(callback);
return true;
}
break;
case 'GET_DATA':
getData().then(callback);
return true;
case 'GET_EXCLUSION_LIST':
storage.get(null, (exclusions) => {
const exclusionList = Object.entries(exclusions || {}).flatMap((exclusion) => {
return exclusion[0] !== 'data' && exclusion[1].on === false ? [exclusion[0]] : [];
});
callback(exclusionList);
});
return true;
case 'GET_LATEST_VERSION':
getLatestVersion().then(callback);
return true;
case 'GET_STATE':
if (hostname) {
getState(hostname).then(callback);
return true;
}
break;
case 'GET_TAB':
getTab().then(callback);
return true;
case 'REFRESH_DATA':
refreshData().then(callback);
return true;
case 'REPORT':
report(message).then(callback);
return true;
case 'UPDATE_BADGE':
if (isPage && tabId !== undefined) {
browser.action.setBadgeBackgroundColor({ color: '#6b7280' });
browser.action.setBadgeText({ tabId, text: message.value ? `${message.value}` : null });
}
break;
case 'UPDATE_STORE':
updateStore(hostname, message.state).then(callback);
return true;
default:
break;
}
});
/**
* @description Listens to extension installed
*/
browser.runtime.onInstalled.addListener((details) => {
const documentUrlPatterns = browser.runtime.getManifest().content_scripts[0].matches;
browser.contextMenus.create(
{
contexts: ['all'],
documentUrlPatterns,
id: extensionMenuItemId,
title: 'Cookie Dialog Monster',
},
suppressLastError
);
browser.contextMenus.create(
{
contexts: ['all'],
documentUrlPatterns,
id: settingsMenuItemId,
parentId: extensionMenuItemId,
title: browser.i18n.getMessage('contextMenu_settingsOption'),
},
suppressLastError
);
browser.contextMenus.create(
{
contexts: ['all'],
documentUrlPatterns,
id: reportMenuItemId,
parentId: extensionMenuItemId,
title: browser.i18n.getMessage('contextMenu_reportOption'),
},
suppressLastError
);
if (details.reason === 'update') {
refreshData();
}
});
/**
* @description Listen to first start
*/
browser.runtime.onStartup.addListener(() => {
refreshData();
});
/**
* @description Listen to the moment before a request is made to apply the rules
* @returns {Promise<void>}
*/
browser.webRequest.onBeforeRequest.addListener(
async (details) => {
const { tabId, type, url } = details;
if (tabId > -1 && type === 'main_frame') {
const { exclusions, rules } = await getData();
if (exclusions.domains.some((x) => location.hostname.match(x.replaceAll(/\*/g, '[^ ]*')))) {
return;
}
const hostname = getHostname(url);
const state = await getState(hostname);
if (rules?.length) {
const rulesWithTabId = rules.map((rule) => ({
...rule,
condition: { ...rule.condition, tabIds: [tabId] },
}));
await browser.declarativeNetRequest.updateSessionRules({
addRules: state.on ? rulesWithTabId : undefined,
removeRuleIds: rules.map((rule) => rule.id),
});
}
}
},
{ urls: ['<all_urls>'] }
);
/**
* @description Listen for errors on network requests
*/
browser.webRequest.onErrorOccurred.addListener(
async (details) => {
const { error, tabId } = details;
if (error === 'net::ERR_BLOCKED_BY_CLIENT' && tabId > -1) {
await browser.tabs.sendMessage(tabId, { type: 'INCREASE_ACTIONS_COUNT', value: error });
}
},
{ urls: ['<all_urls>'] }
);

View File

@ -1,519 +0,0 @@
/**
* @typedef {Object} Action
* @property {string} domain
* @property {string} name
* @property {string} [property]
* @property {string} selector
*/
/**
* @typedef {Object} ContentState
* @property {boolean} on
*/
/**
* @typedef {Object} ExclusionMap
* @property {string[]} domains
* @property {string[]} overflows
* @property {string[]} tags
*/
/**
* @typedef {Object} ExtensionData
* @property {Action[]} actions
* @property {ExclusionMap} exclusions
* @property {string[]} keywords
* @property {TokenMap} tokens
*/
/**
* @typedef {Object} GetElementsParams
* @property {boolean} [filterEarly]
* @property {HTMLElement} [from]
*/
/**
* @typedef {Object} RunParams
* @property {HTMLElement[]} [containers]
* @property {HTMLElement[]} [elements]
* @property {boolean} [skipMatch]
*/
/**
* @typedef {Object} TokenMap
* @property {string[]} backdrops
* @property {string[]} classes
* @property {string[]} containers
* @property {string[]} selectors
*/
/**
* @typedef {Object} SetUpParams
* @property {boolean} [skipRunFn]
*/
if (typeof browser === 'undefined') {
browser = chrome;
}
/**
* @description Class for request batching
*/
class NotifiableSet extends Set {
constructor(...args) {
super(...args);
}
add(value) {
super.add(value);
browser.runtime.sendMessage({ type: 'UPDATE_BADGE', value: super.size });
}
}
/**
* @description Data object with all the necessary information
* @type {ExtensionData}
*/
let { actions, exclusions, keywords, tokens } = {
actions: [],
exclusions: {
domains: [],
overflows: [],
tags: [],
},
keywords: [],
tokens: {
backdrops: [],
classes: [],
selectors: [],
},
};
/**
* @description Shortcut to send messages to background script
*/
const dispatch = browser.runtime.sendMessage;
/**
* @description Current hostname
* @type {string}
*/
const hostname = getHostname();
/**
* @description Initial visibility state
* @type {boolean}
*/
let initiallyVisible = false;
/**
* @description Log of those steps done by the extension
* @type {NotifiableSet<string>}
*/
const log = new NotifiableSet();
/**
* @description Options provided to observer
* @type {MutationObserverInit}
*/
const options = { childList: true, subtree: true };
/**
* @description Elements that were already matched and are removable
* @type {Set<HTMLElement>}
*/
const seen = new Set();
/**
* @description Extension state
* @type {ContentState | undefined}
*/
let state = undefined;
/**
* @description Clean DOM
* @param {Element[]} elements
* @param {boolean} [skipMatch]
* @returns {void}
*/
function clean(elements, skipMatch) {
let index = 0;
const size = 50;
function chunk() {
const end = Math.min(index + size, elements.length);
for (; index < end; index++) {
const element = elements[index];
if (match(element, skipMatch)) {
if (element instanceof HTMLDialogElement) element.close();
hide(element);
log.add(`${Date.now()}`);
}
seen.add(element);
}
if (index < elements.length) {
requestAnimationFrame(chunk);
}
}
requestAnimationFrame(chunk);
}
/**
* @description Check if element contains a keyword
* @param {HTMLElement} element
*/
function hasKeyword(element) {
return !!keywords?.length && !!element.outerHTML.match(new RegExp(keywords.join('|')));
}
/**
* @description Force a DOM clean in the specific element
* @param {HTMLElement} from
* @returns {void}
*/
function forceClean(from) {
const elements = getElements(tokens.selectors, { filterEarly: true, from });
if (elements.length) {
fix();
clean(elements, true);
}
}
/**
* Get all elements that match the selector
* @param {string | string[]} [selector]
* @param {GetElementsParams} [params]
* @returns {HTMLElement[]}
*/
function getElements(selector, params = {}) {
const { filterEarly, from } = params;
let result = [];
if (selector?.length) {
result = [...(from ?? document).querySelectorAll(selector)];
if (filterEarly) {
result = result.flatMap((node) => filterNodeEarly(node));
}
}
return result;
}
/**
* Get all elements with their children that match the selector
* @param {string | string[]} selector
* @param {GetElementsParams} [params]
* @returns {HTMLElement[]}
*/
function getElementsWithChildren(selector, params) {
return getElements(selector, params).flatMap((element) => [element, ...element.children]);
}
/**
* @description Calculate current hostname
* @returns {string}
*/
function getHostname() {
let hostname = document.location.hostname;
const referrer = document.referrer;
if (referrer && window.self !== window.top) {
hostname = new URL(referrer).hostname;
}
return hostname.split('.').slice(-3).join('.').replace('www.', '');
}
/**
* @async
* @description Run if the page wasn't visited yet
* @param {Object} message
* @returns {Promise<void>}
*/
function handleRuntimeMessage(message) {
switch (message.type) {
case 'INCREASE_ACTIONS_COUNT': {
log.add(message.value);
break;
}
}
}
/**
* @description Check if an element is visible in the viewport
* @param {HTMLElement} element
* @returns {boolean}
*/
function isInViewport(element) {
const styles = window.getComputedStyle(element);
const height = window.innerHeight || document.documentElement.clientHeight;
const position = element.getBoundingClientRect();
const scroll = window.scrollY;
return (
position.bottom === position.top ||
(scroll + position.top <= scroll + height && scroll + position.bottom >= scroll) ||
styles.animationDuration !== '0s' ||
styles.transitionDuration !== '0s'
);
}
/**
* @description Check if element element is removable
* @param {Element} element
* @param {boolean} [skipMatch]
* @returns {boolean}
*/
function match(element, skipMatch) {
if (!exclusions.tags.length || !tokens.selectors.length) {
return false;
}
if (!(element instanceof HTMLElement) || !element.tagName) {
return false;
}
if (seen.has(element)) {
return false;
}
const tagName = element.tagName.toUpperCase();
if (exclusions.tags.includes(tagName)) {
return false;
}
const hasAttributes = !!element.getAttributeNames().filter((x) => x !== 'data-nosnippet').length;
if (!hasAttributes && !tagName.includes('-')) {
forceClean(element);
}
// 2023-06-10: fix #113 temporarily
if (element.classList.contains('chat-line__message')) {
return false;
}
// 2024-08-03: fix #701 temporarily
if (element.classList.contains('sellos')) {
return false;
}
const isDialog = tagName === 'DIALOG' && element.getAttribute('open') === 'true';
const isFakeDialog = tagName === 'DIV' && element.className.includes('cmp');
return (
(isDialog || isFakeDialog || isInViewport(element)) &&
(skipMatch || element.matches(tokens.selectors))
);
}
/**
* @description Filter early nodes
* @param {Node} node
* @param {boolean} stopRecursion
* @returns {HTMLElement[]}
*/
function filterNodeEarly(node, stopRecursion) {
if (node.nodeType !== Node.ELEMENT_NODE || !(node instanceof HTMLElement)) {
return [];
}
if (hasKeyword(node) && !stopRecursion) {
return [node, ...[...node.children].flatMap((node) => filterNodeEarly(node, true))];
}
return [node];
}
/**
* @description Fix specific cases
* @returns {void}
*/
function fix() {
for (const action of actions) {
const { domain, name, property, selector } = action;
if (hostname.match(domain.replaceAll(/\*/g, '[^ ]*'))) {
switch (name) {
case 'click': {
const element = document.querySelector(selector);
element?.click();
log.add(name);
break;
}
case 'remove': {
const element = document.querySelector(selector);
element?.style?.removeProperty(property);
log.add(name);
break;
}
case 'reload': {
window.location.reload();
break;
}
case 'reset': {
const element = document.querySelector(selector);
element?.style?.setProperty(property, 'initial', 'important');
log.add(name);
break;
}
case 'resetAll': {
const elements = getElements(selector);
elements.forEach((e) => e?.style?.setProperty(property, 'initial', 'important'));
log.add(name);
break;
}
}
}
}
const backdrops = getElements(tokens.backdrops);
for (const backdrop of backdrops) {
if (backdrop.children.length === 0 && !seen.has(backdrop)) {
log.add(`${Date.now()}`);
seen.add(backdrop);
hide(backdrop);
}
}
const skips = exclusions.overflows.map((x) => (x.split('.').length < 3 ? `*${x}` : x));
if (!skips.some((x) => hostname.match(x.replaceAll(/\*/g, '[^ ]*')))) {
for (const element of [document.body, document.documentElement]) {
element?.classList.remove(...(tokens.classes ?? []));
element?.style.setProperty('position', 'initial', 'important');
element?.style.setProperty('overflow-y', 'initial', 'important');
}
}
const ionRouterOutlet = document.getElementsByTagName('ion-router-outlet')[0];
if (ionRouterOutlet) {
// 2024-08-02: fix #644 temporarily
ionRouterOutlet.removeAttribute('inert');
log.add('ion-router-outlet');
}
const t4Wrapper = document.getElementsByClassName('t4-wrapper')[0];
if (t4Wrapper) {
log.add('t4-wrapper');
// 2024-09-12: fix #945 temporarily
t4Wrapper.removeAttribute('inert');
}
}
/**
* @description Hide DOM element
* @param {HTMLElement} element
* @returns {void}
*/
function hide(element) {
element.style.setProperty('clip-path', 'circle(0px)', 'important');
element.style.setProperty('display', 'none', 'important');
element.style.setProperty('height', '0px', 'important');
element.style.setProperty('overflow', 'hidden', 'important');
element.style.setProperty('transform', 'scale(0)', 'important');
}
/**
* @description Clean DOM when this function is called
* @param {RunParams} [params]
* @returns {void}
*/
function run(params = {}) {
const { containers, elements, skipMatch } = params;
if (document.body?.children.length && state.on && tokens.selectors.length) {
fix();
if (elements?.length) {
clean(elements, skipMatch);
}
if (elements === undefined && containers?.length) {
clean(containers.flatMap((x) => getElementsWithChildren(x, { filterEarly: true })));
}
}
}
/**
* @async
* @description Set up the extension
* @param {SetUpParams} [params]
* @returns {Promise<void>}
*/
async function setUp(params = {}) {
const data = await dispatch({ hostname, type: 'GET_DATA' });
exclusions = data?.exclusions ?? exclusions;
if (exclusions.domains.some((x) => location.hostname.match(x.replaceAll(/\*/g, '[^ ]*')))) {
dispatch({ type: 'DISABLE_ICON' });
observer.disconnect();
return;
}
state = await dispatch({ hostname, type: 'GET_STATE' });
if (state.on) {
browser.runtime.onMessage.addListener(handleRuntimeMessage);
dispatch({ hostname, type: 'ENABLE_ICON' });
actions = data?.actions ?? actions;
keywords = data?.keywords ?? keywords;
tokens = data?.tokens ?? tokens;
observer.observe(document.body ?? document.documentElement, options);
if (!params.skipRunFn) run({ containers: tokens.containers });
}
}
/**
* @description Wait for the body to exist
* @returns {Promise<void>}
*/
async function setUpAfterWaitForBody() {
if (document.visibilityState === 'visible' && !initiallyVisible) {
if (document.body) {
initiallyVisible = true;
await setUp();
return;
}
setTimeout(setUpAfterWaitForBody, 50);
}
}
/**
* @description Mutation Observer instance
* @type {MutationObserver}
*/
const observer = new MutationObserver((mutations) => {
if (!state.on || !tokens.selectors.length) {
return;
}
const nodes = mutations.flatMap((mutation) => [...mutation.addedNodes]);
const elements = nodes.flatMap((node) => filterNodeEarly(node));
run({ elements });
});
document.addEventListener('visibilitychange', setUpAfterWaitForBody);
window.addEventListener('pageshow', setUpAfterWaitForBody);
setUpAfterWaitForBody();

View File

@ -1,275 +0,0 @@
if (typeof browser === 'undefined') {
browser = chrome;
}
/**
* @description Shortcut to send messages to background script
*/
const dispatch = browser.runtime.sendMessage;
/**
* @description RegExp for matching domains
*/
const domainRegExp = /^(?!-)[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z]{2,})+$/;
/**
* @description Exclusion list, URLs where the user prefers to disable the extension
* @type {string[]}
*/
let exclusionList = [];
/**
* @description Render exclusion items into exclusion list
* @returns {void}
*/
function createList() {
const emptyItemElement = document.getElementById('exclusion-list-item-empty');
const exclusionListElement = document.getElementById('exclusion-list');
const exclusionListItemTemplateElement = document.getElementById('exclusion-list-item-template');
Array.from(exclusionListElement.querySelectorAll('[data-value]')).forEach((exclusionItem) => {
exclusionItem.remove();
});
if (exclusionList.length) {
for (const exclusionValue of exclusionList) {
const ariaLabelOrTitle = `Delete ${exclusionValue}`;
const itemElement = exclusionListItemTemplateElement.cloneNode(true);
const deleteButtonElement = itemElement.getElementsByTagName('button')[0];
deleteButtonElement.addEventListener('click', handleDeleteClick);
deleteButtonElement.setAttribute('aria-label', ariaLabelOrTitle);
deleteButtonElement.setAttribute('title', ariaLabelOrTitle);
itemElement.removeAttribute('id');
itemElement.getElementsByTagName('span')[0].innerText = exclusionValue;
itemElement.setAttribute('data-value', exclusionValue);
itemElement.style.removeProperty('display');
exclusionListElement.appendChild(itemElement);
}
} else {
emptyItemElement.innerText = "You don't have any exclusions yet";
emptyItemElement.style.removeProperty('display');
}
}
/**
* @async
* @description Add a new item to the exclusion list
* @returns {Promise<void>}
*/
async function handleAddClick() {
const message = browser.i18n.getMessage('options_addPrompt');
const value = window.prompt(message)?.trim().replace('www.', '');
if (value && (domainRegExp.test(value) || value === 'localhost')) {
const filterInputElement = document.getElementById('filter-input');
const state = { on: false };
await dispatch({ hostname: value, state, type: 'UPDATE_STORE' });
exclusionList = [...new Set([...exclusionList, value])].sort();
createList();
updateList(filterInputElement.value.trim());
}
}
/**
* @async
* @description Clear all items from the exclusion list
* @returns {Promise<void>}
*/
async function handleClearClick() {
const filterInputElement = document.getElementById('filter-input');
for (const exclusionValue of exclusionList) {
const state = { on: true };
await dispatch({ hostname: exclusionValue, state, type: 'UPDATE_STORE' });
}
exclusionList = [];
createList();
updateList(filterInputElement.value.trim());
}
/**
* @async
* @description Setup handlers and items
*/
async function handleContentLoaded() {
exclusionList = await dispatch({ type: 'GET_EXCLUSION_LIST' });
createList();
const addButtonElement = document.getElementById('add-button');
addButtonElement.addEventListener('click', handleAddClick);
const clearButtonElement = document.getElementById('clear-button');
clearButtonElement.addEventListener('click', handleClearClick);
const exportButtonElement = document.getElementById('export-button');
exportButtonElement.addEventListener('click', handleExportClick);
const fileInputElement = document.getElementById('file-input');
fileInputElement.addEventListener('change', handleFileChange);
const filterInputElement = document.getElementById('filter-input');
filterInputElement.addEventListener('keydown', handleFilterKeyDown);
const importButtonElement = document.getElementById('import-button');
importButtonElement.addEventListener('click', handleImportClick);
translate();
}
/**
* @async
* @description Delete the clicked element from the exclusion list
* @param {MouseEvent} event
* @returns {Promise<void>}
*/
async function handleDeleteClick(event) {
const filterInputElement = document.getElementById('filter-input');
const { value } = event.currentTarget.parentElement.dataset;
const itemElement = document.querySelector(`[data-value="${value}"]`);
const state = { on: true };
await dispatch({ hostname: value, state, type: 'UPDATE_STORE' });
exclusionList = exclusionList.filter((exclusionValue) => exclusionValue !== value);
itemElement?.remove();
updateList(filterInputElement.value.trim());
}
/**
* @description Export a file with the current exclusion list
* @returns {void}
*/
function handleExportClick() {
const anchor = document.createElement('a');
const now = new Date();
const day = now.getDate().toString().padStart(2, '0');
const month = now.getMonth().toString().padStart(2, '0');
const year = now.getUTCFullYear();
const text = exclusionList.join('\n');
const defaultTitle = `${year}${month}${day}`;
const customTitle = window.prompt('Enter a file name', defaultTitle);
const blob = new Blob([text], { type: 'octet/stream' });
const url = window.URL.createObjectURL(blob);
anchor.href = url;
anchor.download = `${customTitle || defaultTitle}.cdm`;
anchor.click();
window.URL.revokeObjectURL(url);
}
/**
* @description Process a file and send the updates
* @param {InputEvent} event
* @returns {void}
*/
function handleFileChange(event) {
const file = event.currentTarget.files[0];
const filterInputElement = document.getElementById('filter-input');
const reader = new FileReader();
reader.addEventListener('load', async (event) => {
const input = event.currentTarget.result.split('\n');
const exclusions = [];
for (let value of input) {
value = value.replace('www.', '');
if (value && (domainRegExp.test(value) || value === 'localhost')) {
const state = { on: false };
await dispatch({ hostname: value, state, type: 'UPDATE_STORE' });
exclusions.push(value);
}
}
exclusionList = [...new Set([...exclusionList, ...exclusions])].sort();
createList();
updateList(filterInputElement.value.trim());
});
event.currentTarget.value = '';
reader.readAsText(file);
}
/**
* @description Apply filter to the exclusion list when the user presses ENTER key
* @param {KeyboardEvent} event
* @returns {void}
*/
function handleFilterKeyDown(event) {
if (event.key === 'Enter') {
const filterValue = event.currentTarget.value.trim();
updateList(filterValue);
}
}
/**
* @description Shallow click an hidden input to open the file explorer
* @returns {void}
*/
function handleImportClick() {
const fileInputElement = document.getElementById('file-input');
fileInputElement.click();
}
/**
* @description Apply translations to tags with i18n data attribute
* @returns {void}
*/
function translate() {
const nodes = document.querySelectorAll('[data-i18n], [data-i18n-placeholder]');
for (let i = nodes.length; i--; ) {
const node = nodes[i];
const { i18n, i18nPlaceholder } = node.dataset;
if (i18n) {
node.innerHTML = browser.i18n.getMessage(i18n);
}
if (i18nPlaceholder) {
node.setAttribute('placeholder', browser.i18n.getMessage(i18nPlaceholder));
}
}
}
/**
* @description Update exclusion items in DOM
* @param {string | undefined} filterValue
* @returns {void}
*/
function updateList(filterValue) {
const emptyItemElement = document.getElementById('exclusion-list-item-empty');
const exclusionListElement = document.getElementById('exclusion-list');
const exclusionListElements = exclusionListElement.querySelectorAll(`[data-value]`);
if (exclusionListElements.length) {
let isEmpty = true;
emptyItemElement.style.setProperty('display', 'none');
for (const exclusionItemElement of Array.from(exclusionListElements)) {
if (exclusionItemElement.matches(`[data-value*="${filterValue}"]`) || !filterValue) {
exclusionItemElement.style.removeProperty('display');
isEmpty = false;
} else {
exclusionItemElement.style.setProperty('display', 'none');
}
}
if (isEmpty) {
emptyItemElement.innerText = 'No exclusions found';
emptyItemElement.style.removeProperty('display');
}
} else {
emptyItemElement.innerText = "You don't have any exclusions yet";
emptyItemElement.style.removeProperty('display');
}
}
/**
* @description Listen to document ready
* @listens document#DOMContentLoaded
*/
document.addEventListener('DOMContentLoaded', handleContentLoaded);

View File

@ -1,425 +0,0 @@
/**
* @typedef {Object} ExtensionState
* @property {ExtensionIssue} [issue]
* @property {boolean} on
*/
/**
* @typedef {Object} PopupState
* @extends {ExtensionState}
* @property {number} [tabId]
*/
if (typeof browser === 'undefined') {
browser = chrome;
}
/**
* @description Chrome Web Store link
* @type {string}
*/
const chromeUrl = 'https://chrome.google.com/webstore/detail/djcbfpkdhdkaflcigibkbpboflaplabg';
/**
* @description Shortcut to send messages to background script
*/
const dispatch = browser.runtime.sendMessage;
/**
* @description Edge Add-ons link
* @type {string}
*/
const edgeUrl =
'https://microsoftedge.microsoft.com/addons/detail/hbogodfciblakeneadpcolhmfckmjcii';
/**
* @description Firefox Add-ons link
* @type {string}
*/
const firefoxUrl = 'https://addons.mozilla.org/firefox/addon/cookie-dialog-monster';
/**
* @description Current hostname
* @type {string}
*/
let hostname = '?';
/**
* @description Is current browser an instance of Chromium?
* @type {boolean}
*/
const isChromium = navigator.userAgent.indexOf('Chrome') !== -1;
/**
* @description Is current browser an instance of Edge?
* @type {boolean}
*/
const isEdge = navigator.userAgent.indexOf('Edg') !== -1;
/**
* @description Is current browser an instance of Firefox?
* @type {boolean}
*/
const isFirefox = navigator.userAgent.indexOf('Firefox') !== -1;
/**
* @description Popup state
* @type {PopupState}
*/
let state = { on: true };
/**
* @description Close report form
* @returns {void}
*/
function handleCancelClick() {
const content = document.getElementsByClassName('content')[0];
const report = document.getElementsByClassName('report')[0];
if (content instanceof HTMLElement && report instanceof HTMLElement) {
content.style.removeProperty('display');
report.style.display = 'none';
}
}
/**
* @async
* @description Setup stars handlers and result message links
* @returns {Promise<void>}
*/
async function handleContentLoaded() {
const tab = await dispatch({ type: 'GET_TAB' });
const url = tab?.url ? new URL(tab.url) : undefined;
hostname = url?.hostname.split('.').slice(-3).join('.').replace('www.', '');
state = { ...((await dispatch({ hostname, type: 'GET_STATE' })) ?? state), tabId: tab?.id };
const hostTextElement = document.getElementById('host');
hostTextElement.innerText = hostname ?? 'unknown';
const contributeButtonElement = document.getElementById('contribute-option');
contributeButtonElement?.addEventListener('click', handleLinkRedirect);
const databaseRefreshButtonElement = document.getElementById('refresh-database-button');
databaseRefreshButtonElement?.addEventListener('click', handleDatabaseRefresh);
const extensionVersionElement = document.getElementById('extension-version');
extensionVersionElement.innerText = browser.runtime.getManifest().version;
const helpButtonElement = document.getElementById('help-button');
helpButtonElement?.addEventListener('click', handleLinkRedirect);
const rateButtonElement = document.getElementById('rate-option');
rateButtonElement?.addEventListener('click', handleLinkRedirect);
if (isEdge) rateButtonElement?.setAttribute('data-href', edgeUrl);
else if (isChromium) rateButtonElement?.setAttribute('data-href', chromeUrl);
else if (isFirefox) rateButtonElement?.setAttribute('data-href', firefoxUrl);
const settingsButtonElement = document.getElementById('settings-button');
settingsButtonElement?.addEventListener('click', handleSettingsClick);
translate();
await updateDatabaseVersion();
const { exclusions } = (await dispatch({ hostname, type: 'GET_DATA' })) ?? {};
const currentVersion = browser.runtime.getManifest().version;
const latestVersion = await dispatch({ type: 'GET_LATEST_VERSION' });
const updateAvailable = latestVersion && currentVersion !== latestVersion;
if (updateAvailable) {
const updateBanner = document.getElementById('update-banner');
updateBanner.removeAttribute('aria-hidden');
const updateBannerUrl = document.getElementById('update-banner-url');
updateBannerUrl.href += `/tag/${latestVersion}`;
}
const loader = document.getElementById('loader');
loader.style.setProperty('display', 'none');
if (exclusions?.domains.some((x) => url.hostname.match(x.replaceAll(/\*/g, '[^ ]*')))) {
const supportBanner = document.getElementById('support-banner');
supportBanner.removeAttribute('aria-hidden');
return;
}
const powerButtonElement = document.getElementById('power-option');
powerButtonElement?.addEventListener('click', handlePowerToggle);
powerButtonElement?.removeAttribute('disabled');
if (state.on) powerButtonElement?.setAttribute('data-value', 'on');
else powerButtonElement?.setAttribute('data-value', 'off');
if (state.issue?.url) {
const issueBanner = document.getElementById('issue-banner');
issueBanner.removeAttribute('aria-hidden');
const issueBannerText = document.getElementById('issue-banner-text');
if (state.issue.flags.includes('wontfix'))
issueBannerText.innerText = browser.i18n.getMessage('popup_bannerIssueWontFix');
else issueBannerText.innerText = browser.i18n.getMessage('popup_bannerIssueOpen');
const issueBannerUrl = document.getElementById('issue-banner-url');
issueBannerUrl.setAttribute('href', state.issue.url);
return;
}
const cancelButtonElement = document.getElementsByClassName('report-cancel-button')[0];
cancelButtonElement?.addEventListener('click', handleCancelClick);
const reasonInputElement = document.getElementById('report-input-reason');
reasonInputElement?.addEventListener('input', handleInputChange);
reasonInputElement?.addEventListener('keydown', handleInputKeyDown);
const reportButtonElement = document.getElementById('report-option');
reportButtonElement?.addEventListener('click', handleReportClick);
reportButtonElement?.removeAttribute('disabled');
const submitButtonElement = document.getElementsByClassName('report-submit-button')[0];
submitButtonElement?.addEventListener('click', handleSubmitButtonClick);
const urlInputElement = document.getElementById('report-input-url');
urlInputElement?.addEventListener('input', handleInputChange);
urlInputElement?.addEventListener('keydown', handleInputKeyDown);
if (url) urlInputElement?.setAttribute('value', `${url.origin}${url.pathname}`);
}
/**
* @async
* @description Refresh the database
* @param {MouseEvent} event
*/
async function handleDatabaseRefresh(event) {
const target = event.currentTarget;
if (target.getAttribute('aria-disabled') === 'true') {
return;
}
const checkIcon = target.querySelector('#refresh-database-check');
const spinnerIcon = target.querySelector('#refresh-database-spinner');
target.setAttribute('data-refreshing', 'true');
target.setAttribute('aria-disabled', 'true');
await dispatch({ type: 'REFRESH_DATA' });
checkIcon.style.setProperty('display', 'block');
spinnerIcon.style.setProperty('display', 'none');
target.removeAttribute('data-animation');
target.removeAttribute('data-refreshing');
await updateDatabaseVersion();
window.setTimeout(() => {
checkIcon.style.setProperty('display', 'none');
spinnerIcon.style.setProperty('display', 'block');
target.removeAttribute('aria-disabled');
target.setAttribute('data-animation', 'flip');
}, 5000);
}
/**
* @description Input change handler
* @param {InputEvent} event
*/
function handleInputChange(event) {
event.currentTarget.removeAttribute('aria-invalid');
}
/**
* @description Input key down handler
* @param {KeyboardEvent} event
*/
function handleInputKeyDown(event) {
if (event.key === 'Enter') {
event.preventDefault();
event.currentTarget.blur();
}
}
/**
* @async
* @description Open a new tab
* @param {MouseEvent} event
* @returns {Promise<void>}
*/
async function handleLinkRedirect(event) {
const { href } = event.currentTarget.dataset;
if (href) {
await browser.tabs.create({ url: href });
window.close();
}
}
/**
* @async
* @description Disable or enable extension on current page
* @returns {void}
*/
async function handlePowerToggle() {
const next = { ...state, on: !state.on };
await dispatch({ hostname, state: next, type: 'UPDATE_STORE' });
await browser.tabs.reload(state.tabId, { bypassCache: true });
window.close();
}
/**
* @description Show report form
* @returns {void}
*/
function handleReportClick() {
const content = document.getElementsByClassName('content')[0];
const report = document.getElementsByClassName('report')[0];
if (content instanceof HTMLElement && report instanceof HTMLElement) {
content.style.display = 'none';
report.style.removeProperty('display');
}
}
/**
* @async
* @description Open options page
* @returns {Promise<void>}
*/
async function handleSettingsClick() {
await browser.runtime.openOptionsPage();
}
/**
* @async
* @description Report submit button click handler
* @param {MouseEvent} event
*/
async function handleSubmitButtonClick(event) {
event.preventDefault();
if (event.currentTarget.getAttribute('aria-disabled') === 'true') {
return;
}
event.currentTarget.setAttribute('aria-disabled', 'true');
const reasonInput = document.getElementById('report-input-reason');
const reasonText = reasonInput?.value.trim();
const urlInput = document.getElementById('report-input-url');
const urlText = urlInput?.value.trim();
const errors = validateForm({ reason: reasonText, url: urlText });
if (errors) {
if (errors.reason) {
reasonInput?.setAttribute('aria-invalid', 'true');
reasonInput?.setAttribute('aria-errormessage', 'report-input-reason-error');
}
if (errors.url) {
urlInput?.setAttribute('aria-invalid', 'true');
urlInput?.setAttribute('aria-errormessage', 'report-input-url-error');
}
event.currentTarget.setAttribute('aria-disabled', 'false');
return;
}
const issueButtons = document.getElementsByClassName('report-issue-button');
const formView = document.getElementsByClassName('report-form-view')[0];
const userAgent = window.navigator.userAgent;
const response = await dispatch({ userAgent, reason: reasonText, url: urlText, type: 'REPORT' });
const hostname = new URL(urlText).hostname.split('.').slice(-3).join('.').replace('www.', '');
const issue = { expiresIn: Date.now() + 8 * 60 * 60 * 1000, flags: ['bug'], url: response.data };
if (response.success) {
const successView = document.getElementsByClassName('report-submit-success-view')[0];
await dispatch({ hostname, state: { issue }, type: 'UPDATE_STORE' });
await dispatch({ hostname, type: 'ENABLE_ICON' });
formView?.setAttribute('hidden', 'true');
issueButtons[1]?.addEventListener('click', () => window.open(response.data, '_blank'));
successView?.removeAttribute('hidden');
return;
}
if (response.data) {
const errorView = document.getElementsByClassName('report-submit-error-view')[0];
if (response.errors?.some((error) => error.includes('wontfix'))) {
issue.flags.push('wontfix');
}
await dispatch({ hostname, state: { issue }, type: 'UPDATE_STORE' });
errorView?.removeAttribute('hidden');
formView?.setAttribute('hidden', 'true');
issueButtons[0]?.addEventListener('click', () => window.open(response.data, '_blank'));
return;
}
window.close();
}
/**
* @description Apply translations to tags with i18n data attribute
* @returns {void}
*/
function translate() {
const nodes = document.querySelectorAll('[data-i18n], [data-i18n-placeholder]');
for (let i = nodes.length; i--; ) {
const node = nodes[i];
const { i18n, i18nPlaceholder } = node.dataset;
if (i18n) {
node.innerHTML = browser.i18n.getMessage(i18n);
}
if (i18nPlaceholder) {
node.setAttribute('placeholder', browser.i18n.getMessage(i18nPlaceholder));
}
}
}
/**
* @async
* @description Update the database version element
* @returns {Promise<void>}
*/
async function updateDatabaseVersion() {
const data = await dispatch({ hostname, type: 'GET_DATA' });
const databaseVersionElement = document.getElementById('database-version');
if (data.version) databaseVersionElement.innerText = data.version;
else databaseVersionElement.style.setProperty('display', 'none');
}
/**
* @description Validate form
* @param {{ reason: string | undefined | undefined, url: string | undefined }} params
* @returns {{ reason: string | undefined, url: string | undefined } | undefined}
*/
function validateForm(params) {
const { reason, url } = params;
let errors = undefined;
if (!reason || reason.length < 10 || reason.length > 1000) {
errors = {
...(errors ?? {}),
reason: browser.i18n.getMessage('report_reasonInputError'),
};
}
try {
if (/\s/.test(url)) throw new Error();
new URL(url);
} catch {
errors = {
...(errors ?? {}),
url: browser.i18n.getMessage('report_urlInputError'),
};
}
return errors;
}
/**
* @description Listen to document ready
* @listens document#DOMContentLoaded
*/
document.addEventListener('DOMContentLoaded', handleContentLoaded);

View File

@ -0,0 +1,15 @@
:root {
--color-error: #cc0000;
--color-primary: #3dd9eb;
--color-secondary: #34495e;
--color-success: #5cb85c;
--color-tertiary: #6b7280;
--color-transparent: transparent;
--color-warning: #ffdf00;
--color-white: #ffffff;
}
body * {
box-sizing: border-box;
font-family: Inter, Arial, Helvetica, sans-serif;
}

View File

@ -1,29 +1,4 @@
:root {
--color-error: #cc0000;
--color-primary: #3dd9eb;
--color-secondary: #34495e;
--color-success: #5cb85c;
--color-tertiary: #6b7280;
--color-transparent: transparent;
--color-warning: #ffdf00;
--color-white: #ffffff;
}
body {
box-sizing: border-box;
color: var(--color-tertiary);
display: flex;
flex-direction: column;
font-family: Inter, Arial, Helvetica, sans-serif;
min-height: 100vh;
}
body * {
box-sizing: border-box;
font-family: inherit;
}
button {
.button {
align-items: center;
background-color: var(--color-white);
border: none;
@ -35,24 +10,24 @@ button {
transition: 0.4s;
}
button[data-variant='large'] {
.button:focus,
.button:hover {
background-color: var(--color-secondary);
color: var(--color-white);
}
.button[data-variant='large'] {
direction: rtl;
padding: 8px;
}
@media only screen and (max-device-width: 768px) {
button[data-variant='large'] {
.button[data-variant='large'] {
justify-content: flex-end;
}
}
button:focus,
button:hover {
background-color: var(--color-secondary);
color: var(--color-white);
}
footer {
.footer {
background-color: var(--color-secondary);
font-size: 12px;
height: 4px;
@ -60,14 +35,14 @@ footer {
text-align: center;
}
header {
.header {
background-color: var(--color-secondary);
color: var(--color-white);
font-size: 16px !important;
height: 48px;
}
header > div {
.header > div {
align-items: center;
display: flex;
height: 100%;
@ -75,7 +50,7 @@ header > div {
margin: auto 0px;
}
main input {
.input {
-webkit-appearance: none;
appearance: none;
background-color: var(--color-white);
@ -90,18 +65,18 @@ main input {
width: 100%;
}
main input::placeholder {
.input::placeholder {
color: var(--color-tertiary);
opacity: 1;
}
main input:focus,
main input:hover {
.input:focus,
.input:hover {
border-bottom: 1px solid var(--color-primary);
}
header > div,
main {
.header > div,
.main {
margin: 0px auto;
max-width: 768px;
padding: 16px;
@ -122,13 +97,13 @@ main {
}
}
#exclusion-list {
.exclusion-list {
font-size: 14px;
list-style: none;
padding: 0px;
}
#exclusion-list > li {
.exclusion-list > li {
align-items: center;
border-radius: 4px;
display: flex;
@ -137,14 +112,23 @@ main {
transition: 0.4s;
}
#exclusion-list > li:focus-within,
#exclusion-list > li:hover {
.exclusion-list > li:focus-within,
.exclusion-list > li:hover {
background-color: var(--color-secondary);
color: var(--color-white);
}
#exclusion-list > li > button {
.exclusion-list > button {
background-color: var(--color-white);
color: var(--color-error);
padding: 4px;
}
#__plasmo {
box-sizing: border-box;
color: var(--color-tertiary);
display: flex;
flex-direction: column;
font-family: Inter, Arial, Helvetica, sans-serif;
min-height: 100vh;
}

View File

@ -1,17 +1,3 @@
:root {
--color-error: #cc0000;
--color-primary: #3dd9eb;
--color-secondary: #34495e;
--color-success: #5cb85c;
--color-tertiary: #6b7280;
--color-warning: #ffdf00;
--color-white: #ffffff;
}
b {
font-weight: bold;
}
body {
box-sizing: border-box;
color: var(--color-tertiary);
@ -25,12 +11,42 @@ body {
}
}
body * {
box-sizing: border-box;
font-family: Inter, Arial, Helvetica, sans-serif;
.banner {
font-size: 12px;
line-height: 16px;
margin: 0px;
padding: 16px;
}
footer {
.banner[data-variant='error'] {
background-color: #e74c3c;
color: var(--color-white);
}
.banner[data-variant='notice'] {
background-color: #2196f3;
color: var(--color-white);
}
.banner[data-variant='warning'] {
background-color: #f39c12;
color: #c0392b;
}
.banner a {
color: inherit;
display: inline-block;
vertical-align: middle;
}
.content {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
padding: 16px;
}
.footer {
background-color: var(--color-secondary);
flex-shrink: 0;
font-size: 12px;
@ -39,7 +55,7 @@ footer {
text-align: center;
}
header {
.header {
align-items: center;
background-color: var(--color-secondary);
color: var(--color-white);
@ -49,59 +65,12 @@ header {
height: 48px;
justify-content: space-between;
padding: 0 16px;
}
& .header-actions {
.header-actions {
display: flex;
gap: 8px;
}
}
@media only screen and (max-device-width: 768px) {
main {
margin: 0 auto;
max-width: 320px;
width: 100%;
}
}
.banner {
font-size: 14px;
line-height: 18px;
margin: 0px;
padding: 16px;
&[aria-hidden='true'] {
display: none;
}
&[data-variant='error'] {
background-color: #e74c3c;
color: var(--color-white);
}
&[data-variant='notice'] {
background-color: #2196f3;
color: var(--color-white);
}
&[data-variant='warning'] {
background-color: #f39c12;
color: #c0392b;
}
& a {
color: inherit;
display: inline-block;
vertical-align: middle;
}
}
.content {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
padding: 16px;
}
.header-button {
align-items: center;
@ -114,12 +83,20 @@ header {
outline: none;
padding: 2px;
transition: 0.4s;
}
&:focus:not(:disabled),
&:hover:not(:disabled) {
.header-button:focus,
.header-button:hover {
background-color: var(--color-white);
color: var(--color-secondary);
}
@media only screen and (max-device-width: 768px) {
.main {
margin: 0 auto;
max-width: 320px;
width: 100%;
}
}
.popup-button {
@ -136,18 +113,20 @@ header {
outline: none;
padding: 8px;
text-align: center;
text-decoration: none;
transition: 0.4s;
width: 100%;
word-break: break-word;
}
&:focus,
&:hover {
.popup-button:focus,
.popup-button:hover {
box-shadow:
rgba(50, 50, 93, 0.25) 0px 2px 5px -1px,
rgba(0, 0, 0, 0.3) 0px 1px 3px -1px;
}
&:disabled {
.popup-button:disabled {
background-color: var(--color-tertiary);
box-shadow: none;
color: var(--color-white);
@ -155,14 +134,13 @@ header {
opacity: 0.5;
}
& span {
.popup-button span {
align-self: flex-start;
}
& svg {
.popup-button svg {
align-self: flex-end;
}
}
.popup-data {
align-items: center;
@ -170,10 +148,10 @@ header {
gap: 4px;
justify-content: center;
outline: none;
&:not(:first-child) {
margin-top: 4px;
}
.popup-data:not(:first-child) {
margin-top: 4px;
}
.popup-data-button {
@ -182,19 +160,20 @@ header {
line-height: 0;
outline: none;
transition: 0.4s;
}
&[aria-disabled='true'] {
.popup-data-button:disabled {
cursor: not-allowed;
}
&[data-animation='flip']:focus,
&[data-animation='flip']:hover {
.popup-data-button[data-animation='flip']:focus:not([data-refreshed='true']),
.popup-data-button[data-animation='flip']:hover:not([data-refreshed='true']) {
transform: rotate(-180deg);
}
&[data-refreshing='true'] {
.popup-data-button[data-refreshing='true'] {
animation: spin 1s linear infinite;
}
transform: none;
}
@keyframes spin {
@ -211,106 +190,44 @@ header {
text-align: center;
}
.power-option {
color: var(--color-white);
word-break: break-all;
}
.power-option[data-value='off']:not(:disabled) {
background-color: var(--color-error);
}
.power-option[data-value='on']:not(:disabled) {
background-color: var(--color-success);
}
.rate-option > svg {
transition: 0.4s;
}
.rate-option:focus > svg,
.rate-option:hover > svg {
color: var(--color-warning);
fill: var(--color-warning);
}
.refresh-database-check {
display: none;
}
.report {
font-size: 14px;
line-height: 18px;
padding: 16px;
}
& .report-buttons {
.report-buttons {
margin-top: auto;
}
& .report-form {
display: grid;
gap: 10px;
}
& .report-form-view {
display: flex;
flex-direction: column;
gap: 16px;
}
& .report-form-view[hidden] {
display: none;
}
& .report-input {
all: unset;
border: 1px solid var(--color-tertiary);
border-radius: 4px;
color: var(--color-secondary);
cursor: text;
font-size: 14px;
line-height: 1;
outline: none;
padding: 12px 8px;
}
& .report-input:hover {
border-color: var(--color-secondary);
}
& .report-input:focus {
border-color: var(--color-primary);
}
& .report-input:focus-visible {
box-shadow: initial;
transition: initial;
}
& .report-input::-webkit-scrollbar {
display: none;
}
& .report-input[aria-invalid='true'] {
border-color: var(--color-error);
}
& .report-input[aria-invalid='true'] + .report-input-error {
display: block;
}
& .report-input[aria-multiline='false'] {
-ms-overflow-style: none;
display: flex;
height: 40px;
overflow-x: auto;
scrollbar-width: none;
text-wrap: nowrap;
}
& .report-input[aria-multiline='true'] {
-ms-overflow-style: none;
height: 120px;
overflow-y: auto;
scrollbar-width: none;
}
& .report-input-error {
color: var(--color-error);
display: none;
font-size: 10px;
line-height: 14px;
}
& .report-input-group {
display: grid;
gap: 4px;
}
& .report-input-label {
color: var(--color-secondary);
font-size: 12px;
line-height: 16px;
}
& .report-input-label-required {
color: var(--color-error);
}
& .report-cancel-button {
.report-cancel-button {
align-items: center;
background-color: var(--color-white);
color: var(--color-secondary);
@ -327,13 +244,89 @@ header {
text-align: center;
}
& .report-cancel-button:focus,
& .report-cancel-button:hover {
.report-cancel-button:focus,
.report-cancel-button:hover {
color: var(--color-error);
}
& .report-issue-button,
& .report-submit-button {
.report-form {
display: grid;
gap: 10px;
}
.report-form-view {
display: flex;
flex-direction: column;
gap: 16px;
}
.report-input {
all: unset;
border: 1px solid var(--color-tertiary);
border-radius: 4px;
color: var(--color-secondary);
cursor: text;
font-size: 14px;
line-height: 1;
outline: none;
padding: 12px 8px;
}
.report-input[aria-invalid='true']:not(:disabled) {
border-color: var(--color-error);
}
.report-input[aria-invalid='true']:not(:disabled) + .report-input-error {
display: block;
}
.report-input:hover {
border-color: var(--color-secondary);
}
.report-input:focus {
border-color: var(--color-primary);
}
.report-input:focus-visible {
box-shadow: initial;
transition: initial;
}
.report-input::-webkit-scrollbar {
display: none;
}
.report-input:disabled {
border-color: var(--color-tertiary);
cursor: not-allowed;
opacity: 0.5;
}
.report-input-error {
color: var(--color-error);
display: none;
font-size: 10px;
line-height: 14px;
}
.report-input-group {
display: grid;
gap: 4px;
}
.report-input-label {
color: var(--color-secondary);
font-size: 12px;
line-height: 16px;
}
.report-input-label-required {
color: var(--color-error);
}
.report-issue-button,
.report-submit-button {
align-items: center;
background-color: var(--color-secondary);
border: 1px solid var(--color-secondary);
@ -352,44 +345,30 @@ header {
width: 100%;
}
& .report-issue-button:focus,
& .report-issue-button:hover,
& .report-submit-button:focus,
& .report-submit-button:hover {
.report-issue-button:focus,
.report-submit-button:focus,
.report-issue-button:hover,
.report-submit-button:hover {
background-color: var(--color-white);
color: var(--color-secondary);
}
& .report-issue-button:focus-visible,
& .report-submit-button:focus-visible {
.report-issue-button:focus-visible,
.report-submit-button:focus-visible {
box-shadow: initial;
transition: initial;
}
& .report-issue-button[aria-disabled='true'],
& .report-submit-button[aria-disabled='true'] {
.report-issue-button:disabled,
.report-submit-button:disabled {
background-color: var(--color-tertiary);
border: 1px solid var(--color-tertiary);
color: var(--color-white);
cursor: not-allowed;
}
& .report-submit-extra-text {
font-size: 14px;
line-height: 18px;
margin: 0px;
text-align: justify;
}
& .report-submit-text {
font-size: 18px;
line-height: 22px;
margin: 0px;
text-align: center;
}
& .report-submit-error-view,
& .report-submit-success-view {
.report-submit-error-view,
.report-submit-success-view {
align-items: center;
display: flex;
flex-direction: column;
@ -398,67 +377,16 @@ header {
margin-top: 16px;
}
& .report-submit-error-view[hidden],
& .report-submit-success-view[hidden] {
display: none;
}
.report-submit-extra-text {
font-size: 14px;
line-height: 18px;
margin: 0px;
text-align: justify;
}
#loader {
align-items: center;
background-color: var(--color-white);
bottom: 0px;
display: flex;
justify-content: center;
left: 0px;
position: fixed;
right: 0px;
top: 0px;
& > span {
animation: rotation 1s linear infinite;
border-bottom-color: var(--color-primary) !important;
border-radius: 50%;
border: 6px solid var(--color-secondary);
box-sizing: border-box;
display: inline-block;
height: 48px;
width: 48px;
}
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
#power-option {
color: var(--color-white);
word-break: break-all;
&[data-value='off'] {
background-color: var(--color-error);
}
&[data-value='on'] {
background-color: var(--color-success);
}
}
#rate-option > svg {
transition: 0.4s;
}
#rate-option:focus > svg,
#rate-option:hover > svg {
color: var(--color-warning);
fill: var(--color-warning);
}
#refresh-database-check {
display: none;
.report-submit-text {
font-size: 18px;
line-height: 22px;
margin: 0px;
text-align: center;
}

View File

@ -0,0 +1,40 @@
import type { DomainConfig, ExtensionData } from './types';
export const API_URL = 'https://api.cookie-dialog-monster.com/rest/v6';
export const CHROME_STORE_URL =
'https://chrome.google.com/webstore/detail/djcbfpkdhdkaflcigibkbpboflaplabg';
export const DEFAULT_DOMAIN_CONFIG: DomainConfig = {
issue: undefined,
on: true,
};
export const DEFAULT_EXTENSION_DATA: ExtensionData = {
actions: [],
exclusions: {
domains: [],
overflows: [],
tags: [],
},
keywords: [],
rules: [],
tokens: {
backdrops: [],
classes: [],
containers: [],
selectors: [],
},
version: '?.?.?',
};
export const EDGE_STORE_URL =
'https://microsoftedge.microsoft.com/addons/detail/hbogodfciblakeneadpcolhmfckmjcii';
export const EXTENSION_MENU_ITEM_ID = 'CDM-MENU';
export const FIREFOX_STORE_URL = 'https://addons.mozilla.org/firefox/addon/cookie-dialog-monster';
export const REPORT_MENU_ITEM_ID = 'CDM-REPORT';
export const SETTINGS_MENU_ITEM_ID = 'CDM-SETTINGS';

View File

@ -0,0 +1,15 @@
export const DOMAIN_REG_EXP = /^(?!-)[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z]{2,})+$/;
export function formatDomainFromURL(value: URL): string {
let result: string = value.hostname;
if (result.startsWith('www.')) {
result = result.replace('www.', '');
}
return result;
}
export function validateSupport(hostname: string, exclusions: readonly string[]): boolean {
return !exclusions.some((x) => hostname.match(x.replaceAll(/\*/g, '[^ ]*')));
}

View File

@ -0,0 +1,3 @@
export const noop = () => undefined;
export const suppressLastError = () => void chrome.runtime.lastError;

View File

@ -0,0 +1,3 @@
import { Storage } from '@plasmohq/storage';
export const storage = new Storage({ area: 'local' });

View File

@ -0,0 +1,49 @@
export interface Action {
readonly domain: string;
readonly name: string;
readonly property?: string;
readonly selector: string;
}
export interface DomainConfig {
readonly issue?: DomainIssue;
readonly on: boolean;
}
export interface DomainIssue {
readonly expiresAt?: number;
readonly flags?: readonly string[];
readonly url?: string;
}
export interface ExclusionMap {
readonly domains: readonly string[];
readonly overflows: readonly string[];
readonly tags: readonly string[];
}
export interface ExtensionData {
readonly actions: readonly Action[];
readonly exclusions: ExclusionMap;
readonly keywords: readonly string[];
readonly rules: readonly chrome.declarativeNetRequest.Rule[];
readonly tokens: TokenMap;
readonly version: string;
}
export interface ReportParams {
readonly reason: string;
readonly url: string;
}
export interface ReportResult {
readonly data: string;
readonly success: boolean;
}
export interface TokenMap {
readonly backdrops: readonly string[];
readonly classes: readonly string[];
readonly containers: readonly string[];
readonly selectors: readonly string[];
}

View File

@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import onClicked from '~background/utils/contextMenus/onClicked';
import onInstalled from '~background/utils/runtime/onInstalled';
import onStartup from '~background/utils/runtime/onStartup';
import onBeforeRequest from '~background/utils/webRequest/onBeforeRequest';
import onErrorOccurred from '~background/utils/webRequest/onErrorOccurred';
describe('background/index.ts', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should set up all listeners correctly', async () => {
const filter: chrome.webRequest.RequestFilter = { urls: ['<all_urls>'] };
await import('~background');
expect(chrome.contextMenus.onClicked.addListener).toHaveBeenCalledWith(onClicked);
expect(chrome.runtime.onInstalled.addListener).toHaveBeenCalledWith(onInstalled);
expect(chrome.runtime.onStartup.addListener).toHaveBeenCalledWith(onStartup);
expect(chrome.webRequest.onBeforeRequest.addListener).toHaveBeenCalledWith(
onBeforeRequest,
filter
);
expect(chrome.webRequest.onErrorOccurred.addListener).toHaveBeenCalledWith(
onErrorOccurred,
filter
);
});
});

View File

@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import handler from '~background/messages/database/get';
import { DEFAULT_EXTENSION_DATA } from '~utils/constants';
import type { ExtensionData } from '~utils/types';
describe('background/messages/database/get.ts', () => {
const req = { name: 'database/get' as const };
const res = { send: jest.fn() };
beforeEach(() => {
jest.clearAllMocks();
});
it('should return default data if no data is in storage', async () => {
await handler(req, res);
expect(chrome.storage.local.get).toHaveBeenCalledWith('data');
expect(res.send).toHaveBeenCalledWith({ data: DEFAULT_EXTENSION_DATA, success: true });
});
it('should return stored data if it exists in storage', async () => {
const data: ExtensionData = {
actions: [
{
domain: 'jestjs.io',
name: 'click',
selector: '#jest',
},
],
exclusions: {
domains: ['*.jestjs.io'],
overflows: ['*.jestjs.io'],
tags: ['JEST'],
},
keywords: ['jest'],
rules: [
{
action: {
type: 'block' as chrome.declarativeNetRequest.RuleActionType,
},
condition: {
resourceTypes: [
'font' as chrome.declarativeNetRequest.ResourceType,
'image' as chrome.declarativeNetRequest.ResourceType,
'media' as chrome.declarativeNetRequest.ResourceType,
'object' as chrome.declarativeNetRequest.ResourceType,
'script' as chrome.declarativeNetRequest.ResourceType,
'stylesheet' as chrome.declarativeNetRequest.ResourceType,
'xmlhttprequest' as chrome.declarativeNetRequest.ResourceType,
],
urlFilter: '||jestjs.io^',
},
id: 1,
priority: 1,
},
],
tokens: {
backdrops: ['#backdrop'],
classes: ['jest'],
containers: ['#container'],
selectors: ['#element'],
},
version: '0.0.0',
};
await chrome.storage.local.set({ data });
await handler(req, res);
expect(chrome.storage.local.get).toHaveBeenCalledWith('data');
expect(res.send).toHaveBeenCalledWith({ data, success: true });
});
});

View File

@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import handler from '~background/messages/database/refresh';
import { API_URL } from '~utils/constants';
import type { ExtensionData } from '~utils/types';
describe('background/messages/database/refresh.ts', () => {
const req = { name: 'database/refresh' as const };
const res = { send: jest.fn() };
beforeEach(() => {
jest.clearAllMocks();
});
it('should fetch data from the API, store it, and return the data when successful', async () => {
const data: ExtensionData = {
actions: [
{
domain: 'jestjs.io',
name: 'click',
selector: '#jest',
},
],
exclusions: {
domains: ['*.jestjs.io'],
overflows: ['*.jestjs.io'],
tags: ['JEST'],
},
keywords: ['jest'],
rules: [
{
action: {
type: 'block' as chrome.declarativeNetRequest.RuleActionType,
},
condition: {
resourceTypes: [
'font' as chrome.declarativeNetRequest.ResourceType,
'image' as chrome.declarativeNetRequest.ResourceType,
'media' as chrome.declarativeNetRequest.ResourceType,
'object' as chrome.declarativeNetRequest.ResourceType,
'script' as chrome.declarativeNetRequest.ResourceType,
'stylesheet' as chrome.declarativeNetRequest.ResourceType,
'xmlhttprequest' as chrome.declarativeNetRequest.ResourceType,
],
urlFilter: '||jestjs.io^',
},
id: 1,
priority: 1,
},
],
tokens: {
backdrops: ['#backdrop'],
classes: ['jest'],
containers: ['#container'],
selectors: ['#element'],
},
version: '0.0.0',
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ data, success: true }),
status: 200,
})
);
await handler(req, res);
expect(global.fetch).toHaveBeenCalledWith(`${API_URL}/data/`);
expect(chrome.storage.local.set).toHaveBeenCalledWith({ data });
expect(res.send).toHaveBeenCalledWith({ data, success: true });
});
it('should return success: false if the API call fails', async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ success: false }),
status: 200,
})
);
await handler(req, res);
expect(global.fetch).toHaveBeenCalledWith(`${API_URL}/data/`);
expect(chrome.storage.local.set).not.toHaveBeenCalled();
expect(res.send).toHaveBeenCalledWith({ success: false });
});
});

View File

@ -0,0 +1,176 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import handler from '~background/messages/domain/config';
import { API_URL } from '~utils/constants';
import type { DomainConfig } from '~utils/types';
describe('background/messages/domain/config.ts', () => {
const body = { domain: 'example.com' };
const req = { name: 'domain/config' as const };
const res = { send: jest.fn() };
beforeEach(() => {
jest.clearAllMocks();
});
it('should return success: false when domain is missing in request', async () => {
await handler(req, res);
expect(res.send).toHaveBeenCalledWith({ success: false });
});
it('should return cached data when issue is still valid', async () => {
const config: DomainConfig = {
issue: {
expiresAt: Date.now() + 8 * 60 * 60 * 1000,
flags: ['jest'],
url: 'https://jestjs.io/',
},
on: false,
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
global.fetch = jest.fn(() =>
Promise.resolve({
json: () =>
Promise.resolve({
data: {
flags: config.issue?.flags,
url: config.issue?.url,
},
success: true,
}),
status: 200,
})
);
await chrome.storage.local.set({ 'example.com': config });
await handler({ ...req, body }, res);
expect(chrome.storage.local.get).toHaveBeenCalledWith('example.com');
expect(res.send).toHaveBeenCalledWith({ data: config, success: true });
expect(global.fetch).not.toHaveBeenCalled();
});
it('should fetch new issue data if issue is expired', async () => {
const previous: DomainConfig = {
issue: {
expiresAt: Date.now() - 8 * 60 * 60 * 1000,
flags: ['jest'],
url: 'https://jestjs.io/',
},
on: false,
};
const next: DomainConfig = {
issue: {
expiresAt: Date.now() + 8 * 60 * 60 * 1000,
flags: ['jest'],
url: 'https://jestjs.io/',
},
on: false,
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
global.fetch = jest.fn(() =>
Promise.resolve({
json: () =>
Promise.resolve({
data: {
flags: previous.issue?.flags,
url: previous.issue?.url,
},
success: true,
}),
status: 200,
})
);
await chrome.storage.local.set({ 'example.com': previous });
await handler({ ...req, body }, res);
expect(chrome.storage.local.get).toHaveBeenCalledWith('example.com');
expect(global.fetch).toHaveBeenCalledWith(`${API_URL}/issues/example.com/`);
expect(chrome.storage.local.set).toHaveBeenCalledWith({ 'example.com': next });
expect(res.send).toHaveBeenCalledWith({ data: next, success: true });
});
it('should handle rate-limiting gracefully (HTTP 429)', async () => {
const previous: DomainConfig = {
issue: {
expiresAt: Date.now() - 8 * 60 * 60 * 1000,
flags: ['jest'],
url: 'https://jestjs.io/',
},
on: false,
};
const next: DomainConfig = {
issue: {
expiresAt: Date.now() + 8 * 60 * 60 * 1000,
flags: ['jest'],
url: 'https://jestjs.io/',
},
on: false,
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
global.fetch = jest.fn(() =>
Promise.resolve({
json: () =>
Promise.resolve({
errors: [],
success: false,
}),
status: 429,
})
);
await chrome.storage.local.set({ 'example.com': previous });
await handler({ ...req, body }, res);
expect(chrome.storage.local.get).toHaveBeenCalledWith('example.com');
expect(global.fetch).toHaveBeenCalledWith(`${API_URL}/issues/example.com/`);
expect(chrome.storage.local.set).not.toHaveBeenCalledWith({ 'example.com': next });
expect(res.send).toHaveBeenCalledWith({ data: previous, success: true });
});
it('should fallback to a 24-hour expiration if API returns non-success response', async () => {
const previous: DomainConfig = {
issue: {
expiresAt: Date.now() - 8 * 60 * 60 * 1000,
flags: ['jest'],
url: 'https://jestjs.io/',
},
on: false,
};
const next: DomainConfig = {
issue: {
expiresAt: Date.now() + 24 * 60 * 60 * 1000,
},
on: false,
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
global.fetch = jest.fn(() =>
Promise.resolve({
json: () =>
Promise.resolve({
errors: [],
success: false,
}),
status: 200,
})
);
await chrome.storage.local.set({ 'example.com': previous });
await handler({ ...req, body }, res);
expect(chrome.storage.local.get).toHaveBeenCalledWith('example.com');
expect(global.fetch).toHaveBeenCalledWith(`${API_URL}/issues/example.com/`);
expect(chrome.storage.local.set).toHaveBeenCalledWith({ 'example.com': next });
expect(res.send).toHaveBeenCalledWith({ data: next, success: true });
});
});

Some files were not shown because too many files have changed in this diff Show More