From a0cc17154182ca1e68f12355b9ca2113d782731e Mon Sep 17 00:00:00 2001 From: Bauke Date: Fri, 23 Jun 2023 12:52:03 +0200 Subject: [PATCH] Convert to JSX and new storage method. --- flake.lock | 59 +++++ source/assets/options/index.html | 20 ++ .../options/user-label-editor.html} | 6 +- ...extended-128.png => tildes-reextended.png} | Bin ...extended-128.svg => tildes-reextended.svg} | 3 +- source/background/background.ts | 19 -- source/background/setup.ts | 17 ++ source/build.ts | 122 +++++++++ source/content-scripts.ts | 142 ---------- .../features}/anonymize-usernames.ts | 12 +- .../features/autocomplete.tsx} | 104 ++++---- .../features/back-to-top.tsx} | 22 +- source/content-scripts/features/exports.ts | 9 + source/content-scripts/features/hide-votes.ts | 62 +++++ .../features/jump-to-new-comment.tsx} | 34 ++- .../features/markdown-toolbar.tsx} | 103 ++++---- .../features}/themed-logo.ts | 12 +- .../features/user-labels.tsx} | 186 +++++++------- .../features/username-colors.ts | 51 ++++ source/content-scripts/setup.tsx | 153 +++++++++++ source/manifest.json | 52 ---- source/manifest.ts | 72 ++++++ source/migrations.ts | 50 ---- source/options/components/about.ts | 216 ---------------- source/options/components/about.tsx | 160 ++++++++++++ ...e-usernames.ts => anonymize-usernames.tsx} | 14 +- source/options/components/autocomplete.ts | 14 - source/options/components/autocomplete.tsx | 13 + .../{back-to-top.ts => back-to-top.tsx} | 15 +- source/options/components/exports.ts | 20 +- source/options/components/hide-votes.ts | 55 ---- source/options/components/hide-votes.tsx | 75 ++++++ source/options/components/index.ts | 53 ---- source/options/components/index.tsx | 52 ++++ .../options/components/jump-to-new-comment.ts | 14 - .../components/jump-to-new-comment.tsx | 13 + ...rkdown-toolbar.ts => markdown-toolbar.tsx} | 28 +- source/options/components/themed-logo.ts | 14 - source/options/components/themed-logo.tsx | 13 + .../{user-labels.ts => user-labels.tsx} | 24 +- source/options/components/username-colors.ts | 163 ------------ source/options/components/username-colors.tsx | 176 +++++++++++++ source/options/context.ts | 11 +- source/options/features.ts | 92 +++---- source/options/options.ts | 169 ------------ source/options/setup.tsx | 165 ++++++++++++ source/options/user-label-editor.html | 22 -- source/options/user-label-editor.ts | 241 ----------------- source/options/user-label-editor.tsx | 243 ++++++++++++++++++ source/packages.d.ts | 15 ++ source/scripts/exports.ts | 9 - source/scripts/hide-votes.ts | 62 ----- source/scripts/username-colors.ts | 45 ---- source/scss/_colors.scss | 18 +- source/scss/content-scripts.scss | 9 + source/scss/index.scss | 17 +- source/scss/modern-normalize.scss | 1 - source/scss/scripts.scss | 9 - source/scss/user-label-editor.scss | 2 +- source/settings.ts | 189 -------------- source/storage/common.ts | 104 ++++++++ source/types.d.ts | 44 +--- source/utilities/color.ts | 52 ++-- source/utilities/components/link.ts | 21 -- source/utilities/components/link.tsx | 14 + source/utilities/elements.ts | 2 +- source/utilities/exports.ts | 18 +- source/utilities/groups.ts | 10 +- source/utilities/logging.ts | 10 +- source/utilities/report-a-bug.ts | 16 +- source/web-ext.ts | 75 ++++++ 71 files changed, 2104 insertions(+), 2023 deletions(-) create mode 100644 flake.lock create mode 100644 source/assets/options/index.html rename source/{options/index.html => assets/options/user-label-editor.html} (57%) rename source/assets/{tildes-reextended-128.png => tildes-reextended.png} (100%) rename source/assets/{tildes-reextended-128.svg => tildes-reextended.svg} (93%) delete mode 100644 source/background/background.ts create mode 100644 source/background/setup.ts create mode 100644 source/build.ts delete mode 100644 source/content-scripts.ts rename source/{scripts => content-scripts/features}/anonymize-usernames.ts (81%) rename source/{scripts/autocomplete.ts => content-scripts/features/autocomplete.tsx} (68%) rename source/{scripts/back-to-top.ts => content-scripts/features/back-to-top.tsx} (63%) create mode 100644 source/content-scripts/features/exports.ts create mode 100644 source/content-scripts/features/hide-votes.ts rename source/{scripts/jump-to-new-comment.ts => content-scripts/features/jump-to-new-comment.tsx} (58%) rename source/{scripts/markdown-toolbar.ts => content-scripts/features/markdown-toolbar.tsx} (63%) rename source/{scripts => content-scripts/features}/themed-logo.ts (83%) rename source/{scripts/user-labels.ts => content-scripts/features/user-labels.tsx} (66%) create mode 100644 source/content-scripts/features/username-colors.ts create mode 100644 source/content-scripts/setup.tsx delete mode 100644 source/manifest.json create mode 100644 source/manifest.ts delete mode 100644 source/migrations.ts delete mode 100644 source/options/components/about.ts create mode 100644 source/options/components/about.tsx rename source/options/components/{anonymize-usernames.ts => anonymize-usernames.tsx} (50%) delete mode 100644 source/options/components/autocomplete.ts create mode 100644 source/options/components/autocomplete.tsx rename source/options/components/{back-to-top.ts => back-to-top.tsx} (50%) delete mode 100644 source/options/components/hide-votes.ts create mode 100644 source/options/components/hide-votes.tsx delete mode 100644 source/options/components/index.ts create mode 100644 source/options/components/index.tsx delete mode 100644 source/options/components/jump-to-new-comment.ts create mode 100644 source/options/components/jump-to-new-comment.tsx rename source/options/components/{markdown-toolbar.ts => markdown-toolbar.tsx} (59%) delete mode 100644 source/options/components/themed-logo.ts create mode 100644 source/options/components/themed-logo.tsx rename source/options/components/{user-labels.ts => user-labels.tsx} (76%) delete mode 100644 source/options/components/username-colors.ts create mode 100644 source/options/components/username-colors.tsx delete mode 100644 source/options/options.ts create mode 100644 source/options/setup.tsx delete mode 100644 source/options/user-label-editor.html delete mode 100644 source/options/user-label-editor.ts create mode 100644 source/options/user-label-editor.tsx create mode 100644 source/packages.d.ts delete mode 100644 source/scripts/exports.ts delete mode 100644 source/scripts/hide-votes.ts delete mode 100644 source/scripts/username-colors.ts create mode 100644 source/scss/content-scripts.scss delete mode 100644 source/scss/modern-normalize.scss delete mode 100644 source/scss/scripts.scss delete mode 100644 source/settings.ts create mode 100644 source/storage/common.ts delete mode 100644 source/utilities/components/link.ts create mode 100644 source/utilities/components/link.tsx create mode 100644 source/web-ext.ts diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..fff31be --- /dev/null +++ b/flake.lock @@ -0,0 +1,59 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1687171271, + "narHash": "sha256-BJlq+ozK2B1sJDQXS3tzJM5a+oVZmi1q0FlBK/Xqv7M=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "abfb11bd1aec8ced1c9bb9adfe68018230f4fb3c", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1687488839, + "narHash": "sha256-7JDjuyHwUvGJJge9jxfRJkuYyL5G5yipspc4J3HwjGA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "f9e94676ce6c7531c44d38da61d2669ebec0f603", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "type": "indirect" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/source/assets/options/index.html b/source/assets/options/index.html new file mode 100644 index 0000000..fac58f8 --- /dev/null +++ b/source/assets/options/index.html @@ -0,0 +1,20 @@ + + + + + + + + Tildes ReExtended + + + + + + + + + + diff --git a/source/options/index.html b/source/assets/options/user-label-editor.html similarity index 57% rename from source/options/index.html rename to source/assets/options/user-label-editor.html index 8ed0164..00c47e7 100644 --- a/source/options/index.html +++ b/source/assets/options/user-label-editor.html @@ -5,17 +5,15 @@ Tildes ReExtended - - - - + diff --git a/source/assets/tildes-reextended-128.png b/source/assets/tildes-reextended.png similarity index 100% rename from source/assets/tildes-reextended-128.png rename to source/assets/tildes-reextended.png diff --git a/source/assets/tildes-reextended-128.svg b/source/assets/tildes-reextended.svg similarity index 93% rename from source/assets/tildes-reextended-128.svg rename to source/assets/tildes-reextended.svg index 25354e2..922df04 100644 --- a/source/assets/tildes-reextended-128.svg +++ b/source/assets/tildes-reextended.svg @@ -1,4 +1,5 @@ - + diff --git a/source/background/background.ts b/source/background/background.ts deleted file mode 100644 index 5b842ad..0000000 --- a/source/background/background.ts +++ /dev/null @@ -1,19 +0,0 @@ -import browser from 'webextension-polyfill'; - -import {log} from '../utilities/logging.js'; - -log('Debug logging is enabled.'); - -// Open the options page when the extension icon is clicked. -browser.browserAction.onClicked.addListener(openOptionsPage); - -browser.runtime.onInstalled.addListener(async () => { - // Always automatically open the options page in development. - if (import.meta.env.DEV) { - await openOptionsPage(); - } -}); - -async function openOptionsPage() { - await browser.runtime.openOptionsPage(); -} diff --git a/source/background/setup.ts b/source/background/setup.ts new file mode 100644 index 0000000..1944fd6 --- /dev/null +++ b/source/background/setup.ts @@ -0,0 +1,17 @@ +import browser from "webextension-polyfill"; + +if ($browser === "firefox") { + browser.browserAction.onClicked.addListener(openOptionsPage); +} else if ($browser === "chromium") { + browser.action.onClicked.addListener(openOptionsPage); +} + +browser.runtime.onInstalled.addListener(async () => { + if ($dev) { + await openOptionsPage(); + } +}); + +async function openOptionsPage(): Promise { + await browser.runtime.openOptionsPage(); +} diff --git a/source/build.ts b/source/build.ts new file mode 100644 index 0000000..787f59a --- /dev/null +++ b/source/build.ts @@ -0,0 +1,122 @@ +import path from "node:path"; +import process from "node:process"; +import fsp from "node:fs/promises"; +import esbuild from "esbuild"; +import copyPlugin from "esbuild-copy-static-files"; +import {sassPlugin, type SassPluginOptions} from "esbuild-sass-plugin"; +import cssnano from "cssnano"; +import postcss from "postcss"; +import {createManifest} from "./manifest.js"; +import {createWebExtConfig} from "./web-ext.js"; + +/** + * Create an absolute path from a given relative one, using the directory + * this file is located in as the base. + * + * @param relative The relative path to make absolute. + * @returns The absolute path. + */ +function toAbsolutePath(relative: string): string { + return new URL(relative, import.meta.url).pathname; +} + +// Create variables based on the environment. +const browser = process.env.BROWSER ?? "firefox"; +const dev = process.env.NODE_ENV === "development"; +const test = process.env.TEST === "true"; +const watch = process.env.WATCH === "true"; + +// Create absolute paths to various directories. +const buildDir = toAbsolutePath("../build"); +const outDir = path.join(buildDir, browser); +const sourceDir = toAbsolutePath("../source"); + +// Ensure that the output directory exists. +await fsp.mkdir(outDir, {recursive: true}); + +// Write the WebExtension manifest file. +await fsp.writeFile( + path.join(outDir, "manifest.json"), + JSON.stringify(createManifest(browser)), +); + +// Write the web-ext configuration file. +await fsp.writeFile( + path.join(buildDir, `web-ext-${browser}.json`), + JSON.stringify(createWebExtConfig(browser, buildDir, dev, outDir)), +); + +const cssProcessor = postcss([cssnano()]); + +const createSassPlugin = (type: SassPluginOptions["type"]) => { + return sassPlugin({ + type, + async transform(source) { + // In development, don't do any extra processing. + if (dev) { + return source; + } + + // But in production, run the CSS through PostCSS. + const {css} = await cssProcessor.process(source, {from: undefined}); + return css; + }, + }); +}; + +const options: esbuild.BuildOptions = { + bundle: true, + // Define variables to be replaced in the code. Note that these are replaced + // "as is" and so we have to stringify them as JSON, otherwise a string won't + // have its quotes for example. + define: { + $browser: JSON.stringify(browser), + $dev: JSON.stringify(dev), + $test: JSON.stringify(test), + }, + entryPoints: [ + path.join(sourceDir, "background/setup.ts"), + path.join(sourceDir, "options/setup.tsx"), + path.join(sourceDir, "options/user-label-editor.tsx"), + path.join(sourceDir, "content-scripts/setup.tsx"), + ], + format: "esm", + logLevel: "info", + minify: !dev, + outdir: outDir, + plugins: [ + // Copy all files from `source/assets/` to the output directory. + copyPlugin({src: path.join(sourceDir, "assets/"), dest: outDir}), + + // Compile SCSS to CSS. + createSassPlugin("style"), + ], + // Link sourcemaps in development but omit them in production. + sourcemap: dev ? "linked" : false, + // Currently code splitting can't be used because we use ES modules and + // Firefox doesn't run the background script with `type="module"`. + // Once Firefox properly supports Manifest V3 this should be possible though. + splitting: false, + // Target ES2022, and the first Chromium and Firefox releases from 2022. + target: ["es2022", "chrome97", "firefox102"], + treeShaking: true, +}; + +const contentStyleOptions: esbuild.BuildOptions = { + entryPoints: [path.join(sourceDir, "scss/content-scripts.scss")], + logLevel: options.logLevel, + minify: options.minify, + outfile: path.join(outDir, "css/content-scripts.css"), + plugins: [createSassPlugin("css")], + sourcemap: options.sourcemap, + target: options.target, +}; + +if (watch) { + const context = await esbuild.context(options); + const contentStyleContext = await esbuild.context(contentStyleOptions); + await Promise.all([context.watch(), contentStyleContext.watch()]); +} else { + await esbuild.build(options); + await esbuild.build(contentStyleOptions); +} diff --git a/source/content-scripts.ts b/source/content-scripts.ts deleted file mode 100644 index 8ca23d3..0000000 --- a/source/content-scripts.ts +++ /dev/null @@ -1,142 +0,0 @@ -import {html} from 'htm/preact'; -import {render} from 'preact'; - -import { - AutocompleteFeature, - BackToTopFeature, - JumpToNewCommentFeature, - UserLabelsFeature, - runAnonymizeUsernamesFeature, - runHideVotesFeature, - runMarkdownToolbarFeature, - runThemedLogoFeature, - runUsernameColorsFeature, -} from './scripts/exports.js'; -import Settings from './settings.js'; -import {extractGroups, initializeGlobals, log} from './utilities/exports.js'; - -async function initialize() { - const start = window.performance.now(); - initializeGlobals(); - const settings = await Settings.fromSyncStorage(); - window.TildesReExtended.debug = settings.features.debug; - - // Any features that will use `settings.data.knownGroups` should be added to - // this array so that when groups are changed on Tildes, TRX can still update - // them without having to change the hardcoded values. - const usesKnownGroups = [settings.features.autocomplete]; - // Only when any of the features that uses this data try to save the groups. - if (usesKnownGroups.some((value) => value)) { - const knownGroups = extractGroups(); - if (knownGroups !== undefined) { - settings.data.knownGroups = knownGroups; - await settings.save(); - } - } - - const observerFeatures: Array<() => any> = []; - const observer = new window.MutationObserver(() => { - log('Page mutation detected, rerunning features.'); - observer.disconnect(); - for (const feature of observerFeatures) { - feature(); - } - - startObserver(); - }); - - function startObserver() { - observer.observe(document.body, { - attributes: true, - childList: true, - subtree: true, - }); - } - - if (settings.features.anonymizeUsernames) { - observerFeatures.push(() => { - runAnonymizeUsernamesFeature(); - }); - } - - if (settings.features.hideVotes) { - observerFeatures.push(() => { - runHideVotesFeature(settings); - }); - } - - if (settings.features.markdownToolbar) { - observerFeatures.push(() => { - runMarkdownToolbarFeature(); - }); - } - - if (settings.features.themedLogo) { - observerFeatures.push(() => { - runThemedLogoFeature(); - }); - } - - if (settings.features.usernameColors) { - observerFeatures.push(() => { - runUsernameColorsFeature(settings); - }); - } - - // Initialize all the observer-dependent features first. - for (const feature of observerFeatures) { - feature(); - } - - // Object to hold the active components we are going to render. - const components: Record = {}; - - if (settings.features.autocomplete) { - components.autocomplete = html` - <${AutocompleteFeature} settings=${settings} /> - `; - } - - if (settings.features.backToTop) { - components.backToTop = html`<${BackToTopFeature} />`; - } - - if (settings.features.jumpToNewComment) { - components.jumpToNewComment = html`<${JumpToNewCommentFeature} />`; - } - - if (settings.features.userLabels) { - components.userLabels = html` - <${UserLabelsFeature} settings=${settings} /> - `; - } - - // Insert a placeholder at the end of the body first, then render the rest - // and use that as the replacement element. Otherwise render() would put it - // at the beginning of the body which causes a bunch of different issues. - const replacement = document.createElement('div'); - document.body.append(replacement); - - // The jump to new comment button must come right before - // the back to top button. The CSS depends on them being in this order. - render( - html` -
- ${components.jumpToNewComment} ${components.backToTop} - ${components.autocomplete} ${components.userLabels} -
- `, - document.body, - replacement, - ); - - // Start the mutation observer only when some features depend on it are enabled. - if (observerFeatures.length > 0) { - startObserver(); - } - - const initializedIn = window.performance.now() - start; - log(`Initialized in approximately ${initializedIn} milliseconds.`); -} - -void initialize(); diff --git a/source/scripts/anonymize-usernames.ts b/source/content-scripts/features/anonymize-usernames.ts similarity index 81% rename from source/scripts/anonymize-usernames.ts rename to source/content-scripts/features/anonymize-usernames.ts index 93e0ad1..404ec6f 100644 --- a/source/scripts/anonymize-usernames.ts +++ b/source/content-scripts/features/anonymize-usernames.ts @@ -1,4 +1,4 @@ -import {log, querySelectorAll} from '../utilities/exports.js'; +import {log, querySelectorAll} from "../../utilities/exports.js"; export function runAnonymizeUsernamesFeature() { const count = anonymizeUsernames(); @@ -7,13 +7,13 @@ export function runAnonymizeUsernamesFeature() { function anonymizeUsernames(): number { const usernameElements = querySelectorAll( - '.link-user:not(.trx-anonymized)', + ".link-user:not(.trx-anonymized)", ); const replacements = generateReplacements(usernameElements); for (const element of usernameElements) { let username = usernameFromElement(element); - const isMention = username.startsWith('@'); + const isMention = username.startsWith("@"); if (isMention) { username = username.slice(1); } @@ -21,7 +21,7 @@ function anonymizeUsernames(): number { const replacement = replacements[username]; element.textContent = isMention ? `@${replacement}` : `${replacement}`; - element.classList.add('trx-anonymized'); + element.classList.add("trx-anonymized"); element.dataset.trxUsername = username; } @@ -30,7 +30,7 @@ function anonymizeUsernames(): number { function generateReplacements(elements: HTMLElement[]): Record { const usernames = new Set( - elements.map((element) => usernameFromElement(element).replace(/@/g, '')), + elements.map((element) => usernameFromElement(element).replace(/@/g, "")), ); const replacements: Record = {}; @@ -42,5 +42,5 @@ function generateReplacements(elements: HTMLElement[]): Record { } function usernameFromElement(element: HTMLElement): string { - return (element.textContent ?? '').trim(); + return (element.textContent ?? "").trim(); } diff --git a/source/scripts/autocomplete.ts b/source/content-scripts/features/autocomplete.tsx similarity index 68% rename from source/scripts/autocomplete.ts rename to source/content-scripts/features/autocomplete.tsx index bf58de3..45ecebf 100644 --- a/source/scripts/autocomplete.ts +++ b/source/content-scripts/features/autocomplete.tsx @@ -1,12 +1,12 @@ -import {offset, Offset} from 'caret-pos'; -import {html} from 'htm/preact'; -import {Component} from 'preact'; - -import Settings from '../settings.js'; -import {log, querySelectorAll} from '../utilities/exports.js'; +import {offset, type Offset} from "caret-pos"; +import {Component} from "preact"; +import {type UserLabelsData} from "../../storage/common.js"; +import {log, querySelectorAll} from "../../utilities/exports.js"; type Props = { - settings: Settings; + anonymizeUsernamesEnabled: boolean; + knownGroups: Set; + userLabels: UserLabelsData; }; type State = { @@ -25,22 +25,22 @@ export class AutocompleteFeature extends Component { super(props); // Get all the groups without their leading tildes. - const groups = props.settings.data.knownGroups.map((value) => - value.startsWith('~') ? value.slice(1) : value, + const groups = Array.from(props.knownGroups).map((value) => + value.startsWith("~") ? value.slice(1) : value, ); // Get all the usernames on the page without their leading @s, and get // all the usernames from the saved user labels. - const usernameElements = querySelectorAll('.link-user'); + const usernameElements = querySelectorAll(".link-user"); const usernames = [ ...usernameElements.map((value) => { - if (props.settings.features.anonymizeUsernames) { - return (value.dataset.trxUsername ?? '').toLowerCase(); + if (props.anonymizeUsernamesEnabled) { + return (value.dataset.trxUsername ?? "").toLowerCase(); } - return value.textContent!.replace(/^@/, '').toLowerCase(); + return value.textContent!.replace(/^@/, "").toLowerCase(); }), - ...props.settings.data.userLabels.map((value) => value.username), + ...props.userLabels.map((value) => value.username), ].sort((a, b) => a.localeCompare(b)); this.state = { @@ -55,7 +55,7 @@ export class AutocompleteFeature extends Component { }; // Add a keydown listener for the entire page. - document.addEventListener('keydown', this.globalInputHandler); + document.addEventListener("keydown", this.globalInputHandler); log( `Autocomplete: Initialized with ${this.state.groups.size} groups and ` + @@ -66,7 +66,7 @@ export class AutocompleteFeature extends Component { globalInputHandler = (event: KeyboardEvent) => { const activeElement = document.activeElement as HTMLElement; // Only add the autocompletes to textareas. - if (activeElement.tagName !== 'TEXTAREA') { + if (activeElement.tagName !== "TEXTAREA") { return; } @@ -79,8 +79,8 @@ export class AutocompleteFeature extends Component { const dataAttribute = `data-trx-autocomplete-${target}`; if (event.key === prefix && !activeElement.getAttribute(dataAttribute)) { - activeElement.setAttribute(dataAttribute, 'true'); - activeElement.addEventListener('keyup', (event) => { + activeElement.setAttribute(dataAttribute, "true"); + activeElement.addEventListener("keyup", (event) => { this.textareaInputHandler(event, prefix, target, values); }); @@ -88,8 +88,8 @@ export class AutocompleteFeature extends Component { } }; - createHandler('~', 'groups', this.state.groups); - createHandler('@', 'usernames', this.state.usernames); + createHandler("~", "groups", this.state.groups); + createHandler("@", "usernames", this.state.usernames); }; textareaInputHandler = ( @@ -120,7 +120,7 @@ export class AutocompleteFeature extends Component { // If there is any whitespace in the input or there is no input at all, // return early. Usernames cannot have whitespace in them. - if (/\s/.test(input) || input === '') { + if (/\s/.test(input) || input === "") { this.hide(target); return; } @@ -143,11 +143,11 @@ export class AutocompleteFeature extends Component { }; update = (target: string, matches: Set) => { - if (target === 'groups') { + if (target === "groups") { this.setState({ groupsMatches: matches, }); - } else if (target === 'usernames') { + } else if (target === "usernames") { this.setState({ usernamesMatches: matches, }); @@ -155,12 +155,12 @@ export class AutocompleteFeature extends Component { }; show = (target: string, position: Offset) => { - if (target === 'groups') { + if (target === "groups") { this.setState({ groupsHidden: false, groupsPosition: position, }); - } else if (target === 'usernames') { + } else if (target === "usernames") { this.setState({ usernamesHidden: false, usernamesPosition: position, @@ -169,25 +169,25 @@ export class AutocompleteFeature extends Component { }; hide = (target: string) => { - if (target === 'groups') { + if (target === "groups") { this.setState({groupsHidden: true}); - } else if (target === 'usernames') { + } else if (target === "usernames") { this.setState({usernamesHidden: true}); } }; render() { // Create the list of groups and usernames. - const groups = [...this.state.groupsMatches].map( - (value) => html`
  • ~${value}
  • `, - ); - const usernames = [...this.state.usernamesMatches].map( - (value) => html`
  • @${value}
  • `, - ); + const groups = [...this.state.groupsMatches].map((value) => ( +
  • ~{value}
  • + )); + const usernames = [...this.state.usernamesMatches].map((value) => ( +
  • @{value}
  • + )); // Create the CSS class whether or not to hide the autocomplete. - const groupsHidden = this.state.groupsHidden ? 'trx-hidden' : ''; - const usernamesHidden = this.state.usernamesHidden ? 'trx-hidden' : ''; + const groupsHidden = this.state.groupsHidden ? "trx-hidden" : ""; + const usernamesHidden = this.state.usernamesHidden ? "trx-hidden" : ""; // Create the position for the group and usernames autocomplete. const groupsLeft = this.state.groupsPosition?.left ?? 0; @@ -200,21 +200,23 @@ export class AutocompleteFeature extends Component { (this.state.usernamesPosition?.top ?? 0) + (this.state.usernamesPosition?.height ?? 0); - return html` -
      - ${usernames} -
    -
      - ${groups} -
    - `; + return ( + <> +
      + {usernames} +
    +
      + {groups} +
    + + ); } } diff --git a/source/scripts/back-to-top.ts b/source/content-scripts/features/back-to-top.tsx similarity index 63% rename from source/scripts/back-to-top.ts rename to source/content-scripts/features/back-to-top.tsx index 18ac09f..686f763 100644 --- a/source/scripts/back-to-top.ts +++ b/source/content-scripts/features/back-to-top.tsx @@ -1,8 +1,6 @@ -import debounce from 'debounce'; -import {html} from 'htm/preact'; -import {Component} from 'preact'; - -import {log} from '../utilities/exports.js'; +import debounce from "debounce"; +import {Component} from "preact"; +import {log} from "../../utilities/exports.js"; type Props = Record; @@ -19,7 +17,7 @@ export class BackToTopFeature extends Component { // Add a "debounced" handler to the scroll listener, this will make it so // the handler will only run after scrolling has ended for 150ms. - window.addEventListener('scroll', debounce(this.scrollHandler, 150)); + window.addEventListener("scroll", debounce(this.scrollHandler, 150)); // Run the handler once in case the page was already scroll down. this.scrollHandler(); @@ -32,20 +30,20 @@ export class BackToTopFeature extends Component { }; scrollToTop = () => { - window.scrollTo({behavior: 'smooth', top: 0}); + window.scrollTo({behavior: "smooth", top: 0}); }; render() { - const hidden = this.state.hidden ? 'trx-hidden' : ''; + const hidden = this.state.hidden ? "trx-hidden" : ""; - return html` + return ( Back To Top - `; + ); } } diff --git a/source/content-scripts/features/exports.ts b/source/content-scripts/features/exports.ts new file mode 100644 index 0000000..2de1981 --- /dev/null +++ b/source/content-scripts/features/exports.ts @@ -0,0 +1,9 @@ +export * from "./anonymize-usernames.js"; +export * from "./autocomplete.js"; +export * from "./back-to-top.js"; +export * from "./hide-votes.js"; +export * from "./jump-to-new-comment.js"; +export * from "./markdown-toolbar.js"; +export * from "./themed-logo.js"; +export * from "./user-labels.js"; +export * from "./username-colors.js"; diff --git a/source/content-scripts/features/hide-votes.ts b/source/content-scripts/features/hide-votes.ts new file mode 100644 index 0000000..90917f3 --- /dev/null +++ b/source/content-scripts/features/hide-votes.ts @@ -0,0 +1,62 @@ +import {type HideVotesData} from "../../storage/common.js"; +import {log, querySelectorAll} from "../../utilities/exports.js"; + +export function runHideVotesFeature(data: HideVotesData) { + const counts = hideVotes(data); + log(`Hide Votes: Initialized for ${counts} votes.`); +} + +function hideVotes(data: HideVotesData): number { + let count = 0; + + if (data.otherComments) { + const commentVotes = querySelectorAll( + '.btn-post-action[data-ic-put-to*="/vote"]:not(.trx-votes-hidden)', + '.btn-post-action[data-ic-delete-from*="/vote"]:not(.trx-votes-hidden)', + ); + count += commentVotes.length; + + for (const vote of commentVotes) { + vote.classList.add("trx-votes-hidden"); + if (!vote.textContent!.includes(" ")) { + continue; + } + + vote.textContent = vote.textContent!.slice( + 0, + vote.textContent!.indexOf(" "), + ); + } + } + + if (data.ownComments) { + const ownComments = querySelectorAll(".comment-votes"); + count += ownComments.length; + for (const vote of ownComments) { + vote.classList.add("trx-hidden"); + } + } + + if (data.otherTopics || data.ownTopics) { + const selectors: string[] = []; + + // Topics by other people will be encapsulated with a ` - `; + ); } -function snippetDropdown(props: Props): TRXComponent { - const options = snippets.map( - (snippet) => html``, - ); +function SnippetDropdown(props: Props) { + const options = snippets.map((snippet) => ( + + )); const change = (event: Event) => { event.preventDefault(); @@ -162,12 +159,12 @@ function snippetDropdown(props: Props): TRXComponent { (event.target as HTMLSelectElement).selectedIndex = 0; }; - return html` - - ${options} + {options} - `; + ); } function insertSnippet(props: Required) { @@ -189,7 +186,7 @@ function insertSnippet(props: Required) { // Change the index when the Link snippet is used so the cursor ends up // in the URL part of the Markdown: "[existing text](cursor here)". - if (snippet.name === 'Link') { + if (snippet.name === "Link") { index += 2; } } diff --git a/source/scripts/themed-logo.ts b/source/content-scripts/features/themed-logo.ts similarity index 83% rename from source/scripts/themed-logo.ts rename to source/content-scripts/features/themed-logo.ts index 6afbef0..08fbd92 100644 --- a/source/scripts/themed-logo.ts +++ b/source/content-scripts/features/themed-logo.ts @@ -1,8 +1,8 @@ -import {log, querySelector} from '../utilities/exports.js'; +import {log, querySelector} from "../../utilities/exports.js"; export function runThemedLogoFeature() { themedLogo(); - log('Themed Logo: Initialized.'); + log("Themed Logo: Initialized."); } const tildesLogo = ` @@ -24,15 +24,15 @@ function themedLogo() { for (const customProperty of tildesLogo.match(/var\(--.+\)/g) ?? []) { let color = window .getComputedStyle(document.body) - .getPropertyValue(customProperty.slice('var('.length, -1)); - if (color === '') { - color = '#f0f'; + .getPropertyValue(customProperty.slice("var(".length, -1)); + if (color === "") { + color = "#f0f"; } themedLogo = themedLogo.replace(customProperty, color); } const encodedSvg = encodeURIComponent(themedLogo); - const siteHeader = querySelector('.site-header-logo'); + const siteHeader = querySelector(".site-header-logo"); siteHeader.style.backgroundImage = `url("data:image/svg+xml,${encodedSvg}")`; } diff --git a/source/scripts/user-labels.ts b/source/content-scripts/features/user-labels.tsx similarity index 66% rename from source/scripts/user-labels.ts rename to source/content-scripts/features/user-labels.tsx index 071d6c3..f2571ed 100644 --- a/source/scripts/user-labels.ts +++ b/source/content-scripts/features/user-labels.tsx @@ -1,8 +1,7 @@ -import debounce from 'debounce'; -import {Component, render} from 'preact'; -import {html} from 'htm/preact'; - -import Settings from '../settings.js'; +import debounce from "debounce"; +import {Component, render} from "preact"; +import {type Value} from "@holllo/webextension-storage"; +import {type UserLabelsData} from "../../storage/common.js"; import { createElementFromString, isColorBright, @@ -10,10 +9,11 @@ import { log, querySelectorAll, themeColors, -} from '../utilities/exports.js'; +} from "../../utilities/exports.js"; type Props = { - settings: Settings; + anonymizeUsernamesEnabled: boolean; + userLabels: Value; }; type State = { @@ -28,13 +28,13 @@ type State = { }; const colorPattern: string = [ - '^(?:#(?:', // (?:) are non-capturing groups. - '[a-f\\d]{8}|', // The order of 8 -> 6 -> 4 -> 3 character hex colors matters. - '[a-f\\d]{6}|', - '[a-f\\d]{4}|', - '[a-f\\d]{3})', - '|transparent)$', // "Transparent" is also allowed in the input. -].join(''); + "^(?:#(?:", // (?:) are non-capturing groups. + "[a-f\\d]{8}|", // The order of 8 -> 6 -> 4 -> 3 character hex colors matters. + "[a-f\\d]{6}|", + "[a-f\\d]{4}|", + "[a-f\\d]{3})", + "|transparent)$", // "Transparent" is also allowed in the input. +].join(""); export class UserLabelsFeature extends Component { constructor(props: Props) { @@ -49,14 +49,14 @@ export class UserLabelsFeature extends Component { color: selectedColor, hidden: true, id: undefined, - text: '', + text: "", priority: 0, selectedColor, target: undefined, - username: '', + username: "", }; - const count = this.addLabelsToUsernames(querySelectorAll('.link-user')); + const count = this.addLabelsToUsernames(querySelectorAll(".link-user")); log(`User Labels: Initialized for ${count} user links.`); } @@ -65,12 +65,12 @@ export class UserLabelsFeature extends Component { }; addLabelsToUsernames = (elements: HTMLElement[], onlyID?: number): number => { - const settings = this.props.settings; - const inTopicListing = document.querySelector('.topic-listing') !== null; + const {userLabels} = this.props; + const inTopicListing = document.querySelector(".topic-listing") !== null; // Sort the labels by priority or alphabetically, so 2 labels with the same // priority will be sorted alphabetically. - const sortedLabels = settings.data.userLabels.sort((a, b): number => { + const sortedLabels = userLabels.value.sort((a, b): number => { if (inTopicListing) { // If we're in the topic listing sort with highest priority first. if (a.priority !== b.priority) { @@ -88,10 +88,10 @@ export class UserLabelsFeature extends Component { for (const element of elements) { let username: string = element - .textContent!.replace(/@/g, '') + .textContent!.replace(/@/g, "") .toLowerCase(); - if (settings.features.anonymizeUsernames) { + if (this.props.anonymizeUsernamesEnabled) { username = element.dataset.trxUsername ?? username; } @@ -101,19 +101,18 @@ export class UserLabelsFeature extends Component { (onlyID === undefined ? true : value.id === onlyID), ); - const addLabel = html` + const addLabel = ( { + onClick={(event: MouseEvent) => { this.addLabelHandler(event, username); }} > [+] - `; - + ); if (!inTopicListing && onlyID === undefined) { - const addLabelPlaceholder = document.createElement('span'); + const addLabelPlaceholder = document.createElement("span"); element.after(addLabelPlaceholder); render(addLabel, element.parentElement!, addLabelPlaceholder); } @@ -122,9 +121,9 @@ export class UserLabelsFeature extends Component { if ( inTopicListing && (element.nextElementSibling === null || - !element.nextElementSibling.className.includes('trx-user-label')) + !element.nextElementSibling.className.includes("trx-user-label")) ) { - const addLabelPlaceholder = document.createElement('span'); + const addLabelPlaceholder = document.createElement("span"); element.after(addLabelPlaceholder); render(addLabel, element.parentElement!, addLabelPlaceholder); } @@ -134,8 +133,8 @@ export class UserLabelsFeature extends Component { for (const userLabel of userLabels) { const bright = isColorBright(userLabel.color.trim()) - ? 'trx-bright' - : ''; + ? "trx-bright" + : ""; const label = createElementFromString(` { ${userLabel.text} `); - label.addEventListener('click', (event: MouseEvent) => { + label.addEventListener("click", (event: MouseEvent) => { this.editLabelHandler(event, userLabel.id); }); element.after(label); - label.setAttribute('style', `background-color: ${userLabel.color};`); + label.setAttribute("style", `background-color: ${userLabel.color};`); // If we're in the topic listing, stop after adding 1 label. if (inTopicListing) { @@ -179,7 +178,7 @@ export class UserLabelsFeature extends Component { username, color: selectedColor, id: undefined, - text: '', + text: "", priority: 0, selectedColor, }); @@ -193,12 +192,12 @@ export class UserLabelsFeature extends Component { if (this.state.target === target && !this.state.hidden) { this.hide(); } else { - const label = this.props.settings.data.userLabels.find( + const label = this.props.userLabels.value.find( (value) => value.id === id, ); if (label === undefined) { log( - 'User Labels: Tried to edit label with ID that could not be found.', + "User Labels: Tried to edit label with ID that could not be found.", true, ); return; @@ -214,17 +213,17 @@ export class UserLabelsFeature extends Component { colorChange = (event: Event) => { let color: string = (event.target as HTMLInputElement).value.toLowerCase(); - if (!color.startsWith('#') && !color.startsWith('t') && color.length > 0) { + if (!color.startsWith("#") && !color.startsWith("t") && color.length > 0) { color = `#${color}`; } - if (color !== 'transparent' && !isValidHexColor(color)) { + if (color !== "transparent" && !isValidHexColor(color)) { log('User Labels: Color must be a valid hex color or "transparent".'); } // If the color was changed through the preset values, also change the // selected color state. - if ((event.target as HTMLElement).tagName === 'SELECT') { + if ((event.target as HTMLElement).tagName === "SELECT") { this.setState({color, selectedColor: color}); } else { this.setState({color}); @@ -244,34 +243,32 @@ export class UserLabelsFeature extends Component { save = async (event: MouseEvent) => { event.preventDefault(); const {color, id, text, priority, username} = this.state; - if (color === '' || username === '') { - log('Cannot save user label without all values present.'); + if (color === "" || username === "") { + log("Cannot save user label without all values present."); return; } - const {settings} = this.props; + const {userLabels} = this.props; // If no ID is present then save a new label otherwise edit the existing one. if (id === undefined) { - let newID = 1; - if (settings.data.userLabels.length > 0) { - newID = settings.data.userLabels.sort((a, b) => b.id - a.id)[0].id + 1; + let newId = 1; + if (userLabels.value.length > 0) { + newId = userLabels.value.sort((a, b) => b.id - a.id)[0].id + 1; } - settings.data.userLabels.push({ + userLabels.value.push({ color, - id: newID, + id: newId, priority, text, username, }); - this.addLabelsToUsernames(querySelectorAll('.link-user'), newID); + this.addLabelsToUsernames(querySelectorAll(".link-user"), newId); } else { - const index = settings.data.userLabels.findIndex( - (value) => value.id === id, - ); - settings.data.userLabels.splice(index, 1); - settings.data.userLabels.push({ + const index = userLabels.value.findIndex((value) => value.id === id); + userLabels.value.splice(index, 1); + userLabels.value.push({ id, color, priority, @@ -283,17 +280,17 @@ export class UserLabelsFeature extends Component { const bright = isColorBright(color); for (const element of elements) { element.textContent = text; - element.setAttribute('style', `background-color: ${color};`); + element.setAttribute("style", `background-color: ${color};`); if (bright) { - element.classList.add('trx-bright'); + element.classList.add("trx-bright"); } else { - element.classList.remove('trx-bright'); + element.classList.remove("trx-bright"); } } } - await settings.save(); - this.props.settings = settings; + await userLabels.save(); + this.props.userLabels = userLabels; this.hide(); }; @@ -301,14 +298,12 @@ export class UserLabelsFeature extends Component { event.preventDefault(); const {id} = this.state; if (id === undefined) { - log('User Labels: Tried remove label when ID was undefined.'); + log("User Labels: Tried remove label when ID was undefined."); return; } - const {settings} = this.props; - const index = settings.data.userLabels.findIndex( - (value) => value.id === id, - ); + const {userLabels} = this.props; + const index = userLabels.value.findIndex((value) => value.id === id); if (index === undefined) { log( `User Labels: Tried to remove label with ID ${id} that could not be found.`, @@ -321,25 +316,20 @@ export class UserLabelsFeature extends Component { value.remove(); } - settings.data.userLabels.splice(index, 1); - await settings.save(); - this.props.settings = settings; + userLabels.value.splice(index, 1); + await userLabels.save(); + this.props.userLabels = userLabels; this.hide(); }; render() { const bodyStyle = window.getComputedStyle(document.body); - const themeSelectOptions = themeColors.map( - ({name, value}) => - html` - - `, - ); + const themeSelectOptions = themeColors.map(({name, value}) => ( + + )); - const bright = isColorBright(this.state.color) ? 'trx-bright' : ''; - const hidden = this.state.hidden ? 'trx-hidden' : ''; + const bright = isColorBright(this.state.color) ? "trx-bright" : ""; + const hidden = this.state.hidden ? "trx-hidden" : ""; const {color, text: label, priority, selectedColor, username} = this.state; let top = 0; @@ -355,8 +345,8 @@ export class UserLabelsFeature extends Component { const position = `left: ${left}px; top: ${top}px;`; const previewStyle = `background-color: ${color}`; - return html` -
    + return ( +
    @@ -374,8 +364,8 @@ export class UserLabelsFeature extends Component { @@ -390,18 +380,18 @@ export class UserLabelsFeature extends Component { type="text" class="form-input" placeholder="Color" - value="${color}" - onInput=${debounce(this.colorChange, 250)} - pattern="${colorPattern}" + value={color} + onInput={debounce(this.colorChange, 250)} + pattern={colorPattern} required />
    @@ -415,22 +405,28 @@ export class UserLabelsFeature extends Component { type="text" class="form-input" placeholder="Text" - value="${label}" - onInput=${debounce(this.labelChange, 250)} + value={label} + onInput={debounce(this.labelChange, 250)} /> -
    -

    ${label}

    +
    +

    {label}

    - `; + ); } } diff --git a/source/content-scripts/features/username-colors.ts b/source/content-scripts/features/username-colors.ts new file mode 100644 index 0000000..bf38db8 --- /dev/null +++ b/source/content-scripts/features/username-colors.ts @@ -0,0 +1,51 @@ +import {log, querySelectorAll} from "../../utilities/exports.js"; +import {type UsernameColorsData} from "../../storage/common.js"; + +export function runUsernameColorsFeature( + data: UsernameColorsData, + anonymizeUsernamesEnabled: boolean, +) { + const count = usernameColors(data, anonymizeUsernamesEnabled); + log(`Username Colors: Applied ${count} colors.`); +} + +function usernameColors( + data: UsernameColorsData, + anonymizeUsernamesEnabled: boolean, +): number { + const usernameColors = new Map(); + for (const {color, username: usernames} of data) { + for (const username of usernames.split(",")) { + usernameColors.set(username.trim().toLowerCase(), color); + } + } + + let count = 0; + const usernameElements = querySelectorAll( + ".link-user:not(.trx-username-colors)", + ); + + for (const element of usernameElements) { + if (element.classList.contains("trx-username-colors")) { + continue; + } + + let target = + element.textContent?.replace(/@/g, "").trim().toLowerCase() ?? + ""; + if (anonymizeUsernamesEnabled) { + target = element.dataset.trxUsername?.toLowerCase() ?? target; + } + + element.classList.add("trx-username-colors"); + const color = usernameColors.get(target); + if (color === undefined) { + continue; + } + + element.style.color = color; + count += 1; + } + + return count; +} diff --git a/source/content-scripts/setup.tsx b/source/content-scripts/setup.tsx new file mode 100644 index 0000000..e24063b --- /dev/null +++ b/source/content-scripts/setup.tsx @@ -0,0 +1,153 @@ +import {type JSX, render} from "preact"; +import {extractGroups, initializeGlobals, log} from "../utilities/exports.js"; +import {Feature, fromStorage, Data} from "../storage/common.js"; +import { + AutocompleteFeature, + BackToTopFeature, + JumpToNewCommentFeature, + UserLabelsFeature, + runAnonymizeUsernamesFeature, + runHideVotesFeature, + runMarkdownToolbarFeature, + runThemedLogoFeature, + runUsernameColorsFeature, +} from "./features/exports.js"; + +async function initialize() { + const start = window.performance.now(); + initializeGlobals(); + const enabledFeatures = await fromStorage(Data.EnabledFeatures); + + // Any features that will use the knownGroups data should be added to this + // array so that when groups are changed on Tildes, TRX can still update + // them without having to change the hardcoded values. + const usesKnownGroups = new Set([Feature.Autocomplete]); + const knownGroups = await fromStorage(Data.KnownGroups); + + // Only when any of the features that uses this data are enabled, try to save + // the groups. + if ( + Array.from(usesKnownGroups).some((feature) => + enabledFeatures.value.has(feature), + ) + ) { + const extractedGroups = extractGroups(); + if (extractedGroups !== undefined) { + knownGroups.value = new Set(extractedGroups); + await knownGroups.save(); + } + } + + const anonymizeUsernamesEnabled = enabledFeatures.value.has( + Feature.AnonymizeUsernames, + ); + + const observerFeatures: Array<() => void | Promise> = []; + const observer = new window.MutationObserver(async () => { + log("Page mutation detected, rerunning features."); + observer.disconnect(); + await Promise.all(observerFeatures.map(async (feature) => feature())); + startObserver(); + }); + + function startObserver() { + observer.observe(document.body, { + attributes: true, + childList: true, + subtree: true, + }); + } + + if (anonymizeUsernamesEnabled) { + observerFeatures.push(() => { + runAnonymizeUsernamesFeature(); + }); + } + + if (enabledFeatures.value.has(Feature.HideVotes)) { + observerFeatures.push(async () => { + const data = await fromStorage(Feature.HideVotes); + runHideVotesFeature(data.value); + }); + } + + if (enabledFeatures.value.has(Feature.MarkdownToolbar)) { + observerFeatures.push(() => { + runMarkdownToolbarFeature(); + }); + } + + if (enabledFeatures.value.has(Feature.ThemedLogo)) { + observerFeatures.push(() => { + runThemedLogoFeature(); + }); + } + + if (enabledFeatures.value.has(Feature.UsernameColors)) { + observerFeatures.push(async () => { + const data = await fromStorage(Feature.UsernameColors); + runUsernameColorsFeature(data.value, anonymizeUsernamesEnabled); + }); + } + + // Initialize all the observer-dependent features first. + await Promise.all(observerFeatures.map(async (feature) => feature())); + + // Object to hold the active components we are going to render. + const components: Record = {}; + + const userLabels = await fromStorage(Feature.UserLabels); + if (enabledFeatures.value.has(Feature.Autocomplete)) { + components.autocomplete = ( + + ); + } + + if (enabledFeatures.value.has(Feature.BackToTop)) { + components.backToTop = ; + } + + if (enabledFeatures.value.has(Feature.JumpToNewComment)) { + components.jumpToNewComment = ; + } + + if (enabledFeatures.value.has(Feature.UserLabels)) { + components.userLabels = ( + + ); + } + + // Insert a placeholder at the end of the body first, then render the rest + // and use that as the replacement element. Otherwise render() would put it + // at the beginning of the body which causes a bunch of different issues. + const replacement = document.createElement("div"); + document.body.append(replacement); + + // The jump to new comment button must come right before + // the back to top button. The CSS depends on them being in this order. + render( +
    + {components.jumpToNewComment} {components.backToTop} + {components.autocomplete} {components.userLabels} +
    , + document.body, + replacement, + ); + + // Start the mutation observer only when some features depend on it are enabled. + if (observerFeatures.length > 0) { + startObserver(); + } + + const initializedIn = window.performance.now() - start; + log(`Initialized in approximately ${initializedIn} milliseconds.`); +} + +document.addEventListener("DOMContentLoaded", initialize); diff --git a/source/manifest.json b/source/manifest.json deleted file mode 100644 index 32c1bc5..0000000 --- a/source/manifest.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "$schema": "http://json.schemastore.org/webextension", - "manifest_version": 2, - "name": "Tildes ReExtended", - "description": "An updated and reimagined recreation of Tildes Extended to enhance and improve the experience of Tildes.net.", - "version": "1.1.2", - "permissions": [ - "downloads", - "storage", - "*://tildes.net/*" - ], - "content_security_policy": "script-src 'self'; object-src 'self'; style-src 'unsafe-inline'", - "web_accessible_resources": [ - "assets/**" - ], - "icons": { - "128": "assets/tildes-reextended-128.png" - }, - "background": { - "scripts": [ - "background/background.ts" - ] - }, - "browser_action": { - "default_icon": { - "128": "assets/tildes-reextended-128.png" - } - }, - "options_ui": { - "page": "options/index.html", - "open_in_tab": true - }, - "content_scripts": [ - { - "matches": [ - "*://tildes.net/*" - ], - "run_at": "document_end", - "css": [ - "scss/scripts.scss" - ], - "js": [ - "content-scripts.ts" - ] - } - ], - "applications": { - "gecko": { - "id": "{3a6a9b87-5ea1-441c-98d8-e27a1a0958c8}" - } - } -} diff --git a/source/manifest.ts b/source/manifest.ts new file mode 100644 index 0000000..bb1fd90 --- /dev/null +++ b/source/manifest.ts @@ -0,0 +1,72 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +import {type Manifest} from "webextension-polyfill"; + +/** + * Creates the WebExtension manifest based on the browser target. + * + * @param browser The browser target ("firefox" or "chromium"). + * @returns The WebExtension manifest. + */ +export function createManifest(browser: string): Manifest.WebExtensionManifest { + const manifest: Manifest.WebExtensionManifest = { + manifest_version: Number.NaN, + name: "Tildes ReExtended", + description: "The principal enhancement suite for Tildes.", + version: "2.0.0", + permissions: ["downloads", "storage", "*://tildes.net/*"], + options_ui: { + page: "options/index.html", + open_in_tab: true, + }, + content_security_policy: + "script-src 'self'; object-src 'self'; style-src 'unsafe-inline'", + content_scripts: [ + { + css: ["css/content-scripts.css"], + js: ["content-scripts/setup.js"], + matches: ["https://*.tildes.net/*"], + run_at: "document_start", + }, + ], + }; + + const icons: Manifest.IconPath = { + 128: "tildes-reextended.png", + }; + + const action: Manifest.ActionManifest = { + default_icon: icons, + }; + + const backgroundScript = "background/setup.js"; + + if (browser === "firefox") { + manifest.manifest_version = 2; + manifest.background = { + scripts: [backgroundScript], + }; + manifest.browser_action = action; + manifest.browser_specific_settings = { + gecko: { + id: "{3a6a9b87-5ea1-441c-98d8-e27a1a0958c8}", + strict_min_version: "102.0", + }, + }; + } else if (browser === "chromium") { + manifest.manifest_version = 3; + manifest.action = action; + manifest.background = { + service_worker: backgroundScript, + type: "module", + }; + } else { + throw new Error(`Unknown target browser: ${browser}`); + } + + if (Number.isNaN(manifest.manifest_version)) { + throw new TypeError("Manifest version is NaN"); + } + + return manifest; +} diff --git a/source/migrations.ts b/source/migrations.ts deleted file mode 100644 index 498450a..0000000 --- a/source/migrations.ts +++ /dev/null @@ -1,50 +0,0 @@ -import {Migration} from '@holllo/migration-helper'; - -export const migrations: Array> = [ - { - version: '1.1.2', - async migrate(data: Record) { - const migrated: Record = { - data: { - hideVotes: data.data.hideVotes as Record, - knownGroups: data.data.knownGroups as string[], - latestActiveFeatureTab: data.data.latestActiveFeatureTab as string, - }, - features: (data.features as Record) ?? {}, - version: '1.1.2', - }; - - const userLabels = data.data.userLabels as UserLabel[]; - for (const label of userLabels) { - migrated[`userLabel${label.id}`] = label; - } - - const usernameColors = data.data.usernameColors as UsernameColor[]; - for (const color of usernameColors) { - migrated[`usernameColor${color.id}`] = color; - } - - return migrated; - }, - }, -]; - -export function deserializeData(data: Record): { - userLabels: UserLabel[]; - usernameColors: UsernameColor[]; -} { - const deserialized: ReturnType = { - userLabels: [], - usernameColors: [], - }; - - for (const [key, value] of Object.entries(data)) { - if (key.startsWith('userLabel')) { - deserialized.userLabels.push(value); - } else if (key.startsWith('usernameColor')) { - deserialized.usernameColors.push(value); - } - } - - return deserialized; -} diff --git a/source/options/components/about.ts b/source/options/components/about.ts deleted file mode 100644 index f706eca..0000000 --- a/source/options/components/about.ts +++ /dev/null @@ -1,216 +0,0 @@ -import browser from 'webextension-polyfill'; -import {html} from 'htm/preact'; - -import Settings from '../../settings.js'; -import { - Link, - log, - isValidHexColor, - isValidTildesUsername, -} from '../../utilities/exports.js'; -import {SettingProps, Setting} from './index.js'; - -async function logSettings() { - log(await Settings.fromSyncStorage(), true); -} - -async function importFileHandler(event: Event): Promise { - // Grab the imported files (if any). - const fileList = (event.target as HTMLInputElement).files; - - if (fileList === null) { - log('No file imported.'); - return; - } - - const reader = new window.FileReader(); - - reader.addEventListener('load', async (): Promise => { - let data: Partial; - - try { - // eslint-disable-next-line @typescript-eslint/no-base-to-string - data = JSON.parse(reader.result!.toString()) as Partial; - } catch (error: unknown) { - log(error, true); - return; - } - - const settings = await Settings.fromSyncStorage(); - if (typeof data.data !== 'undefined') { - if (typeof data.data.userLabels !== 'undefined') { - settings.data.userLabels = []; - - for (const label of data.data.userLabels) { - if ( - typeof label.username === 'undefined' || - !isValidTildesUsername(label.username) - ) { - log(`Invalid username in imported labels: ${label.username}`); - continue; - } - - settings.data.userLabels.push({ - color: isValidHexColor(label.color) ? label.color : '#f0f', - id: settings.data.userLabels.length + 1, - priority: Number.isNaN(label.priority) ? 0 : label.priority, - text: typeof label.text === 'undefined' ? 'Label' : label.text, - username: label.username, - }); - } - } - - if (typeof data.data.hideVotes !== 'undefined') { - settings.data.hideVotes = data.data.hideVotes; - } - } - - if (typeof data.features !== 'undefined') { - settings.features = {...data.features}; - } - - await settings.save(); - log('Successfully imported your settings, reloading the page to apply.'); - setTimeout(() => { - window.location.reload(); - }, 1000); - }); - - reader.addEventListener('error', (): void => { - log(reader.error, true); - reader.abort(); - }); - - reader.readAsText(fileList[0]); -} - -async function exportSettings(event: MouseEvent): Promise { - event.preventDefault(); - - const settings = await Settings.fromSyncStorage(); - const settingsBlob = new window.Blob([JSON.stringify(settings, null, 2)], { - type: 'text/json', - }); - - const objectURL = URL.createObjectURL(settingsBlob); - - try { - await browser.downloads.download({ - filename: 'tildes-reextended-settings.json', - url: objectURL, - saveAs: true, - }); - } catch (error: unknown) { - log(error); - } finally { - // According to MDN, when creating an object URL we should also revoke it - // when "it's safe to do so" to prevent excessive memory/storage use. - // 60 seconds should probably be enough time to download the settings. - setTimeout(() => { - URL.revokeObjectURL(objectURL); - }, 60 * 1000); - } -} - -export function AboutSetting(props: SettingProps): TRXComponent { - const importSettings = () => { - document.querySelector('#import-settings')!.click(); - }; - - const communityLink = html` - <${Link} - url="https://gitlab.com/tildes-community" - text="Tildes Community project" - /> - `; - - const criusLink = html` - <${Link} url="https://tildes.net/user/crius" text="Crius" /> - `; - - const gitlabIssuesLink = html` - <${Link} - url="https://gitlab.com/tildes-community/tildes-reextended/-/issues" - text="GitLab issue tracker" - /> - `; - - const gitlabLicenseLink = html` - <${Link} - url="https://gitlab.com/tildes-community/tildes-reextended/blob/main/LICENSE" - text="MIT License" - /> - `; - - const messageCommunityLink = html` - <${Link} - url="https://tildes.net/user/Community/new_message" - text="message Community" - /> - `; - - const tildesExtendedLink = html` - <${Link} - url="https://github.com/theCrius/tildes-extended" - text="Tildes Extended" - /> - `; - - return html` - <${Setting} ...${props}> -

    - This feature will make debugging logs output to the console when - enabled. -

    - -

    - Tildes ReExtended is a from-scratch recreation of the original${' '} - ${tildesExtendedLink} web extension by ${criusLink}. Open-sourced${' '} - with the ${gitlabLicenseLink} and maintained as a ${communityLink}. -

    - -

    - To report bugs or request new features use the links at the bottom of - this page, check out the ${gitlabIssuesLink} or${' '} - ${messageCommunityLink}${' '} on Tildes. -

    - -
    - -
    -

    - Note that importing settings will delete and overwrite your existing - ones. -

    - - - - -
    - -
    - -
    - Danger Zone - -
    - - - -
    -
    - - `; -} diff --git a/source/options/components/about.tsx b/source/options/components/about.tsx new file mode 100644 index 0000000..7fe773a --- /dev/null +++ b/source/options/components/about.tsx @@ -0,0 +1,160 @@ +import browser from "webextension-polyfill"; +import {type JSX} from "preact"; +import { + Link, + log, + isValidHexColor, + isValidTildesUsername, +} from "../../utilities/exports.js"; +import {type SettingProps, Setting} from "./index.js"; + +async function importFileHandler(event: Event): Promise { + // Grab the imported files (if any). + const fileList = (event.target as HTMLInputElement).files; + + if (fileList === null) { + log("No file imported."); + return; + } + + const reader = new window.FileReader(); + + reader.addEventListener("load", async (): Promise => { + let data: unknown; + + try { + // eslint-disable-next-line @typescript-eslint/no-base-to-string + data = JSON.parse(reader.result!.toString()); + } catch (error: unknown) { + log(error, true); + return; + } + + if (!(data instanceof Object)) { + log("Imported data is not an object", true); + return; + } + + await browser.storage.sync.set(data); + log("Successfully imported your settings, reloading the page to apply."); + setTimeout(() => { + window.location.reload(); + }, 1000); + }); + + reader.addEventListener("error", (): void => { + log(reader.error, true); + reader.abort(); + }); + + reader.readAsText(fileList[0]); +} + +async function exportSettings(event: MouseEvent): Promise { + event.preventDefault(); + + const storage = await browser.storage.sync.get(); + const settingsBlob = new window.Blob([JSON.stringify(storage, null, 2)], { + type: "text/json", + }); + + const objectUrl = URL.createObjectURL(settingsBlob); + + try { + await browser.downloads.download({ + filename: "tildes-reextended-sync-data.json", + url: objectUrl, + saveAs: true, + }); + } catch (error: unknown) { + log(error); + } finally { + // According to MDN, when creating an object URL we should also revoke it + // when "it's safe to do so" to prevent excessive memory/storage use. + // 60 seconds should probably be enough time to download the settings. + setTimeout(() => { + URL.revokeObjectURL(objectUrl); + }, 60 * 1000); + } +} + +export function AboutSetting(props: SettingProps): JSX.Element { + const importSettings = () => { + document.querySelector("#import-settings")!.click(); + }; + + const communityLink = ( + + ); + const criusLink = ; + const gitlabIssuesLink = ( + + ); + const gitlabLicenseLink = ( + + ); + const messageCommunityLink = ( + + ); + const tildesExtendedLink = ( + + ); + return ( + +

    + This feature will make debugging logs output to the console when + enabled. +

    + +

    + Tildes ReExtended is a from-scratch recreation of the original{" "} + {tildesExtendedLink} web extension by {criusLink}. Open-sourced with the{" "} + {gitlabLicenseLink} and maintained as a {communityLink}. +

    + +

    + To report bugs or request new features use the links at the bottom of + this page, check out the {gitlabIssuesLink} or {messageCommunityLink} on + Tildes. +

    + +
    + +
    +

    + Note that importing settings will delete and overwrite your existing + ones. +

    + + + + +
    + + ); +} diff --git a/source/options/components/anonymize-usernames.ts b/source/options/components/anonymize-usernames.tsx similarity index 50% rename from source/options/components/anonymize-usernames.ts rename to source/options/components/anonymize-usernames.tsx index 111a874..8ff86b0 100644 --- a/source/options/components/anonymize-usernames.ts +++ b/source/options/components/anonymize-usernames.tsx @@ -1,16 +1,14 @@ -import {html} from 'htm/preact'; +import {Setting, type SettingProps} from "./index.js"; -import {Setting, SettingProps} from './index.js'; - -export function AnonymizeUsernamesSetting(props: SettingProps): TRXComponent { - return html` - <${Setting} ...${props}> +export function AnonymizeUsernamesSetting(props: SettingProps) { + return ( +

    Anonymizes usernames by replacing them with "Anonymous #".
    Note that User Labels and Username Colors will still be applied to any usernames as normal.

    - - `; +
    + ); } diff --git a/source/options/components/autocomplete.ts b/source/options/components/autocomplete.ts deleted file mode 100644 index df85228..0000000 --- a/source/options/components/autocomplete.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {html} from 'htm/preact'; - -import {Setting, SettingProps} from './index.js'; - -export function AutocompleteSetting(props: SettingProps): TRXComponent { - return html` - <${Setting} ...${props}> -

    - Adds autocompletion in textareas for user mentions (starting with${' '} - @) and groups (starting with ~). -

    - - `; -} diff --git a/source/options/components/autocomplete.tsx b/source/options/components/autocomplete.tsx new file mode 100644 index 0000000..c0c8c4e --- /dev/null +++ b/source/options/components/autocomplete.tsx @@ -0,0 +1,13 @@ +import {type JSX} from "preact"; +import {Setting, type SettingProps} from "./index.js"; + +export function AutocompleteSetting(props: SettingProps): JSX.Element { + return ( + +

    + Adds autocompletion in textareas for user mentions (starting with{" "} + @) and groups (starting with ~). +

    +
    + ); +} diff --git a/source/options/components/back-to-top.ts b/source/options/components/back-to-top.tsx similarity index 50% rename from source/options/components/back-to-top.ts rename to source/options/components/back-to-top.tsx index 090688b..4e87621 100644 --- a/source/options/components/back-to-top.ts +++ b/source/options/components/back-to-top.tsx @@ -1,15 +1,14 @@ -import {html} from 'htm/preact'; +import {type JSX} from "preact"; +import {Setting, type SettingProps} from "./index.js"; -import {Setting, SettingProps} from './index.js'; - -export function BackToTopSetting(props: SettingProps): TRXComponent { - return html` - <${Setting} ...${props}> +export function BackToTopSetting(props: SettingProps): JSX.Element { + return ( +

    Adds a hovering button to the bottom-right of all pages once you've scrolled down far enough that, when clicked, will scroll you back to the top of the page.

    - - `; +
    + ); } diff --git a/source/options/components/exports.ts b/source/options/components/exports.ts index af54560..5eebed5 100644 --- a/source/options/components/exports.ts +++ b/source/options/components/exports.ts @@ -1,10 +1,10 @@ -export {AboutSetting} from './about.js'; -export {AnonymizeUsernamesSetting} from './anonymize-usernames.js'; -export {AutocompleteSetting} from './autocomplete.js'; -export {BackToTopSetting} from './back-to-top.js'; -export {HideVotesSetting} from './hide-votes.js'; -export {JumpToNewCommentSetting} from './jump-to-new-comment.js'; -export {MarkdownToolbarSetting} from './markdown-toolbar.js'; -export {ThemedLogoSetting} from './themed-logo.js'; -export {UserLabelsSetting} from './user-labels.js'; -export {UsernameColorsSetting} from './username-colors.js'; +export {AboutSetting} from "./about.js"; +export {AnonymizeUsernamesSetting} from "./anonymize-usernames.js"; +export {AutocompleteSetting} from "./autocomplete.js"; +export {BackToTopSetting} from "./back-to-top.js"; +export {HideVotesSetting} from "./hide-votes.js"; +export {JumpToNewCommentSetting} from "./jump-to-new-comment.js"; +export {MarkdownToolbarSetting} from "./markdown-toolbar.js"; +export {ThemedLogoSetting} from "./themed-logo.js"; +export {UserLabelsSetting} from "./user-labels.js"; +export {UsernameColorsSetting} from "./username-colors.js"; diff --git a/source/options/components/hide-votes.ts b/source/options/components/hide-votes.ts deleted file mode 100644 index b3f0528..0000000 --- a/source/options/components/hide-votes.ts +++ /dev/null @@ -1,55 +0,0 @@ -import {html} from 'htm/preact'; -import {useContext, useState} from 'preact/hooks'; - -import {AppContext} from '../context.js'; -import {Setting, SettingProps} from './index.js'; - -export function HideVotesSetting(props: SettingProps): TRXComponent { - const {settings} = useContext(AppContext); - - const [checked, setChecked] = useState(settings.data.hideVotes); - function toggle(target: string) { - checked[target] = !checked[target]; - setChecked(checked); - - settings.data.hideVotes = checked; - void settings.save(); - } - - // Checkbox labels and "targets". The targets should match the keys as defined - // in the user extension settings. - const checkboxes = [ - {label: 'Your comments', target: 'ownComments'}, - {label: 'Your topics', target: 'ownTopics'}, - {label: "Other's comments", target: 'comments'}, - {label: "Other's topics", target: 'topics'}, - ].map( - ({label, target}) => - html` -
  • - -
  • - `, - ); - - return html` - <${Setting} ...${props}> -

    - Hides vote counts from topics and comments of yourself or other people. -

    - -
      - ${checkboxes} -
    - - `; -} diff --git a/source/options/components/hide-votes.tsx b/source/options/components/hide-votes.tsx new file mode 100644 index 0000000..48bb847 --- /dev/null +++ b/source/options/components/hide-votes.tsx @@ -0,0 +1,75 @@ +import {Component} from "preact"; +import {type Value} from "@holllo/webextension-storage"; +import { + fromStorage, + Feature, + type HideVotesData, +} from "../../storage/common.js"; +import {Setting, type SettingProps} from "./index.js"; + +type State = { + hideVotes: Value; +}; + +type HideVotesKey = keyof State["hideVotes"]["value"]; + +export class HideVotesSetting extends Component { + constructor(props: SettingProps) { + super(props); + + this.state = { + hideVotes: undefined!, + }; + } + + async componentDidMount() { + this.setState({hideVotes: await fromStorage(Feature.HideVotes)}); + } + + toggle(target: HideVotesKey): void { + const hideVotes = this.state.hideVotes; + hideVotes.value[target] = !hideVotes.value[target]; + void hideVotes.save(); + this.setState({hideVotes}); + } + + render() { + const {hideVotes} = this.state; + if (hideVotes === undefined) { + return; + } + + const checkboxesData: Array<{label: string; target: HideVotesKey}> = [ + {label: "Your comments", target: "ownComments"}, + {label: "Your topics", target: "ownTopics"}, + {label: "Other's comments", target: "otherComments"}, + {label: "Other's topics", target: "otherTopics"}, + ]; + + const checkboxes = checkboxesData.map(({label, target}) => ( +
  • + +
  • + )); + + return ( + +

    + Hides vote counts from topics and comments of yourself or other + people. +

    + +
      {checkboxes}
    +
    + ); + } +} diff --git a/source/options/components/index.ts b/source/options/components/index.ts deleted file mode 100644 index 7095d15..0000000 --- a/source/options/components/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -import {Component} from 'preact'; -import {useContext} from 'preact/hooks'; -import {html} from 'htm/preact'; - -import {AppContext} from '../context.js'; - -export type SettingProps = { - children: TRXComponent | undefined; - class: string; - enabled: boolean; - feature: string; - title: string; -}; - -class Header extends Component { - render() { - const {props} = this; - const context = useContext(AppContext); - const enabled = props.enabled ? 'Enabled' : 'Disabled'; - - return html` -
    -

    ${props.title}

    - -
    - `; - } -} - -// A base component for all the settings, this adds the header and the -// enable/disable buttons. This can also be used as a placeholder for new -// settings when you're still developing them. -export function Setting(props: SettingProps): TRXComponent { - const children = - props.children === undefined - ? html`

    This setting still needs a component!

    ` - : props.children; - - const enabled = (props.enabled ? 'Enabled' : 'Disabled').toLowerCase(); - - return html` -
    - <${Header} ...${props} /> -
    ${children}
    -
    - `; -} diff --git a/source/options/components/index.tsx b/source/options/components/index.tsx new file mode 100644 index 0000000..ff575eb --- /dev/null +++ b/source/options/components/index.tsx @@ -0,0 +1,52 @@ +import {Component, type ComponentChildren, type JSX} from "preact"; +// eslint-disable-next-line n/file-extension-in-import +import {useContext} from "preact/hooks"; +import {AppContext} from "../context.js"; +import {type Feature} from "../../storage/common.js"; + +export type SettingProps = { + children: ComponentChildren; + class: string; + enabled: boolean; + feature: Feature; + title: string; +}; + +class Header extends Component { + render() { + const {props} = this; + const context = useContext(AppContext); + const enabled = props.enabled ? "Enabled" : "Disabled"; + + return ( +
    +

    {props.title}

    + +
    + ); + } +} + +// A base component for all the settings, this adds the header and the +// enable/disable buttons. This can also be used as a placeholder for new +// settings when you're still developing them. +export function Setting(props: SettingProps): JSX.Element { + const children = props.children ?? ( +

    This setting still needs a component!

    + ); + + const enabled = (props.enabled ? "Enabled" : "Disabled").toLowerCase(); + + return ( +
    +
    +
    {children}
    +
    + ); +} diff --git a/source/options/components/jump-to-new-comment.ts b/source/options/components/jump-to-new-comment.ts deleted file mode 100644 index 45e1d6d..0000000 --- a/source/options/components/jump-to-new-comment.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {html} from 'htm/preact'; - -import {Setting, SettingProps} from './index.js'; - -export function JumpToNewCommentSetting(props: SettingProps): TRXComponent { - return html` - <${Setting} ...${props}> -

    - Adds a hovering button to the bottom-right of pages with new comments - that, when clicked, will scroll you to the next new comment. -

    - - `; -} diff --git a/source/options/components/jump-to-new-comment.tsx b/source/options/components/jump-to-new-comment.tsx new file mode 100644 index 0000000..a8124a2 --- /dev/null +++ b/source/options/components/jump-to-new-comment.tsx @@ -0,0 +1,13 @@ +import {type JSX} from "preact"; +import {Setting, type SettingProps} from "./index.js"; + +export function JumpToNewCommentSetting(props: SettingProps): JSX.Element { + return ( + +

    + Adds a hovering button to the bottom-right of pages with new comments + that, when clicked, will scroll you to the next new comment. +

    +
    + ); +} diff --git a/source/options/components/markdown-toolbar.ts b/source/options/components/markdown-toolbar.tsx similarity index 59% rename from source/options/components/markdown-toolbar.ts rename to source/options/components/markdown-toolbar.tsx index f09c1fe..a04e17e 100644 --- a/source/options/components/markdown-toolbar.ts +++ b/source/options/components/markdown-toolbar.tsx @@ -1,31 +1,27 @@ -import {html} from 'htm/preact'; +import {type JSX} from "preact"; +import {Link} from "../../utilities/exports.js"; +import {Setting, type SettingProps} from "./index.js"; -import {Link} from '../../utilities/exports.js'; -import {Setting, SettingProps} from './index.js'; - -export function MarkdownToolbarSetting(props: SettingProps): TRXComponent { - return html` - <${Setting} ...${props}> +export function MarkdownToolbarSetting(props: SettingProps): JSX.Element { + return ( +

    Adds a toolbar with a selection of Markdown snippets that when used will insert the according Markdown where your cursor is. Particularly useful - for the${' '} - <${Link} + for the{" "} + /spoilerbox syntax. If you have text selected, the Markdown will be inserted around your text. - -
    - - A full list of the snippets is available${' '} - <${Link} +
    A full list of the snippets is available{" "} + .

    - - `; +
    + ); } diff --git a/source/options/components/themed-logo.ts b/source/options/components/themed-logo.ts deleted file mode 100644 index ba6380c..0000000 --- a/source/options/components/themed-logo.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {html} from 'htm/preact'; - -import {Setting, SettingProps} from './index.js'; - -export function ThemedLogoSetting(props: SettingProps): TRXComponent { - return html` - <${Setting} ...${props}> -

    - Replaces the Tildes logo in the site header with a dynamic one that uses - the colors of your chosen Tildes theme. -

    - - `; -} diff --git a/source/options/components/themed-logo.tsx b/source/options/components/themed-logo.tsx new file mode 100644 index 0000000..d3d8048 --- /dev/null +++ b/source/options/components/themed-logo.tsx @@ -0,0 +1,13 @@ +import {type JSX} from "preact"; +import {Setting, type SettingProps} from "./index.js"; + +export function ThemedLogoSetting(props: SettingProps): JSX.Element { + return ( + +

    + Replaces the Tildes logo in the site header with a dynamic one that uses + the colors of your chosen Tildes theme. +

    +
    + ); +} diff --git a/source/options/components/user-labels.ts b/source/options/components/user-labels.tsx similarity index 76% rename from source/options/components/user-labels.ts rename to source/options/components/user-labels.tsx index 2731ee4..ac3022b 100644 --- a/source/options/components/user-labels.ts +++ b/source/options/components/user-labels.tsx @@ -1,25 +1,25 @@ -import {html} from 'htm/preact'; +import {Setting, type SettingProps} from "./index.js"; -import {Setting, SettingProps} from './index.js'; - -export function UserLabelsSetting(props: SettingProps): TRXComponent { - return html` - <${Setting} ...${props}> +export function UserLabelsSetting(props: SettingProps) { + return ( +

    Adds a way to create customizable labels to users. Wherever a link to a person's profile is available, a [+] will be put next to it. Clicking on that will bring up a dialog to add a new label and clicking on existing labels will bring up the same dialog to edit them.
    - Or you can use the dedicated${' '} - User Label Editor - to add, edit, or remove user labels. + Or you can use the dedicated{" "} + User Label Editor to add, + edit, or remove user labels.

    View Customizable Values
      -
    • Username: who to apply the label to.
    • +
    • + Username: who to apply the label to. +
    • Priority: determines the order of labels. If multiple labels have the same priority they will be sorted alphabetically. In the @@ -41,6 +41,6 @@ export function UserLabelsSetting(props: SettingProps): TRXComponent {
    - - `; +
    + ); } diff --git a/source/options/components/username-colors.ts b/source/options/components/username-colors.ts deleted file mode 100644 index deae1e2..0000000 --- a/source/options/components/username-colors.ts +++ /dev/null @@ -1,163 +0,0 @@ -import {html} from 'htm/preact'; -import {Component} from 'preact'; - -import Settings from '../../settings.js'; -import {log} from '../../utilities/exports.js'; -import {Setting, SettingProps} from './index.js'; - -type State = { - previewChecked: 'off' | 'foreground' | 'background'; - usernameColors: UsernameColor[]; -}; - -export class UsernameColorsSetting extends Component { - constructor(props: SettingProps) { - super(props); - - this.state = { - previewChecked: 'off', - usernameColors: [], - }; - } - - async componentDidMount() { - const settings = await Settings.fromSyncStorage(); - this.setState({usernameColors: settings.data.usernameColors}); - } - - addNewColor = () => { - let id = 1; - if (this.state.usernameColors.length > 0) { - id = this.state.usernameColors.sort((a, b) => b.id - a.id)[0].id + 1; - } - - const newColor: UsernameColor = { - color: '', - id, - username: '', - }; - - this.setState({ - usernameColors: [...this.state.usernameColors, newColor], - }); - }; - - removeColor = (targetId: number) => { - const usernameColors = this.state.usernameColors.filter( - ({id}) => id !== targetId, - ); - this.setState({usernameColors}); - }; - - saveChanges = async () => { - const settings = await Settings.fromSyncStorage(); - settings.data.usernameColors = this.state.usernameColors; - await settings.save(); - }; - - togglePreview = async () => { - let {previewChecked} = this.state; - - // eslint-disable-next-line default-case - switch (previewChecked) { - case 'off': - previewChecked = 'foreground'; - break; - case 'foreground': - previewChecked = 'background'; - break; - case 'background': - previewChecked = 'off'; - break; - } - - this.setState({previewChecked}); - }; - - onInput = (event: Event, id: number, key: 'color' | 'username') => { - const colorIndex = this.state.usernameColors.findIndex( - (color) => color.id === id, - ); - if (colorIndex === -1) { - log(`Tried to edit unknown UsernameColor ID: ${id}`); - return; - } - - const newValue = (event.target as HTMLInputElement).value; - this.state.usernameColors[colorIndex][key] = newValue; - this.setState({usernameColors: this.state.usernameColors}); - }; - - render() { - const {previewChecked, usernameColors} = this.state; - usernameColors.sort((a, b) => a.id - b.id); - - const editors = usernameColors.map(({color, id, username}) => { - const style: Record = {}; - if (previewChecked === 'background') { - style.backgroundColor = color; - } else if (previewChecked === 'foreground') { - style.color = color; - } - - const usernameHandler = (event: Event) => { - this.onInput(event, id, 'username'); - }; - - const colorHandler = (event: Event) => { - this.onInput(event, id, 'color'); - }; - - const removeHandler = () => { - this.removeColor(id); - }; - - return html` -
    - - - -
    - `; - }); - - return html` - <${Setting} ...${this.props}> -

    - Assign custom colors to usernames. -
    - You can enter multiple usernames separated by a comma if you want them - to use the same color. -

    - -
    - - - - - -
    - - ${editors} - - `; - } -} diff --git a/source/options/components/username-colors.tsx b/source/options/components/username-colors.tsx new file mode 100644 index 0000000..ee956f6 --- /dev/null +++ b/source/options/components/username-colors.tsx @@ -0,0 +1,176 @@ +import {Component} from "preact"; +import {type Value} from "@holllo/webextension-storage"; +import {log} from "../../utilities/exports.js"; +import { + type UsernameColorsData, + type UsernameColor, + Feature, + fromStorage, +} from "../../storage/common.js"; +import {Setting, type SettingProps} from "./index.js"; + +type State = { + previewChecked: "off" | "foreground" | "background"; + usernameColors: Value; +}; + +export class UsernameColorsSetting extends Component { + constructor(props: SettingProps) { + super(props); + + this.state = { + previewChecked: "off", + usernameColors: undefined!, + }; + } + + async componentDidMount() { + this.setState({usernameColors: await fromStorage(Feature.UsernameColors)}); + } + + addNewColor = () => { + let id = 1; + if (this.state.usernameColors.value.length > 0) { + id = + this.state.usernameColors.value.sort((a, b) => b.id - a.id)[0].id + 1; + } + + const newColor: UsernameColor = { + color: "", + id, + username: "", + }; + + this.state.usernameColors.value.push(newColor); + this.setState({ + usernameColors: this.state.usernameColors, + }); + }; + + removeColor = (targetId: number) => { + const targetIndex = this.state.usernameColors.value.findIndex( + ({id}) => id === targetId, + ); + this.state.usernameColors.value.splice(targetIndex, 1); + this.setState({usernameColors: this.state.usernameColors}); + }; + + saveChanges = async () => { + await this.state.usernameColors.save(); + }; + + togglePreview = async () => { + let {previewChecked} = this.state; + + // eslint-disable-next-line default-case + switch (previewChecked) { + case "off": { + previewChecked = "foreground"; + break; + } + + case "foreground": { + previewChecked = "background"; + break; + } + + case "background": { + previewChecked = "off"; + break; + } + } + + this.setState({previewChecked}); + }; + + onInput = (event: Event, id: number, key: "color" | "username") => { + const colorIndex = this.state.usernameColors.value.findIndex( + (color) => color.id === id, + ); + if (colorIndex === -1) { + log(`Tried to edit unknown UsernameColor ID: ${id}`); + return; + } + + const newValue = (event.target as HTMLInputElement).value; + this.state.usernameColors.value[colorIndex][key] = newValue; + this.setState({usernameColors: this.state.usernameColors}); + }; + + render() { + const {previewChecked, usernameColors} = this.state; + if (usernameColors === undefined) { + return; + } + + usernameColors.value.sort((a, b) => a.id - b.id); + + const editors = usernameColors.value.map(({color, id, username}) => { + const style: Record = {}; + if (previewChecked === "background") { + style.backgroundColor = color; + } else if (previewChecked === "foreground") { + style.color = color; + } + + const usernameHandler = (event: Event) => { + this.onInput(event, id, "username"); + }; + + const colorHandler = (event: Event) => { + this.onInput(event, id, "color"); + }; + + const removeHandler = () => { + this.removeColor(id); + }; + + return ( +
    + + + +
    + ); + }); + + return ( + +

    + Assign custom colors to usernames. +
    + You can enter multiple usernames separated by a comma if you want them + to use the same color. +

    + +
    + + + + + +
    + + {editors} +
    + ); + } +} diff --git a/source/options/context.ts b/source/options/context.ts index e642ca5..85ae439 100644 --- a/source/options/context.ts +++ b/source/options/context.ts @@ -1,11 +1,10 @@ -import {createContext} from 'preact'; - -import Settings from '../settings.js'; +import {createContext} from "preact"; +import {type Feature} from "../storage/common.js"; type AppContextValues = { - settings: Settings; - setActiveFeature: (feature: string) => void; - toggleFeature: (feature: string) => void; + setActiveFeature: (feature: Feature) => void; + toggleFeature: (feature: Feature) => void; }; +// eslint-disable-next-line @typescript-eslint/naming-convention export const AppContext = createContext(null!); diff --git a/source/options/features.ts b/source/options/features.ts index 881f41b..ee8fc50 100644 --- a/source/options/features.ts +++ b/source/options/features.ts @@ -1,4 +1,4 @@ -import Settings from '../settings.js'; +import {Feature} from "../storage/common.js"; import { AboutSetting, AnonymizeUsernamesSetting, @@ -10,86 +10,86 @@ import { ThemedLogoSetting, UserLabelsSetting, UsernameColorsSetting, -} from './components/exports.js'; +} from "./components/exports.js"; -type Feature = { +type FeatureData = { availableSince: Date; index: number; - key: keyof RemoveIndexSignature; + key: Feature; title: string; - component: () => any; + component: any; }; -export const features: Feature[] = [ +export const features: FeatureData[] = [ { - availableSince: new Date('2022-02-23'), + availableSince: new Date("2022-02-23"), index: 0, - key: 'anonymizeUsernames', - title: 'Anonymize Usernames', - component: () => AnonymizeUsernamesSetting, + key: Feature.AnonymizeUsernames, + title: "Anonymize Usernames", + component: AnonymizeUsernamesSetting, }, { - availableSince: new Date('2020-10-03'), + availableSince: new Date("2020-10-03"), index: 0, - key: 'autocomplete', - title: 'Autocomplete', - component: () => AutocompleteSetting, + key: Feature.Autocomplete, + title: "Autocomplete", + component: AutocompleteSetting, }, { - availableSince: new Date('2019-11-10'), + availableSince: new Date("2019-11-10"), index: 0, - key: 'backToTop', - title: 'Back To Top', - component: () => BackToTopSetting, + key: Feature.BackToTop, + title: "Back To Top", + component: BackToTopSetting, }, { - availableSince: new Date('2019-11-12'), + availableSince: new Date("2019-11-12"), index: 0, - key: 'hideVotes', - title: 'Hide Votes', - component: () => HideVotesSetting, + key: Feature.HideVotes, + title: "Hide Votes", + component: HideVotesSetting, }, { - availableSince: new Date('2019-11-10'), + availableSince: new Date("2019-11-10"), index: 0, - key: 'jumpToNewComment', - title: 'Jump To New Comment', - component: () => JumpToNewCommentSetting, + key: Feature.JumpToNewComment, + title: "Jump To New Comment", + component: JumpToNewCommentSetting, }, { - availableSince: new Date('2019-11-12'), + availableSince: new Date("2019-11-12"), index: 0, - key: 'markdownToolbar', - title: 'Markdown Toolbar', - component: () => MarkdownToolbarSetting, + key: Feature.MarkdownToolbar, + title: "Markdown Toolbar", + component: MarkdownToolbarSetting, }, { - availableSince: new Date('2022-02-27'), + availableSince: new Date("2022-02-27"), index: 0, - key: 'themedLogo', - title: 'Themed Logo', - component: () => ThemedLogoSetting, + key: Feature.ThemedLogo, + title: "Themed Logo", + component: ThemedLogoSetting, }, { - availableSince: new Date('2019-11-10'), + availableSince: new Date("2019-11-10"), index: 0, - key: 'userLabels', - title: 'User Labels', - component: () => UserLabelsSetting, + key: Feature.UserLabels, + title: "User Labels", + component: UserLabelsSetting, }, { - availableSince: new Date('2022-02-25'), + availableSince: new Date("2022-02-25"), index: 0, - key: 'usernameColors', - title: 'Username Colors', - component: () => UsernameColorsSetting, + key: Feature.UsernameColors, + title: "Username Colors", + component: UsernameColorsSetting, }, { - availableSince: new Date('2019-11-10'), + availableSince: new Date("2019-11-10"), index: 1, - key: 'debug', - title: 'About & Info', - component: () => AboutSetting, + key: Feature.Debug, + title: "About & Info", + component: AboutSetting, }, ]; diff --git a/source/options/options.ts b/source/options/options.ts deleted file mode 100644 index dae302b..0000000 --- a/source/options/options.ts +++ /dev/null @@ -1,169 +0,0 @@ -import {html} from 'htm/preact'; -import {Component, render} from 'preact'; - -import Settings from '../settings.js'; -import { - Link, - createReportTemplate, - initializeGlobals, -} from '../utilities/exports.js'; -import {AppContext} from './context.js'; -import {features} from './features.js'; - -window.addEventListener('load', async () => { - initializeGlobals(); - const settings = await Settings.fromSyncStorage(); - - render( - html`<${App} manifest=${settings.manifest()} settings=${settings} />`, - document.body, - ); -}); - -type Props = { - manifest: TRXManifest; - settings: Settings; -}; - -type State = { - activeFeature: string; - enabledFeatures: Set; -}; - -class App extends Component { - state: State; - - // Duration for how long the "NEW" indicator should appear next to a feature, - // currently 14 days. - readonly newFeatureDuration = 14 * 24 * 60 * 60 * 1000; - - constructor(props: Props) { - super(props); - - const {settings} = props; - - this.state = { - activeFeature: settings.data.latestActiveFeatureTab, - enabledFeatures: this.getEnabledFeatures(), - }; - } - - getEnabledFeatures = (): Set => { - return new Set( - Object.entries(this.props.settings.features) - .filter(([_, value]) => value) - .map(([key, _]) => key), - ); - }; - - setActiveFeature = (feature: string) => { - const {settings} = this.props; - settings.data.latestActiveFeatureTab = feature; - void settings.save(); - - this.setState({activeFeature: feature}); - }; - - toggleFeature = (feature: string) => { - const {settings} = this.props; - settings.features[feature] = !settings.features[feature]; - void settings.save(); - - const features = this.getEnabledFeatures(); - this.setState({enabledFeatures: features}); - }; - - render() { - const {manifest, settings} = this.props; - const {activeFeature, enabledFeatures} = this.state; - - // Create the version link for the header. - const version = manifest.version; - const versionURL = encodeURI( - `https://gitlab.com/tildes-community/tildes-reextended/-/tags/${version}`, - ); - const versionLink = html` - <${Link} class="version" text="v${version}" url="${versionURL}" /> - `; - - // Create the GitLab report a bug link for the footer. - const gitlabTemplate = createReportTemplate('gitlab', version); - const gitlabURL = encodeURI( - `https://gitlab.com/tildes-community/tildes-reextended/issues/new?issue[description]=${gitlabTemplate}`, - ); - const gitlabLink = html`<${Link} text="GitLab" url="${gitlabURL}" />`; - - // Create the Tildes report a bug link for the footer. - const tildesReportTemplate = createReportTemplate('tildes', version); - const tildesURL = encodeURI( - `https://tildes.net/user/Community/new_message?subject=Tildes ReExtended Bug&message=${tildesReportTemplate}`, - ); - const tildesLink = html`<${Link} text="Tildes" url="${tildesURL}" />`; - - const asideElements = features.map(({availableSince, key, title}) => { - const isNew = - Date.now() - availableSince.getTime() < this.newFeatureDuration - ? html`NEW` - : undefined; - - return html` -
  • - ${title}${isNew} -
  • - `; - }); - - const mainElements = features.map( - ({key, title, component}) => - html` - <${component()} - class="${activeFeature === key ? '' : 'trx-hidden'}" - enabled="${enabledFeatures.has(key)}" - feature=${key} - key=${key} - title="${title}" - /> - `, - ); - - return html` - <${AppContext.Provider} - value=${{ - settings, - setActiveFeature: this.setActiveFeature, - toggleFeature: this.toggleFeature, - }} - > - - -
    - -
    ${mainElements}
    -
    - -
    -

    Report a bug via ${gitlabLink} or ${tildesLink}.

    -

    © Tildes Community and Contributors

    -
    - - `; - } -} diff --git a/source/options/setup.tsx b/source/options/setup.tsx new file mode 100644 index 0000000..fbd71a0 --- /dev/null +++ b/source/options/setup.tsx @@ -0,0 +1,165 @@ +import {Component, render} from "preact"; +import browser from "webextension-polyfill"; +import {type Value} from "@holllo/webextension-storage"; +import "../scss/index.scss"; +import { + Link, + createReportTemplate, + initializeGlobals, +} from "../utilities/exports.js"; +import {type Feature, Data, fromStorage} from "../storage/common.js"; +import {AppContext} from "./context.js"; +import {features} from "./features.js"; + +window.addEventListener("load", async () => { + initializeGlobals(); + const manifest = browser.runtime.getManifest(); + + render(, document.body); +}); + +type Props = { + manifest: browser.Manifest.WebExtensionManifest; +}; + +type State = { + activeFeature: Value; + enabledFeatures: Value>; +}; + +class App extends Component { + state: State; + + // Duration for how long the "NEW" indicator should appear next to a feature, + // currently 14 days. + readonly newFeatureDuration = 14 * 24 * 60 * 60 * 1000; + + constructor(props: Props) { + super(props); + + this.state = { + activeFeature: undefined!, + enabledFeatures: undefined!, + }; + } + + async componentDidMount() { + this.setState({ + activeFeature: await fromStorage(Data.LatestActiveFeatureTab), + enabledFeatures: await fromStorage(Data.EnabledFeatures), + }); + } + + setActiveFeature = (feature: Feature) => { + const {activeFeature} = this.state; + activeFeature.value = feature; + void activeFeature.save(); + this.setState({activeFeature}); + }; + + toggleFeature = (feature: Feature) => { + const {enabledFeatures} = this.state; + if (enabledFeatures.value.has(feature)) { + enabledFeatures.value.delete(feature); + } else { + enabledFeatures.value.add(feature); + } + + void enabledFeatures.save(); + this.setState({enabledFeatures}); + }; + + render() { + const {manifest} = this.props; + const {activeFeature, enabledFeatures} = this.state; + if (activeFeature === undefined || enabledFeatures === undefined) { + return; + } + + // Create the version link for the header. + const version = manifest.version; + const versionUrl = encodeURI( + `https://gitlab.com/tildes-community/tildes-reextended/-/tags/${version}`, + ); + const versionLink = ( + + ); + // Create the GitLab report a bug link for the footer. + const gitlabTemplate = createReportTemplate("gitlab", version); + const gitlabUrl = encodeURI( + `https://gitlab.com/tildes-community/tildes-reextended/issues/new?issue[description]=${gitlabTemplate}`, + ); + const gitlabLink = ; + + // Create the Tildes report a bug link for the footer. + const tildesReportTemplate = createReportTemplate("tildes", version); + const tildesUrl = encodeURI( + `https://tildes.net/user/Community/new_message?subject=Tildes ReExtended Bug&message=${tildesReportTemplate}`, + ); + const tildesLink = ; + + const asideElements = features.map(({availableSince, key, title}) => { + const isNew = + Date.now() - availableSince.getTime() < this.newFeatureDuration ? ( + NEW + ) : undefined; + + return ( +
  • { + this.setActiveFeature(key); + }} + > + {title} + {isNew} +
  • + ); + }); + + const mainElements = features.map(({key, title, component: Setting}) => { + return ( + + ); + }); + + return ( + + + +
    + +
    {mainElements}
    +
    + +
    +

    + Report a bug via {gitlabLink} or {tildesLink}. +

    +

    © Tildes Community and Contributors

    +
    +
    + ); + } +} diff --git a/source/options/user-label-editor.html b/source/options/user-label-editor.html deleted file mode 100644 index 70eb43c..0000000 --- a/source/options/user-label-editor.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - Tildes ReExtended - - - - - - - - - - - - diff --git a/source/options/user-label-editor.ts b/source/options/user-label-editor.ts deleted file mode 100644 index 8399abe..0000000 --- a/source/options/user-label-editor.ts +++ /dev/null @@ -1,241 +0,0 @@ -import {html} from 'htm/preact'; -import {Component, render} from 'preact'; - -import Settings from '../settings.js'; -import { - initializeGlobals, - isValidTildesUsername, - log, -} from '../utilities/exports.js'; - -window.addEventListener('load', async () => { - initializeGlobals(); - const settings = await Settings.fromSyncStorage(); - - render(html`<${App} settings=${settings} />`, document.body); -}); - -type Props = { - settings: Settings; -}; - -type State = { - hasUnsavedChanges: boolean; - newLabelUsername: string; - userLabels: UserLabel[]; -}; - -class App extends Component { - constructor(props: Props) { - super(props); - - this.state = { - hasUnsavedChanges: false, - newLabelUsername: '', - userLabels: props.settings.data.userLabels, - }; - } - - addNewLabel = () => { - const {newLabelUsername, userLabels} = this.state; - if (!isValidTildesUsername(newLabelUsername)) { - return; - } - - const existingUserLabel = userLabels.find( - ({username}) => username.toLowerCase() === newLabelUsername.toLowerCase(), - ); - - let id = 1; - if (userLabels.length > 0) { - id = userLabels.sort((a, b) => b.id - a.id)[0].id + 1; - } - - userLabels.push({ - color: '#ff00ff', - id, - priority: 0, - text: 'New Label', - username: existingUserLabel?.username ?? newLabelUsername, - }); - this.setState({userLabels}); - }; - - onNewUsernameInput = (event: Event) => { - const username = (event.target as HTMLInputElement).value; - this.setState({newLabelUsername: username}); - }; - - editUserLabel = (event: Event, targetId: number, key: keyof UserLabel) => { - const index = this.state.userLabels.findIndex(({id}) => id === targetId); - if (index === -1) { - log(`Tried to edit UserLabel with unknown ID: ${targetId}`); - return; - } - - const newValue = (event.target as HTMLInputElement).value; - if (key === 'id' || key === 'priority') { - this.state.userLabels[index][key] = Number(newValue); - } else { - this.state.userLabels[index][key] = newValue; - } - - this.setState({ - hasUnsavedChanges: true, - userLabels: this.state.userLabels, - }); - }; - - removeUserLabel = (targetId: number) => { - const userLabels = this.state.userLabels.filter(({id}) => id !== targetId); - this.setState({ - hasUnsavedChanges: true, - userLabels, - }); - }; - - saveUserLabels = () => { - const {settings} = this.props; - const {userLabels} = this.state; - settings.data.userLabels = userLabels; - void settings.save(); - this.setState({hasUnsavedChanges: false}); - }; - - render() { - const {hasUnsavedChanges, newLabelUsername, userLabels} = this.state; - userLabels.sort((a, b) => a.username.localeCompare(b.username)); - - const labelGroups: Map = new Map(); - for (const label of userLabels) { - const group = labelGroups.get(label.username) ?? []; - group.push(label); - labelGroups.set(label.username, group); - } - - const labels: TRXComponent[] = []; - for (const [username, group] of labelGroups) { - group.sort((a, b) => - a.priority === b.priority - ? a.text.localeCompare(b.text) - : b.priority - a.priority, - ); - const labelPreviews: TRXComponent[] = group.map( - ({color, text}) => html` - - ${text} - - `, - ); - - group.sort((a, b) => a.id - b.id); - const userLabels: TRXComponent[] = []; - for (const [index, label] of group.entries()) { - const textHandler = (event: Event) => { - this.editUserLabel(event, label.id, 'text'); - }; - - const colorHandler = (event: Event) => { - this.editUserLabel(event, label.id, 'color'); - }; - - const priorityHandler = (event: Event) => { - this.editUserLabel(event, label.id, 'priority'); - }; - - const removeHandler = () => { - this.removeUserLabel(label.id); - }; - - userLabels.push( - html` -
  • -
    - ${index === 0 ? html`` : undefined} - -
    - -
    - ${index === 0 ? html`` : undefined} - -
    - -
    - ${index === 0 ? html`` : undefined} - -
    - -
    - ${index === 0 ? html`` : undefined} - -
    -
  • - `, - ); - } - - labels.push(html` -
    -

    ${username} ${labelPreviews}

    -
      - ${userLabels} -
    -
    - `); - } - - return html` - - -
    -

    - To add a new label, enter the username for who you'd like to add the - label for, then press the Add New Label button. -
    - Changes are not automatically saved! -
    - If there are any unsaved changes an asterisk will appear in the Save - All Changes button. To undo all unsaved changes simply refresh the - page. -

    - -
    - - - - - -
    -
    ${labels}
    -
    - `; - } -} diff --git a/source/options/user-label-editor.tsx b/source/options/user-label-editor.tsx new file mode 100644 index 0000000..4f84b92 --- /dev/null +++ b/source/options/user-label-editor.tsx @@ -0,0 +1,243 @@ +import {Component, render, type JSX} from "preact"; +import {type Value} from "@holllo/webextension-storage"; +import { + initializeGlobals, + isValidTildesUsername, + log, +} from "../utilities/exports.js"; +import { + type UserLabelsData, + type UserLabel, + fromStorage, + Feature, +} from "../storage/common.js"; +import "../scss/index.scss"; +import "../scss/user-label-editor.scss"; + +window.addEventListener("load", async () => { + initializeGlobals(); + const userLabels = await fromStorage(Feature.UserLabels); + render(, document.body); +}); + +type Props = { + userLabels: Value; +}; + +type State = { + hasUnsavedChanges: boolean; + newLabelUsername: string; + userLabels: UserLabelsData; +}; + +class App extends Component { + constructor(props: Props) { + super(props); + + this.state = { + hasUnsavedChanges: false, + newLabelUsername: "", + userLabels: props.userLabels.value, + }; + } + + addNewLabel = () => { + const {newLabelUsername, userLabels} = this.state; + if (!isValidTildesUsername(newLabelUsername)) { + return; + } + + const existingUserLabel = userLabels.find( + ({username}) => username.toLowerCase() === newLabelUsername.toLowerCase(), + ); + + let id = 1; + if (userLabels.length > 0) { + id = userLabels.sort((a, b) => b.id - a.id)[0].id + 1; + } + + userLabels.push({ + color: "#ff00ff", + id, + priority: 0, + text: "New Label", + username: existingUserLabel?.username ?? newLabelUsername, + }); + this.setState({userLabels}); + }; + + onNewUsernameInput = (event: Event) => { + const username = (event.target as HTMLInputElement).value; + this.setState({newLabelUsername: username}); + }; + + editUserLabel = (event: Event, targetId: number, key: keyof UserLabel) => { + const index = this.state.userLabels.findIndex(({id}) => id === targetId); + if (index === -1) { + log(`Tried to edit UserLabel with unknown ID: ${targetId}`); + return; + } + + const newValue = (event.target as HTMLInputElement).value; + // eslint-disable-next-line unicorn/prefer-ternary + if (key === "id" || key === "priority") { + this.state.userLabels[index][key] = Number(newValue); + } else { + this.state.userLabels[index][key] = newValue; + } + + this.setState({ + hasUnsavedChanges: true, + userLabels: this.state.userLabels, + }); + }; + + removeUserLabel = (targetId: number) => { + const userLabels = this.state.userLabels.filter(({id}) => id !== targetId); + this.setState({ + hasUnsavedChanges: true, + userLabels, + }); + }; + + saveUserLabels = () => { + this.props.userLabels.value = this.state.userLabels; + void this.props.userLabels.save(); + this.setState({hasUnsavedChanges: false}); + }; + + render() { + const {hasUnsavedChanges, newLabelUsername, userLabels} = this.state; + userLabels.sort((a, b) => a.username.localeCompare(b.username)); + + const labelGroups = new Map(); + for (const label of userLabels) { + const group = labelGroups.get(label.username) ?? []; + group.push(label); + labelGroups.set(label.username, group); + } + + const labels: JSX.Element[] = []; + for (const [username, group] of labelGroups) { + group.sort((a, b) => + a.priority === b.priority + ? a.text.localeCompare(b.text) + : b.priority - a.priority, + ); + const labelPreviews: JSX.Element[] = group.map(({color, text}) => ( + + {text} + + )); + + group.sort((a, b) => a.id - b.id); + const userLabels: JSX.Element[] = []; + for (const [index, label] of group.entries()) { + const textHandler = (event: Event) => { + this.editUserLabel(event, label.id, "text"); + }; + + const colorHandler = (event: Event) => { + this.editUserLabel(event, label.id, "color"); + }; + + const priorityHandler = (event: Event) => { + this.editUserLabel(event, label.id, "priority"); + }; + + const removeHandler = () => { + this.removeUserLabel(label.id); + }; + + userLabels.push( +
  • +
    + {index === 0 ? : undefined} + +
    + +
    + {index === 0 ? : undefined} + +
    + +
    + {index === 0 ? : undefined} + +
    + +
    + {index === 0 ? : undefined} + +
    +
  • , + ); + } + + labels.push( +
    +

    + {username} {labelPreviews} +

    +
      {userLabels}
    +
    , + ); + } + + return ( + <> + + +
    +

    + To add a new label, enter the username for who you'd like to add the + label for, then press the Add New Label button. +
    + Changes are not automatically saved! +
    + If there are any unsaved changes an asterisk will appear in the Save + All Changes button. To undo all unsaved changes simply refresh the + page. +

    + +
    + + + + + +
    +
    {labels}
    +
    + + ); + } +} diff --git a/source/packages.d.ts b/source/packages.d.ts new file mode 100644 index 0000000..4311bd7 --- /dev/null +++ b/source/packages.d.ts @@ -0,0 +1,15 @@ +// Type definitions for third-party packages. + +declare module "esbuild-copy-static-files" { + import {type cpSync} from "node:fs"; + import {type Plugin} from "esbuild"; + + type CopySyncParameters = Parameters; + + type Options = { + src?: CopySyncParameters[0]; + dest?: CopySyncParameters[1]; + } & CopySyncParameters[2]; + + export default function (options: Options): Plugin; +} diff --git a/source/scripts/exports.ts b/source/scripts/exports.ts deleted file mode 100644 index 8cc7476..0000000 --- a/source/scripts/exports.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from './anonymize-usernames.js'; -export * from './autocomplete.js'; -export * from './back-to-top.js'; -export * from './hide-votes.js'; -export * from './jump-to-new-comment.js'; -export * from './markdown-toolbar.js'; -export * from './themed-logo.js'; -export * from './user-labels.js'; -export * from './username-colors.js'; diff --git a/source/scripts/hide-votes.ts b/source/scripts/hide-votes.ts deleted file mode 100644 index 2449f07..0000000 --- a/source/scripts/hide-votes.ts +++ /dev/null @@ -1,62 +0,0 @@ -import Settings from '../settings.js'; -import {log, querySelectorAll} from '../utilities/exports.js'; - -export function runHideVotesFeature(settings: Settings) { - const counts = hideVotes(settings); - log(`Hide Votes: Initialized for ${counts} votes.`); -} - -function hideVotes(settings: Settings): number { - let count = 0; - - if (settings.data.hideVotes.comments) { - const commentVotes = querySelectorAll( - '.btn-post-action[data-ic-put-to*="/vote"]:not(.trx-votes-hidden)', - '.btn-post-action[data-ic-delete-from*="/vote"]:not(.trx-votes-hidden)', - ); - count += commentVotes.length; - - for (const vote of commentVotes) { - vote.classList.add('trx-votes-hidden'); - if (!vote.textContent!.includes(' ')) { - continue; - } - - vote.textContent = vote.textContent!.slice( - 0, - vote.textContent!.indexOf(' '), - ); - } - } - - if (settings.data.hideVotes.ownComments) { - const ownComments = querySelectorAll('.comment-votes'); - count += ownComments.length; - for (const vote of ownComments) { - vote.classList.add('trx-hidden'); - } - } - - if (settings.data.hideVotes.topics || settings.data.hideVotes.ownTopics) { - const selectors: string[] = []; - - // Topics by other people will be encapsulated with a `