1
Fork 0

Fix all the things.

* Switch from Parcel to Vite.
* Rewrite the Settings handling.
* Fix a bug that made the extension not work on first load.
* A bunch of other stuff, too lazy to write it all out.
This commit is contained in:
Bauke 2022-02-23 14:52:06 +01:00
parent 58b5192d5a
commit 52696fe50e
Signed by: Bauke
GPG Key ID: C1C0F29952BCF558
67 changed files with 7043 additions and 11545 deletions

53
.gitignore vendored
View File

@ -4,6 +4,10 @@ logs
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data # Runtime data
pids pids
@ -16,11 +20,12 @@ lib-cov
# Coverage directory used by tools like istanbul # Coverage directory used by tools like istanbul
coverage coverage
*.lcov
# nyc test coverage # nyc test coverage
.nyc_output .nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt .grunt
# Bower dependency directory (https://bower.io/) # Bower dependency directory (https://bower.io/)
@ -39,12 +44,21 @@ jspm_packages/
# TypeScript v1 declaration files # TypeScript v1 declaration files
typings/ typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory # Optional npm cache directory
.npm .npm
# Optional eslint cache # Optional eslint cache
.eslintcache .eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history # Optional REPL history
.node_repl_history .node_repl_history
@ -56,17 +70,42 @@ typings/
# dotenv environment variables file # dotenv environment variables file
.env .env
.env.test
# next.js build output # parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next .next
# Profile directories # Nuxt.js build / generate output
chromium/ .nuxt
firefox/ dist
# Parcel cache # Gatsby files
.cache/ .cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# Output directory # vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Build output directories
build/ build/
web-ext-artifacts/ web-ext-artifacts/
# Firefox profile directory
firefox/

1
.husky/.gitignore vendored
View File

@ -1 +0,0 @@
_

View File

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

View File

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

View File

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

View File

@ -8,73 +8,51 @@
], ],
"private": true, "private": true,
"scripts": { "scripts": {
"watch": "NODE_ENV=development parcel 'source/manifest.json' -d 'build/' --no-hmr", "start": "vite build -m development --watch",
"start": "web-ext run --source-dir build/ --bc", "clean": "trash build web-ext-artifacts",
"start:chromium": "yarn start --chromium-profile chromium/ --keep-profile-changes --target chromium --start-url \"chrome://extensions\"", "build": "pnpm clean && vite build && web-ext build --source-dir build && pnpm zip-source",
"start:firefox": "yarn start --firefox-profile firefox/ --keep-profile-changes --target firefox-desktop --start-url \"about:debugging#/runtime/this-firefox\"", "zip-source": "git archive --format zip --output web-ext-artifacts/tildes_reextended-source.zip HEAD",
"clean": "trash .cache build/ web-ext-artifacts/", "test": "xo && stylelint 'source/**/*.scss' && tsc"
"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",
"postinstall": "husky install"
}, },
"dependencies": { "dependencies": {
"caret-pos": "^2.0.0", "caret-pos": "^2.0.0",
"debounce": "^1.2.0", "debounce": "^1.2.1",
"htm": "^3.0.4", "htm": "^3.1.0",
"modern-normalize": "^1.0.0", "modern-normalize": "^1.1.0",
"platform": "^1.3.6", "platform": "^1.3.6",
"preact": "^10.5.12", "preact": "^10.6.6",
"webextension-polyfill-ts": "^0.25.0" "webextension-polyfill": "^0.8.0"
}, },
"devDependencies": { "devDependencies": {
"@types/debounce": "^1.2.0", "@preact/preset-vite": "^2.1.7",
"@types/platform": "^1.3.3", "@types/debounce": "^1.2.1",
"eslint-config-xo-typescript": "^0.38.0", "@types/platform": "^1.3.4",
"husky": "5", "@types/webextension-polyfill": "^0.8.2",
"parcel-bundler": "^1.12.4", "postcss": "^8.4.6",
"parcel-plugin-web-extension": "^1.6.1", "sass": "^1.49.8",
"sass": "^1.32.8", "stylelint": "^14.5.1",
"stylelint": "^13.11.0", "stylelint-config-standard-scss": "^3.0.0",
"stylelint-config-xo-scss": "^0.14.0", "trash-cli": "^5.0.0",
"stylelint-config-xo-space": "^0.15.1", "typescript": "^4.5.5",
"trash-cli": "^4.0.0", "vite": "^2.8.4",
"typescript": "^4.1.5", "vite-plugin-web-extension": "^1.1.2",
"web-ext": "^5.5.0", "web-ext": "^6.7.0",
"web-ext-types": "^3.2.1", "xo": "^0.48.0"
"xo": "^0.38.1"
}, },
"stylelint": { "stylelint": {
"extends": [ "extends": [
"stylelint-config-xo-scss", "stylelint-config-standard-scss"
"stylelint-config-xo-space"
],
"ignoreFiles": [
"source/**/*.ts",
"build/**"
], ],
"rules": { "rules": {
"scss/at-rule-no-unknown": null, "no-descending-specificity": null,
"at-rule-empty-line-before": null, "string-quotes": "single"
"at-rule-no-unknown": null,
"block-no-empty": null,
"no-descending-specificity": null
} }
}, },
"xo": { "xo": {
"globals": [
"document",
"window"
],
"prettier": true, "prettier": true,
"rules": { "rules": {
"@typescript-eslint/no-implicit-any-catch": "off", "@typescript-eslint/naming-convention": "off"
"@typescript-eslint/no-loop-func": "off",
"node/file-extension-in-import": "off"
}, },
"space": true "space": true
}, }
"browserslist": [
"last 2 Chrome versions"
]
} }

5805
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,33 +0,0 @@
import {browser} from 'webextension-polyfill-ts';
import {
defaultSettings,
getManifest,
getSettings,
setSettings,
versionAsNumber
} from '.';
// Add listeners to open the options page when:
// * The extension icon is clicked.
// * The extension is installed.
browser.browserAction.onClicked.addListener(openOptionsPage);
browser.runtime.onInstalled.addListener(async () => {
const manifest = getManifest();
const settings = await getSettings();
const versionGotUpdated =
versionAsNumber(manifest.version) >
versionAsNumber(settings.data.version ?? defaultSettings.data.version!);
if (versionGotUpdated) {
settings.data.version = manifest.version;
await setSettings(settings);
}
if (versionGotUpdated || manifest.nodeEnv === 'development') {
await openOptionsPage();
}
});
async function openOptionsPage() {
await browser.runtime.openOptionsPage();
}

View File

@ -0,0 +1,19 @@
import browser from 'webextension-polyfill';
import {log} from '../utilities/logging.js';
log('Debug logging is enabled.');
// Open the options page when the extension icon is clicked.
browser.browserAction.onClicked.addListener(openOptionsPage);
browser.runtime.onInstalled.addListener(async () => {
// Always automatically open the options page in development.
if (import.meta.env.DEV) {
await openOptionsPage();
}
});
async function openOptionsPage() {
await browser.runtime.openOptionsPage();
}

View File

