From e496376236df041373796224820d142e0cc4937c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 31 Oct 2025 08:33:39 -0400 Subject: [PATCH 1/2] Align subscription lists with NIP-51 follow set kind --- ...nhanced_Profile_Channel_Views_Specification.md | 2 +- ...lock_Subscription_&_Reporting_Specification.md | 14 +++++++------- ...ic_Home_Page_&_Video_Tracking_Specification.md | 4 ++-- docs/nostr-event-schemas.md | 10 ++++++++-- js/nostrEventSchemas.js | 4 ++-- js/subscriptions.js | 15 ++++++++++----- 6 files changed, 30 insertions(+), 19 deletions(-) diff --git a/content/roadmap/04_bitvid_Enhanced_Profile_Channel_Views_Specification.md b/content/roadmap/04_bitvid_Enhanced_Profile_Channel_Views_Specification.md index 43faf7f2..5afc5887 100644 --- a/content/roadmap/04_bitvid_Enhanced_Profile_Channel_Views_Specification.md +++ b/content/roadmap/04_bitvid_Enhanced_Profile_Channel_Views_Specification.md @@ -100,7 +100,7 @@ Add placeholders for different views in `index.html`: - Fetch videos (`kind 30078`) filtered by the user's `npub`. ### **Subscriptions** -- Use `kind 30002` to manage the subscription list: +- Use `kind 30000` to manage the subscription follow set: - Subscribe: Add user to the list. - Unsubscribe: Remove user. diff --git a/content/roadmap/05_bitvid_Enhanced_Block_Subscription_&_Reporting_Specification.md b/content/roadmap/05_bitvid_Enhanced_Block_Subscription_&_Reporting_Specification.md index bd8e9f35..8b934b35 100644 --- a/content/roadmap/05_bitvid_Enhanced_Block_Subscription_&_Reporting_Specification.md +++ b/content/roadmap/05_bitvid_Enhanced_Block_Subscription_&_Reporting_Specification.md @@ -47,8 +47,8 @@ This document describes how to implement **Block Lists**, **Subscription Lists** A **Subscription List** lets users follow video creators independently of their main “following” list on Nostr. It supports categorization and can be made private via encryption. ### Event Kind -- **Kind**: `30002` -- **Description**: “Video Subscription List” (inspired by NIP-51 but for custom lists) +- **Kind**: `30000` +- **Description**: “Video Subscription Follow Set” aligned with [NIP-51 follow sets](../../docs/nips/51.md#sets) ### JSON Structure **Public Tags** @@ -64,7 +64,7 @@ A **Subscription List** lets users follow video creators independently of their ### Example ```json { - "kind": 30002, + "kind": 30000, "tags": [ ["d", "favorite-creators"], ["p", "npub1creator1pubkey"], @@ -208,7 +208,7 @@ The `p` tag references a pubkey, and the `e` tag references a note. The third el ## Implementation Details ### Replaceable Events -- **Subscription Lists** (`30002`) and **Block Lists** (`10001`) can be implemented as replaceable events by using a deterministic `d` tag. +- **Subscription Lists** (`30000`) and **Block Lists** (`10001`) can be implemented as replaceable events by using a deterministic `d` tag. - For example, `["d","my-blocklist"]` ensures older events with the same `d` are replaced when new ones arrive. ### Encryption @@ -220,7 +220,7 @@ The `p` tag references a pubkey, and the `e` tag references a note. The third el - Query each relay for events matching: ```jsonc { - "kinds": [10001, 30002], + "kinds": [10001, 30000], "authors": [] } ``` @@ -275,7 +275,7 @@ The `p` tag references a pubkey, and the `e` tag references a note. The third el ## Future Considerations 1. **Paid Subscriptions** - - Could layer subscription tiers on top of `30002` events. +- Could layer subscription tiers on top of `30000` events. 2. **Global Block Lists** - Let users publish or subscribe to a curated blocklist (or share one in a group). 3. **Web of Trust** @@ -284,4 +284,4 @@ The `p` tag references a pubkey, and the `e` tag references a note. The third el --- ### Summary -By leveraging Nostr event kinds (`30002` for subscriptions, `10001` for blocks, and `1984` for reports), bitvid can maintain a decentralized, user-controlled moderation system. Users can sync their lists across devices through relays, while administrators can choose how to handle flagged content on a platform level. This approach keeps moderation flexible and transparent. +By leveraging Nostr event kinds (`30000` for subscriptions, `10001` for blocks, and `1984` for reports), bitvid can maintain a decentralized, user-controlled moderation system. Users can sync their lists across devices through relays, while administrators can choose how to handle flagged content on a platform level. This approach keeps moderation flexible and transparent. diff --git a/content/roadmap/08_bitvid_Enhanced_Dynamic_Home_Page_&_Video_Tracking_Specification.md b/content/roadmap/08_bitvid_Enhanced_Dynamic_Home_Page_&_Video_Tracking_Specification.md index 62f6bd8d..fb4c1bf7 100644 --- a/content/roadmap/08_bitvid_Enhanced_Dynamic_Home_Page_&_Video_Tracking_Specification.md +++ b/content/roadmap/08_bitvid_Enhanced_Dynamic_Home_Page_&_Video_Tracking_Specification.md @@ -87,7 +87,7 @@ Rather than measuring active peers, bitvid now counts views by logging them as N - Compare the count of new views against a previous period, or calculate the rate of increase. 3. **For You** - - Look at what the user watched or subscribed to (via kind `30002` subscription lists). + - Look at what the user watched or subscribed to (via kind `30000` subscription follow sets). - Track the user’s or session’s recent view tags, then recommend videos with overlapping tags or from the same channels. --- @@ -124,7 +124,7 @@ Your `index.html` might include placeholders for each section: 1. **Fetch Data** - Pull video metadata from kind `30078` events that contain video info or from your existing approach. - - Pull subscription data (kind `30002`). + - Pull subscription data (kind `30000`). - Pull view events (kind `30078` with `"t", "view"`). 2. **Process Rankings** diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 2ba0e00f..45ec5d6b 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -147,13 +147,19 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca | View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | Canonical tag set: `['t','view']`, a pointer tag (`['e', ]` or `['a',
]`), and a stable dedupe tag `['d', ]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message | | Watch history index (`NOTE_TYPES.WATCH_HISTORY_INDEX`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', WATCH_HISTORY_LIST_IDENTIFIER]`, `['snapshot', ]`, `['chunks', ]`, repeated `['a', ]` pointers plus schema append tags | JSON payload `{ snapshot, totalChunks }` (may be empty when using tags only) | | Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', ]`, `['encrypted','nip04']`, `['snapshot', ]`, `['chunk', , ]`, optional leading `['head','1']` on the first chunk, pointer tags for each item, plus schema append tags | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) | -| Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30002` | `['d', 'subscriptions']` | NIP-04 encrypted JSON `{ subPubkeys: string[] }` | -| User block list (`NOTE_TYPES.USER_BLOCK_LIST`) | `30002` | `['d', 'user-blocks']` | NIP-04 encrypted JSON `{ blockedPubkeys: string[] }` | +| Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30000` | `['d', 'subscriptions']` | NIP-04/NIP-44 encrypted JSON array of NIP-51 follow-set tuples (e.g., `[['p', ], …]`) | +| User block list (`NOTE_TYPES.USER_BLOCK_LIST`) | `10000` | `['d', 'user-blocks']` | NIP-04/NIP-44 encrypted JSON `{ blockedPubkeys: string[] }` | | Hashtag preferences (`NOTE_TYPES.HASHTAG_PREFERENCES`) | `30005` | `['d', 'bitvid:tag-preferences']` plus schema-appended `['encrypted','nip44_v2']` | NIP-44 encrypted JSON `{ version, interests: string[], disinterests: string[] }` | | Admin moderation list (`NOTE_TYPES.ADMIN_MODERATION_LIST`) | `30000` | `['d', 'bitvid:admin:editors']`, repeated `['p', ]` entries | Empty content | | Admin blacklist (`NOTE_TYPES.ADMIN_BLACKLIST`) | `30000` | `['d', 'bitvid:admin:blacklist']`, repeated `['p', ]` entries | Empty content | | Admin whitelist (`NOTE_TYPES.ADMIN_WHITELIST`) | `30000` | `['d', 'bitvid:admin:whitelist']`, repeated `['p', ]` entries | Empty content | +Subscription lists therefore match the +[NIP-51 follow-set specification](./nips/51.md#sets) by emitting kind `30000` +events with the shared `['d','subscriptions']` identifier. Builders continue to +encrypt the payload so individual follows stay private unless explicitly +revealed by the author. + NIP-94 compliance: mirror events now normalize the `['m', ]` tag to lowercase—covering both user input and inferred values—so they satisfy [NIP-94's lowercase MIME requirement](./nips/94.md). Related helpers reuse the same normalization to keep future publishers aligned. ### Direct messages diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index c64ddd4b..e7b211a6 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -295,7 +295,7 @@ const BASE_SCHEMAS = { [NOTE_TYPES.SUBSCRIPTION_LIST]: { type: NOTE_TYPES.SUBSCRIPTION_LIST, label: "Subscription list", - kind: 30002, + kind: 30000, identifierTag: { name: "d", value: SUBSCRIPTION_LIST_IDENTIFIER, @@ -1399,7 +1399,7 @@ export function buildSubscriptionListEvent({ } appendSchemaTags(tags, schema); return { - kind: schema?.kind ?? 30002, + kind: schema?.kind ?? 30000, pubkey, created_at, tags, diff --git a/js/subscriptions.js b/js/subscriptions.js index 24263dc0..73dca45b 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -14,7 +14,9 @@ import { import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js"; import { buildSubscriptionListEvent, - SUBSCRIPTION_LIST_IDENTIFIER + SUBSCRIPTION_LIST_IDENTIFIER, + getNostrEventSchema, + NOTE_TYPES, } from "./nostrEventSchemas.js"; import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; import { @@ -28,6 +30,9 @@ import { devLogger, userLogger } from "./utils/logger.js"; import moderationService from "./services/moderationService.js"; import nostrService from "./services/nostrService.js"; +const SUBSCRIPTION_SET_KIND = + getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST)?.kind ?? 30000; + function normalizeHexPubkey(value) { if (typeof value !== "string") { return ""; @@ -210,7 +215,7 @@ const subscribeVideoViewEventsApi = (pointer, options) => subscribeVideoViewEvents(nostrClient, pointer, options); /** - * Manages the user's subscription list (kind=30002) *privately*, + * Manages the user's subscription list (kind=30000 follow set) *privately*, * using encrypted NIP-51 tag arrays (NIP-04/NIP-44) for the content field. * Also handles fetching and rendering subscribed channels' videos * in the same card style as your home page. @@ -234,7 +239,7 @@ class SubscriptionsManager { } /** - * Decrypt the subscription list from kind=30002 (d="subscriptions"). + * Decrypt the subscription list from kind=30000 (d="subscriptions"). */ async loadSubscriptions(userPubkey) { if (!userPubkey) { @@ -261,7 +266,7 @@ class SubscriptionsManager { } const filter = { - kinds: [30002], + kinds: [SUBSCRIPTION_SET_KIND], authors, "#d": [SUBSCRIPTION_LIST_IDENTIFIER], limit: 1 @@ -566,7 +571,7 @@ class SubscriptionsManager { /** * Encrypt (NIP-04) + publish the updated subscription set - * as kind=30002 with ["d", "subscriptions"] to be replaceable. + * as kind=30000 with ["d", "subscriptions"] to be replaceable. */ async publishSubscriptionList(userPubkey) { if (!userPubkey) { From 7db78b07f2bb99db83d240b8e32227e3acacdcca Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 3 Nov 2025 09:42:17 -0500 Subject: [PATCH 2/2] Restore legacy subscription list compatibility --- docs/nostr-event-schemas.md | 5 +- js/subscriptions.js | 8 ++- tests/subscriptions-manager.test.mjs | 80 +++++++++++++++++++++++++++- 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 45ec5d6b..8cd532bf 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -147,7 +147,7 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca | View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | Canonical tag set: `['t','view']`, a pointer tag (`['e', ]` or `['a',
]`), and a stable dedupe tag `['d', ]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message | | Watch history index (`NOTE_TYPES.WATCH_HISTORY_INDEX`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', WATCH_HISTORY_LIST_IDENTIFIER]`, `['snapshot', ]`, `['chunks', ]`, repeated `['a', ]` pointers plus schema append tags | JSON payload `{ snapshot, totalChunks }` (may be empty when using tags only) | | Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', ]`, `['encrypted','nip04']`, `['snapshot', ]`, `['chunk', , ]`, optional leading `['head','1']` on the first chunk, pointer tags for each item, plus schema append tags | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) | -| Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30000` | `['d', 'subscriptions']` | NIP-04/NIP-44 encrypted JSON array of NIP-51 follow-set tuples (e.g., `[['p', ], …]`) | +| Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30000` (clients also read legacy `30002`) | `['d', 'subscriptions']` | NIP-04/NIP-44 encrypted JSON array of NIP-51 follow-set tuples (e.g., `[['p', ], …]`) | | User block list (`NOTE_TYPES.USER_BLOCK_LIST`) | `10000` | `['d', 'user-blocks']` | NIP-04/NIP-44 encrypted JSON `{ blockedPubkeys: string[] }` | | Hashtag preferences (`NOTE_TYPES.HASHTAG_PREFERENCES`) | `30005` | `['d', 'bitvid:tag-preferences']` plus schema-appended `['encrypted','nip44_v2']` | NIP-44 encrypted JSON `{ version, interests: string[], disinterests: string[] }` | | Admin moderation list (`NOTE_TYPES.ADMIN_MODERATION_LIST`) | `30000` | `['d', 'bitvid:admin:editors']`, repeated `['p', ]` entries | Empty content | @@ -158,7 +158,8 @@ Subscription lists therefore match the [NIP-51 follow-set specification](./nips/51.md#sets) by emitting kind `30000` events with the shared `['d','subscriptions']` identifier. Builders continue to encrypt the payload so individual follows stay private unless explicitly -revealed by the author. +revealed by the author. Readers still request legacy kind `30002` alongside +`30000` while older clients migrate so existing follow sets remain visible. NIP-94 compliance: mirror events now normalize the `['m', ]` tag to lowercase—covering both user input and inferred values—so they satisfy [NIP-94's lowercase MIME requirement](./nips/94.md). Related helpers reuse the same normalization to keep future publishers aligned. diff --git a/js/subscriptions.js b/js/subscriptions.js index 73dca45b..f8588d4a 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -32,6 +32,7 @@ import nostrService from "./services/nostrService.js"; const SUBSCRIPTION_SET_KIND = getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST)?.kind ?? 30000; +const LEGACY_SUBSCRIPTION_SET_KIND = 30002; function normalizeHexPubkey(value) { if (typeof value !== "string") { @@ -265,8 +266,13 @@ class SubscriptionsManager { authors.push(userPubkey); } + const subscriptionKinds = [SUBSCRIPTION_SET_KIND]; + if (!subscriptionKinds.includes(LEGACY_SUBSCRIPTION_SET_KIND)) { + subscriptionKinds.push(LEGACY_SUBSCRIPTION_SET_KIND); + } + const filter = { - kinds: [SUBSCRIPTION_SET_KIND], + kinds: subscriptionKinds, authors, "#d": [SUBSCRIPTION_LIST_IDENTIFIER], limit: 1 diff --git a/tests/subscriptions-manager.test.mjs b/tests/subscriptions-manager.test.mjs index 1656b15e..e7c50597 100644 --- a/tests/subscriptions-manager.test.mjs +++ b/tests/subscriptions-manager.test.mjs @@ -8,6 +8,7 @@ import { subscriptions } from "../js/subscriptions.js"; import { setApplication } from "../js/applicationContext.js"; import nostrService from "../js/services/nostrService.js"; import moderationService from "../js/services/moderationService.js"; +import { getNostrEventSchema, NOTE_TYPES } from "../js/nostrEventSchemas.js"; test("loadSubscriptions aggregates relay results when one rejects", async () => { const SubscriptionsManager = subscriptions.constructor; @@ -55,7 +56,7 @@ test("loadSubscriptions aggregates relay results when one rejects", async () => const listCalls = []; nostrClient.pool = { - list(urls) { + list(urls, _filters) { const url = Array.isArray(urls) ? urls[0] : urls; listCalls.push(url); if (url === "wss://relay-b.example") { @@ -130,6 +131,83 @@ test("loadSubscriptions aggregates relay results when one rejects", async () => } }); +test("loadSubscriptions queries legacy kind fallback for pre-migration events", async () => { + const SubscriptionsManager = subscriptions.constructor; + const manager = new SubscriptionsManager(); + + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalWriteRelays = Array.isArray(nostrClient.writeRelays) + ? [...nostrClient.writeRelays] + : nostrClient.writeRelays; + const originalPool = nostrClient.pool; + const originalEnsurePermissions = nostrClient.ensureExtensionPermissions; + + const hadWindow = typeof globalThis.window !== "undefined"; + if (!hadWindow) { + globalThis.window = {}; + } + const originalWindowNostr = globalThis.window.nostr; + + const relayUrls = ["wss://relay-legacy.example"]; + nostrClient.relays = relayUrls; + nostrClient.writeRelays = relayUrls; + + const capturedFilters = []; + nostrClient.pool = { + list(urls, filters) { + const filterArray = Array.isArray(filters) ? filters : []; + if (filterArray.length) { + capturedFilters.push(filterArray[0]); + } + return Promise.resolve([]); + }, + }; + + nostrClient.ensureExtensionPermissions = async () => ({ ok: true }); + + try { + await manager.loadSubscriptions("user-pubkey-legacy"); + + assert.ok( + capturedFilters.length > 0, + "loadSubscriptions should pass subscription filters to pool.list", + ); + + const schemaKind = + getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST)?.kind ?? 30000; + + for (const filter of capturedFilters) { + const kinds = Array.isArray(filter?.kinds) ? filter.kinds : []; + assert.ok(kinds.includes(schemaKind), "filter should include the active follow-set kind"); + assert.ok( + kinds.includes(30002), + "filter should include the legacy kind for backwards compatibility", + ); + const uniqueKinds = Array.from(new Set(kinds)); + assert.equal( + kinds.length, + uniqueKinds.length, + "filter kinds should not contain duplicate entries", + ); + } + } finally { + nostrClient.relays = originalRelays; + nostrClient.writeRelays = originalWriteRelays; + nostrClient.pool = originalPool; + nostrClient.ensureExtensionPermissions = originalEnsurePermissions; + if (typeof originalWindowNostr === "undefined") { + delete globalThis.window.nostr; + } else { + globalThis.window.nostr = originalWindowNostr; + } + if (!hadWindow) { + delete globalThis.window; + } + } +}); + test("loadSubscriptions falls back to nip44 when hinted", async () => { const SubscriptionsManager = subscriptions.constructor; const manager = new SubscriptionsManager();