Add shared hashtag normalization helper

This commit is contained in:
thePR0M3TH3AN committed 2026-01-18 13:35:34 -05:00
1 parent cedc2a91d0
commit 0ac64867da
10 files changed
+145 -77

No files matched your search

+2 -10
View File
@@ -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) {
+1
View File
@@ -8,6 +8,7 @@ export {
export {
createDedupeByRootStage,
createBlacklistFilterStage,
createTagPreferenceFilterStage,
createWatchHistorySuppressionStage,
createModerationStage,
createResolvePostedAtStage,
+53 -1
View File
@@ -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 = [];
+9 -2
View File
@@ -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;
}
}
+7 -19
View File
@@ -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;
}
+2 -1
View File
@@ -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;
+6 -14
View File
@@ -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;
}
}
+9 -15
View File
@@ -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,
+6 -15
View File
@@ -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) {
+50
View File
@@ -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}`;
}