1
Fork 0

Completely rewrite Tildes ReExtended with HTM and Preact.

This commit is contained in:
Bauke 2020-10-11 01:32:27 +02:00
parent 3f4df6c615
commit 662057df25
Signed by: Bauke
GPG Key ID: C1C0F29952BCF558
70 changed files with 3037 additions and 3235 deletions

View File

@ -1,3 +0,0 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}

22
LICENSE Normal file
View File

@ -0,0 +1,22 @@
The MIT License
Copyright 2019-2020 Tildes Community and Contributors
https://gitlab.com/tildes-community/tildes-reextended
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,8 +0,0 @@
Copyright 2019 Tildes Community and Contributors
https://gitlab.com/tildes-community/tildes-reextended
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

44
README.md Normal file
View File

@ -0,0 +1,44 @@
# Tildes ReExtended
> 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.
* [ ] The **Random Tildes Logo** feature now picks from theme-appropriate logos instead of a regular tilde character. [*WIP](https://gitlab.com/tildes-community/tildes-reextended/issues/7)
* [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.
* [ ] Dedicated interface to add, edit, and remove labels. [*WIP](https://gitlab.com/tildes-community/tildes-reextended/issues/1)
### 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.
* [ ] Anonymize usernames. [*WIP](https://gitlab.com/tildes-community/tildes-reextended/issues/5)
* [ ] Assign unique colors to people's usernames. [*WIP](https://gitlab.com/tildes-community/tildes-reextended/issues/6)
* [x] Export and import your extension settings.
## License
Open-sourced under the [MIT License](https://gitlab.com/tildes-community/tildes-reextended/blob/main/LICENSE).

View File

@ -1,44 +0,0 @@
# Tildes ReExtended
> An updated and reimagined recreation of [Crius' original Tildes Extended](https://github.com/theCrius/tildes-extended) web extension.
## 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 that while better and 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.
* [ ] The "Random Tildes Logo" feature now picks from theme-appropriate logos instead of a regular tilde character. [*WIP](https://gitlab.com/tildes-community/tildes-reextended/issues/7)
* [x] The "Jump To New Comment" button now uncollapses comments if the new one is collapsed or is 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] Able to pick any color you want.
* [ ] Dedicated interface to add, edit, and remove labels. [*WIP](https://gitlab.com/tildes-community/tildes-reextended/issues/1)
### 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 all vote counts. Or all but your own.
* [ ] Anonymize usernames while adding a unique color to usernames for easy recognition. [*WIP](https://gitlab.com/tildes-community/tildes-reextended/issues/5)
* [ ] Assign unique colors to people's usernames. [*WIP](https://gitlab.com/tildes-community/tildes-reextended/issues/6)
* [x] Export and import your settings.
## License
Licensed under [MIT](License).

View File

@ -2,48 +2,42 @@
"name": "tildes-reextended",
"description": "An updated and reimagined recreation of Tildes Extended to enhance and improve the experience of Tildes.net.",
"license": "MIT",
"version": "0.3.3",
"repository": "https://gitlab.com/tildes-community/tildes-reextended",
"authors": [
"Bauke <me@bauke.xyz>"
],
"private": true,
"scripts": {
"watch": "NODE_ENV=development parcel source/manifest.json -d build/ --no-hmr",
"watch": "NODE_ENV=development parcel 'source/manifest.json' -d 'build/' --no-hmr",
"start": "web-ext run --source-dir build/ --bc",
"start:chromium": "yarn start --chromium-profile chromium/ --keep-profile-changes --target chromium --start-url \"chrome://extensions\"",
"start:firefox": "yarn start --firefox-profile firefox/ --keep-profile-changes --target firefox-desktop --start-url \"about:debugging#/runtime/this-firefox\"",
"build": "yarn clean && parcel build source/manifest.json -d build/ && web-ext build --source-dir build/ && yarn zip-source",
"zip-source": "zip -r web-ext-artifacts/tildes_reextended-source.zip ReadMe.md yarn.lock tsconfig.json package.json .gitignore License source/ scripts/ .vscode/",
"clean": "trash .cache build/ web-ext-artifacts/",
"bump": "ts-node scripts/bump-version.ts && yarn build",
"test": "xo && stylelint 'source/scss/**/*.scss'"
"build": "yarn clean && parcel build 'source/manifest.json' -d 'build/' && web-ext build --source-dir 'build/' && yarn zip-source",
"zip-source": "zip -r web-ext-artifacts/tildes_reextended-source.zip README.md yarn.lock tsconfig.json package.json .gitignore LICENSE source/",
"test": "xo && stylelint 'source/scss/**/*.scss' && tsc --noEmit"
},
"dependencies": {
"caret-pos": "^2.0.0",
"debounce": "^1.2.0",
"htm": "^3.0.4",
"modern-normalize": "^1.0.0",
"platform": "^1.3.6",
"preact": "^10.5.3",
"webextension-polyfill-ts": "^0.20.0"
},
"devDependencies": {
"@types/debounce": "^1.2.0",
"@types/platform": "^1.3.3",
"@types/prompts": "^2.0.9",
"@types/semver": "^7.3.4",
"eslint-config-xo-typescript": "^0.33.0",
"husky": "^4.3.0",
"parcel-bundler": "^1.12.4",
"parcel-plugin-web-extension": "^1.6.1",
"prompts": "^2.3.2",
"sass": "^1.26.11",
"semver": "^7.3.2",
"simple-git": "^2.20.1",
"stylelint": "^13.7.2",
"stylelint-config-xo-scss": "^0.13.0",
"stylelint-config-xo-space": "^0.14.0",
"trash-cli": "^3.1.0",
"ts-node": "^9.0.0",
"type-fest": "^0.17.0",
"typescript": "^4.0.3",
"web-ext": "^5.1.0",
"web-ext-types": "^3.2.1",
@ -55,42 +49,36 @@
"stylelint-config-xo-space"
],
"ignoreFiles": [
"source/**/*.ts",
"build/**"
],
"rules": {
"scss/at-rule-no-unknown": null,
"at-rule-empty-line-before": null,
"at-rule-no-unknown": null,
"block-no-empty": null,
"no-descending-specificity": null
}
},
"xo": {
"global": [
"Blob",
"confirm",
"globals": [
"document",
"FileReader",
"getComputedStyle",
"performance",
"window"
],
"ignores": [
"build/",
"public/"
],
"prettier": true,
"rules": {
"@typescript-eslint/no-implicit-any-catch": "off",
"@typescript-eslint/member-ordering": "off",
"@typescript-eslint/no-loop-func": "off",
"unicorn/prefer-dataset": "off",
"unicorn/prefer-modern-dom-apis": "off",
"capitalized-comments": "off",
"no-await-in-loop": "off"
"@typescript-eslint/no-loop-func": "off"
},
"space": true
},
"browserslist": [
"last 2 Chrome versions"
]
],
"husky": {
"hooks": {
"pre-commit": "yarn test",
"pre-push": "yarn test"
}
}
}

View File

@ -1,116 +0,0 @@
import {promises as fs} from 'fs';
import {join} from 'path';
import prompts from 'prompts';
import semver from 'semver';
import git from 'simple-git/promise';
(async (): Promise<void> => {
const manifestJSONPath: string = join(__dirname, '../source/manifest.json');
const packageJSONPath: string = join(__dirname, '../package.json');
const manifestJSON: any = JSON.parse(
await fs.readFile(manifestJSONPath, 'utf8')
);
const packageJSON: any = JSON.parse(
await fs.readFile(packageJSONPath, 'utf8')
);
if (manifestJSON.version !== packageJSON.version) {
console.log(
`manifest.json and package.json versions are not the same:\n` +
`${String(manifestJSON.version)} | ${String(packageJSON.version)}`
);
return;
}
const currentVersion: string = manifestJSON.version;
const input = await prompts({
message: 'Bump major, minor or patch?',
name: 'type',
type: 'select',
choices: [
{
title: 'Major',
description: `${currentVersion} -> ${semver.inc(
currentVersion,
'major'
)!}`,
value: 'major'
},
{
title: 'Minor',
description: `${currentVersion} -> ${semver.inc(
currentVersion,
'minor'
)!}`,
value: 'minor'
},
{
title: 'Patch',
description: `${currentVersion} -> ${semver.inc(
currentVersion,
'patch'
)!}`,
value: 'patch'
}
] as Array<prompts.Choice & {description?: string}> | undefined,
initial: 1
});
switch (input.type) {
case 'major':
break;
case 'minor':
break;
case 'patch':
break;
default:
console.log(`Unknown input: ${String(input.type)}`);
return;
}
const newVersion: string | null = semver.inc(currentVersion, input.type);
if (newVersion === null) {
console.log(
`Something went wrong with semver incrementing ${currentVersion}`
);
return;
}
if (process.env.NODE_ENV === 'development') {
console.log(
'Running in development, not writing JSONs to file or committing the changes.'
);
return;
}
const repository: git.SimpleGit = git(join(__dirname, '../'));
const status: git.StatusResult = await repository.status();
if (status.staged.length > 0 || status.created.length > 0) {
console.log(
`Git repository has ${status.staged.length}/${status.created.length} staged/created files, commit these or unstage them then run this script again.`
);
return;
}
console.log(
`Bumping ${currentVersion} to ${newVersion}, writing JSONs to file.`
);
manifestJSON.version = newVersion;
await fs.writeFile(
manifestJSONPath,
JSON.stringify(manifestJSON, null, 2) + '\n'
);
packageJSON.version = newVersion;
await fs.writeFile(
packageJSONPath,
JSON.stringify(packageJSON, null, 2) + '\n'
);
console.log('Committing changed files and tagging version.');
await repository.add([manifestJSONPath, packageJSONPath]);
await repository.commit(`Version: ${newVersion}`);
await repository.addAnnotatedTag(`${newVersion}`, `Version ${newVersion}`);
})();

View File

@ -1,11 +1,11 @@
import {browser} from 'webextension-polyfill-ts';
// Add listeners to open the options page when:
// * The extension icon gets clicked.
// * The extension first gets installed.
// * The extension icon is clicked.
// * The extension is installed.
browser.browserAction.onClicked.addListener(openOptionsPage);
browser.runtime.onInstalled.addListener(openOptionsPage);
async function openOptionsPage(): Promise<void> {
async function openOptionsPage() {
await browser.runtime.openOptionsPage();
}

81
source/content-scripts.ts Normal file
View File

@ -0,0 +1,81 @@
import {html} from 'htm/preact';
import {render} from 'preact';
import {
AutocompleteFeature,
BackToTopFeature,
extractAndSaveGroups,
getSettings,
initialize,
JumpToNewCommentFeature,
log,
runHideVotesFeature,
runMarkdownToolbarFeature,
TRXComponent,
UserLabelsFeature
} from '.';
window.addEventListener('load', async () => {
const start = window.performance.now();
initialize();
const settings = await getSettings();
// 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)) {
settings.data.knownGroups = await extractAndSaveGroups(settings);
}
// 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.hideVotes) {
runHideVotesFeature(settings);
}
if (settings.features.jumpToNewComment) {
components.jumpToNewComment = html`<${JumpToNewCommentFeature} />`;
}
if (settings.features.markdownToolbar) {
runMarkdownToolbarFeature();
}
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
);
const initializedIn = window.performance.now() - start;
log(`Initialized in approximately ${initializedIn} milliseconds.`);
});

View File

@ -1,265 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Tildes ReExtended Options</title>
<link href="../assets/tildes-reextended-128.png" rel="shortcut icon"
type="image/x-icon">
<link href="../scss/options.scss" rel="stylesheet">
</head>
<body>
<div id="wrapper">
<header id="header">
<img src="../assets/tildes-reextended-128.png"
alt="Tildes ReExtended Logo">
<h1>
Tildes <span class="red-re">Re</span>Extended
</h1>
<a id="version" target="_blank" rel="noopener"></a>
</header>
<main id="main">
<div id="settings-list">
<a id="autocomplete-list">
Autocomplete
</a>
<a id="back-to-top-list">
Back To Top
</a>
<a id="hide-votes-list">
Hide Votes
</a>
<a id="jump-to-new-comment-list">
Jump To New Comment
</a>
<a id="markdown-toolbar-list">
Markdown Toolbar
</a>
<a id="user-labels-list">
User Labels
</a>
<a id="debug-list">
About & Info
</a>
</div>
<div id="settings-content">
<div id="autocomplete" class="setting">
<header>
<h2>Autocomplete</h2>
<button id="autocomplete-button"></button>
</header>
<p>
Adds autocompletion for user mentions (starting with <code>@</code>)
and groups (starting with <code>~</code>) in textareas.
</p>
</div>
<div id="back-to-top" class="setting">
<header>
<h2>Back To Top</h2>
<button id="back-to-top-button"></button>
</header>
<div class="content">
<p>
Adds a hovering button to the bottom-right of all pages (once
you've scrolled past a certain point) that when clicked will
scroll you back to the top.
</p>
</div>
</div>
<div id="hide-votes" class="setting">
<header>
<h2>Hide Votes</h2>
<button id="hide-votes-button"></button>
</header>
<div class="content">
<p>
Select which vote counts you don't want to see, "Comments" and
"Topics" will hide the votes on other people's comments/topics
while "Own Comments/Topics" will hide the votes on your own
comments/topics.
</p>
<form>
<div>
<input type="checkbox" id="hide-votes-comments">
<label for="hide-votes-comments">Comments</label>
</div>
<div>
<input type="checkbox" id="hide-votes-topics">
<label for="hide-votes-topics">Topics</label>
</div>
<div>
<input type="checkbox" id="hide-votes-own-comments">
<label for="hide-votes-own-comments">Own Comments</label>
</div>
<div>
<input type="checkbox" id="hide-votes-own-topics">
<label for="hide-votes-own-topics">Own Topics</label>
</div>
</form>
</div>
</div>
<div id="jump-to-new-comment" class="setting">
<header>
<h2>Jump To New Comment</h2>
<button id="jump-to-new-comment-button"></button>
</header>
<div class="content">
<p>
Adds a hovering button to the bottom-right of comment sections to
automatically scroll to the next new comment.
</p>
</div>
</div>
<div id="markdown-toolbar" class="setting">
<header>
<h2>Markdown Toolbar</h2>
<button id="markdown-toolbar-button"></button>
</header>
<div class="content">
<p>
Adds a toolbar with a selection of Markdown snippets that when
used will insert the according Markdown where your cursor is.
Particularly useful for the
<a href="https://docs.tildes.net/instructions/text-formatting#expandable-sections"
target="_blank" rel="noopener">expandable section</a>/spoilerbox
syntax. If you have text selected, the Markdown will be inserted
around your text.
</p>
<p>
For a list of all snippets,
<a href="https://gitlab.com/tildes-community/tildes-reextended/issues/12"
target="_blank" rel="noopener">see this issue</a>.
</p>
</div>
</div>
<div id="user-labels" class="setting">
<header>
<h2>User Labels</h2>
<button id="user-labels-button"></button>
</header>
<div class="content">
<p>
Adds the ability to add customizable labels to users. When in a
comments section or in the topic listing a username is visible, a
<code>[+]</code> will be next to it. Clicking on that will bring
up a dialog to add a label. The values you can customize are:
</p>
<details>
<summary></summary>
<ul>
<li>
<b>Username</b>:
specifies who the label will be applied to.
</li>
<li>
<b>Priority</b>:
determines the order of labels. If multiple labels have the
same priority they will be sorted alphabetically. In the topic
listing only the highest priority label will be shown.
</li>
<li>
<b>Color</b>:
will set the background color of the label. The foreground
color
is calculated to be black or white depending on the brightness
of the background color.<br>
Valid color values are 3, 4, 6 or 8 character hex colors and a
special "transparent" value for a transparent background.<br>
Next to the color input there is also a dropdown menu with
various names, these are colors taken from the Tildes theme
you're using. Note that if you add a label with one of these
colors they won't change when you switch themes.
</li>
<li>
<b>Text</b>:
the text that will go in your label. If left empty, the label
will be a 12 by 12 pixel square instead.
</li>
</ul>
</details>
<p>
To edit or remove labels, click on the labels wherever you see
them or
use the menu below.<a class="asterisk-link"
href="https://gitlab.com/tildes-community/tildes-reextended/issues/1"
target="_blank" rel="noopener"><small>*WIP</small></a>
</p>
</div>
</div>
<div id="debug" class="setting">
<header>
<h2>About & Info</h2>
<button id="debug-button"></button>
</header>
<div class="content">
<p>
When this feature is enabled, debug information will be logged to
the console.
</p>
<p>
Tildes <span class="red-re">Re</span>Extended is a recreation of
<a href="https://tildes.net/user/crius" target="_blank"
rel="noopener">Crius</a>' <a
href="https://github.com/theCrius/tildes-extended"
target="_blank" rel="noopener">Tildes Extended</a> web
extension, completely remade from scratch. Open-sourced under <a
href="https://gitlab.com/tildes-community/tildes-reextended/blob/master/License"
target="_blank" rel="noopener">the MIT license</a>
and maintained as a
<a href="https://gitlab.com/tildes-community" target="_blank"
rel="noopener">
Tildes Community project</a>.
</p>
<p>
You can report bugs or request features in <a
href="https://gitlab.com/tildes-community/tildes-reextended/issues"
target="_blank" rel="noopener">the GitLab issue tracker</a>
through the link at the bottom of this page or by
<a href="https://tildes.net/user/Bauke/new_message">private
messaging Bauke</a>
on Tildes. If you're reporting a bug through, please use the links
available in the footer. They will prefill a bug report
template with some system information and instructions that can help
tremendously with determining the problem.
</p>
<form id="import-export">
<input class="trx-hidden" accept="application/json" type="file"
id="import-file">
<button id="import-button">Import Settings</button>
<button id="export-button">Export Settings</button>
</form>
<p>
When importing settings, note that your current settings will be
deleted and overwritten with the new ones.
</p>
</div>
<div id="debug-buttons">
<button id="copy-bug-template-button">
Copy Bug Template
</button>
<button id="log-stored-data-button">
Log Stored Data
</button>
<button id="remove-all-data-button">
Remove All Data
</button>
</div>
</div>
</div>
</main>
<footer id="footer">
<p>
Report a bug through
<a class="report-a-bug-gitlab" target="_blank" rel="noopener">GitLab</a>
or
<a class="report-a-bug-tildes" target="_blank" rel="noopener">Tildes</a>.
</p>
</footer>
</div>
<script src="../ts/options.ts"></script>
</body>
</html>

22
source/index.html Normal file
View File

@ -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="../node_modules/modern-normalize/modern-normalize.css">
<link rel="stylesheet" href="./scss/index.scss">
</head>
<body>
<noscript>
This web extension does not work without JavaScript, sorry. :(
</noscript>
<script src="./settings-page.ts"></script>
</body>
</html>

19
source/index.ts Normal file
View File

@ -0,0 +1,19 @@
import {html} from 'htm/preact';
import {createContext} from 'preact';
import {Settings} from './settings';
export type TRXComponent = ReturnType<typeof html>;
type AppContextValues = {
settings: Settings;
setActiveFeature: (feature: string) => void;
toggleFeature: (feature: string) => void;
};
// We create this context with null as we'll create the state and the other
// functions inside App itself. See `settings-page.ts` for that.
export const AppContext = createContext<AppContextValues>(null!);
export * from './scripts';
export * from './settings';
export * from './utilities';

View File

@ -3,7 +3,7 @@
"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": "0.3.3",
"version": "1.0.0",
"permissions": [
"downloads",
"storage",
@ -18,7 +18,7 @@
},
"background": {
"scripts": [
"./ts/background.ts"
"./background.ts"
]
},
"browser_action": {
@ -27,7 +27,7 @@
}
},
"options_ui": {
"page": "./html/options.html",
"page": "./index.html",
"open_in_tab": true
},
"content_scripts": [
@ -37,19 +37,10 @@
],
"run_at": "document_end",
"css": [
"./scss/scripts/jump-to-new-comment.scss",
"./scss/scripts/back-to-top.scss",
"./scss/scripts/user-labels.scss",
"./scss/scripts/markdown-toolbar.scss",
"./scss/scripts/autocomplete.scss"
"./scss/scripts.scss"
],
"js": [
"./ts/scripts/jump-to-new-comment.ts",
"./ts/scripts/back-to-top.ts",
"./ts/scripts/user-labels.ts",
"./ts/scripts/markdown-toolbar.ts",
"./ts/scripts/hide-votes.ts",
"./ts/scripts/autocomplete.ts"
"./content-scripts.ts"
]
}
],

View File

@ -0,0 +1,213 @@
import {offset, Offset} from 'caret-pos';
import {html} from 'htm/preact';
import {Component} from 'preact';
import {log, querySelectorAll, Settings} from '..';
type Props = {
settings: Settings;
};
type State = {
groups: Set<string>;
groupsHidden: boolean;
groupsMatches: Set<string>;
groupsPosition: Offset | null;
usernames: Set<string>;
usernamesHidden: boolean;
usernamesMatches: Set<string>;
usernamesPosition: Offset | null;
};
export class AutocompleteFeature extends Component<Props, State> {
constructor(props: Props) {
super(props);
// Get all the groups without their leading tildes.
const groups = props.settings.data.knownGroups.map((value) =>
value.startsWith('~') ? value.slice(1) : value
);
// Get all the usernames on the page without their leading @s, and get
// all the username from the saved user labels.
const usernames = [
...querySelectorAll('.link-user').map((value) =>
value.textContent!.replace(/^@/, '').toLowerCase()
),
...props.settings.data.userLabels.map((value) => value.username)
].sort((a, b) => a.localeCompare(b));
this.state = {
groups: new Set(groups),
groupsHidden: true,
groupsMatches: new Set(groups),
groupsPosition: null,
usernames: new Set(usernames),
usernamesHidden: true,
usernamesMatches: new Set(usernames),
usernamesPosition: null
};
// Add a keydown listener for the entire page.
document.addEventListener('keydown', this.globalInputHandler);
log(
`Autocomplete: Initialized with ${this.state.groups.size} groups and ` +
`${this.state.usernames.size} usernames.`
);
}
globalInputHandler = (event: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement;
// Only add the autocompletes to textareas.
if (activeElement.tagName !== 'TEXTAREA') {
return;
}
// Helper function to create autocompletes with.
const createHandler = (
prefix: string,
target: string,
values: Set<string>
) => {
const dataAttribute = `data-trx-autocomplete-${target}`;
if (event.key === prefix && !activeElement.getAttribute(dataAttribute)) {
activeElement.setAttribute(dataAttribute, 'true');
activeElement.addEventListener('keyup', (event) =>
this.textareaInputHandler(event, prefix, target, values)
);
this.textareaInputHandler(event, prefix, target, values);
}
};
createHandler('~', 'groups', this.state.groups);
createHandler('@', 'usernames', this.state.usernames);
};
textareaInputHandler = (
event: KeyboardEvent,
prefix: string,
target: string,
values: Set<string>
) => {
const textarea = event.target as HTMLTextAreaElement;
const text = textarea.value;
// If the prefix isn't in the textarea, return early.
if (!text.includes(prefix)) {
this.hide(target);
return;
}
// Grab the starting position of the caret (text cursor).
const position = textarea.selectionStart;
// Grab the last index of the prefix inside the beginning of the textarea
// and the starting position of the caret.
const prefixIndex = text.slice(0, position).lastIndexOf(prefix);
// Grab the input between the prefix and the caret position, which will be
// what the user is currently typing.
const input = text.slice(prefixIndex + prefix.length, position);
// If there is any whitespace in the input or there is no input at all,
// return early. Usernames cannot have whitespace in them.
if (/\s/.exec(input) || input === '') {
this.hide(target);
return;
}
// Find all the values that match the input using `includes`.
const matches = new Set<string>(
[...values].filter((value) => value.includes(input.toLowerCase()))
);
// If there are no matches, return early.
if (matches.size === 0) {
this.hide(target);
return;
}
// Otherwise make sure the list is shown in the correct place and also
// has all the new matches.
this.show(target, offset(textarea));
this.update(target, matches);
};
update = (target: string, matches: Set<string>) => {
if (target === 'groups') {
this.setState({
groupsMatches: matches
});
} else if (target === 'usernames') {
this.setState({
usernamesMatches: matches
});
}
};
show = (target: string, position: Offset) => {
if (target === 'groups') {
this.setState({
groupsHidden: false,
groupsPosition: position
});
} else if (target === 'usernames') {
this.setState({
usernamesHidden: false,
usernamesPosition: position
});
}
};
hide = (target: string) => {
if (target === 'groups') {
this.setState({groupsHidden: true});
} else if (target === 'usernames') {
this.setState({usernamesHidden: true});
}
};
render() {
// Create the list of groups and usernames.
const groups = [...this.state.groupsMatches].map(
(value) => html`<li>~${value}</li>`
);
const usernames = [...this.state.usernamesMatches].map(
(value) => html`<li>@${value}</li>`
);
// Create the CSS class whether or not to hide the autocomplete.
const groupsHidden = this.state.groupsHidden ? 'trx-hidden' : '';
const usernamesHidden = this.state.usernamesHidden ? 'trx-hidden' : '';
// Create the position for the group and usernames autocomplete.
const groupsLeft = this.state.groupsPosition?.left ?? 0;
const groupsTop =
(this.state.groupsPosition?.top ?? 0) +
(this.state.groupsPosition?.height ?? 0);
const usernamesLeft = this.state.usernamesPosition?.left ?? 0;
const usernamesTop =
(this.state.usernamesPosition?.top ?? 0) +
(this.state.usernamesPosition?.height ?? 0);
return html`
<ul
id="trx-autocomplete-usernames"
class="trx-autocomplete ${usernamesHidden}"
style="left: ${usernamesLeft}px; top: ${usernamesTop}px"
>
${usernames}
</ul>
<ul
id="trx-autocomplete-groups"
class="trx-autocomplete ${groupsHidden}"
style="left: ${groupsLeft}px; top: ${groupsTop}px"
>
${groups}
</ul>
`;
}
}

View File

@ -0,0 +1,48 @@
import debounce from 'debounce';
import {html} from 'htm/preact';
import {Component} from 'preact';
import {log} from '..';
type Props = Record<string, unknown>;
type State = {
hidden: boolean;
};
export class BackToTopFeature extends Component<Props, State> {
constructor() {
super();
this.state = {
hidden: true
};
// Add a "debounced" handler to the scroll listener, this will make it so
// the handler will only run after scrolling has ended for 150ms.
window.addEventListener('scroll', debounce(this.scrollHandler, 150));
// Run the handler once in case the page was already scroll down.
this.scrollHandler();
log(`Back To Top: Initialized.`);
}
scrollHandler = () => {
this.setState({hidden: window.scrollY < 500});
};
scrollToTop = () => {
window.scrollTo({behavior: 'smooth', top: 0});
};
render() {
const hidden = this.state.hidden ? 'trx-hidden' : '';
return html`<a
id="trx-back-to-top"
class="btn btn-primary ${hidden}"
onClick=${this.scrollToTop}
>
Back To Top
</a>`;
}
}

View File

@ -0,0 +1,63 @@
import {log, querySelectorAll, Settings} from '..';
export function runHideVotesFeature(settings: Settings) {
const observer = new window.MutationObserver(() => {
observer.disconnect();
hideVotes(settings);
startObserver();
});
function startObserver() {
observer.observe(document, {
childList: true,
subtree: true
});
}
hideVotes(settings);
startObserver();
log('Hide Votes: Initialized.');
}
function hideVotes(settings: Settings) {
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)'
);
for (const vote of commentVotes) {
vote.classList.add('trx-votes-hidden');
vote.textContent = vote.textContent!.slice(
0,
vote.textContent!.indexOf(' ')
);
}
}
if (settings.data.hideVotes.ownComments) {
for (const vote of querySelectorAll('.comment-votes')) {
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)');
}
for (const vote of querySelectorAll(...selectors)) {
vote.classList.add('trx-votes-hidden');
vote.textContent = '-';
}
}
}