@ -1,23 +1,24 @@
import './scss/scripts.scss';
import {html} from 'htm/preact'; import {html} from 'htm/preact';
import {render} from 'preact'; import {render} from 'preact';
import { import {
AutocompleteFeature, AutocompleteFeature,
BackToTopFeature, BackToTopFeature,
extractAndSaveGroups,
getSettings,
initialize,
JumpToNewCommentFeature, JumpToNewCommentFeature,
log, UserLabelsFeature,
runHideVotesFeature, runHideVotesFeature,
runMarkdownToolbarFeature, runMarkdownToolbarFeature,
TRXComponent, } from './scripts/exports.js';
UserLabelsFeature import Settings from './settings.js';
} from '.'; import {extractGroups, initializeGlobals, log} from './utilities/exports.js';
window.addEventListener('load', async () => { async function initialize() {
const start = window.performance.now(); const start = window.performance.now();
initialize(); initializeGlobals();
const settings = await getSettings(); const settings = await Settings.fromSyncStorage();
window.TildesReExtended.debug = settings.features.debug;
// Any features that will use `settings.data.knownGroups` should be added to // 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 // this array so that when groups are changed on Tildes, TRX can still update
@ -25,7 +26,11 @@ window.addEventListener('load', async () => {
const usesKnownGroups = [settings.features.autocomplete]; const usesKnownGroups = [settings.features.autocomplete];
// Only when any of the features that uses this data try to save the groups. // Only when any of the features that uses this data try to save the groups.
if (usesKnownGroups.some((value) => value)) { if (usesKnownGroups.some((value) => value)) {
settings.data.knownGroups = await extractAndSaveGroups(settings); const knownGroups = extractGroups();
if (knownGroups !== undefined) {
settings.data.knownGroups = knownGroups;
await settings.save();
}
} }
// Object to hold the active components we are going to render. // Object to hold the active components we are going to render.
@ -68,14 +73,18 @@ window.addEventListener('load', async () => {
// The jump to new comment button must come right before // The jump to new comment button must come right before
// the back to top button. The CSS depends on them being in this order. // the back to top button. The CSS depends on them being in this order.
render( render(
html`<div id="trx-container"> html`
<div id="trx-container">
${components.jumpToNewComment} ${components.backToTop} ${components.jumpToNewComment} ${components.backToTop}
${components.autocomplete} ${components.userLabels} ${components.autocomplete} ${components.userLabels}
</div>`, </div>
`,
document.body, document.body,
replacement replacement,
); );
const initializedIn = window.performance.now() - start; const initializedIn = window.performance.now() - start;
log(`Initialized in approximately ${initializedIn} milliseconds.`); log(`Initialized in approximately ${initializedIn} milliseconds.`);
}); }
void initialize();

View File

@ -1,19 +0,0 @@
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

@ -1,3 +0,0 @@
{
"nodeEnv": "development"
}

View File

@ -3,7 +3,7 @@
"manifest_version": 2, "manifest_version": 2,
"name": "Tildes ReExtended", "name": "Tildes ReExtended",
"description": "An updated and reimagined recreation of Tildes Extended to enhance and improve the experience of Tildes.net.", "description": "An updated and reimagined recreation of Tildes Extended to enhance and improve the experience of Tildes.net.",
"version": "1.0.3", "version": "1.0.4",
"permissions": [ "permissions": [
"downloads", "downloads",
"storage", "storage",
@ -11,23 +11,23 @@
], ],
"content_security_policy": "script-src 'self'; object-src 'self'; style-src 'unsafe-inline'", "content_security_policy": "script-src 'self'; object-src 'self'; style-src 'unsafe-inline'",
"web_accessible_resources": [ "web_accessible_resources": [
"./assets/**" "assets/**"
], ],
"icons": { "icons": {
"128": "./assets/tildes-reextended-128.png" "128": "assets/tildes-reextended-128.png"
}, },
"background": { "background": {
"scripts": [ "scripts": [
"./background.ts" "background/background.ts"
] ]
}, },
"browser_action": { "browser_action": {
"default_icon": { "default_icon": {
"128": "./assets/tildes-reextended-128.png" "128": "assets/tildes-reextended-128.png"
} }
}, },
"options_ui": { "options_ui": {
"page": "./index.html", "page": "options/index.html",
"open_in_tab": true "open_in_tab": true
}, },
"content_scripts": [ "content_scripts": [
@ -37,10 +37,10 @@
], ],
"run_at": "document_end", "run_at": "document_end",
"css": [ "css": [
"./scss/scripts.scss" "generated:style.css"
], ],
"js": [ "js": [
"./content-scripts.ts" "content-scripts.ts"
] ]
} }
], ],

View File

@ -0,0 +1,216 @@
import browser from 'webextension-polyfill';
import {html} from 'htm/preact';
import Settings from '../../settings.js';
import {
Link,
log,
isValidHexColor,
isValidTildesUsername,
} from '../../utilities/exports.js';
import {SettingProps, Setting} from './index.js';
async function logSettings() {
log(await Settings.fromSyncStorage(), true);
}
async function importFileHandler(event: Event): Promise<void> {
// Grab the imported files (if any).
const fileList = (event.target as HTMLInputElement).files;
if (fileList === null) {
log('No file imported.');
return;
}
const reader = new window.FileReader();
reader.addEventListener('load', async (): Promise<void> => {
let data: Partial<Settings>;
try {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
data = JSON.parse(reader.result!.toString()) as Partial<Settings>;
} catch (error: unknown) {
log(error, true);
return;
}
const settings = await Settings.fromSyncStorage();
if (typeof data.data !== 'undefined') {
if (typeof data.data.userLabels !== 'undefined') {
settings.data.userLabels = [];
for (const label of data.data.userLabels) {
if (
typeof label.username === 'undefined' ||
!isValidTildesUsername(label.username)
) {
log(`Invalid username in imported labels: ${label.username}`);
continue;
}
settings.data.userLabels.push({
color: isValidHexColor(label.color) ? label.color : '#f0f',
id: settings.data.userLabels.length + 1,
priority: Number.isNaN(label.priority) ? 0 : label.priority,
text: typeof label.text === 'undefined' ? 'Label' : label.text,
username: label.username,
});
}
}
if (typeof data.data.hideVotes !== 'undefined') {
settings.data.hideVotes = data.data.hideVotes;
}
}
if (typeof data.features !== 'undefined') {
settings.features = {...data.features};
}
await settings.save();
log('Successfully imported your settings, reloading the page to apply.');
setTimeout(() => {
window.location.reload();
}, 1000);
});
reader.addEventListener('error', (): void => {
log(reader.error, true);
reader.abort();
});
reader.readAsText(fileList[0]);
}
async function exportSettings(event: MouseEvent): Promise<void> {
event.preventDefault();
const settings = await Settings.fromSyncStorage();
const settingsBlob = new window.Blob([JSON.stringify(settings, null, 2)], {
type: 'text/json',
});
const objectURL = URL.createObjectURL(settingsBlob);
try {
await browser.downloads.download({
filename: 'tildes-reextended-settings.json',
url: objectURL,
saveAs: true,
});
} catch (error: unknown) {
log(error);
} finally {
// According to MDN, when creating an object URL we should also revoke it
// when "it's safe to do so" to prevent excessive memory/storage use.
// 60 seconds should probably be enough time to download the settings.
setTimeout(() => {
URL.revokeObjectURL(objectURL);
}, 60 * 1000);
}
}
export function AboutSetting(props: SettingProps): TRXComponent {
const importSettings = () => {
document.querySelector<HTMLElement>('#import-settings')!.click();
};
const communityLink = html`
<${Link}
url="https://gitlab.com/tildes-community"
text="Tildes Community project"
/>
`;
const criusLink = html`
<${Link} url="https://tildes.net/user/crius" text="Crius" />
`;
const gitlabIssuesLink = html`
<${Link}
url="https://gitlab.com/tildes-community/tildes-reextended/-/issues"
text="GitLab issue tracker"
/>
`;
const gitlabLicenseLink = html`
<${Link}
url="https://gitlab.com/tildes-community/tildes-reextended/blob/main/LICENSE"
text="MIT License"
/>
`;
const messageCommunityLink = html`
<${Link}
url="https://tildes.net/user/Community/new_message"
text="message Community"
/>
`;
const tildesExtendedLink = html`
<${Link}
url="https://github.com/theCrius/tildes-extended"
text="Tildes Extended"
/>
`;
return html`
<${Setting} ...${props}>
<p class="info">
This feature will make debugging logs output to the console when
enabled.
</p>
<p>
Tildes ReExtended is a from-scratch recreation of the original${' '}
${tildesExtendedLink} web extension by ${criusLink}. Open-sourced${' '}
with the ${gitlabLicenseLink} and maintained as a ${communityLink}.
</p>
<p>
To report bugs or request new features use the links at the bottom of
this page, check out the ${gitlabIssuesLink} or${' '}
${messageCommunityLink}${' '} on Tildes.
</p>
<div class="divider" />
<div class="import-export">
<p>
Note that importing settings will delete and overwrite your existing
ones.
</p>
<input
id="import-settings"
onChange=${importFileHandler}
class="trx-hidden"
accept="application/json"
type="file"
/>
<button onClick=${importSettings} class="button">
Import Settings
</button>
<button onClick=${exportSettings} class="button">
Export Settings
</button>
</div>
<div class="divider" />
<details class="misc-utilities">
<summary>Danger Zone</summary>
<div class="inner">
<button onClick=${logSettings} class="button">Log Settings</button>
<button onClick=${Settings.nuke} class="button destructive">
Remove All Data
</button>
</div>
</details>
<//>
`;
}

