1
Fork 0

Compare commits

...

13 Commits

84 changed files with 7482 additions and 4754 deletions

3
.envrc Normal file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
use flake

115
.gitignore vendored
View File

@ -1,111 +1,8 @@
# Logs .direnv/
logs .vscode/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Build output directories
build/ build/
web-ext-artifacts/ chromium/
coverage/
# Firefox profile directory
firefox/ firefox/
node_modules/
web-ext-artifacts/

100
DEVELOPMENT.md Normal file
View File

@ -0,0 +1,100 @@
# Development Guide
## Prerequisites
### Nix + Direnv
If you have [Nix](https://nixos.org/) with flakes enabled and [Direnv](https://direnv.net/) installed, all you need to do is `direnv allow` the directory and all the prerequisites will be automatically installed. This may take a moment on first load.
Firefox and git are excluded, which are assumed to already be present on your system.
### Manual
To build and develop Tildes ReExtended you will need:
* [git](https://git-scm.com)
* [NodeJS](https://nodejs.org) (recommended 18.16.0)
* [pnpm](https://pnpm.io) (recommended 8.6.0)
* [cargo-make](https://sagiegurari.github.io/cargo-make/)
## cargo-make
All the different tasks we'd want to do are setup in `Makefile.toml` to be used with `cargo-make` (or the `makers` alias).
In the Tasks section below you can find a list of all the tasks, or you can look at the Makefile itself, where all the tasks have a description of what they do.
### Environment
Two important environment variables are `BROWSER` and `NODE_ENV`.
`BROWSER` dictates what browser should be targeted, by default `BROWSER="firefox"`. To target Chromium set `BROWSER="chromium"`.
`NODE_ENV` dictates minifying and optimization found in `source/build.ts`, by default `NODE_ENV="development"` which does no minifying and includes sourcemaps in the build. Setting `NODE_ENV="production"` will minify and exclude sourcemaps.
### Tasks
<details>
<summary>Click to view all tasks</summary>
* The most common scenario will likely be that you want a live-reloading browser instance, this can be done using the `dev` task.
```sh
# If makers doesn't work, replace it with cargo-make.
makers dev
# To change the environment, prefix the command with the
# variables you want to set.
BROWSER="chromium" NODE_ENV="production" makers dev
```
* To watch for changes but without starting a live-reloading browser instance, use the `watch` task.
```sh
makers watch
# Which is a simple alias for the following.
WATCH="true" makers build
```
* To start a browser instance with an already built extension present in the `build/` directory, the `run` task is available. Note that this will fail if the extension hasn't been built before.
```sh
makers run
```
* To clean the build directory, a `clean` task is available. This uses `trash-cli` so if you accidentally remove something and want it back, check your trash bin where you can restore it.
```sh
makers clean
# Clean the Chromium directory.
BROWSER="chromium" makers clean
```
* To lint the code, `lint` is the task.
```sh
makers lint
# To only lint JS or SCSS.
makers lint-js
makers lint-scss
```
* To pack the WebExtension for publishing, `pack` is what you need.
```sh
# Make sure to set NODE_ENV, otherwise the extension size will be
# a lot bigger than it needs to be.
NODE_ENV="production" makers pack
# To pack Chromium.
BROWSER="chromium" NODE_ENV="production" makers pack
```
* Mozilla Addons requires the zipped source code too, since we're using minification, so `zip-source` is available. This uses Git's `archive` command.
```sh
makers zip-source
```
</details>

38
DIFFERENCES.md Normal file
View File

@ -0,0 +1,38 @@
# Differences
Tildes ReExtended is a reimagination of [Crius' original Tildes Extended](https://github.com/theCrius/tildes-extended). This document outlines the differences between the two.
## Removed Functionality
Large parts of the original Tildes Extended have been removed for various reasons:
* **Link In New Tab**: this functionality now exists natively and can be configured [in your settings](https://tildes.net/settings)!
* **Markdown Preview**: this too exists natively (although not a "live" preview). Another reason this isn't included is due to Tildes using a customized flavor of Markdown that is difficult to replicate accurately enough with what's available and keep up to date.
* **Sticky Header**: with the dedicated "Back To Top" button now I wasn't sure if there was a need for it, so it's left out.
* **Custom Styles**: this feature introduced many issues in Tildes Extended while better and more dedicated extensions exist, such as [Stylus](https://add0n.com/stylus.html), that can reliably handle custom styles instead.
## Extended Functionality
Some functionality has also been extended more:
* [x] The **Back To Top** button has been separated out into its own feature. It used to be apart of the "Jump To New Comments" one.
* [x] The **Themed Tildes Logo** feature now picks from theme-appropriate logos instead of a regular tilde character.
* [x] The **Jump To New Comment** button now uncollapses comments if the new one is collapsed or is deeper inside a collapsed one.
### User Labels
* [x] Multiple labels per person.
* [x] Specify priority of labels.
* [x] A dropdown with theme-appropriate colors for easy access.
* [x] Pick any hex color you want.
* [x] Dedicated interface to add, edit, and remove labels.
## New Functionality
And various new features have been added such as:
* [ ] Hide (and unhide) topics from the topic listing. [*WIP](https://gitlab.com/tildes-community/tildes-reextended/issues/3)
* [x] Hide your own and/or other people's vote counts.
* [x] Anonymize usernames.
* [x] Assign unique colors to people's usernames.
* [x] Export and import your extension settings.

View File

@ -1,6 +1,6 @@
The MIT License The MIT License
Copyright 2019-2022 Tildes Community and Contributors Copyright 2019-2023 Tildes Community and Contributors
https://gitlab.com/tildes-community/tildes-reextended https://gitlab.com/tildes-community/tildes-reextended
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy

67
Makefile.toml Normal file
View File

@ -0,0 +1,67 @@
[env]
# Set BROWSER="firefox" if not already defined.
# All browser targets are defined in `source/types.d.ts` as a global `$browser`.
BROWSER = { condition = { env_not_set = ["BROWSER"] }, value = "firefox" }
# Set NODE_ENV="development" if not already defined.
# Either "development" or "production" should be used.
NODE_ENV = { condition = { env_not_set = ["NODE_ENV"] }, value = "development" }
# Start a browser instance that will reload the extension when changes are made.
[tasks.dev]
clear = true
dependencies = ["build"]
command = "pnpm"
args = ["conc", "-c=auto", "-k", "makers watch", "makers run"]
# Build the WebExtension.
[tasks.build]
clear = true
command = "pnpm"
args = ["tsx", "source/build.ts"]
# Remove build directories.
[tasks.clean]
clear = true
command = "pnpm"
args = ["trash", "build/${BROWSER}"]
# Run all other linting tasks.
[tasks.lint]
clear = true
dependencies = ["lint-js", "lint-scss"]
# Run XO.
[tasks.lint-js]
clear = true
command = "pnpm"
args = ["xo"]
# Run Stylelint.
[tasks.lint-scss]
clear = true
command = "pnpm"
args = ["stylelint", "source/**/*.scss"]
# Re-build and pack the WebExtension for publishing.
[tasks.pack]
clear = true
dependencies = ["clean", "build"]
command = "pnpm"
args = ["web-ext", "build", "--config=build/web-ext-${BROWSER}.json"]
# Start a browser instance with the extension loaded.
[tasks.run]
clear = true
command = "pnpm"
args = ["web-ext", "run", "--config=build/web-ext-${BROWSER}.json"]
# Alias for `WATCH=true makers build`.
[tasks.watch]
env = { WATCH="true" }
extend = "build"
# Create a ZIP archive with only the source code, for AMO publishing.
[tasks.zip-source]
clear = true
command = "git"
args = ["archive", "--format=zip", "--output=build/tildes-reextended-source.zip", "HEAD"]

View File

@ -1,44 +1,7 @@
# Tildes ReExtended # Tildes ReExtended
> An updated and reimagined recreation of the [original Tildes Extended](https://github.com/theCrius/tildes-extended) web extension by Crius. > **The principal enhancement suite for Tildes.**
## Differences
### Removed Functionality
Large parts of the original Tildes Extended have been removed for various reasons:
* **Link In New Tab**: this functionality now exists natively and can be configured [in your settings](https://tildes.net/settings)!
* **Markdown Preview**: this too exists natively (although not a "live" preview). Another reason this isn't included is due to Tildes using a customized flavor of Markdown that is difficult to replicate accurately enough with what's available and keep up to date.
* **Sticky Header**: with the dedicated "Back To Top" button now I wasn't sure if there was a need for it, so it's left out.
* **Custom Styles**: this feature introduced many issues in Tildes Extended while better and more dedicated extensions exist such as [Stylus](https://add0n.com/stylus.html), that can reliably handle custom styles instead.
### Extended Functionality
Some functionality has also been extended more:
* [x] The **Back To Top** button has been separated out into its own feature. It used to be apart of the "Jump To New Comments" one.
* [x] The **Themed Tildes Logo** feature now picks from theme-appropriate logos instead of a regular tilde character.
* [x] The **Jump To New Comment** button now uncollapses comments if the new one is collapsed or is deeper inside a collapsed one.
#### User Labels
* [x] Multiple labels per person.
* [x] Specify priority of labels.
* [x] A dropdown with theme-appropriate colors for easy access.
* [x] Pick any hex color you want.
* [x] Dedicated interface to add, edit, and remove labels.
### New Functionality
And various new features have been added such as:
* [ ] Hide (and unhide) topics from the topic listing. [*WIP](https://gitlab.com/tildes-community/tildes-reextended/issues/3)
* [x] Hide your own and/or other people's vote counts.
* [x] Anonymize usernames.
* [x] Assign unique colors to people's usernames.
* [x] Export and import your extension settings.
## License ## License
Open-sourced under the [MIT License](https://gitlab.com/tildes-community/tildes-reextended/blob/main/LICENSE). Distributed under the [MIT](https://spdx.org/licenses/MIT.html) license, see [LICENSE](https://gitlab.com/tildes-community/tildes-reextended/-/blob/main/LICENSE) for more information.

59
flake.lock Normal file
View File

@ -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
}

13
flake.nix Normal file
View File

@ -0,0 +1,13 @@
{
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = import ./shell.nix { inherit pkgs; };
}
);
}

View File

@ -1,59 +1,45 @@
{ {
"name": "tildes-reextended", "type": "module",
"description": "An updated and reimagined recreation of Tildes Extended to enhance and improve the experience of Tildes.net.",
"license": "MIT",
"repository": "https://gitlab.com/tildes-community/tildes-reextended",
"authors": [
"Bauke <me@bauke.xyz>"
],
"private": true, "private": true,
"scripts": {
"start": "vite build -m development --watch",
"clean": "trash build web-ext-artifacts",
"build": "pnpm clean && vite build && web-ext build --source-dir build && pnpm zip-source",
"zip-source": "git archive --format zip --output web-ext-artifacts/tildes_reextended-source.zip HEAD",
"test": "xo && stylelint 'source/**/*.scss' && tsc"
},
"dependencies": { "dependencies": {
"@holllo/migration-helper": "^0.1.4",
"@holllo/webextension-storage": "^0.2.0",
"caret-pos": "^2.0.0", "caret-pos": "^2.0.0",
"debounce": "^1.2.1", "debounce": "^1.2.1",
"htm": "^3.1.0", "modern-normalize": "^2.0.0",
"migration-helper": "^0.1.2",
"modern-normalize": "^1.1.0",
"platform": "^1.3.6", "platform": "^1.3.6",
"preact": "^10.6.6", "preact": "^10.15.1",
"webextension-polyfill": "^0.8.0" "webextension-polyfill": "^0.10.0"
}, },
"devDependencies": { "devDependencies": {
"@preact/preset-vite": "^2.1.7", "@bauke/eslint-config": "^0.1.2",
"@bauke/prettier-config": "^0.1.2",
"@bauke/stylelint-config": "^0.1.2",
"@types/debounce": "^1.2.1", "@types/debounce": "^1.2.1",
"@types/node": "^20.3.1",
"@types/platform": "^1.3.4", "@types/platform": "^1.3.4",
"@types/webextension-polyfill": "^0.8.2", "@types/webextension-polyfill": "^0.10.0",
"postcss": "^8.4.8", "concurrently": "^8.2.0",
"sass": "^1.49.9", "cssnano": "^6.0.1",
"stylelint": "^14.5.3", "esbuild": "^0.18.6",
"stylelint-config-standard-scss": "^3.0.0", "esbuild-copy-static-files": "^0.1.0",
"esbuild-sass-plugin": "^2.10.0",
"postcss": "^8.4.24",
"sass": "^1.63.6",
"stylelint": "^15.9.0",
"trash-cli": "^5.0.0", "trash-cli": "^5.0.0",
"typescript": "^4.6.2", "tsx": "^3.12.7",
"vite": "^2.8.6", "typescript": "^5.1.3",
"vite-plugin-web-extension": "^1.1.3", "web-ext": "^7.6.2",
"web-ext": "^6.7.0", "xo": "^0.54.2"
"xo": "^0.48.0"
}, },
"prettier": "@bauke/prettier-config",
"stylelint": { "stylelint": {
"extends": [ "extends": "@bauke/stylelint-config"
"stylelint-config-standard-scss"
],
"rules": {
"no-descending-specificity": null,
"string-quotes": "single"
}
}, },
"xo": { "xo": {
"extends": "@bauke/eslint-config",
"prettier": true, "prettier": true,
"rules": {
"@typescript-eslint/naming-convention": "off"
},
"space": true "space": true
} }
} }

File diff suppressed because it is too large Load Diff

7
shell.nix Normal file
View File

@ -0,0 +1,7 @@
{ pkgs ? import <nixpkgs> { } }:
with pkgs;
mkShell rec {
packages = [ cargo-make nodejs nodePackages.pnpm ];
}

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tildes ReExtended</title>
<link rel="shortcut icon" href="/tildes-reextended.png" type="image/png">
</head>
<body>
<noscript>
This WebExtension doesn't work without JavaScript enabled, sorry! 😭
</noscript>
<script type="module" src="/options/setup.js"></script>
</body>
</html>

View File

@ -5,17 +5,15 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tildes ReExtended</title> <title>Tildes ReExtended</title>
<link rel="shortcut icon" href="../assets/tildes-reextended-128.png" <link rel="shortcut icon" href="/tildes-reextended.png"
type="image/png"> type="image/png">
<link rel="stylesheet" href="../scss/modern-normalize.scss">
<link rel="stylesheet" href="../scss/index.scss">
</head> </head>
<body> <body>
<noscript> <noscript>
This web extension does not work without JavaScript, sorry. :( This web extension does not work without JavaScript, sorry. :(
</noscript> </noscript>
<script type="module" src="./options.ts"></script> <script type="module" src="/options/user-label-editor.js"></script>
</body> </body>
</html> </html>

View File

Before

Width:  |  Height:  |  Size: 963 B

After

Width:  |  Height:  |  Size: 963 B

View File

@ -1,4 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" viewBox="0 0 100 100"> <svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"
viewBox="0 0 100 100">
<!-- Default Tildes logo with Solarized colors: --> <!-- Default Tildes logo with Solarized colors: -->
<rect fill="#002b36" width="100" height="100"/> <rect fill="#002b36" width="100" height="100"/>
<g> <g>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -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();
}

View File

@ -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<void> {
await browser.runtime.openOptionsPage();
}

122
source/build.ts Normal file
View File

@ -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);
}

View File

@ -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<string, TRXComponent | undefined> = {};
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`
<div id="trx-container">
${components.jumpToNewComment} ${components.backToTop}
${components.autocomplete} ${components.userLabels}
</div>
`,
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();

View File

@ -1,4 +1,4 @@
import {log, querySelectorAll} from '../utilities/exports.js'; import {log, querySelectorAll} from "../../utilities/exports.js";
export function runAnonymizeUsernamesFeature() { export function runAnonymizeUsernamesFeature() {
const count = anonymizeUsernames(); const count = anonymizeUsernames();
@ -7,13 +7,13 @@ export function runAnonymizeUsernamesFeature() {
function anonymizeUsernames(): number { function anonymizeUsernames(): number {
const usernameElements = querySelectorAll<HTMLElement>( const usernameElements = querySelectorAll<HTMLElement>(
'.link-user:not(.trx-anonymized)', ".link-user:not(.trx-anonymized)",
); );
const replacements = generateReplacements(usernameElements); const replacements = generateReplacements(usernameElements);
for (const element of usernameElements) { for (const element of usernameElements) {
let username = usernameFromElement(element); let username = usernameFromElement(element);
const isMention = username.startsWith('@'); const isMention = username.startsWith("@");
if (isMention) { if (isMention) {
username = username.slice(1); username = username.slice(1);
} }
@ -21,7 +21,7 @@ function anonymizeUsernames(): number {
const replacement = replacements[username]; const replacement = replacements[username];
element.textContent = isMention ? `@${replacement}` : `${replacement}`; element.textContent = isMention ? `@${replacement}` : `${replacement}`;
element.classList.add('trx-anonymized'); element.classList.add("trx-anonymized");
element.dataset.trxUsername = username; element.dataset.trxUsername = username;
} }
@ -30,7 +30,7 @@ function anonymizeUsernames(): number {
function generateReplacements(elements: HTMLElement[]): Record<string, string> { function generateReplacements(elements: HTMLElement[]): Record<string, string> {
const usernames = new Set( const usernames = new Set(
elements.map((element) => usernameFromElement(element).replace(/@/g, '')), elements.map((element) => usernameFromElement(element).replace(/@/g, "")),
); );
const replacements: Record<string, string> = {}; const replacements: Record<string, string> = {};
@ -42,5 +42,5 @@ function generateReplacements(elements: HTMLElement[]): Record<string, string> {
} }
function usernameFromElement(element: HTMLElement): string { function usernameFromElement(element: HTMLElement): string {
return (element.textContent ?? '<unknown>').trim(); return (element.textContent ?? "<unknown>").trim();
} }

View File

@ -1,12 +1,12 @@
import {offset, Offset} from 'caret-pos'; import {offset, type Offset} from "caret-pos";
import {html} from 'htm/preact'; import {Component} from "preact";
import {Component} from 'preact'; import {type UserLabelsData} from "../../storage/common.js";
import {log, querySelectorAll} from "../../utilities/exports.js";
import Settings from '../settings.js';
import {log, querySelectorAll} from '../utilities/exports.js';
type Props = { type Props = {
settings: Settings; anonymizeUsernamesEnabled: boolean;
knownGroups: Set<string>;
userLabels: UserLabelsData;
}; };
type State = { type State = {
@ -25,22 +25,22 @@ export class AutocompleteFeature extends Component<Props, State> {
super(props); super(props);
// Get all the groups without their leading tildes. // Get all the groups without their leading tildes.
const groups = props.settings.data.knownGroups.map((value) => const groups = Array.from(props.knownGroups).map((value) =>
value.startsWith('~') ? value.slice(1) : value, value.startsWith("~") ? value.slice(1) : value,
); );
// Get all the usernames on the page without their leading @s, and get // Get all the usernames on the page without their leading @s, and get
// all the usernames from the saved user labels. // all the usernames from the saved user labels.
const usernameElements = querySelectorAll<HTMLElement>('.link-user'); const usernameElements = querySelectorAll<HTMLElement>(".link-user");
const usernames = [ const usernames = [
...usernameElements.map((value) => { ...usernameElements.map((value) => {
if (props.settings.features.anonymizeUsernames) { if (props.anonymizeUsernamesEnabled) {
return (value.dataset.trxUsername ?? '<unknown>').toLowerCase(); return (value.dataset.trxUsername ?? "<unknown>").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)); ].sort((a, b) => a.localeCompare(b));
this.state = { this.state = {
@ -55,7 +55,7 @@ export class AutocompleteFeature extends Component<Props, State> {
}; };
// Add a keydown listener for the entire page. // Add a keydown listener for the entire page.
document.addEventListener('keydown', this.globalInputHandler); document.addEventListener("keydown", this.globalInputHandler);
log( log(
`Autocomplete: Initialized with ${this.state.groups.size} groups and ` + `Autocomplete: Initialized with ${this.state.groups.size} groups and ` +
@ -66,7 +66,7 @@ export class AutocompleteFeature extends Component<Props, State> {
globalInputHandler = (event: KeyboardEvent) => { globalInputHandler = (event: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement; const activeElement = document.activeElement as HTMLElement;
// Only add the autocompletes to textareas. // Only add the autocompletes to textareas.
if (activeElement.tagName !== 'TEXTAREA') { if (activeElement.tagName !== "TEXTAREA") {
return; return;
} }
@ -79,8 +79,8 @@ export class AutocompleteFeature extends Component<Props, State> {
const dataAttribute = `data-trx-autocomplete-${target}`; const dataAttribute = `data-trx-autocomplete-${target}`;
if (event.key === prefix && !activeElement.getAttribute(dataAttribute)) { if (event.key === prefix && !activeElement.getAttribute(dataAttribute)) {
activeElement.setAttribute(dataAttribute, 'true'); activeElement.setAttribute(dataAttribute, "true");
activeElement.addEventListener('keyup', (event) => { activeElement.addEventListener("keyup", (event) => {
this.textareaInputHandler(event, prefix, target, values); this.textareaInputHandler(event, prefix, target, values);
}); });
@ -88,8 +88,8 @@ export class AutocompleteFeature extends Component<Props, State> {
} }
}; };
createHandler('~', 'groups', this.state.groups); createHandler("~", "groups", this.state.groups);
createHandler('@', 'usernames', this.state.usernames); createHandler("@", "usernames", this.state.usernames);
}; };
textareaInputHandler = ( textareaInputHandler = (
@ -120,7 +120,7 @@ export class AutocompleteFeature extends Component<Props, State> {
// If there is any whitespace in the input or there is no input at all, // If there is any whitespace in the input or there is no input at all,
// return early. Usernames cannot have whitespace in them. // return early. Usernames cannot have whitespace in them.
if (/\s/.test(input) || input === '') { if (/\s/.test(input) || input === "") {
this.hide(target); this.hide(target);
return; return;
} }
@ -143,11 +143,11 @@ export class AutocompleteFeature extends Component<Props, State> {
}; };
update = (target: string, matches: Set<string>) => { update = (target: string, matches: Set<string>) => {
if (target === 'groups') { if (target === "groups") {
this.setState({ this.setState({
groupsMatches: matches, groupsMatches: matches,
}); });
} else if (target === 'usernames') { } else if (target === "usernames") {
this.setState({ this.setState({
usernamesMatches: matches, usernamesMatches: matches,
}); });
@ -155,12 +155,12 @@ export class AutocompleteFeature extends Component<Props, State> {
}; };
show = (target: string, position: Offset) => { show = (target: string, position: Offset) => {
if (target === 'groups') { if (target === "groups") {
this.setState({ this.setState({
groupsHidden: false, groupsHidden: false,
groupsPosition: position, groupsPosition: position,
}); });
} else if (target === 'usernames') { } else if (target === "usernames") {
this.setState({ this.setState({
usernamesHidden: false, usernamesHidden: false,
usernamesPosition: position, usernamesPosition: position,
@ -169,25 +169,25 @@ export class AutocompleteFeature extends Component<Props, State> {
}; };
hide = (target: string) => { hide = (target: string) => {
if (target === 'groups') { if (target === "groups") {
this.setState({groupsHidden: true}); this.setState({groupsHidden: true});
} else if (target === 'usernames') { } else if (target === "usernames") {
this.setState({usernamesHidden: true}); this.setState({usernamesHidden: true});
} }
}; };
render() { render() {
// Create the list of groups and usernames. // Create the list of groups and usernames.
const groups = [...this.state.groupsMatches].map( const groups = [...this.state.groupsMatches].map((value) => (
(value) => html`<li>~${value}</li>`, <li>~{value}</li>
); ));
const usernames = [...this.state.usernamesMatches].map( const usernames = [...this.state.usernamesMatches].map((value) => (
(value) => html`<li>@${value}</li>`, <li>@{value}</li>
); ));
// Create the CSS class whether or not to hide the autocomplete. // Create the CSS class whether or not to hide the autocomplete.
const groupsHidden = this.state.groupsHidden ? 'trx-hidden' : ''; const groupsHidden = this.state.groupsHidden ? "trx-hidden" : "";
const usernamesHidden = this.state.usernamesHidden ? 'trx-hidden' : ''; const usernamesHidden = this.state.usernamesHidden ? "trx-hidden" : "";
// Create the position for the group and usernames autocomplete. // Create the position for the group and usernames autocomplete.
const groupsLeft = this.state.groupsPosition?.left ?? 0; const groupsLeft = this.state.groupsPosition?.left ?? 0;
@ -200,21 +200,23 @@ export class AutocompleteFeature extends Component<Props, State> {
(this.state.usernamesPosition?.top ?? 0) + (this.state.usernamesPosition?.top ?? 0) +
(this.state.usernamesPosition?.height ?? 0); (this.state.usernamesPosition?.height ?? 0);
return html` return (
<>
<ul <ul
id="trx-autocomplete-usernames" id="trx-autocomplete-usernames"
class="trx-autocomplete ${usernamesHidden}" class={`trx-autocomplete ${usernamesHidden}`}
style="left: ${usernamesLeft}px; top: ${usernamesTop}px" style={`left: ${usernamesLeft}px; top: ${usernamesTop}px`}
> >
${usernames} {usernames}
</ul> </ul>
<ul <ul
id="trx-autocomplete-groups" id="trx-autocomplete-groups"
class="trx-autocomplete ${groupsHidden}" class={`trx-autocomplete ${groupsHidden}`}
style="left: ${groupsLeft}px; top: ${groupsTop}px" style={`left: ${groupsLeft}px; top: ${groupsTop}px`}
> >
${groups} {groups}
</ul> </ul>
`; </>
);
} }
} }

View File

@ -1,8 +1,6 @@
import debounce from 'debounce'; import debounce from "debounce";
import {html} from 'htm/preact'; import {Component} from "preact";
import {Component} from 'preact'; import {log} from "../../utilities/exports.js";
import {log} from '../utilities/exports.js';
type Props = Record<string, unknown>; type Props = Record<string, unknown>;
@ -19,7 +17,7 @@ export class BackToTopFeature extends Component<Props, State> {
// Add a "debounced" handler to the scroll listener, this will make it so // Add a "debounced" handler to the scroll listener, this will make it so
// the handler will only run after scrolling has ended for 150ms. // 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. // Run the handler once in case the page was already scroll down.
this.scrollHandler(); this.scrollHandler();
@ -32,20 +30,20 @@ export class BackToTopFeature extends Component<Props, State> {
}; };
scrollToTop = () => { scrollToTop = () => {
window.scrollTo({behavior: 'smooth', top: 0}); window.scrollTo({behavior: "smooth", top: 0});
}; };
render() { render() {
const hidden = this.state.hidden ? 'trx-hidden' : ''; const hidden = this.state.hidden ? "trx-hidden" : "";
return html` return (
<a <a
id="trx-back-to-top" id="trx-back-to-top"
class="btn btn-primary ${hidden}" class={`btn btn-primary ${hidden}`}
onClick=${this.scrollToTop} onClick={this.scrollToTop}
> >
Back To Top Back To Top
</a> </a>
`; );
} }
} }

View File

@ -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";

View File

@ -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 `<button>`.
if (data.otherTopics) {
selectors.push("button > .topic-voting-votes:not(.trx-votes-hidden)");
}
// Topics by yourself will be encapsulated with a `<div>`.
if (data.ownTopics) {
selectors.push("div > .topic-voting-votes:not(.trx-votes-hidden)");
}
const topicVotes = querySelectorAll(...selectors);
count += topicVotes.length;
for (const vote of topicVotes) {
vote.classList.add("trx-votes-hidden");
vote.textContent = "-";
}
}
return count;
}

View File

@ -1,7 +1,5 @@
import {html} from 'htm/preact'; import {Component} from "preact";
import {Component} from 'preact'; import {log, querySelector, querySelectorAll} from "../../utilities/exports.js";
import {log, querySelector, querySelectorAll} from '../utilities/exports.js';
type Props = Record<string, unknown>; type Props = Record<string, unknown>;
@ -15,7 +13,7 @@ export class JumpToNewCommentFeature extends Component<Props, State> {
constructor() { constructor() {
super(); super();
const newCommentCount = querySelectorAll('.comment.is-comment-new').length; const newCommentCount = querySelectorAll(".comment.is-comment-new").length;
this.state = { this.state = {
hidden: false, hidden: false,
@ -24,7 +22,7 @@ export class JumpToNewCommentFeature extends Component<Props, State> {
}; };
if (newCommentCount === 0) { if (newCommentCount === 0) {
log('Jump To New Comment: 0 new comments found, not doing anything.'); log("Jump To New Comment: 0 new comments found, not doing anything.");
} else { } else {
log( log(
`Jump To New Comment: Initialized for ${newCommentCount} new comments.`, `Jump To New Comment: Initialized for ${newCommentCount} new comments.`,
@ -35,25 +33,25 @@ export class JumpToNewCommentFeature extends Component<Props, State> {
jump = () => { jump = () => {
// Remove the new comment style from the previous // Remove the new comment style from the previous
// jumped comment if there is one. // jumped comment if there is one.
this.state.previousComment?.classList.remove('is-comment-new'); this.state.previousComment?.classList.remove("is-comment-new");
const newestComment = document.querySelector<HTMLElement>( const newestComment = document.querySelector<HTMLElement>(
'.comment.is-comment-new', ".comment.is-comment-new",
); );
// If there are no new comments left, hide the button. // If there are no new comments left, hide the button.
if (newestComment === null) { if (newestComment === null) {
log('Jump To New Comment: Final comment reached, hiding the button.'); log("Jump To New Comment: Final comment reached, hiding the button.");
this.setState({hidden: true}); this.setState({hidden: true});
return; return;
} }
// If the newest comment is invisible, expand all comments to make it visible. // If the newest comment is invisible, expand all comments to make it visible.
if (newestComment.offsetParent === null) { if (newestComment.offsetParent === null) {
querySelector<HTMLElement>('[data-js-comment-expand-all-button]').click(); querySelector<HTMLElement>("[data-js-comment-expand-all-button]").click();
} }
newestComment.scrollIntoView({behavior: 'smooth'}); newestComment.scrollIntoView({behavior: "smooth"});
this.setState({previousComment: newestComment}); this.setState({previousComment: newestComment});
}; };
@ -65,17 +63,17 @@ export class JumpToNewCommentFeature extends Component<Props, State> {
return; return;
} }
const commentsLeft = querySelectorAll('.comment.is-comment-new').length; const commentsLeft = querySelectorAll(".comment.is-comment-new").length;
const hidden = this.state.hidden ? 'trx-hidden' : ''; const hidden = this.state.hidden ? "trx-hidden" : "";
return html` return (
<a <a
id="trx-jump-to-new-comment" id="trx-jump-to-new-comment"
class="btn btn-primary ${hidden}" class={`btn btn-primary ${hidden}`}
onClick="${this.jump}" onClick={this.jump}
> >
Jump To New Comment (${commentsLeft}/${this.state.newCommentCount}) Jump To New Comment ({commentsLeft}/{this.state.newCommentCount})
</a> </a>
`; );
} }
} }

View File

@ -1,7 +1,5 @@
import {html} from 'htm/preact'; import {render} from "preact";
import {render} from 'preact'; import {log, querySelectorAll} from "../../utilities/exports.js";
import {log, querySelectorAll} from '../utilities/exports.js';
type MarkdownSnippet = { type MarkdownSnippet = {
dropdown: boolean; dropdown: boolean;
@ -13,65 +11,65 @@ type MarkdownSnippet = {
const snippets: MarkdownSnippet[] = [ const snippets: MarkdownSnippet[] = [
{ {
dropdown: false, dropdown: false,
markdown: '[<>]()', markdown: "[<>]()",
name: 'Link', name: "Link",
}, },
{ {
dropdown: false, dropdown: false,
markdown: '```\n<>\n```', markdown: "```\n<>\n```",
name: 'Code', name: "Code",
}, },
{ {
dropdown: false, dropdown: false,
markdown: '~~<>~~', markdown: "~~<>~~",
name: 'Strikethrough', name: "Strikethrough",
}, },
{ {
dropdown: false, dropdown: false,
markdown: markdown:
'<details>\n<summary>Click to expand spoiler.</summary>\n\n<>\n</details>', "<details>\n<summary>Click to expand spoiler.</summary>\n\n<>\n</details>",
name: 'Spoilerbox', name: "Spoilerbox",
}, },
{ {
dropdown: true, dropdown: true,
markdown: '**<>**', markdown: "**<>**",
name: 'Bold', name: "Bold",
}, },
{ {
dropdown: true, dropdown: true,
markdown: '\n\n---\n\n<>', markdown: "\n\n---\n\n<>",
name: 'Horizontal Divider', name: "Horizontal Divider",
}, },
{ {
dropdown: true, dropdown: true,
markdown: '`<>`', markdown: "`<>`",
name: 'Inline Code', name: "Inline Code",
}, },
{ {
dropdown: true, dropdown: true,
markdown: '*<>*', markdown: "*<>*",
name: 'Italic', name: "Italic",
}, },
{ {
dropdown: true, dropdown: true,
markdown: '1. <>', markdown: "1. <>",
name: 'Ordered List', name: "Ordered List",
}, },
{ {
dropdown: true, dropdown: true,
markdown: '<small><></small>', markdown: "<small><></small>",
name: 'Small', name: "Small",
}, },
{ {
dropdown: true, dropdown: true,
markdown: '* <>', markdown: "* <>",
name: 'Unordered List', name: "Unordered List",
}, },
].map(({dropdown, markdown, name}) => ({ ].map(({dropdown, markdown, name}) => ({
dropdown, dropdown,
name, name,
index: markdown.indexOf('<>'), index: markdown.indexOf("<>"),
markdown: markdown.replace(/<>/, ''), markdown: markdown.replace(/<>/, ""),
})); }));
export function runMarkdownToolbarFeature() { export function runMarkdownToolbarFeature() {
@ -81,39 +79,38 @@ export function runMarkdownToolbarFeature() {
function addToolbarsToTextareas(): number { function addToolbarsToTextareas(): number {
// Grab all Markdown forms that don't have already have a toolbar. // Grab all Markdown forms that don't have already have a toolbar.
const markdownForms = querySelectorAll('.form-markdown:not(.trx-toolbar)'); const markdownForms = querySelectorAll(".form-markdown:not(.trx-toolbar)");
if (markdownForms.length === 0) { if (markdownForms.length === 0) {
return 0; return 0;
} }
for (const form of markdownForms) { for (const form of markdownForms) {
// Add `trx-toolbar` to indicate this Markdown form already has the toolbar. // Add `trx-toolbar` to indicate this Markdown form already has the toolbar.
form.classList.add('trx-toolbar'); form.classList.add("trx-toolbar");
const menu = form.querySelector<HTMLElement>('.tab-markdown-mode')!; const menu = form.querySelector<HTMLElement>(".tab-markdown-mode")!;
const textarea = form.querySelector<HTMLElement>( const textarea = form.querySelector<HTMLTextAreaElement>(
'textarea[name="markdown"]', 'textarea[name="markdown"]',
)!; )!;
const snippetButtons = snippets const snippetButtons = snippets
.filter((snippet) => !snippet.dropdown) .filter((snippet) => !snippet.dropdown)
.map( .map((snippet) => (
(snippet) => <SnippetButton snippet={snippet} textarea={textarea} />
html`<${snippetButton} snippet=${snippet} textarea=${textarea} />`, ));
);
// Render the buttons inside the tab menu so they appear // Render the buttons inside the tab menu so they appear
// next to the Edit and Preview buttons. // next to the Edit and Preview buttons.
const menuPlaceholder = document.createElement('div'); const menuPlaceholder = document.createElement("div");
menu.append(menuPlaceholder); menu.append(menuPlaceholder);
render(snippetButtons, menu, menuPlaceholder); render(snippetButtons, menu, menuPlaceholder);
// And render the dropdown directly after the menu. // And render the dropdown directly after the menu.
const dropdownPlaceholder = document.createElement('div'); const dropdownPlaceholder = document.createElement("div");
const menuParent = menu.parentElement!; const menuParent = menu.parentElement!;
menu.after(dropdownPlaceholder); menu.after(dropdownPlaceholder);
render( render(
html`<${snippetDropdown} textarea=${textarea} />`, <SnippetDropdown textarea={textarea} />,
menuParent, menuParent,
dropdownPlaceholder, dropdownPlaceholder,
); );
@ -127,25 +124,25 @@ type Props = {
textarea: HTMLTextAreaElement; textarea: HTMLTextAreaElement;
}; };
function snippetButton(props: Required<Props>): TRXComponent { function SnippetButton(props: Required<Props>) {
const click = (event: MouseEvent) => { const click = (event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
insertSnippet(props); insertSnippet(props);
}; };
return html` return (
<li class="tab-item"> <li class="tab-item">
<button class="btn btn-link" onClick="${click}"> <button class="btn btn-link" onClick={click}>
${props.snippet.name} {props.snippet.name}
</button> </button>
</li> </li>
`; );
} }
function snippetDropdown(props: Props): TRXComponent { function SnippetDropdown(props: Props) {
const options = snippets.map( const options = snippets.map((snippet) => (
(snippet) => html`<option value="${snippet.name}">${snippet.name}</option>`, <option value={snippet.name}>{snippet.name}</option>
); ));
const change = (event: Event) => { const change = (event: Event) => {
event.preventDefault(); event.preventDefault();
@ -162,12 +159,12 @@ function snippetDropdown(props: Props): TRXComponent {
(event.target as HTMLSelectElement).selectedIndex = 0; (event.target as HTMLSelectElement).selectedIndex = 0;
}; };
return html` return (
<select class="form-select" onChange=${change}> <select class="form-select" onChange={change}>
<option>More</option> <option>More</option>
${options} {options}
</select> </select>
`; );
} }
function insertSnippet(props: Required<Props>) { function insertSnippet(props: Required<Props>) {
@ -189,7 +186,7 @@ function insertSnippet(props: Required<Props>) {
// Change the index when the Link snippet is used so the cursor ends up // 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)". // in the URL part of the Markdown: "[existing text](cursor here)".
if (snippet.name === 'Link') { if (snippet.name === "Link") {
index += 2; index += 2;
} }
} }

View File

@ -1,8 +1,8 @@
import {log, querySelector} from '../utilities/exports.js'; import {log, querySelector} from "../../utilities/exports.js";
export function runThemedLogoFeature() { export function runThemedLogoFeature() {
themedLogo(); themedLogo();
log('Themed Logo: Initialized.'); log("Themed Logo: Initialized.");
} }
const tildesLogo = ` const tildesLogo = `
@ -24,15 +24,15 @@ function themedLogo() {
for (const customProperty of tildesLogo.match(/var\(--.+\)/g) ?? []) { for (const customProperty of tildesLogo.match(/var\(--.+\)/g) ?? []) {
let color = window let color = window
.getComputedStyle(document.body) .getComputedStyle(document.body)
.getPropertyValue(customProperty.slice('var('.length, -1)); .getPropertyValue(customProperty.slice("var(".length, -1));
if (color === '') { if (color === "") {
color = '#f0f'; color = "#f0f";
} }
themedLogo = themedLogo.replace(customProperty, color); themedLogo = themedLogo.replace(customProperty, color);
} }
const encodedSvg = encodeURIComponent(themedLogo); const encodedSvg = encodeURIComponent(themedLogo);
const siteHeader = querySelector<HTMLElement>('.site-header-logo'); const siteHeader = querySelector<HTMLElement>(".site-header-logo");
siteHeader.style.backgroundImage = `url("data:image/svg+xml,${encodedSvg}")`; siteHeader.style.backgroundImage = `url("data:image/svg+xml,${encodedSvg}")`;
} }

View File

@ -1,8 +1,7 @@
import debounce from 'debounce'; import debounce from "debounce";
import {Component, render} from 'preact'; import {Component, render} from "preact";
import {html} from 'htm/preact'; import {type Value} from "@holllo/webextension-storage";
import {type UserLabelsData} from "../../storage/common.js";
import Settings from '../settings.js';
import { import {
createElementFromString, createElementFromString,
isColorBright, isColorBright,
@ -10,10 +9,11 @@ import {
log, log,
querySelectorAll, querySelectorAll,
themeColors, themeColors,
} from '../utilities/exports.js'; } from "../../utilities/exports.js";
type Props = { type Props = {
settings: Settings; anonymizeUsernamesEnabled: boolean;
userLabels: Value<UserLabelsData>;
}; };
type State = { type State = {
@ -28,13 +28,13 @@ type State = {
}; };
const colorPattern: string = [ const colorPattern: string = [
'^(?:#(?:', // (?:) are non-capturing groups. "^(?:#(?:", // (?:) are non-capturing groups.
'[a-f\\d]{8}|', // The order of 8 -> 6 -> 4 -> 3 character hex colors matters. "[a-f\\d]{8}|", // The order of 8 -> 6 -> 4 -> 3 character hex colors matters.
'[a-f\\d]{6}|', "[a-f\\d]{6}|",
'[a-f\\d]{4}|', "[a-f\\d]{4}|",
'[a-f\\d]{3})', "[a-f\\d]{3})",
'|transparent)$', // "Transparent" is also allowed in the input. "|transparent)$", // "Transparent" is also allowed in the input.
].join(''); ].join("");
export class UserLabelsFeature extends Component<Props, State> { export class UserLabelsFeature extends Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
@ -49,14 +49,14 @@ export class UserLabelsFeature extends Component<Props, State> {
color: selectedColor, color: selectedColor,
hidden: true, hidden: true,
id: undefined, id: undefined,
text: '', text: "",
priority: 0, priority: 0,
selectedColor, selectedColor,
target: undefined, 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.`); log(`User Labels: Initialized for ${count} user links.`);
} }
@ -65,12 +65,12 @@ export class UserLabelsFeature extends Component<Props, State> {
}; };
addLabelsToUsernames = (elements: HTMLElement[], onlyID?: number): number => { addLabelsToUsernames = (elements: HTMLElement[], onlyID?: number): number => {
const settings = this.props.settings; const {userLabels} = this.props;
const inTopicListing = document.querySelector('.topic-listing') !== null; const inTopicListing = document.querySelector(".topic-listing") !== null;
// Sort the labels by priority or alphabetically, so 2 labels with the same // Sort the labels by priority or alphabetically, so 2 labels with the same
// priority will be sorted alphabetically. // 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 (inTopicListing) {
// If we're in the topic listing sort with highest priority first. // If we're in the topic listing sort with highest priority first.
if (a.priority !== b.priority) { if (a.priority !== b.priority) {
@ -88,10 +88,10 @@ export class UserLabelsFeature extends Component<Props, State> {
for (const element of elements) { for (const element of elements) {
let username: string = element let username: string = element
.textContent!.replace(/@/g, '') .textContent!.replace(/@/g, "")
.toLowerCase(); .toLowerCase();
if (settings.features.anonymizeUsernames) { if (this.props.anonymizeUsernamesEnabled) {
username = element.dataset.trxUsername ?? username; username = element.dataset.trxUsername ?? username;
} }
@ -101,19 +101,18 @@ export class UserLabelsFeature extends Component<Props, State> {
(onlyID === undefined ? true : value.id === onlyID), (onlyID === undefined ? true : value.id === onlyID),
); );
const addLabel = html` const addLabel = (
<span <span
class="trx-user-label-add" class="trx-user-label-add"
onClick=${(event: MouseEvent) => { onClick={(event: MouseEvent) => {
this.addLabelHandler(event, username); this.addLabelHandler(event, username);
}} }}
> >
[+] [+]
</span> </span>
`; );
if (!inTopicListing && onlyID === undefined) { if (!inTopicListing && onlyID === undefined) {
const addLabelPlaceholder = document.createElement('span'); const addLabelPlaceholder = document.createElement("span");
element.after(addLabelPlaceholder); element.after(addLabelPlaceholder);
render(addLabel, element.parentElement!, addLabelPlaceholder); render(addLabel, element.parentElement!, addLabelPlaceholder);
} }
@ -122,9 +121,9 @@ export class UserLabelsFeature extends Component<Props, State> {
if ( if (
inTopicListing && inTopicListing &&
(element.nextElementSibling === null || (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); element.after(addLabelPlaceholder);
render(addLabel, element.parentElement!, addLabelPlaceholder); render(addLabel, element.parentElement!, addLabelPlaceholder);
} }
@ -134,8 +133,8 @@ export class UserLabelsFeature extends Component<Props, State> {
for (const userLabel of userLabels) { for (const userLabel of userLabels) {
const bright = isColorBright(userLabel.color.trim()) const bright = isColorBright(userLabel.color.trim())
? 'trx-bright' ? "trx-bright"
: ''; : "";
const label = createElementFromString<HTMLSpanElement>(`<span const label = createElementFromString<HTMLSpanElement>(`<span
data-trx-label-id="${userLabel.id}" data-trx-label-id="${userLabel.id}"
@ -144,12 +143,12 @@ export class UserLabelsFeature extends Component<Props, State> {
${userLabel.text} ${userLabel.text}
</span>`); </span>`);
label.addEventListener('click', (event: MouseEvent) => { label.addEventListener("click", (event: MouseEvent) => {
this.editLabelHandler(event, userLabel.id); this.editLabelHandler(event, userLabel.id);
}); });
element.after(label); 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 we're in the topic listing, stop after adding 1 label.
if (inTopicListing) { if (inTopicListing) {
@ -179,7 +178,7 @@ export class UserLabelsFeature extends Component<Props, State> {
username, username,
color: selectedColor, color: selectedColor,
id: undefined, id: undefined,
text: '', text: "",
priority: 0, priority: 0,
selectedColor, selectedColor,
}); });
@ -193,12 +192,12 @@ export class UserLabelsFeature extends Component<Props, State> {
if (this.state.target === target && !this.state.hidden) { if (this.state.target === target && !this.state.hidden) {
this.hide(); this.hide();
} else { } else {
const label = this.props.settings.data.userLabels.find( const label = this.props.userLabels.value.find(
(value) => value.id === id, (value) => value.id === id,
); );
if (label === undefined) { if (label === undefined) {
log( 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, true,
); );
return; return;
@ -214,17 +213,17 @@ export class UserLabelsFeature extends Component<Props, State> {
colorChange = (event: Event) => { colorChange = (event: Event) => {
let color: string = (event.target as HTMLInputElement).value.toLowerCase(); 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}`; color = `#${color}`;
} }
if (color !== 'transparent' && !isValidHexColor(color)) { if (color !== "transparent" && !isValidHexColor(color)) {
log('User Labels: Color must be a valid hex color or "transparent".'); log('User Labels: Color must be a valid hex color or "transparent".');
} }
// If the color was changed through the preset values, also change the // If the color was changed through the preset values, also change the
// selected color state. // selected color state.
if ((event.target as HTMLElement).tagName === 'SELECT') { if ((event.target as HTMLElement).tagName === "SELECT") {
this.setState({color, selectedColor: color}); this.setState({color, selectedColor: color});
} else { } else {
this.setState({color}); this.setState({color});
@ -244,34 +243,32 @@ export class UserLabelsFeature extends Component<Props, State> {
save = async (event: MouseEvent) => { save = async (event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
const {color, id, text, priority, username} = this.state; const {color, id, text, priority, username} = this.state;
if (color === '' || username === '') { if (color === "" || username === "") {
log('Cannot save user label without all values present.'); log("Cannot save user label without all values present.");
return; 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 no ID is present then save a new label otherwise edit the existing one.
if (id === undefined) { if (id === undefined) {
let newID = 1; let newId = 1;
if (settings.data.userLabels.length > 0) { if (userLabels.value.length > 0) {
newID = settings.data.userLabels.sort((a, b) => b.id - a.id)[0].id + 1; newId = userLabels.value.sort((a, b) => b.id - a.id)[0].id + 1;
} }
settings.data.userLabels.push({ userLabels.value.push({
color, color,
id: newID, id: newId,
priority, priority,
text, text,
username, username,
}); });
this.addLabelsToUsernames(querySelectorAll('.link-user'), newID); this.addLabelsToUsernames(querySelectorAll(".link-user"), newId);
} else { } else {
const index = settings.data.userLabels.findIndex( const index = userLabels.value.findIndex((value) => value.id === id);
(value) => value.id === id, userLabels.value.splice(index, 1);
); userLabels.value.push({
settings.data.userLabels.splice(index, 1);
settings.data.userLabels.push({
id, id,
color, color,
priority, priority,
@ -283,17 +280,17 @@ export class UserLabelsFeature extends Component<Props, State> {
const bright = isColorBright(color); const bright = isColorBright(color);
for (const element of elements) { for (const element of elements) {
element.textContent = text; element.textContent = text;
element.setAttribute('style', `background-color: ${color};`); element.setAttribute("style", `background-color: ${color};`);
if (bright) { if (bright) {
element.classList.add('trx-bright'); element.classList.add("trx-bright");
} else { } else {
element.classList.remove('trx-bright'); element.classList.remove("trx-bright");
} }
} }
} }
await settings.save(); await userLabels.save();
this.props.settings = settings; this.props.userLabels = userLabels;
this.hide(); this.hide();
}; };
@ -301,14 +298,12 @@ export class UserLabelsFeature extends Component<Props, State> {
event.preventDefault(); event.preventDefault();
const {id} = this.state; const {id} = this.state;
if (id === undefined) { if (id === undefined) {
log('User Labels: Tried remove label when ID was undefined.'); log("User Labels: Tried remove label when ID was undefined.");
return; return;
} }
const {settings} = this.props; const {userLabels} = this.props;
const index = settings.data.userLabels.findIndex( const index = userLabels.value.findIndex((value) => value.id === id);
(value) => value.id === id,
);
if (index === undefined) { if (index === undefined) {
log( log(
`User Labels: Tried to remove label with ID ${id} that could not be found.`, `User Labels: Tried to remove label with ID ${id} that could not be found.`,
@ -321,25 +316,20 @@ export class UserLabelsFeature extends Component<Props, State> {
value.remove(); value.remove();
} }
settings.data.userLabels.splice(index, 1); userLabels.value.splice(index, 1);
await settings.save(); await userLabels.save();
this.props.settings = settings; this.props.userLabels = userLabels;
this.hide(); this.hide();
}; };
render() { render() {
const bodyStyle = window.getComputedStyle(document.body); const bodyStyle = window.getComputedStyle(document.body);
const themeSelectOptions = themeColors.map( const themeSelectOptions = themeColors.map(({name, value}) => (
({name, value}) => <option value={bodyStyle.getPropertyValue(value).trim()}>{name}</option>
html` ));
<option value="${bodyStyle.getPropertyValue(value).trim()}">
${name}
</option>
`,
);
const bright = isColorBright(this.state.color) ? 'trx-bright' : ''; const bright = isColorBright(this.state.color) ? "trx-bright" : "";
const hidden = this.state.hidden ? 'trx-hidden' : ''; const hidden = this.state.hidden ? "trx-hidden" : "";
const {color, text: label, priority, selectedColor, username} = this.state; const {color, text: label, priority, selectedColor, username} = this.state;
let top = 0; let top = 0;
@ -355,8 +345,8 @@ export class UserLabelsFeature extends Component<Props, State> {
const position = `left: ${left}px; top: ${top}px;`; const position = `left: ${left}px; top: ${top}px;`;
const previewStyle = `background-color: ${color}`; const previewStyle = `background-color: ${color}`;
return html` return (
<form class="trx-user-label-form ${hidden}" style="${position}"> <form class={`trx-user-label-form ${hidden}`} style={position}>
<div class="trx-label-username-priority"> <div class="trx-label-username-priority">
<label class="trx-label-username"> <label class="trx-label-username">
Add New Label Add New Label
@ -364,7 +354,7 @@ export class UserLabelsFeature extends Component<Props, State> {
type="text" type="text"
class="form-input" class="form-input"
placeholder="Username" placeholder="Username"
value="${username}" value={username}
required required
/> />
</label> </label>
@ -374,8 +364,8 @@ export class UserLabelsFeature extends Component<Props, State> {
<input <input
type="number" type="number"
class="form-input" class="form-input"
value="${priority}" value={priority}
onChange=${this.priorityChange} onChange={this.priorityChange}
required required
/> />
</label> </label>
@ -390,18 +380,18 @@ export class UserLabelsFeature extends Component<Props, State> {
type="text" type="text"
class="form-input" class="form-input"
placeholder="Color" placeholder="Color"
value="${color}" value={color}
onInput=${debounce(this.colorChange, 250)} onInput={debounce(this.colorChange, 250)}
pattern="${colorPattern}" pattern={colorPattern}
required required
/> />
<select <select
class="form-select" class="form-select"
value="${selectedColor}" value={selectedColor}
onChange="${this.colorChange}" onChange={this.colorChange}
> >
${themeSelectOptions} {themeSelectOptions}
</select> </select>
</div> </div>
</div> </div>
@ -415,22 +405,28 @@ export class UserLabelsFeature extends Component<Props, State> {
type="text" type="text"
class="form-input" class="form-input"
placeholder="Text" placeholder="Text"
value="${label}" value={label}
onInput=${debounce(this.labelChange, 250)} onInput={debounce(this.labelChange, 250)}
/> />
<div class="trx-label-preview ${bright}" style="${previewStyle}"> <div class={`trx-label-preview ${bright}`} style={previewStyle}>
<p>${label}</p> <p>{label}</p>
</div> </div>
</div> </div>
</div> </div>
<div class="trx-label-actions"> <div class="trx-label-actions">
<a class="btn-post-action" onClick=${this.save}>Save</a> <a class="btn-post-action" onClick={this.save}>
<a class="btn-post-action" onClick=${this.hide}>Close</a> Save
<a class="btn-post-action" onClick=${this.remove}>Remove</a> </a>
<a class="btn-post-action" onClick={this.hide}>
Close
</a>
<a class="btn-post-action" onClick={this.remove}>
Remove
</a>
</div> </div>
</form> </form>
`; );
} }
} }

View File

@ -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<string, string>();
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<HTMLElement>(
".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() ??
"<unknown>";
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;
}

View File

@ -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>([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<void>> = [];
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<string, JSX.Element | undefined> = {};
const userLabels = await fromStorage(Feature.UserLabels);
if (enabledFeatures.value.has(Feature.Autocomplete)) {
components.autocomplete = (
<AutocompleteFeature
anonymizeUsernamesEnabled={anonymizeUsernamesEnabled}
knownGroups={knownGroups.value}
userLabels={userLabels.value}
/>
);
}
if (enabledFeatures.value.has(Feature.BackToTop)) {
components.backToTop = <BackToTopFeature />;
}
if (enabledFeatures.value.has(Feature.JumpToNewComment)) {
components.jumpToNewComment = <JumpToNewCommentFeature />;
}
if (enabledFeatures.value.has(Feature.UserLabels)) {
components.userLabels = (
<UserLabelsFeature
anonymizeUsernamesEnabled={anonymizeUsernamesEnabled}
userLabels={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(
<div id="trx-container">
{components.jumpToNewComment} {components.backToTop}
{components.autocomplete} {components.userLabels}
</div>,
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);

View File

@ -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}"
}
}
}

72
source/manifest.ts Normal file
View File

@ -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;
}

View File

@ -1,50 +0,0 @@
import {Migration} from 'migration-helper';
export const migrations: Array<Migration<string>> = [
{
version: '1.1.2',
async migrate(data: Record<string, any>) {
const migrated: Record<string, any> = {
data: {
hideVotes: data.data.hideVotes as Record<string, boolean>,
knownGroups: data.data.knownGroups as string[],
latestActiveFeatureTab: data.data.latestActiveFeatureTab as string,
},
features: (data.features as Record<string, string>) ?? {},
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<string, any>): {
userLabels: UserLabel[];
usernameColors: UsernameColor[];
} {
const deserialized: ReturnType<typeof deserializeData> = {
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;
}

View File

@ -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<void> {
// 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<void> => {
let data: Partial<Settings>;
try {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
data = JSON.parse(reader.result!.toString()) as Partial<Settings>;
} 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<void> {
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<HTMLElement>('#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}>
<p class="info">
This feature will make debugging logs output to the console when
enabled.
</p>
<p>
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}.
</p>
<p>
To report bugs or request new features use the links at the bottom of
this page, check out the ${gitlabIssuesLink} or${' '}
${messageCommunityLink}${' '} on Tildes.
</p>
<div class="divider" />
<div class="import-export">
<p>
Note that importing settings will delete and overwrite your existing
ones.
</p>
<input
id="import-settings"
onChange=${importFileHandler}
class="trx-hidden"
accept="application/json"
type="file"
/>
<button onClick=${importSettings} class="button">
Import Settings
</button>
<button onClick=${exportSettings} class="button">
Export Settings
</button>
</div>
<div class="divider" />
<details class="misc-utilities">
<summary>Danger Zone</summary>
<div class="inner">
<button onClick=${logSettings} class="button">Log Settings</button>
<button onClick=${Settings.nuke} class="button destructive">
Remove All Data
</button>
</div>
</details>
<//>
`;
}

View File

@ -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<void> {
// 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<void> => {
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<void> {
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<HTMLElement>("#import-settings")!.click();
};
const communityLink = (
<Link
url="https://gitlab.com/tildes-community"
text="Tildes Community project"
/>
);
const criusLink = <Link url="https://tildes.net/user/crius" text="Crius" />;
const gitlabIssuesLink = (
<Link
url="https://gitlab.com/tildes-community/tildes-reextended/-/issues"
text="GitLab issue tracker"
/>
);
const gitlabLicenseLink = (
<Link
url="https://gitlab.com/tildes-community/tildes-reextended/blob/main/LICENSE"
text="MIT License"
/>
);
const messageCommunityLink = (
<Link
url="https://tildes.net/user/Community/new_message"
text="message Community"
/>
);
const tildesExtendedLink = (
<Link
url="https://github.com/theCrius/tildes-extended"
text="Tildes Extended"
/>
);
return (
<Setting {...props}>
<p class="info">
This feature will make debugging logs output to the console when
enabled.
</p>
<p>
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}.
</p>
<p>
To report bugs or request new features use the links at the bottom of
this page, check out the {gitlabIssuesLink} or {messageCommunityLink} on
Tildes.
</p>
<div class="divider" />
<div class="import-export">
<p>
Note that importing settings will delete and overwrite your existing
ones.
</p>
<input
id="import-settings"
onChange={importFileHandler}
class="trx-hidden"
accept="application/json"
type="file"
/>
<button onClick={importSettings} class="button">
Import Settings
</button>
<button onClick={exportSettings} class="button">
Export Settings
</button>
</div>
</Setting>
);
}

View File

@ -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) {
return (
export function AnonymizeUsernamesSetting(props: SettingProps): TRXComponent { <Setting {...props}>
return html`
<${Setting} ...${props}>
<p class="info"> <p class="info">
Anonymizes usernames by replacing them with "Anonymous #". Anonymizes usernames by replacing them with "Anonymous #".
<br /> <br />
Note that User Labels and Username Colors will still be applied to any Note that User Labels and Username Colors will still be applied to any
usernames as normal. usernames as normal.
</p> </p>
<//> </Setting>
`; );
} }

View File

@ -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}>
<p class="info">
Adds autocompletion in textareas for user mentions (starting with${' '}
<code>@</code>) and groups (starting with <code>~</code>).
</p>
<//>
`;
}

View File

@ -0,0 +1,13 @@
import {type JSX} from "preact";
import {Setting, type SettingProps} from "./index.js";
export function AutocompleteSetting(props: SettingProps): JSX.Element {
return (
<Setting {...props}>
<p class="info">
Adds autocompletion in textareas for user mentions (starting with{" "}
<code>@</code>) and groups (starting with <code>~</code>).
</p>
</Setting>
);
}

View File

@ -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): JSX.Element {
return (
export function BackToTopSetting(props: SettingProps): TRXComponent { <Setting {...props}>
return html`
<${Setting} ...${props}>
<p class="info"> <p class="info">
Adds a hovering button to the bottom-right of all pages once you've 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 scrolled down far enough that, when clicked, will scroll you back to the
top of the page. top of the page.
</p> </p>
<//> </Setting>
`; );
} }

View File

@ -1,10 +1,10 @@
export {AboutSetting} from './about.js'; export {AboutSetting} from "./about.js";
export {AnonymizeUsernamesSetting} from './anonymize-usernames.js'; export {AnonymizeUsernamesSetting} from "./anonymize-usernames.js";
export {AutocompleteSetting} from './autocomplete.js'; export {AutocompleteSetting} from "./autocomplete.js";
export {BackToTopSetting} from './back-to-top.js'; export {BackToTopSetting} from "./back-to-top.js";
export {HideVotesSetting} from './hide-votes.js'; export {HideVotesSetting} from "./hide-votes.js";
export {JumpToNewCommentSetting} from './jump-to-new-comment.js'; export {JumpToNewCommentSetting} from "./jump-to-new-comment.js";
export {MarkdownToolbarSetting} from './markdown-toolbar.js'; export {MarkdownToolbarSetting} from "./markdown-toolbar.js";
export {ThemedLogoSetting} from './themed-logo.js'; export {ThemedLogoSetting} from "./themed-logo.js";
export {UserLabelsSetting} from './user-labels.js'; export {UserLabelsSetting} from "./user-labels.js";
export {UsernameColorsSetting} from './username-colors.js'; export {UsernameColorsSetting} from "./username-colors.js";

View File

@ -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`
<li>
<label>
<input
type="checkbox"
checked=${checked[target]}
onClick=${() => {
toggle(target);
}}
/>
${label}
</label>
</li>
`,
);
return html`
<${Setting} ...${props}>
<p class="info">
Hides vote counts from topics and comments of yourself or other people.
</p>
<ul class="checkbox-list">
${checkboxes}
</ul>
<//>
`;
}

View File

@ -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<HideVotesData>;
};
type HideVotesKey = keyof State["hideVotes"]["value"];
export class HideVotesSetting extends Component<SettingProps, State> {
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}) => (
<li>
<label>
<input
type="checkbox"
checked={hideVotes.value[target]}
onClick={() => {
this.toggle(target);
}}
/>
{label}
</label>
</li>
));
return (
<Setting {...this.props}>
<p class="info">
Hides vote counts from topics and comments of yourself or other
people.
</p>
<ul class="checkbox-list">{checkboxes}</ul>
</Setting>
);
}
}

View File

@ -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<SettingProps> {
render() {
const {props} = this;
const context = useContext(AppContext);
const enabled = props.enabled ? 'Enabled' : 'Disabled';
return html`
<header>
<h2>${props.title}</h2>
<button
onClick="${() => {
context.toggleFeature(props.feature);
}}"
>
${enabled}
</button>
</header>
`;
}
}
// 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`<p class="info">This setting still needs a component!</p>`
: props.children;
const enabled = (props.enabled ? 'Enabled' : 'Disabled').toLowerCase();
return html`
<section class="setting ${props.class} ${enabled}">
<${Header} ...${props} />
<div class="content">${children}</div>
</section>
`;
}

View File

@ -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<SettingProps> {
render() {
const {props} = this;
const context = useContext(AppContext);
const enabled = props.enabled ? "Enabled" : "Disabled";
return (
<header>
<h2>{props.title}</h2>
<button
onClick={() => {
context.toggleFeature(props.feature);
}}
>
{enabled}
</button>
</header>
);
}
}
// 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 ?? (
<p class="info">This setting still needs a component!</p>
);
const enabled = (props.enabled ? "Enabled" : "Disabled").toLowerCase();
return (
<section class={`setting ${props.class} ${enabled}`}>
<Header {...props} />
<div class="content">{children}</div>
</section>
);
}

View File

@ -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}>
<p class="info">
Adds a hovering button to the bottom-right of pages with new comments
that, when clicked, will scroll you to the next new comment.
</p>
<//>
`;
}

View File

@ -0,0 +1,13 @@
import {type JSX} from "preact";
import {Setting, type SettingProps} from "./index.js";
export function JumpToNewCommentSetting(props: SettingProps): JSX.Element {
return (
<Setting {...props}>
<p class="info">
Adds a hovering button to the bottom-right of pages with new comments
that, when clicked, will scroll you to the next new comment.
</p>
</Setting>
);
}

View File

@ -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'; export function MarkdownToolbarSetting(props: SettingProps): JSX.Element {
import {Setting, SettingProps} from './index.js'; return (
<Setting {...props}>
export function MarkdownToolbarSetting(props: SettingProps): TRXComponent {
return html`
<${Setting} ...${props}>
<p class="info"> <p class="info">
Adds a toolbar with a selection of Markdown snippets that when used will Adds a toolbar with a selection of Markdown snippets that when used will
insert the according Markdown where your cursor is. Particularly useful insert the according Markdown where your cursor is. Particularly useful
for the${' '} for the{" "}
<${Link} <Link
url="https://docs.tildes.net/instructions/text-formatting#expandable-sections" url="https://docs.tildes.net/instructions/text-formatting#expandable-sections"
text="expandable section" text="expandable section"
/> />
/spoilerbox syntax. If you have text selected, the Markdown will be /spoilerbox syntax. If you have text selected, the Markdown will be
inserted around your text. inserted around your text.
<br />A full list of the snippets is available{" "}
<br /> <Link
A full list of the snippets is available${' '}
<${Link}
url="https://gitlab.com/tildes-community/tildes-reextended/-/issues/12" url="https://gitlab.com/tildes-community/tildes-reextended/-/issues/12"
text="on GitLab" text="on GitLab"
/> />
. .
</p> </p>
<//> </Setting>
`; );
} }

View File

@ -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}>
<p class="info">
Replaces the Tildes logo in the site header with a dynamic one that uses
the colors of your chosen Tildes theme.
</p>
<//>
`;
}

View File

@ -0,0 +1,13 @@
import {type JSX} from "preact";
import {Setting, type SettingProps} from "./index.js";
export function ThemedLogoSetting(props: SettingProps): JSX.Element {
return (
<Setting {...props}>
<p class="info">
Replaces the Tildes logo in the site header with a dynamic one that uses
the colors of your chosen Tildes theme.
</p>
</Setting>
);
}

View File

@ -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) {
return (
export function UserLabelsSetting(props: SettingProps): TRXComponent { <Setting {...props}>
return html`
<${Setting} ...${props}>
<p class="info"> <p class="info">
Adds a way to create customizable labels to users. Wherever a link to a Adds a way to create customizable labels to users. Wherever a link to a
person's profile is available, a <code>[+]</code> will be put next to person's profile is available, a <code>[+]</code> will be put next to
it. Clicking on that will bring up a dialog to add a new label and 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. clicking on existing labels will bring up the same dialog to edit them.
<br /> <br />
Or you can use the dedicated${' '} Or you can use the dedicated{" "}
<a href="./user-label-editor.html">User Label Editor</a> <a href="/options/user-label-editor.html">User Label Editor</a> to add,
to add, edit, or remove user labels. edit, or remove user labels.
</p> </p>
<details> <details>
<summary>View Customizable Values</summary> <summary>View Customizable Values</summary>
<ul class="user-label-values"> <ul class="user-label-values">
<li><b>Username</b>: who to apply the label to.</li> <li>
<b>Username</b>: who to apply the label to.
</li>
<li> <li>
<b>Priority</b>: determines the order of labels. If multiple labels <b>Priority</b>: determines the order of labels. If multiple labels
have the same priority they will be sorted alphabetically. In the have the same priority they will be sorted alphabetically. In the
@ -41,6 +41,6 @@ export function UserLabelsSetting(props: SettingProps): TRXComponent {
</li> </li>
</ul> </ul>
</details> </details>
<//> </Setting>
`; );
} }

View File

@ -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<SettingProps, State> {
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<string, string> = {};
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`
<div class="username-colors-editor" key=${id}>
<input
style=${style}
placeholder="Username(s)"
value=${username}
onInput=${usernameHandler}
/>
<input
style=${style}
placeholder="Color"
value=${color}
onInput=${colorHandler}
/>
<button class="button destructive" onClick=${removeHandler}>
Remove
</button>
</div>
`;
});
return html`
<${Setting} ...${this.props}>
<p class="info">
Assign custom colors to usernames.
<br />
You can enter multiple usernames separated by a comma if you want them
to use the same color.
</p>
<div class="username-colors-controls">
<button class="button" onClick=${this.addNewColor}>
Add New Color
</button>
<button class="button" onClick=${this.togglePreview}>
Toggle Preview
</button>
<button class="button" onClick=${this.saveChanges}>
Save Changes
</button>
</div>
${editors}
<//>
`;
}
}

View File

@ -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<UsernameColorsData>;
};
export class UsernameColorsSetting extends Component<SettingProps, State> {
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<string, string> = {};
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 (
<div class="username-colors-editor" key={id}>
<input
style={style}
placeholder="Username(s)"
value={username}
onInput={usernameHandler}
/>
<input
style={style}
placeholder="Color"
value={color}
onInput={colorHandler}
/>
<button class="button destructive" onClick={removeHandler}>
Remove
</button>
</div>
);
});
return (
<Setting {...this.props}>
<p class="info">
Assign custom colors to usernames.
<br />
You can enter multiple usernames separated by a comma if you want them
to use the same color.
</p>
<div class="username-colors-controls">
<button class="button" onClick={this.addNewColor}>
Add New Color
</button>
<button class="button" onClick={this.togglePreview}>
Toggle Preview
</button>
<button class="button" onClick={this.saveChanges}>
Save Changes
</button>
</div>
{editors}
</Setting>
);
}
}

View File

@ -1,11 +1,10 @@
import {createContext} from 'preact'; import {createContext} from "preact";
import {type Feature} from "../storage/common.js";
import Settings from '../settings.js';
type AppContextValues = { type AppContextValues = {
settings: Settings; setActiveFeature: (feature: Feature) => void;
setActiveFeature: (feature: string) => void; toggleFeature: (feature: Feature) => void;
toggleFeature: (feature: string) => void;
}; };
// eslint-disable-next-line @typescript-eslint/naming-convention
export const AppContext = createContext<AppContextValues>(null!); export const AppContext = createContext<AppContextValues>(null!);

View File

@ -1,4 +1,4 @@
import Settings from '../settings.js'; import {Feature} from "../storage/common.js";
import { import {
AboutSetting, AboutSetting,
AnonymizeUsernamesSetting, AnonymizeUsernamesSetting,
@ -10,86 +10,86 @@ import {
ThemedLogoSetting, ThemedLogoSetting,
UserLabelsSetting, UserLabelsSetting,
UsernameColorsSetting, UsernameColorsSetting,
} from './components/exports.js'; } from "./components/exports.js";
type Feature = { type FeatureData = {
availableSince: Date; availableSince: Date;
index: number; index: number;
key: keyof RemoveIndexSignature<Settings['features']>; key: Feature;
title: string; 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, index: 0,
key: 'anonymizeUsernames', key: Feature.AnonymizeUsernames,
title: 'Anonymize Usernames', title: "Anonymize Usernames",
component: () => AnonymizeUsernamesSetting, component: AnonymizeUsernamesSetting,
}, },
{ {
availableSince: new Date('2020-10-03'), availableSince: new Date("2020-10-03"),
index: 0, index: 0,
key: 'autocomplete', key: Feature.Autocomplete,
title: 'Autocomplete', title: "Autocomplete",
component: () => AutocompleteSetting, component: AutocompleteSetting,
}, },
{ {
availableSince: new Date('2019-11-10'), availableSince: new Date("2019-11-10"),
index: 0, index: 0,
key: 'backToTop', key: Feature.BackToTop,
title: 'Back To Top', title: "Back To Top",
component: () => BackToTopSetting, component: BackToTopSetting,
}, },
{ {
availableSince: new Date('2019-11-12'), availableSince: new Date("2019-11-12"),
index: 0, index: 0,
key: 'hideVotes', key: Feature.HideVotes,
title: 'Hide Votes', title: "Hide Votes",
component: () => HideVotesSetting, component: HideVotesSetting,
}, },
{ {
availableSince: new Date('2019-11-10'), availableSince: new Date("2019-11-10"),
index: 0, index: 0,
key: 'jumpToNewComment', key: Feature.JumpToNewComment,
title: 'Jump To New Comment', title: "Jump To New Comment",
component: () => JumpToNewCommentSetting, component: JumpToNewCommentSetting,
}, },
{ {
availableSince: new Date('2019-11-12'), availableSince: new Date("2019-11-12"),
index: 0, index: 0,
key: 'markdownToolbar', key: Feature.MarkdownToolbar,
title: 'Markdown Toolbar', title: "Markdown Toolbar",
component: () => MarkdownToolbarSetting, component: MarkdownToolbarSetting,
}, },
{ {
availableSince: new Date('2022-02-27'), availableSince: new Date("2022-02-27"),
index: 0, index: 0,
key: 'themedLogo', key: Feature.ThemedLogo,
title: 'Themed Logo', title: "Themed Logo",
component: () => ThemedLogoSetting, component: ThemedLogoSetting,
}, },
{ {
availableSince: new Date('2019-11-10'), availableSince: new Date("2019-11-10"),
index: 0, index: 0,
key: 'userLabels', key: Feature.UserLabels,
title: 'User Labels', title: "User Labels",
component: () => UserLabelsSetting, component: UserLabelsSetting,
}, },
{ {
availableSince: new Date('2022-02-25'), availableSince: new Date("2022-02-25"),
index: 0, index: 0,
key: 'usernameColors', key: Feature.UsernameColors,
title: 'Username Colors', title: "Username Colors",
component: () => UsernameColorsSetting, component: UsernameColorsSetting,
}, },
{ {
availableSince: new Date('2019-11-10'), availableSince: new Date("2019-11-10"),
index: 1, index: 1,
key: 'debug', key: Feature.Debug,
title: 'About & Info', title: "About & Info",
component: () => AboutSetting, component: AboutSetting,
}, },
]; ];

View File

@ -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<string>;
};
class App extends Component<Props, State> {
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<string> => {
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`<span class="is-new">NEW</span>`
: undefined;
return html`
<li
key=${key}
class="${activeFeature === key ? 'active' : ''}
${enabledFeatures.has(key) ? 'enabled' : ''}"
onClick="${() => {
this.setActiveFeature(key);
}}"
>
${title}${isNew}
</li>
`;
});
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,
}}
>
<header class="page-header">
<h1>
<img src="../assets/tildes-reextended-128.png" />
Tildes ReExtended
</h1>
${versionLink}
</header>
<div class="main-wrapper">
<aside class="page-aside">
<ul>
${asideElements}
</ul>
</aside>
<main class="page-main">${mainElements}</main>
</div>
<footer class="page-footer">
<p>Report a bug via ${gitlabLink} or ${tildesLink}.</p>
<p>© Tildes Community and Contributors</p>
</footer>
<//>
`;
}
}

165
source/options/setup.tsx Normal file
View File

@ -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(<App manifest={manifest} />, document.body);
});
type Props = {
manifest: browser.Manifest.WebExtensionManifest;
};
type State = {
activeFeature: Value<Feature>;
enabledFeatures: Value<Set<Feature>>;
};
class App extends Component<Props, State> {
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 = (
<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 = <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 = <Link text="Tildes" url={tildesUrl} />;
const asideElements = features.map(({availableSince, key, title}) => {
const isNew =
Date.now() - availableSince.getTime() < this.newFeatureDuration ? (
<span class="is-new">NEW</span>
) : undefined;
return (
<li
key={key}
class={`${activeFeature.value === key ? "active" : ""}
${enabledFeatures.value.has(key) ? "enabled" : ""}`}
onClick={() => {
this.setActiveFeature(key);
}}
>
{title}
{isNew}
</li>
);
});
const mainElements = features.map(({key, title, component: Setting}) => {
return (
<Setting
class={activeFeature.value === key ? "" : "trx-hidden"}
enabled={enabledFeatures.value.has(key)}
feature={key}
key={key}
title={title}
/>
);
});
return (
<AppContext.Provider
value={{
setActiveFeature: this.setActiveFeature,
toggleFeature: this.toggleFeature,
}}
>
<header class="page-header">
<h1>
<img src="/tildes-reextended.png" />
Tildes ReExtended
</h1>
{versionLink}
</header>
<div class="main-wrapper">
<aside class="page-aside">
<ul>{asideElements}</ul>
</aside>
<main class="page-main">{mainElements}</main>
</div>
<footer class="page-footer">
<p>
Report a bug via {gitlabLink} or {tildesLink}.
</p>
<p>© Tildes Community and Contributors</p>
</footer>
</AppContext.Provider>
);
}
}

View File

@ -1,22 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tildes ReExtended</title>
<link rel="shortcut icon" href="../assets/tildes-reextended-128.png"
type="image/png">
<link rel="stylesheet" href="../scss/modern-normalize.scss">
<link rel="stylesheet" href="../scss/index.scss">
<link rel="stylesheet" href="../scss/user-label-editor.scss">
</head>
<body>
<noscript>
This web extension does not work without JavaScript, sorry. :(
</noscript>
<script type="module" src="./user-label-editor.ts"></script>
</body>
</html>

View File

@ -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<Props, State> {
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<string, UserLabel[]> = 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`
<span style=${{background: color}} class="label-preview">
${text}
</span>
`,
);
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`
<li key=${label.id}>
<div>
${index === 0 ? html`<label>Text</label>` : undefined}
<input
onInput=${textHandler}
placeholder="Text"
value=${label.text}
/>
</div>
<div>
${index === 0 ? html`<label>Color</label>` : undefined}
<input
onInput=${colorHandler}
placeholder="Color"
value=${label.color}
/>
</div>
<div>
${index === 0 ? html`<label>Priority</label>` : undefined}
<input
onInput=${priorityHandler}
placeholder="Priority"
type="number"
value=${label.priority}
/>
</div>
<div>
${index === 0 ? html`<label>Controls</label>` : undefined}
<button class="button destructive" onClick=${removeHandler}>
Remove
</button>
</div>
</li>
`,
);
}
labels.push(html`
<div class="group">
<h2>${username} ${labelPreviews}</h2>
<ul>
${userLabels}
</ul>
</div>
`);
}
return html`
<header class="page-header">
<h1>
<img src="/assets/tildes-reextended-128.png" />
User Label Editor
</h1>
</header>
<main class="page-main user-label-editor">
<p class="info">
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.
<br />
<b>Changes are not automatically saved!</b>
<br />
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.
</p>
<div class="main-controls">
<input
onInput=${this.onNewUsernameInput}
placeholder="Username"
value=${newLabelUsername}
/>
<button class="button" onClick=${this.addNewLabel}>
Add New Label
</button>
<button class="button" onClick=${this.saveUserLabels}>
Save All Changes${hasUnsavedChanges ? '*' : ''}
</button>
</div>
<div class="groups">${labels}</div>
</main>
`;
}
}

View File

@ -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(<App userLabels={userLabels} />, document.body);
});
type Props = {
userLabels: Value<UserLabelsData>;
};
type State = {
hasUnsavedChanges: boolean;
newLabelUsername: string;
userLabels: UserLabelsData;
};
class App extends Component<Props, State> {
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<string, UserLabel[]>();
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}) => (
<span style={{background: color}} class="label-preview">
{text}
</span>
));
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(
<li key={label.id}>
<div>
{index === 0 ? <label>Text</label> : undefined}
<input
onInput={textHandler}
placeholder="Text"
value={label.text}
/>
</div>
<div>
{index === 0 ? <label>Color</label> : undefined}
<input
onInput={colorHandler}
placeholder="Color"
value={label.color}
/>
</div>
<div>
{index === 0 ? <label>Priority</label> : undefined}
<input
onInput={priorityHandler}
placeholder="Priority"
type="number"
value={label.priority}
/>
</div>
<div>
{index === 0 ? <label>Controls</label> : undefined}
<button class="button destructive" onClick={removeHandler}>
Remove
</button>
</div>
</li>,
);
}
labels.push(
<div class="group">
<h2>
{username} {labelPreviews}
</h2>
<ul>{userLabels}</ul>
</div>,
);
}
return (
<>
<header class="page-header">
<h1>
<img src="/tildes-reextended.png" />
User Label Editor
</h1>
</header>
<main class="page-main user-label-editor">
<p class="info">
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.
<br />
<b>Changes are not automatically saved!</b>
<br />
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.
</p>
<div class="main-controls">
<input
onInput={this.onNewUsernameInput}
placeholder="Username"
value={newLabelUsername}
/>
<button class="button" onClick={this.addNewLabel}>
Add New Label
</button>
<button class="button" onClick={this.saveUserLabels}>
Save All Changes{hasUnsavedChanges ? "*" : ""}
</button>
</div>
<div class="groups">{labels}</div>
</main>
</>
);
}
}

15
source/packages.d.ts vendored Normal file
View File

@ -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<typeof cpSync>;
type Options = {
src?: CopySyncParameters[0];
dest?: CopySyncParameters[1];
} & CopySyncParameters[2];
export default function (options: Options): Plugin;
}

View File

@ -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';

View File

@ -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 `<button>`.
if (settings.data.hideVotes.topics) {
selectors.push('button > .topic-voting-votes:not(.trx-votes-hidden)');
}
// Topics by yourself will be encapsulated with a `<div>`.
if (settings.data.hideVotes.ownTopics) {
selectors.push('div > .topic-voting-votes:not(.trx-votes-hidden)');
}
const topicVotes = querySelectorAll(...selectors);
count += topicVotes.length;
for (const vote of topicVotes) {
vote.classList.add('trx-votes-hidden');
vote.textContent = '-';
}
}
return count;
}

View File

@ -1,45 +0,0 @@
import Settings from '../settings.js';
import {log, querySelectorAll} from '../utilities/exports.js';
export function runUsernameColorsFeature(settings: Settings) {
const count = usernameColors(settings);
log(`Username Colors: Applied ${count} colors.`);
}
function usernameColors(settings: Settings): number {
const usernameColors = new Map<string, string>();
for (const {color, username: usernames} of settings.data.usernameColors) {
for (const username of usernames.split(',')) {
usernameColors.set(username.trim().toLowerCase(), color);
}
}
let count = 0;
const usernameElements = querySelectorAll<HTMLElement>(
'.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() ??
'<unknown>';
if (settings.features.anonymizeUsernames) {
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;
}

View File

@ -1,15 +1,15 @@
@use 'sass:color'; @use "sass:color";
body { body {
$accents: ( $accents: (
'red' #dc322f, "red" #dc322f,
'orange' #cb4b16, "orange" #cb4b16,
'yellow' #b58900, "yellow" #b58900,
'green' #859900, "green" #859900,
'cyan' #2aa198, "cyan" #2aa198,
'blue' #268bd2, "blue" #268bd2,
'violet' #6c71c4, "violet" #6c71c4,
'magenta' #d33682, "magenta" #d33682,
); );
--background-primary: #{color.adjust(#002b36, $lightness: -5%)}; --background-primary: #{color.adjust(#002b36, $lightness: -5%)};

View File

@ -0,0 +1,9 @@
// Scripts
@import "scripts/autocomplete";
@import "scripts/back-to-top";
@import "scripts/jump-to-new-comment";
@import "scripts/markdown-toolbar";
@import "scripts/user-labels";
// Miscellaneous
@import "shared";

View File

@ -1,7 +1,10 @@
@import 'reset'; @use "modern-normalize/modern-normalize.css";
@import 'variables'; @import "reset";
@import 'colors'; @import "variables";
@import 'button'; @import "colors";
@import "button";
@import "shared";
@import "settings";
html { html {
font-size: 62.5%; font-size: 62.5%;
@ -136,7 +139,7 @@ details {
&::after { &::after {
color: var(--light-green); color: var(--light-green);
content: ''; content: "";
margin-left: auto; margin-left: auto;
} }
} }
@ -154,7 +157,3 @@ details {
background-color: var(--background-secondary); background-color: var(--background-secondary);
padding: 16px; padding: 16px;
} }
/* stylelint-disable no-invalid-position-at-import-rule */
@import 'shared';
@import 'settings';

View File

@ -1 +0,0 @@
@import '../../node_modules/modern-normalize/modern-normalize.css';

View File

@ -1,9 +0,0 @@
// Scripts
@import 'scripts/autocomplete';
@import 'scripts/back-to-top';
@import 'scripts/jump-to-new-comment';
@import 'scripts/markdown-toolbar';
@import 'scripts/user-labels';
// Miscellaneous
@import 'shared';

View File

@ -1,4 +1,4 @@
@import 'button'; @import "button";
.user-label-editor { .user-label-editor {
input { input {

View File

@ -1,189 +0,0 @@
import {migrate} from 'migration-helper';
import browser from 'webextension-polyfill';
import {migrations, deserializeData} from './migrations.js';
import {log} from './utilities/exports.js';
export default class Settings {
public static async fromSyncStorage(): Promise<Settings> {
const settings = new Settings();
const sync = {
...settings,
...(await browser.storage.sync.get(null)),
};
const migrated = (await migrate(
sync,
sync.version ?? settings.version,
migrations,
)) as Record<string, any>;
const deserialized = deserializeData(migrated);
settings.data = migrated.data as Settings['data'];
settings.data.userLabels = deserialized.userLabels;
settings.data.usernameColors = deserialized.usernameColors;
settings.features = migrated.features as Settings['features'];
settings.version = migrated.version as Settings['version'];
if (sync.version !== settings.version) {
await settings.save();
}
return settings;
}
public static manifest(): TRXManifest {
return browser.runtime.getManifest();
}
public static async nuke(event?: MouseEvent): Promise<void> {
if (event !== undefined) {
event.preventDefault();
}
if (
// eslint-disable-next-line no-alert
window.confirm(
'Are you sure you want to delete your data? There is no way to recover it once it has been deleted.',
)
) {
await browser.storage.sync.clear();
log(
'Data removed, reloading this page to reinitialize default settings.',
true,
);
setTimeout(() => {
window.location.reload();
}, 1000);
}
}
public data: {
hideVotes: {
[index: string]: boolean;
comments: boolean;
topics: boolean;
ownComments: boolean;
ownTopics: boolean;
};
knownGroups: string[];
latestActiveFeatureTab: string;
userLabels: UserLabel[];
usernameColors: UsernameColor[];
};
public features: {
[index: string]: boolean;
anonymizeUsernames: boolean;
autocomplete: boolean;
backToTop: boolean;
debug: boolean;
hideVotes: boolean;
jumpToNewComment: boolean;
markdownToolbar: boolean;
themedLogo: boolean;
userLabels: boolean;
usernameColors: boolean;
};
public version: string;
private constructor() {
this.data = {
hideVotes: {
comments: true,
topics: true,
ownComments: true,
ownTopics: true,
},
// If groups are added or removed from Tildes this does not necessarily need
// to be updated. There is a helper function available to update it whenever
// the user goes to "/groups", where all the groups are easily available.
// Features that use this data should be added to the `usesKnownGroups`
// array that is near the top of `content-scripts.ts`.
knownGroups: [
'~anime',
'~arts',
'~books',
'~comp',
'~creative',
'~design',
'~enviro',
'~finance',
'~food',
'~games',
'~games.game_design',
'~games.tabletop',
'~health',
'~health.coronavirus',
'~hobbies',
'~humanities',
'~lgbt',
'~life',
'~misc',
'~movies',
'~music',
'~news',
'~science',
'~space',
'~sports',
'~talk',
'~tech',
'~test',
'~tildes',
'~tildes.official',
'~tv',
],
latestActiveFeatureTab: 'debug',
userLabels: [],
usernameColors: [],
};
this.features = {
anonymizeUsernames: false,
autocomplete: true,
backToTop: true,
debug: false,
hideVotes: false,
jumpToNewComment: true,
markdownToolbar: true,
themedLogo: false,
userLabels: true,
usernameColors: false,
};
this.version = '0.0.0';
}
public manifest(): TRXManifest {
return Settings.manifest();
}
public async nuke(event?: MouseEvent): Promise<void> {
await Settings.nuke(event);
}
public async save(): Promise<void> {
const sync: Record<string, any> = {
data: {
hideVotes: this.data.hideVotes,
knownGroups: this.data.knownGroups,
latestActiveFeatureTab: this.data.latestActiveFeatureTab,
},
features: this.features,
version: this.version,
};
for (const label of this.data.userLabels) {
sync[`userLabel${label.id}`] = {...label};
}
for (const color of this.data.usernameColors) {
sync[`usernameColor${color.id}`] = {...color};
}
await browser.storage.sync.set(sync);
}
}

104
source/storage/common.ts Normal file
View File

@ -0,0 +1,104 @@
import {type Value, createValue} from "@holllo/webextension-storage";
import browser from "webextension-polyfill";
export enum Feature {
AnonymizeUsernames = "anonymize-users",
Autocomplete = "autocomplete",
BackToTop = "back-to-top",
Debug = "debug",
HideVotes = "hide-votes",
JumpToNewComment = "jump-to-new-comment",
MarkdownToolbar = "markdown-toolbar",
ThemedLogo = "themed-logo",
UserLabels = "user-labels",
UsernameColors = "username-colors",
}
export enum Data {
EnabledFeatures = "enabled-features",
KnownGroups = "known-groups",
LatestActiveFeatureTab = "latest-active-feature-tab",
}
export type HideVotesData = {
otherComments: boolean;
otherTopics: boolean;
ownComments: boolean;
ownTopics: boolean;
};
export type UserLabel = {
color: string;
id: number;
priority: number;
text: string;
username: string;
};
export type UserLabelsData = UserLabel[];
export type UsernameColor = {
color: string;
id: number;
username: string;
};
export type UsernameColorsData = UsernameColor[];
export const storageValues = {
[Data.EnabledFeatures]: createValue({
deserialize: (input) => new Set(JSON.parse(input) as Feature[]),
serialize: (input) => JSON.stringify(Array.from(input)),
key: Data.EnabledFeatures,
value: new Set([]),
storage: browser.storage.sync,
}),
[Data.KnownGroups]: createValue({
deserialize: (input) => new Set(JSON.parse(input) as string[]),
serialize: (input) => JSON.stringify(Array.from(input)),
key: Data.KnownGroups,
value: new Set([]),
storage: browser.storage.sync,
}),
[Data.LatestActiveFeatureTab]: createValue({
deserialize: (input) => JSON.parse(input) as Feature,
serialize: (input) => JSON.stringify(input),
key: Data.LatestActiveFeatureTab,
value: Feature.Debug,
storage: browser.storage.sync,
}),
[Feature.HideVotes]: createValue({
deserialize: (input) => JSON.parse(input) as HideVotesData,
serialize: (input) => JSON.stringify(input),
key: Feature.HideVotes,
value: {
otherComments: false,
otherTopics: false,
ownComments: true,
ownTopics: true,
},
storage: browser.storage.sync,
}),
[Feature.UserLabels]: createValue({
deserialize: (input) => JSON.parse(input) as UserLabelsData,
serialize: (input) => JSON.stringify(Array.from(input)),
key: Feature.UserLabels,
value: [],
storage: browser.storage.sync,
}),
[Feature.UsernameColors]: createValue({
deserialize: (input) => JSON.parse(input) as UsernameColorsData,
serialize: (input) => JSON.stringify(Array.from(input)),
key: Feature.UsernameColors,
value: [],
storage: browser.storage.sync,
}),
};
type StorageValues = typeof storageValues;
export async function fromStorage<K extends keyof StorageValues>(
key: K,
): Promise<StorageValues[K]> {
return storageValues[key];
}

44
source/types.d.ts vendored
View File

@ -1,47 +1,15 @@
import {html} from 'htm/preact'; // Export something so TypeScript doesn't see this file as an ambient module.
import browser from 'webextension-polyfill'; export {};
declare global { declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface Window { interface Window {
TildesReExtended: { TildesReExtended: {
debug: boolean; debug: boolean;
}; };
} }
interface ImportMetaEnv { const $browser: "chromium" | "firefox";
readonly DEV: boolean; const $dev: boolean;
} const $test: boolean;
interface ImportMeta {
readonly env: ImportMetaEnv;
}
type TRXComponent = ReturnType<typeof html>;
type TRXManifest = browser.Manifest.ManifestBase;
type UserLabel = {
color: string;
id: number;
priority: number;
text: string;
username: string;
};
type UsernameColor = {
color: string;
id: number;
username: string;
};
// Removes an index signature from a type, useful for getting all defined keys
// from an object that also has an index signature, like Settings.features.
// https://stackoverflow.com/a/66252656
type RemoveIndexSignature<T> = {
[K in keyof T as string extends K
? never
: number extends K
? never
: K]: T[K];
};
} }

View File

@ -1,6 +1,6 @@
/** Returns whether a hex color is "bright". */ /** Returns whether a hex color is "bright". */
export function isColorBright(color: string): boolean { export function isColorBright(color: string): boolean {
if (color.startsWith('#')) { if (color.startsWith("#")) {
color = color.slice(1); color = color.slice(1);
} }
@ -19,16 +19,16 @@ export function isColorBright(color: string): boolean {
// transform it. For example "#123" is the same as "#112233". // transform it. For example "#123" is the same as "#112233".
if (color.length === 3) { if (color.length === 3) {
color = color color = color
.split('') .split("")
.map((value) => value.repeat(2)) .map((value) => value.repeat(2))
.join(''); .join("");
} }
// Split the color up into 3 segments of 2 characters and convert them from // Split the color up into 3 segments of 2 characters and convert them from
// hexadecimal to decimal. // hexadecimal to decimal.
const [red, green, blue] = color const [red, green, blue] = color
.split(/(.{2})/) .split(/(.{2})/)
.filter((value) => value !== '') .filter((value) => value !== "")
.map((value) => Number.parseInt(value, 16)); .map((value) => Number.parseInt(value, 16));
// Magical numbers taken from https://stackoverflow.com/a/12043228/12251171. // Magical numbers taken from https://stackoverflow.com/a/12043228/12251171.
@ -40,47 +40,47 @@ export function isColorBright(color: string): boolean {
/** CSS custom properties from the Tildes themes. */ /** CSS custom properties from the Tildes themes. */
export const themeColors = [ export const themeColors = [
{ {
name: 'Background Primary', name: "Background Primary",
value: '--background-primary-color', value: "--background-primary-color",
}, },
{ {
name: 'Background Secondary', name: "Background Secondary",
value: '--background-secondary-color', value: "--background-secondary-color",
}, },
{ {
name: 'Foreground Primary', name: "Foreground Primary",
value: '--foreground-primary-color', value: "--foreground-primary-color",
}, },
{ {
name: 'Foreground Secondary', name: "Foreground Secondary",
value: '--foreground-secondary-color', value: "--foreground-secondary-color",
}, },
{ {
name: 'Exemplary', name: "Exemplary",
value: '--comment-label-exemplary-color', value: "--comment-label-exemplary-color",
}, },
{ {
name: 'Off-topic', name: "Off-topic",
value: '--comment-label-offtopic-color', value: "--comment-label-offtopic-color",
}, },
{ {
name: 'Joke', name: "Joke",
value: '--comment-label-joke-color', value: "--comment-label-joke-color",
}, },
{ {
name: 'Noise', name: "Noise",
value: '--comment-label-noise-color', value: "--comment-label-noise-color",
}, },
{ {
name: 'Malice', name: "Malice",
value: '--comment-label-malice-color', value: "--comment-label-malice-color",
}, },
{ {
name: 'Mine', name: "Mine",
value: '--stripe-mine-color', value: "--stripe-mine-color",
}, },
{ {
name: 'Official', name: "Official",
value: '--alert-color', value: "--alert-color",
}, },
]; ];

View File

@ -1,21 +0,0 @@
import {html} from 'htm/preact';
type Props = {
class: string;
text: string;
url: string;
};
/** An `<a />` helper component with `target="_blank"` and `rel="noopener"`. */
export function Link(props: Props): TRXComponent {
return html`
<a
class="${props.class}"
href="${props.url}"
target="_blank"
rel="noopener"
>
${props.text}
</a>
`;
}

View File

@ -0,0 +1,14 @@
type Props = {
class?: string;
text: string;
url: string;
};
/** An `<a />` helper component with `target="_blank"` and `rel="noopener"`. */
export function Link(props: Props): TRXComponent {
return (
<a class={props.class} href={props.url} target="_blank" rel="noopener">
{props.text}
</a>
);
}

View File

@ -3,7 +3,7 @@
* `htm/preact` isn't practical. * `htm/preact` isn't practical.
*/ */
export function createElementFromString<T extends Element>(input: string): T { export function createElementFromString<T extends Element>(input: string): T {
const template = document.createElement('template'); const template = document.createElement("template");
template.innerHTML = input.trim(); template.innerHTML = input.trim();
return template.content.firstElementChild as T; return template.content.firstElementChild as T;
} }

View File

@ -1,9 +1,9 @@
export * from './color.js'; export * from "./color.js";
export * from './components/link.js'; export * from "./components/link.js";
export * from './elements.js'; export * from "./elements.js";
export * from './globals.js'; export * from "./globals.js";
export * from './groups.js'; export * from "./groups.js";
export * from './logging.js'; export * from "./logging.js";
export * from './query-selectors.js'; export * from "./query-selectors.js";
export * from './report-a-bug.js'; export * from "./report-a-bug.js";
export * from './validators.js'; export * from "./validators.js";

View File

@ -1,17 +1,17 @@
import {log} from './logging.js'; import {log} from "./logging.js";
import {querySelectorAll} from './query-selectors.js'; import {querySelectorAll} from "./query-selectors.js";
/** /**
* Tries to extract and save the groups. Returns the current saved groups when * Tries to extract and save the groups. Returns the current saved groups when
* the user is not in `/groups` and the new ones when they are in `/groups`. * the user is not in `/groups` and the new ones when they are in `/groups`.
*/ */
export function extractGroups(): string[] | undefined { export function extractGroups(): string[] | undefined {
if (window.location.pathname !== '/groups') { if (window.location.pathname !== "/groups") {
log('Not in "/groups", returning early.'); log('Not in "/groups", returning early.');
return; return;
} }
return querySelectorAll('.link-group').map( return querySelectorAll(".link-group").map(
(value) => value.textContent ?? '<unknown group>', (value) => value.textContent ?? "<unknown group>",
); );
} }

View File

@ -4,14 +4,14 @@
* @param force If true, ignores whether or not debug logging is enabled. * @param force If true, ignores whether or not debug logging is enabled.
*/ */
export function log(thing: any, force = false): void { export function log(thing: any, force = false): void {
let overrideStyle = ''; let overrideStyle = "";
let prefix = '[TRX]'; let prefix = "[TRX]";
if (force) { if (force) {
prefix = '%c' + prefix; prefix = "%c" + prefix;
overrideStyle = 'background-color: #dc322f; margin-right: 9px;'; overrideStyle = "background-color: #dc322f; margin-right: 9px;";
} }
if (window.TildesReExtended?.debug || import.meta.env.DEV || force) { if (window.TildesReExtended?.debug || $dev || force) {
if (overrideStyle.length > 0) { if (overrideStyle.length > 0) {
console.debug(prefix, overrideStyle, thing); console.debug(prefix, overrideStyle, thing);
} else { } else {

View File

@ -1,4 +1,4 @@
import platform from 'platform'; import platform from "platform";
/** /**
* Creates a bug report template in Markdown. * Creates a bug report template in Markdown.
@ -6,21 +6,21 @@ import platform from 'platform';
* @param trxVersion The Tildes ReExtended version to include in the template. * @param trxVersion The Tildes ReExtended version to include in the template.
*/ */
export function createReportTemplate( export function createReportTemplate(
location: 'gitlab' | 'tildes', location: "gitlab" | "tildes",
trxVersion: string, trxVersion: string,
): string { ): string {
let introText = let introText =
"Thank you for taking the time to report a bug! Don't forget to fill in an\n appropriate title above, and make sure the information below is correct."; "Thank you for taking the time to report a bug! Don't forget to fill in an\n appropriate title above, and make sure the information below is correct.";
if (location === 'tildes') { if (location === "tildes") {
introText = introText =
'Thank you for taking the time to report a bug! Please make sure the\n information below is correct.'; "Thank you for taking the time to report a bug! Please make sure the\n information below is correct.";
} }
const layout = platform.layout ?? '<unknown>'; const layout = platform.layout ?? "<unknown>";
const name = platform.name ?? '<unknown>'; const name = platform.name ?? "<unknown>";
const os = platform.os?.toString() ?? '<unknown>'; const os = platform.os?.toString() ?? "<unknown>";
const version = platform.version ?? '<unknown>'; const version = platform.version ?? "<unknown>";
// Set the headers using HTML tags, these can't be with #-style Markdown // Set the headers using HTML tags, these can't be with #-style Markdown
// headers as they'll be interpreted as an ID instead of Markdown content. // headers as they'll be interpreted as an ID instead of Markdown content.

75
source/web-ext.ts Normal file
View File

@ -0,0 +1,75 @@
import path from "node:path";
/**
* Barebones type definition for web-ext configuration.
*
* Since web-ext doesn't export any types this is done by ourselves. The keys
* mostly follow a camelCased version of the CLI options
* (ie. --start-url becomes startUrl).
*/
type WebExtConfig = {
artifactsDir: string;
sourceDir: string;
verbose?: boolean;
build: {
filename: string;
overwriteDest: boolean;
};
run: {
browserConsole: boolean;
firefoxProfile: string;
keepProfileChanges: boolean;
profileCreateIfMissing: boolean;
startUrl: string[];
target: string[];
};
};
/**
* Create the web-ext configuration.
*
* @param browser The browser target ("firefox" or "chromium").
* @param buildDir The path to the build directory.
* @param dev Is this for development or production.
* @param outDir The path to the output directory.
* @returns The configuration for web-ext.
*/
export function createWebExtConfig(
browser: string,
buildDir: string,
dev: boolean,
outDir: string,
): WebExtConfig {
const config: WebExtConfig = {
artifactsDir: path.join(buildDir, "artifacts"),
sourceDir: outDir,
build: {
filename: `{name}-{version}-${browser}.zip`,
overwriteDest: true,
},
run: {
browserConsole: dev,
firefoxProfile: path.join(buildDir, "firefox-profile/"),
keepProfileChanges: true,
profileCreateIfMissing: true,
startUrl: [],
target: [],
},
};
if (browser === "firefox") {
config.run.startUrl.push("about:debugging#/runtime/this-firefox");
config.run.target.push("firefox-desktop");
} else if (browser === "chromium") {
config.run.startUrl.push("chrome://extensions/");
config.run.target.push("chromium");
} else {
throw new Error(`Unknown target browser: ${browser}`);
}
return config;
}

View File

@ -1,21 +1,19 @@
{ {
"compilerOptions": { "compilerOptions": {
"esModuleInterop": true, "esModuleInterop": true,
"jsx": "react-jsx",
"jsxImportSource": "preact",
"lib": [ "lib": [
"ES2020" "DOM",
"ES2022"
], ],
"module": "esnext", "module": "ES2022",
"moduleResolution": "node", "moduleResolution": "Node",
"noEmit": true, "resolveJsonModule": true,
"outDir": "build/",
"strict": true, "strict": true,
"target": "esnext" "target": "ES2022"
}, },
"include": [ "include": [
"source/**/*.ts", "source"
"vite.config.ts"
],
"exclude": [
"node_modules/"
] ]
} }

View File

@ -1,35 +0,0 @@
import path from 'node:path';
import url from 'node:url';
import preact from '@preact/preset-vite';
import {defineConfig} from 'vite';
import webExtension from 'vite-plugin-web-extension';
const currentDir = path.dirname(url.fileURLToPath(import.meta.url));
const buildDir = path.join(currentDir, 'build');
const sourceDir = path.join(currentDir, 'source');
export default defineConfig({
build: {
outDir: buildDir,
sourcemap: 'inline',
},
plugins: [
preact(),
webExtension({
additionalInputs: ['options/user-label-editor.html'],
assets: 'assets',
browser: 'firefox',
manifest: path.join(sourceDir, 'manifest.json'),
webExtConfig: {
browserConsole: true,
firefoxProfile: 'firefox/',
keepProfileChanges: true,
startUrl: 'about:debugging#/runtime/this-firefox',
target: 'firefox-desktop',
},
}),
],
root: sourceDir,
});