6
source/scripts/index.ts Normal file
View File

@ -0,0 +1,6 @@
export * from './autocomplete';
export * from './back-to-top';
export * from './hide-votes';
export * from './jump-to-new-comment';
export * from './markdown-toolbar';
export * from './user-labels';

View File

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

View File

@ -0,0 +1,214 @@
import {html} from 'htm/preact';
import {render} from 'preact';
import {log, querySelectorAll, TRXComponent} from '..';
type MarkdownSnippet = {
dropdown: boolean;
index: number;
markdown: string;
name: string;
};
const snippets: MarkdownSnippet[] = [
{
dropdown: false,
markdown: '[<>]()',
name: 'Link'
},
{
dropdown: false,
markdown: '```\n<>\n```',
name: 'Code'
},
{
dropdown: false,
markdown: '~~<>~~',
name: 'Strikethrough'
},
{
dropdown: false,
markdown:
'<details>\n<summary>Click to expand spoiler.</summary>\n\n<>\n</details>',
name: 'Spoilerbox'
},
{
dropdown: true,
markdown: '**<>**',
name: 'Bold'
},
{
dropdown: true,
markdown: '\n\n---\n\n<>',
name: 'Horizontal Divider'
},
{
dropdown: true,
markdown: '`<>`',
name: 'Inline Code'
},
{
dropdown: true,
markdown: '*<>*',
name: 'Italic'
},
{
dropdown: true,
markdown: '1. <>',
name: 'Ordered List'
},
{
dropdown: true,
markdown: '<small><></small>',
name: 'Small'
},
{
dropdown: true,
markdown: '* <>',
name: 'Unordered List'
}
].map(({dropdown, markdown, name}) => ({
dropdown,
name,
index: markdown.indexOf('<>'),
markdown: markdown.replace(/<>/, '')
}));
export function runMarkdownToolbarFeature() {
// Create an observer that will add toolbars whenever
// a new Markdown form is created (like when clicking Reply).
const observer = new window.MutationObserver(() => {
observer.disconnect();
addToolbarsToTextareas();
startObserver();
});
function startObserver() {
observer.observe(document, {
childList: true,
subtree: true
});
}
// Run once when the page loads.
addToolbarsToTextareas();
startObserver();
log('Markdown Toolbar: Initialized.');
}
function addToolbarsToTextareas() {
// Grab all Markdown forms that don't have already have a toolbar.
const markdownForms = querySelectorAll('.form-markdown:not(.trx-toolbar)');
if (markdownForms.length === 0) {
return;
}
for (const form of markdownForms) {
// Add `trx-toolbar` to indicate this Markdown form already has the toolbar.
form.classList.add('trx-toolbar');
const menu = form.querySelector<HTMLElement>('.tab-markdown-mode')!;
const textarea = form.querySelector<HTMLElement>(
'textarea[name="markdown"]'
)!;
const snippetButtons = snippets
.filter((snippet) => !snippet.dropdown)
.map(
(snippet) =>
html`<${snippetButton} snippet=${snippet} textarea=${textarea} />`
);
// Render the buttons inside the tab menu so they appear
// next to the Edit and Preview buttons.
const menuPlaceholder = document.createElement('div');
menu.append(menuPlaceholder);
render(snippetButtons, menu, menuPlaceholder);
// And render the dropdown directly after the menu.
const dropdownPlaceholder = document.createElement('div');
const menuParent = menu.parentElement!;
menu.after(dropdownPlaceholder);
render(
html`<${snippetDropdown} textarea=${textarea} />`,
menuParent,
dropdownPlaceholder
);
}
}
type Props = {
snippet?: MarkdownSnippet;
textarea: HTMLTextAreaElement;
};
function snippetButton(props: Required<Props>): TRXComponent {
const click = (event: MouseEvent) => {
event.preventDefault();
insertSnippet(props);
};
return html`<li class="tab-item">
<button class="btn btn-link" onClick="${click}">
${props.snippet.name}
</button>
</li>`;
}
function snippetDropdown(props: Props): TRXComponent {
const options = snippets.map(
(snippet) => html`<option value="${snippet.name}">${snippet.name}</option>`
);
const change = (event: Event) => {
event.preventDefault();
const snippet = snippets.find(
(value) => value.name === (event.target as HTMLSelectElement).value
)!;
insertSnippet({
...props,
snippet
});
(event.target as HTMLSelectElement).selectedIndex = 0;
};
return html`<select class="form-select" onChange=${change}>
<option>More</option>
${options}
</select>`;
}
function insertSnippet(props: Required<Props>) {
const {textarea, snippet} = props;
const {selectionStart, selectionEnd} = textarea;
// Since you have to press a button or go into a dropdown to click on a
// snippet, the textarea won't be focused anymore. So focus it again.
textarea.focus();
let {index, markdown} = snippet;
// If any text has been selected, include it.
if (selectionStart !== selectionEnd) {
markdown =
markdown.slice(0, index) +
textarea.value.slice(selectionStart, selectionEnd) +
markdown.slice(index);
// Change the index when the Link snippet is used so the cursor ends up
// in the URL part of the Markdown: "[existing text](cursor here)".
if (snippet.name === 'Link') {
index += 2;
}
}
textarea.value =
textarea.value.slice(0, selectionStart) +
markdown +
textarea.value.slice(selectionEnd);
textarea.selectionEnd = selectionEnd + index;
}

