1
Fork 0

Compare commits

...

9 Commits

20 changed files with 724 additions and 3 deletions

View File

@ -42,6 +42,7 @@
"extends": "@bauke/eslint-config",
"prettier": true,
"rules": {
"complexity": "off",
"no-await-in-loop": "off"
},
"space": true

View File

@ -7,3 +7,4 @@ export * from "./markdown-toolbar.js";
export * from "./themed-logo.js";
export * from "./user-labels.js";
export * from "./username-colors.js";
export * from "./hide-topics.js";

View File

@ -0,0 +1,206 @@
import {Component, render} from "preact";
import {
fromStorage,
Feature,
HideTopicMatcher,
type UserLabelsData,
} from "../../storage/exports.js";
import {
createElementFromString,
log,
pluralize,
querySelector,
querySelectorAll,
} from "../../utilities/exports.js";
/**
* Alias for {@link HideTopicMatcher} for code brevity.
*/
const Matcher = HideTopicMatcher;
type Props = Record<string, unknown>;
type State = {
hidden: boolean;
hiddenTopicsCount: number;
};
/**
* Hide a topic by adding `.trx-hidden` and setting the `data-trx-hide-topics`
* attribute.
* @param topic The topic to hide.
*/
function hideTopic(topic: HTMLElement) {
if (
topic.parentElement?.nextElementSibling?.getAttribute(
"data-trx-hide-topics-spacer",
) === null
) {
// Add a spacer to the topic listing so the `li:nth-child(2n)` styling
// doesn't become inconsistent when this topic is hidden.
topic.parentElement.insertAdjacentElement(
"afterend",
createElementFromString(
'<li class="trx-hidden" data-trx-hide-topics-spacer></li>',
),
);
}
topic.classList.add("trx-hidden");
topic.dataset.trxHideTopics = "";
}
/**
* Run the Hide Topics feature.
* @param userLabels The {@link UserLabelsData} to use with the UserLabelEquals
* {@link HideTopicMatcher}.
*/
export async function runHideTopicsFeature(
userLabels: UserLabelsData,
): Promise<void> {
const predicates = await fromStorage(Feature.HideTopics);
// Select all topics not already handled by TRX.
const topics = querySelectorAll<HTMLElement>(
".topic-listing .topic:not([data-trx-hide-topics])",
);
// Define all the predicates before going through the topics so matching the
// topics is easier later.
const domainPredicates = [];
const titlePredicates = [];
const userPredicates = new Set();
for (const predicate of predicates) {
const {matcher, value} = predicate.value;
switch (matcher) {
case Matcher.DomainIncludes: {
domainPredicates.push(value.toLowerCase());
break;
}
case Matcher.TildesUsernameEquals: {
userPredicates.add(value.toLowerCase());
break;
}
case Matcher.TitleIncludes: {
titlePredicates.push(value.toLowerCase());
break;
}
case Matcher.UserLabelEquals: {
for (const userLabel of userLabels) {
if (value === userLabel.value.text) {
userPredicates.add(userLabel.value.username.toLowerCase());
}
}
break;
}
default: {
console.warn(`Unknown HideTopicMatcher: ${matcher as string}`);
}
}
}
// Keep a count of how many topics have been hidden.
let topicsHidden = 0;
// Shorthand to hide a topic and increment the count.
const hide = (topic: HTMLElement) => {
hideTopic(topic);
topicsHidden++;
};
for (const topic of topics) {
// First check the topic author.
const author = (topic.dataset.topicPostedBy ?? "<unknown>").toLowerCase();
if (userPredicates.has(author)) {
hide(topic);
continue;
}
// Second check the topic title.
const title = (
topic.querySelector(".topic-title")?.textContent ?? ""
).toLowerCase();
if (titlePredicates.some((value) => title.includes(value))) {
hide(topic);
continue;
}
// Third check the topic link.
const url = new URL(
topic.querySelector<HTMLAnchorElement>(".topic-title a")!.href,
);
if (domainPredicates.some((value) => url.hostname.includes(value))) {
hide(topic);
continue;
}
}
// Only add the Hide Topics button if any topics have been hidden and if the
// button isn't already there.
if (
topicsHidden > 0 &&
document.querySelector("#trx-hide-topics-button") === null
) {
const container = document.createElement("div");
render(<HideTopicsFeature hiddenTopicsCount={topicsHidden} />, container);
querySelector("#sidebar").insertAdjacentElement("beforeend", container);
}
log(`Hide Topics: Initialized for ${topicsHidden} topics.`);
}
export class HideTopicsFeature extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hiddenTopicsCount: this.getHiddenTopics().length,
hidden: true,
};
}
getHiddenTopics = (): HTMLElement[] => {
return querySelectorAll("[data-trx-hide-topics]");
};
toggleHidden = () => {
const {hidden} = this.state;
if (hidden) {
// Remove all the `<li>` spacers when unhiding the topics.
for (const spacer of querySelectorAll("[data-trx-hide-topics-spacer]")) {
spacer.remove();
}
}
for (const topic of this.getHiddenTopics()) {
if (hidden) {
topic.classList.remove("trx-hidden");
} else {
hideTopic(topic);
}
}
this.setState({hidden: !hidden});
};
render() {
const {hidden, hiddenTopicsCount} = this.state;
const pluralized = pluralize(hiddenTopicsCount, "topic");
return (
<button
id="trx-hide-topics-button"
class="btn primary"
onClick={this.toggleHidden}
>
{hidden ? "Unhide" : "Hide"} {hiddenTopicsCount} {pluralized}
</button>
);
}
}

