Compare commits
No commits in common. "e03fcec09c6ae3b66ce4f359596fc1e679e4307c" and "42eedd833f4af46ef9e36b3a5461644aebafe042" have entirely different histories.
e03fcec09c
...
42eedd833f
|
@ -1,8 +1,111 @@
|
||||||
.direnv/
|
# Logs
|
||||||
.vscode/
|
logs
|
||||||
build/
|
*.log
|
||||||
chromium/
|
npm-debug.log*
|
||||||
coverage/
|
yarn-debug.log*
|
||||||
firefox/
|
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/
|
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/
|
||||||
web-ext-artifacts/
|
web-ext-artifacts/
|
||||||
|
|
||||||
|
# Firefox profile directory
|
||||||
|
firefox/
|
||||||
|
|
100
DEVELOPMENT.md
100
DEVELOPMENT.md
|
@ -1,100 +0,0 @@
|
||||||
# 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>
|
|
|
@ -1,38 +0,0 @@
|
||||||
# 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.
|
|
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
||||||
The MIT License
|
The MIT License
|
||||||
|
|
||||||
Copyright 2019-2023 Tildes Community and Contributors
|
Copyright 2019-2022 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
|
||||||
|
|
|
@ -1,67 +0,0 @@
|
||||||
[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"]
|
|
41
README.md
41
README.md
|
@ -1,7 +1,44 @@
|
||||||
# Tildes ReExtended
|
# Tildes ReExtended
|
||||||
|
|
||||||
> **The principal enhancement suite for Tildes.**
|
> An updated and reimagined recreation of the [original Tildes Extended](https://github.com/theCrius/tildes-extended) web extension by Crius.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
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.
|
Open-sourced under the [MIT License](https://gitlab.com/tildes-community/tildes-reextended/blob/main/LICENSE).
|
||||||
|
|
59
flake.lock
59
flake.lock
|
@ -1,59 +0,0 @@
|
||||||
{
|
|
||||||
"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
13
flake.nix
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
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; };
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
66
package.json
66
package.json
|
@ -1,45 +1,59 @@
|
||||||
{
|
{
|
||||||
"type": "module",
|
"name": "tildes-reextended",
|
||||||
|
"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",
|
||||||
"modern-normalize": "^2.0.0",
|
"htm": "^3.1.0",
|
||||||
|
"migration-helper": "^0.1.2",
|
||||||
|
"modern-normalize": "^1.1.0",
|
||||||
"platform": "^1.3.6",
|
"platform": "^1.3.6",
|
||||||
"preact": "^10.15.1",
|
"preact": "^10.6.6",
|
||||||
"webextension-polyfill": "^0.10.0"
|
"webextension-polyfill": "^0.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@bauke/eslint-config": "^0.1.2",
|
"@preact/preset-vite": "^2.1.7",
|
||||||
"@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.10.0",
|
"@types/webextension-polyfill": "^0.8.2",
|
||||||
"concurrently": "^8.2.0",
|
"postcss": "^8.4.8",
|
||||||
"cssnano": "^6.0.1",
|
"sass": "^1.49.9",
|
||||||
"esbuild": "^0.18.6",
|
"stylelint": "^14.5.3",
|
||||||
"esbuild-copy-static-files": "^0.1.0",
|
"stylelint-config-standard-scss": "^3.0.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",
|
||||||
"tsx": "^3.12.7",
|
"typescript": "^4.6.2",
|
||||||
"typescript": "^5.1.3",
|
"vite": "^2.8.6",
|
||||||
"web-ext": "^7.6.2",
|
"vite-plugin-web-extension": "^1.1.3",
|
||||||
"xo": "^0.54.2"
|
"web-ext": "^6.7.0",
|
||||||
|
"xo": "^0.48.0"
|
||||||
},
|
},
|
||||||
"prettier": "@bauke/prettier-config",
|
|
||||||
"stylelint": {
|
"stylelint": {
|
||||||
"extends": "@bauke/stylelint-config"
|
"extends": [
|
||||||
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
7584
pnpm-lock.yaml
7584
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
@ -1,7 +0,0 @@
|
||||||
{ pkgs ? import <nixpkgs> { } }:
|
|
||||||
|
|
||||||
with pkgs;
|
|
||||||
|
|
||||||
mkShell rec {
|
|
||||||
packages = [ cargo-make nodejs nodePackages.pnpm ];
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
<!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>
|
|
Before Width: | Height: | Size: 963 B After Width: | Height: | Size: 963 B |
|
@ -1,5 +1,4 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"
|
<svg xmlns="http://www.w3.org/2000/svg" width="128" viewBox="0 0 100 100">
|
||||||
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.4 KiB After Width: | Height: | Size: 1.3 KiB |
|
@ -0,0 +1,19 @@
|
||||||
|
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();
|
||||||
|
}
|
|
@ -1,17 +0,0 @@
|
||||||
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
122
source/build.ts
|
@ -1,122 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
|
@ -0,0 +1,142 @@
|
||||||
|
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();
|
|
@ -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";
|
|
|
@ -1,62 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
|
@ -1,51 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
|
@ -1,153 +0,0 @@
|
||||||
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);
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
{
|
||||||
|
"$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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,72 +0,0 @@
|
||||||
/* 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;
|
|
||||||
}
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
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;
|
||||||
|
}
|
|
@ -0,0 +1,216 @@
|
||||||
|
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>
|
||||||
|
<//>
|
||||||
|
`;
|
||||||
|
}
|
|
@ -1,160 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,14 +1,16 @@
|
||||||
import {Setting, type SettingProps} from "./index.js";
|
import {html} from 'htm/preact';
|
||||||
|
|
||||||
export function AnonymizeUsernamesSetting(props: SettingProps) {
|
import {Setting, SettingProps} from './index.js';
|
||||||
return (
|
|
||||||
<Setting {...props}>
|
export function AnonymizeUsernamesSetting(props: SettingProps): TRXComponent {
|
||||||
|
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>
|
<//>
|
||||||
);
|
`;
|
||||||
}
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
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>
|
||||||
|
<//>
|
||||||
|
`;
|
||||||
|
}
|
|
@ -1,13 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,14 +1,15 @@
|
||||||
import {type JSX} from "preact";
|
import {html} from 'htm/preact';
|
||||||
import {Setting, type SettingProps} from "./index.js";
|
|
||||||
|
|
||||||
export function BackToTopSetting(props: SettingProps): JSX.Element {
|
import {Setting, SettingProps} from './index.js';
|
||||||
return (
|
|
||||||
<Setting {...props}>
|
export function BackToTopSetting(props: SettingProps): TRXComponent {
|
||||||
|
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>
|
<//>
|
||||||
);
|
`;
|
||||||
}
|
}
|
|
@ -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';
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
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>
|
||||||
|
<//>
|
||||||
|
`;
|
||||||
|
}
|
|
@ -1,75 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
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>
|
||||||
|
`;
|
||||||
|
}
|
|
@ -1,52 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
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>
|
||||||
|
<//>
|
||||||
|
`;
|
||||||
|
}
|
|
@ -1,13 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,27 +1,31 @@
|
||||||
import {type JSX} from "preact";
|
import {html} from 'htm/preact';
|
||||||
import {Link} from "../../utilities/exports.js";
|
|
||||||
import {Setting, type SettingProps} from "./index.js";
|
|
||||||
|
|
||||||
export function MarkdownToolbarSetting(props: SettingProps): JSX.Element {
|
import {Link} from '../../utilities/exports.js';
|
||||||
return (
|
import {Setting, SettingProps} from './index.js';
|
||||||
<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{" "}
|
|
||||||
<Link
|
<br />
|
||||||
|
|
||||||
|
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>
|
<//>
|
||||||
);
|
`;
|
||||||
}
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
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>
|
||||||
|
<//>
|
||||||
|
`;
|
||||||
|
}
|
|
@ -1,13 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,25 +1,25 @@
|
||||||
import {Setting, type SettingProps} from "./index.js";
|
import {html} from 'htm/preact';
|
||||||
|
|
||||||
export function UserLabelsSetting(props: SettingProps) {
|
import {Setting, SettingProps} from './index.js';
|
||||||
return (
|
|
||||||
<Setting {...props}>
|
export function UserLabelsSetting(props: SettingProps): TRXComponent {
|
||||||
|
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="/options/user-label-editor.html">User Label Editor</a> to add,
|
<a href="./user-label-editor.html">User Label Editor</a>
|
||||||
edit, or remove user labels.
|
to add, 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>
|
<li><b>Username</b>: who to apply the label to.</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) {
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
</Setting>
|
<//>
|
||||||
);
|
`;
|
||||||
}
|
}
|
|
@ -0,0 +1,163 @@
|
||||||
|
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}
|
||||||
|
<//>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,176 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,10 +1,11 @@
|
||||||
import {createContext} from "preact";
|
import {createContext} from 'preact';
|
||||||
import {type Feature} from "../storage/common.js";
|
|
||||||
|
import Settings from '../settings.js';
|
||||||
|
|
||||||
type AppContextValues = {
|
type AppContextValues = {
|
||||||
setActiveFeature: (feature: Feature) => void;
|
settings: Settings;
|
||||||
toggleFeature: (feature: Feature) => void;
|
setActiveFeature: (feature: string) => 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!);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import {Feature} from "../storage/common.js";
|
import Settings from '../settings.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 FeatureData = {
|
type Feature = {
|
||||||
availableSince: Date;
|
availableSince: Date;
|
||||||
index: number;
|
index: number;
|
||||||
key: Feature;
|
key: keyof RemoveIndexSignature<Settings['features']>;
|
||||||
title: string;
|
title: string;
|
||||||
component: any;
|
component: () => any;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const features: FeatureData[] = [
|
export const features: Feature[] = [
|
||||||
{
|
{
|
||||||
availableSince: new Date("2022-02-23"),
|
availableSince: new Date('2022-02-23'),
|
||||||
index: 0,
|
index: 0,
|
||||||
key: Feature.AnonymizeUsernames,
|
key: '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: Feature.Autocomplete,
|
key: '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: Feature.BackToTop,
|
key: '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: Feature.HideVotes,
|
key: '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: Feature.JumpToNewComment,
|
key: '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: Feature.MarkdownToolbar,
|
key: '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: Feature.ThemedLogo,
|
key: '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: Feature.UserLabels,
|
key: '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: Feature.UsernameColors,
|
key: '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: Feature.Debug,
|
key: 'debug',
|
||||||
title: "About & Info",
|
title: 'About & Info',
|
||||||
component: AboutSetting,
|
component: () => AboutSetting,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -5,15 +5,17 @@
|
||||||
<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="/tildes-reextended.png"
|
<link rel="shortcut icon" href="../assets/tildes-reextended-128.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/user-label-editor.js"></script>
|
<script type="module" src="./options.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
|
@ -0,0 +1,169 @@
|
||||||
|
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>
|
||||||
|
<//>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,165 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
<!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>
|
|
@ -0,0 +1,241 @@
|
||||||
|
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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,243 +0,0 @@
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
|
@ -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();
|
||||||
}
|
}
|
|
@ -1,12 +1,12 @@
|
||||||
import {offset, type Offset} from "caret-pos";
|
import {offset, Offset} from 'caret-pos';
|
||||||
import {Component} from "preact";
|
import {html} from 'htm/preact';
|
||||||
import {type UserLabelsData} from "../../storage/common.js";
|
import {Component} from 'preact';
|
||||||
import {log, querySelectorAll} from "../../utilities/exports.js";
|
|
||||||
|
import Settings from '../settings.js';
|
||||||
|
import {log, querySelectorAll} from '../utilities/exports.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
anonymizeUsernamesEnabled: boolean;
|
settings: Settings;
|
||||||
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 = Array.from(props.knownGroups).map((value) =>
|
const groups = props.settings.data.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.anonymizeUsernamesEnabled) {
|
if (props.settings.features.anonymizeUsernames) {
|
||||||
return (value.dataset.trxUsername ?? "<unknown>").toLowerCase();
|
return (value.dataset.trxUsername ?? '<unknown>').toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
return value.textContent!.replace(/^@/, "").toLowerCase();
|
return value.textContent!.replace(/^@/, '').toLowerCase();
|
||||||
}),
|
}),
|
||||||
...props.userLabels.map((value) => value.username),
|
...props.settings.data.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((value) => (
|
const groups = [...this.state.groupsMatches].map(
|
||||||
<li>~{value}</li>
|
(value) => html`<li>~${value}</li>`,
|
||||||
));
|
);
|
||||||
const usernames = [...this.state.usernamesMatches].map((value) => (
|
const usernames = [...this.state.usernamesMatches].map(
|
||||||
<li>@{value}</li>
|
(value) => html`<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,23 +200,21 @@ 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 (
|
return html`
|
||||||
<>
|
|
||||||
<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>
|
||||||
</>
|
`;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,6 +1,8 @@
|
||||||
import debounce from "debounce";
|
import debounce from 'debounce';
|
||||||
import {Component} from "preact";
|
import {html} from 'htm/preact';
|
||||||
import {log} from "../../utilities/exports.js";
|
import {Component} from 'preact';
|
||||||
|
|
||||||
|
import {log} from '../utilities/exports.js';
|
||||||
|
|
||||||
type Props = Record<string, unknown>;
|
type Props = Record<string, unknown>;
|
||||||
|
|
||||||
|
@ -17,7 +19,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();
|
||||||
|
@ -30,20 +32,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 (
|
return html`
|
||||||
<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>
|
||||||
);
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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';
|
|
@ -0,0 +1,62 @@
|
||||||
|
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;
|
||||||
|
}
|
|
@ -1,5 +1,7 @@
|
||||||
import {Component} from "preact";
|
import {html} from 'htm/preact';
|
||||||
import {log, querySelector, querySelectorAll} from "../../utilities/exports.js";
|
import {Component} from 'preact';
|
||||||
|
|
||||||
|
import {log, querySelector, querySelectorAll} from '../utilities/exports.js';
|
||||||
|
|
||||||
type Props = Record<string, unknown>;
|
type Props = Record<string, unknown>;
|
||||||
|
|
||||||
|
@ -13,7 +15,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,
|
||||||
|
@ -22,7 +24,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.`,
|
||||||
|
@ -33,25 +35,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});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -63,17 +65,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 (
|
return html`
|
||||||
<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>
|
||||||
);
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,5 +1,7 @@
|
||||||
import {render} from "preact";
|
import {html} from 'htm/preact';
|
||||||
import {log, querySelectorAll} from "../../utilities/exports.js";
|
import {render} from 'preact';
|
||||||
|
|
||||||
|
import {log, querySelectorAll} from '../utilities/exports.js';
|
||||||
|
|
||||||
type MarkdownSnippet = {
|
type MarkdownSnippet = {
|
||||||
dropdown: boolean;
|
dropdown: boolean;
|
||||||
|
@ -11,65 +13,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() {
|
||||||
|
@ -79,38 +81,39 @@ 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<HTMLTextAreaElement>(
|
const textarea = form.querySelector<HTMLElement>(
|
||||||
'textarea[name="markdown"]',
|
'textarea[name="markdown"]',
|
||||||
)!;
|
)!;
|
||||||
|
|
||||||
const snippetButtons = snippets
|
const snippetButtons = snippets
|
||||||
.filter((snippet) => !snippet.dropdown)
|
.filter((snippet) => !snippet.dropdown)
|
||||||
.map((snippet) => (
|
.map(
|
||||||
<SnippetButton snippet={snippet} textarea={textarea} />
|
(snippet) =>
|
||||||
));
|
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(
|
||||||
<SnippetDropdown textarea={textarea} />,
|
html`<${snippetDropdown} textarea=${textarea} />`,
|
||||||
menuParent,
|
menuParent,
|
||||||
dropdownPlaceholder,
|
dropdownPlaceholder,
|
||||||
);
|
);
|
||||||
|
@ -124,25 +127,25 @@ type Props = {
|
||||||
textarea: HTMLTextAreaElement;
|
textarea: HTMLTextAreaElement;
|
||||||
};
|
};
|
||||||
|
|
||||||
function SnippetButton(props: Required<Props>) {
|
function snippetButton(props: Required<Props>): TRXComponent {
|
||||||
const click = (event: MouseEvent) => {
|
const click = (event: MouseEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
insertSnippet(props);
|
insertSnippet(props);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return html`
|
||||||
<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) {
|
function snippetDropdown(props: Props): TRXComponent {
|
||||||
const options = snippets.map((snippet) => (
|
const options = snippets.map(
|
||||||
<option value={snippet.name}>{snippet.name}</option>
|
(snippet) => html`<option value="${snippet.name}">${snippet.name}</option>`,
|
||||||
));
|
);
|
||||||
|
|
||||||
const change = (event: Event) => {
|
const change = (event: Event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
@ -159,12 +162,12 @@ function SnippetDropdown(props: Props) {
|
||||||
(event.target as HTMLSelectElement).selectedIndex = 0;
|
(event.target as HTMLSelectElement).selectedIndex = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return html`
|
||||||
<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>) {
|
||||||
|
@ -186,7 +189,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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}")`;
|
||||||
}
|
}
|
|
@ -1,7 +1,8 @@
|
||||||
import debounce from "debounce";
|
import debounce from 'debounce';
|
||||||
import {Component, render} from "preact";
|
import {Component, render} from 'preact';
|
||||||
import {type Value} from "@holllo/webextension-storage";
|
import {html} from 'htm/preact';
|
||||||
import {type UserLabelsData} from "../../storage/common.js";
|
|
||||||
|
import Settings from '../settings.js';
|
||||||
import {
|
import {
|
||||||
createElementFromString,
|
createElementFromString,
|
||||||
isColorBright,
|
isColorBright,
|
||||||
|
@ -9,11 +10,10 @@ import {
|
||||||
log,
|
log,
|
||||||
querySelectorAll,
|
querySelectorAll,
|
||||||
themeColors,
|
themeColors,
|
||||||
} from "../../utilities/exports.js";
|
} from '../utilities/exports.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
anonymizeUsernamesEnabled: boolean;
|
settings: Settings;
|
||||||
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 {userLabels} = this.props;
|
const settings = this.props.settings;
|
||||||
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 = userLabels.value.sort((a, b): number => {
|
const sortedLabels = settings.data.userLabels.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 (this.props.anonymizeUsernamesEnabled) {
|
if (settings.features.anonymizeUsernames) {
|
||||||
username = element.dataset.trxUsername ?? username;
|
username = element.dataset.trxUsername ?? username;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,18 +101,19 @@ export class UserLabelsFeature extends Component<Props, State> {
|
||||||
(onlyID === undefined ? true : value.id === onlyID),
|
(onlyID === undefined ? true : value.id === onlyID),
|
||||||
);
|
);
|
||||||
|
|
||||||
const addLabel = (
|
const addLabel = html`
|
||||||
<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);
|
||||||
}
|
}
|
||||||
|
@ -121,9 +122,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);
|
||||||
}
|
}
|
||||||
|
@ -133,8 +134,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}"
|
||||||
|
@ -143,12 +144,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) {
|
||||||
|
@ -178,7 +179,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,
|
||||||
});
|
});
|
||||||
|
@ -192,12 +193,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.userLabels.value.find(
|
const label = this.props.settings.data.userLabels.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;
|
||||||
|
@ -213,17 +214,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});
|
||||||
|
@ -243,32 +244,34 @@ 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 {userLabels} = this.props;
|
const {settings} = 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 (userLabels.value.length > 0) {
|
if (settings.data.userLabels.length > 0) {
|
||||||
newId = userLabels.value.sort((a, b) => b.id - a.id)[0].id + 1;
|
newID = settings.data.userLabels.sort((a, b) => b.id - a.id)[0].id + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
userLabels.value.push({
|
settings.data.userLabels.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 = userLabels.value.findIndex((value) => value.id === id);
|
const index = settings.data.userLabels.findIndex(
|
||||||
userLabels.value.splice(index, 1);
|
(value) => value.id === id,
|
||||||
userLabels.value.push({
|
);
|
||||||
|
settings.data.userLabels.splice(index, 1);
|
||||||
|
settings.data.userLabels.push({
|
||||||
id,
|
id,
|
||||||
color,
|
color,
|
||||||
priority,
|
priority,
|
||||||
|
@ -280,17 +283,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 userLabels.save();
|
await settings.save();
|
||||||
this.props.userLabels = userLabels;
|
this.props.settings = settings;
|
||||||
this.hide();
|
this.hide();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -298,12 +301,14 @@ 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 {userLabels} = this.props;
|
const {settings} = this.props;
|
||||||
const index = userLabels.value.findIndex((value) => value.id === id);
|
const index = settings.data.userLabels.findIndex(
|
||||||
|
(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.`,
|
||||||
|
@ -316,20 +321,25 @@ export class UserLabelsFeature extends Component<Props, State> {
|
||||||
value.remove();
|
value.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
userLabels.value.splice(index, 1);
|
settings.data.userLabels.splice(index, 1);
|
||||||
await userLabels.save();
|
await settings.save();
|
||||||
this.props.userLabels = userLabels;
|
this.props.settings = settings;
|
||||||
this.hide();
|
this.hide();
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const bodyStyle = window.getComputedStyle(document.body);
|
const bodyStyle = window.getComputedStyle(document.body);
|
||||||
const themeSelectOptions = themeColors.map(({name, value}) => (
|
const themeSelectOptions = themeColors.map(
|
||||||
<option value={bodyStyle.getPropertyValue(value).trim()}>{name}</option>
|
({name, value}) =>
|
||||||
));
|
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;
|
||||||
|
@ -345,8 +355,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 (
|
return html`
|
||||||
<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
|
||||||
|
@ -354,7 +364,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>
|
||||||
|
@ -364,8 +374,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>
|
||||||
|
@ -380,18 +390,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>
|
||||||
|
@ -405,28 +415,22 @@ 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}>
|
<a class="btn-post-action" onClick=${this.save}>Save</a>
|
||||||
Save
|
<a class="btn-post-action" onClick=${this.hide}>Close</a>
|
||||||
</a>
|
<a class="btn-post-action" onClick=${this.remove}>Remove</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>
|
||||||
);
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
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;
|
||||||
|
}
|
|
@ -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%)};
|
||||||
|
|
|
@ -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";
|
|
|
@ -1,10 +1,7 @@
|
||||||
@use "modern-normalize/modern-normalize.css";
|
@import 'reset';
|
||||||
@import "reset";
|
@import 'variables';
|
||||||
@import "variables";
|
@import 'colors';
|
||||||
@import "colors";
|
@import 'button';
|
||||||
@import "button";
|
|
||||||
@import "shared";
|
|
||||||
@import "settings";
|
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-size: 62.5%;
|
font-size: 62.5%;
|
||||||
|
@ -139,7 +136,7 @@ details {
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
color: var(--light-green);
|
color: var(--light-green);
|
||||||
content: "●";
|
content: '●';
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -157,3 +154,7 @@ 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';
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
@import '../../node_modules/modern-normalize/modern-normalize.css';
|
|
@ -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';
|
|
@ -1,4 +1,4 @@
|
||||||
@import "button";
|
@import 'button';
|
||||||
|
|
||||||
.user-label-editor {
|
.user-label-editor {
|
||||||
input {
|
input {
|
||||||
|
|
|
@ -0,0 +1,189 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,104 +0,0 @@
|
||||||
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];
|
|
||||||
}
|
|
|
@ -1,15 +1,47 @@
|
||||||
// Export something so TypeScript doesn't see this file as an ambient module.
|
import {html} from 'htm/preact';
|
||||||
export {};
|
import browser from 'webextension-polyfill';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
|
||||||
interface Window {
|
interface Window {
|
||||||
TildesReExtended: {
|
TildesReExtended: {
|
||||||
debug: boolean;
|
debug: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const $browser: "chromium" | "firefox";
|
interface ImportMetaEnv {
|
||||||
const $dev: boolean;
|
readonly 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];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
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>
|
||||||
|
`;
|
||||||
|
}
|
|
@ -1,14 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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>',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 || $dev || force) {
|
if (window.TildesReExtended?.debug || import.meta.env.DEV || force) {
|
||||||
if (overrideStyle.length > 0) {
|
if (overrideStyle.length > 0) {
|
||||||
console.debug(prefix, overrideStyle, thing);
|
console.debug(prefix, overrideStyle, thing);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -1,75 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
|
@ -1,19 +1,21 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"jsx": "react-jsx",
|
|
||||||
"jsxImportSource": "preact",
|
|
||||||
"lib": [
|
"lib": [
|
||||||
"DOM",
|
"ES2020"
|
||||||
"ES2022"
|
|
||||||
],
|
],
|
||||||
"module": "ES2022",
|
"module": "esnext",
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "node",
|
||||||
"resolveJsonModule": true,
|
"noEmit": true,
|
||||||
|
"outDir": "build/",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"target": "ES2022"
|
"target": "esnext"
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"source"
|
"source/**/*.ts",
|
||||||
|
"vite.config.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules/"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
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,
|
||||||
|
});
|
Loading…
Reference in New Issue