View File

@ -0,0 +1,429 @@
import debounce from 'debounce';
import {Component, render} from 'preact';
import {html} from 'htm/preact';
import {
createElementFromString,
isColorBright,
isValidHexColor,
log,
querySelectorAll,
setSettings,
Settings,
themeColors
} from '..';
type Props = {
settings: Settings;
};
type State = {
color: string;
selectedColor: string;
hidden: boolean;
id: number | null;
priority: number;
target: HTMLElement | null;
text: string;
username: string;
};
const colorPattern: string = [
'^(?:#(?:', // (?:) are non-capturing groups.
'[a-f\\d]{8}|', // The order of 8 -> 6 -> 4 -> 3 character hex colors matters.
'[a-f\\d]{6}|',
'[a-f\\d]{4}|',
'[a-f\\d]{3})',
'|transparent)$' // "Transparent" is also allowed in the input.
].join('');
export class UserLabelsFeature extends Component<Props, State> {
constructor(props: Props) {
super(props);
const selectedColor = window
.getComputedStyle(document.body)
.getPropertyValue(themeColors[1].value)
.trim();
this.state = {
color: selectedColor,
hidden: true,
id: null,
text: '',
priority: 0,
selectedColor,
target: null,
username: ''
};
const count = this.addLabelsToUsernames(querySelectorAll('.link-user'));
log(`User Labels: Initialized for ${count} user links.`);
}
hide = () => {
this.setState({hidden: true});
};
addLabelsToUsernames = (elements: Element[], onlyID?: number): number => {
const settings = this.props.settings;
const inTopicListing = document.querySelector('.topic-listing') !== null;
// Sort the labels by priority or alphabetically, so 2 labels with the same
// priority will be sorted alphabetically.
const sortedLabels = settings.data.userLabels.sort((a, b): number => {
if (inTopicListing) {
// If we're in the topic listing sort with highest priority first.
if (a.priority !== b.priority) {
return b.priority - a.priority;
}
} else if (a.priority !== b.priority) {
// If we're not in the topic listing, sort with lowest priority first.
// We will add elements backwards, so the first label will be
// behind all the other labels.
return a.priority - b.priority;
}
return b.text.localeCompare(a.text);
});
for (const element of elements) {
const username: string = element
.textContent!.replace(/@/g, '')
.toLowerCase();
const userLabels = sortedLabels.filter(
(value) =>
value.username === username &&
(onlyID === undefined ? true : value.id === onlyID)
);
const addLabel = html`
<span
class="trx-user-label-add"
onClick=${(event: MouseEvent) =>
this.addLabelHandler(event, username)}
>
[+]
</span>
`;
if (!inTopicListing && onlyID === undefined) {
const addLabelPlaceholder = document.createElement('span');
element.after(addLabelPlaceholder);
render(addLabel, element.parentElement!, addLabelPlaceholder);
}
if (userLabels.length === 0 && onlyID === undefined) {
if (
inTopicListing &&
(element.nextElementSibling === null ||
!element.nextElementSibling.className.includes('trx-user-label'))
) {
const addLabelPlaceholder = document.createElement('span');
element.after(addLabelPlaceholder);
render(addLabel, element.parentElement!, addLabelPlaceholder);
}
continue;
}
for (const userLabel of userLabels) {
const bright = isColorBright(userLabel.color.trim())
? 'trx-bright'
: '';
const label = createElementFromString<HTMLSpanElement>(`<span
data-trx-label-id="${userLabel.id}"
class="trx-user-label ${bright}"
>
${userLabel.text}
</span>`);
label.addEventListener('click', (event: MouseEvent) =>
this.editLabelHandler(event, userLabel.id)
);
element.after(label);
label.setAttribute('style', `background-color: ${userLabel.color};`);
// If we're in the topic listing, stop after adding 1 label.
if (inTopicListing) {
break;
}
}
}
return elements.length;
};
addLabelHandler = (event: MouseEvent, username: string) => {
event.preventDefault();
const target = event.target as HTMLElement;
if (this.state.target === target && !this.state.hidden) {
this.hide();
} else {
const selectedColor = window
.getComputedStyle(document.body)
.getPropertyValue(themeColors[1].value)
.trim();
this.setState({
hidden: false,
target,
username,
color: selectedColor,
id: null,
text: '',
priority: 0,
selectedColor
});
}
};
editLabelHandler = (event: MouseEvent, id: number) => {
event.preventDefault();
const target = event.target as HTMLElement;
if (this.state.target === target && !this.state.hidden) {
this.hide();
} else {
const label = this.props.settings.data.userLabels.find(
(value) => value.id === id
);
if (label === undefined) {
log(
'User Labels: Tried to edit label with ID that could not be found.',
true
);
return;
}
this.setState({
hidden: false,
target,
...label
});
}
};
colorChange = (event: Event) => {
let color: string = (event.target as HTMLInputElement).value.toLowerCase();
if (!color.startsWith('#') && !color.startsWith('t') && color.length > 0) {
color = `#${color}`;
}
if (color !== 'transparent' && !isValidHexColor(color)) {
log('User Labels: Color must be a valid hex color or "transparent".');
}
// If the color was changed through the preset values, also change the
// selected color state.
if ((event.target as HTMLElement).tagName === 'SELECT') {
this.setState({color, selectedColor: color});
} else {
this.setState({color});
}
};
labelChange = (event: Event) => {
this.setState({text: (event.target as HTMLInputElement).value});
};
priorityChange = (event: Event) => {
this.setState({
priority: Number((event.target as HTMLInputElement).value)
});
};
save = (event: MouseEvent) => {
event.preventDefault();
const {color, id, text, priority, username} = this.state;
if (color === '' || username === '') {
log('Cannot save user label without all values present.');
return;
}
const {settings} = this.props;
// If no ID is present then save a new label otherwise edit the existing one.
if (id === null) {
let newID = 1;
if (settings.data.userLabels.length > 0) {
newID = settings.data.userLabels.sort((a, b) => b.id - a.id)[0].id + 1;
}
settings.data.userLabels.push({
color,
id: newID,
priority,
text,
username
});
this.addLabelsToUsernames(querySelectorAll('.link-user'), newID);
} else {
const index = settings.data.userLabels.findIndex(
(value) => value.id === id
);
settings.data.userLabels.splice(index, 1);
settings.data.userLabels.push({
id,
color,
priority,
text,
username
});
const elements = querySelectorAll(`[data-trx-label-id="${id}"]`);
const bright = isColorBright(color);
for (const element of elements) {
element.textContent = text;
element.setAttribute('style', `background-color: ${color};`);
if (bright) {
element.classList.add('trx-bright');
} else {
element.classList.remove('trx-bright');
}
}
}
void setSettings(settings);
this.props.settings = settings;
this.hide();
};
remove = (event: MouseEvent) => {
event.preventDefault();
const {id} = this.state;
if (id === null) {
log('User Labels: Tried remove label when ID was null.');
return;
}
const {settings} = this.props;
const index = settings.data.userLabels.findIndex(
(value) => value.id === id
);
if (index === undefined) {
log(
`User Labels: Tried to remove label with ID ${id} that could not be found.`,
true
);
return;
}
querySelectorAll(`[data-trx-label-id="${id}"]`).map((value) =>
value.remove()
);
settings.data.userLabels.splice(index, 1);
void setSettings(settings);
this.props.settings = settings;
this.hide();
};
render() {
const bodyStyle = window.getComputedStyle(document.body);
const themeSelectOptions = themeColors.map(
({name, value}) =>
html`
<option value="${bodyStyle.getPropertyValue(value).trim()}">
${name}
</option>
`
);
const bright = isColorBright(this.state.color) ? 'trx-bright' : '';
const hidden = this.state.hidden ? 'trx-hidden' : '';
const {color, text: label, priority, selectedColor, username} = this.state;
let top = 0;
let left = 0;
const target = this.state.target;
if (target !== null) {
const bounds = target.getBoundingClientRect();
top = bounds.y + bounds.height + 4 + window.scrollY;
left = bounds.x + window.scrollX;
}
const position = `left: ${left}px; top: ${top}px;`;
const previewStyle = `background-color: ${color}`;
return html`<form class="trx-user-label-form ${hidden}" style="${position}">
<div class="trx-label-username-priority">
<label class="trx-label-username">
Add New Label
<input
type="text"
class="form-input"
placeholder="Username"
value="${username}"
required
/>
</label>
<label class="trx-label-priority">
Priority
<input
type="number"
class="form-input"
value="${priority}"
onChange=${this.priorityChange}
required
/>
</label>
</div>
<div>
<label for="trx-label-color-input">Pick A Color</label>
<div class="trx-label-grid">
<input
id="trx-label-color-input"
type="text"
class="form-input"
placeholder="Color"
value="${color}"
onInput=${debounce(this.colorChange, 250)}
pattern="${colorPattern}"
required
/>
<select
class="form-select"
value="${selectedColor}"
onChange="${this.colorChange}"
>
${themeSelectOptions}
</select>
</div>
</div>
<div>
<label for="trx-label-input">Label</label>
<div class="trx-label-grid">
<input
id="trx-label-input"
type="text"
class="form-input"
placeholder="Text"
value="${label}"
onInput=${debounce(this.labelChange, 250)}
/>
<div class="trx-label-preview ${bright}" style="${previewStyle}">
<p>${label}</p>
</div>
</div>
</div>
<div class="trx-label-actions">
<a class="btn-post-action" onClick=${this.save}>Save</a>
<a class="btn-post-action" onClick=${this.hide}>Close</a>
<a class="btn-post-action" onClick=${this.remove}>Remove</a>
</div>
</form>`;
}
}

23
source/scss/_colors.scss Normal file
View File

@ -0,0 +1,23 @@
body {
$accents: (
'red' #dc322f,
'orange' #cb4b16,
'yellow' #b58900,
'green' #859900,
'cyan' #2aa198,
'blue' #268bd2,
'violet' #6c71c4,
'magenta' #d33682,
);
--background-primary: #{adjust-color(#002b36, $lightness: -5%)};
--background-secondary: #002b36;
--background-tertiary: #000;
--foreground: #fdf6e3;
@each $name, $color in $accents {
--#{$name}: #{$color};
--light-#{$name}: #{adjust-color($color, $lightness: 10%)};
--dark-#{$name}: #{adjust-color($color, $lightness: -10%)};
}
}

View File

@ -1,278 +0,0 @@
// stylelint-disable-next-line scss/partial-no-import
@import 'utilities';
html {
font-size: 62.5%;
}
body {
background-color: adjust-color($background, $lightness: -5%);
color: $foreground;
font-family: sans-serif;
font-size: 2rem;
}
a,
a:visited {
color: adjust-color($blue, $lightness: 10%);
&:hover {
color: $magenta;
}
}
h1,
h2,
p,
ul {
margin: 0;
}
ul {
padding-left: 2rem;
}
button {
&:hover {
cursor: pointer;
}
}
code {
background-color: adjust-color($background, $lightness: -5%);
}
details {
border: 0.25rem solid $blue;
margin-bottom: 1rem;
padding: 1rem;
> summary {
cursor: pointer;
&::after {
content: 'Click to expand.';
}
}
&[open] > summary {
margin-bottom: 0.25rem;
&::after {
content: 'Click to collapse.';
}
}
}
p {
margin-bottom: 1rem;
}
*:last-child > p:last-child {
margin-bottom: 0;
}
.red-re {
color: $red;
}
p > .red-re {
font-weight: bolder;
}
.asterisk-link {
font-family: monospace;
text-decoration: none;
}
#wrapper {
padding: 1rem;
}
#header {
align-items: center;
display: flex;
padding-bottom: 1rem;
> img {
height: 4rem;
margin: 0 1rem 0 0;
}
> h1 {
margin-right: auto;
}
> #version {
align-self: flex-end;
}
}
#main {
background-color: $background;
border-top: 0.25rem solid $blue;
border-bottom: 0.25rem solid $cyan;
display: grid;
grid-template-columns: 35% 65%;
padding: 1rem;
}
#settings-list {
display: flex;
flex-direction: column;
margin-right: 1rem;
> a {
background-color: adjust-color($background, $lightness: 5%);
border-bottom: 0.25rem solid adjust-color($background, $lightness: -5%);
color: $foreground;
padding: 2rem;
&:last-child {
border-bottom: none;
}
&:hover:not(.active) {
background-color: adjust-color($blue, $lightness: -20%);
cursor: pointer;
}
&.active {
background-color: adjust-color($blue, $lightness: -10%);
}
}
}
.setting {
> header {
border-bottom: 0.25rem solid $red;
margin-bottom: 1rem;
display: flex;
padding-bottom: 0.5rem;
> h2 {
margin-right: auto;
}
> button {
align-self: flex-start;
background-color: $red;
border: none;
color: $foreground;
padding: 0.5rem;
min-width: 10rem;
&:hover {
background-color: adjust-color($red, $lightness: -10%);
}
}
}
&.enabled > header {
border-color: $green;
> button {
background-color: $green;
&:hover {
background-color: adjust-color($green, $lightness: -10%);
}
}
}
&:not(.active) {
display: none;
}
}
#debug {
display: flex;
flex-direction: column;
height: 100%;
&:not(.active) {
display: none;
}
&-buttons {
align-self: flex-end;
margin-top: auto;
}
}
#hide-votes {
form {
margin-left: 1rem;
> div {
margin-bottom: 0.5rem;
}
}
}
#import-export {
align-items: center;
display: flex;
justify-content: center;
margin-bottom: 1rem;
> button {
border: none;
color: $foreground;
background-color: $cyan;
padding: 1rem;
&:hover {
background-color: adjust-color($cyan, $lightness: -10%);
}
&:last-child {
margin-left: 1rem;
}
}
}
#footer {
align-items: center;
display: flex;
justify-content: center;
padding: 2rem 0;
p {
margin: 0;
}
}
#copy-bug-template-button,
#log-stored-data-button,
#remove-all-data-button {
align-self: flex-end;
border: none;
color: $foreground;
padding: 0.5rem 1rem;
width: auto;
}
#copy-bug-template-button,
#log-stored-data-button {
background-color: $blue;
&:hover {
background-color: adjust-color($blue, $lightness: -10%);
}
}
#remove-all-data-button {
background-color: $red;
&:hover {
background-color: adjust-color($red, $lightness: -10%);
}
}
@media (min-width: $large-breakpoint) {
#wrapper {
margin: 0 auto;
width: $large-breakpoint;
}
}

12
source/scss/_reset.scss Normal file
View File

@ -0,0 +1,12 @@
h1,
h2,
h3,
h4,
h5,
li,
ol,
p,
ul {
margin: 0;
padding: 0;
}

126
source/scss/_settings.scss Normal file
View File

