From f4744cb9218556aa566c1f6baea8a16dcd674348 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 25 Oct 2025 17:48:37 -0400 Subject: [PATCH] Document hashtag preferences and extend tests --- README.md | 7 ++ docs/moderation/README.md | 6 + docs/nostr-event-schemas.md | 7 ++ js/ui/views/VideoListView.js | 5 + tests/hashtag-preferences.test.mjs | 119 ++++++++++++++++++ .../ui/video-list-view-popular-tags.test.mjs | 12 ++ 6 files changed, 156 insertions(+) diff --git a/README.md b/README.md index d66e2b40..2ca7f1a2 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,13 @@ Operators can tune thresholds and lists from the Profile modal: - **Report** to issue a NIP-56 moderation report. - When a rebroadcast hits rate limits, the app surfaces cooldown guidance (e.g., “Rebroadcast is cooling down. Try again in 30s.”) so operators know when to retry without spamming relays. +### Hashtag interests & disinterests + +- The Profile modal’s **Hashtags** tab lets you maintain interests (tags you want surfaced) and disinterests (tags to downrank) with dedicated input fields and per-tag remove actions. Preferences are cached locally so list edits render instantly. +- bitvid persists the lists by publishing a replaceable `kind 30005` event with the identifier `bitvid:tag-preferences`. The `HashtagPreferencesService` normalizes tags, keeps interests and disinterests mutually exclusive, and emits change events so UI stays in sync.【F:js/services/hashtagPreferencesService.js†L206-L276】【F:js/services/hashtagPreferencesService.js†L304-L324】【F:docs/nostr-event-schemas.md†L150-L168】 +- Preference payloads are encrypted before leaving the browser. The service attempts NIP-44 v2 first, falls back to NIP-44 or NIP-04 based on the signer/browser capabilities, records the active scheme in the `['encrypted', ]` tag, and decrypts in the same priority order when loading from relays.【F:js/services/hashtagPreferencesService.js†L520-L657】【F:js/services/hashtagPreferencesService.js†L356-L511】 +- Published ciphertext is stored on every configured write relay and accepted ones are tracked so operators can diagnose relay failures without exposing the plaintext list.【F:js/services/hashtagPreferencesService.js†L658-L711】 + --- ## For Developers diff --git a/docs/moderation/README.md b/docs/moderation/README.md index e454e9cd..13dcb9e1 100644 --- a/docs/moderation/README.md +++ b/docs/moderation/README.md @@ -75,6 +75,12 @@ New operators should also note that fresh viewer accounts automatically inherit The profile modal now exposes blur, autoplay, and hide thresholds so operators can dial in stricter or more permissive behavior. Enter a non-negative whole number to override the default or leave the field blank to fall back to the baseline values from [`config/instance-config.js`](../../config/instance-config.js) (`DEFAULT_BLUR_THRESHOLD`, `DEFAULT_AUTOPLAY_BLOCK_THRESHOLD`, `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD`, `DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD`). Adjustments are stored locally, applied immediately to the active feed, and rehydrate on every load. Update the config file to change the per-instance defaults that new viewers see. +### Personal hashtag preferences + +- The Profile modal’s Hashtags tab is wired to [`HashtagPreferencesService`](../../js/services/hashtagPreferencesService.js), which maintains a local snapshot of the viewer’s interests and disinterests, normalizes every tag, and guarantees that the two sets stay mutually exclusive before broadcasting UI change events.【F:js/services/hashtagPreferencesService.js†L206-L276】【F:js/services/hashtagPreferencesService.js†L304-L324】 +- `bitvidApp` resolves the active pubkey’s preferences at login, listens for `CHANGE` events, and forwards the normalized snapshot to controllers so moderation and discovery logic can react without querying relays again.【F:js/app.js†L3811-L3906】【F:js/app.js†L3940-L4050】 +- Publishing writes a replaceable `kind 30005` list with `d=bitvid:tag-preferences`, encrypting the `{ version, interests, disinterests }` payload via the best available NIP-44/NIP-04 scheme before hitting every configured write relay.【F:docs/nostr-event-schemas.md†L150-L168】【F:js/services/hashtagPreferencesService.js†L520-L711】 + ### Runtime flags - `TRUSTED_MUTE_HIDE_THRESHOLD` — numeric default for the trusted mute hide control. Initialized from `DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD` in [`config/instance-config.js`](../../config/instance-config.js); the upstream config sets this to `1`, but adjust the export to match your policy. diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 80112cd8..f8f7ddb3 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -169,6 +169,13 @@ field must be a NIP-44 ciphertext representing the JSON shape share their preferred and muted hashtags without exposing the raw preferences on relays. +When decrypting, the client inspects both `['encrypted', ...]` and +`['encryption', ...]` hints on the event to prioritize NIP-44 v2 payloads, +falling back to legacy NIP-44 or NIP-04 decryptors when required. Publishing +performs the same capability probe, updating the existing `encrypted` tag with +the negotiated scheme before signing so other readers know which cipher to try +first.【F:js/services/hashtagPreferencesService.js†L356-L511】【F:js/services/hashtagPreferencesService.js†L520-L657】 + ### NIP-71 rollout bitvid now emits a paired NIP-71 event (kind `21` for long-form, `22` for short diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index 333018f1..3a682874 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -189,6 +189,7 @@ export class VideoListView { delete: null, blacklist: null, moderationOverride: null, + moderationHide: null, tagActivate: null, }; @@ -298,6 +299,10 @@ export class VideoListView { this.handlers.moderationOverride = typeof handler === "function" ? handler : null; } + setModerationHideHandler(handler) { + this.handlers.moderationHide = typeof handler === "function" ? handler : null; + } + setTagActivationHandler(handler) { this.handlers.tagActivate = typeof handler === "function" ? handler : null; } diff --git a/tests/hashtag-preferences.test.mjs b/tests/hashtag-preferences.test.mjs index f76bb42e..d30959a6 100644 --- a/tests/hashtag-preferences.test.mjs +++ b/tests/hashtag-preferences.test.mjs @@ -131,6 +131,52 @@ test( }, ); +test( + "load retries decryptors when hinted scheme fails", + { concurrency: false }, + async () => { + const pubkey = "d".repeat(64); + const attempts = []; + + setActiveSigner({ + nip44Decrypt: async () => { + attempts.push("nip44"); + throw new Error("nip44 failed"); + }, + nip04Decrypt: async () => { + attempts.push("nip04"); + return JSON.stringify({ + version: 1, + interests: ["Retry"], + disinterests: [], + }); + }, + }); + + nostrClient.pool = { + async list() { + return [ + { + id: "evt-retry", + created_at: 300, + pubkey, + content: "cipher", // never decrypted successfully by nip44 + tags: [["encrypted", "nip44 nip04"]], + }, + ]; + }, + }; + nostrClient.relays = ["wss://relay.retry"]; + nostrClient.writeRelays = ["wss://relay.retry"]; + + await hashtagPreferences.load(pubkey); + + assert.deepEqual(attempts, ["nip44", "nip04"]); + assert.deepEqual(hashtagPreferences.getInterests(), ["retry"]); + assert.deepEqual(hashtagPreferences.getDisinterests(), []); + }, +); + test( "interest and disinterest lists remain exclusive", { concurrency: false }, @@ -206,3 +252,76 @@ test( }, ); +test( + "publish falls back to window nostr encryptors", + { concurrency: false }, + async () => { + const pubkey = "e".repeat(64); + const encryptCalls = []; + + setActiveSigner({ + type: "inpage", + signEvent: async (event) => ({ ...event, id: "signed-window" }), + }); + + window.nostr = { + nip44: { + encrypt: async (target, plaintext) => { + encryptCalls.push({ scheme: "nip44", target, plaintext }); + throw new Error("nip44 failed"); + }, + v2: { + encrypt: async (target, plaintext) => { + encryptCalls.push({ scheme: "nip44_v2", target, plaintext }); + return "cipher-from-window"; + }, + }, + }, + nip04: { + encrypt: async (target, plaintext) => { + encryptCalls.push({ scheme: "nip04", target, plaintext }); + return "cipher-nip04"; + }, + }, + }; + + const publishedEvents = []; + nostrClient.pool = { + list: async () => [], + publish(urls, event) { + publishedEvents.push({ urls, event }); + return { + on(eventName, handler) { + if (eventName === "ok") { + setImmediate(() => handler()); + } + return true; + }, + }; + }, + }; + nostrClient.relays = ["wss://relay.window"]; + nostrClient.writeRelays = ["wss://relay.window"]; + + await hashtagPreferences.load(pubkey); + hashtagPreferences.addInterest("WindowTag"); + + const signedEvent = await hashtagPreferences.publish(); + + assert.equal(signedEvent.id, "signed-window"); + assert.equal(encryptCalls.length >= 2, true); + assert.equal(encryptCalls[0].scheme, "nip44"); + assert.equal(encryptCalls[1].scheme, "nip44_v2"); + assert.equal(encryptCalls[0].target, pubkey); + assert.equal(encryptCalls[1].target, pubkey); + assert.ok(encryptCalls[0].plaintext.includes("windowtag")); + assert.ok(encryptCalls[1].plaintext.includes("windowtag")); + assert.equal(publishedEvents.length, 1); + assert.deepEqual(publishedEvents[0].urls, ["wss://relay.window"]); + const encryptedTag = publishedEvents[0].event.tags.find( + (tag) => Array.isArray(tag) && tag[0] === "encrypted", + ); + assert.equal(encryptedTag[1], "nip44_v2"); + }, +); + diff --git a/tests/ui/video-list-view-popular-tags.test.mjs b/tests/ui/video-list-view-popular-tags.test.mjs index 755d1676..263ce730 100644 --- a/tests/ui/video-list-view-popular-tags.test.mjs +++ b/tests/ui/video-list-view-popular-tags.test.mjs @@ -78,3 +78,15 @@ test("VideoListView hides popular tags when no tags are available", () => { assert.equal(tagsRoot.hidden, true); assert.equal(tagsRoot.childElementCount, 0); }); + +test("VideoListView setModerationHideHandler stores callable", () => { + const { document } = createViewDom(); + const view = new VideoListView({ document }); + + const handler = () => {}; + view.setModerationHideHandler(handler); + assert.equal(view.handlers.moderationHide, handler); + + view.setModerationHideHandler(null); + assert.equal(view.handlers.moderationHide, null); +});