diff --git a/source/content-scripts/features/exports.ts b/source/content-scripts/features/exports.ts index 2de1981..f27d53a 100644 --- a/source/content-scripts/features/exports.ts +++ b/source/content-scripts/features/exports.ts @@ -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"; diff --git a/source/content-scripts/features/hide-topics.tsx b/source/content-scripts/features/hide-topics.tsx new file mode 100644 index 0000000..89041a4 --- /dev/null +++ b/source/content-scripts/features/hide-topics.tsx @@ -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; + +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( + '
  • ', + ), + ); + } + + 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 { + const predicates = await fromStorage(Feature.HideTopics); + + // Select all topics not already handled by TRX. + const topics = querySelectorAll( + ".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 ?? "").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(".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(, container); + querySelector("#sidebar").insertAdjacentElement("beforeend", container); + } + + log(`Hide Topics: Initialized for ${topicsHidden} topics.`); +} + +export class HideTopicsFeature extends Component { + 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 `
  • ` 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 ( + + ); + } +} diff --git a/source/content-scripts/setup.tsx b/source/content-scripts/setup.tsx index 96c8764..f2a789e 100644 --- a/source/content-scripts/setup.tsx +++ b/source/content-scripts/setup.tsx @@ -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.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 = {}; - const userLabels = await fromStorage(Feature.UserLabels); if (enabledFeatures.value.has(Feature.Autocomplete)) { components.autocomplete = (