diff --git a/source/options/components/exports.ts b/source/options/components/exports.ts index 5eebed5..aa90ddf 100644 --- a/source/options/components/exports.ts +++ b/source/options/components/exports.ts @@ -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"; diff --git a/source/options/components/hide-topics.tsx b/source/options/components/hide-topics.tsx new file mode 100644 index 0000000..dc3fb50 --- /dev/null +++ b/source/options/components/hide-topics.tsx @@ -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 { + 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) => ( + + )); + + const hasUnsavedChanges = unsavedPredicateIds.includes(predicate.id) + ? "unsaved-changes" + : ""; + + return ( +
+ + + +
+ ); + }); + + const hasUnsavedChanges = unsavedPredicateIds.length > 0; + return ( + +

+ Hide topics from the topic listing matching custom predicates. +
+ 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. +
+ For hiding topics with certain tags, Tildes can do that natively via + your{" "} + + filtered tags settings + + . +

+ +
+ Matcher explanations + +

+ All matches are done without taking casing into account, so you + don't have to care about upper or lowercase letters. +

+ +
    +
  • + Domain Includes will match the domain of all topic links + (including text topics). For example with the link{" "} + https://tildes.net/~tildes, "tildes.net" is what will + be matched against. If your value is included in the domain + anywhere the topic will be hidden. +
  • + +
  • + Tildes Username Equals will match the topic author's + username. If your value is exactly the same as the topic author's + the topic will be hidden. +
  • + +
  • + Title Includes will match the topic title. If your value is + anywhere in the title the topic will be hidden. +
  • + +
  • + User Label Equals 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. +
  • +
+
+ +
+ + +
+
{editors}
+
+ ); + } +} diff --git a/source/options/features.ts b/source/options/features.ts index c672d0c..7f963a4 100644 --- a/source/options/features.ts +++ b/source/options/features.ts @@ -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, diff --git a/source/scss/index.scss b/source/scss/index.scss index 943fc4b..379f9d6 100644 --- a/source/scss/index.scss +++ b/source/scss/index.scss @@ -5,6 +5,7 @@ @import "button"; @import "shared"; @import "settings"; +@import "settings/hide-topics"; html { font-size: 62.5%; diff --git a/source/scss/settings/_hide-topics.scss b/source/scss/settings/_hide-topics.scss new file mode 100644 index 0000000..f1b9cc0 --- /dev/null +++ b/source/scss/settings/_hide-topics.scss @@ -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); + } + } +}