View File

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

View File

@ -0,0 +1,15 @@
import {html} from 'htm/preact';
import {Setting, SettingProps} from './index.js';
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,7 @@
export {AboutSetting} from './about.js';
export {AutocompleteSetting} from './autocomplete.js';
export {BackToTopSetting} from './back-to-top.js';
export {HideVotesSetting} from './hide-votes.js';
export {JumpToNewCommentSetting} from './jump-to-new-comment.js';
export {MarkdownToolbarSetting} from './markdown-toolbar.js';
export {UserLabelsSetting} from './user-labels.js';

View File

@ -1,12 +1,8 @@
import {html} from 'htm/preact'; import {html} from 'htm/preact';
import {useContext, useState} from 'preact/hooks'; import {useContext, useState} from 'preact/hooks';
import {
AppContext, import {AppContext} from '../context.js';
setSettings, import {Setting, SettingProps} from './index.js';
Setting,
SettingProps,
TRXComponent
} from '../..';
export function HideVotesSetting(props: SettingProps): TRXComponent { export function HideVotesSetting(props: SettingProps): TRXComponent {
const {settings} = useContext(AppContext); const {settings} = useContext(AppContext);
@ -17,7 +13,7 @@ export function HideVotesSetting(props: SettingProps): TRXComponent {
setChecked(checked); setChecked(checked);
settings.data.hideVotes = checked; settings.data.hideVotes = checked;
void setSettings(settings); void settings.save();
} }
// Checkbox labels and "targets". The targets should match the keys as defined // Checkbox labels and "targets". The targets should match the keys as defined
@ -26,7 +22,7 @@ export function HideVotesSetting(props: SettingProps): TRXComponent {
{label: 'Your comments', target: 'ownComments'}, {label: 'Your comments', target: 'ownComments'},
{label: 'Your topics', target: 'ownTopics'}, {label: 'Your topics', target: 'ownTopics'},
{label: "Other's comments", target: 'comments'}, {label: "Other's comments", target: 'comments'},
{label: "Other's topics", target: 'topics'} {label: "Other's topics", target: 'topics'},
].map( ].map(
({label, target}) => ({label, target}) =>
html` html`
@ -42,10 +38,11 @@ export function HideVotesSetting(props: SettingProps): TRXComponent {
${label} ${label}
</label> </label>
</li> </li>
` `,
); );
return html`<${Setting} ...${props}> return html`
<${Setting} ...${props}>
<p class="info"> <p class="info">
Hides vote counts from topics and comments of yourself or other people. Hides vote counts from topics and comments of yourself or other people.
</p> </p>
@ -53,5 +50,6 @@ export function HideVotesSetting(props: SettingProps): TRXComponent {
<ul class="checkbox-list"> <ul class="checkbox-list">
${checkboxes} ${checkboxes}
</ul> </ul>
<//>`; <//>
`;
} }

View File

@ -1,6 +1,8 @@
import {html} from 'htm/preact'; import {Component} from 'preact';
import {useContext} from 'preact/hooks'; import {useContext} from 'preact/hooks';
import {AppContext, TRXComponent} from '../..'; import {html} from 'htm/preact';
import {AppContext} from '../context.js';
export type SettingProps = { export type SettingProps = {
children: TRXComponent | undefined; children: TRXComponent | undefined;
@ -10,11 +12,14 @@ export type SettingProps = {
title: string; title: string;
}; };
function Header(props: SettingProps): TRXComponent { class Header extends Component<SettingProps> {
render() {
const {props} = this;
const context = useContext(AppContext); const context = useContext(AppContext);
const enabled = props.enabled ? 'Enabled' : 'Disabled'; const enabled = props.enabled ? 'Enabled' : 'Disabled';
return html`<header> return html`
<header>
<h2>${props.title}</h2> <h2>${props.title}</h2>
<button <button
onClick="${() => { onClick="${() => {
@ -23,7 +28,9 @@ function Header(props: SettingProps): TRXComponent {
> >
${enabled} ${enabled}
</button> </button>
</header>`; </header>
`;
}
} }
// A base component for all the settings, this adds the header and the // A base component for all the settings, this adds the header and the
@ -44,11 +51,3 @@ export function Setting(props: SettingProps): TRXComponent {
</section> </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,14 @@
import {html} from 'htm/preact';
import {Setting, SettingProps} from './index.js';
export function JumpToNewCommentSetting(props: SettingProps): TRXComponent {
return html`
<${Setting} ...${props}>
<p class="info">
Adds a hovering button to the bottom-right of pages with new comments
that, when clicked, will scroll you to the next new comment.
</p>
<//>
`;
}

View File

@ -0,0 +1,31 @@
import {html} from 'htm/preact';
import {Link} from '../../utilities/exports.js';
import {Setting, SettingProps} from './index.js';
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,42 @@
import {html} from 'htm/preact';
import {Setting, SettingProps} from './index.js';
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>
<//>
`;
}

11
source/options/context.ts Normal file
View File

@ -0,0 +1,11 @@
import {createContext} from 'preact';
import Settings from '../settings.js';
type AppContextValues = {
settings: Settings;
setActiveFeature: (feature: string) => void;
toggleFeature: (feature: string) => void;
};
export const AppContext = createContext<AppContextValues>(null!);

View File

@ -0,0 +1,61 @@
import {
AboutSetting,
AutocompleteSetting,
BackToTopSetting,
HideVotesSetting,
JumpToNewCommentSetting,
MarkdownToolbarSetting,
UserLabelsSetting,
} from './components/exports.js';
/**
* 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);

View File

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

161
source/options/options.ts Normal file
View File

@ -0,0 +1,161 @@
import {html} from 'htm/preact';
import {Component, render} from 'preact';
import Settings from '../settings.js';
import {
Link,
createReportTemplate,
initializeGlobals,
} from '../utilities/exports.js';
import {AppContext} from './context.js';
import {features} from './features.js';
window.addEventListener('load', async () => {
initializeGlobals();
const settings = await Settings.fromSyncStorage();
render(
html`<${App} manifest=${settings.manifest()} settings=${settings} />`,
document.body,
);
});
type Props = {
manifest: TRXManifest;
settings: Settings;
};
type State = {
activeFeature: string;
enabledFeatures: Set<string>;
};
class App extends Component<Props, State> {
state: State;
constructor(props: Props) {
super(props);
const {settings} = props;
this.state = {
activeFeature: settings.data.latestActiveFeatureTab,
enabledFeatures: this.getEnabledFeatures(),
};
}
getEnabledFeatures = (): Set<string> => {
return new Set(
Object.entries(this.props.settings.features)
.filter(([_, value]) => value)
.map(([key, _]) => key),
);
};
setActiveFeature = (feature: string) => {
const {settings} = this.props;
settings.data.latestActiveFeatureTab = feature;
void settings.save();
this.setState({activeFeature: feature});
};
toggleFeature = (feature: string) => {
const {settings} = this.props;
settings.features[feature] = !settings.features[feature];
void settings.save();
const features = this.getEnabledFeatures();
this.setState({enabledFeatures: features});
};
render() {
const {manifest, settings} = this.props;
const {activeFeature, enabledFeatures} = this.state;
// Create the version link for the header.
const version = manifest.version;
const versionURL = encodeURI(
`https://gitlab.com/tildes-community/tildes-reextended/-/tags/${version}`,
);
const versionLink = html`
<${Link} class="version" text="v${version}" url="${versionURL}" />
`;
// Create the GitLab report a bug link for the footer.
const gitlabTemplate = createReportTemplate('gitlab', version);
const gitlabURL = encodeURI(
`https://gitlab.com/tildes-community/tildes-reextended/issues/new?issue[description]=${gitlabTemplate}`,
);
const gitlabLink = html`<${Link} text="GitLab" url="${gitlabURL}" />`;
// Create the Tildes report a bug link for the footer.
const tildesReportTemplate = createReportTemplate('tildes', version);
const tildesURL = encodeURI(
`https://tildes.net/user/Community/new_message?subject=Tildes ReExtended Bug&message=${tildesReportTemplate}`,
);
const tildesLink = html`<${Link} text="Tildes" url="${tildesURL}" />`;
const asideElements = features.map(
({key, value}) =>
html`
<li
key=${key}
class="${activeFeature === key ? 'active' : ''}
${enabledFeatures.has(key) ? 'enabled' : ''}"
onClick="${() => {
this.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: this.setActiveFeature,
toggleFeature: this.toggleFeature,
}}
>
<header class="page-header">
<h1>
<img src="../assets/tildes-reextended-128.png" />
Tildes ReExtended
</h1>
${versionLink}
</header>
<div class="main-wrapper">
<aside class="page-aside">
<ul>
${asideElements}
</ul>
</aside>
<main class="page-main">${mainElements}</main>
</div>
<footer class="page-footer">
<p>Report a bug via ${gitlabLink} or ${tildesLink}.</p>
<p>© Tildes Community and Contributors</p>
</footer>
<//>
`;
}
}

View File

@ -1,7 +1,9 @@
import {offset, Offset} from 'caret-pos'; import {offset, Offset} from 'caret-pos';
import {html} from 'htm/preact'; import {html} from 'htm/preact';
import {Component} from 'preact'; import {Component} from 'preact';
import {log, querySelectorAll, Settings} from '..';
import Settings from '../settings.js';
import {log, querySelectorAll} from '../utilities/exports.js';
type Props = { type Props = {
settings: Settings; settings: Settings;
@ -11,11 +13,11 @@ type State = {
groups: Set<string>; groups: Set<string>;
groupsHidden: boolean; groupsHidden: boolean;
groupsMatches: Set<string>; groupsMatches: Set<string>;
groupsPosition: Offset | null; groupsPosition: Offset | undefined;
usernames: Set<string>; usernames: Set<string>;
usernamesHidden: boolean; usernamesHidden: boolean;
usernamesMatches: Set<string>; usernamesMatches: Set<string>;
usernamesPosition: Offset | null; usernamesPosition: Offset | undefined;
}; };
export class AutocompleteFeature extends Component<Props, State> { export class AutocompleteFeature extends Component<Props, State> {
@ -24,27 +26,27 @@ export class AutocompleteFeature extends Component<Props, State> {
// Get all the groups without their leading tildes. // Get all the groups without their leading tildes.
const groups = props.settings.data.knownGroups.map((value) => const groups = props.settings.data.knownGroups.map((value) =>
value.startsWith('~') ? value.slice(1) : value value.startsWith('~') ? value.slice(1) : value,
); );
// Get all the usernames on the page without their leading @s, and get // Get all the usernames on the page without their leading @s, and get
// all the username from the saved user labels. // all the username from the saved user labels.
const usernames = [ const usernames = [
...querySelectorAll('.link-user').map((value) => ...querySelectorAll('.link-user').map((value) =>
value.textContent!.replace(/^@/, '').toLowerCase() value.textContent!.replace(/^@/, '').toLowerCase(),
), ),
...props.settings.data.userLabels.map((value) => value.username) ...props.settings.data.userLabels.map((value) => value.username),
].sort((a, b) => a.localeCompare(b)); ].sort((a, b) => a.localeCompare(b));
this.state = { this.state = {
groups: new Set(groups), groups: new Set(groups),
groupsHidden: true, groupsHidden: true,
groupsMatches: new Set(groups), groupsMatches: new Set(groups),
groupsPosition: null, groupsPosition: undefined,
usernames: new Set(usernames), usernames: new Set(usernames),
usernamesHidden: true, usernamesHidden: true,
usernamesMatches: new Set(usernames), usernamesMatches: new Set(usernames),
usernamesPosition: null usernamesPosition: undefined,
}; };
// Add a keydown listener for the entire page. // Add a keydown listener for the entire page.
@ -52,7 +54,7 @@ export class AutocompleteFeature extends Component<Props, State> {
log( log(
`Autocomplete: Initialized with ${this.state.groups.size} groups and ` + `Autocomplete: Initialized with ${this.state.groups.size} groups and ` +
`${this.state.usernames.size} usernames.` `${this.state.usernames.size} usernames.`,
); );
} }
@ -67,7 +69,7 @@ export class AutocompleteFeature extends Component<Props, State> {
const createHandler = ( const createHandler = (
prefix: string, prefix: string,
target: string, target: string,
values: Set<string> values: Set<string>,
) => { ) => {
const dataAttribute = `data-trx-autocomplete-${target}`; const dataAttribute = `data-trx-autocomplete-${target}`;
@ -89,7 +91,7 @@ export class AutocompleteFeature extends Component<Props, State> {
event: KeyboardEvent, event: KeyboardEvent,
prefix: string, prefix: string,
target: string, target: string,
values: Set<string> values: Set<string>,
) => { ) => {
const textarea = event.target as HTMLTextAreaElement; const textarea = event.target as HTMLTextAreaElement;
const text = textarea.value; const text = textarea.value;
@ -120,7 +122,7 @@ export class AutocompleteFeature extends Component<Props, State> {
// Find all the values that match the input using `includes`. // Find all the values that match the input using `includes`.
const matches = new Set<string>( const matches = new Set<string>(
[...values].filter((value) => value.includes(input.toLowerCase())) [...values].filter((value) => value.includes(input.toLowerCase())),
); );
// If there are no matches, return early. // If there are no matches, return early.
@ -138,11 +140,11 @@ export class AutocompleteFeature extends Component<Props, State> {
update = (target: string, matches: Set<string>) => { update = (target: string, matches: Set<string>) => {
if (target === 'groups') { if (target === 'groups') {
this.setState({ this.setState({
groupsMatches: matches groupsMatches: matches,
}); });
} else if (target === 'usernames') { } else if (target === 'usernames') {
this.setState({ this.setState({
usernamesMatches: matches usernamesMatches: matches,
}); });
} }
}; };
@ -151,12 +153,12 @@ export class AutocompleteFeature extends Component<Props, State> {
if (target === 'groups') { if (target === 'groups') {
this.setState({ this.setState({
groupsHidden: false, groupsHidden: false,
groupsPosition: position groupsPosition: position,
}); });
} else if (target === 'usernames') { } else if (target === 'usernames') {
this.setState({ this.setState({
usernamesHidden: false, usernamesHidden: false,
usernamesPosition: position usernamesPosition: position,
}); });
} }
}; };
@ -172,10 +174,10 @@ export class AutocompleteFeature extends Component<Props, State> {
render() { render() {
// Create the list of groups and usernames. // Create the list of groups and usernames.
const groups = [...this.state.groupsMatches].map( const groups = [...this.state.groupsMatches].map(
(value) => html`<li>~${value}</li>` (value) => html`<li>~${value}</li>`,
); );
const usernames = [...this.state.usernamesMatches].map( const usernames = [...this.state.usernamesMatches].map(
(value) => html`<li>@${value}</li>` (value) => html`<li>@${value}</li>`,
); );
// Create the CSS class whether or not to hide the autocomplete. // Create the CSS class whether or not to hide the autocomplete.

View File

@ -1,7 +1,8 @@
import debounce from 'debounce'; import debounce from 'debounce';
import {html} from 'htm/preact'; import {html} from 'htm/preact';
import {Component} from 'preact'; import {Component} from 'preact';
import {log} from '..';
import {log} from '../utilities/exports.js';
type Props = Record<string, unknown>; type Props = Record<string, unknown>;
@ -13,7 +14,7 @@ export class BackToTopFeature extends Component<Props, State> {
constructor() { constructor() {
super(); super();
this.state = { this.state = {
hidden: true hidden: true,
}; };
// Add a "debounced" handler to the scroll listener, this will make it so // Add a "debounced" handler to the scroll listener, this will make it so
@ -37,12 +38,14 @@ export class BackToTopFeature extends Component<Props, State> {
render() { render() {
const hidden = this.state.hidden ? 'trx-hidden' : ''; const hidden = this.state.hidden ? 'trx-hidden' : '';
return html`<a return html`
<a
id="trx-back-to-top" id="trx-back-to-top"
class="btn btn-primary ${hidden}" class="btn btn-primary ${hidden}"
onClick=${this.scrollToTop} onClick=${this.scrollToTop}
> >
Back To Top Back To Top
</a>`; </a>
`;
} }
} }

View File

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

View File

@ -1,4 +1,5 @@
import {log, querySelectorAll, Settings} from '..'; import Settings from '../settings.js';
import {log, querySelectorAll} from '../utilities/exports.js';
export function runHideVotesFeature(settings: Settings) { export function runHideVotesFeature(settings: Settings) {
const observer = new window.MutationObserver(() => { const observer = new window.MutationObserver(() => {
@ -10,7 +11,7 @@ export function runHideVotesFeature(settings: Settings) {
function startObserver() { function startObserver() {
observer.observe(document, { observer.observe(document, {
childList: true, childList: true,
subtree: true subtree: true,
}); });
} }
@ -24,14 +25,14 @@ function hideVotes(settings: Settings) {
if (settings.data.hideVotes.comments) { if (settings.data.hideVotes.comments) {
const commentVotes = querySelectorAll( const commentVotes = querySelectorAll(
'.btn-post-action[data-ic-put-to*="/vote"]:not(.trx-votes-hidden)', '.btn-post-action[data-ic-put-to*="/vote"]:not(.trx-votes-hidden)',
'.btn-post-action[data-ic-delete-from*="/vote"]:not(.trx-votes-hidden)' '.btn-post-action[data-ic-delete-from*="/vote"]:not(.trx-votes-hidden)',
); );
for (const vote of commentVotes) { for (const vote of commentVotes) {
vote.classList.add('trx-votes-hidden'); vote.classList.add('trx-votes-hidden');
vote.textContent = vote.textContent!.slice( vote.textContent = vote.textContent!.slice(
0, 0,
vote.textContent!.indexOf(' ') vote.textContent!.indexOf(' '),
); );
} }
} }

View File

@ -1,6 +0,0 @@
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

@ -1,13 +1,14 @@
import {html} from 'htm/preact'; import {html} from 'htm/preact';
import {Component} from 'preact'; import {Component} from 'preact';
import {log, querySelector, querySelectorAll} from '..';
import {log, querySelector, querySelectorAll} from '../utilities/exports.js';
type Props = Record<string, unknown>; type Props = Record<string, unknown>;
type State = { type State = {
hidden: boolean; hidden: boolean;
newCommentCount: number; newCommentCount: number;
previousComment: HTMLElement | null; previousComment: HTMLElement | undefined;
}; };
export class JumpToNewCommentFeature extends Component<Props, State> { export class JumpToNewCommentFeature extends Component<Props, State> {
@ -19,14 +20,14 @@ export class JumpToNewCommentFeature extends Component<Props, State> {
this.state = { this.state = {
hidden: false, hidden: false,
newCommentCount, newCommentCount,
previousComment: null previousComment: undefined,
}; };
if (newCommentCount === 0) { if (newCommentCount === 0) {
log('Jump To New Comment: 0 new comments found, not doing anything.'); log('Jump To New Comment: 0 new comments found, not doing anything.');
} else { } else {
log( log(
`Jump To New Comment: Initialized for ${newCommentCount} new comments.` `Jump To New Comment: Initialized for ${newCommentCount} new comments.`,
); );
} }
} }
@ -37,7 +38,7 @@ export class JumpToNewCommentFeature extends Component<Props, State> {
this.state.previousComment?.classList.remove('is-comment-new'); this.state.previousComment?.classList.remove('is-comment-new');
const newestComment = document.querySelector<HTMLElement>( const newestComment = document.querySelector<HTMLElement>(
'.comment.is-comment-new' '.comment.is-comment-new',
); );
// If there are no new comments left, hide the button. // If there are no new comments left, hide the button.
@ -67,12 +68,14 @@ export class JumpToNewCommentFeature extends Component<Props, State> {
const commentsLeft = querySelectorAll('.comment.is-comment-new').length; const commentsLeft = querySelectorAll('.comment.is-comment-new').length;
const hidden = this.state.hidden ? 'trx-hidden' : ''; const hidden = this.state.hidden ? 'trx-hidden' : '';
return html`<a return html`
<a
id="trx-jump-to-new-comment" id="trx-jump-to-new-comment"
class="btn btn-primary ${hidden}" class="btn btn-primary ${hidden}"
onClick="${this.jump}" onClick="${this.jump}"
> >
Jump To New Comment (${commentsLeft}/${this.state.newCommentCount}) Jump To New Comment (${commentsLeft}/${this.state.newCommentCount})
</a>`; </a>
`;
} }
} }

View File

@ -1,6 +1,7 @@
import {html} from 'htm/preact'; import {html} from 'htm/preact';
import {render} from 'preact'; import {render} from 'preact';
import {log, querySelectorAll, TRXComponent} from '..';
import {log, querySelectorAll} from '../utilities/exports.js';
type MarkdownSnippet = { type MarkdownSnippet = {
dropdown: boolean; dropdown: boolean;
@ -13,64 +14,64 @@ const snippets: MarkdownSnippet[] = [
{ {
dropdown: false, dropdown: false,
markdown: '[<>]()', markdown: '[<>]()',
name: 'Link' name: 'Link',
}, },
{ {
dropdown: false, dropdown: false,
markdown: '```\n<>\n```', markdown: '```\n<>\n```',
name: 'Code' name: 'Code',
}, },
{ {
dropdown: false, dropdown: false,
markdown: '~~<>~~', markdown: '~~<>~~',
name: 'Strikethrough' name: 'Strikethrough',
}, },
{ {
dropdown: false, dropdown: false,
markdown: markdown:
'<details>\n<summary>Click to expand spoiler.</summary>\n\n<>\n</details>', '<details>\n<summary>Click to expand spoiler.</summary>\n\n<>\n</details>',
name: 'Spoilerbox' name: 'Spoilerbox',
}, },
{ {
dropdown: true, dropdown: true,
markdown: '**<>**', markdown: '**<>**',
name: 'Bold' name: 'Bold',
}, },
{ {
dropdown: true, dropdown: true,
markdown: '\n\n---\n\n<>', markdown: '\n\n---\n\n<>',
name: 'Horizontal Divider' name: 'Horizontal Divider',
}, },
{ {
dropdown: true, dropdown: true,
markdown: '`<>`', markdown: '`<>`',
name: 'Inline Code' name: 'Inline Code',
}, },
{ {
dropdown: true, dropdown: true,
markdown: '*<>*', markdown: '*<>*',
name: 'Italic' name: 'Italic',
}, },
{ {
dropdown: true, dropdown: true,
markdown: '1. <>', markdown: '1. <>',
name: 'Ordered List' name: 'Ordered List',
}, },
{ {
dropdown: true, dropdown: true,
markdown: '<small><></small>', markdown: '<small><></small>',
name: 'Small' name: 'Small',
}, },
{ {
dropdown: true, dropdown: true,
markdown: '* <>', markdown: '* <>',
name: 'Unordered List' name: 'Unordered List',
} },
].map(({dropdown, markdown, name}) => ({ ].map(({dropdown, markdown, name}) => ({
dropdown, dropdown,
name, name,
index: markdown.indexOf('<>'), index: markdown.indexOf('<>'),
markdown: markdown.replace(/<>/, '') markdown: markdown.replace(/<>/, ''),
})); }));
export function runMarkdownToolbarFeature() { export function runMarkdownToolbarFeature() {
@ -85,7 +86,7 @@ export function runMarkdownToolbarFeature() {
function startObserver() { function startObserver() {
observer.observe(document, { observer.observe(document, {
childList: true, childList: true,
subtree: true subtree: true,
}); });
} }
@ -109,14 +110,14 @@ function addToolbarsToTextareas() {
const menu = form.querySelector<HTMLElement>('.tab-markdown-mode')!; const menu = form.querySelector<HTMLElement>('.tab-markdown-mode')!;
const textarea = form.querySelector<HTMLElement>( const textarea = form.querySelector<HTMLElement>(
'textarea[name="markdown"]' 'textarea[name="markdown"]',
)!; )!;
const snippetButtons = snippets const snippetButtons = snippets
.filter((snippet) => !snippet.dropdown) .filter((snippet) => !snippet.dropdown)
.map( .map(
(snippet) => (snippet) =>
html`<${snippetButton} snippet=${snippet} textarea=${textarea} />` html`<${snippetButton} snippet=${snippet} textarea=${textarea} />`,
); );
// Render the buttons inside the tab menu so they appear // Render the buttons inside the tab menu so they appear
@ -132,7 +133,7 @@ function addToolbarsToTextareas() {
render( render(
html`<${snippetDropdown} textarea=${textarea} />`, html`<${snippetDropdown} textarea=${textarea} />`,
menuParent, menuParent,
dropdownPlaceholder dropdownPlaceholder,
); );
} }
} }
@ -148,37 +149,41 @@ function snippetButton(props: Required<Props>): TRXComponent {
insertSnippet(props); insertSnippet(props);
}; };
return html`<li class="tab-item"> return html`
<li class="tab-item">
<button class="btn btn-link" onClick="${click}"> <button class="btn btn-link" onClick="${click}">
${props.snippet.name} ${props.snippet.name}
</button> </button>
</li>`; </li>
`;
} }
function snippetDropdown(props: Props): TRXComponent { function snippetDropdown(props: Props): TRXComponent {
const options = snippets.map( const options = snippets.map(
(snippet) => html`<option value="${snippet.name}">${snippet.name}</option>` (snippet) => html`<option value="${snippet.name}">${snippet.name}</option>`,
); );
const change = (event: Event) => { const change = (event: Event) => {
event.preventDefault(); event.preventDefault();
const snippet = snippets.find( const snippet = snippets.find(
(value) => value.name === (event.target as HTMLSelectElement).value (value) => value.name === (event.target as HTMLSelectElement).value,
)!; )!;
insertSnippet({ insertSnippet({
...props, ...props,
snippet snippet,
}); });
(event.target as HTMLSelectElement).selectedIndex = 0; (event.target as HTMLSelectElement).selectedIndex = 0;
}; };
return html`<select class="form-select" onChange=${change}> return html`
<select class="form-select" onChange=${change}>
<option>More</option> <option>More</option>
${options} ${options}
</select>`; </select>
`;
} }
function insertSnippet(props: Required<Props>) { function insertSnippet(props: Required<Props>) {

View File

@ -1,16 +1,16 @@
import debounce from 'debounce'; import debounce from 'debounce';
import {Component, render} from 'preact'; import {Component, render} from 'preact';
import {html} from 'htm/preact'; import {html} from 'htm/preact';
import Settings from '../settings.js';
import { import {
createElementFromString, createElementFromString,
isColorBright, isColorBright,
isValidHexColor, isValidHexColor,
log, log,
querySelectorAll, querySelectorAll,
setSettings, themeColors,
Settings, } from '../utilities/exports.js';
themeColors
} from '..';
type Props = { type Props = {
settings: Settings; settings: Settings;
@ -20,9 +20,9 @@ type State = {
color: string; color: string;
selectedColor: string; selectedColor: string;
hidden: boolean; hidden: boolean;
id: number | null; id: number | undefined;
priority: number; priority: number;
target: HTMLElement | null; target: HTMLElement | undefined;
text: string; text: string;
username: string; username: string;
}; };
@ -33,7 +33,7 @@ const colorPattern: string = [
'[a-f\\d]{6}|', '[a-f\\d]{6}|',
'[a-f\\d]{4}|', '[a-f\\d]{4}|',
'[a-f\\d]{3})', '[a-f\\d]{3})',
'|transparent)$' // "Transparent" is also allowed in the input. '|transparent)$', // "Transparent" is also allowed in the input.
].join(''); ].join('');
export class UserLabelsFeature extends Component<Props, State> { export class UserLabelsFeature extends Component<Props, State> {
@ -48,12 +48,12 @@ export class UserLabelsFeature extends Component<Props, State> {
this.state = { this.state = {
color: selectedColor, color: selectedColor,
hidden: true, hidden: true,
id: null, id: undefined,
text: '', text: '',
priority: 0, priority: 0,
selectedColor, selectedColor,
target: null, target: undefined,
username: '' username: '',
}; };
const count = this.addLabelsToUsernames(querySelectorAll('.link-user')); const count = this.addLabelsToUsernames(querySelectorAll('.link-user'));
@ -94,7 +94,7 @@ export class UserLabelsFeature extends Component<Props, State> {
const userLabels = sortedLabels.filter( const userLabels = sortedLabels.filter(
(value) => (value) =>
value.username === username && value.username === username &&
(onlyID === undefined ? true : value.id === onlyID) (onlyID === undefined ? true : value.id === onlyID),
); );
const addLabel = html` const addLabel = html`
@ -174,10 +174,10 @@ export class UserLabelsFeature extends Component<Props, State> {
target, target,
username, username,
color: selectedColor, color: selectedColor,
id: null, id: undefined,
text: '', text: '',
priority: 0, priority: 0,
selectedColor selectedColor,
}); });
} }
}; };
@ -190,12 +190,12 @@ export class UserLabelsFeature extends Component<Props, State> {
this.hide(); this.hide();
} else { } else {
const label = this.props.settings.data.userLabels.find( const label = this.props.settings.data.userLabels.find(
(value) => value.id === id (value) => value.id === id,
); );
if (label === undefined) { if (label === undefined) {
log( log(
'User Labels: Tried to edit label with ID that could not be found.', 'User Labels: Tried to edit label with ID that could not be found.',
true true,
); );
return; return;
} }
@ -203,7 +203,7 @@ export class UserLabelsFeature extends Component<Props, State> {
this.setState({ this.setState({
hidden: false, hidden: false,
target, target,
...label ...label,
}); });
} }
}; };
@ -233,11 +233,11 @@ export class UserLabelsFeature extends Component<Props, State> {
priorityChange = (event: Event) => { priorityChange = (event: Event) => {
this.setState({ this.setState({
priority: Number((event.target as HTMLInputElement).value) priority: Number((event.target as HTMLInputElement).value),
}); });
}; };
save = (event: MouseEvent) => { save = async (event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
const {color, id, text, priority, username} = this.state; const {color, id, text, priority, username} = this.state;
if (color === '' || username === '') { if (color === '' || username === '') {
@ -247,7 +247,7 @@ export class UserLabelsFeature extends Component<Props, State> {
const {settings} = this.props; const {settings} = this.props;
// If no ID is present then save a new label otherwise edit the existing one. // If no ID is present then save a new label otherwise edit the existing one.
if (id === null) { if (id === undefined) {
let newID = 1; let newID = 1;
if (settings.data.userLabels.length > 0) { if (settings.data.userLabels.length > 0) {
newID = settings.data.userLabels.sort((a, b) => b.id - a.id)[0].id + 1; newID = settings.data.userLabels.sort((a, b) => b.id - a.id)[0].id + 1;
@ -258,13 +258,13 @@ export class UserLabelsFeature extends Component<Props, State> {
id: newID, id: newID,
priority, priority,
text, text,
username username,
}); });
this.addLabelsToUsernames(querySelectorAll('.link-user'), newID); this.addLabelsToUsernames(querySelectorAll('.link-user'), newID);
} else { } else {
const index = settings.data.userLabels.findIndex( const index = settings.data.userLabels.findIndex(
(value) => value.id === id (value) => value.id === id,
); );
settings.data.userLabels.splice(index, 1); settings.data.userLabels.splice(index, 1);
settings.data.userLabels.push({ settings.data.userLabels.push({
@ -272,7 +272,7 @@ export class UserLabelsFeature extends Component<Props, State> {
color, color,
priority, priority,
text, text,
username username,
}); });
const elements = querySelectorAll(`[data-trx-label-id="${id}"]`); const elements = querySelectorAll(`[data-trx-label-id="${id}"]`);
@ -288,27 +288,27 @@ export class UserLabelsFeature extends Component<Props, State> {
} }
} }
void setSettings(settings); await settings.save();
this.props.settings = settings; this.props.settings = settings;
this.hide(); this.hide();
}; };
remove = (event: MouseEvent) => { remove = async (event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
const {id} = this.state; const {id} = this.state;
if (id === null) { if (id === undefined) {
log('User Labels: Tried remove label when ID was null.'); log('User Labels: Tried remove label when ID was undefined.');
return; return;
} }
const {settings} = this.props; const {settings} = this.props;
const index = settings.data.userLabels.findIndex( const index = settings.data.userLabels.findIndex(
(value) => value.id === id (value) => value.id === id,
); );
if (index === undefined) { if (index === undefined) {
log( log(
`User Labels: Tried to remove label with ID ${id} that could not be found.`, `User Labels: Tried to remove label with ID ${id} that could not be found.`,
true true,
); );
return; return;
} }
@ -318,7 +318,7 @@ export class UserLabelsFeature extends Component<Props, State> {
} }
settings.data.userLabels.splice(index, 1); settings.data.userLabels.splice(index, 1);
void setSettings(settings); await settings.save();
this.props.settings = settings; this.props.settings = settings;
this.hide(); this.hide();
}; };
@ -331,7 +331,7 @@ export class UserLabelsFeature extends Component<Props, State> {
<option value="${bodyStyle.getPropertyValue(value).trim()}"> <option value="${bodyStyle.getPropertyValue(value).trim()}">
${name} ${name}
</option> </option>
` `,
); );
const bright = isColorBright(this.state.color) ? 'trx-bright' : ''; const bright = isColorBright(this.state.color) ? 'trx-bright' : '';
@ -342,7 +342,7 @@ export class UserLabelsFeature extends Component<Props, State> {
let left = 0; let left = 0;
const target = this.state.target; const target = this.state.target;
if (target !== null) { if (target !== undefined) {
const bounds = target.getBoundingClientRect(); const bounds = target.getBoundingClientRect();
top = bounds.y + bounds.height + 4 + window.scrollY; top = bounds.y + bounds.height + 4 + window.scrollY;
left = bounds.x + window.scrollX; left = bounds.x + window.scrollX;
@ -351,7 +351,8 @@ export class UserLabelsFeature extends Component<Props, State> {
const position = `left: ${left}px; top: ${top}px;`; const position = `left: ${left}px; top: ${top}px;`;
const previewStyle = `background-color: ${color}`; const previewStyle = `background-color: ${color}`;
return html`<form class="trx-user-label-form ${hidden}" style="${position}"> return html`
<form class="trx-user-label-form ${hidden}" style="${position}">
<div class="trx-label-username-priority"> <div class="trx-label-username-priority">
<label class="trx-label-username"> <label class="trx-label-username">
Add New Label Add New Label
@ -425,6 +426,7 @@ export class UserLabelsFeature extends Component<Props, State> {
<a class="btn-post-action" onClick=${this.hide}>Close</a> <a class="btn-post-action" onClick=${this.hide}>Close</a>
<a class="btn-post-action" onClick=${this.remove}>Remove</a> <a class="btn-post-action" onClick=${this.remove}>Remove</a>
</div> </div>
</form>`; </form>
`;
} }
} }

View File

@ -1,3 +1,5 @@
@use 'sass:color';
body { body {
$accents: ( $accents: (
'red' #dc322f, 'red' #dc322f,
@ -10,14 +12,16 @@ body {
'magenta' #d33682, 'magenta' #d33682,
); );
--background-primary: #{adjust-color(#002b36, $lightness: -5%)}; --background-primary: #{color.adjust(#002b36, $lightness: -5%)};
--background-secondary: #002b36; --background-secondary: #002b36;
--background-tertiary: #000; --background-tertiary: #000;
--foreground: #fdf6e3; --foreground: #fdf6e3;
@each $name, $color in $accents { @each $name, $color in $accents {
/* stylelint-disable custom-property-pattern */
--#{$name}: #{$color}; --#{$name}: #{$color};
--light-#{$name}: #{adjust-color($color, $lightness: 10%)}; --light-#{$name}: #{color.adjust($color, $lightness: 10%)};
--dark-#{$name}: #{adjust-color($color, $lightness: -10%)}; --dark-#{$name}: #{color.adjust($color, $lightness: -10%)};
/* stylelint-enable */
} }
} }