View File

@ -7,6 +7,7 @@ import {
JumpToNewCommentFeature,
UserLabelsFeature,
runAnonymizeUsernamesFeature,
runHideTopicsFeature,
runHideVotesFeature,
runMarkdownToolbarFeature,
runThemedLogoFeature,
@ -23,6 +24,7 @@ async function initialize() {
// them without having to change the hardcoded values.
const usesKnownGroups = new Set<Feature>([Feature.Autocomplete]);
const knownGroups = await fromStorage(Data.KnownGroups);
const userLabels = await fromStorage(Feature.UserLabels);
// Only when any of the features that uses this data are enabled, try to save
// the groups.
@ -64,6 +66,12 @@ async function initialize() {
});
}
if (enabledFeatures.value.has(Feature.HideTopics)) {
observerFeatures.push(async () => {
await runHideTopicsFeature(userLabels);
});
}
if (enabledFeatures.value.has(Feature.HideVotes)) {
observerFeatures.push(async () => {
const data = await fromStorage(Feature.HideVotes);
@ -96,7 +104,6 @@ async function initialize() {
// Object to hold the active components we are going to render.
const components: Record<string, JSX.Element | undefined> = {};
const userLabels = await fromStorage(Feature.UserLabels);
if (enabledFeatures.value.has(Feature.Autocomplete)) {
components.autocomplete = (
<AutocompleteFeature

View File

@ -2,6 +2,7 @@ export {AboutSetting} from "./about.js";
export {AnonymizeUsernamesSetting} from "./anonymize-usernames.js";
export {AutocompleteSetting} from "./autocomplete.js";
export {BackToTopSetting} from "./back-to-top.js";
export {HideTopicsSetting} from "./hide-topics.js";
export {HideVotesSetting} from "./hide-votes.js";
export {JumpToNewCommentSetting} from "./jump-to-new-comment.js";
export {MarkdownToolbarSetting} from "./markdown-toolbar.js";

View File

@ -0,0 +1,233 @@
import {Component} from "preact";
import {
createValueHideTopicPredicate,
fromStorage,
isHideTopicMatcher,
Feature,
HideTopicMatcher,
type HideTopicPredicate,
type HideTopicsData,
} from "../../storage/exports.js";
import {log} from "../../utilities/logging.js";
import {Setting, type SettingProps} from "./index.js";
type State = {
predicates: HideTopicsData;
predicatesToRemove: HideTopicsData;
unsavedPredicateIds: number[];
};
export class HideTopicsSetting extends Component<SettingProps, State> {
constructor(props: SettingProps) {
super(props);
this.state = {
predicates: [],
predicatesToRemove: [],
unsavedPredicateIds: [],
};
}
async componentDidMount() {
this.setState({
predicates: await fromStorage(Feature.HideTopics),
});
}
newPredicate = async () => {
const {predicates, unsavedPredicateIds} = this.state;
predicates.sort((a, b) => b.value.id - a.value.id);
const newId = (predicates[0]?.value.id ?? 0) + 1;
predicates.push(
await createValueHideTopicPredicate({
id: newId,
matcher: HideTopicMatcher.DomainIncludes,
value: "example.org",
}),
);
unsavedPredicateIds.push(newId);
this.setState({
predicates,
unsavedPredicateIds,
});
};
onInput = (event: Event, id: number, key: keyof HideTopicPredicate) => {
const {predicates, unsavedPredicateIds} = this.state;
const index = predicates.findIndex(({value}) => value.id === id);
if (index === -1) {
log(`Tried to edit unknown predicate with ID: ${id}`);
return;
}
const newValue = (event.target as HTMLInputElement)!.value;
switch (key) {
case "matcher": {
if (isHideTopicMatcher(newValue)) {
predicates[index].value.matcher = newValue;
} else {
log(`Unknown HideTopicMatcher: ${newValue}`, true);
return;
}
break;
}
case "value": {
predicates[index].value.value = newValue;
break;
}
default: {
log(`Can't edit predicate key: ${key}`, true);
return;
}
}
unsavedPredicateIds.push(id);
this.setState({predicates, unsavedPredicateIds});
};
remove = (id: number) => {
const {predicates, predicatesToRemove, unsavedPredicateIds} = this.state;
const index = predicates.findIndex(({value}) => value.id === id);
if (index === -1) {
log(`Tried to remove unknown predicate with ID: ${id}`);
return;
}
predicatesToRemove.push(...predicates.splice(index, 1));
unsavedPredicateIds.push(id);
this.setState({predicates, predicatesToRemove, unsavedPredicateIds});
};
save = async () => {
const {predicates, predicatesToRemove} = this.state;
for (const predicate of predicates) {
await predicate.save();
}
for (const predicate of predicatesToRemove) {
await predicate.remove();
}
this.setState({predicatesToRemove: [], unsavedPredicateIds: []});
};
render() {
const {predicates, unsavedPredicateIds} = this.state;
predicates.sort((a, b) => a.value.id - b.value.id);
const editors = predicates.map(({value: predicate}) => {
const matcherHandler = (event: Event) => {
this.onInput(event, predicate.id, "matcher");
};
const valueHandler = (event: Event) => {
this.onInput(event, predicate.id, "value");
};
const removeHandler = () => {
this.remove(predicate.id);
};
const matcherOptions = Object.values(HideTopicMatcher).map((key) => (
<option selected={predicate.matcher === key} value={key}>
{key
.replace(/-/g, " ")
.replace(/(\b[a-z])/gi, (character) => character.toUpperCase())}
</option>
));
const hasUnsavedChanges = unsavedPredicateIds.includes(predicate.id)
? "unsaved-changes"
: "";
return (
<div class={`hide-topics-editor ${hasUnsavedChanges}`}>
<select onChange={matcherHandler}>{matcherOptions}</select>
<input
type="text"
placeholder="Value to match"
value={predicate.value}
onInput={valueHandler}
/>
<button class="button destructive" onClick={removeHandler}>
Remove
</button>
</div>
);
});
const hasUnsavedChanges = unsavedPredicateIds.length > 0;
return (
<Setting {...this.props}>
<p class="info">
Hide topics from the topic listing matching custom predicates.
<br />
Topics will be hidden when any of your predicates match and you can
unhide them by clicking a button that will appear at the bottom of the
sidebar. The topics will then show themselves and have a red border.
<br />
For hiding topics with certain tags, Tildes can do that natively via
your{" "}
<a href="https://tildes.net/settings/filters">
filtered tags settings
</a>
.
</p>
<details class="hide-topics-matcher-explanation">
<summary>Matcher explanations</summary>
<p>
All matches are done without taking casing into account, so you
don't have to care about upper or lowercase letters.
</p>
<ul>
<li>
<b>Domain Includes</b> will match the domain of all topic links
(including text topics). For example with the link{" "}
<code>https://tildes.net/~tildes</code>, "tildes.net" is what will
be matched against. If your value is included in the domain
anywhere the topic will be hidden.
</li>
<li>
<b>Tildes Username Equals</b> will match the topic author's
username. If your value is exactly the same as the topic author's
the topic will be hidden.
</li>
<li>
<b>Title Includes</b> will match the topic title. If your value is
anywhere in the title the topic will be hidden.
</li>
<li>
<b>User Label Equals</b> will match any user labels you have
applied to the topic author. For example if you set a "Hide
Topics" user labels matcher and then add a user label to someone
with "Hide Topics" as the text, their topics will be hidden.
</li>
</ul>
</details>
<div class="hide-topics-main-button-group">
<button class="button" onClick={this.newPredicate}>
New Predicate
</button>
<button
class={`button ${hasUnsavedChanges ? "unsaved-changes" : ""}`}
onClick={this.save}
>
Save{hasUnsavedChanges ? "*" : ""}
</button>
</div>
<div class="hide-topics-predicate-editors">{editors}</div>
</Setting>
);
}
}

View File

@ -4,6 +4,7 @@ import {
AnonymizeUsernamesSetting,
AutocompleteSetting,
BackToTopSetting,
HideTopicsSetting,
HideVotesSetting,
JumpToNewCommentSetting,
MarkdownToolbarSetting,
@ -42,6 +43,13 @@ export const features: FeatureData[] = [
title: "Back To Top",
component: BackToTopSetting,
},
{
availableSince: new Date("2023-06-31"),
index: 0,
key: Feature.HideTopics,
title: "Hide Topics",
component: HideTopicsSetting,
},
{
availableSince: new Date("2019-11-12"),
index: 0,

View File

@ -1,6 +1,7 @@
// Scripts
@import "scripts/autocomplete";
@import "scripts/back-to-top";
@import "scripts/hide-topics";
@import "scripts/jump-to-new-comment";
@import "scripts/markdown-toolbar";
@import "scripts/user-labels";

View File

@ -5,6 +5,7 @@
@import "button";
@import "shared";
@import "settings";
@import "settings/hide-topics";
html {
font-size: 62.5%;

View File

@ -0,0 +1,7 @@
[data-trx-hide-topics] {
border: 1px solid #f00;
// Remove the .is-topic-official margin-left rule, otherwise the border looks
// weird on official topics.
margin-left: 0;
}

View File

@ -0,0 +1,70 @@
.setting {
--unsaved-changes-color: var(--yellow);
.hide-topics-matcher-explanation {
margin-bottom: 8px;
p {
margin: 0;
padding: 8px;
}
ul {
margin-left: 2rem;
padding-bottom: 8px;
li {
margin-bottom: 4px;
&:last-child {
margin-bottom: 0;
}
}
}
}
.hide-topics-main-button-group {
display: flex;
gap: 8px;
margin-bottom: 8px;
button.unsaved-changes {
background-color: var(--unsaved-changes-color);
}
}
.hide-topics-predicate-editors {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.hide-topics-editor {
--save-status-color: var(--blue);
display: grid;
grid-template-columns: min-content auto min-content;
gap: 8px;
button {
min-width: 10rem;
}
input,
select {
background-color: var(--background-primary);
border: 1px solid var(--save-status-color);
color: var(--foreground);
padding: 8px;
}
select {
padding-right: 16px;
}
&.unsaved-changes {
--save-status-color: var(--unsaved-changes-color);
}
}
}

View File

@ -1,8 +1,12 @@
/**
* Keys of feature names used in WebExtension storage.
*/
export enum Feature {
AnonymizeUsernames = "anonymize-usernames",
Autocomplete = "autocomplete",
BackToTop = "back-to-top",
Debug = "debug",
HideTopics = "hide-topics",
HideVotes = "hide-votes",
JumpToNewComment = "jump-to-new-comment",
MarkdownToolbar = "markdown-toolbar",
@ -11,6 +15,9 @@ export enum Feature {
UsernameColors = "username-colors",
}
/**
* Keys of miscellaneous data stored in WebExtension storage.
*/
export enum Data {
EnabledFeatures = "enabled-features",
KnownGroups = "known-groups",

View File

@ -1,13 +1,19 @@
import {createValue} from "@holllo/webextension-storage";
import browser from "webextension-polyfill";
import {Data, Feature} from "./enums.js";
import {collectHideTopicsData} from "./hide-topics.js";
import {defaultKnownGroups} from "./known-groups.js";
import {collectUsernameColors} from "./username-color.js";
import {collectUserLabels} from "./user-label.js";
export * from "./enums.js";
export * from "./hide-topics.js";
export * from "./username-color.js";
export * from "./user-label.js";
/**
* The data stored for the Hide Votes feature.
*/
export type HideVotesData = {
otherComments: boolean;
otherTopics: boolean;
@ -15,19 +21,27 @@ export type HideVotesData = {
ownTopics: boolean;
};
/**
* All storage {@link Value}s stored in WebExtension storage.
*/
export const storageValues = {
[Data.EnabledFeatures]: createValue({
deserialize: (input) => new Set(JSON.parse(input) as Feature[]),
serialize: (input) => JSON.stringify(Array.from(input)),
key: Data.EnabledFeatures,
value: new Set([]),
value: new Set([
Feature.BackToTop,
Feature.JumpToNewComment,
Feature.MarkdownToolbar,
Feature.UserLabels,
]),
storage: browser.storage.sync,
}),
[Data.KnownGroups]: createValue({
deserialize: (input) => new Set(JSON.parse(input) as string[]),
serialize: (input) => JSON.stringify(Array.from(input)),
key: Data.KnownGroups,
value: new Set([]),
value: new Set(defaultKnownGroups),
storage: browser.storage.sync,
}),
[Data.LatestActiveFeatureTab]: createValue({
@ -44,6 +58,7 @@ export const storageValues = {
value: "2.0.0",
storage: browser.storage.sync,
}),
[Feature.HideTopics]: collectHideTopicsData(),
[Feature.HideVotes]: createValue({
deserialize: (input) => JSON.parse(input) as HideVotesData,
serialize: (input) => JSON.stringify(input),
@ -60,8 +75,15 @@ export const storageValues = {
[Feature.UsernameColors]: collectUsernameColors(),
};
/**
* Shorthand for the inferred shape of {@link storageValues}.
*/
type StorageValues = typeof storageValues;
/**
* Return the {@link Value}-wrapped data associated with a particular key.
* @param key The key of the value to get from {@link storageValues}.
*/
export async function fromStorage<K extends keyof StorageValues>(
key: K,
): Promise<StorageValues[K]> {

View File

@ -0,0 +1,72 @@
import browser from "webextension-polyfill";
import {createValue, type Value} from "@holllo/webextension-storage";
import {Feature} from "./enums.js";
/**
* The different matchers for {@link HideTopicPredicate}.
*/
export enum HideTopicMatcher {
DomainIncludes = "domain-includes",
TildesUsernameEquals = "tildes-username-equals",
TitleIncludes = "title-includes",
UserLabelEquals = "user-label-equals",
}
/**
* Type guard check to see if a string is a valid {@link HideTopicMatcher}.
* @param input The string to check.
*/
export function isHideTopicMatcher(input: string): input is HideTopicMatcher {
return Object.values(HideTopicMatcher).includes(input as HideTopicMatcher);
}
/**
* The predicate for whether a topic should be hidden or not.
*/
export type HideTopicPredicate = {
id: number;
matcher: HideTopicMatcher;
value: string;
};
/**
* Shorthand for an array of {@link Value}-wrapped {@link HideTopicPredicate}s.
*/
export type HideTopicsData = Array<Value<HideTopicPredicate>>;
/**
* Create a {@link Value}-wrapped {@link HideTopicPredicate}.
*/
export async function createValueHideTopicPredicate(
predicate: HideTopicPredicate,
): Promise<HideTopicsData[number]> {
return createValue<HideTopicPredicate>({
deserialize: (input) => JSON.parse(input) as HideTopicPredicate,
serialize: (input) => JSON.stringify(input),
key: `${Feature.HideTopics}-${predicate.id}`,
value: predicate,
storage: browser.storage.sync,
});
}
/**
* Get all hide topic predicates from storage and combine them into a single
* array.
*/
export async function collectHideTopicsData(): Promise<HideTopicsData> {
const storage = await browser.storage.sync.get();
const predicates = [];
for (const [key, value] of Object.entries(storage)) {
if (!key.startsWith(Feature.HideTopics)) {
continue;
}
predicates.push(
await createValueHideTopicPredicate(
JSON.parse(value as string) as HideTopicPredicate,
),
);
}
return predicates;
}

View File

@ -0,0 +1,44 @@
/**
* A list of default known groups to seed the Autocomplete feature with.
*
* This list does not need to be updated to match the groups available on Tildes.
* Instead, in `source/utilities/groups.ts` is a function that will extract the
* groups from Tildes whenever the user goes to `https://tildes.net/groups`.
*
* Inside `source/content-scripts/setup.tsx` a list of features that uses this
* data is defined, when any of those features are enabled the extract function
* will be called. So if a feature ever gets added that uses this data, remember
* to add it to the list in the content scripts setup.
*/
export const defaultKnownGroups = [
"~anime",
"~arts",
"~books",
"~comp",
"~creative",
"~design",
"~enviro",
"~finance",
"~food",
"~games",
"~games.game_design",
"~games.tabletop",
"~health",
"~hobbies",
"~humanities",
"~lgbt",
"~life",
"~misc",
"~movies",
"~music",
"~news",
"~science",
"~space",
"~sports",
"~talk",
"~tech",
"~test",
"~tildes",
"~tildes.official",
"~tv",
];

View File

@ -1,3 +1,6 @@
/**
* The deserialize data function from version 1.1.2 and before.
*/
export function v112DeserializeData(data: Record<string, any>): {
userLabels: V112Settings["data"]["userLabels"];
usernameColors: V112Settings["data"]["usernameColors"];
@ -22,6 +25,9 @@ export function v112DeserializeData(data: Record<string, any>): {
return deserialized;
}
/**
* The Settings data structure from version 1.1.2 and before.
*/
export type V112Settings = {
[index: string]: any;
data: {
@ -61,6 +67,9 @@ export type V112Settings = {
version: string;
};
/**
* A sample of the version 1.1.2 Settings data to use in testing.
*/
export const v112Sample: V112Settings = {
data: {
hideVotes: {

View File

@ -2,6 +2,9 @@ import {createValue, type Value} from "@holllo/webextension-storage";
import browser from "webextension-polyfill";
import {Feature} from "./enums.js";
/**
* The data structure for a user label.
*/
export type UserLabel = {
color: string;
id: number;
@ -10,6 +13,9 @@ export type UserLabel = {
username: string;
};
/**
* Shorthand for an array of {@link Value}-wrapped {@link UserLabel}s.
*/
export type UserLabelsData = Array<Value<UserLabel>>;
/**

View File

@ -2,12 +2,18 @@ import {type Value, createValue} from "@holllo/webextension-storage";
import browser from "webextension-polyfill";
import {Feature} from "./enums.js";
/**
* The data structure for a username color.
*/
export type UsernameColor = {
color: string;
id: number;
username: string;
};
/**
* Shorthand for an array of {@link Value}-wrapped {@link UsernameColor}s.
*/
export type UsernameColorsData = Array<Value<UsernameColor>>;
/**

View File

@ -6,4 +6,5 @@ export * from "./groups.js";
export * from "./logging.js";
export * from "./query-selectors.js";
export * from "./report-a-bug.js";
export * from "./text.js";
export * from "./validators.js";

18
source/utilities/text.ts Normal file
View File

@ -0,0 +1,18 @@
/**
* Pluralize a word based on a count.
* @param count The number of things.
* @param singular The word in its singular form.
* @param plural Optionally the word in its plural form. If left undefined the
* returned string will be the singular form plus the letter "s".
*/
export function pluralize(
count: number,
singular: string,
plural?: string,
): string {
if (count === 1) {
return singular;
}
return plural ?? singular + "s";
}