mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge pull request #1023 from PR0M3TH3AN/codex/update-to-kind-30000-for-subscriptions
Align subscription list events with NIP-51 follow sets
This commit is contained in:
7 files changed
+116
-20
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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": [<user-pubkey>]
|
||||
}
|
||||
```
|
||||
@@ -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.
|
||||
+2
-2
@@ -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**
|
||||
|
||||
@@ -147,13 +147,20 @@ 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', <eventId>]` or `['a', <address>]`), and a stable dedupe tag `['d', <view identifier>]`, 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', <id>]`, `['chunks', <total>]`, repeated `['a', <chunk address>]` 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', <snapshotId:index>]`, `['encrypted','nip04']`, `['snapshot', <id>]`, `['chunk', <index>, <total>]`, 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` (clients also read legacy `30002`) | `['d', 'subscriptions']` | NIP-04/NIP-44 encrypted JSON array of NIP-51 follow-set tuples (e.g., `[['p', <hex>], …]`) |
|
||||
| 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', <pubkey>]` entries | Empty content |
|
||||
| Admin blacklist (`NOTE_TYPES.ADMIN_BLACKLIST`) | `30000` | `['d', 'bitvid:admin:blacklist']`, repeated `['p', <pubkey>]` entries | Empty content |
|
||||
| Admin whitelist (`NOTE_TYPES.ADMIN_WHITELIST`) | `30000` | `['d', 'bitvid:admin:whitelist']`, repeated `['p', <pubkey>]` 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. 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', <mime>]` 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+16
-5
@@ -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,10 @@ 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;
|
||||
const LEGACY_SUBSCRIPTION_SET_KIND = 30002;
|
||||
|
||||
function normalizeHexPubkey(value) {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
@@ -210,7 +216,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 +240,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) {
|
||||
@@ -260,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: [30002],
|
||||
kinds: subscriptionKinds,
|
||||
authors,
|
||||
"#d": [SUBSCRIPTION_LIST_IDENTIFIER],
|
||||
limit: 1
|
||||
@@ -566,7 +577,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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user