@ -0,0 +1,126 @@
.setting {
&.enabled {
--setting-color: var(--green);
--setting-color-alt: var(--dark-green);
}
&.disabled {
--setting-color: var(--red);
--setting-color-alt: var(--dark-red);
}
header {
border-bottom: 2px solid var(--setting-color);
display: flex;
margin-bottom: 8px;
padding-bottom: 8px;
h2 {
margin-right: auto;
}
button {
background-color: var(--setting-color);
color: var(--foreground);
border: none;
font-weight: bold;
min-width: 10rem;
padding: 4px 0;
&:hover {
background-color: var(--setting-color-alt);
cursor: pointer;
}
}
}
.content {
br {
margin-bottom: 8px;
}
code {
background-color: var(--background-primary);
}
p {
line-height: 125%;
margin-bottom: 8px;
}
}
.info {
border: 1px solid var(--blue);
padding: 8px;
}
.divider {
border-top: 1px solid var(--blue);
margin: 16px 0;
}
.button {
--button-color: var(--blue);
--button-color-alt: var(--dark-blue);
background-color: var(--button-color);
border: none;
color: var(--foreground);
font-weight: bold;
min-width: 15rem;
padding: 8px 0;
&:hover {
background-color: var(--button-color-alt);
cursor: pointer;
}
&.destructive {
--button-color: var(--red);
--button-color-alt: var(--dark-red);
}
}
.import-export {
display: grid;
gap: 8px;
grid-template-columns: repeat(2, 1fr);
margin-bottom: 8px;
p {
grid-column: 1 / 3;
margin: 0;
}
}
.misc-utilities .inner {
display: grid;
gap: 8px;
grid-template-columns: repeat(2, 1fr);
}
.checkbox-list {
display: flex;
flex-direction: column;
gap: 8px;
list-style: none;
label {
cursor: pointer;
display: inline-flex;
user-select: none;
}
input {
cursor: pointer;
margin-right: 8px;
}
}
.user-label-values {
display: flex;
flex-direction: column;
gap: 8px;
list-style: square;
padding: 8px 8px 8px 24px;
}
}

3
source/scss/_shared.scss Normal file
View File

@ -0,0 +1,3 @@
.trx-hidden {
display: none;
}

View File

@ -1,95 +0,0 @@
$background: #002b36;
$foreground: #fdf6e3;
$red: #dc322f;
$orange: #cb4b16;
$yellow: #b58900;
$green: #859900;
$cyan: #2aa198;
$blue: #268bd2;
$violet: #6c71c4;
$magenta: #d33682;
$small-breakpoint: 600px;
$medium-breakpoint: 900px;
$large-breakpoint: 1200px;
$extra-large-breakpoint: 1800px;
.trx-flash-message {
align-items: center;
background-color: $background;
color: $foreground;
border: 0.25rem solid $blue;
bottom: 20px;
display: flex;
justify-content: center;
left: 20px;
overflow: hidden;
opacity: 0%;
padding: 1rem;
position: fixed;
transition: opacity 0.5s;
width: 350px;
&:hover {
cursor: pointer;
}
&.trx-opaque {
opacity: 100%;
}
&.trx-flash-error {
background-color: adjust-color($red, $lightness: -25%);
border-color: $red;
}
}
.trx-hidden {
// stylelint-disable-next-line declaration-no-important
display: none !important;
}
.trx-offscreen {
left: -250vw;
position: absolute;
}
@mixin invalid($color) {
.trx-invalid {
border-color: $color;
}
}
body {
@include invalid(#d91e18);
// stylelint-disable-next-line selector-class-pattern
&.theme- {
&atom-one-dark {
@include invalid(#E06C75);
}
&black {
@include invalid(#f00);
}
&dracula {
@include invalid(#ff5555);
}
&gruvbox-dark,
&gruvbox-light {
@include invalid(#fb4934);
}
&solarized-dark,
&solarized-light {
@include invalid(#dc322f);
}
&zenburn {
@include invalid(#dc8c6c);
}
}
}

View File

@ -0,0 +1,4 @@
$small-breakpoint: 600px;
$medium-breakpoint: 900px;
$large-breakpoint: 1200px;
$extra-large-breakpoint: 1800px;

146
source/scss/index.scss Normal file
View File

@ -0,0 +1,146 @@
@import 'reset';
@import 'variables';
@import 'colors';
html {
font-size: 62.5%;
}
body {
background-color: var(--background-primary);
color: var(--foreground);
display: flex;
flex-direction: column;
font-size: 1.5rem;
gap: 16px;
margin: 16px;
}
a {
color: var(--light-blue);
&:hover {
color: var(--magenta);
}
}
details {
border: 1px solid var(--blue);
&[open] summary {
border-bottom: 1px solid var(--blue);
}
summary {
padding: 8px;
&:hover {
background-color: var(--blue);
cursor: pointer;
}
}
.inner {
padding: 8px;
}
}
.main-wrapper,
.page-header,
.page-footer {
margin-left: auto;
margin-right: auto;
width: $large-breakpoint;
@media (max-width: $large-breakpoint) {
width: 100%;
}
}
.page-header {
align-items: center;
display: flex;
img {
height: 4rem;
}
h1 {
align-items: center;
display: flex;
gap: 16px;
margin-right: auto;
}
.version {
align-self: flex-end;
border-bottom-width: 2px;
font-weight: bold;
}
}
.page-footer {
background-color: var(--background-secondary);
padding: 16px;
a {
font-weight: bold;
}
p:not(:last-child) {
margin-bottom: 8px;
}
}
.main-wrapper {
display: grid;
gap: 16px;
grid-template-columns: $large-breakpoint / 4 auto;
}
.page-aside {
background-color: var(--background-secondary);
padding: 8px;
ul {
list-style: none;
}
li {
border: 1px solid transparent;
font-weight: bold;
padding: 8px;
&:hover {
background-color: var(--light-blue);
border-color: var(--light-blue);
cursor: pointer;
}
&:not(:last-child) {
margin-bottom: 8px;
}
&.active {
border-color: var(--light-blue);
}
&.enabled {
display: flex;
&::after {
color: var(--light-green);
content: '';
margin-left: auto;
}
}
}
}
.page-main {
background-color: var(--background-secondary);
padding: 16px;
}
@import 'shared';
@import 'settings';

View File

@ -1,3 +0,0 @@
@import '~modern-normalize';
// stylelint-disable-next-line scss/at-import-no-partial-leading-underscore
@import '_options';

7
source/scss/scripts.scss Normal file
View File

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

View File

@ -1,4 +1,4 @@
.trx-autocomplete-form {
.trx-autocomplete {
background-color: var(--background-secondary-color);
border: 1px solid var(--border-color);
font-size: 80%;

View File

@ -1,5 +1,3 @@
@import '../utilities';
#trx-back-to-top {
bottom: 2vh;
position: fixed;

View File

@ -1,5 +1,3 @@
@import '../utilities';
#trx-jump-to-new-comment {
bottom: 2vh;
position: fixed;

View File

@ -0,0 +1,81 @@
.trx-user-label {
&,
&-add {
color: #fff;
cursor: pointer;
display: inline-block;
font-style: initial;
margin: 0 2px;
min-height: 12px;
min-width: 12px;
padding: 0 2px;
}
&-add {
background-color: var(--background-primary-color);
color: var(--foreground-primary-color);
}
}
.trx-bright {
color: #000;
}
.trx-user-label-form {
background-color: var(--background-primary-color);
border: 1px solid;
color: var(--foreground-primary-color);
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 8px;
position: absolute;
width: 350px;
select {
text-overflow: ellipsis;
}
}
.trx-label-username {
display: flex;
flex-direction: column;
margin-right: 4px;
}
.trx-label-priority {
display: flex;
flex-direction: column;
}
.trx-label-username-priority {
display: grid;
grid-template-columns: 75% 25%;
}
.trx-label-grid {
display: grid;
gap: 4px;
grid-template-columns: repeat(2, 1fr);
}
.trx-label-preview {
align-items: center;
border: 1px solid var(--border-color);
color: #fff;
display: flex;
height: 100%;
justify-content: center;
overflow: hidden;
width: 100%;
&.trx-bright {
color: #000;
}
}
.trx-label-actions {
align-items: center;
display: flex;
justify-content: space-evenly;
}

View File

@ -1,101 +0,0 @@
@import '../utilities';
.trx-user-label {
color: #fff;
display: inline-block;
min-height: 12px;
min-width: 12px;
&,
&-add {
font-style: initial;
margin: 0 2px;
padding: 0 2px;
&:hover {
cursor: pointer;
}
}
&-add {
background-color: #0002;
}
}
.trx-bright {
color: #000;
}
#trx-user-label-form {
border: 1px solid;
display: flex;
flex-direction: column;
padding: 4px;
position: absolute;
width: 325px;
> div:nth-child(1),
> div:nth-child(2) {
display: flex;
> label,
> input {
&:nth-of-type(1) {
margin-right: 4px;
width: 75%;
}
&:nth-of-type(2) {
width: calc(25% - 4px);
}
}
}
}
#trx-user-label-form-color {
display: flex;
> input {
margin-right: 4px;
width: 50%;
}
> select {
width: 50%;
}
}
#trx-user-label-input {
display: flex;
> input {
margin-right: 4px;
width: 50%;
}
> #trx-user-label-preview {
align-items: center;
border: 1px solid;
color: #fff;
display: flex;
justify-content: center;
overflow: hidden;
width: 50%;
&.trx-bright {
color: #000;
}
}
}
#trx-user-label-actions {
align-items: center;
display: flex;
justify-content: space-evenly;
}
body.theme-black {
.trx-user-label-add {
background-color: #fff2;
}
}

143
source/settings-page.ts Normal file
View File

@ -0,0 +1,143 @@
import {html} from 'htm/preact';
import {render} from 'preact';
import {useState} from 'preact/hooks';
import {
AppContext,
createReportTemplate,
features,
getManifest,
getSettings,
initialize,
Link,
setSettings,
Settings,
TRXManifest
} from '.';
window.addEventListener('load', async () => {
initialize();
render(
html`<${App} manifest=${getManifest()} settings=${await getSettings()} />`,
document.body
);
});
type Props = {
manifest: TRXManifest;
settings: Settings;
};
function App(props: Props) {
const {manifest, settings} = props;
// Create some state to set the active feature tab.
const [activeFeature, _setActiveFeature] = useState(
settings.data.latestActiveFeatureTab
);
function setActiveFeature(feature: string) {
// Update the state and save the settings.
_setActiveFeature(feature);
settings.data.latestActiveFeatureTab = feature;
void setSettings(settings);
}
// Create some state to set the enabled features.
const [enabledFeatures, _setFeature] = useState(
new Set(
Object.entries(settings.features)
.filter(([_, value]) => value)
.map(([key, _]) => key)
)
);
function toggleFeature(feature: string) {
settings.features[feature] = !settings.features[feature];
const features = new Set(
Object.entries(settings.features)
.filter(([_, value]) => value)
.map(([key, _]) => key)
);
_setFeature(features);
void setSettings(settings);
}
// 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(
({key, value}) =>
html`<li
key=${key}
class="${activeFeature === key ? 'active' : ''}
${enabledFeatures.has(key) ? 'enabled' : ''}"
onClick="${() => setActiveFeature(key)}"
>
${value}
</li>`
);
const mainElements = features.map(
({key, value, component}) =>
html`<${component()}
class="${activeFeature === key ? '' : 'trx-hidden'}"
enabled="${enabledFeatures.has(key)}"
feature=${key}
key=${key}
title="${value}"
/>`
);
return html`
<${AppContext.Provider} value=${{
settings,
setActiveFeature,
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>
</${AppContext.Provider}>
`;
}
// Do not export anything from this file, otherwise if a content script
// somehow imports anything that is also connected to this file, it will try
// to run on Tildes as well. This file is solely for the extension options page!

View File

@ -0,0 +1,112 @@
import {html} from 'htm/preact';
import {
getSettings,
exportSettings,
importFileHandler,
Link,
log,
removeAllData,
Setting,
SettingProps,
TRXComponent
} from '../..';
async function logSettings() {
log(await getSettings(), true);
}
export function AboutSetting(props: SettingProps): TRXComponent {
const importSettings = () => {
(document.querySelector('#import-settings') as HTMLInputElement).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=${removeAllData} class="button destructive">
Remove All Data
</button>
</div>
</details>
<//>`;
}

View File

@ -0,0 +1,11 @@
import {html} from 'htm/preact';
import {Setting, SettingProps, TRXComponent} from '../..';
export function AutocompleteSetting(props: SettingProps): TRXComponent {
return html`<${Setting} ...${props}>
<p class="info">
Adds autocompletion in textareas for user mentions (starting with${' '}
<code>@</code>) and groups (starting with <code>~</code>).
</p>
<//>`;
}

View File

@ -0,0 +1,12 @@
import {html} from 'htm/preact';
import {Setting, SettingProps, TRXComponent} from '../..';
export function BackToTopSetting(props: SettingProps): TRXComponent {
return html`<${Setting} ...${props}>
<p class="info">
Adds a hovering button to the bottom-right of all pages once you've
scrolled down far enough that, when clicked, will scroll you back to the
top of the page.
</p>
<//>`;
}

View File

@ -0,0 +1,55 @@
import {html} from 'htm/preact';
import {useContext, useState} from 'preact/hooks';
import {
AppContext,
setSettings,
Setting,
SettingProps,
TRXComponent
} from '../..';
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 setSettings(settings);
}
// Checkbox labels and "targets". The targets should match the keys as defined
// in the user extension settings.
const checkboxes = [
{label: 'Your comments', target: 'ownComments'},
{label: 'Your topics', target: 'ownTopics'},
{label: "Other's comments", target: 'comments'},
{label: "Other's topics", target: 'topics'}
].map(
({label, target}) =>
html`
<li>
<label>
<input
type="checkbox"
checked=${checked[target]}
onClick=${() => toggle(target)}
/>
${label}
</label>
</li>
`
);
return html`<${Setting} ...${props}>
<p class="info">
Hides vote counts from topics and comments of yourself or other people.
</p>
<ul class="checkbox-list">
${checkboxes}
</ul>
<//>`;
}

View File

@ -0,0 +1,52 @@
import {html} from 'htm/preact';
import {useContext} from 'preact/hooks';
import {AppContext, TRXComponent} from '../..';
export type SettingProps = {
children: TRXComponent | undefined;
class: string;
enabled: boolean;
feature: string;
title: string;
};
function toggleFeature(feature: string) {
const {toggleFeature: toggle} = useContext(AppContext);
toggle(feature);
}
function Header(props: SettingProps): TRXComponent {
const enabled = props.enabled ? 'Enabled' : 'Disabled';
return html`<header>
<h2>${props.title}</h2>
<button onClick="${() => 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>
`;
}
export * from './about';
export * from './autocomplete';
export * from './back-to-top';
export * from './hide-votes';
export * from './jump-to-new-comment';
export * from './markdown-toolbar';
export * from './user-labels';

View File

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

View File

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

View File

@ -0,0 +1,39 @@
import {html} from 'htm/preact';
import {Setting, SettingProps, TRXComponent} from '../..';
export function UserLabelsSetting(props: SettingProps): TRXComponent {
return html`<${Setting} ...${props}>
<p class="info">
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 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.
</p>
<details>
<summary>View Customizable Values</summary>
<ul class="user-label-values">
<li><b>Username</b>: who to apply the label to.</li>
<li>
<b>Priority</b>: determines the order of labels. If multiple labels
have the same priority they will be sorted alphabetically. In the
topic listing only the highest priority label will be shown.
</li>
<li>
<b>Color</b>: will set the background color of the label. The
foreground color is calculated to be black or white depending on the
brightness of the background color.
<br />
Valid values are hex colors or <code>transparent</code>.
<br />
Colors based on your current Tildes theme are also available in the
dropdown menu.
</li>
<li>
<b>Text</b>: the text to go in the label. If left empty the label will
show as a 12 by 12 pixel square instead.
</li>
</ul>
</details>
<//>`;
}

124
source/settings/defaults.ts Normal file
View File

@ -0,0 +1,124 @@
import {
AboutSetting,
AutocompleteSetting,
BackToTopSetting,
HideVotesSetting,
JumpToNewCommentSetting,
MarkdownToolbarSetting,
Settings,
UserLabelsSetting
} from '..';
export const defaultSettings: Settings = {
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: []
},
features: {
autocomplete: true,
backToTop: true,
debug: false,
hideVotes: false,
jumpToNewComment: true,
markdownToolbar: true,
userLabels: true
}
};
export const defaultActiveFeature = defaultSettings.data.latestActiveFeatureTab;
/**
* The array of features available in TRX.
* * The index exists to sort the sidebar listing.
* * The key should match the corresponding key from `Settings.features`.
* * The value should be the header title for display.
* * The component function should return the corresponding settings components.
*/
export const features = [
{
index: 0,
key: 'autocomplete',
value: 'Autocomplete',
component: () => AutocompleteSetting
},
{
index: 0,
key: 'backToTop',
value: 'Back To Top',
component: () => BackToTopSetting
},
{
index: 0,
key: 'hideVotes',
value: 'Hide Votes',
component: () => HideVotesSetting
},
{
index: 0,
key: 'jumpToNewComment',
value: 'Jump To New Comment',
component: () => JumpToNewCommentSetting
},
{
index: 0,
key: 'markdownToolbar',
value: 'Markdown Toolbar',
component: () => MarkdownToolbarSetting
},
{
index: 0,
key: 'userLabels',
value: 'User Labels',
component: () => UserLabelsSetting
},
{
index: 1,
key: 'debug',
value: 'About & Info',
component: () => AboutSetting
}
].sort((a, b) => a.index - b.index);

28
source/settings/export.ts Normal file
View File

@ -0,0 +1,28 @@
import {browser} from 'webextension-polyfill-ts';
import {getSettings, log} from '..';
export async function exportSettings(event: MouseEvent): Promise<void> {
event.preventDefault();
const settings = await getSettings();
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) {
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);
}
}

