From 0e29b07d5bbda0850d136a4a028ab186ef9b16a3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Oct 2025 13:21:59 -0400 Subject: [PATCH] refactor: centralize nostr event schemas --- docs/nostr-event-schemas.md | 56 ++++ js/adminListStore.js | 32 +-- js/nostr.js | 85 ++---- js/nostrEventSchemas.js | 531 ++++++++++++++++++++++++++++++++++++ js/subscriptions.js | 12 +- js/userBlocks.js | 9 +- 6 files changed, 634 insertions(+), 91 deletions(-) create mode 100644 docs/nostr-event-schemas.md create mode 100644 js/nostrEventSchemas.js diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md new file mode 100644 index 00000000..7b295cf5 --- /dev/null +++ b/docs/nostr-event-schemas.md @@ -0,0 +1,56 @@ +# Nostr event schemas + +BitVid now centralizes every note that it publishes to Nostr in +[`js/nostrEventSchemas.js`](../js/nostrEventSchemas.js). The module defines the +kind, required tags, and content format for each note type so troubleshooting +no longer requires hunting through the codebase. It also exposes helpers for +building events and for overriding the schema at runtime when you need to +experiment. + +## Runtime helpers + +```js +import { + NOTE_TYPES, + getNostrEventSchema, + setNostrEventSchemaOverrides, + buildVideoPostEvent, +} from "./nostrEventSchemas.js"; + +// Inspect the current schema +console.log(getNostrEventSchema(NOTE_TYPES.VIDEO_POST)); + +// Temporarily override the kind while debugging +setNostrEventSchemaOverrides({ + [NOTE_TYPES.VIDEO_POST]: { kind: 30000 }, +}); + +// Build an event with the active schema +const event = buildVideoPostEvent({ + pubkey, + created_at: Math.floor(Date.now() / 1000), + dTagValue: "debug-video", + content: { version: 3, title: "Test", videoRootId: "debug" }, +}); +``` + +In the browser you can call the same helpers from DevTools via +`window.BitVidNostrEvents` and `window.BitVidNostrEventOverrides`. + +## Event catalogue + +| Note | Kind (default) | Tags | Content format | +| --- | --- | --- | --- | +| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', ]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `enableComments`, `ws`, `xs`) | +| NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description | +| View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30078`) | `['t','view']`, `['video', ]`, pointer tag (`['a', ...]` or `['e', ...]`), optional dedupe `['d', ]`, optional `['session','true']` when a session actor signs, plus any extra debugging tags | Optional plaintext message | +| Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`) | `WATCH_HISTORY_KIND` | `['d', ]`, `['encrypted','nip04']`, `['snapshot', ]`, `['chunk', , ]`, pointer tags for each entry, `['head','1']` + `['a',
]` pointers on the first chunk | 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[] }` | +| 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 | + +If you introduce a new Nostr feature, add its schema to +`js/nostrEventSchemas.js` so that the catalogue stays complete and so existing +builders inherit the same debugging knobs. diff --git a/js/adminListStore.js b/js/adminListStore.js index 3b9dd969..428a8e9d 100644 --- a/js/adminListStore.js +++ b/js/adminListStore.js @@ -8,12 +8,10 @@ import { isDevMode, } from "./config.js"; import { nostrClient } from "./nostr.js"; - -const LIST_IDENTIFIERS = { - editors: "editors", - whitelist: "whitelist", - blacklist: "blacklist", -}; +import { + buildAdminListEvent, + ADMIN_LIST_IDENTIFIERS, +} from "./nostrEventSchemas.js"; const LEGACY_STORAGE_KEYS = { editors: "bitvid_admin_editors", @@ -319,18 +317,15 @@ async function loadNostrList(identifier) { async function loadNostrState() { const [editors, whitelist, blacklist] = await Promise.all([ - loadNostrList(LIST_IDENTIFIERS.editors), - loadNostrList(LIST_IDENTIFIERS.whitelist), - loadNostrList(LIST_IDENTIFIERS.blacklist), + loadNostrList(ADMIN_LIST_IDENTIFIERS.editors), + loadNostrList(ADMIN_LIST_IDENTIFIERS.whitelist), + loadNostrList(ADMIN_LIST_IDENTIFIERS.blacklist), ]); return sanitizeAdminState({ editors, whitelist, blacklist }); } function buildListEvent(listKey, npubs, actorHex) { - const identifier = LIST_IDENTIFIERS[listKey]; - const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`; - const hexPubkeys = Array.from( new Set(npubs.map((npub) => { try { @@ -360,18 +355,11 @@ function buildListEvent(listKey, npubs, actorHex) { } } - const tags = [["d", dTagValue]]; - hexPubkeys.forEach((hex) => { - tags.push(["p", hex]); - }); - - return { - kind: 30000, + return buildAdminListEvent(listKey, { pubkey: actorHex, created_at: Math.floor(Date.now() / 1000), - tags, - content: "", - }; + hexPubkeys, + }); } function publishToRelay(url, signedEvent, listKey) { diff --git a/js/nostr.js b/js/nostr.js index 943ffa87..95da8ecc 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -16,6 +16,12 @@ import { accessControl } from "./accessControl.js"; // 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable import { deriveTitleFromEvent, magnetFromText } from "./videoEventUtils.js"; import { extractMagnetHints } from "./magnet.js"; +import { + buildVideoPostEvent, + buildVideoMirrorEvent, + buildViewEvent, + buildWatchHistoryChunkEvent, +} from "./nostrEventSchemas.js"; /** * The usual relays @@ -1935,34 +1941,19 @@ class NostrClient { const chunkIdentifier = chunkIdentifiers[index] || WATCH_HISTORY_LIST_IDENTIFIER; - const tags = [ - ["d", chunkIdentifier], - ["encrypted", "nip04"], - ["snapshot", snapshotId], - ["chunk", String(index), String(totalChunks)], - ...pointerTags, - ]; - if (index === 0) { - tags.splice(2, 0, ["head", "1"]); - const pointerTagsForChunks = chunkAddresses - .map((address) => - typeof address === "string" && address - ? ["a", address] - : null - ) - .filter(Boolean); - tags.push(...pointerTagsForChunks); - } - // Ensure created_at stays monotonic so chunk 0 always outranks prior snapshots // even when multiple publishes land within the same wall-clock second. - const event = { - kind: WATCH_HISTORY_KIND, + const event = buildWatchHistoryChunkEvent({ pubkey: normalizedActor, created_at: baseTimestamp + (totalChunks - index - 1), - tags, + chunkIdentifier, + snapshotId, + chunkIndex: index, + totalChunks, + pointerTags, + chunkAddresses, content: encryptionResult.ciphertext, - }; + }); let signedEvent; if (useExtension) { @@ -2234,41 +2225,24 @@ class NostrClient { ? ["e", pointer.value, pointer.relay] : ["e", pointer.value]; - const tags = [ - ["t", "view"], - ["video", pointer.value], - pointerTag, - ...additionalTags, - ]; - const dedupeTagValue = deriveViewEventDedupTag( actorPubkey, pointer, createdAt ); - if ( - dedupeTagValue && - !tags.some((tag) => tag[0] === "d" && tag[1] === dedupeTagValue) - ) { - tags.push(["d", dedupeTagValue]); - } - - if ( - usingSessionActor && - !tags.some((tag) => tag[0] === "session" && tag[1] === "true") - ) { - tags.push(["session", "true"]); - } const content = typeof options.content === "string" ? options.content : ""; - const event = { - kind: WATCH_HISTORY_KIND, + const event = buildViewEvent({ pubkey: actorPubkey, created_at: createdAt, - tags, + pointerValue: pointer.value, + pointerTag, + dedupeTag: dedupeTagValue, + includeSessionTag: usingSessionActor, + additionalTags, content, - }; + }); let signedEvent = null; @@ -3918,16 +3892,12 @@ class NostrClient { contentObject.xs = finalXs; } - const event = { - kind: 30078, + const event = buildVideoPostEvent({ pubkey, created_at: createdAt, - tags: [ - ["t", "video"], - ["d", dTagValue], - ], - content: JSON.stringify(contentObject), - }; + dTagValue, + content: contentObject, + }); if (isDevMode) { console.log("Publish event with brand-new root:", videoRootId); @@ -3972,13 +3942,12 @@ class NostrClient { mirrorTags.push(["magnet", finalMagnet]); } - const mirrorEvent = { - kind: 1063, + const mirrorEvent = buildVideoMirrorEvent({ pubkey, created_at: createdAt, tags: mirrorTags, content: altText, - }; + }); if (isDevMode) { console.log("Prepared NIP-94 mirror event:", mirrorEvent); diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js new file mode 100644 index 00000000..360754d1 --- /dev/null +++ b/js/nostrEventSchemas.js @@ -0,0 +1,531 @@ +import { + ADMIN_LIST_NAMESPACE, + isDevMode, + WATCH_HISTORY_KIND, + WATCH_HISTORY_LIST_IDENTIFIER, +} from "./config.js"; + +export const NOTE_TYPES = Object.freeze({ + VIDEO_POST: "videoPost", + VIDEO_MIRROR: "videoMirror", + VIEW_EVENT: "viewEvent", + WATCH_HISTORY_CHUNK: "watchHistoryChunk", + SUBSCRIPTION_LIST: "subscriptionList", + USER_BLOCK_LIST: "userBlockList", + ADMIN_MODERATION_LIST: "adminModerationList", + ADMIN_BLACKLIST: "adminBlacklist", + ADMIN_WHITELIST: "adminWhitelist", +}); + +export const SUBSCRIPTION_LIST_IDENTIFIER = "subscriptions"; +export const BLOCK_LIST_IDENTIFIER = "user-blocks"; +export const ADMIN_LIST_IDENTIFIERS = Object.freeze({ + moderation: "editors", + editors: "editors", + whitelist: "whitelist", + blacklist: "blacklist", +}); + +const DEFAULT_APPEND_TAGS = []; + +const BASE_SCHEMAS = { + [NOTE_TYPES.VIDEO_POST]: { + type: NOTE_TYPES.VIDEO_POST, + label: "Video post", + kind: 30078, + topicTag: { name: "t", value: "video" }, + identifierTag: { name: "d" }, + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "json", + schemaVersion: 3, + fields: [ + { key: "version", type: "number", required: true }, + { key: "title", type: "string", required: true }, + { key: "url", type: "string", required: false }, + { key: "magnet", type: "string", required: false }, + { key: "thumbnail", type: "string", required: false }, + { key: "description", type: "string", required: false }, + { key: "mode", type: "string", required: false }, + { key: "videoRootId", type: "string", required: true }, + { key: "deleted", type: "boolean", required: false }, + { key: "isPrivate", type: "boolean", required: false }, + { key: "enableComments", type: "boolean", required: false }, + { key: "ws", type: "string", required: false }, + { key: "xs", type: "string", required: false }, + ], + }, + }, + [NOTE_TYPES.VIDEO_MIRROR]: { + type: NOTE_TYPES.VIDEO_MIRROR, + label: "NIP-94 mirror", + kind: 1063, + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "text", + description: "Optional alt text carried alongside hosted URL metadata.", + }, + }, + [NOTE_TYPES.VIEW_EVENT]: { + type: NOTE_TYPES.VIEW_EVENT, + label: "View counter", + kind: WATCH_HISTORY_KIND, + topicTag: { name: "t", value: "view" }, + pointerTagName: "video", + identifierTag: { name: "d" }, + sessionTag: { name: "session", value: "true" }, + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "text", + description: "Optional plaintext content used for diagnostics.", + }, + }, + [NOTE_TYPES.WATCH_HISTORY_CHUNK]: { + type: NOTE_TYPES.WATCH_HISTORY_CHUNK, + label: "Watch history snapshot", + kind: WATCH_HISTORY_KIND, + identifierTag: { + name: "d", + value: WATCH_HISTORY_LIST_IDENTIFIER, + }, + encryptionTag: { name: "encrypted", value: "nip04" }, + snapshotTagName: "snapshot", + chunkTagName: "chunk", + headTag: { name: "head", value: "1" }, + headTagIndex: 2, + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "nip04-json", + description: + "Encrypted JSON payload containing chunked watch history entries.", + }, + }, + [NOTE_TYPES.SUBSCRIPTION_LIST]: { + type: NOTE_TYPES.SUBSCRIPTION_LIST, + label: "Subscription list", + kind: 30002, + identifierTag: { + name: "d", + value: SUBSCRIPTION_LIST_IDENTIFIER, + }, + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "nip04-json", + description: "Encrypted JSON: { subPubkeys: string[] }.", + }, + }, + [NOTE_TYPES.USER_BLOCK_LIST]: { + type: NOTE_TYPES.USER_BLOCK_LIST, + label: "User block list", + kind: 30002, + identifierTag: { + name: "d", + value: BLOCK_LIST_IDENTIFIER, + }, + appendTags: DEFAULT_APPEND_TAGS, + content: { + format: "nip04-json", + description: "Encrypted JSON: { blockedPubkeys: string[] }.", + }, + }, + [NOTE_TYPES.ADMIN_MODERATION_LIST]: { + type: NOTE_TYPES.ADMIN_MODERATION_LIST, + label: "Admin moderation list", + kind: 30000, + identifierTag: { + name: "d", + value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.moderation}`, + }, + participantTagName: "p", + appendTags: DEFAULT_APPEND_TAGS, + content: { format: "empty", description: "Content field unused." }, + }, + [NOTE_TYPES.ADMIN_BLACKLIST]: { + type: NOTE_TYPES.ADMIN_BLACKLIST, + label: "Admin blacklist", + kind: 30000, + identifierTag: { + name: "d", + value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.blacklist}`, + }, + participantTagName: "p", + appendTags: DEFAULT_APPEND_TAGS, + content: { format: "empty", description: "Content field unused." }, + }, + [NOTE_TYPES.ADMIN_WHITELIST]: { + type: NOTE_TYPES.ADMIN_WHITELIST, + label: "Admin whitelist", + kind: 30000, + identifierTag: { + name: "d", + value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.whitelist}`, + }, + participantTagName: "p", + appendTags: DEFAULT_APPEND_TAGS, + content: { format: "empty", description: "Content field unused." }, + }, +}; + +let schemaOverrides = {}; + +function clone(value) { + if (Array.isArray(value)) { + return value.map(clone); + } + if (value && typeof value === "object") { + const result = {}; + for (const [key, nested] of Object.entries(value)) { + result[key] = clone(nested); + } + return result; + } + return value; +} + +function mergeDeep(base, override) { + const result = clone(base); + if (!override || typeof override !== "object") { + return result; + } + for (const [key, value] of Object.entries(override)) { + if (value && typeof value === "object" && !Array.isArray(value)) { + result[key] = mergeDeep(base?.[key] ?? {}, value); + } else { + result[key] = clone(value); + } + } + return result; +} + +function resolveOverride(type) { + const local = schemaOverrides?.[type]; + if (local) { + return local; + } + + if (typeof window !== "undefined") { + const runtimeOverrides = window.BitVidNostrEventOverrides; + if (runtimeOverrides && runtimeOverrides[type]) { + return runtimeOverrides[type]; + } + } + return null; +} + +export function setNostrEventSchemaOverrides(overrides = {}) { + if (overrides && typeof overrides === "object") { + schemaOverrides = overrides; + } else { + schemaOverrides = {}; + } + + if (typeof window !== "undefined") { + window.BitVidNostrEventOverrides = schemaOverrides; + } +} + +export function getNostrEventSchema(type) { + const base = BASE_SCHEMAS[type]; + if (!base) { + return null; + } + const override = resolveOverride(type); + return mergeDeep(base, override); +} + +export function getAllNostrEventSchemas() { + const entries = {}; + for (const type of Object.keys(BASE_SCHEMAS)) { + entries[type] = getNostrEventSchema(type); + } + return entries; +} + +function appendSchemaTags(tags, schema) { + if (!Array.isArray(schema?.appendTags)) { + return tags; + } + schema.appendTags.forEach((tag) => { + if (Array.isArray(tag) && tag.length >= 2) { + tags.push(tag.map((value) => (typeof value === "string" ? value : String(value)))); + } + }); + return tags; +} + +export function buildVideoPostEvent({ + pubkey, + created_at, + dTagValue, + content, + additionalTags = [], +}) { + const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_POST); + const tags = []; + if (schema?.topicTag?.name && schema?.topicTag?.value) { + tags.push([schema.topicTag.name, schema.topicTag.value]); + } + if (schema?.identifierTag?.name && dTagValue) { + tags.push([schema.identifierTag.name, dTagValue]); + } + appendSchemaTags(tags, schema); + if (Array.isArray(additionalTags)) { + additionalTags.forEach((tag) => { + if (Array.isArray(tag) && tag.length >= 2) { + tags.push(tag.map((value) => (typeof value === "string" ? value : String(value)))); + } + }); + } + + return { + kind: schema?.kind ?? 30078, + pubkey, + created_at, + tags, + content: typeof content === "string" ? content : JSON.stringify(content ?? {}), + }; +} + +export function buildVideoMirrorEvent({ + pubkey, + created_at, + tags = [], + content = "", +}) { + const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_MIRROR); + const combinedTags = []; + appendSchemaTags(combinedTags, schema); + if (Array.isArray(tags)) { + tags.forEach((tag) => { + if (Array.isArray(tag) && tag.length >= 2) { + combinedTags.push(tag.map((value) => (typeof value === "string" ? value : String(value)))); + } + }); + } + return { + kind: schema?.kind ?? 1063, + pubkey, + created_at, + tags: combinedTags, + content: typeof content === "string" ? content : String(content ?? ""), + }; +} + +export function buildViewEvent({ + pubkey, + created_at, + pointerValue, + pointerTag, + dedupeTag, + includeSessionTag = false, + additionalTags = [], + content = "", +}) { + const schema = getNostrEventSchema(NOTE_TYPES.VIEW_EVENT); + const tags = []; + if (schema?.topicTag?.name && schema?.topicTag?.value) { + tags.push([schema.topicTag.name, schema.topicTag.value]); + } + + const pointerTagName = schema?.pointerTagName || "video"; + if (pointerValue) { + tags.push([pointerTagName, pointerValue]); + } + if (Array.isArray(pointerTag) && pointerTag.length >= 2) { + tags.push(pointerTag.map((value) => (typeof value === "string" ? value : String(value)))); + } + if (Array.isArray(additionalTags)) { + additionalTags.forEach((tag) => { + if (Array.isArray(tag) && tag.length >= 2) { + tags.push(tag.map((value) => (typeof value === "string" ? value : String(value)))); + } + }); + } + + if (dedupeTag) { + const identifierName = schema?.identifierTag?.name || "d"; + const hasDedupe = tags.some( + (tag) => tag[0] === identifierName && tag[1] === dedupeTag + ); + if (!hasDedupe) { + tags.push([identifierName, dedupeTag]); + } + } + if (includeSessionTag && schema?.sessionTag?.name && schema?.sessionTag?.value) { + const hasSession = tags.some( + (tag) => tag[0] === schema.sessionTag.name && tag[1] === schema.sessionTag.value + ); + if (!hasSession) { + tags.push([schema.sessionTag.name, schema.sessionTag.value]); + } + } + appendSchemaTags(tags, schema); + + return { + kind: schema?.kind ?? WATCH_HISTORY_KIND, + pubkey, + created_at, + tags, + content: typeof content === "string" ? content : String(content ?? ""), + }; +} + +export function buildWatchHistoryChunkEvent({ + pubkey, + created_at, + chunkIdentifier, + snapshotId, + chunkIndex, + totalChunks, + pointerTags = [], + chunkAddresses = [], + content, +}) { + const schema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY_CHUNK); + const tags = []; + const identifierName = schema?.identifierTag?.name || "d"; + const identifierValue = chunkIdentifier || schema?.identifierTag?.value; + if (identifierName && identifierValue) { + tags.push([identifierName, identifierValue]); + } + if (schema?.encryptionTag?.name && schema?.encryptionTag?.value) { + tags.push([schema.encryptionTag.name, schema.encryptionTag.value]); + } + const snapshotTagName = schema?.snapshotTagName; + if (snapshotTagName && snapshotId) { + tags.push([snapshotTagName, snapshotId]); + } + const chunkTagName = schema?.chunkTagName; + if (chunkTagName && typeof chunkIndex === "number" && typeof totalChunks === "number") { + tags.push([chunkTagName, String(chunkIndex), String(totalChunks)]); + } + pointerTags.forEach((tag) => { + if (Array.isArray(tag) && tag.length >= 2) { + tags.push(tag.map((value) => (typeof value === "string" ? value : String(value)))); + } + }); + + if (chunkIndex === 0 && schema?.headTag?.name && schema?.headTag?.value) { + const insertionIndex = Number.isInteger(schema?.headTagIndex) + ? Math.max(0, Math.min(tags.length, schema.headTagIndex)) + : Math.min(tags.length, 2); + tags.splice(insertionIndex, 0, [schema.headTag.name, schema.headTag.value]); + chunkAddresses.forEach((address) => { + if (typeof address === "string" && address) { + tags.push(["a", address]); + } + }); + } + + appendSchemaTags(tags, schema); + + return { + kind: schema?.kind ?? WATCH_HISTORY_KIND, + pubkey, + created_at, + tags, + content: typeof content === "string" ? content : String(content ?? ""), + }; +} + +export function buildSubscriptionListEvent({ + pubkey, + created_at, + content, +}) { + const schema = getNostrEventSchema(NOTE_TYPES.SUBSCRIPTION_LIST); + const tags = []; + const identifierName = schema?.identifierTag?.name || "d"; + const identifierValue = schema?.identifierTag?.value || SUBSCRIPTION_LIST_IDENTIFIER; + if (identifierName && identifierValue) { + tags.push([identifierName, identifierValue]); + } + appendSchemaTags(tags, schema); + return { + kind: schema?.kind ?? 30002, + pubkey, + created_at, + tags, + content: typeof content === "string" ? content : String(content ?? ""), + }; +} + +export function buildBlockListEvent({ + pubkey, + created_at, + content, +}) { + const schema = getNostrEventSchema(NOTE_TYPES.USER_BLOCK_LIST); + const tags = []; + const identifierName = schema?.identifierTag?.name || "d"; + const identifierValue = schema?.identifierTag?.value || BLOCK_LIST_IDENTIFIER; + if (identifierName && identifierValue) { + tags.push([identifierName, identifierValue]); + } + appendSchemaTags(tags, schema); + return { + kind: schema?.kind ?? 30002, + pubkey, + created_at, + tags, + content: typeof content === "string" ? content : String(content ?? ""), + }; +} + +function resolveAdminNoteType(listKey) { + switch (listKey) { + case "moderation": + case "editors": + return NOTE_TYPES.ADMIN_MODERATION_LIST; + case "whitelist": + return NOTE_TYPES.ADMIN_WHITELIST; + case "blacklist": + return NOTE_TYPES.ADMIN_BLACKLIST; + default: + return null; + } +} + +export function buildAdminListEvent(listKey, { pubkey, created_at, hexPubkeys = [] }) { + const schema = getNostrEventSchema(resolveAdminNoteType(listKey)); + if (!schema) { + if (isDevMode) { + console.warn(`[nostrEventSchemas] Unknown admin list key: ${listKey}`); + } + return { + kind: 30000, + pubkey, + created_at, + tags: [], + content: "", + }; + } + + const tags = []; + if (schema?.identifierTag?.name && schema?.identifierTag?.value) { + tags.push([schema.identifierTag.name, schema.identifierTag.value]); + } + const participantTagName = schema?.participantTagName || "p"; + hexPubkeys.forEach((hex) => { + if (typeof hex === "string" && hex.trim()) { + tags.push([participantTagName, hex.trim()]); + } + }); + appendSchemaTags(tags, schema); + return { + kind: schema?.kind ?? 30000, + pubkey, + created_at, + tags, + content: "", + }; +} + +if (typeof window !== "undefined") { + window.BitVidNostrEvents = { + NOTE_TYPES, + getSchema: getNostrEventSchema, + getAllSchemas: getAllNostrEventSchemas, + setOverrides: setNostrEventSchemaOverrides, + }; +} diff --git a/js/subscriptions.js b/js/subscriptions.js index 95392d7f..a11f5b6e 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -5,6 +5,10 @@ import { } from "./nostr.js"; import { attachHealthBadges } from "./gridHealth.js"; import { attachUrlHealthBadges } from "./urlHealthObserver.js"; +import { + buildSubscriptionListEvent, + SUBSCRIPTION_LIST_IDENTIFIER, +} from "./nostrEventSchemas.js"; function getAbsoluteShareUrl(nevent) { if (!nevent) { @@ -58,7 +62,7 @@ class SubscriptionsManager { const filter = { kinds: [30002], authors: [userPubkey], - "#d": ["subscriptions"], + "#d": [SUBSCRIPTION_LIST_IDENTIFIER], limit: 1, }; @@ -168,13 +172,11 @@ class SubscriptionsManager { throw err; } - const evt = { - kind: 30002, + const evt = buildSubscriptionListEvent({ pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), - tags: [["d", "subscriptions"]], content: cipherText, - }; + }); try { const signedEvent = await window.nostr.signEvent(evt); diff --git a/js/userBlocks.js b/js/userBlocks.js index 2845d203..56aaac8f 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -1,7 +1,6 @@ // js/userBlocks.js import { nostrClient } from "./nostr.js"; - -const BLOCK_LIST_IDENTIFIER = "user-blocks"; +import { buildBlockListEvent, BLOCK_LIST_IDENTIFIER } from "./nostrEventSchemas.js"; function normalizeHex(pubkey) { if (typeof pubkey !== "string") { @@ -250,13 +249,11 @@ class UserBlockListManager { throw err; } - const event = { - kind: 30002, + const event = buildBlockListEvent({ pubkey: normalized, created_at: Math.floor(Date.now() / 1000), - tags: [["d", BLOCK_LIST_IDENTIFIER]], content: cipherText, - }; + }); const signedEvent = await window.nostr.signEvent(event);