View File

@ -62,6 +62,7 @@
.button { .button {
--button-color: var(--blue); --button-color: var(--blue);
--button-color-alt: var(--dark-blue); --button-color-alt: var(--dark-blue);
background-color: var(--button-color); background-color: var(--button-color);
border: none; border: none;
color: var(--foreground); color: var(--foreground);

View File

@ -95,7 +95,7 @@ details {
.main-wrapper { .main-wrapper {
display: grid; display: grid;
gap: 16px; gap: 16px;
grid-template-columns: $large-breakpoint / 4 auto; grid-template-columns: calc($large-breakpoint / 4) auto;
} }
.page-aside { .page-aside {
@ -142,5 +142,6 @@ details {
padding: 16px; padding: 16px;
} }
/* stylelint-disable no-invalid-position-at-import-rule */
@import 'shared'; @import 'shared';
@import 'settings'; @import 'settings';

View File

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

View File

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

View File

@ -1,145 +0,0 @@
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!

144
source/settings.ts Normal file
View File

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

View File

@ -1,112 +0,0 @@
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<HTMLElement>('#import-settings')!.click();
};
const communityLink = html`
<${Link}
url="https://gitlab.com/tildes-community"
text="Tildes Community project"
/>
`;
const criusLink = html`
<${Link} url="https://tildes.net/user/crius" text="Crius" />
`;
const gitlabIssuesLink = html`
<${Link}
url="https://gitlab.com/tildes-community/tildes-reextended/-/issues"
text="GitLab issue tracker"
/>
`;
const gitlabLicenseLink = html`
<${Link}
url="https://gitlab.com/tildes-community/tildes-reextended/blob/main/LICENSE"
text="MIT License"
/>
`;
const messageCommunityLink = html`
<${Link}
url="https://tildes.net/user/Community/new_message"
text="message Community"
/>
`;
const tildesExtendedLink = html`
<${Link}
url="https://github.com/theCrius/tildes-extended"
text="Tildes Extended"
/>
`;
return html`<${Setting} ...${props}>
<p class="info">
This feature will make debugging logs output to the console when enabled.
</p>
<p>
Tildes ReExtended is a from-scratch recreation of the original${' '}
${tildesExtendedLink} web extension by ${criusLink}. Open-sourced${' '}
with the ${gitlabLicenseLink} and maintained as a ${communityLink}.
</p>
<p>
To report bugs or request new features use the links at the bottom of this
page, check out the ${gitlabIssuesLink} or ${messageCommunityLink}${' '}
on Tildes.
</p>
<div class="divider" />
<div class="import-export">
<p>
Note that importing settings will delete and overwrite your existing
ones.
</p>
<input
id="import-settings"
onChange=${importFileHandler}
class="trx-hidden"
accept="application/json"
type="file"
/>
<button onClick=${importSettings} class="button">Import Settings</button>
<button onClick=${exportSettings} class="button">Export Settings</button>
</div>
<div class="divider" />
<details class="misc-utilities">
<summary>Danger Zone</summary>
<div class="inner">
<button onClick=${logSettings} class="button">Log Settings</button>
<button onClick=${removeAllData} class="button destructive">
Remove All Data
</button>
</div>
</details>
<//>`;
}

View File

@ -1,11 +0,0 @@
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

@ -1,12 +0,0 @@
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

@ -1,11 +0,0 @@
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

@ -1,27 +0,0 @@
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

@ -1,39 +0,0 @@
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>
<//>`;
}

View File

@ -1,125 +0,0 @@
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: [],
version: '0.0.0'
},
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);