View File

@ -1,47 +1,43 @@
import {browser} from 'webextension-polyfill-ts';
import {
querySelector,
flashMessage,
Settings,
log,
getSettings,
isValidTildesUsername,
log,
isValidHexColor,
setSettings
} from './utilities';
export async function importSettingsHandler(event: MouseEvent): Promise<void> {
event.preventDefault();
const fileInput: HTMLInputElement = querySelector('#import-file');
fileInput.click();
}
isValidTildesUsername,
setSettings,
Settings
} from '..';
export async function importFileHandler(event: Event): Promise<void> {
// Grab the imported files (if any).
const fileList: FileList | null = (event.target as HTMLInputElement).files;
if (fileList === null) {
flashMessage('No file imported.');
log('No file imported.');
return;
}
const reader: FileReader = new FileReader();
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());
} catch (error) {
log(error, true);
flashMessage(error, true);
return;
}
const settings: Settings = await getSettings();
const newSettings: Settings = {...settings};
if (typeof data.data !== 'undefined') {
if (typeof data.data.userLabels !== 'undefined') {
newSettings.data.userLabels = [];
for (const label of data.data.userLabels) {
if (
typeof label.username === 'undefined' ||
@ -71,40 +67,17 @@ export async function importFileHandler(event: Event): Promise<void> {
}
await setSettings(newSettings);
flashMessage(
'Successfully imported your settings, reloading the page to apply.'
);
log('Successfully imported your settings, reloading the page to apply.');
setTimeout(() => {
window.location.reload();
}, 2500);
}, 1000);
}
);
reader.addEventListener('error', (): void => {
log(reader.error, true);
reader.abort();
});
reader.readAsText(fileList[0]);
}
export async function exportSettingsHandler(event: MouseEvent): Promise<void> {
event.preventDefault();
const settings: Settings = await getSettings();
const settingsBlob: Blob = new Blob([JSON.stringify(settings, null, 2)], {
type: 'text/json'
});
const blobObjectURL: string = URL.createObjectURL(settingsBlob);
try {
await browser.downloads.download({
filename: 'tildes_reextended-settings.json',
url: blobObjectURL,
saveAs: true
});
} catch (error) {
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 excess memory/storage use. 60
// seconds should be enough time to download the settings.
setTimeout(() => URL.revokeObjectURL(blobObjectURL), 60000);
}
}

113
source/settings/index.ts Normal file
View File

@ -0,0 +1,113 @@
import {browser, Manifest} from 'webextension-polyfill-ts';
import {defaultSettings, log} from '..';
/**
* UserLabel type definition.
*/
export type UserLabel = {
color: string;
id: number;
priority: number;
text: string;
username: string;
};
/**
* User extension settings.
*/
export type Settings = {
data: {
hideVotes: {
[index: string]: boolean;
comments: boolean;
topics: boolean;
ownComments: boolean;
ownTopics: boolean;
};
knownGroups: string[];
latestActiveFeatureTab: string;
userLabels: UserLabel[];
version?: string;
};
features: {
[index: string]: boolean;
autocomplete: boolean;
backToTop: boolean;
debug: boolean;
hideVotes: boolean;
jumpToNewComment: boolean;
markdownToolbar: boolean;
userLabels: boolean;
};
};
/**
* Fetches and returns the user extension settings.
*/
export async function getSettings(): Promise<Settings> {
const syncSettings: any = await browser.storage.sync.get(defaultSettings);
const settings: Settings = {
data: {...defaultSettings.data, ...syncSettings.data},
features: {...defaultSettings.features, ...syncSettings.features}
};
window.TildesReExtended.debug = settings.features.debug;
// If we're in development, force debug output.
if (getManifest().nodeEnv === 'development') {
window.TildesReExtended.debug = true;
}
return settings;
}
/**
* Saves the user extension settings.
* @param newSettings The new settings to save.
*/
export async function setSettings(newSettings: Settings): Promise<void> {
return browser.storage.sync.set(newSettings);
}
/**
* Tildes ReExtended WebExtension manifest type definition.
*/
export type TRXManifest = {nodeEnv?: string} & Manifest.ManifestBase;
/**
* Fetch the WebExtension manifest.
*/
export function getManifest(): TRXManifest {
const manifest: Manifest.ManifestBase = browser.runtime.getManifest();
return {...manifest};
}
/**
* Removes all user extension settings and reloads the page.
* @param event The mouse click event.
*/
export async function removeAllData(event: MouseEvent): Promise<void> {
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.'
)
) {
return;
}
await browser.storage.sync.clear();
log(
'Data removed, reloading this page to reinitialize default settings.',
true
);
setTimeout(() => {
window.location.reload();
}, 1000);
}
export * from './components';
export * from './defaults';
export * from './export';
export * from './import';

View File

@ -1,324 +0,0 @@
import platform from 'platform';
import {browser} from 'webextension-polyfill-ts';
import {
importSettingsHandler,
importFileHandler,
exportSettingsHandler
} from './import-export';
import {
getSettings,
Settings,
camelToKebab,
log,
kebabToCamel,
querySelector,
setSettings,
createElementFromString,
flashMessage,
querySelectorAll
} from './utilities';
window.addEventListener(
'load',
async (): Promise<void> => {
const settings: Settings = await getSettings();
// Grab the version anchor from the header and add the tag link to it.
const versionSpan: HTMLAnchorElement = querySelector('#version');
const {version} = browser.runtime.getManifest();
versionSpan.setAttribute(
'href',
`https://gitlab.com/tildes-community/tildes-reextended/-/tags/${version}`
);
versionSpan.textContent = `v${version}`;
const gitlabReportTemplate: string = createReportTemplate('gitlab');
const tildesReportTemplate: string = createReportTemplate('tildes');
// Grab the "Report A Bug" anchors and add a prefilled template for them.
const gitlabReportAnchors: HTMLAnchorElement[] = querySelectorAll(
'.report-a-bug-gitlab'
);
for (const element of gitlabReportAnchors) {
element.setAttribute(
'href',
encodeURI(
`https://gitlab.com/tildes-community/tildes-reextended/issues/new?issue[description]=${gitlabReportTemplate}`
)
);
}
const tildesReportAnchors: HTMLAnchorElement[] = querySelectorAll(
'.report-a-bug-tildes'
);
for (const element of tildesReportAnchors) {
element.setAttribute(
'href',
encodeURI(
`https://tildes.net/user/Bauke/new_message?subject=Tildes ReExtended Bug&message=${tildesReportTemplate}`
)
);
}
['comments', 'topics', 'own-comments', 'own-topics'].forEach(
(value: string): void => {
const hideCheckbox: HTMLInputElement = querySelector(
`#hide-votes-${value}`
);
const settingsKey: string = kebabToCamel(value);
hideCheckbox.checked = settings.data.hideVotes[settingsKey];
hideCheckbox.addEventListener(
'change',
async (): Promise<void> => {
settings.data.hideVotes[settingsKey] = hideCheckbox.checked;
await setSettings(settings);
}
);
}
);
const importSettingsButton: HTMLButtonElement = querySelector(
'#import-button'
);
importSettingsButton.addEventListener('click', importSettingsHandler);
const importFileInput: HTMLInputElement = querySelector('#import-file');
importFileInput.addEventListener('change', importFileHandler);
const exportSettingsButton: HTMLButtonElement = querySelector(
'#export-button'
);
exportSettingsButton.addEventListener('click', exportSettingsHandler);
const copyBugTemplateButton: HTMLButtonElement = querySelector(
'#copy-bug-template-button'
);
copyBugTemplateButton.addEventListener('click', copyBugTemplateHandler);
const logStoredDataButton: HTMLButtonElement = querySelector(
'#log-stored-data-button'
);
logStoredDataButton.addEventListener('click', logStoredDataHandler);
const removeAllDataButton: HTMLButtonElement = querySelector(
'#remove-all-data-button'
);
removeAllDataButton.addEventListener('click', removeAllDataHandler);
// Set the latest feature to active.
const latestActiveListItem: HTMLAnchorElement = querySelector(
`#${settings.data.latestActiveFeatureTab}-list`
);
latestActiveListItem.classList.add('active');
const latestActiveContent: HTMLDivElement = querySelector(
`#${settings.data.latestActiveFeatureTab}`
);
latestActiveContent.classList.add('active');
for (const key in settings.features) {
if (Object.hasOwnProperty.call(settings.features, key)) {
const value: boolean = settings.features[key];
// Convert the camelCase key to a kebab-case string.
const id: string = camelToKebab(key);
const settingContent: HTMLDivElement | null = document.querySelector(
`#${id}`
);
if (settingContent === null) {
log(
`New setting key:${key} id:${id} does not have an entry in the settings content!`,
true
);
continue;
}
if (value) {
settingContent.classList.add('enabled');
}
// Set the button text to enable/disable based on the current setting.
const toggleButton: HTMLButtonElement = querySelector(`#${id}-button`);
toggleButton.addEventListener('click', toggleButtonClickHandler);
toggleButton.textContent = value ? 'Enabled' : 'Disabled';
// Add a checkmark to the list item if the feature is enabled.
const listItem: HTMLAnchorElement = querySelector(`#${id}-list`);
listItem.addEventListener('click', listItemClickHandler);
if (value) {
listItem.textContent += ' ✔';
}
}
}
if (typeof settings.data.version !== 'undefined') {
if (settings.data.version !== version) {
flashMessage(`Updated to ${version}!`);
}
}
settings.data.version = version;
await setSettings(settings);
}
);
async function toggleButtonClickHandler(event: MouseEvent): Promise<void> {
event.preventDefault();
const target: HTMLButtonElement = event.target as HTMLButtonElement;
const settings: Settings = await getSettings();
// Convert the kebab-case ID to camelCase and remove the `-button` suffix.
const wantedSettingKey: string = kebabToCamel(
target.id.slice(0, target.id.lastIndexOf('-'))
);
// Toggle the value and update it in the settings.
const wantedSettingValue = !settings.features[wantedSettingKey];
settings.features[wantedSettingKey] = wantedSettingValue;
await setSettings(settings);
// Update the button text.
target.textContent = wantedSettingValue ? 'Enabled' : 'Disabled';
// Grab the equivalent list item and update the checkmark.
const listItem: HTMLAnchorElement = querySelector(
`#${camelToKebab(wantedSettingKey)}-list`
);
if (wantedSettingValue) {
listItem.textContent += ' ✔';
} else {
listItem.textContent = listItem.textContent!.slice(
0,
listItem.textContent!.lastIndexOf(' ')
);
}
const settingContent: HTMLDivElement = querySelector(
`#${camelToKebab(wantedSettingKey)}`
);
if (wantedSettingValue) {
settingContent.classList.add('enabled');
} else {
settingContent.classList.remove('enabled');
}
}
async function listItemClickHandler(event: MouseEvent): Promise<void> {
const target: HTMLAnchorElement = event.target as HTMLAnchorElement;
const id: string = target.id.slice(0, target.id.lastIndexOf('-'));
const currentActiveListItem: HTMLAnchorElement = querySelector(
'#settings-list > .active'
);
// If the currently selected item is the same as the new one, do nothing.
if (target.id === currentActiveListItem.id) {
return;
}
// Hide the currently active settings content.
const currentActiveContent: HTMLDivElement = querySelector(
'#settings-content > .active'
);
currentActiveContent.classList.remove('active');
// And show the newly selected active settings content.
const newActiveContent: HTMLDivElement = querySelector(`#${id}`);
newActiveContent.classList.add('active');
// Remove the active style from the currently active settings list item.
currentActiveListItem.classList.remove('active');
// And add it to the newly selected settings list item.
target.classList.add('active');
// Update the latest active feature data.
const settings: Settings = await getSettings();
settings.data.latestActiveFeatureTab = id;
await setSettings(settings);
}
function copyBugTemplateHandler(event: MouseEvent): void {
event.preventDefault();
const temporaryElement: HTMLTextAreaElement = createElementFromString(
`<textarea>${createReportTemplate('tildes')}</textarea>`
);
temporaryElement.classList.add('trx-offscreen');
document.body.append(temporaryElement);
temporaryElement.select();
try {
document.execCommand('copy');
flashMessage('Copied bug report template to clipboard.');
} catch (error) {
flashMessage(
'Failed to copy bug report template to clipboard. Check the console for an error.',
true
);
log(error, true);
} finally {
temporaryElement.remove();
log('Removed temporary textarea from DOM.');
}
}
async function logStoredDataHandler(event: MouseEvent): Promise<void> {
event.preventDefault();
log(JSON.stringify(await getSettings(), null, 2), true);
}
async function removeAllDataHandler(event: MouseEvent): Promise<void> {
event.preventDefault();
if (
// eslint-disable-next-line no-alert
!confirm(
'Are you sure you want to delete your data? There is no way to recover your data once it has been deleted.'
)
) {
return;
}
await browser.storage.sync.clear();
flashMessage(
'Data removed, reloading this page to reinitialize default settings.'
);
setTimeout(() => {
window.location.reload();
}, 2500);
}
function createReportTemplate(location: 'gitlab' | 'tildes'): string {
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.";
if (location === 'tildes') {
introText =
'Thank you for taking the time to report a bug! Please make sure the\n information below is correct.';
}
// 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
// and so GitLab won't add it to the description.
let reportTemplate = `<h2>Bug Report</h2>
<!--
${introText}
-->
<h3>Info</h3>\n
| Type | Value |
|------|-------|
| Operating System | ${platform.os!.toString()} |
| Browser | ${platform.name!} ${platform.version!} (${platform.layout!}) |\n`;
// The platform manufacturer and product can be null in certain cases (such as
// desktops) so only when they're both not null include them.
if (platform.manufacturer !== null && platform.product !== null) {
reportTemplate += `| Device | ${platform.manufacturer!} ${platform.product!} |\n`;
}
reportTemplate += `\n<h3>The Problem</h3>
<!--
Please explain in sufficient detail what the problem is. When suitable,
including an image or video showing the problem will also help immensely.
-->\n\n
<h3>A Solution</h3>
<!--
If you know of any possible solutions, feel free to include them. If the
solution is just something like "it should work" then you can safely omit
this section.
-->\n\n\n`;
return reportTemplate;
}

View File

