Compare commits
9 Commits
8fb439c98c
...
7d480598da
Author | SHA1 | Date |
---|---|---|
Bauke | 7d480598da | |
Bauke | 67e796d12d | |
Bauke | bddc4ceddf | |
Bauke | de0730db28 | |
Bauke | 17bee78e70 | |
Bauke | 0fff485473 | |
Bauke | 35f1bf35ca | |
Bauke | 009ff2e424 | |
Bauke | 03d737b9cf |
|
@ -42,6 +42,7 @@
|
||||||
"extends": "@bauke/eslint-config",
|
"extends": "@bauke/eslint-config",
|
||||||
"prettier": true,
|
"prettier": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
|
"complexity": "off",
|
||||||
"no-await-in-loop": "off"
|
"no-await-in-loop": "off"
|
||||||
},
|
},
|
||||||
"space": true
|
"space": true
|
||||||
|
|
|
@ -7,3 +7,4 @@ export * from "./markdown-toolbar.js";
|
||||||
export * from "./themed-logo.js";
|
export * from "./themed-logo.js";
|
||||||
export * from "./user-labels.js";
|
export * from "./user-labels.js";
|
||||||
export * from "./username-colors.js";
|
export * from "./username-colors.js";
|
||||||
|
export * from "./hide-topics.js";
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ import {
|
||||||
JumpToNewCommentFeature,
|
JumpToNewCommentFeature,
|
||||||
UserLabelsFeature,
|
UserLabelsFeature,
|
||||||
runAnonymizeUsernamesFeature,
|
runAnonymizeUsernamesFeature,
|
||||||
|
runHideTopicsFeature,
|
||||||
runHideVotesFeature,
|
runHideVotesFeature,
|
||||||
runMarkdownToolbarFeature,
|
runMarkdownToolbarFeature,
|
||||||
runThemedLogoFeature,
|
runThemedLogoFeature,
|
||||||
|
@ -23,6 +24,7 @@ async function initialize() {
|
||||||
// them without having to change the hardcoded values.
|
// them without having to change the hardcoded values.
|
||||||
const usesKnownGroups = new Set<Feature>([Feature.Autocomplete]);
|
const usesKnownGroups = new Set<Feature>([Feature.Autocomplete]);
|
||||||
const knownGroups = await fromStorage(Data.KnownGroups);
|
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
|
// Only when any of the features that uses this data are enabled, try to save
|
||||||
// the groups.
|
// 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)) {
|
if (enabledFeatures.value.has(Feature.HideVotes)) {
|
||||||
observerFeatures.push(async () => {
|
observerFeatures.push(async () => {
|
||||||
const data = await fromStorage(Feature.HideVotes);
|
const data = await fromStorage(Feature.HideVotes);
|
||||||
|
@ -96,7 +104,6 @@ async function initialize() {
|
||||||
// Object to hold the active components we are going to render.
|
// Object to hold the active components we are going to render.
|
||||||
const components: Record<string, JSX.Element | undefined> = {};
|
const components: Record<string, JSX.Element | undefined> = {};
|
||||||
|
|
||||||
const userLabels = await fromStorage(Feature.UserLabels);
|
|
||||||
if (enabledFeatures.value.has(Feature.Autocomplete)) {
|
if (enabledFeatures.value.has(Feature.Autocomplete)) {
|
||||||
components.autocomplete = (
|
components.autocomplete = (
|
||||||
<AutocompleteFeature
|
<AutocompleteFeature
|
||||||
|
|
|
@ -2,6 +2,7 @@ export {AboutSetting} from "./about.js";
|
||||||
export {AnonymizeUsernamesSetting} from "./anonymize-usernames.js";
|
export {AnonymizeUsernamesSetting} from "./anonymize-usernames.js";
|
||||||
export {AutocompleteSetting} from "./autocomplete.js";
|
export {AutocompleteSetting} from "./autocomplete.js";
|
||||||
export {BackToTopSetting} from "./back-to-top.js";
|
export {BackToTopSetting} from "./back-to-top.js";
|
||||||
|
export {HideTopicsSetting} from "./hide-topics.js";
|
||||||
export {HideVotesSetting} from "./hide-votes.js";
|
export {HideVotesSetting} from "./hide-votes.js";
|
||||||
export {JumpToNewCommentSetting} from "./jump-to-new-comment.js";
|
export {JumpToNewCommentSetting} from "./jump-to-new-comment.js";
|
||||||
export {MarkdownToolbarSetting} from "./markdown-toolbar.js";
|
export {MarkdownToolbarSetting} from "./markdown-toolbar.js";
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ import {
|
||||||
AnonymizeUsernamesSetting,
|
AnonymizeUsernamesSetting,
|
||||||
AutocompleteSetting,
|
AutocompleteSetting,
|
||||||
BackToTopSetting,
|
BackToTopSetting,
|
||||||
|
HideTopicsSetting,
|
||||||
HideVotesSetting,
|
HideVotesSetting,
|
||||||
JumpToNewCommentSetting,
|
JumpToNewCommentSetting,
|
||||||
MarkdownToolbarSetting,
|
MarkdownToolbarSetting,
|
||||||
|
@ -42,6 +43,13 @@ export const features: FeatureData[] = [
|
||||||
title: "Back To Top",
|
title: "Back To Top",
|
||||||
component: BackToTopSetting,
|
component: BackToTopSetting,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
availableSince: new Date("2023-06-31"),
|
||||||
|
index: 0,
|
||||||
|
key: Feature.HideTopics,
|
||||||
|
title: "Hide Topics",
|
||||||
|
component: HideTopicsSetting,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
availableSince: new Date("2019-11-12"),
|
availableSince: new Date("2019-11-12"),
|
||||||
index: 0,
|
index: 0,
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
// Scripts
|
// Scripts
|
||||||
@import "scripts/autocomplete";
|
@import "scripts/autocomplete";
|
||||||
@import "scripts/back-to-top";
|
@import "scripts/back-to-top";
|
||||||
|
@import "scripts/hide-topics";
|
||||||
@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";
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
@import "button";
|
@import "button";
|
||||||
@import "shared";
|
@import "shared";
|
||||||
@import "settings";
|
@import "settings";
|
||||||
|
@import "settings/hide-topics";
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-size: 62.5%;
|
font-size: 62.5%;
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,8 +1,12 @@
|
||||||
|
/**
|
||||||
|
* Keys of feature names used in WebExtension storage.
|
||||||
|
*/
|
||||||
export enum Feature {
|
export enum Feature {
|
||||||
AnonymizeUsernames = "anonymize-usernames",
|
AnonymizeUsernames = "anonymize-usernames",
|
||||||
Autocomplete = "autocomplete",
|
Autocomplete = "autocomplete",
|
||||||
BackToTop = "back-to-top",
|
BackToTop = "back-to-top",
|
||||||
Debug = "debug",
|
Debug = "debug",
|
||||||
|
HideTopics = "hide-topics",
|
||||||
HideVotes = "hide-votes",
|
HideVotes = "hide-votes",
|
||||||
JumpToNewComment = "jump-to-new-comment",
|
JumpToNewComment = "jump-to-new-comment",
|
||||||
MarkdownToolbar = "markdown-toolbar",
|
MarkdownToolbar = "markdown-toolbar",
|
||||||
|
@ -11,6 +15,9 @@ export enum Feature {
|
||||||
UsernameColors = "username-colors",
|
UsernameColors = "username-colors",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys of miscellaneous data stored in WebExtension storage.
|
||||||
|
*/
|
||||||
export enum Data {
|
export enum Data {
|
||||||
EnabledFeatures = "enabled-features",
|
EnabledFeatures = "enabled-features",
|
||||||
KnownGroups = "known-groups",
|
KnownGroups = "known-groups",
|
||||||
|
|
|
@ -1,13 +1,19 @@
|
||||||
import {createValue} from "@holllo/webextension-storage";
|
import {createValue} from "@holllo/webextension-storage";
|
||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
import {Data, Feature} from "./enums.js";
|
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 {collectUsernameColors} from "./username-color.js";
|
||||||
import {collectUserLabels} from "./user-label.js";
|
import {collectUserLabels} from "./user-label.js";
|
||||||
|
|
||||||
export * from "./enums.js";
|
export * from "./enums.js";
|
||||||
|
export * from "./hide-topics.js";
|
||||||
export * from "./username-color.js";
|
export * from "./username-color.js";
|
||||||
export * from "./user-label.js";
|
export * from "./user-label.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The data stored for the Hide Votes feature.
|
||||||
|
*/
|
||||||
export type HideVotesData = {
|
export type HideVotesData = {
|
||||||
otherComments: boolean;
|
otherComments: boolean;
|
||||||
otherTopics: boolean;
|
otherTopics: boolean;
|
||||||
|
@ -15,19 +21,27 @@ export type HideVotesData = {
|
||||||
ownTopics: boolean;
|
ownTopics: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All storage {@link Value}s stored in WebExtension storage.
|
||||||
|
*/
|
||||||
export const storageValues = {
|
export const storageValues = {
|
||||||
[Data.EnabledFeatures]: createValue({
|
[Data.EnabledFeatures]: createValue({
|
||||||
deserialize: (input) => new Set(JSON.parse(input) as Feature[]),
|
deserialize: (input) => new Set(JSON.parse(input) as Feature[]),
|
||||||
serialize: (input) => JSON.stringify(Array.from(input)),
|
serialize: (input) => JSON.stringify(Array.from(input)),
|
||||||
key: Data.EnabledFeatures,
|
key: Data.EnabledFeatures,
|
||||||
value: new Set([]),
|
value: new Set([
|
||||||
|
Feature.BackToTop,
|
||||||
|
Feature.JumpToNewComment,
|
||||||
|
Feature.MarkdownToolbar,
|
||||||
|
Feature.UserLabels,
|
||||||
|
]),
|
||||||
storage: browser.storage.sync,
|
storage: browser.storage.sync,
|
||||||
}),
|
}),
|
||||||
[Data.KnownGroups]: createValue({
|
[Data.KnownGroups]: createValue({
|
||||||
deserialize: (input) => new Set(JSON.parse(input) as string[]),
|
deserialize: (input) => new Set(JSON.parse(input) as string[]),
|
||||||
serialize: (input) => JSON.stringify(Array.from(input)),
|
serialize: (input) => JSON.stringify(Array.from(input)),
|
||||||
key: Data.KnownGroups,
|
key: Data.KnownGroups,
|
||||||
value: new Set([]),
|
value: new Set(defaultKnownGroups),
|
||||||
storage: browser.storage.sync,
|
storage: browser.storage.sync,
|
||||||
}),
|
}),
|
||||||
[Data.LatestActiveFeatureTab]: createValue({
|
[Data.LatestActiveFeatureTab]: createValue({
|
||||||
|
@ -44,6 +58,7 @@ export const storageValues = {
|
||||||
value: "2.0.0",
|
value: "2.0.0",
|
||||||
storage: browser.storage.sync,
|
storage: browser.storage.sync,
|
||||||
}),
|
}),
|
||||||
|
[Feature.HideTopics]: collectHideTopicsData(),
|
||||||
[Feature.HideVotes]: createValue({
|
[Feature.HideVotes]: createValue({
|
||||||
deserialize: (input) => JSON.parse(input) as HideVotesData,
|
deserialize: (input) => JSON.parse(input) as HideVotesData,
|
||||||
serialize: (input) => JSON.stringify(input),
|
serialize: (input) => JSON.stringify(input),
|
||||||
|
@ -60,8 +75,15 @@ export const storageValues = {
|
||||||
[Feature.UsernameColors]: collectUsernameColors(),
|
[Feature.UsernameColors]: collectUsernameColors(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorthand for the inferred shape of {@link storageValues}.
|
||||||
|
*/
|
||||||
type StorageValues = typeof 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>(
|
export async function fromStorage<K extends keyof StorageValues>(
|
||||||
key: K,
|
key: K,
|
||||||
): Promise<StorageValues[K]> {
|
): Promise<StorageValues[K]> {
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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",
|
||||||
|
];
|
|
@ -1,3 +1,6 @@
|
||||||
|
/**
|
||||||
|
* The deserialize data function from version 1.1.2 and before.
|
||||||
|
*/
|
||||||
export function v112DeserializeData(data: Record<string, any>): {
|
export function v112DeserializeData(data: Record<string, any>): {
|
||||||
userLabels: V112Settings["data"]["userLabels"];
|
userLabels: V112Settings["data"]["userLabels"];
|
||||||
usernameColors: V112Settings["data"]["usernameColors"];
|
usernameColors: V112Settings["data"]["usernameColors"];
|
||||||
|
@ -22,6 +25,9 @@ export function v112DeserializeData(data: Record<string, any>): {
|
||||||
return deserialized;
|
return deserialized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Settings data structure from version 1.1.2 and before.
|
||||||
|
*/
|
||||||
export type V112Settings = {
|
export type V112Settings = {
|
||||||
[index: string]: any;
|
[index: string]: any;
|
||||||
data: {
|
data: {
|
||||||
|
@ -61,6 +67,9 @@ export type V112Settings = {
|
||||||
version: string;
|
version: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sample of the version 1.1.2 Settings data to use in testing.
|
||||||
|
*/
|
||||||
export const v112Sample: V112Settings = {
|
export const v112Sample: V112Settings = {
|
||||||
data: {
|
data: {
|
||||||
hideVotes: {
|
hideVotes: {
|
||||||
|
|
|
@ -2,6 +2,9 @@ import {createValue, type Value} from "@holllo/webextension-storage";
|
||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
import {Feature} from "./enums.js";
|
import {Feature} from "./enums.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The data structure for a user label.
|
||||||
|
*/
|
||||||
export type UserLabel = {
|
export type UserLabel = {
|
||||||
color: string;
|
color: string;
|
||||||
id: number;
|
id: number;
|
||||||
|
@ -10,6 +13,9 @@ export type UserLabel = {
|
||||||
username: string;
|
username: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorthand for an array of {@link Value}-wrapped {@link UserLabel}s.
|
||||||
|
*/
|
||||||
export type UserLabelsData = Array<Value<UserLabel>>;
|
export type UserLabelsData = Array<Value<UserLabel>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -2,12 +2,18 @@ import {type Value, createValue} from "@holllo/webextension-storage";
|
||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
import {Feature} from "./enums.js";
|
import {Feature} from "./enums.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The data structure for a username color.
|
||||||
|
*/
|
||||||
export type UsernameColor = {
|
export type UsernameColor = {
|
||||||
color: string;
|
color: string;
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorthand for an array of {@link Value}-wrapped {@link UsernameColor}s.
|
||||||
|
*/
|
||||||
export type UsernameColorsData = Array<Value<UsernameColor>>;
|
export type UsernameColorsData = Array<Value<UsernameColor>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -6,4 +6,5 @@ export * from "./groups.js";
|
||||||
export * from "./logging.js";
|
export * from "./logging.js";
|
||||||
export * from "./query-selectors.js";
|
export * from "./query-selectors.js";
|
||||||
export * from "./report-a-bug.js";
|
export * from "./report-a-bug.js";
|
||||||
|
export * from "./text.js";
|
||||||
export * from "./validators.js";
|
export * from "./validators.js";
|
||||||
|
|
|
@ -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";
|
||||||
|
}
|
Loading…
Reference in New Issue