View File

@ -1,30 +0,0 @@
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,83 +0,0 @@
import {
getSettings,
log,
isValidHexColor,
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) {
log('No file imported.');
return;
}
const reader = new window.FileReader();
reader.addEventListener(
'load',
async (): Promise<void> => {
let data: Partial<Settings>;
try {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
data = JSON.parse(reader.result!.toString());
} catch (error) {
log(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' ||
!isValidTildesUsername(label.username)
) {
log(`Invalid username in imported labels: ${label.username}`);
continue;
}
newSettings.data.userLabels.push({
color: isValidHexColor(label.color) ? label.color : '#f0f',
id: newSettings.data.userLabels.length + 1,
priority: Number.isNaN(label.priority) ? 0 : label.priority,
text: typeof label.text === 'undefined' ? 'Label' : label.text,
username: label.username
});
}
}
if (typeof data.data.hideVotes !== 'undefined') {
newSettings.data.hideVotes = data.data.hideVotes;
}
}
if (typeof data.features !== 'undefined') {
newSettings.features = {...data.features};
}
await setSettings(newSettings);
log('Successfully imported your settings, reloading the page to apply.');
setTimeout(() => {
window.location.reload();
}, 1000);
}
);
reader.addEventListener('error', (): void => {
log(reader.error, true);
reader.abort();
});
reader.readAsText(fileList[0]);
}