@ -1,222 +0,0 @@
import {offset, Offset} from 'caret-pos';
import {
Settings,
getSettings,
createElementFromString,
extractAndSaveGroups
} from '../utilities';
const knownGroups: Set<string> = new Set();
const knownUsers: Set<string> = new Set();
(async (): Promise<void> => {
let settings: Settings = await getSettings();
if (!settings.features.autocomplete) {
return;
}
try {
settings = await extractAndSaveGroups(settings);
} catch {
// This will intentionally error when we're not in "/groups".
}
for (const group of settings.data.knownGroups) {
if (group.startsWith('~')) {
knownGroups.add(group.slice(1));
} else {
knownGroups.add(group);
}
}
// Add usernames from all linked users on the page.
const userLinks = document.querySelectorAll('.link-user');
for (const link of userLinks) {
const username: string = link.textContent!.replace(/@/g, '').toLowerCase();
knownUsers.add(username);
}
// Add usernames we have saved in the user labels.
for (const label of settings.data.userLabels) {
knownUsers.add(label.username);
}
document.addEventListener('keydown', globalInputHandler);
})();
function globalInputHandler(event: KeyboardEvent) {
const activeElement: HTMLElement = document.activeElement as HTMLElement;
// Only add the autocompletes to textareas.
if (activeElement.tagName !== 'TEXTAREA') {
return;
}
// If a ~ is entered in a textarea and that textarea doesn't already have
// the group input handler running, add it.
if (
event.key === '~' &&
!activeElement.getAttribute('data-trx-autocomplete-group')
) {
activeElement.setAttribute('data-trx-autocomplete-group', 'true');
// Sort the groups alphabetically.
const groups: string[] = [...knownGroups];
groups.sort((a, b) => a.localeCompare(b));
if (!document.querySelector('#trx-autocomplete-group-form')) {
const form: HTMLFormElement = createOrGetAutocompleteForm(
'group',
groups
);
document.body.append(form);
}
textareaInputHandler(event, '~', 'group', groups);
activeElement.addEventListener('keyup', (event) =>
textareaInputHandler(event, '~', 'group', groups)
);
}
// If an @ is entered in a textarea and that textarea doesn't already have
// the user input handler running, add it.
if (
event.key === '@' &&
!activeElement.getAttribute('data-trx-autocomplete-user')
) {
activeElement.setAttribute('data-trx-autocomplete-user', 'true');
// Sort the usernames alphabetically.
const users: string[] = [...knownUsers];
users.sort((a, b) => a.localeCompare(b));
if (!document.querySelector('#trx-autocomplete-user-form')) {
const form: HTMLFormElement = createOrGetAutocompleteForm('user', users);
document.body.append(form);
}
textareaInputHandler(event, '@', 'user', users);
activeElement.addEventListener('keyup', (event) =>
textareaInputHandler(event, '@', 'user', users)
);
}
}
function textareaInputHandler(
event: KeyboardEvent,
prefix: string,
id: string,
values: string[]
) {
const textarea: HTMLTextAreaElement = event.target as HTMLTextAreaElement;
const text: string = textarea.value;
// If the prefix isn't in the textarea, return early.
if (!text.includes(prefix)) {
hideAutocompleteForm(id);
return;
}
// Grab the starting position of the caret (text cursor).
const position: number = textarea.selectionStart;
// Grab the last index of the prefix inside the beginning of the textarea and
// the starting position of the caret. Basically doing a reversed index of.
const prefixIndex: number = text.slice(0, position).lastIndexOf(prefix);
// Grab the input between the prefix and the caret position, which will be
// what the user is currently typing.
const input = text.slice(prefixIndex + prefix.length, position);
// If there is any whitespace in the input or there is no input at all, return
// early.
if (/\s/.exec(input) || input === '') {
hideAutocompleteForm(id);
return;
}
// Find all the values that match the input.
const matches: string[] = [];
for (const value of values) {
if (value.includes(input.toLocaleLowerCase())) {
matches.push(value);
}
}
// If there are no matches, return early.
if (matches.length === 0) {
hideAutocompleteForm(id);
return;
}
// If the autocomplete form is hidden, unhide it.
if (document.querySelector(`#trx-autocomplete-${id}-form.trx-hidden`)) {
showAutocompleteForm(id, offset(textarea));
}
// And finally update the values in the autocomplete form.
updateAutocompleteFormValues(id, matches);
}
function createOrGetAutocompleteForm(
id: string,
values: string[]
): HTMLFormElement {
const existing: Element | null = document.querySelector(
`#trx-autocomplete-${id}-form`
);
if (existing !== null) {
return existing as HTMLFormElement;
}
const options: string[] = [];
for (const value of values) {
options.push(`<li>${value}</li>`);
}
const autocompleteFormTemplate = `<form id="trx-autocomplete-${id}-form"
class="trx-autocomplete-form trx-hidden">
<ul>${options.join('\n')}</ul>
</form>`;
const form: HTMLFormElement = createElementFromString(
autocompleteFormTemplate
);
return form;
}
function hideAutocompleteForm(id: string): HTMLFormElement {
const form: HTMLFormElement = createOrGetAutocompleteForm(id, []);
form.classList.add('trx-hidden');
return form;
}
function showAutocompleteForm(id: string, offset: Offset): HTMLFormElement {
const form: HTMLFormElement = createOrGetAutocompleteForm(id, []);
form.classList.remove('trx-hidden');
form.setAttribute(
'style',
`left: ${offset.left}px; top: ${offset.top + offset.height}px;`
);
return form;
}
function updateAutocompleteFormValues(
id: string,
values: string[]
): HTMLFormElement {
const form: HTMLFormElement = createOrGetAutocompleteForm(id, []);
form.firstElementChild!.remove();
const options: string[] = [];
for (const value of values) {
options.push(`<li>${value}</li>`);
}
const list: HTMLUListElement = createElementFromString(
`<ul>${options.join('\n')}</ul>`
);
form.append(list);
return form;
}

View File

@ -1,44 +0,0 @@
import debounce from 'debounce';
import {
getSettings,
Settings,
createElementFromString,
querySelector
} from '../utilities';
(async (): Promise<void> => {
const settings: Settings = await getSettings();
if (!settings.features.backToTop) {
return;
}
// Create the Back To Top button.
const backToTopButton: HTMLAnchorElement = createElementFromString(
'<a id="trx-back-to-top" class="btn btn-primary trx-hidden">Back To Top</a>'
);
backToTopButton.addEventListener('click', clickHandler);
document.body.append(backToTopButton);
// Add a "debounced" handler to the scroll listener, this will make it so
// the handler will only run after scrolling has ended for 150ms.
window.addEventListener('scroll', debounce(scrollHandler, 150));
// And finally run the handler once, in case the page was already scrolled
// down when it got loaded.
scrollHandler();
})();
function scrollHandler(): void {
const backToTopButton: HTMLAnchorElement = querySelector('#trx-back-to-top');
const yPosition: number = window.scrollY;
// TODO: See if 500 is a good position. Tildes Extended was originally at 250
// but I think that might be a bit too little.
if (yPosition > 500) {
backToTopButton.classList.remove('trx-hidden');
} else {
backToTopButton.classList.add('trx-hidden');
}
}
function clickHandler(): void {
window.scrollTo({behavior: 'smooth', top: 0});
}

View File

@ -1,82 +0,0 @@
import {Settings, getSettings} from '../utilities';
(async (): Promise<void> => {
const settings: Settings = await getSettings();
if (!settings.features.hideVotes) {
return;
}
const observer: MutationObserver = new window.MutationObserver(
async (): Promise<void> => {
observer.disconnect();
await hideVotes();
startObserver();
}
);
function startObserver(): void {
observer.observe(document, {
childList: true,
subtree: true
});
}
await hideVotes();
startObserver();
})();
async function hideVotes(): Promise<void> {
const settings: Settings = await getSettings();
if (settings.data.hideVotes.comments) {
const commentVotes: HTMLButtonElement[] = [
...document.querySelectorAll(
'.btn-post-action[name="vote"]:not(.trx-votes-hidden)'
),
...document.querySelectorAll(
'.btn-post-action[name="unvote"]:not(.trx-votes-hidden)'
)
] as HTMLButtonElement[];
for (const vote of commentVotes) {
vote.classList.add('trx-votes-hidden');
vote.textContent = vote.textContent!.slice(
0,
vote.textContent!.indexOf(' ')
);
}
}
if (settings.data.hideVotes.ownComments) {
const commentVotes: NodeListOf<HTMLDivElement> = document.querySelectorAll(
'.comment-votes'
);
for (const vote of commentVotes) {
vote.classList.add('trx-hidden');
}
}
if (settings.data.hideVotes.topics || settings.data.hideVotes.ownTopics) {
// Topics by other people will be encapsulated with a `<button>`.
const topicVotes: Element[] = [];
if (settings.data.hideVotes.topics) {
topicVotes.push(
...document.querySelectorAll(
'button > .topic-voting-votes:not(.trx-votes-hidden)'
)
);
}
// Topics by yourself will be encapsulated with a `<div>`.
if (settings.data.hideVotes.ownTopics) {
topicVotes.push(
...document.querySelectorAll(
'div > .topic-voting-votes:not(.trx-votes-hidden)'
)
);
}
for (const vote of topicVotes) {
vote.classList.add('trx-votes-hidden');
vote.textContent = '-';
}
}
}

View File

@ -1,61 +0,0 @@
import {
getSettings,
Settings,
createElementFromString,
querySelector
} from '../utilities';
(async (): Promise<void> => {
const settings: Settings = await getSettings();
if (
!settings.features.jumpToNewComment ||
!window.location.pathname.startsWith('/~') ||
document.querySelectorAll('.comment.is-comment-new').length === 0
) {
return;
}
// Create the Jump To New Comment button.
const jumpToNewCommentButton: HTMLAnchorElement = createElementFromString(
'<a id="trx-jump-to-new-comment" class="btn btn-primary">Jump To New Comment</a>'
);
jumpToNewCommentButton.addEventListener('click', clickHandler);
document.body.append(jumpToNewCommentButton);
})();
let previousComment: HTMLElement | null = null;
function clickHandler(): void {
if (previousComment !== null) {
previousComment.classList.remove('is-comment-new');
}
// If there's no new comments left, remove the button.
if (document.querySelectorAll('.comment.is-comment-new').length === 0) {
const jumpToNewCommentButton: HTMLAnchorElement = querySelector(
'#trx-jump-to-new-comment'
);
jumpToNewCommentButton.remove();
}
const newestComment: HTMLElement | null = document.querySelector(
'.comment.is-comment-new'
);
if (newestComment === null) {
return;
}
// If the newest comment is invisible, expand all comments to make it visible.
if (newestComment.offsetParent === null) {
// TODO: Instead of expanding all comments, only expand the ones necessary
// to make the comment visible.
const expandAllButton: HTMLButtonElement = querySelector(
'[data-js-comment-expand-all-button]'
);
expandAllButton.click();
}
newestComment.scrollIntoView({behavior: 'smooth'});
previousComment = newestComment;
}

View File

@ -1,219 +0,0 @@
import {Settings, getSettings, createElementFromString} from '../utilities';
const markdownSnippets: MarkdownSnippet[] = [
{
dropdown: false,
index: -1,
markdown: '[$]()',
name: 'Link'
},
{
dropdown: false,
index: -1,
markdown: '```\n$\n```',
name: 'Code'
},
{
dropdown: false,
index: -1,
markdown: '~~$~~',
name: 'Strikethrough'
},
{
dropdown: false,
index: -1,
markdown:
'<details>\n<summary>Click to expand spoiler.</summary>\n\n$\n</details>',
name: 'Spoilerbox'
},
{
dropdown: true,
index: -1,
markdown: '**$**',
name: 'Bold'
},
{
dropdown: true,
index: -1,
markdown: '\n\n---\n\n$',
name: 'Horizontal Divider'
},
{
dropdown: true,
index: -1,
markdown: '`$`',
name: 'Inline Code'
},
{
dropdown: true,
index: -1,
markdown: '*$*',
name: 'Italic'
},
{
dropdown: true,
index: -1,
markdown: '1. $',
name: 'Ordered List'
},
{
dropdown: true,
index: -1,
markdown: '<small>$</small>',
name: 'Small'
},
{
dropdown: true,
index: -1,
markdown: '* $',
name: 'Unordered List'
}
];
(async (): Promise<void> => {
const settings: Settings = await getSettings();
if (!settings.features.markdownToolbar) {
return;
}
calculateSnippetIndexes();
// Create an observer that will add toolbars whenever something changes.
const observer: MutationObserver = new window.MutationObserver((): void => {
observer.disconnect();
addToolbarToTextareas();
startObserver();
});
function startObserver(): void {
observer.observe(document, {
childList: true,
subtree: true
});
}
// Run once when the page loads.
addToolbarToTextareas();
startObserver();
})();
interface MarkdownSnippet {
dropdown: boolean;
index: number;
markdown: string;
name: string;
}
function addToolbarToTextareas(): void {
// Grab all Markdown forms that don't have already have a toolbar (see below).
const markdownForms: NodeListOf<HTMLDivElement> = document.querySelectorAll(
'.form-markdown:not(.trx-toolbar)'
);
if (markdownForms.length === 0) {
return;
}
for (const form of markdownForms) {
// Add `trx-toolbar` to indicate this Markdown form already has the toolbar.
form.classList.add('trx-toolbar');
const tabMenu: HTMLMenuElement = form.querySelector(
'.tab-markdown-mode'
) as HTMLMenuElement;
const textarea: HTMLTextAreaElement = form.querySelector(
'textarea[name="markdown"]'
) as HTMLTextAreaElement;
const markdownSelect: HTMLSelectElement = createElementFromString(
'<select class="form-select"><option>More…</option></select>'
);
for (const snippet of markdownSnippets) {
// If the snippet should go in the dropdown, add the `<option>` for it.
if (snippet.dropdown) {
const snippetOption: HTMLOptionElement = createElementFromString(
`<option value="${snippet.name}">${snippet.name}</option>`
);
markdownSelect.insertAdjacentElement('beforeend', snippetOption);
continue;
}
// Otherwise, add it the tab menu as a tab item.
const tabItem: HTMLLIElement = createElementFromString(
`<li class="tab-item"><button class="btn btn-link">${snippet.name}</button></li>`
);
tabItem.addEventListener('click', (event: MouseEvent): void =>
insertSnippet(snippet, textarea, event)
);
tabMenu.insertAdjacentElement('beforeend', tabItem);
}
// When the dropdown value changes, add the snippet.
markdownSelect.addEventListener('change', (): void => {
const snippet: MarkdownSnippet | undefined = markdownSnippets.find(
(value) => value.name === markdownSelect.value
);
if (typeof snippet === 'undefined') {
return;
}
insertSnippet(snippet, textarea);
// Reset the dropdown index so it always displays "More..." and so it's
// possible to select the same snippet multiple times.
markdownSelect.selectedIndex = 0;
});
// Insert the dropdown after the tab menu.
tabMenu.insertAdjacentElement('afterend', markdownSelect);
}
}
function insertSnippet(
snippet: MarkdownSnippet,
textarea: HTMLTextAreaElement,
event?: MouseEvent
): void {
// If insertSnippet is called from a button it will pass through event.
// So preventDefault() that when it's defined.
if (typeof event !== 'undefined') {
event.preventDefault();
}
// Since you have to press a button or go into a dropdown to click on a
// snippet, the textarea won't be focused anymore. So focus it again.
textarea.focus();
const currentSelectionStart: number = textarea.selectionStart;
const currentSelectionEnd: number = textarea.selectionEnd;
let {markdown} = snippet;
let snippetIndex: number = snippet.index;
// If text has been selected, change the markdown so it includes what's
// been selected.
if (currentSelectionStart !== currentSelectionEnd) {
markdown =
snippet.markdown.slice(0, snippetIndex) +
textarea.value.slice(currentSelectionStart, currentSelectionEnd) +
snippet.markdown.slice(snippetIndex);
// A special behavior for the Link snippet so it places the cursor in the
// URL part of a Markdown link instead of the text part: "[](here)".
if (snippet.name === 'Link') {
snippetIndex += 2;
}
}
textarea.value =
textarea.value.slice(0, currentSelectionStart) +
markdown +
textarea.value.slice(currentSelectionEnd);
textarea.selectionEnd = currentSelectionEnd + snippetIndex;
}
// This function gets called at the beginning of the script to set the snippet
// indexes where the dollar sign is and to remove the dollar sign from it.
// This could be manually done but I figure it's easier to write snippets and
// have a placeholder for where the cursor is intended to go than to count
// where the index is manually and figure it out yourself.
function calculateSnippetIndexes(): void {
for (const snippet of markdownSnippets) {
const insertIndex: number = snippet.markdown.indexOf('$');
const newMarkdown: string = snippet.markdown.replace('$', '');
snippet.index = insertIndex;
snippet.markdown = newMarkdown;
}
}

View File

