diff --git a/js/app.js b/js/app.js index 4ce668d4..2c1164a2 100644 --- a/js/app.js +++ b/js/app.js @@ -27,6 +27,7 @@ import { attachHealthBadges } from "./gridHealth.js"; import { attachUrlHealthBadges } from "./urlHealthObserver.js"; import { updateVideoCardSourceVisibility } from "./utils/cardSourceVisibility.js"; import { collectVideoTags } from "./utils/videoTags.js"; +import { normalizeHashtag } from "./utils/hashtagNormalization.js"; import { sanitizeProfileMediaUrl } from "./utils/profileMedia.js"; import { ADMIN_INITIAL_EVENT_BLACKLIST } from "./lists.js"; import { userBlocks } from "./userBlocks.js"; @@ -2514,16 +2515,7 @@ class Application { } normalizeTagPreferenceCandidate(tag) { - if (typeof tag !== "string") { - return ""; - } - - const trimmed = tag.trim().replace(/^#+/, ""); - if (!trimmed) { - return ""; - } - - return trimmed.toLowerCase(); + return normalizeHashtag(tag); } getTagPreferenceState(tag) { diff --git a/js/feedEngine/index.js b/js/feedEngine/index.js index 67adf59a..4f03bd02 100644 --- a/js/feedEngine/index.js +++ b/js/feedEngine/index.js @@ -8,6 +8,7 @@ export { export { createDedupeByRootStage, createBlacklistFilterStage, + createTagPreferenceFilterStage, createWatchHistorySuppressionStage, createModerationStage, createResolvePostedAtStage, diff --git a/js/feedEngine/stages.js b/js/feedEngine/stages.js index 38c56beb..a4b583c3 100644 --- a/js/feedEngine/stages.js +++ b/js/feedEngine/stages.js @@ -10,10 +10,25 @@ import nostrService from "../services/nostrService.js"; import moderationService from "../services/moderationService.js"; import logger from "../utils/logger.js"; import { dedupeToNewestByRoot } from "../utils/videoDeduper.js"; +import { normalizeHashtag } from "../utils/hashtagNormalization.js"; import { isPlainObject, toSet, hasDisinterestedTag } from "./utils.js"; const FEED_HIDE_BYPASS_NAMES = new Set(["home", "recent"]); +function normalizeTagSet(values) { + const normalized = new Set(); + const source = toSet(values); + + for (const value of source) { + const tag = normalizeHashtag(value); + if (tag) { + normalized.add(tag); + } + } + + return normalized; +} + function resolveDedupeFunction(customDedupe) { if (typeof customDedupe === "function") { return customDedupe; @@ -258,6 +273,43 @@ export function createResolvePostedAtStage({ }; } +export function createTagPreferenceFilterStage({ + stageName = "tag-preference-filter", +} = {}) { + return async function tagPreferenceFilterStage(items = [], context = {}) { + const tagPreferences = context?.runtime?.tagPreferences; + const disinterests = normalizeTagSet(tagPreferences?.disinterests); + if (!disinterests.size) { + return items; + } + + const results = []; + + for (const item of items) { + const video = item?.video; + if (!video || typeof video !== "object") { + results.push(item); + continue; + } + + if (hasDisinterestedTag(video, disinterests)) { + context?.addWhy?.({ + stage: stageName, + type: "filter", + reason: "disinterested-tag", + videoId: typeof video.id === "string" ? video.id : null, + pubkey: typeof video.pubkey === "string" ? video.pubkey : null, + }); + continue; + } + + results.push(item); + } + + return results; + }; +} + export function createBlacklistFilterStage({ stageName = "blacklist-filter", shouldIncludeVideo, @@ -277,7 +329,7 @@ export function createBlacklistFilterStage({ const options = { blacklistedEventIds: blacklist, isAuthorBlocked }; const tagPreferences = context?.runtime?.tagPreferences; - const disinterests = toSet(tagPreferences?.disinterests); + const disinterests = normalizeTagSet(tagPreferences?.disinterests); const hasTagPreferences = disinterests.size > 0; const results = []; diff --git a/js/feedEngine/utils.js b/js/feedEngine/utils.js index 44b94112..915c950b 100644 --- a/js/feedEngine/utils.js +++ b/js/feedEngine/utils.js @@ -1,5 +1,7 @@ // js/feedEngine/utils.js +import { normalizeHashtag } from "../utils/hashtagNormalization.js"; + export function isPlainObject(value) { if (value === null || typeof value !== "object") { return false; @@ -33,11 +35,16 @@ export function hasDisinterestedTag(video, disinterestsSet) { return false; } + const matchesDisinterest = (tag) => { + const normalized = normalizeHashtag(tag); + return Boolean(normalized && disinterestsSet.has(normalized)); + }; + // Check raw tags if (Array.isArray(video.tags)) { for (const tag of video.tags) { if (Array.isArray(tag) && tag[0] === "t" && typeof tag[1] === "string") { - if (disinterestsSet.has(tag[1].toLowerCase())) { + if (matchesDisinterest(tag[1])) { return true; } } @@ -47,7 +54,7 @@ export function hasDisinterestedTag(video, disinterestsSet) { // Check NIP-71 metadata hashtags if available if (video.nip71 && Array.isArray(video.nip71.hashtags)) { for (const tag of video.nip71.hashtags) { - if (typeof tag === "string" && disinterestsSet.has(tag.toLowerCase())) { + if (typeof tag === "string" && matchesDisinterest(tag)) { return true; } } diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 86c6a1bb..b4788b5b 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -15,6 +15,7 @@ import { assertAnyRelayAccepted, } from "../nostrPublish.js"; import { userLogger, devLogger } from "../utils/logger.js"; +import { normalizeHashtag } from "../utils/hashtagNormalization.js"; import { profileCache } from "../state/profileCache.js"; const LOG_PREFIX = "[HashtagPreferences]"; @@ -95,19 +96,6 @@ function sanitizeRelayList(candidate) { : []; } -function normalizeTag(input) { - if (typeof input !== "string") { - return ""; - } - - const trimmed = input.trim().replace(/^#+/, ""); - if (!trimmed) { - return ""; - } - - return trimmed.toLowerCase(); -} - function normalizeEncryptionToken(value) { if (typeof value !== "string") { return ""; @@ -133,7 +121,7 @@ function normalizePreferencesPayload(payload) { const interests = new Set(); for (const tag of rawInterests) { - const normalizedTag = normalizeTag(tag); + const normalizedTag = normalizeHashtag(tag); if (normalizedTag) { interests.add(normalizedTag); } @@ -141,7 +129,7 @@ function normalizePreferencesPayload(payload) { const disinterests = new Set(); for (const tag of rawDisinterests) { - const normalizedTag = normalizeTag(tag); + const normalizedTag = normalizeHashtag(tag); if (!normalizedTag) { continue; } @@ -342,7 +330,7 @@ class HashtagPreferencesService { } addInterest(tag) { - const normalized = normalizeTag(tag); + const normalized = normalizeHashtag(tag); if (!normalized) { return false; } @@ -360,7 +348,7 @@ class HashtagPreferencesService { } removeInterest(tag) { - const normalized = normalizeTag(tag); + const normalized = normalizeHashtag(tag); if (!normalized || !this.interests.has(normalized)) { return false; } @@ -371,7 +359,7 @@ class HashtagPreferencesService { } addDisinterest(tag) { - const normalized = normalizeTag(tag); + const normalized = normalizeHashtag(tag); if (!normalized) { return false; } @@ -389,7 +377,7 @@ class HashtagPreferencesService { } removeDisinterest(tag) { - const normalized = normalizeTag(tag); + const normalized = normalizeHashtag(tag); if (!normalized || !this.disinterests.has(normalized)) { return false; } diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index fee035dc..f34761e4 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -35,6 +35,7 @@ import { formatShortNpub as defaultFormatShortNpub, } from "../../utils/formatters.js"; import { sanitizeProfileMediaUrl } from "../../utils/profileMedia.js"; +import { normalizeHashtag } from "../../utils/hashtagNormalization.js"; import { getBreakpointLg } from "../../designSystem/metrics.js"; const HEX64_REGEX = /^[0-9a-f]{64}$/i; @@ -2917,7 +2918,7 @@ export class VideoModal { } const raw = typeof entry === "string" ? entry : String(entry ?? ""); - const trimmed = raw.trim().replace(/^#+/, ""); + const trimmed = normalizeHashtag(raw); if (!trimmed) { continue; diff --git a/js/ui/components/nip71FormManager.js b/js/ui/components/nip71FormManager.js index 6c7dc589..7c970cf9 100644 --- a/js/ui/components/nip71FormManager.js +++ b/js/ui/components/nip71FormManager.js @@ -1,4 +1,8 @@ import { userLogger } from "../../utils/logger.js"; +import { + normalizeHashtag, + formatHashtag, +} from "../../utils/hashtagNormalization.js"; // NOTE: Keep the Upload, Edit, and Revert modals in lockstep when updating NIP-71 form features. const REPEATER_KEYS = ["imeta", "text-track", "segment", "t", "p", "r"]; @@ -468,22 +472,11 @@ export class Nip71FormManager { value = `${value ?? ""}`; } - const trimmed = value.trim(); - if (!trimmed) { - return ""; - } - - const withoutHash = trimmed.replace(/^#+/, "").trim(); - if (!withoutHash) { - return ""; - } - - return withoutHash.toLowerCase(); + return normalizeHashtag(value); } renderHashtagValue(value) { - const sanitized = this.sanitizeHashtagValue(value); - return sanitized ? `#${sanitized}` : ""; + return formatHashtag(value); } collectNestedValues(entry, nestedKey) { @@ -804,4 +797,3 @@ export class Nip71FormManager { return this.sections.get(key) || null; } } - diff --git a/js/ui/components/tagPreferenceMenu.js b/js/ui/components/tagPreferenceMenu.js index b56a3bbb..fa25b148 100644 --- a/js/ui/components/tagPreferenceMenu.js +++ b/js/ui/components/tagPreferenceMenu.js @@ -1,4 +1,8 @@ import { normalizeDesignSystemContext } from "../../designSystem.js"; +import { + normalizeHashtag, + formatHashtag, +} from "../../utils/hashtagNormalization.js"; export const TAG_PREFERENCE_ACTIONS = { ADD_INTEREST: "add-interest", @@ -83,19 +87,9 @@ function appendMenuAction(doc, list, { text, action, dataset = {}, disabled = fa } function normalizeTag(tag) { - if (typeof tag !== "string") { - return { label: "", normalized: "" }; - } - - const trimmed = tag.trim(); - if (!trimmed) { - return { label: "", normalized: "" }; - } - - const withoutHash = trimmed.replace(/^#+/, ""); - const normalized = withoutHash.toLowerCase(); + const normalized = normalizeHashtag(tag); return { - label: withoutHash, + label: normalized, normalized, }; } @@ -172,7 +166,7 @@ export function createTagPreferenceMenu({ normalizeDesignSystemContext(designSystem); const { label, normalized } = normalizeTag(tag); - const headingLabel = label ? `#${label}` : "Tag preferences"; + const headingLabel = label ? formatHashtag(label) : "Tag preferences"; const { panel, list } = ensureMenuContainer(doc, ["w-56", "p-0"]); panel.dataset.menu = "tag-preference"; @@ -191,7 +185,7 @@ export function createTagPreferenceMenu({ } const buttons = {}; - const actionDataset = { tag: label || normalized }; + const actionDataset = { tag: normalized }; buttons.addInterest = appendMenuAction(doc, list, { text: "Add to interests", @@ -225,7 +219,7 @@ export function createTagPreferenceMenu({ } try { onAction(action, { - tag: label || normalized, + tag: normalized, normalizedTag: normalized, event, button, diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index c3670352..a1c74187 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -16,6 +16,10 @@ import { import { getBreakpointLg } from "../designSystem/metrics.js"; import { getProviderMetadata } from "../services/authProviders/index.js"; import { devLogger, userLogger } from "../utils/logger.js"; +import { + normalizeHashtag, + formatHashtag, +} from "../utils/hashtagNormalization.js"; import { getActiveSigner } from "../nostr/client.js"; const noop = () => {}; @@ -4465,24 +4469,11 @@ export class ProfileModalController { } normalizeHashtagTag(value) { - if (typeof value !== "string") { - return ""; - } - - const trimmed = value.trim(); - if (!trimmed) { - return ""; - } - - return trimmed.replace(/^#+/, "").toLowerCase(); + return normalizeHashtag(value); } formatHashtagTag(value) { - const normalized = this.normalizeHashtagTag(value); - if (!normalized) { - return ""; - } - return `#${normalized}`; + return formatHashtag(value); } sanitizeHashtagList(list) { diff --git a/js/utils/hashtagNormalization.js b/js/utils/hashtagNormalization.js new file mode 100644 index 00000000..c1d0f624 --- /dev/null +++ b/js/utils/hashtagNormalization.js @@ -0,0 +1,50 @@ +// js/utils/hashtagNormalization.js + +const HASH_PREFIX_PATTERN = /^#+/; + +/** + * Normalize hashtags consistently across storage, matching, and UI validation. + * + * Normalization rules: + * 1) Trim surrounding whitespace. + * 2) Strip leading "#" characters. + * 3) Lowercase the remaining value. + * + * Note: We do not strip punctuation today. Only add punctuation stripping if + * product explicitly decides to change the rules. + * + * @param {string} value + * @returns {string} normalized hashtag or empty string when invalid. + */ +export function normalizeHashtag(value) { + if (typeof value !== "string") { + return ""; + } + + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + + const withoutHashes = trimmed.replace(HASH_PREFIX_PATTERN, "").trim(); + if (!withoutHashes) { + return ""; + } + + return withoutHashes.toLowerCase(); +} + +/** + * Format a hashtag for display, ensuring a single leading hash. + * + * @param {string} value + * @returns {string} + */ +export function formatHashtag(value) { + const normalized = normalizeHashtag(value); + if (!normalized) { + return ""; + } + + return `#${normalized}`; +}