View File

@ -1,115 +0,0 @@
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}
};
if (window?.TildesReExtended !== undefined) {
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';

35
source/types.d.ts vendored
View File

@ -1,15 +1,30 @@
// A fix for TypeScript so it sees this file as a module and thus allows to import {html} from 'htm/preact';
// modify the global scope. import browser from 'webextension-polyfill';
export {};
// See the initialize function located in `utilities/index.ts` for actual code.
type TildesReExtended = {
debug: boolean;
};
declare global { declare global {
interface Window { interface Window {
TildesReExtended: TildesReExtended; TildesReExtended: {
debug: boolean;
};
} }
interface ImportMetaEnv {
readonly DEV: boolean;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
type TRXComponent = ReturnType<typeof html>;
type TRXManifest = browser.Manifest.ManifestBase;
type UserLabel = {
color: string;
id: number;
priority: number;
text: string;
username: string;
};
} }

View File

@ -1,7 +1,4 @@
/** /** Returns whether a hex color is "bright". */
* Returns whether a hex color is "bright".
* @param color The hex color.
*/
export function isColorBright(color: string): boolean { export function isColorBright(color: string): boolean {
if (color.startsWith('#')) { if (color.startsWith('#')) {
color = color.slice(1); color = color.slice(1);
@ -40,50 +37,50 @@ export function isColorBright(color: string): boolean {
return brightness > 128; return brightness > 128;
} }
// CSS custom properties from the Tildes themes. /** CSS custom properties from the Tildes themes. */
export const themeColors = [ export const themeColors = [
{ {
name: 'Background Primary', name: 'Background Primary',
value: '--background-primary-color' value: '--background-primary-color',
}, },
{ {
name: 'Background Secondary', name: 'Background Secondary',
value: '--background-secondary-color' value: '--background-secondary-color',
}, },
{ {
name: 'Foreground Primary', name: 'Foreground Primary',
value: '--foreground-primary-color' value: '--foreground-primary-color',
}, },
{ {
name: 'Foreground Secondary', name: 'Foreground Secondary',
value: '--foreground-secondary-color' value: '--foreground-secondary-color',
}, },
{ {
name: 'Exemplary', name: 'Exemplary',
value: '--comment-label-exemplary-color' value: '--comment-label-exemplary-color',
}, },
{ {
name: 'Off-topic', name: 'Off-topic',
value: '--comment-label-offtopic-color' value: '--comment-label-offtopic-color',
}, },
{ {
name: 'Joke', name: 'Joke',
value: '--comment-label-joke-color' value: '--comment-label-joke-color',
}, },
{ {
name: 'Noise', name: 'Noise',
value: '--comment-label-noise-color' value: '--comment-label-noise-color',
}, },
{ {
name: 'Malice', name: 'Malice',
value: '--comment-label-malice-color' value: '--comment-label-malice-color',
}, },
{ {
name: 'Mine', name: 'Mine',
value: '--stripe-mine-color' value: '--stripe-mine-color',
}, },
{ {
name: 'Official', name: 'Official',
value: '--alert-color' value: '--alert-color',
} },
]; ];

View File

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

View File

@ -0,0 +1,9 @@
/**
* Creates an HTML Element from a given string. Only use this when using
* `htm/preact` isn't practical.
*/
export function createElementFromString<T extends Element>(input: string): T {
const template = document.createElement('template');
template.innerHTML = input.trim();
return template.content.firstElementChild as T;
}

View File

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

View File

@ -0,0 +1,7 @@
export function initializeGlobals() {
if (window.TildesReExtended === undefined) {
window.TildesReExtended = {
debug: false,
};
}
}

View File

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

View File

@ -1,51 +0,0 @@
/**
* 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';
export * from './version';

View File

@ -0,0 +1,21 @@
/**
* 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 || import.meta.env.DEV || force) {
if (overrideStyle.length > 0) {
console.debug(prefix, overrideStyle, thing);
} else {
console.debug(prefix, thing);
}
}
}

View File

@ -4,22 +4,16 @@
// The first function should only ever be used when we know for certain that // The first function should only ever be used when we know for certain that
// the target element is going to exist. // the target element is going to exist.
/** /** Returns the first element found that matches the selector. */
* Returns the first element found that matches the selector.
* @param selector The selector.
*/
export function querySelector<T extends Element>(selector: string): T { export function querySelector<T extends Element>(selector: string): T {
return document.querySelector<T>(selector)!; return document.querySelector(selector)!;
} }
/** /** Returns all elements found from all the selectors. */
* Returns all elements found from all the selectors.
* @param selectors The selectors.
*/
export function querySelectorAll<T extends Element>( export function querySelectorAll<T extends Element>(
...selectors: string[] ...selectors: string[]
): T[] { ): T[] {
return selectors.flatMap((selector) => return selectors.flatMap((selector) =>
Array.from(document.querySelectorAll(selector)) Array.from(document.querySelectorAll(selector)),
); );
} }

View File

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

View File

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

View File

@ -1,7 +0,0 @@
/**
* Returns a version string as a number by removing the periods.
* @param version
*/
export function versionAsNumber(version: string): number {
return Number(version.replace(/\./g, ''));
}

View File

@ -2,19 +2,18 @@
"compilerOptions": { "compilerOptions": {
"esModuleInterop": true, "esModuleInterop": true,
"lib": [ "lib": [
"ES2019" "ES2020"
], ],
"module": "commonjs", "module": "esnext",
"moduleResolution": "node",
"noEmit": true,
"outDir": "build/", "outDir": "build/",
"strict": true, "strict": true,
"target": "es6", "target": "esnext"
"typeRoots": [
"node_modules/@types",
"node_modules/web-ext-types"
],
}, },
"include": [ "include": [
"source/**/*.ts", "source/**/*.ts",
"vite.config.ts"
], ],
"exclude": [ "exclude": [
"node_modules/" "node_modules/"

34
vite.config.ts Normal file
View File

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

10325
yarn.lock

File diff suppressed because it is too large Load Diff