@ -1,369 +0,0 @@
import {Except} from 'type-fest';
import debounce from 'debounce';
import {ColorKey, themeColors} from '../theme-colors';
import {
getSettings,
Settings,
log,
createElementFromString,
UserLabel,
isInTopicListing,
getCSSCustomPropertyValue,
isColorBright,
setSettings,
appendStyleAttribute,
querySelector
} from '../utilities';
import {
getLabelForm,
getLabelFormValues,
hideLabelForm,
getLabelFormID
} from './user-labels/label-form';
import {
editLabelHandler,
addLabelHandler,
labelTextInputHandler,
presetColorSelectHandler,
labelColorInputHandler
} from './user-labels/handlers';
(async (): Promise<void> => {
const settings: Settings = await getSettings();
if (!settings.features.userLabels) {
return;
}
addLabelsToUsernames(settings);
const existingLabelForm: HTMLElement | null = document.querySelector(
'#trx-user-label-form'
);
if (existingLabelForm !== null) {
existingLabelForm.remove();
}
const themeSelectOptions: string[] = [];
for (const color in themeColors) {
if (Object.hasOwnProperty.call(themeColors, color)) {
const colorValue = getCSSCustomPropertyValue(
themeColors[color as ColorKey]
);
themeSelectOptions.push(
`<option value="${colorValue}">${color}</option>`
);
}
}
const labelFormTemplate = `<form id="trx-user-label-form" class="trx-hidden">
<div>
<label for="trx-user-label-form-username">Add New Label</label>
<label for="trx-user-label-priority">Priority</label>
</div>
<div>
<input type="text" id="trx-user-label-form-username" class="form-input" placeholder="Username">
<input id="trx-user-label-priority" type="number" class="form-input" value="0">
</div>
<label>Pick A Color</label>
<div id="trx-user-label-form-color">
<input type="text" class="form-input" placeholder="Color">
<select class="form-select">
${themeSelectOptions.join('\n')}
</select>
</div>
<label>Label</label>
<div id="trx-user-label-input">
<input type="text" class="form-input" placeholder="Label">
<div id="trx-user-label-preview">
<p></p>
</div>
</div>
<div id="trx-user-label-actions">
<a id="trx-user-label-save" class="btn-post-action">Save</a>
<a id="trx-user-label-close" class="btn-post-action">Close</a>
<a id="trx-user-label-remove" class="btn-post-action">Remove</a>
</div>
</form>`;
const labelForm: HTMLFormElement = createElementFromString(labelFormTemplate);
document.body.append(labelForm);
labelForm.setAttribute(
'style',
`background-color: var(${themeColors.backgroundPrimary});` +
`border-color: var(${themeColors.foregroundSecondary});`
);
const labelColorInput: HTMLInputElement = querySelector(
'#trx-user-label-form-color > input'
);
labelColorInput.addEventListener(
'keyup',
debounce(labelColorInputHandler, 250)
);
const presetColorSelect: HTMLSelectElement = querySelector(
'#trx-user-label-form-color > select'
);
presetColorSelect.addEventListener('change', presetColorSelectHandler);
presetColorSelect.value = getCSSCustomPropertyValue(
themeColors.backgroundSecondary
);
const labelTextInput: HTMLInputElement = querySelector(
'#trx-user-label-input > input'
);
labelTextInput.addEventListener('keyup', labelTextInputHandler);
const labelPreview: HTMLDivElement = querySelector('#trx-user-label-preview');
labelPreview.setAttribute(
'style',
`background-color: var(${themeColors.backgroundPrimary});` +
`border-color: var(${themeColors.foregroundSecondary});`
);
const formSaveButton: HTMLAnchorElement = querySelector(
'#trx-user-label-save'
);
formSaveButton.addEventListener('click', saveUserLabel);
const formCloseButton: HTMLAnchorElement = querySelector(
'#trx-user-label-close'
);
formCloseButton.addEventListener('click', hideLabelForm);
const formRemoveButton: HTMLAnchorElement = querySelector(
'#trx-user-label-remove'
);
formRemoveButton.addEventListener('click', removeUserLabel);
const commentObserver = new window.MutationObserver(
async (mutations: MutationRecord[]): Promise<void> => {
const commentElements: HTMLElement[] = mutations
.map((value) => value.target as HTMLElement)
.filter(
(value) =>
value.classList.contains('comment-itself') ||
value.classList.contains('comment')
);
if (commentElements.length === 0) {
return;
}
commentObserver.disconnect();
addLabelsToUsernames(await getSettings());
startObserver();
}
);
function startObserver(): void {
const topicComments: HTMLElement | null = document.querySelector(
'.topic-comments'
);
if (topicComments !== null) {
commentObserver.observe(topicComments, {
childList: true,
subtree: true
});
return;
}
const postListing: HTMLElement | null = document.querySelector(
'.post-listing'
);
if (postListing !== null) {
commentObserver.observe(postListing, {
childList: true,
subtree: true
});
}
}
startObserver();
})();
// TODO: Refactor this function to be able to only add labels to specific
// elements. At the moment it goes through all `.link-user` elements which is
// inefficient.
function addLabelsToUsernames(settings: Settings): void {
for (const element of [
...document.querySelectorAll('.trx-user-label'),
...document.querySelectorAll('.trx-user-label-add')
]) {
element.remove();
}
for (const element of document.querySelectorAll('.link-user')) {
const username: string = element
.textContent!.replace(/@/g, '')
.toLowerCase();
const addLabelSpan: HTMLSpanElement = createElementFromString(
`<span class="trx-user-label-add" data-trx-username="${username}">[+]</span>`
);
addLabelSpan.addEventListener('click', (event: MouseEvent): void =>
addLabelHandler(event, addLabelSpan)
);
if (!isInTopicListing()) {
element.insertAdjacentElement('afterend', addLabelSpan);
appendStyleAttribute(
addLabelSpan,
`color: var(${themeColors.foregroundPrimary});`
);
}
const userLabels: UserLabel[] = settings.data.userLabels.filter(
(value) => value.username === username
);
if (userLabels.length === 0) {
if (
isInTopicListing() &&
(element.nextElementSibling === null ||
!element.nextElementSibling.className.includes('trx-user-label'))
) {
element.insertAdjacentElement('afterend', addLabelSpan);
appendStyleAttribute(
addLabelSpan,
`color: var(${themeColors.foregroundPrimary});`
);
}
continue;
}
if (isInTopicListing()) {
userLabels.sort((a, b) => b.priority - a.priority);
} else {
userLabels.sort((a, b): number => {
if (a.priority !== b.priority) {
return a.priority - b.priority;
}
return b.text.localeCompare(a.text);
});
}
for (const userLabel of userLabels) {
const userLabelSpan: HTMLSpanElement = createElementFromString(
`<span class="trx-user-label" data-trx-user-label-id="${userLabel.id}">${userLabel.text}</span>`
);
userLabelSpan.addEventListener(
'click',
async (event: MouseEvent): Promise<void> =>
editLabelHandler(event, userLabelSpan)
);
element.insertAdjacentElement('afterend', userLabelSpan);
// Set the inline-style after the element gets added to the DOM, this
// will prevent a CSP error saying inline-styles aren't permitted.
userLabelSpan.setAttribute(
'style',
`background-color: ${userLabel.color};`
);
if (isColorBright(userLabel.color.trim())) {
userLabelSpan.classList.add('trx-bright');
} else {
userLabelSpan.classList.remove('trx-bright');
}
if (isInTopicListing()) {
break;
}
}
}
}
async function saveUserLabel(): Promise<void> {
const settings: Settings = await getSettings();
const labelForm: HTMLFormElement = getLabelForm();
const labelNoID: Except<UserLabel, 'id'> | undefined = getLabelFormValues();
if (typeof labelNoID === 'undefined') {
return;
}
const existingIDString: string | null = labelForm.getAttribute(
'data-trx-user-label-id'
);
if (existingIDString === null) {
settings.data.userLabels.push({
id: (await getHighestLabelID(settings)) + 1,
...labelNoID
});
await setSettings(settings);
hideLabelForm();
addLabelsToUsernames(settings);
return;
}
const existingID = Number(existingIDString);
const existingLabel: UserLabel | undefined = settings.data.userLabels.find(
(value) => value.id === existingID
);
if (typeof existingLabel === 'undefined') {
log(`Tried to find label with ID that doesn't exist: ${existingID}`, true);
return;
}
const existingLabelIndex: number = settings.data.userLabels.findIndex(
(value) => value.id === existingID
);
settings.data.userLabels.splice(existingLabelIndex, 1);
settings.data.userLabels.push({
id: existingID,
...labelNoID
});
await setSettings(settings);
hideLabelForm();
addLabelsToUsernames(settings);
}
async function removeUserLabel(): Promise<void> {
const labelNoID: Except<UserLabel, 'id'> | undefined = getLabelFormValues();
if (typeof labelNoID === 'undefined') {
return;
}
const id: number | undefined = getLabelFormID();
if (typeof id === 'undefined') {
log('Attempted to remove user label without an ID.');
hideLabelForm();
return;
}
const settings: Settings = await getSettings();
const labelIndex: number = settings.data.userLabels.findIndex(
(value) => value.id === id
);
if (typeof findLabelByID(id) === 'undefined') {
log(
`Attempted to remove user label with an ID that doesn't exist ${id}.`,
true
);
hideLabelForm();
return;
}
settings.data.userLabels.splice(labelIndex, 1);
await setSettings(settings);
hideLabelForm();
addLabelsToUsernames(settings);
}
export async function findLabelByID(
id: number,
settings?: Settings
): Promise<UserLabel | undefined> {
if (typeof settings === 'undefined') {
settings = await getSettings();
}
return settings.data.userLabels.find((value) => value.id === id);
}
async function getHighestLabelID(settings?: Settings): Promise<number> {
if (typeof settings === 'undefined') {
settings = await getSettings();
}
if (settings.data.userLabels.length === 0) {
return 0;
}
return Math.max(...settings.data.userLabels.map((value) => value.id));
}

View File

@ -1,94 +0,0 @@
import {
log,
isValidHexColor,
UserLabel,
querySelector,
getCSSCustomPropertyValue
} from '../../utilities';
import {findLabelByID} from '../user-labels';
import {themeColors} from '../../theme-colors';
import {
getLabelForm,
setLabelFormColor,
setLabelFormPriority,
setLabelFormUserID,
setLabelFormUsername,
setLabelFormText,
setLabelFormTitle,
showLabelForm,
updatePreview
} from './label-form';
export function addLabelHandler(
event: MouseEvent,
element: HTMLSpanElement
): void {
const labelForm = getLabelForm();
labelForm.removeAttribute('data-trx-user-label-id');
setLabelFormTitle('Add New Label');
setLabelFormUsername(element.getAttribute('data-trx-username')!);
setLabelFormPriority(0);
setLabelFormColor(getCSSCustomPropertyValue(themeColors.backgroundSecondary));
setLabelFormText('');
showLabelForm(element);
updatePreview();
}
export async function editLabelHandler(
event: MouseEvent,
element: HTMLSpanElement
): Promise<void> {
setLabelFormTitle('Edit Existing Label');
const labelID = Number(element.getAttribute('data-trx-user-label-id')!);
const label: UserLabel | undefined = await findLabelByID(labelID);
if (typeof label === 'undefined') {
log(`Tried to find label with ID that doesn't exist: ${labelID}`, true);
return;
}
setLabelFormUserID(label.id);
setLabelFormUsername(label.username.toLowerCase());
setLabelFormPriority(label.priority);
setLabelFormColor(label.color);
setLabelFormText(label.text);
showLabelForm(element);
updatePreview();
}
export function labelColorInputHandler(): void {
const labelColorInput: HTMLInputElement = querySelector(
'#trx-user-label-form-color > input'
);
let color: string = labelColorInput.value.toLowerCase();
if (!color.startsWith('#') && !color.startsWith('t') && color.length > 0) {
color = `#${color}`;
labelColorInput.value = color;
}
if (color !== 'transparent' && !isValidHexColor(color)) {
log('Invalid color input, must be a valid 3/4/6/8-character hex color.');
labelColorInput.classList.add('trx-invalid');
return;
}
labelColorInput.classList.remove('trx-invalid');
updatePreview(color);
}
export function labelTextInputHandler(): void {
const labelTextInput: HTMLInputElement = querySelector(
'#trx-user-label-input > input'
);
updatePreview(undefined, labelTextInput.value);
}
export function presetColorSelectHandler(): void {
const labelColorInput: HTMLInputElement = querySelector(
'#trx-user-label-form-color > input'
);
const presetColorSelect: HTMLSelectElement = querySelector(
'#trx-user-label-form-color > select'
);
labelColorInput.value = presetColorSelect.value;
updatePreview(presetColorSelect.value);
}

View File

@ -1,144 +0,0 @@
import {Except} from 'type-fest';
import {
appendStyleAttribute,
UserLabel,
log,
isColorBright,
querySelector,
isValidTildesUsername
} from '../../utilities';
import {themeColors} from '../../theme-colors';
export function getLabelForm(): HTMLFormElement {
return querySelector('#trx-user-label-form');
}
export function showLabelForm(label: HTMLSpanElement): HTMLFormElement {
const labelBounds: DOMRect = label.getBoundingClientRect();
const labelForm = getLabelForm();
const horizontalOffset: number = labelBounds.x + window.scrollX;
const verticalOffset: number =
labelBounds.y + labelBounds.height + 4 + window.scrollY;
appendStyleAttribute(
labelForm,
`left: ${horizontalOffset}px; top: ${verticalOffset}px;`
);
labelForm.classList.remove('trx-hidden');
return labelForm;
}
export function hideLabelForm(): HTMLFormElement {
const labelForm = getLabelForm();
labelForm.classList.add('trx-hidden');
return labelForm;
}
export function setLabelFormTitle(title: string): void {
const labelTitle: HTMLLabelElement = querySelector(
'#trx-user-label-form > div:first-child > label:first-child'
);
labelTitle.textContent = title;
}
export function setLabelFormUserID(id: number): void {
getLabelForm().dataset.trxUserLabelId = String(id);
}
export function setLabelFormUsername(username: string): void {
const usernameInput: HTMLInputElement = querySelector(
'#trx-user-label-form-username'
);
usernameInput.value = username.toLowerCase();
}
export function setLabelFormPriority(priority: number): void {
const priorityInput: HTMLInputElement = querySelector(
'#trx-user-label-priority'
);
priorityInput.value = String(priority);
}
export function setLabelFormColor(color: string): void {
const colorInput: HTMLInputElement = querySelector(
'#trx-user-label-form-color > input'
);
colorInput.value = color;
}
export function setLabelFormText(text: string): void {
const textInput: HTMLInputElement = querySelector(
'#trx-user-label-input > input'
);
textInput.value = text;
}
export function getLabelFormValues(): Except<UserLabel, 'id'> | undefined {
const usernameInput: HTMLInputElement = querySelector(
'#trx-user-label-form-username'
);
const priorityInput: HTMLInputElement = querySelector(
'#trx-user-label-priority'
);
const colorInput: HTMLInputElement = querySelector(
'#trx-user-label-form-color > input'
);
const textInput: HTMLInputElement = querySelector(
'#trx-user-label-input > input'
);
const data: Except<UserLabel, 'id'> = {
color: colorInput.value.toLowerCase(),
priority: Number(priorityInput.value),
text: textInput.value,
username: usernameInput.value.toLowerCase()
};
if (!isValidTildesUsername(data.username)) {
log(`Invalid Tildes username detected: ${data.username}`);
usernameInput.classList.add('trx-invalid');
return undefined;
}
usernameInput.classList.remove('trx-invalid');
return data;
}
export function getLabelFormID(): number | undefined {
const labelForm: HTMLFormElement = getLabelForm();
const id: string | null = labelForm.getAttribute('data-trx-user-label-id');
if (id === null) {
return undefined;
}
return Number(id);
}
export function updatePreview(color?: string, text?: string): void {
if (typeof color === 'undefined') {
const labelColorInput: HTMLInputElement = querySelector(
'#trx-user-label-form-color > input'
);
color = labelColorInput.value;
}
if (typeof text === 'undefined') {
const labelTextInput: HTMLInputElement = querySelector(
'#trx-user-label-input > input'
);
text = labelTextInput.value;
}
const labelPreview: HTMLDivElement = querySelector('#trx-user-label-preview');
labelPreview.setAttribute(
'style',
`background-color: ${color}; ` +
`border-color: var(${themeColors.foregroundSecondary});`
);
labelPreview.firstElementChild!.textContent = text;
if (isColorBright(color.trim())) {
labelPreview.classList.add('trx-bright');
} else {
labelPreview.classList.remove('trx-bright');
}
}

View File

@ -1,15 +0,0 @@
export type ColorKey = keyof typeof themeColors;
export const themeColors = {
backgroundPrimary: '--background-primary-color',
backgroundSecondary: '--background-secondary-color',
foregroundPrimary: '--foreground-primary-color',
foregroundSecondary: '--foreground-secondary-color',
exemplary: '--comment-label-exemplary-color',
offtopic: '--comment-label-offtopic-color',
joke: '--comment-label-joke-color',
noise: '--comment-label-noise-color',
malice: '--comment-label-malice-color',
mine: '--stripe-mine-color',
official: '--alert-color'
};

View File

@ -1,330 +0,0 @@
import {browser, Manifest} from 'webextension-polyfill-ts';
export interface UserLabel {
color: string;
id: number;
text: string;
priority: number;
username: string;
}
export interface Settings {
data: {
hideVotes: {
comments: boolean;
topics: boolean;
ownComments: boolean;
ownTopics: boolean;
[index: string]: boolean;
};
knownGroups: string[];
latestActiveFeatureTab: string;
userLabels: UserLabel[];
version?: string;
};
features: {
autocomplete: boolean;
backToTop: boolean;
debug: boolean;
hideVotes: boolean;
jumpToNewComment: boolean;
markdownToolbar: boolean;
userLabels: boolean;
[index: string]: boolean;
};
}
export const defaultSettings: Settings = {
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. So
// scripts that use this should call that function when they are run.
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: []
},
features: {
autocomplete: true,
backToTop: true,
debug: false,
hideVotes: false,
jumpToNewComment: true,
markdownToolbar: true,
userLabels: true
}
};
// Keep a local variable here for the debug logging, this way we don't have to
// call `getSettings()` each time we potentially want to log something. It gets
// set each time `getSettings()` is called so it's always accurate.
let debug = true;
export async function getSettings(): Promise<Settings> {
const syncSettings: any = await browser.storage.sync.get(defaultSettings);
const settings: Settings = {
data: {...defaultSettings.data, ...syncSettings.data},
features: {...defaultSettings.features, ...syncSettings.features}
};
debug = settings.features.debug;
// If we're in development, force debug output.
if (getManifest().nodeEnv === 'development') {
debug = true;
}
return settings;
}
export async function setSettings(
newSettings: Partial<Settings>
): Promise<void> {
return browser.storage.sync.set(newSettings);
}
export function log(message: any, override = false): void {
let overrideStyle = '';
let prefix = '[TRX]';
if (override) {
prefix = '%c' + prefix;
overrideStyle = 'background-color: #dc322f; margin-right: 9px;';
}
if (debug || override) {
if (overrideStyle.length > 0) {
console.debug(prefix, overrideStyle, message);
} else {
console.debug(prefix, message);
}
}
}
// Helper function to convert kebab-case strings to camelCase ones.
// Primarily for converting element IDs to Object keys.
export function kebabToCamel(input: string): string {
let output = '';
for (const part of input.split('-')) {
output += part[0].toUpperCase();
output += part.slice(1);
}
return output[0].toLowerCase() + output.slice(1);
}
// The opposite of `kebabToCamel()`.
export function camelToKebab(input: string): string {
const uppercaseMatches: RegExpMatchArray | null = input.match(/[A-Z]/g);
// If there are no uppercase letters in the input, just return it.
if (uppercaseMatches === null) {
return input;
}
// Find all the indexes of where uppercase letters are.
const uppercaseIndexes: number[] = [];
for (const match of uppercaseMatches) {
const latestIndex: number =
typeof uppercaseIndexes[uppercaseIndexes.length - 1] === 'undefined'
? 0
: uppercaseIndexes[uppercaseIndexes.length - 1];
uppercaseIndexes.push(input.indexOf(match, latestIndex + 1));
}
// Convert each section up to the next uppercase letter to lowercase with
// a dash between each section.
let output = '';
let previousIndex = 0;
for (const index of uppercaseIndexes) {
output += input.slice(previousIndex, index).toLowerCase();
output += '-';
previousIndex = index;
}
output += input.slice(previousIndex).toLowerCase();
return output;
}
// This utility function should only be used in cases where we know for certain
// that the wanted element is going to exist.
export function querySelector<T extends Element>(selector: string): T {
return document.querySelector<T>(selector)!;
}
export function querySelectorAll<T extends Element>(selector: string): T[] {
const elements: T[] = [];
for (const element of document.querySelectorAll<T>(selector)) {
elements.push(element);
}
return elements;
}
export function createElementFromString<T extends Element>(input: string): T {
const template: HTMLTemplateElement = document.createElement('template');
template.innerHTML = input.trim();
return template.content.firstElementChild! as T;
}
export function isInTopicListing(): boolean {
return document.querySelector('.topic-listing') !== null;
}
export function getManifest(): {nodeEnv?: string} & Manifest.ManifestBase {
const manifest: Manifest.ManifestBase = browser.runtime.getManifest();
return {...manifest};
}
export function getCSSCustomPropertyValue(property: string): string {
return getComputedStyle(document.body).getPropertyValue(property);
}
// Adapted from https://stackoverflow.com/a/12043228/12251171.
export function isColorBright(color: string): boolean {
if (color.startsWith('#')) {
color = color.slice(1);
}
if (color.length === 4) {
color = color.slice(0, 3);
}
if (color.length === 8) {
color = color.slice(0, 6);
}
if (color.length === 3) {
color = color
.split('')
.map((value) => value.repeat(2))
.join('');
}
const red: number = Number.parseInt(color.slice(0, 2), 16);
const green: number = Number.parseInt(color.slice(2, 4), 16);
const blue: number = Number.parseInt(color.slice(4, 6), 16);
const brightness: number = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
return brightness > 128;
}
export function appendStyleAttribute(element: Element, styles: string): void {
const existingStyles: string | null = element.getAttribute('style');
if (existingStyles === null) {
element.setAttribute('style', styles);
} else {
element.setAttribute('style', `${existingStyles} ${styles}`);
}
}
export function isValidHexColor(color: string): boolean {
// Overly verbose validation for 3/4/6/8-character hex colors.
if (
/^#[a-f\d]{6}$/i.exec(color) === null &&
/^#[a-f\d]{3}$/i.exec(color) === null &&
/^#[a-f\d]{8}$/i.exec(color) === null &&
/^#[a-f\d]{4}$/i.exec(color) === null
) {
return false;
}
return true;
}
export function flashMessage(message: string, error = false): void {
if (document.querySelector('.trx-flash-message') !== null) {
log(
`A flash message already exists, skipping requested one with message:\n${message}`
);
return;
}
const messageElement: HTMLDivElement = createElementFromString(
`<div class="trx-flash-message">${message}</div>`
);
if (error) {
messageElement.classList.add('trx-flash-error');
}
let isRemoved = false;
messageElement.addEventListener('click', (): void => {
messageElement.remove();
isRemoved = true;
});
document.body.append(messageElement);
setTimeout(() => {
messageElement.classList.add('trx-opaque');
}, 50);
setTimeout(() => {
if (!isRemoved) {
messageElement.classList.remove('trx-opaque');
setTimeout(() => {
messageElement.remove();
}, 500);
}
}, 5000);
}
// Validation copied from Tildes source code:
// https://gitlab.com/tildes/tildes/blob/master/tildes/tildes/schemas/user.py
export function isValidTildesUsername(username: string): boolean {
return (
username.length >= 3 &&
username.length <= 20 &&
/^[a-z\d]([a-z\d]|[_-](?![_-]))*[a-z\d]$/i.exec(username) !== null
);
}
// This function will update the saved known groups when we're in the Tildes
// group listing. Any script that uses the known groups should call this before
// running.
export async function extractAndSaveGroups(
settings: Settings
): Promise<Settings> {
if (window.location.pathname !== '/groups') {
return Promise.reject(new Error('Not in /groups.'));
}
const groups: string[] = [...document.querySelectorAll('.link-group')].map(
(value) => value.textContent!
);
settings.data.knownGroups = groups;
await setSettings(settings);
log('Updated saved groups.', true);
return settings;
}

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

@ -0,0 +1,15 @@
// A fix for TypeScript so it sees this file as a module and thus allows to
// modify the global scope.
export {};
// See the initialize function located in `utilities/index.ts` for actual code.
type TildesReExtended = {
debug: boolean;
};
declare global {
interface Window {
TildesReExtended: TildesReExtended;
}
}

89
source/utilities/color.ts Normal file
View File

@ -0,0 +1,89 @@
/**
* Returns whether a hex color is "bright".
* @param color The hex color.
*/
export function isColorBright(color: string): boolean {
if (color.startsWith('#')) {
color = color.slice(1);
}
// 4 character hex colors have an alpha value, we only need RGB here so remove
// the alpha character.
if (color.length === 4) {
color = color.slice(0, 3);
}
// 8 character hex colors also have an alpha value, so remove the last 2.
if (color.length === 8) {
color = color.slice(0, 6);
}
// 3 character hex colors can be represented as 6 character ones too, so
// transform it. For example "#123" is the same as "#112233".
if (color.length === 3) {
color = color
.split('')
.map((value) => value.repeat(2))
.join('');
}
// Split the color up into 3 segments of 2 characters and convert them from
// hexadecimal to decimal.
const [red, green, blue] = color
.split(/(.{2})/)
.filter((value) => value !== '')
.map((value) => Number.parseInt(value, 16));
// Magical numbers taken from https://stackoverflow.com/a/12043228/12251171.
// "Per ITU-R BT.709"
const brightness = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
return brightness > 128;
}
// CSS custom properties from the Tildes themes.
export const themeColors = [
{
name: 'Background Primary',
value: '--background-primary-color'
},
{
name: 'Background Secondary',
value: '--background-secondary-color'
},
{
name: 'Foreground Primary',
value: '--foreground-primary-color'
},
{
name: 'Foreground Secondary',
value: '--foreground-secondary-color'
},
{
name: 'Exemplary',
value: '--comment-label-exemplary-color'
},
{
name: 'Off-topic',
value: '--comment-label-offtopic-color'
},
{
name: 'Joke',
value: '--comment-label-joke-color'
},
{
name: 'Noise',
value: '--comment-label-noise-color'
},
{
name: 'Malice',
value: '--comment-label-malice-color'
},
{
name: 'Mine',
value: '--stripe-mine-color'
},
{
name: 'Official',
value: '--alert-color'
}
];

View File

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

View File

@ -0,0 +1,24 @@
import {log, querySelectorAll, setSettings, Settings} from '..';
/**
* 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`.
* @param settings The user's extension settings.
*/
export async function extractAndSaveGroups(
settings: Settings
): Promise<string[]> {
if (window.location.pathname !== '/groups') {
log('Not in "/groups", returning early.');
return settings.data.knownGroups;
}
const groups: string[] = querySelectorAll('.link-group').map(
(value) => value.textContent!
);
settings.data.knownGroups = groups;
await setSettings(settings);
log('Updated saved groups.', true);
return groups;
}

50
source/utilities/index.ts Normal file
View File

@ -0,0 +1,50 @@
/**
* Creates an HTMLElement from a given string. Only use this when using
* `htm/preact` isn't practical.
* @param input The HTML.
*/
export function createElementFromString<T extends Element>(input: string): T {
const template: HTMLTemplateElement = document.createElement('template');
template.innerHTML = input.trim();
return template.content.firstElementChild! as T;
}
/**
* Initializes the global window with Tildes ReExtended-specific settings.
*/
export function initialize(): void {
if (window.TildesReExtended === undefined) {
window.TildesReExtended = {
debug: false
};
}
}
/**
* Logs something to the console under the debug level.
* @param thing The thing to log.
* @param force If true, ignores whether or not debug logging is enabled.
*/
export function log(thing: any, force = false): void {
let overrideStyle = '';
let prefix = '[TRX]';
if (force) {
prefix = '%c' + prefix;
overrideStyle = 'background-color: #dc322f; margin-right: 9px;';
}
if (window.TildesReExtended.debug || force) {
if (overrideStyle.length > 0) {
console.debug(prefix, overrideStyle, thing);
} else {
console.debug(prefix, thing);
}
}
}
export * from './color';
export * from './components';
export * from './groups';
export * from './query-selectors';
export * from './report-a-bug';
export * from './validators';

View File

@ -0,0 +1,25 @@
// These utility functions mainly exist so it's easier to work with TypeScript's
// typing and so we don't have to write `document.query...` all the time.
// The first function should only ever be used when we know for certain that
// the target element is going to exist.
/**
* Returns the first element found that matches the selector.
* @param selector The selector.
*/
export function querySelector<T extends Element>(selector: string): T {
return document.querySelector<T>(selector)!;
}
/**
* Returns all elements found from all the selectors.
* @param selectors The selectors.
*/
export function querySelectorAll<T extends Element>(
...selectors: string[]
): T[] {
return selectors.flatMap((selector) =>
Array.from(document.querySelectorAll(selector))
);
}

View File

@ -0,0 +1,59 @@
import platform from 'platform';
/**
* Creates a bug report template in Markdown.
* @param location The location this template will apply to.
* @param trxVersion The Tildes ReExtended version to include in the template.
*/
export function createReportTemplate(
location: 'gitlab' | 'tildes',
trxVersion: string
): string {
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.";
if (location === 'tildes') {
introText =
'Thank you for taking the time to report a bug! Please make sure the\n information below is correct.';
}
const layout: string = platform.layout!;
const name: string = platform.name!;
const os: string = platform.os?.toString()!;
const version: string = platform.version!;
// 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.
let reportTemplate = `<h2>Bug Report</h2>
<!--
${introText}
-->
<h3>Info</h3>\n
| Type | Value |
|------|-------|
| Extension Version | ${trxVersion} |
| Operating System | ${os} |
| Browser | ${name} ${version} (${layout}) |\n`;
// The platform manufacturer and product can be null in certain cases (such as
// desktops) so only when they're both not null include them.
if (platform.manufacturer !== null && platform.product !== null) {
const manufacturer: string = platform.manufacturer!;
const product: string = platform.product!;
reportTemplate += `| Device | ${manufacturer} ${product} |\n`;
}
reportTemplate += `\n<h3>The Problem</h3>
<!--
Please explain in sufficient detail what the problem is. When possible,
including an image or video showing the problem also helps immensely.
-->\n\n\n
<h3>A Solution</h3>
<!--
If you know of any possible solutions, feel free to include them. If the
solution is just something like "it should work" then you can safely omit
this section.
-->\n\n\n`;
return reportTemplate;
}

View File

@ -0,0 +1,23 @@
/**
* Return whether the input is a valid hex color with a starting `#`.
* @param color The potential hex color.
*/
export function isValidHexColor(color: string): boolean {
return (
/^#(?:[a-f\d]{8}|[a-f\d]{6}|[a-f\d]{4}|[a-f\d]{3})$/i.exec(color) !== null
);
}
/**
* Return whether the input is a valid Tildes username.
* @param username The potential username.
*/
export function isValidTildesUsername(username: string): boolean {
// Validation copied from Tildes source code:
// https://gitlab.com/tildes/tildes/blob/master/tildes/tildes/schemas/user.py
return (
username.length >= 3 &&
username.length <= 20 &&
/^[a-z\d]([a-z\d]|[_-](?![_-]))*[a-z\d]$/i.exec(username) !== null
);
}

View File

@ -1,6 +1,9 @@
{
"compilerOptions": {
"esModuleInterop": true,
"lib": [
"ES2019"
],
"module": "commonjs",
"outDir": "build/",
"strict": true,
@ -11,8 +14,7 @@
],
},
"include": [
"source/ts/**/*.ts",
"scripts/*.ts"
"source/**/*.ts",
],
"exclude": [
"node_modules/"

631
yarn.lock

File diff suppressed because it is too large Load Diff