mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
"Share on Nostr" published a kind-1 note whose body ended with a bare thumbnail
URL. Many clients only inline such a URL when a NIP-92 `imeta` entry describes
it, so the viewer often saw a plain link instead of a preview image.
- buildShareEvent now emits an `imeta` entry for the thumbnail. The media type
is inferred from the extension and OMITTED when unknown -- a wrong `m` is
worse than a missing one, since clients use it to decide whether to render
inline at all, so extensionless Blossom URLs ship url + alt only.
- inferImageMimeTypeFromUrl lives in a SEPARATE map from EXTENSION_MIME_MAP:
that map gates video upload/publish paths, and folding image types into it
would let an image pass a video check.
- shareNostrController was passing only { id, pubkey } to buildShareEvent, so
the tag could never be built; it now forwards title and thumbnail.
When a video has a NIP-71 mirror, the note instead quotes it with
`nostr:naddr1...`, which nostr clients render as a native video quote card --
the closest thing to a rich preview that does not depend on any client fetching
OpenGraph tags (most never do).
- The mirror is addressable (kind 34235/34236, author = video pubkey,
d = videoRootId), so js/nostr/mirrorPointer.js derives the coordinate with no
relay lookup, and the pointer survives the author editing the video where a
plain `nevent` would rot on the next replace.
- The pointer is only embedded after nip71MirrorService.findMirror() confirms
the mirror exists. A pointer to a missing event renders as a broken/empty
quote box, which is worse than the plain-link fallback, so a disabled
service, a throwing lookup, a 2.5s timeout or a missing videoRootId all fall
back to the thumbnail note.
- resolveMirrorKind prefers the kinds actually observed on relays and only then
falls back to the same portrait rule the mirror builder uses, so a derived
pointer matches what would be published rather than guessing independently.
- While quoting, the raw thumbnail URL is dropped from the body and the imeta
tag is skipped: the quote card already shows the thumbnail, and emitting both
rendered the same image twice. An `a` tag links the note to the coordinate.
Known limitation: a client rendering the quote card still has to play the
video, so mirrors whose imeta url is an .m3u8 hit the same HLS wall -- title and
thumbnail render, playback depends on the client.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
3709 lines
103 KiB
JavaScript
3709 lines
103 KiB
JavaScript
/**
|
||
* @fileoverview Central definition for Nostr event schemas, Kinds, and builder functions.
|
||
* This module manages the construction and validation of all application-specific Nostr events,
|
||
* ensuring they conform to defined schemas (NIP-01, NIP-33, etc.).
|
||
*
|
||
* It exports:
|
||
* - NOTE_TYPES: Enum of supported event types.
|
||
* - Builder functions: Pure functions to create unsigned event objects (e.g., buildVideoPostEvent).
|
||
* - Validation helpers: Functions to check event structure against schemas.
|
||
*/
|
||
|
||
import { devLogger } from "./utils/logger.js";
|
||
import { decodeNpubToHex } from "./nostr/nip46Client.js";
|
||
import {
|
||
ADMIN_LIST_NAMESPACE,
|
||
ADMIN_COMMUNITY_BLACKLIST_SOURCES,
|
||
ADMIN_COMMUNITY_BLACKLIST_PREFIX,
|
||
isDevMode,
|
||
WATCH_HISTORY_KIND,
|
||
WATCH_HISTORY_VERSION_TAG_VALUE,
|
||
} from "./config.js";
|
||
import {
|
||
buildStoragePointerValue,
|
||
deriveStoragePointerFromUrl,
|
||
} from "./utils/storagePointer.js";
|
||
import { inferImageMimeTypeFromUrl } from "./utils/mime.js";
|
||
|
||
/**
|
||
* Enumeration of all supported Nostr event types in the application.
|
||
* Maps logical names (e.g., VIDEO_POST) to schema definitions.
|
||
* @readonly
|
||
* @enum {string}
|
||
*/
|
||
export const NOTE_TYPES = Object.freeze({
|
||
VIDEO_POST: "videoPost",
|
||
VIDEO_MIRROR: "videoMirror",
|
||
NIP71_VIDEO: "nip71Video",
|
||
NIP71_SHORT_VIDEO: "nip71ShortVideo",
|
||
REPOST: "repost",
|
||
GENERIC_REPOST: "genericRepost",
|
||
SHARE: "share",
|
||
RELAY_LIST: "relayList",
|
||
VIEW_EVENT: "viewEvent",
|
||
VIDEO_REACTION: "videoReaction",
|
||
VIDEO_COMMENT: "videoComment",
|
||
DM_ATTACHMENT: "dmAttachment",
|
||
DM_READ_RECEIPT: "dmReadReceipt",
|
||
DM_TYPING: "dmTypingIndicator",
|
||
ZAP_REQUEST: "zapRequest",
|
||
ZAP_RECEIPT: "zapReceipt",
|
||
ZAP_TALLY: "zapTally",
|
||
PLAYLIST: "playlist",
|
||
SUBMISSION: "submission",
|
||
WATCH_HISTORY: "watchHistory",
|
||
SUBSCRIPTION_LIST: "subscriptionList",
|
||
SUBSCRIPTION_BACKUP: "subscriptionBackup",
|
||
USER_BLOCK_LIST: "userBlockList",
|
||
HASHTAG_PREFERENCES: "hashtagPreferences",
|
||
DM_RELAY_LIST: "dmRelayList",
|
||
ADMIN_MODERATION_LIST: "adminModerationList",
|
||
ADMIN_BLACKLIST: "adminBlacklist",
|
||
ADMIN_WHITELIST: "adminWhitelist",
|
||
ADMIN_EVENT_BLACKLIST: "adminEventBlacklist",
|
||
PROFILE_METADATA: "profileMetadata",
|
||
MUTE_LIST: "muteList",
|
||
DELETION: "deletion",
|
||
LEGACY_DM: "legacyDm",
|
||
HTTP_AUTH: "httpAuth",
|
||
REPORT: "report",
|
||
GIFT_WRAP: "giftWrap",
|
||
SEAL: "seal",
|
||
CHAT_MESSAGE: "chatMessage",
|
||
});
|
||
|
||
/** Kind for NIP-51 Mute List. */
|
||
export const KIND_MUTE_LIST = 10000;
|
||
|
||
export const SUBSCRIPTION_LIST_IDENTIFIER = "subscriptions";
|
||
export const BLOCK_LIST_IDENTIFIER = "user-blocks";
|
||
|
||
/**
|
||
* Identifiers for admin-related NIP-33 lists.
|
||
* @readonly
|
||
*/
|
||
export const ADMIN_LIST_IDENTIFIERS = Object.freeze({
|
||
moderation: "editors",
|
||
editors: "editors",
|
||
whitelist: "whitelist",
|
||
blacklist: "blacklist",
|
||
eventBlacklist: "event-blacklist",
|
||
communityBlacklistSources: ADMIN_COMMUNITY_BLACKLIST_SOURCES,
|
||
});
|
||
|
||
export { ADMIN_COMMUNITY_BLACKLIST_SOURCES };
|
||
export { ADMIN_COMMUNITY_BLACKLIST_PREFIX };
|
||
|
||
const DEFAULT_APPEND_TAGS = [];
|
||
|
||
// bitvid-native zap tally event kind. Addressable (30000–39999) so `d =
|
||
// payment_hash` yields one canonical event per real payment. In bitvid's family
|
||
// (30078 video, 30079 view). See docs/zap-tally-plan.md.
|
||
export const ZAP_TALLY_KIND = 30081;
|
||
|
||
// bitvid-native creator playlist (#37). Addressable (30000–39999) so `d =
|
||
// playlist id` yields one canonical, replaceable event per playlist. In bitvid's
|
||
// family (30078 video, 30079 view, 30081 zap tally). Deliberately NOT NIP-51's
|
||
// 30005 "video curation set": bitvid already reads 30005 as legacy interest-sets
|
||
// (hashtagPreferencesService), so reusing it would collide. Ordered `a` tags
|
||
// (30078:pubkey:d) reference the videos; order = tag order.
|
||
export const PLAYLIST_KIND = 30082;
|
||
|
||
// bitvid submission (#23): applications / appeals / bug / feature / feedback,
|
||
// PUBLIC + structured so an admin "Submissions" tab can list + approve/deny them
|
||
// (replacing the old kind-4 encrypted DM flow). Addressable (30000–39999) in
|
||
// bitvid's family; published from an EPHEMERAL key (no login), so the `applicant`
|
||
// tag carries the CLAIMED npub the admin vouches for. `d` = ephemeral id.
|
||
export const SUBMISSION_KIND = 30083;
|
||
|
||
let cachedUtf8Encoder = null;
|
||
|
||
const ADDITIONAL_POINTER_TAGS = new Set(["p", "e"]);
|
||
const HEX_32_BYTE_REGEX = /^[0-9a-f]{64}$/i;
|
||
|
||
function safeStringify(value) {
|
||
if (typeof value === "string") {
|
||
return value;
|
||
}
|
||
if (value === null || value === undefined) {
|
||
return "";
|
||
}
|
||
if (typeof value === "object") {
|
||
try {
|
||
return JSON.stringify(value);
|
||
} catch (e) {
|
||
return "";
|
||
}
|
||
}
|
||
try {
|
||
return String(value);
|
||
} catch (err) {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function normalizePointerIdentifier(value) {
|
||
if (typeof value !== "string") {
|
||
return "";
|
||
}
|
||
|
||
const trimmed = value.trim();
|
||
if (!trimmed) {
|
||
return "";
|
||
}
|
||
|
||
if (HEX_32_BYTE_REGEX.test(trimmed)) {
|
||
return trimmed.toLowerCase();
|
||
}
|
||
|
||
const decoded = decodeNpubToHex(trimmed);
|
||
if (decoded && HEX_32_BYTE_REGEX.test(decoded)) {
|
||
return decoded.toLowerCase();
|
||
}
|
||
|
||
return "";
|
||
}
|
||
|
||
function normalizeTagSlotValue(value) {
|
||
if (typeof value === "string") {
|
||
const trimmed = value.trim();
|
||
return trimmed;
|
||
}
|
||
|
||
if (typeof value === "number") {
|
||
if (!Number.isFinite(value)) {
|
||
return "";
|
||
}
|
||
return String(value);
|
||
}
|
||
|
||
if (value === null || value === undefined) {
|
||
return "";
|
||
}
|
||
|
||
return "";
|
||
}
|
||
|
||
/**
|
||
* Normalizes and sanitizes an array of additional user-provided tags.
|
||
* Ensures that tags are arrays of strings and handles hex/npub conversion for 'p'/'e' tags.
|
||
*
|
||
* @param {Array<Array<string>>} additionalTags - Raw tags to sanitize.
|
||
* @returns {Array<Array<string>>} Cleaned tags.
|
||
*/
|
||
export function sanitizeAdditionalTags(additionalTags) {
|
||
if (!Array.isArray(additionalTags) || !additionalTags.length) {
|
||
return [];
|
||
}
|
||
|
||
const sanitized = [];
|
||
|
||
additionalTags.forEach((tag) => {
|
||
if (!Array.isArray(tag) || tag.length < 2) {
|
||
return;
|
||
}
|
||
|
||
const rawName = tag[0];
|
||
if (typeof rawName !== "string") {
|
||
return;
|
||
}
|
||
|
||
const tagName = rawName.trim();
|
||
if (!tagName) {
|
||
return;
|
||
}
|
||
|
||
const lowerName = tagName.toLowerCase();
|
||
let primaryValue = "";
|
||
|
||
if (ADDITIONAL_POINTER_TAGS.has(lowerName)) {
|
||
primaryValue = normalizePointerIdentifier(tag[1]);
|
||
} else {
|
||
primaryValue = normalizeTagSlotValue(tag[1]);
|
||
}
|
||
|
||
if (!primaryValue) {
|
||
return;
|
||
}
|
||
|
||
const sanitizedTag = [tagName, primaryValue];
|
||
|
||
for (let index = 2; index < tag.length; index += 1) {
|
||
const extraValue = normalizeTagSlotValue(tag[index]);
|
||
if (extraValue) {
|
||
sanitizedTag.push(extraValue);
|
||
}
|
||
}
|
||
|
||
sanitized.push(sanitizedTag);
|
||
});
|
||
|
||
return sanitized;
|
||
}
|
||
|
||
function ensureValidUtf8Content(value) {
|
||
let normalized = "";
|
||
if (typeof value === "string") {
|
||
normalized = value;
|
||
} else if (value === undefined || value === null) {
|
||
normalized = "";
|
||
} else if (typeof value === "object") {
|
||
try {
|
||
const stringified = JSON.stringify(value);
|
||
normalized = typeof stringified === "string" ? stringified : "";
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[nostrEventSchemas] Failed to serialize view event content:",
|
||
error
|
||
);
|
||
normalized = "";
|
||
}
|
||
} else {
|
||
normalized = String(value);
|
||
}
|
||
|
||
if (normalized) {
|
||
const builder = [];
|
||
let mutated = false;
|
||
for (let index = 0; index < normalized.length; index += 1) {
|
||
const code = normalized.charCodeAt(index);
|
||
if (code >= 0xd800 && code <= 0xdbff) {
|
||
const nextCode = normalized.charCodeAt(index + 1);
|
||
if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
|
||
builder.push(normalized[index], normalized[index + 1]);
|
||
index += 1;
|
||
} else {
|
||
mutated = true;
|
||
}
|
||
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||
mutated = true;
|
||
} else {
|
||
builder.push(normalized[index]);
|
||
}
|
||
}
|
||
if (mutated) {
|
||
devLogger.warn(
|
||
"[nostrEventSchemas] Dropping unmatched surrogate characters from event content"
|
||
);
|
||
normalized = builder.join("");
|
||
}
|
||
}
|
||
|
||
if (!cachedUtf8Encoder && typeof TextEncoder !== "undefined") {
|
||
cachedUtf8Encoder = new TextEncoder();
|
||
}
|
||
|
||
if (cachedUtf8Encoder) {
|
||
try {
|
||
cachedUtf8Encoder.encode(normalized);
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[nostrEventSchemas] Dropping invalid UTF-8 characters from event content",
|
||
error
|
||
);
|
||
const sanitized = Array.from(normalized)
|
||
.filter((char) => {
|
||
const code = char.charCodeAt(0);
|
||
return code < 0xd800 || code > 0xdfff;
|
||
})
|
||
.join("");
|
||
try {
|
||
cachedUtf8Encoder.encode(sanitized);
|
||
normalized = sanitized;
|
||
} catch (encodeError) {
|
||
devLogger.warn(
|
||
"[nostrEventSchemas] Failed to normalize view event content; defaulting to empty string",
|
||
encodeError
|
||
);
|
||
normalized = "";
|
||
}
|
||
}
|
||
}
|
||
|
||
return normalized;
|
||
}
|
||
|
||
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" },
|
||
requiredTagNames: ["s"],
|
||
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: "isNsfw", type: "boolean", required: false },
|
||
{ key: "isForKids", type: "boolean", required: false },
|
||
{ key: "enableComments", type: "boolean", required: false },
|
||
{ key: "infoHash", type: "string", required: false },
|
||
{ key: "fileSha256", type: "string", required: false },
|
||
{ key: "originalFileSha256", type: "string", required: false },
|
||
{ key: "ws", type: "string", required: false },
|
||
{ key: "xs", type: "string", required: false },
|
||
],
|
||
},
|
||
},
|
||
[NOTE_TYPES.PLAYLIST]: {
|
||
type: NOTE_TYPES.PLAYLIST,
|
||
label: "Playlist",
|
||
kind: PLAYLIST_KIND,
|
||
topicTag: { name: "t", value: "playlist" },
|
||
identifierTag: { name: "d" },
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "text",
|
||
description:
|
||
"Reserved for future notes; playlist data lives in tags (title/description/image + ordered `a` video refs).",
|
||
},
|
||
},
|
||
[NOTE_TYPES.SUBMISSION]: {
|
||
type: NOTE_TYPES.SUBMISSION,
|
||
label: "Submission",
|
||
kind: SUBMISSION_KIND,
|
||
topicTag: { name: "t", value: "bitvid-submission" },
|
||
identifierTag: { name: "d" },
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "text",
|
||
description:
|
||
"Free-text submission body (application reason, appeal, bug report, etc.). Structured fields live in tags: k (type), applicant (npub), p (recipient admin).",
|
||
},
|
||
},
|
||
[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.REPOST]: {
|
||
type: NOTE_TYPES.REPOST,
|
||
label: "NIP-18 repost",
|
||
kind: 6,
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
description: "Content field contains the JSON-serialized event being reposted (or empty if unavailable).",
|
||
},
|
||
},
|
||
[NOTE_TYPES.GENERIC_REPOST]: {
|
||
type: NOTE_TYPES.GENERIC_REPOST,
|
||
label: "Generic repost",
|
||
kind: 16,
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
description: "Content field contains the JSON-serialized event being reposted (or empty if unavailable).",
|
||
},
|
||
},
|
||
[NOTE_TYPES.SHARE]: {
|
||
type: NOTE_TYPES.SHARE,
|
||
label: "Share note",
|
||
kind: 1,
|
||
relayTagName: "r",
|
||
readMarker: "read",
|
||
writeMarker: "write",
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "text",
|
||
description: "Plaintext share note referencing a video.",
|
||
},
|
||
},
|
||
[NOTE_TYPES.NIP71_VIDEO]: {
|
||
type: NOTE_TYPES.NIP71_VIDEO,
|
||
label: "NIP-71 video (normal)",
|
||
kind: 21,
|
||
featureFlag: "FEATURE_PUBLISH_NIP71",
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "text",
|
||
description:
|
||
"Summary or description for the video body; tags carry structured metadata.",
|
||
},
|
||
},
|
||
[NOTE_TYPES.NIP71_SHORT_VIDEO]: {
|
||
type: NOTE_TYPES.NIP71_SHORT_VIDEO,
|
||
label: "NIP-71 video (short)",
|
||
kind: 22,
|
||
featureFlag: "FEATURE_PUBLISH_NIP71",
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "text",
|
||
description:
|
||
"Summary or description for the short-form video; structured fields live in tags.",
|
||
},
|
||
},
|
||
[NOTE_TYPES.RELAY_LIST]: {
|
||
type: NOTE_TYPES.RELAY_LIST,
|
||
label: "Relay list metadata",
|
||
kind: 10002,
|
||
relayTagName: "r",
|
||
readMarker: "read",
|
||
writeMarker: "write",
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: { format: "empty", description: "Content field unused." },
|
||
},
|
||
[NOTE_TYPES.DM_RELAY_LIST]: {
|
||
type: NOTE_TYPES.DM_RELAY_LIST,
|
||
label: "DM relay hints",
|
||
kind: 10050,
|
||
relayTagName: "relay",
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: { format: "empty", description: "Content field unused." },
|
||
},
|
||
[NOTE_TYPES.VIEW_EVENT]: {
|
||
type: NOTE_TYPES.VIEW_EVENT,
|
||
label: "View counter",
|
||
kind: WATCH_HISTORY_KIND,
|
||
topicTag: { name: "t", value: "view" },
|
||
identifierTag: { name: "d" },
|
||
sessionTag: { name: "session", value: "true" },
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "text",
|
||
description: "Optional plaintext content used for diagnostics.",
|
||
},
|
||
},
|
||
[NOTE_TYPES.VIDEO_REACTION]: {
|
||
type: NOTE_TYPES.VIDEO_REACTION,
|
||
label: "Reaction event",
|
||
kind: 7,
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "text",
|
||
description:
|
||
"Reaction payload for the referenced event (e.g., '+', '-', or emoji).",
|
||
},
|
||
},
|
||
[NOTE_TYPES.VIDEO_COMMENT]: {
|
||
type: NOTE_TYPES.VIDEO_COMMENT,
|
||
label: "Video comment",
|
||
kind: 1111,
|
||
videoEventTagName: "E",
|
||
videoDefinitionTagName: "A",
|
||
parentCommentTagName: "E",
|
||
parentAuthorTagName: "P",
|
||
rootEventPointerTagName: "E",
|
||
rootDefinitionPointerTagName: "A",
|
||
rootIdentifierPointerTagName: "I",
|
||
rootKindTagName: "K",
|
||
rootAuthorTagName: "P",
|
||
parentKindTagName: "K",
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "text",
|
||
description: "Plain text comment body sanitized for UTF-8 compatibility.",
|
||
},
|
||
},
|
||
[NOTE_TYPES.DM_ATTACHMENT]: {
|
||
type: NOTE_TYPES.DM_ATTACHMENT,
|
||
label: "DM attachment (NIP-17 file rumor)",
|
||
kind: 15,
|
||
participantTagName: "p",
|
||
hashTagName: "x",
|
||
urlTagName: "url",
|
||
nameTagName: "name",
|
||
typeTagName: "type",
|
||
sizeTagName: "size",
|
||
keyTagName: "k",
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "empty",
|
||
description: "Attachment metadata is carried in tags; content is empty.",
|
||
},
|
||
},
|
||
[NOTE_TYPES.DM_READ_RECEIPT]: {
|
||
type: NOTE_TYPES.DM_READ_RECEIPT,
|
||
label: "DM read receipt",
|
||
kind: 20001,
|
||
recipientTagName: "p",
|
||
eventTagName: "e",
|
||
kindTagName: "k",
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "empty",
|
||
description: "Ephemeral read receipt pointer for a DM event.",
|
||
},
|
||
},
|
||
[NOTE_TYPES.DM_TYPING]: {
|
||
type: NOTE_TYPES.DM_TYPING,
|
||
label: "DM typing indicator",
|
||
kind: 20002,
|
||
recipientTagName: "p",
|
||
eventTagName: "e",
|
||
statusTagName: "t",
|
||
statusTagValue: "typing",
|
||
expirationTagName: "expiration",
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "empty",
|
||
description: "Ephemeral typing indicator with an expiration timestamp.",
|
||
},
|
||
},
|
||
[NOTE_TYPES.ZAP_REQUEST]: {
|
||
type: NOTE_TYPES.ZAP_REQUEST,
|
||
label: "Zap request",
|
||
kind: 9734,
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "text",
|
||
description:
|
||
"Optional zap message included in the zap request sent to LNURL pay callbacks.",
|
||
},
|
||
},
|
||
[NOTE_TYPES.ZAP_RECEIPT]: {
|
||
type: NOTE_TYPES.ZAP_RECEIPT,
|
||
label: "Zap receipt",
|
||
kind: 9735,
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "text",
|
||
description:
|
||
"Zap receipts are published by lightning wallets to confirm zap requests.",
|
||
},
|
||
},
|
||
[NOTE_TYPES.ZAP_TALLY]: {
|
||
// bitvid-native, payer-signed, preimage-verified zap tally (docs/zap-tally-plan.md).
|
||
// Addressable (d = payment_hash) so each real payment maps to one canonical
|
||
// event: relay-level idempotency, re-publish replaces rather than duplicates.
|
||
// Counted alongside NIP-57 9735 receipts (deduped by payment_hash), so a
|
||
// Most-Zapped tally survives when the recipient's wallet never publishes a 9735.
|
||
type: NOTE_TYPES.ZAP_TALLY,
|
||
label: "Zap tally",
|
||
kind: ZAP_TALLY_KIND,
|
||
identifierTag: { name: "d" },
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "text",
|
||
description:
|
||
"Optional zap message; the authoritative amount + proof live in the tags.",
|
||
},
|
||
},
|
||
[NOTE_TYPES.WATCH_HISTORY]: {
|
||
type: NOTE_TYPES.WATCH_HISTORY,
|
||
label: "Watch history month",
|
||
kind: WATCH_HISTORY_KIND,
|
||
identifierTag: {
|
||
name: "d",
|
||
},
|
||
monthTagName: "month",
|
||
appendTags: [["v", WATCH_HISTORY_VERSION_TAG_VALUE]],
|
||
content: {
|
||
format: "json",
|
||
description:
|
||
"JSON payload containing a month's watched event identifiers with optional watchedAt metadata.",
|
||
},
|
||
},
|
||
[NOTE_TYPES.SUBSCRIPTION_LIST]: {
|
||
type: NOTE_TYPES.SUBSCRIPTION_LIST,
|
||
label: "Subscription list",
|
||
kind: 30000,
|
||
identifierTag: {
|
||
name: "d",
|
||
value: SUBSCRIPTION_LIST_IDENTIFIER,
|
||
},
|
||
appendTags: [["encrypted", "nip44_v2"]],
|
||
content: {
|
||
format: "encrypted-tag-list",
|
||
description:
|
||
"NIP-04/NIP-44 encrypted JSON array of NIP-51 tag tuples (e.g., [['p', <hex>], …]).",
|
||
},
|
||
},
|
||
[NOTE_TYPES.SUBSCRIPTION_BACKUP]: {
|
||
type: NOTE_TYPES.SUBSCRIPTION_BACKUP,
|
||
label: "Subscription list backup",
|
||
kind: 30000,
|
||
identifierTag: {
|
||
name: "d",
|
||
},
|
||
appendTags: [["encrypted", "nip44_v2"]],
|
||
content: {
|
||
format: "encrypted-tag-list",
|
||
description:
|
||
"NIP-04/NIP-44 encrypted JSON array of NIP-51 tag tuples (e.g., [['p', <hex>], …]).",
|
||
},
|
||
},
|
||
[NOTE_TYPES.USER_BLOCK_LIST]: {
|
||
type: NOTE_TYPES.USER_BLOCK_LIST,
|
||
label: "User block list (legacy)",
|
||
kind: KIND_MUTE_LIST,
|
||
identifierTag: {
|
||
name: "d",
|
||
value: BLOCK_LIST_IDENTIFIER,
|
||
},
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "encrypted-tag-list",
|
||
description:
|
||
"Legacy NIP-51 block list using custom d tag. Migrated to standard Mute List (kind 10000).",
|
||
},
|
||
},
|
||
[NOTE_TYPES.HASHTAG_PREFERENCES]: {
|
||
type: NOTE_TYPES.HASHTAG_PREFERENCES,
|
||
label: "Hashtag preferences",
|
||
kind: 30015,
|
||
identifierTag: {
|
||
name: "d",
|
||
value: "bitvid:tag-preferences",
|
||
},
|
||
appendTags: [["encrypted", "nip44_v2"]],
|
||
content: {
|
||
format: "nip44-json",
|
||
description:
|
||
"NIP-44 encrypted JSON: { version, interests: string[], disinterests: 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." },
|
||
},
|
||
// Per-event (per-video) admin block list (#25): hides a SINGLE offending event
|
||
// without blocking its author. Same kind-30000 NIP-51 shape as the author lists,
|
||
// but the entries are EVENT ids carried as `e` tags (not `p` pubkey tags).
|
||
[NOTE_TYPES.ADMIN_EVENT_BLACKLIST]: {
|
||
type: NOTE_TYPES.ADMIN_EVENT_BLACKLIST,
|
||
label: "Admin event blacklist",
|
||
kind: 30000,
|
||
identifierTag: {
|
||
name: "d",
|
||
value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.eventBlacklist}`,
|
||
},
|
||
participantTagName: "e",
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: { format: "empty", description: "Content field unused." },
|
||
},
|
||
[NOTE_TYPES.PROFILE_METADATA]: {
|
||
type: NOTE_TYPES.PROFILE_METADATA,
|
||
label: "Profile metadata",
|
||
kind: 0,
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "json",
|
||
description: "Standard NIP-01 profile metadata (name, about, picture, etc.).",
|
||
},
|
||
},
|
||
[NOTE_TYPES.MUTE_LIST]: {
|
||
type: NOTE_TYPES.MUTE_LIST,
|
||
label: "Mute list",
|
||
kind: KIND_MUTE_LIST,
|
||
participantTagName: "p",
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "text",
|
||
description: "Standard NIP-51 Mute List. Public mutes in 'p' tags, private blocks in encrypted content.",
|
||
},
|
||
},
|
||
[NOTE_TYPES.DELETION]: {
|
||
type: NOTE_TYPES.DELETION,
|
||
label: "Deletion",
|
||
kind: 5,
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "text",
|
||
description: "Reason for deletion.",
|
||
},
|
||
},
|
||
[NOTE_TYPES.LEGACY_DM]: {
|
||
type: NOTE_TYPES.LEGACY_DM,
|
||
label: "Legacy Direct Message",
|
||
kind: 4,
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
recipientTagName: "p",
|
||
content: {
|
||
format: "text",
|
||
description: "NIP-04 encrypted ciphertext.",
|
||
},
|
||
},
|
||
[NOTE_TYPES.HTTP_AUTH]: {
|
||
type: NOTE_TYPES.HTTP_AUTH,
|
||
label: "HTTP Auth",
|
||
kind: 27235,
|
||
domainTagName: "u",
|
||
methodTagName: "method",
|
||
payloadTagName: "payload",
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "text",
|
||
description: "Optional content (NIP-98 recommends empty, but some implementations use it).",
|
||
},
|
||
},
|
||
[NOTE_TYPES.REPORT]: {
|
||
type: NOTE_TYPES.REPORT,
|
||
label: "Report",
|
||
kind: 1984,
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "text",
|
||
description: "Report reason.",
|
||
},
|
||
},
|
||
[NOTE_TYPES.GIFT_WRAP]: {
|
||
type: NOTE_TYPES.GIFT_WRAP,
|
||
label: "Gift Wrap",
|
||
kind: 1059,
|
||
recipientTagName: "p",
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "text",
|
||
description: "NIP-44 encrypted seal.",
|
||
},
|
||
},
|
||
[NOTE_TYPES.SEAL]: {
|
||
type: NOTE_TYPES.SEAL,
|
||
label: "Seal",
|
||
kind: 13,
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "text",
|
||
description: "NIP-44 encrypted rumor.",
|
||
},
|
||
},
|
||
[NOTE_TYPES.CHAT_MESSAGE]: {
|
||
type: NOTE_TYPES.CHAT_MESSAGE,
|
||
label: "Chat Message (Rumor)",
|
||
kind: 14,
|
||
participantTagName: "p",
|
||
appendTags: DEFAULT_APPEND_TAGS,
|
||
content: {
|
||
format: "text",
|
||
description: "Plain text chat message.",
|
||
},
|
||
},
|
||
};
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Injects schema overrides for runtime configuration (e.g., feature flags).
|
||
* @param {Object} overrides - A deep object to merge onto BASE_SCHEMAS.
|
||
*/
|
||
export function setNostrEventSchemaOverrides(overrides = {}) {
|
||
if (overrides && typeof overrides === "object") {
|
||
try {
|
||
JSON.stringify(overrides);
|
||
schemaOverrides = overrides;
|
||
} catch (error) {
|
||
devLogger.warn(
|
||
"[nostrEventSchemas] Invalid overrides provided (circular structure?):",
|
||
error
|
||
);
|
||
schemaOverrides = {};
|
||
}
|
||
} else {
|
||
schemaOverrides = {};
|
||
}
|
||
|
||
if (typeof window !== "undefined") {
|
||
window.bitvidNostrEventOverrides = schemaOverrides;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Retrieves the schema configuration for a specific note type.
|
||
* Applies any active runtime overrides.
|
||
*
|
||
* @param {string} type - The NOTE_TYPES key.
|
||
* @returns {Object|null} Schema object or null if not found.
|
||
*/
|
||
export function getNostrEventSchema(type) {
|
||
if (typeof type !== "string") {
|
||
return null;
|
||
}
|
||
const base = BASE_SCHEMAS[type];
|
||
if (!base) {
|
||
return null;
|
||
}
|
||
const override = resolveOverride(type);
|
||
return mergeDeep(base, override);
|
||
}
|
||
|
||
/**
|
||
* Returns all schema definitions with overrides applied.
|
||
* @returns {Object.<string, Object>} Map of schema configurations by type.
|
||
*/
|
||
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;
|
||
}
|
||
|
||
function collectPointerTags(schema, {
|
||
pointerValue,
|
||
pointerTag,
|
||
pointerTags = [],
|
||
} = {}) {
|
||
const normalized = [];
|
||
|
||
const pointerTagName = schema?.pointerTagName;
|
||
if (pointerValue && pointerTagName) {
|
||
const normalizedValue =
|
||
typeof pointerValue === "string" ? pointerValue : String(pointerValue);
|
||
if (normalizedValue) {
|
||
normalized.push([pointerTagName, normalizedValue]);
|
||
}
|
||
}
|
||
|
||
if (Array.isArray(pointerTag) && pointerTag.length >= 2) {
|
||
normalized.push(
|
||
pointerTag.map((value) => (typeof value === "string" ? value : String(value)))
|
||
);
|
||
}
|
||
|
||
if (Array.isArray(pointerTags)) {
|
||
pointerTags.forEach((tag) => {
|
||
if (Array.isArray(tag) && tag.length >= 2) {
|
||
normalized.push(
|
||
tag.map((value) => (typeof value === "string" ? value : String(value)))
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
return normalized;
|
||
}
|
||
|
||
function mergePointerTags(pointerTags = []) {
|
||
if (!Array.isArray(pointerTags) || pointerTags.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const merged = [];
|
||
const pointerIndexByKey = new Map();
|
||
|
||
const toTrimmedString = (value) => {
|
||
if (typeof value === "string") {
|
||
return value.trim();
|
||
}
|
||
if (value === undefined || value === null) {
|
||
return "";
|
||
}
|
||
return String(value).trim();
|
||
};
|
||
|
||
pointerTags.forEach((tag) => {
|
||
if (!Array.isArray(tag) || tag.length < 2) {
|
||
return;
|
||
}
|
||
|
||
const normalizedType = toTrimmedString(tag[0]);
|
||
if (!normalizedType) {
|
||
return;
|
||
}
|
||
|
||
const canonicalType =
|
||
normalizedType === "a" ? "a" : normalizedType === "e" ? "e" : normalizedType;
|
||
|
||
const normalizedValue = toTrimmedString(tag[1]);
|
||
if (!normalizedValue) {
|
||
return;
|
||
}
|
||
|
||
const sanitizedTag = [canonicalType, normalizedValue];
|
||
for (let index = 2; index < tag.length; index += 1) {
|
||
const extra = toTrimmedString(tag[index]);
|
||
if (extra) {
|
||
sanitizedTag.push(extra);
|
||
}
|
||
}
|
||
|
||
if (canonicalType !== "a" && canonicalType !== "e") {
|
||
merged.push(sanitizedTag);
|
||
return;
|
||
}
|
||
|
||
const dedupeKey = `${canonicalType}:${normalizedValue.toLowerCase()}`;
|
||
const relayHint =
|
||
sanitizedTag.length > 2 && typeof sanitizedTag[2] === "string"
|
||
? sanitizedTag[2].trim()
|
||
: "";
|
||
|
||
if (!pointerIndexByKey.has(dedupeKey)) {
|
||
if (relayHint && sanitizedTag.length > 2) {
|
||
sanitizedTag[2] = relayHint;
|
||
}
|
||
const index = merged.length;
|
||
merged.push(sanitizedTag);
|
||
pointerIndexByKey.set(dedupeKey, { index, relay: relayHint });
|
||
return;
|
||
}
|
||
|
||
const existingEntry = pointerIndexByKey.get(dedupeKey);
|
||
const existingTag = merged[existingEntry.index];
|
||
const existingRelay =
|
||
existingTag.length > 2 && typeof existingTag[2] === "string"
|
||
? existingTag[2].trim()
|
||
: "";
|
||
const hasExistingRelay = Boolean(existingRelay);
|
||
const hasNewRelay = Boolean(relayHint);
|
||
|
||
if (
|
||
(!hasExistingRelay && hasNewRelay) ||
|
||
(hasExistingRelay && hasNewRelay && existingRelay !== relayHint)
|
||
) {
|
||
const replacement = sanitizedTag.slice();
|
||
if (relayHint && replacement.length > 2) {
|
||
replacement[2] = relayHint;
|
||
}
|
||
merged[existingEntry.index] = replacement;
|
||
pointerIndexByKey.set(dedupeKey, {
|
||
index: existingEntry.index,
|
||
relay: relayHint,
|
||
});
|
||
}
|
||
});
|
||
|
||
return merged;
|
||
}
|
||
|
||
/**
|
||
* Builds a Video Post event (Kind 30078).
|
||
* Ensures required tags ('d', 's', 't') are present and content is serialized.
|
||
*
|
||
* @param {Object} params - Event parameters.
|
||
* @param {string} params.pubkey - Author hex pubkey.
|
||
* @param {number} params.created_at - Timestamp in seconds.
|
||
* @param {string} params.dTagValue - NIP-33 identifier (video slug).
|
||
* @param {Object|string} params.content - Video metadata payload.
|
||
* @param {Array<Array<string>>} [params.additionalTags] - Extra tags.
|
||
* @returns {Object} Unsigned Nostr event.
|
||
*/
|
||
export function buildVideoPostEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
dTagValue,
|
||
content,
|
||
additionalTags = [],
|
||
} = params || {};
|
||
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);
|
||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||
if (sanitizedAdditionalTags.length) {
|
||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||
}
|
||
|
||
const hasSTag = tags.some((t) => t[0] === "s");
|
||
if (!hasSTag) {
|
||
let contentObj = content;
|
||
if (typeof content === "string") {
|
||
try {
|
||
contentObj = JSON.parse(content);
|
||
} catch (e) {
|
||
contentObj = {};
|
||
}
|
||
}
|
||
contentObj = contentObj || {};
|
||
|
||
let pointer = "";
|
||
if (contentObj.infoHash) {
|
||
pointer = buildStoragePointerValue({
|
||
provider: "btih",
|
||
prefix: contentObj.infoHash,
|
||
});
|
||
} else if (contentObj.url) {
|
||
pointer = deriveStoragePointerFromUrl(contentObj.url);
|
||
}
|
||
|
||
if (!pointer && (dTagValue || contentObj.videoRootId)) {
|
||
const prefix = dTagValue || contentObj.videoRootId;
|
||
pointer = buildStoragePointerValue({ provider: "nostr", prefix });
|
||
}
|
||
|
||
if (pointer) {
|
||
tags.push(["s", pointer]);
|
||
}
|
||
}
|
||
|
||
let serializedContent = "";
|
||
if (typeof content === "string") {
|
||
serializedContent = content;
|
||
} else {
|
||
try {
|
||
serializedContent = JSON.stringify(content ?? {});
|
||
} catch (error) {
|
||
serializedContent = "{}";
|
||
}
|
||
}
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? 30078,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: serializedContent,
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.VIDEO_POST, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds an HTTP Auth event (Kind 27235).
|
||
* Used for NIP-98 authentication.
|
||
*
|
||
* @param {Object} params - Auth params.
|
||
* @param {string} params.url - Target URL.
|
||
* @param {string} params.method - HTTP method.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildHttpAuthEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
url,
|
||
method,
|
||
payload,
|
||
content,
|
||
additionalTags = [],
|
||
} = params || {};
|
||
const schema = getNostrEventSchema(NOTE_TYPES.HTTP_AUTH);
|
||
const tags = [];
|
||
|
||
const domainTagName = schema?.domainTagName || "u";
|
||
if (url) {
|
||
tags.push([domainTagName, url]);
|
||
}
|
||
|
||
const methodTagName = schema?.methodTagName || "method";
|
||
if (method) {
|
||
tags.push([methodTagName, method]);
|
||
}
|
||
|
||
const payloadTagName = schema?.payloadTagName || "payload";
|
||
if (payload) {
|
||
tags.push([payloadTagName, payload]);
|
||
}
|
||
|
||
appendSchemaTags(tags, schema);
|
||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||
if (sanitizedAdditionalTags.length) {
|
||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||
}
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? 27235,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: typeof content === "string" ? content : "",
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.HTTP_AUTH, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a Report event (Kind 1984).
|
||
* Used for flagging content or users.
|
||
*
|
||
* @param {Object} params - Report params.
|
||
* @param {string} [params.eventId] - Reported event ID.
|
||
* @param {string} [params.userId] - Reported user pubkey.
|
||
* @param {string} [params.reportType] - Type of report (e.g., 'spam').
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildReportEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
eventId,
|
||
userId,
|
||
reportType,
|
||
relayHint,
|
||
content,
|
||
additionalTags = [],
|
||
} = params || {};
|
||
const schema = getNostrEventSchema(NOTE_TYPES.REPORT);
|
||
const tags = [];
|
||
|
||
const normalizedReportType = typeof reportType === "string" ? reportType.trim() : "";
|
||
const normalizedRelayHint = typeof relayHint === "string" ? relayHint.trim() : "";
|
||
|
||
if (eventId) {
|
||
const eTag = ["e", eventId];
|
||
if (normalizedReportType || normalizedRelayHint) {
|
||
eTag.push(normalizedReportType);
|
||
}
|
||
if (normalizedRelayHint) {
|
||
eTag.push(normalizedRelayHint);
|
||
}
|
||
tags.push(eTag);
|
||
}
|
||
|
||
if (userId) {
|
||
const pTag = ["p", userId];
|
||
if (normalizedReportType) {
|
||
pTag.push(normalizedReportType);
|
||
}
|
||
tags.push(pTag);
|
||
}
|
||
|
||
// Some implementations use 't' tag for report type too?
|
||
// ModerationService logic:
|
||
// tags.push(["t", normalizedType]);
|
||
// So we support it.
|
||
if (normalizedReportType) {
|
||
// Check if we should add it as 't' tag or if it's already in e/p
|
||
// ModerationService adds it explicitly
|
||
const hasTTag = tags.some(t => t[0] === "t" && t[1] === normalizedReportType);
|
||
if (!hasTTag) {
|
||
// ModerationService does: ["t", normalizedType]
|
||
// But let's check what NIP-56 says. It relies on e/p markers.
|
||
// But we should match what ModerationService expects/does to be safe.
|
||
// It adds ["t", normalizedType]
|
||
tags.push(["t", normalizedReportType]);
|
||
}
|
||
}
|
||
|
||
appendSchemaTags(tags, schema);
|
||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||
if (sanitizedAdditionalTags.length) {
|
||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||
}
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? 1984,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: typeof content === "string" ? content : "",
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.REPORT, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a NIP-59 Gift Wrap event (Kind 1059).
|
||
* Contains an encrypted Seal event.
|
||
*
|
||
* @param {Object} params - Params.
|
||
* @param {string} params.recipientPubkey - Intended recipient.
|
||
* @param {string} params.ciphertext - Encrypted seal.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildGiftWrapEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
recipientPubkey,
|
||
ciphertext = "",
|
||
relayHint,
|
||
additionalTags = [],
|
||
} = params || {};
|
||
const schema = getNostrEventSchema(NOTE_TYPES.GIFT_WRAP);
|
||
const tags = [];
|
||
|
||
const recipient = normalizePointerIdentifier(recipientPubkey);
|
||
if (recipient) {
|
||
const pTag = [schema?.recipientTagName || "p", recipient];
|
||
if (typeof relayHint === "string" && relayHint.trim()) {
|
||
pTag.push(relayHint.trim());
|
||
}
|
||
tags.push(pTag);
|
||
}
|
||
|
||
appendSchemaTags(tags, schema);
|
||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||
if (sanitizedAdditionalTags.length) {
|
||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||
}
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? 1059,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: typeof ciphertext === "string" ? ciphertext : "",
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.GIFT_WRAP, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a NIP-59 Seal event (Kind 13).
|
||
* Wraps the actual rumor event.
|
||
*
|
||
* @param {Object} params - Params.
|
||
* @param {string} params.ciphertext - Encrypted rumor.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildSealEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
ciphertext = "",
|
||
additionalTags = [],
|
||
} = params || {};
|
||
const schema = getNostrEventSchema(NOTE_TYPES.SEAL);
|
||
const tags = [];
|
||
|
||
appendSchemaTags(tags, schema);
|
||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||
if (sanitizedAdditionalTags.length) {
|
||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||
}
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? 13,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: typeof ciphertext === "string" ? ciphertext : "",
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.SEAL, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a Chat Message rumor (Kind 14).
|
||
* Intended to be wrapped in a Seal.
|
||
*
|
||
* @param {Object} params - Params.
|
||
* @param {string} params.content - Chat message text.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildChatMessageEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
recipientPubkey,
|
||
content = "",
|
||
additionalTags = [],
|
||
} = params || {};
|
||
const schema = getNostrEventSchema(NOTE_TYPES.CHAT_MESSAGE);
|
||
const tags = [];
|
||
|
||
const participant = normalizePointerIdentifier(recipientPubkey);
|
||
if (participant) {
|
||
tags.push([schema?.participantTagName || "p", participant]);
|
||
}
|
||
|
||
appendSchemaTags(tags, schema);
|
||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||
if (sanitizedAdditionalTags.length) {
|
||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||
}
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? 14,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: typeof content === "string" ? content : "",
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.CHAT_MESSAGE, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a NIP-94 Video Mirror event (Kind 1063).
|
||
*
|
||
* @param {Object} params - Params.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildVideoMirrorEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
tags = [],
|
||
content = "",
|
||
} = params || {};
|
||
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))));
|
||
}
|
||
});
|
||
}
|
||
const event = {
|
||
kind: schema?.kind ?? 1063,
|
||
pubkey,
|
||
created_at,
|
||
tags: combinedTags,
|
||
content: safeStringify(content),
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.VIDEO_MIRROR, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a bitvid Playlist event (Kind 30082, addressable). All playlist data
|
||
* lives in tags: `d` (id), `title`, `description`, `image`, and ordered `a`
|
||
* refs (`30078:pubkey:d`) to the videos — order is preserved as tag order.
|
||
* Republishing with the same `d` replaces the playlist (NIP-33).
|
||
*
|
||
* @param {Object} params
|
||
* @param {string} params.pubkey
|
||
* @param {number} params.created_at
|
||
* @param {string} params.dTagValue stable playlist id (required)
|
||
* @param {string} [params.title]
|
||
* @param {string} [params.description]
|
||
* @param {string} [params.image]
|
||
* @param {string[]} [params.videoCoordinates] ordered "30078:pubkey:d" strings (`a`)
|
||
* @param {string[]} [params.eventRefs] optional ordered raw event ids (`e`)
|
||
* @param {string} [params.content]
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildPlaylistEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
dTagValue,
|
||
title = "",
|
||
description = "",
|
||
image = "",
|
||
videoCoordinates = [],
|
||
eventRefs = [],
|
||
content = "",
|
||
} = params || {};
|
||
|
||
const identifier = typeof dTagValue === "string" ? dTagValue.trim() : "";
|
||
if (!identifier) {
|
||
throw new Error("A playlist requires a d-tag identifier.");
|
||
}
|
||
|
||
const schema = getNostrEventSchema(NOTE_TYPES.PLAYLIST);
|
||
const tags = [];
|
||
|
||
if (schema?.topicTag?.name && schema?.topicTag?.value) {
|
||
tags.push([schema.topicTag.name, schema.topicTag.value]);
|
||
}
|
||
tags.push([schema?.identifierTag?.name || "d", identifier]);
|
||
|
||
const trimmedTitle = typeof title === "string" ? title.trim() : "";
|
||
if (trimmedTitle) {
|
||
tags.push(["title", trimmedTitle]);
|
||
}
|
||
const trimmedDescription =
|
||
typeof description === "string" ? description.trim() : "";
|
||
if (trimmedDescription) {
|
||
tags.push(["description", trimmedDescription]);
|
||
}
|
||
const trimmedImage = typeof image === "string" ? image.trim() : "";
|
||
if (trimmedImage) {
|
||
tags.push(["image", trimmedImage]);
|
||
}
|
||
|
||
// Ordered video refs; dedupe so the same video can't appear twice.
|
||
const seen = new Set();
|
||
if (Array.isArray(videoCoordinates)) {
|
||
videoCoordinates.forEach((coord) => {
|
||
const value = typeof coord === "string" ? coord.trim() : "";
|
||
if (value && !seen.has(`a:${value}`)) {
|
||
seen.add(`a:${value}`);
|
||
tags.push(["a", value]);
|
||
}
|
||
});
|
||
}
|
||
if (Array.isArray(eventRefs)) {
|
||
eventRefs.forEach((ref) => {
|
||
const value = typeof ref === "string" ? ref.trim() : "";
|
||
if (value && !seen.has(`e:${value}`)) {
|
||
seen.add(`e:${value}`);
|
||
tags.push(["e", value]);
|
||
}
|
||
});
|
||
}
|
||
|
||
appendSchemaTags(tags, schema);
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? PLAYLIST_KIND,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: ensureValidUtf8Content(content),
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.PLAYLIST, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a bitvid Submission event (Kind 30083, addressable, PUBLIC). Structured
|
||
* so an admin "Submissions" tab can list + approve/deny. Published from an
|
||
* ephemeral key, so the `applicant` tag carries the CLAIMED npub.
|
||
*
|
||
* @param {Object} params
|
||
* @param {string} params.pubkey the (ephemeral) author pubkey
|
||
* @param {number} params.created_at
|
||
* @param {string} params.dTagValue stable submission id (required)
|
||
* @param {string} [params.submissionType] application | appeal | bug | feature | feedback
|
||
* @param {string} [params.applicantNpub] the npub the submission is about
|
||
* @param {string} [params.recipientPubkey] the admin hex pubkey (`p` tag)
|
||
* @param {string} [params.content] free-text body
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildSubmissionEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
dTagValue,
|
||
submissionType = "application",
|
||
applicantNpub = "",
|
||
recipientPubkey = "",
|
||
content = "",
|
||
} = params || {};
|
||
|
||
const identifier = typeof dTagValue === "string" ? dTagValue.trim() : "";
|
||
if (!identifier) {
|
||
throw new Error("A submission requires a d-tag identifier.");
|
||
}
|
||
|
||
const schema = getNostrEventSchema(NOTE_TYPES.SUBMISSION);
|
||
const tags = [];
|
||
|
||
if (schema?.topicTag?.name && schema?.topicTag?.value) {
|
||
tags.push([schema.topicTag.name, schema.topicTag.value]);
|
||
}
|
||
tags.push([schema?.identifierTag?.name || "d", identifier]);
|
||
|
||
const type =
|
||
typeof submissionType === "string" && submissionType.trim()
|
||
? submissionType.trim()
|
||
: "application";
|
||
tags.push(["k", type]);
|
||
|
||
const applicant =
|
||
typeof applicantNpub === "string" ? applicantNpub.trim() : "";
|
||
if (applicant) {
|
||
tags.push(["applicant", applicant]);
|
||
}
|
||
|
||
const recipient = normalizePointerIdentifier(recipientPubkey);
|
||
if (recipient) {
|
||
tags.push(["p", recipient]);
|
||
}
|
||
|
||
appendSchemaTags(tags, schema);
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? SUBMISSION_KIND,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: ensureValidUtf8Content(content),
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.SUBMISSION, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a Repost (Kind 6) or Generic Repost (Kind 16) event.
|
||
*
|
||
* @param {Object} params - Repost params.
|
||
* @param {string} [params.eventId] - ID of event to repost.
|
||
* @param {string} [params.address] - NIP-33 address to repost.
|
||
* @param {Object} [params.targetEvent] - Full event object being reposted (for Kind 16).
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildRepostEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
eventId = "",
|
||
eventRelay = "",
|
||
address = "",
|
||
addressRelay = "",
|
||
authorPubkey = "",
|
||
additionalTags = [],
|
||
repostKind,
|
||
targetKind,
|
||
targetEvent = null,
|
||
serializedEvent = "",
|
||
} = params || {};
|
||
const schema = getNostrEventSchema(NOTE_TYPES.REPOST);
|
||
const tags = [];
|
||
|
||
const normalizedEventId = typeof eventId === "string" ? eventId.trim() : "";
|
||
const normalizedEventRelay =
|
||
typeof eventRelay === "string" ? eventRelay.trim() : "";
|
||
|
||
if (normalizedEventId) {
|
||
if (normalizedEventRelay) {
|
||
tags.push(["e", normalizedEventId, normalizedEventRelay]);
|
||
} else {
|
||
tags.push(["e", normalizedEventId]);
|
||
}
|
||
}
|
||
|
||
const normalizedAddress = typeof address === "string" ? address.trim() : "";
|
||
const normalizedAddressRelay = typeof addressRelay === "string" ? addressRelay.trim() : "";
|
||
if (normalizedAddress) {
|
||
if (normalizedAddressRelay) {
|
||
tags.push(["a", normalizedAddress, normalizedAddressRelay]);
|
||
} else {
|
||
tags.push(["a", normalizedAddress]);
|
||
}
|
||
}
|
||
|
||
const normalizedAuthorPubkey =
|
||
typeof authorPubkey === "string" ? authorPubkey.trim().toLowerCase() : "";
|
||
if (normalizedAuthorPubkey) {
|
||
tags.push(["p", normalizedAuthorPubkey]);
|
||
}
|
||
|
||
appendSchemaTags(tags, schema);
|
||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||
if (sanitizedAdditionalTags.length) {
|
||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||
}
|
||
|
||
const normalizedRepostKind =
|
||
Number.isFinite(repostKind) && repostKind !== null
|
||
? Math.floor(repostKind)
|
||
: null;
|
||
const normalizedTargetKind =
|
||
Number.isFinite(targetKind) && targetKind !== null
|
||
? Math.floor(targetKind)
|
||
: null;
|
||
const resolvedKind = (() => {
|
||
if (normalizedRepostKind) {
|
||
return normalizedRepostKind;
|
||
}
|
||
if (normalizedTargetKind !== null) {
|
||
return normalizedTargetKind === 1 ? schema?.kind ?? 6 : 16;
|
||
}
|
||
return schema?.kind ?? 6;
|
||
})();
|
||
|
||
if (resolvedKind === 16 && normalizedTargetKind !== null) {
|
||
tags.push(["k", String(normalizedTargetKind)]);
|
||
}
|
||
|
||
const normalizedSerializedEvent =
|
||
typeof serializedEvent === "string" ? serializedEvent : "";
|
||
let normalizedTargetEvent =
|
||
targetEvent && typeof targetEvent === "object" ? targetEvent : null;
|
||
|
||
if (!normalizedTargetEvent && normalizedSerializedEvent) {
|
||
try {
|
||
normalizedTargetEvent = JSON.parse(normalizedSerializedEvent);
|
||
} catch (error) {
|
||
void error;
|
||
}
|
||
}
|
||
|
||
const hasProtectedTag = Array.isArray(normalizedTargetEvent?.tags)
|
||
? normalizedTargetEvent.tags.some(
|
||
(tag) => Array.isArray(tag) && tag.length && tag[0] === "-",
|
||
)
|
||
: false;
|
||
|
||
let content = "";
|
||
|
||
if (!hasProtectedTag) {
|
||
if (normalizedSerializedEvent) {
|
||
content = normalizedSerializedEvent;
|
||
} else if (normalizedTargetEvent) {
|
||
try {
|
||
content = JSON.stringify(normalizedTargetEvent);
|
||
} catch (error) {
|
||
void error;
|
||
}
|
||
}
|
||
}
|
||
|
||
const event = {
|
||
kind: resolvedKind,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content,
|
||
};
|
||
|
||
if (isDevMode) {
|
||
const type = event.kind === 16 ? NOTE_TYPES.GENERIC_REPOST : NOTE_TYPES.REPOST;
|
||
validateEventAgainstSchema(type, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a Share event (Kind 1).
|
||
* Typically used to share a video URL with text.
|
||
*
|
||
* @param {Object} params - Share params.
|
||
* @param {Object} [params.video] - Video metadata {id, pubkey} for tags.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildShareEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
content = "",
|
||
video = null,
|
||
relays = [],
|
||
additionalTags = [],
|
||
} = params || {};
|
||
const schema = getNostrEventSchema(NOTE_TYPES.SHARE);
|
||
const tags = [];
|
||
const relayTagName = schema?.relayTagName || "r";
|
||
const readMarker = schema?.readMarker || "read";
|
||
const writeMarker = schema?.writeMarker || "write";
|
||
|
||
const normalizedVideoId = normalizePointerIdentifier(video?.id);
|
||
if (normalizedVideoId) {
|
||
tags.push(["e", normalizedVideoId, "", "mention"]);
|
||
}
|
||
|
||
const normalizedVideoPubkey = normalizePointerIdentifier(video?.pubkey);
|
||
if (normalizedVideoPubkey) {
|
||
tags.push(["p", normalizedVideoPubkey, "", "mention"]);
|
||
}
|
||
|
||
// Addressable coordinate of the video's NIP-71 mirror, when one exists. Lets
|
||
// quote-aware clients and indexers link the share note to the video event
|
||
// that its body quotes via `nostr:naddr1…`.
|
||
const mirrorCoordinate =
|
||
typeof video?.mirrorCoordinate === "string"
|
||
? video.mirrorCoordinate.trim()
|
||
: "";
|
||
if (/^\d+:[0-9a-f]{64}:/i.test(mirrorCoordinate)) {
|
||
tags.push(["a", mirrorCoordinate, "", "mention"]);
|
||
}
|
||
|
||
// NIP-92 `imeta` for the thumbnail. The share content ends with the raw
|
||
// thumbnail URL, and many clients only render such a URL inline when an
|
||
// imeta entry describes it — without this the viewer just sees a bare link.
|
||
// Only emitted for http(s) thumbnails: relative paths and data: URIs are
|
||
// meaningless to a reader on another client.
|
||
//
|
||
// SKIPPED when the note quotes a mirror: the quote card already renders the
|
||
// video with its own thumbnail, and the body carries no bare image URL to
|
||
// describe. Emitting both showed the same image twice.
|
||
const quotesMirror =
|
||
typeof video?.mirrorNaddr === "string" && video.mirrorNaddr.trim() !== "";
|
||
const thumbnailUrl =
|
||
typeof video?.thumbnail === "string" ? video.thumbnail.trim() : "";
|
||
if (!quotesMirror && /^https?:\/\//i.test(thumbnailUrl)) {
|
||
const imeta = ["imeta", `url ${thumbnailUrl}`];
|
||
|
||
// Omit `m` entirely when the extension is unrecognized. A wrong media type
|
||
// is worse than a missing one — clients use it to decide whether to render
|
||
// the URL as an image at all.
|
||
const mimeType = inferImageMimeTypeFromUrl(thumbnailUrl);
|
||
if (mimeType) {
|
||
imeta.push(`m ${mimeType}`);
|
||
}
|
||
|
||
// Alt text for screen readers, and a caption fallback in clients that show
|
||
// it. Newlines would corrupt the space-delimited imeta entry, so collapse.
|
||
const altText =
|
||
typeof video?.title === "string" ? video.title.replace(/\s+/g, " ").trim() : "";
|
||
if (altText) {
|
||
imeta.push(`alt ${altText}`);
|
||
}
|
||
|
||
tags.push(imeta);
|
||
}
|
||
|
||
const seenRelayTags = new Set();
|
||
|
||
const pushRelayTag = (url, marker) => {
|
||
const normalizedUrl = typeof url === "string" ? url.trim() : "";
|
||
if (!normalizedUrl) {
|
||
return;
|
||
}
|
||
const normalizedMarker =
|
||
typeof marker === "string" ? marker.trim().toLowerCase() : "";
|
||
const resolvedMarker =
|
||
normalizedMarker === "read" || normalizedMarker === "write"
|
||
? normalizedMarker
|
||
: "";
|
||
const dedupeKey = `${normalizedUrl}|${resolvedMarker}`;
|
||
if (seenRelayTags.has(dedupeKey)) {
|
||
return;
|
||
}
|
||
seenRelayTags.add(dedupeKey);
|
||
if (resolvedMarker) {
|
||
const markerValue = resolvedMarker === "read" ? readMarker : writeMarker;
|
||
tags.push([relayTagName, normalizedUrl, markerValue]);
|
||
} else {
|
||
tags.push([relayTagName, normalizedUrl]);
|
||
}
|
||
};
|
||
|
||
if (Array.isArray(relays)) {
|
||
relays.forEach((entry) => {
|
||
if (typeof entry === "string") {
|
||
pushRelayTag(entry, "");
|
||
return;
|
||
}
|
||
|
||
if (Array.isArray(entry) && entry.length) {
|
||
if (entry[0] === relayTagName) {
|
||
pushRelayTag(entry[1], entry[2]);
|
||
return;
|
||
}
|
||
pushRelayTag(entry[0], entry[1]);
|
||
return;
|
||
}
|
||
|
||
if (entry && typeof entry === "object") {
|
||
const url =
|
||
typeof entry.url === "string"
|
||
? entry.url
|
||
: typeof entry.relay === "string"
|
||
? entry.relay
|
||
: "";
|
||
let marker = "";
|
||
if (typeof entry.mode === "string") {
|
||
marker = entry.mode;
|
||
} else if (typeof entry.marker === "string") {
|
||
marker = entry.marker;
|
||
} else if (entry.read === true && entry.write === false) {
|
||
marker = "read";
|
||
} else if (entry.write === true && entry.read === false) {
|
||
marker = "write";
|
||
}
|
||
pushRelayTag(url, marker);
|
||
}
|
||
});
|
||
}
|
||
|
||
appendSchemaTags(tags, schema);
|
||
|
||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||
if (sanitizedAdditionalTags.length) {
|
||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||
}
|
||
|
||
const resolvedContent = ensureValidUtf8Content(content);
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? 1,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: resolvedContent,
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.SHARE, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a Relay List event (Kind 10002).
|
||
* NIP-65: Defines read/write relays for the user.
|
||
*
|
||
* @param {Object} params - Params.
|
||
* @param {Array<string|Object>} params.relays - List of relay URLs or objects with markers.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildRelayListEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
relays = [],
|
||
additionalTags = [],
|
||
} = params || {};
|
||
const schema = getNostrEventSchema(NOTE_TYPES.RELAY_LIST);
|
||
const tags = [];
|
||
const relayTagName = schema?.relayTagName || "r";
|
||
const readMarker = schema?.readMarker || "read";
|
||
const writeMarker = schema?.writeMarker || "write";
|
||
|
||
if (Array.isArray(relays)) {
|
||
relays.forEach((entry) => {
|
||
let url = "";
|
||
let mode = "";
|
||
if (typeof entry === "string") {
|
||
url = entry;
|
||
} else if (entry && typeof entry === "object") {
|
||
if (typeof entry.url === "string") {
|
||
url = entry.url;
|
||
}
|
||
if (typeof entry.mode === "string") {
|
||
mode = entry.mode;
|
||
} else if (typeof entry.marker === "string") {
|
||
mode = entry.marker;
|
||
} else if (entry.read === true && entry.write === false) {
|
||
mode = "read";
|
||
} else if (entry.write === true && entry.read === false) {
|
||
mode = "write";
|
||
}
|
||
} else if (Array.isArray(entry) && entry.length >= 1) {
|
||
url = typeof entry[0] === "string" ? entry[0] : String(entry[0]);
|
||
if (entry.length > 1 && typeof entry[1] === "string") {
|
||
mode = entry[1];
|
||
}
|
||
}
|
||
|
||
const normalizedUrl = typeof url === "string" ? url.trim() : "";
|
||
if (!normalizedUrl) {
|
||
return;
|
||
}
|
||
|
||
const normalizedMode = typeof mode === "string" ? mode.trim().toLowerCase() : "";
|
||
if (normalizedMode === "read") {
|
||
tags.push([relayTagName, normalizedUrl, readMarker]);
|
||
} else if (normalizedMode === "write") {
|
||
tags.push([relayTagName, normalizedUrl, writeMarker]);
|
||
} else {
|
||
tags.push([relayTagName, normalizedUrl]);
|
||
}
|
||
});
|
||
}
|
||
|
||
appendSchemaTags(tags, schema);
|
||
|
||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||
if (sanitizedAdditionalTags.length) {
|
||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||
}
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? 10002,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: "",
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.RELAY_LIST, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a DM Relay List event (Kind 10050).
|
||
* NIP-17: Hints for where to find DMs.
|
||
*
|
||
* @param {Object} params - Params.
|
||
* @param {Array<string>} params.relays - List of relay URLs.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildDmRelayListEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
relays = [],
|
||
additionalTags = [],
|
||
} = params || {};
|
||
const schema = getNostrEventSchema(NOTE_TYPES.DM_RELAY_LIST);
|
||
const tags = [];
|
||
const relayTagName = schema?.relayTagName || "relay";
|
||
|
||
if (Array.isArray(relays)) {
|
||
relays.forEach((relay) => {
|
||
const normalized = typeof relay === "string" ? relay.trim() : "";
|
||
if (normalized) {
|
||
tags.push([relayTagName, normalized]);
|
||
}
|
||
});
|
||
}
|
||
|
||
appendSchemaTags(tags, schema);
|
||
|
||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||
if (sanitizedAdditionalTags.length) {
|
||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||
}
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? 10050,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: "",
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.DM_RELAY_LIST, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a Profile Metadata event (Kind 0).
|
||
* NIP-01: User profile information.
|
||
*
|
||
* @param {Object} params - Params.
|
||
* @param {Object} params.metadata - Profile object {name, about, picture, ...}.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildProfileMetadataEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
metadata = {},
|
||
content,
|
||
additionalTags = [],
|
||
} = params || {};
|
||
const schema = getNostrEventSchema(NOTE_TYPES.PROFILE_METADATA);
|
||
const tags = [];
|
||
appendSchemaTags(tags, schema);
|
||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||
if (sanitizedAdditionalTags.length) {
|
||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||
}
|
||
|
||
let serializedContent = "";
|
||
if (typeof content === "string") {
|
||
serializedContent = content;
|
||
} else {
|
||
try {
|
||
serializedContent = JSON.stringify(metadata || {});
|
||
} catch (error) {
|
||
serializedContent = "{}";
|
||
}
|
||
}
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? 0,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: serializedContent,
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.PROFILE_METADATA, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a Mute List event (Kind 10000).
|
||
* NIP-51: Public mutes (p tags) and private blocks (encrypted content).
|
||
*
|
||
* @param {Object} params - Params.
|
||
* @param {Array<string>} params.pTags - Publicly muted pubkeys.
|
||
* @param {boolean} [params.encrypted] - If true, adds 'encrypted' tag.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildMuteListEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
pTags = [],
|
||
content = "",
|
||
encrypted = false,
|
||
encryptionTag = "",
|
||
additionalTags = [],
|
||
} = params || {};
|
||
const schema = getNostrEventSchema(NOTE_TYPES.MUTE_LIST);
|
||
const tags = [];
|
||
|
||
const participantTagName = schema?.participantTagName || "p";
|
||
if (Array.isArray(pTags)) {
|
||
pTags.forEach((hex) => {
|
||
if (typeof hex === "string" && hex.trim()) {
|
||
tags.push([participantTagName, hex.trim()]);
|
||
}
|
||
});
|
||
}
|
||
|
||
if (encrypted && encryptionTag) {
|
||
tags.push(["encrypted", encryptionTag]);
|
||
}
|
||
|
||
appendSchemaTags(tags, schema);
|
||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||
if (sanitizedAdditionalTags.length) {
|
||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||
}
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? KIND_MUTE_LIST,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: typeof content === "string" ? content : "",
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.MUTE_LIST, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a Deletion event (Kind 5).
|
||
* NIP-09: Request to delete past events.
|
||
*
|
||
* @param {Object} params - Params.
|
||
* @param {Array<string>} [params.eventIds] - IDs to delete.
|
||
* @param {Array<string>} [params.addresses] - NIP-33 addresses to delete.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildDeletionEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
eventIds = [],
|
||
addresses = [],
|
||
reason = "",
|
||
additionalTags = [],
|
||
} = params || {};
|
||
const schema = getNostrEventSchema(NOTE_TYPES.DELETION);
|
||
const tags = [];
|
||
|
||
if (Array.isArray(eventIds)) {
|
||
eventIds.forEach((id) => {
|
||
const normalized = normalizePointerIdentifier(id);
|
||
if (normalized) {
|
||
tags.push(["e", normalized]);
|
||
}
|
||
});
|
||
}
|
||
|
||
if (Array.isArray(addresses)) {
|
||
addresses.forEach((addr) => {
|
||
const normalized = typeof addr === "string" ? addr.trim() : "";
|
||
if (normalized) {
|
||
tags.push(["a", normalized]);
|
||
}
|
||
});
|
||
}
|
||
|
||
appendSchemaTags(tags, schema);
|
||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||
if (sanitizedAdditionalTags.length) {
|
||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||
}
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? 5,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: typeof reason === "string" ? reason : "",
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.DELETION, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a Legacy Direct Message event (Kind 4).
|
||
* NIP-04: Encrypted text message.
|
||
*
|
||
* @param {Object} params - Params.
|
||
* @param {string} params.recipientPubkey - Recipient hex key.
|
||
* @param {string} params.ciphertext - Encrypted content.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildLegacyDirectMessageEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
recipientPubkey,
|
||
ciphertext = "",
|
||
additionalTags = [],
|
||
} = params || {};
|
||
const schema = getNostrEventSchema(NOTE_TYPES.LEGACY_DM);
|
||
const tags = [];
|
||
|
||
const recipient = normalizePointerIdentifier(recipientPubkey);
|
||
if (recipient) {
|
||
tags.push([schema?.recipientTagName || "p", recipient]);
|
||
}
|
||
|
||
appendSchemaTags(tags, schema);
|
||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||
if (sanitizedAdditionalTags.length) {
|
||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||
}
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? 4,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: typeof ciphertext === "string" ? ciphertext : "",
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.LEGACY_DM, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a DM Attachment event (Kind 15).
|
||
* NIP-17: File rumor with metadata tags (x, url, etc.).
|
||
*
|
||
* @param {Object} params - Params.
|
||
* @param {Object} params.attachment - {url, x (hash), name, type, size, key}.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildDmAttachmentEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
recipientPubkey,
|
||
attachment = {},
|
||
additionalTags = [],
|
||
} = params || {};
|
||
const schema = getNostrEventSchema(NOTE_TYPES.DM_ATTACHMENT);
|
||
const tags = [];
|
||
|
||
const participantTagName = schema?.participantTagName || "p";
|
||
if (recipientPubkey && typeof recipientPubkey === "string") {
|
||
tags.push([participantTagName, recipientPubkey.trim()]);
|
||
}
|
||
|
||
if (attachment?.x) {
|
||
tags.push([schema?.hashTagName || "x", attachment.x]);
|
||
}
|
||
if (attachment?.url) {
|
||
tags.push([schema?.urlTagName || "url", attachment.url]);
|
||
}
|
||
if (attachment?.name) {
|
||
tags.push([schema?.nameTagName || "name", attachment.name]);
|
||
}
|
||
if (attachment?.type) {
|
||
tags.push([schema?.typeTagName || "type", attachment.type]);
|
||
}
|
||
if (Number.isFinite(attachment?.size)) {
|
||
tags.push([schema?.sizeTagName || "size", String(attachment.size)]);
|
||
}
|
||
if (attachment?.key) {
|
||
tags.push([schema?.keyTagName || "k", attachment.key]);
|
||
}
|
||
|
||
appendSchemaTags(tags, schema);
|
||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||
if (sanitizedAdditionalTags.length) {
|
||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||
}
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? 15,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: "",
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.DM_ATTACHMENT, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a DM Read Receipt event (Kind 20001).
|
||
*
|
||
* @param {Object} params - Params.
|
||
* @param {string} params.eventId - ID of message read.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildDmReadReceiptEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
recipientPubkey,
|
||
eventId,
|
||
messageKind,
|
||
additionalTags = [],
|
||
} = params || {};
|
||
const schema = getNostrEventSchema(NOTE_TYPES.DM_READ_RECEIPT);
|
||
const tags = [];
|
||
|
||
const recipient = normalizePointerIdentifier(recipientPubkey);
|
||
if (recipient) {
|
||
tags.push([schema?.recipientTagName || "p", recipient]);
|
||
}
|
||
|
||
const normalizedEventId = normalizePointerIdentifier(eventId);
|
||
if (normalizedEventId) {
|
||
tags.push([schema?.eventTagName || "e", normalizedEventId]);
|
||
}
|
||
|
||
if (Number.isFinite(messageKind)) {
|
||
tags.push([schema?.kindTagName || "k", String(Math.floor(messageKind))]);
|
||
}
|
||
|
||
appendSchemaTags(tags, schema);
|
||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||
if (sanitizedAdditionalTags.length) {
|
||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||
}
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? 20001,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: "",
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.DM_READ_RECEIPT, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a DM Typing Indicator event (Kind 20002).
|
||
* Ephemeral event to show typing status.
|
||
*
|
||
* @param {Object} params - Params.
|
||
* @param {number} params.expiresAt - Expiration timestamp.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildDmTypingIndicatorEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
recipientPubkey,
|
||
eventId,
|
||
expiresAt,
|
||
additionalTags = [],
|
||
} = params || {};
|
||
const schema = getNostrEventSchema(NOTE_TYPES.DM_TYPING);
|
||
const tags = [];
|
||
|
||
const recipient = normalizePointerIdentifier(recipientPubkey);
|
||
if (recipient) {
|
||
tags.push([schema?.recipientTagName || "p", recipient]);
|
||
}
|
||
|
||
const normalizedEventId = normalizePointerIdentifier(eventId);
|
||
if (normalizedEventId) {
|
||
tags.push([schema?.eventTagName || "e", normalizedEventId]);
|
||
}
|
||
|
||
if (schema?.statusTagName && schema?.statusTagValue) {
|
||
tags.push([schema.statusTagName, schema.statusTagValue]);
|
||
}
|
||
|
||
if (Number.isFinite(expiresAt)) {
|
||
tags.push([schema?.expirationTagName || "expiration", String(Math.floor(expiresAt))]);
|
||
}
|
||
|
||
appendSchemaTags(tags, schema);
|
||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||
if (sanitizedAdditionalTags.length) {
|
||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||
}
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? 20002,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: "",
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.DM_TYPING, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a View event (Kind 30078 or WATCH_HISTORY_KIND).
|
||
* Tracks analytics for video views.
|
||
*
|
||
* @param {Object} params - Params.
|
||
* @param {boolean} [params.includeSessionTag] - Whether to include a session marker.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildViewEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
pointerValue,
|
||
pointerTag,
|
||
pointerTags = [],
|
||
dedupeTag,
|
||
includeSessionTag = false,
|
||
additionalTags = [],
|
||
content = "",
|
||
} = params || {};
|
||
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 normalizedPointerTags = collectPointerTags(schema, {
|
||
pointerValue,
|
||
pointerTag,
|
||
pointerTags,
|
||
});
|
||
normalizedPointerTags.forEach((tag) => {
|
||
tags.push(tag);
|
||
});
|
||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||
if (sanitizedAdditionalTags.length) {
|
||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||
}
|
||
|
||
// No random fallback: view events must be parameterized-replaceable on the
|
||
// exact d-tag the caller supplies (publishViewEvent computes a deterministic,
|
||
// window-bucketed one). A random d-tag here would make every view a unique
|
||
// event and defeat relay-side dedupe, inflating view counts (item #4 audit).
|
||
const resolvedDedupeTag = typeof dedupeTag === "string" ? dedupeTag : "";
|
||
|
||
if (resolvedDedupeTag && schema?.identifierTag?.name) {
|
||
const identifierName = schema.identifierTag.name;
|
||
const hasDedupe = tags.some(
|
||
(tag) => tag[0] === identifierName && tag[1] === resolvedDedupeTag
|
||
);
|
||
if (!hasDedupe) {
|
||
tags.push([identifierName, resolvedDedupeTag]);
|
||
}
|
||
}
|
||
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);
|
||
|
||
const resolvedContent = ensureValidUtf8Content(content);
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? WATCH_HISTORY_KIND,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: resolvedContent,
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.VIEW_EVENT, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
// --- WebTorrent piece-map companion event (NIP-78 app data) ------------------
|
||
// Restores P2P for Blossom-hosted videos (whose magnet is webseed-only because
|
||
// Blossom rejects a .torrent) by publishing the torrent metadata as a separate,
|
||
// infohash-keyed event that bitvid fetches lazily on the torrent path.
|
||
// See docs/blossom-torrent-metadata-plan.md.
|
||
export const TORRENT_METADATA_KIND = 30078;
|
||
export const TORRENT_METADATA_DTAG_PREFIX = "bitvid:torrent:";
|
||
export const TORRENT_METADATA_ENVELOPE_VERSION = 1;
|
||
|
||
// Normalize an infohash to the lowercase-hex form used in the addressing key.
|
||
// BitTorrent v1 (WebTorrent) infohashes are 40 hex chars.
|
||
export function normalizeInfoHash(infoHash) {
|
||
const hex = typeof infoHash === "string" ? infoHash.trim().toLowerCase() : "";
|
||
return /^[0-9a-f]{40}$/.test(hex) ? hex : "";
|
||
}
|
||
|
||
// The parameterized-replaceable `d`-tag value that addresses a video's piece-map.
|
||
export function torrentMetadataDTag(infoHash) {
|
||
const hex = normalizeInfoHash(infoHash);
|
||
return hex ? `${TORRENT_METADATA_DTAG_PREFIX}${hex}` : "";
|
||
}
|
||
|
||
/**
|
||
* Builds the WebTorrent piece-map companion event (kind 30078). The `content` is a
|
||
* versioned JSON envelope carrying the full `.torrent` file bytes (base64); the
|
||
* `info` dict inside is what determines the infohash, so storing the raw bytes
|
||
* keeps the reconstructed infohash exact. Addressed by `d = bitvid:torrent:<hex>`
|
||
* so bitvid can look it up from a magnet's `btih`.
|
||
*
|
||
* @param {Object} params
|
||
* @param {string} params.pubkey - Author (the video's author) hex pubkey.
|
||
* @param {number} params.created_at - Unix seconds.
|
||
* @param {string} params.infoHash - BitTorrent v1 infohash (40 hex chars).
|
||
* @param {string} params.torrentBase64 - base64 of the full `.torrent` file bytes.
|
||
* @param {string} [params.videoEventId] - The video event id (back-reference only).
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildTorrentMetadataEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
infoHash,
|
||
torrentBase64,
|
||
videoEventId = "",
|
||
} = params || {};
|
||
|
||
const hex = normalizeInfoHash(infoHash);
|
||
if (!hex) {
|
||
throw new Error(
|
||
"buildTorrentMetadataEvent requires a valid 40-hex-char infoHash."
|
||
);
|
||
}
|
||
if (typeof torrentBase64 !== "string" || !torrentBase64) {
|
||
throw new Error(
|
||
"buildTorrentMetadataEvent requires a non-empty base64 torrent payload."
|
||
);
|
||
}
|
||
|
||
const tags = [
|
||
["d", `${TORRENT_METADATA_DTAG_PREFIX}${hex}`],
|
||
["x", hex],
|
||
["client", "bitvid"],
|
||
];
|
||
if (typeof videoEventId === "string" && videoEventId.trim()) {
|
||
tags.push(["e", videoEventId.trim()]);
|
||
}
|
||
|
||
const content = JSON.stringify({
|
||
v: TORRENT_METADATA_ENVELOPE_VERSION,
|
||
infohash: hex,
|
||
torrent: torrentBase64,
|
||
});
|
||
|
||
return {
|
||
kind: TORRENT_METADATA_KIND,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: ensureValidUtf8Content(content),
|
||
};
|
||
}
|
||
|
||
// --- Blossom server list (BUD-03, kind 10063) --------------------------------
|
||
// A user's preferred Blossom media servers, in order. Lets other clients discover
|
||
// where a user hosts blobs, and lets bitvid pre-fill its storage pane from it.
|
||
export const BLOSSOM_SERVER_LIST_KIND = 10063;
|
||
|
||
/**
|
||
* Builds a Blossom server-list event (BUD-03, kind 10063): one `['server', <url>]`
|
||
* tag per server, in preference order, de-duped.
|
||
*
|
||
* @param {Object} params
|
||
* @param {string} params.pubkey
|
||
* @param {number} params.created_at
|
||
* @param {string[]} params.servers - Ordered server URLs.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildBlossomServerListEvent(params) {
|
||
const { pubkey, created_at, servers } = params || {};
|
||
const seen = new Set();
|
||
const tags = [];
|
||
if (Array.isArray(servers)) {
|
||
for (const entry of servers) {
|
||
const url = typeof entry === "string" ? entry.trim() : "";
|
||
if (url && !seen.has(url)) {
|
||
seen.add(url);
|
||
tags.push(["server", url]);
|
||
}
|
||
}
|
||
}
|
||
tags.push(["client", "bitvid"]);
|
||
return {
|
||
kind: BLOSSOM_SERVER_LIST_KIND,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: "",
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Builds a Zap Request event (Kind 9734).
|
||
* Used to request an invoice from a Lightning Service Provider (LSP).
|
||
*
|
||
* @param {Object} params - Params.
|
||
* @param {number} params.amountSats - Amount in satoshis.
|
||
* @param {string} params.lnurl - LNURL of the recipient.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildZapRequestEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
recipientPubkey,
|
||
relays = [],
|
||
amountSats,
|
||
lnurl,
|
||
eventId,
|
||
coordinate,
|
||
additionalTags = [],
|
||
content = "",
|
||
} = params || {};
|
||
const schema = getNostrEventSchema(NOTE_TYPES.ZAP_REQUEST);
|
||
const tags = [];
|
||
|
||
const normalizedRecipient = normalizePointerIdentifier(recipientPubkey);
|
||
if (normalizedRecipient) {
|
||
tags.push(["p", normalizedRecipient]);
|
||
}
|
||
|
||
const normalizedEventId = normalizePointerIdentifier(eventId);
|
||
if (normalizedEventId) {
|
||
tags.push(["e", normalizedEventId]);
|
||
}
|
||
|
||
if (typeof coordinate === "string" && coordinate.trim()) {
|
||
tags.push(["a", coordinate.trim()]);
|
||
}
|
||
|
||
if (typeof lnurl === "string" && lnurl.trim()) {
|
||
tags.push(["lnurl", lnurl.trim()]);
|
||
}
|
||
|
||
if (Number.isFinite(amountSats)) {
|
||
tags.push(["amount", String(Math.max(0, Math.round(amountSats)) * 1000)]);
|
||
}
|
||
|
||
const relayList = Array.isArray(relays) ? relays : [];
|
||
const relaySeen = new Set();
|
||
const normalizedRelays = [];
|
||
relayList.forEach((relay) => {
|
||
if (typeof relay !== "string") {
|
||
return;
|
||
}
|
||
const trimmed = relay.trim();
|
||
if (!trimmed || relaySeen.has(trimmed)) {
|
||
return;
|
||
}
|
||
relaySeen.add(trimmed);
|
||
normalizedRelays.push(trimmed);
|
||
});
|
||
if (normalizedRelays.length) {
|
||
tags.push(["relays", ...normalizedRelays]);
|
||
}
|
||
|
||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||
if (sanitizedAdditionalTags.length) {
|
||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||
}
|
||
|
||
appendSchemaTags(tags, schema);
|
||
|
||
const resolvedContent = ensureValidUtf8Content(content);
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? 9734,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: resolvedContent,
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.ZAP_REQUEST, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a bitvid-native zap tally event (kind ZAP_TALLY_KIND, addressable by
|
||
* d=payment_hash). Payer-signed, preimage-verified stand-in for a NIP-57 receipt
|
||
* when the recipient's LNURL server doesn't publish one. Carries the same
|
||
* {bolt11, preimage, description(zap request)} a 9735 would, so any client can
|
||
* verify sha256(preimage)==payment_hash and description_hash==sha256(zapRequest).
|
||
* See docs/zap-tally-plan.md §3. Returns an UNSIGNED event; the caller signs.
|
||
*
|
||
* @param {object} params
|
||
* @param {string} params.pubkey payer pubkey
|
||
* @param {number} params.created_at
|
||
* @param {string} params.paymentHash hex; becomes the `d` tag (idempotency)
|
||
* @param {string} params.recipientPubkey the zapped party (`p`)
|
||
* @param {string} [params.eventId] zapped video event id (`e`)
|
||
* @param {string} [params.coordinate] zapped video coordinate kind:pubkey:d (`a`)
|
||
* @param {number} [params.amountMsats] echoes the zap request amount tag
|
||
* @param {string} params.bolt11 paid invoice (carries payment_hash + description_hash)
|
||
* @param {string} params.preimage NWC pay_invoice preimage (proof of settlement)
|
||
* @param {string} params.zapRequestJson the signed 9734 JSON (NIP-57 `description`)
|
||
* @param {string} [params.comment]
|
||
* @returns {object} Unsigned event.
|
||
*/
|
||
export function buildZapTallyEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
paymentHash,
|
||
recipientPubkey,
|
||
eventId,
|
||
coordinate,
|
||
amountMsats,
|
||
bolt11,
|
||
preimage,
|
||
zapRequestJson,
|
||
comment = "",
|
||
} = params || {};
|
||
|
||
const schema = getNostrEventSchema(NOTE_TYPES.ZAP_TALLY);
|
||
const tags = [];
|
||
|
||
const normalizedHash = normalizePointerIdentifier(paymentHash);
|
||
if (normalizedHash) {
|
||
tags.push(["d", normalizedHash]); // addressable identifier = payment_hash
|
||
}
|
||
|
||
const normalizedRecipient = normalizePointerIdentifier(recipientPubkey);
|
||
if (normalizedRecipient) {
|
||
tags.push(["p", normalizedRecipient]);
|
||
}
|
||
|
||
const normalizedEventId = normalizePointerIdentifier(eventId);
|
||
if (normalizedEventId) {
|
||
tags.push(["e", normalizedEventId]);
|
||
}
|
||
|
||
if (typeof coordinate === "string" && coordinate.trim()) {
|
||
tags.push(["a", coordinate.trim()]);
|
||
}
|
||
|
||
if (Number.isFinite(amountMsats)) {
|
||
tags.push(["amount", String(Math.max(0, Math.round(amountMsats)))]);
|
||
}
|
||
|
||
if (typeof bolt11 === "string" && bolt11.trim()) {
|
||
tags.push(["bolt11", bolt11.trim()]);
|
||
}
|
||
|
||
if (typeof preimage === "string" && preimage.trim()) {
|
||
tags.push(["preimage", preimage.trim()]);
|
||
}
|
||
|
||
if (typeof zapRequestJson === "string" && zapRequestJson.trim()) {
|
||
tags.push(["description", zapRequestJson.trim()]);
|
||
}
|
||
|
||
tags.push(["client", "bitvid"]);
|
||
tags.push(["t", "zap"]);
|
||
|
||
appendSchemaTags(tags, schema);
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? ZAP_TALLY_KIND,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: ensureValidUtf8Content(typeof comment === "string" ? comment : ""),
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.ZAP_TALLY, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a Reaction event (Kind 7).
|
||
* E.g., Like ('+'), Dislike ('-'), or emoji.
|
||
*
|
||
* @param {Object} params - Params.
|
||
* @param {string} params.content - Reaction content (+, -, emoji).
|
||
* @param {Object} [params.targetPointer] - Target event details.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildReactionEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
pointerValue,
|
||
pointerTag,
|
||
pointerTags = [],
|
||
targetPointer = null,
|
||
targetAuthorPubkey = "",
|
||
additionalTags = [],
|
||
content = "",
|
||
} = params || {};
|
||
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_REACTION);
|
||
const tags = [];
|
||
|
||
const normalizedPointerTags = collectPointerTags(schema, {
|
||
pointerValue,
|
||
pointerTag,
|
||
pointerTags,
|
||
});
|
||
const mergedPointerTags = mergePointerTags(normalizedPointerTags);
|
||
mergedPointerTags.forEach((tag) => {
|
||
if (Array.isArray(tag) && tag.length >= 2) {
|
||
tags.push(tag);
|
||
}
|
||
});
|
||
|
||
const resolvePointerDetails = (pointerCandidate) => {
|
||
if (!pointerCandidate) {
|
||
return null;
|
||
}
|
||
|
||
if (Array.isArray(pointerCandidate) && pointerCandidate.length >= 2) {
|
||
const [type, value, relay] = pointerCandidate;
|
||
const normalizedType = type === "a" ? "a" : type === "e" ? "e" : "";
|
||
const normalizedValue = typeof value === "string" ? value.trim() : "";
|
||
if (!normalizedType || !normalizedValue) {
|
||
return null;
|
||
}
|
||
const normalizedRelay =
|
||
typeof relay === "string" && relay.trim() ? relay.trim() : "";
|
||
return { type: normalizedType, value: normalizedValue, relay: normalizedRelay };
|
||
}
|
||
|
||
if (pointerCandidate && typeof pointerCandidate === "object") {
|
||
const { type, value, relay } = pointerCandidate;
|
||
const normalizedType = type === "a" ? "a" : type === "e" ? "e" : "";
|
||
const normalizedValue = typeof value === "string" ? value.trim() : "";
|
||
if (!normalizedType || !normalizedValue) {
|
||
return null;
|
||
}
|
||
const normalizedRelay =
|
||
typeof relay === "string" && relay.trim() ? relay.trim() : "";
|
||
return { type: normalizedType, value: normalizedValue, relay: normalizedRelay };
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
const pointerDetails = (() => {
|
||
const explicitPointer = resolvePointerDetails(targetPointer);
|
||
if (explicitPointer) {
|
||
return explicitPointer;
|
||
}
|
||
|
||
const pointerTagEntry = mergedPointerTags.find(
|
||
(tag) => Array.isArray(tag) && tag.length >= 2 && (tag[0] === "a" || tag[0] === "e")
|
||
);
|
||
if (!pointerTagEntry) {
|
||
return null;
|
||
}
|
||
|
||
return resolvePointerDetails(pointerTagEntry);
|
||
})();
|
||
|
||
const resolvedRelay = pointerDetails?.relay || "";
|
||
|
||
const normalizedAuthorPubkey = (() => {
|
||
if (typeof targetAuthorPubkey === "string" && targetAuthorPubkey.trim()) {
|
||
return targetAuthorPubkey.trim();
|
||
}
|
||
|
||
if (pointerDetails?.type === "a" && pointerDetails.value) {
|
||
const segments = pointerDetails.value.split(":");
|
||
if (segments.length >= 2) {
|
||
const candidate = segments[1]?.trim();
|
||
if (candidate) {
|
||
return candidate;
|
||
}
|
||
}
|
||
}
|
||
|
||
return "";
|
||
})();
|
||
|
||
if (normalizedAuthorPubkey) {
|
||
const existingAuthorTag = tags.find(
|
||
(tag) => Array.isArray(tag) && tag[0] === "p" && tag[1] === normalizedAuthorPubkey
|
||
);
|
||
if (!existingAuthorTag) {
|
||
if (resolvedRelay) {
|
||
tags.push(["p", normalizedAuthorPubkey, resolvedRelay]);
|
||
} else {
|
||
tags.push(["p", normalizedAuthorPubkey]);
|
||
}
|
||
} else if (resolvedRelay) {
|
||
if (existingAuthorTag.length < 3) {
|
||
existingAuthorTag.push(resolvedRelay);
|
||
} else if (existingAuthorTag[2] !== resolvedRelay) {
|
||
existingAuthorTag[2] = resolvedRelay;
|
||
}
|
||
}
|
||
}
|
||
|
||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||
if (sanitizedAdditionalTags.length) {
|
||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||
}
|
||
|
||
appendSchemaTags(tags, schema);
|
||
|
||
const resolvedContent = ensureValidUtf8Content(content);
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? 7,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: resolvedContent,
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.VIDEO_REACTION, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
function normalizeCommentInputs(params) {
|
||
const normalizeString = (value) =>
|
||
typeof value === "string" ? value.trim() : "";
|
||
|
||
return {
|
||
normalizedVideoEventId: normalizeString(params.videoEventId),
|
||
normalizedVideoEventRelay: normalizeString(params.videoEventRelay),
|
||
normalizedVideoDefinitionAddress: normalizeString(params.videoDefinitionAddress),
|
||
normalizedVideoDefinitionRelay: normalizeString(params.videoDefinitionRelay),
|
||
normalizedRootIdentifier: normalizeString(params.rootIdentifier),
|
||
normalizedRootIdentifierRelay: normalizeString(params.rootIdentifierRelay),
|
||
normalizedParentCommentId: normalizeString(params.parentCommentId),
|
||
normalizedParentCommentRelay: normalizeString(params.parentCommentRelay),
|
||
normalizedThreadParticipantPubkey: normalizeString(params.threadParticipantPubkey),
|
||
normalizedThreadParticipantRelay: normalizeString(params.threadParticipantRelay),
|
||
normalizedParentIdentifier: normalizeString(params.parentIdentifier),
|
||
normalizedParentIdentifierRelay: normalizeString(params.parentIdentifierRelay),
|
||
resolvedRootKind: normalizeString(params.rootKind),
|
||
resolvedRootAuthorPubkey: normalizeString(params.rootAuthorPubkey),
|
||
resolvedRootAuthorRelay: normalizeString(params.rootAuthorRelay),
|
||
resolvedParentAuthorPubkey: normalizeString(params.parentAuthorPubkey),
|
||
resolvedParentAuthorRelay: normalizeString(params.parentAuthorRelay),
|
||
resolvedParentKind: normalizeString(params.parentKind),
|
||
};
|
||
}
|
||
|
||
function resolveCommentContext(inputs, schema) {
|
||
let {
|
||
resolvedRootKind,
|
||
resolvedRootAuthorPubkey,
|
||
resolvedRootAuthorRelay,
|
||
resolvedParentAuthorPubkey,
|
||
resolvedParentAuthorRelay,
|
||
resolvedParentKind,
|
||
normalizedVideoDefinitionAddress,
|
||
normalizedVideoDefinitionRelay,
|
||
normalizedThreadParticipantPubkey,
|
||
normalizedThreadParticipantRelay,
|
||
normalizedParentCommentId,
|
||
} = inputs;
|
||
|
||
if (normalizedVideoDefinitionAddress) {
|
||
const definitionSegments = normalizedVideoDefinitionAddress.split(":");
|
||
if (!resolvedRootKind && definitionSegments[0]) {
|
||
resolvedRootKind = definitionSegments[0];
|
||
}
|
||
if (!resolvedRootAuthorPubkey && definitionSegments[1]) {
|
||
resolvedRootAuthorPubkey = definitionSegments[1];
|
||
}
|
||
if (!resolvedRootAuthorRelay && normalizedVideoDefinitionRelay) {
|
||
resolvedRootAuthorRelay = normalizedVideoDefinitionRelay;
|
||
}
|
||
}
|
||
|
||
if (!resolvedParentAuthorPubkey) {
|
||
resolvedParentAuthorPubkey = normalizedThreadParticipantPubkey;
|
||
}
|
||
|
||
if (!resolvedParentAuthorRelay) {
|
||
resolvedParentAuthorRelay = normalizedThreadParticipantRelay;
|
||
}
|
||
|
||
if (!resolvedRootAuthorPubkey && !normalizedParentCommentId) {
|
||
resolvedRootAuthorPubkey = resolvedParentAuthorPubkey;
|
||
}
|
||
if (!resolvedRootAuthorRelay && !normalizedParentCommentId) {
|
||
resolvedRootAuthorRelay = resolvedParentAuthorRelay;
|
||
}
|
||
|
||
if (!resolvedParentAuthorPubkey && !normalizedParentCommentId) {
|
||
resolvedParentAuthorPubkey = resolvedRootAuthorPubkey;
|
||
}
|
||
if (!resolvedParentAuthorRelay && !normalizedParentCommentId) {
|
||
resolvedParentAuthorRelay = resolvedRootAuthorRelay;
|
||
}
|
||
|
||
if (!resolvedParentKind) {
|
||
if (normalizedParentCommentId) {
|
||
resolvedParentKind = String(schema?.kind ?? 1111);
|
||
} else if (resolvedRootKind) {
|
||
resolvedParentKind = resolvedRootKind;
|
||
}
|
||
}
|
||
|
||
if (!resolvedRootKind) {
|
||
resolvedRootKind = resolvedParentKind;
|
||
}
|
||
|
||
return {
|
||
resolvedRootKind,
|
||
resolvedRootAuthorPubkey,
|
||
resolvedRootAuthorRelay,
|
||
resolvedParentAuthorPubkey,
|
||
resolvedParentAuthorRelay,
|
||
resolvedParentKind,
|
||
};
|
||
}
|
||
|
||
function buildCommentTags(schema, inputs, context) {
|
||
const {
|
||
normalizedVideoEventId,
|
||
normalizedVideoEventRelay,
|
||
normalizedVideoDefinitionAddress,
|
||
normalizedVideoDefinitionRelay,
|
||
normalizedRootIdentifier,
|
||
normalizedRootIdentifierRelay,
|
||
normalizedParentCommentId,
|
||
normalizedParentCommentRelay,
|
||
normalizedParentIdentifier,
|
||
normalizedParentIdentifierRelay,
|
||
} = inputs;
|
||
|
||
const {
|
||
resolvedRootKind,
|
||
resolvedRootAuthorPubkey,
|
||
resolvedRootAuthorRelay,
|
||
resolvedParentAuthorPubkey,
|
||
resolvedParentAuthorRelay,
|
||
resolvedParentKind,
|
||
} = context;
|
||
|
||
const tags = [];
|
||
|
||
const rootDefinitionPointerTagName = schema?.rootDefinitionPointerTagName || "A";
|
||
const rootEventPointerTagName = schema?.rootEventPointerTagName || "E";
|
||
const rootIdentifierPointerTagName = schema?.rootIdentifierPointerTagName || "I";
|
||
const rootKindTagName = schema?.rootKindTagName || "K";
|
||
const rootAuthorTagName = schema?.rootAuthorTagName || "P";
|
||
const parentIdentifierTagName = schema?.parentIdentifierTagName || "I";
|
||
const videoDefinitionTagName = schema?.videoDefinitionTagName || "A";
|
||
const videoEventTagName = schema?.videoEventTagName || "E";
|
||
const parentCommentTagName = schema?.parentCommentTagName || "E";
|
||
const parentAuthorTagName = schema?.parentAuthorTagName || "P";
|
||
const parentKindTagName = schema?.parentKindTagName || "K";
|
||
|
||
const appendPointerTag = (tagName, value, relay, authorHint) => {
|
||
if (!tagName || !value) {
|
||
return;
|
||
}
|
||
const tag = [tagName, value];
|
||
if (relay) {
|
||
tag.push(relay);
|
||
}
|
||
if (authorHint) {
|
||
tag.push(authorHint);
|
||
}
|
||
const isDuplicate = tags.some(
|
||
(existing) =>
|
||
existing.length === tag.length &&
|
||
existing.every((val, index) => val === tag[index]),
|
||
);
|
||
if (!isDuplicate) {
|
||
tags.push(tag);
|
||
}
|
||
};
|
||
|
||
if (normalizedRootIdentifier) {
|
||
appendPointerTag(
|
||
rootIdentifierPointerTagName,
|
||
normalizedRootIdentifier,
|
||
normalizedRootIdentifierRelay,
|
||
);
|
||
} else if (normalizedVideoDefinitionAddress) {
|
||
appendPointerTag(
|
||
rootDefinitionPointerTagName,
|
||
normalizedVideoDefinitionAddress,
|
||
normalizedVideoDefinitionRelay,
|
||
);
|
||
} else if (normalizedVideoEventId) {
|
||
appendPointerTag(
|
||
rootEventPointerTagName,
|
||
normalizedVideoEventId,
|
||
normalizedVideoEventRelay,
|
||
resolvedRootAuthorPubkey,
|
||
);
|
||
}
|
||
|
||
if (rootKindTagName && resolvedRootKind) {
|
||
tags.push([rootKindTagName, resolvedRootKind]);
|
||
}
|
||
|
||
if (rootAuthorTagName && resolvedRootAuthorPubkey) {
|
||
if (resolvedRootAuthorRelay) {
|
||
tags.push([rootAuthorTagName, resolvedRootAuthorPubkey, resolvedRootAuthorRelay]);
|
||
} else {
|
||
tags.push([rootAuthorTagName, resolvedRootAuthorPubkey]);
|
||
}
|
||
}
|
||
|
||
const shouldAppendVideoEventTag =
|
||
Boolean(videoEventTagName) && Boolean(normalizedVideoEventId);
|
||
|
||
if (shouldAppendVideoEventTag) {
|
||
appendPointerTag(
|
||
videoEventTagName,
|
||
normalizedVideoEventId,
|
||
normalizedVideoEventRelay,
|
||
);
|
||
}
|
||
|
||
if (videoDefinitionTagName && normalizedVideoDefinitionAddress) {
|
||
appendPointerTag(
|
||
videoDefinitionTagName,
|
||
normalizedVideoDefinitionAddress,
|
||
normalizedVideoDefinitionRelay,
|
||
);
|
||
}
|
||
|
||
if (normalizedParentIdentifier) {
|
||
appendPointerTag(
|
||
parentIdentifierTagName,
|
||
normalizedParentIdentifier,
|
||
normalizedParentIdentifierRelay,
|
||
);
|
||
}
|
||
|
||
if (parentCommentTagName && normalizedParentCommentId) {
|
||
appendPointerTag(
|
||
parentCommentTagName,
|
||
normalizedParentCommentId,
|
||
normalizedParentCommentRelay,
|
||
resolvedParentAuthorPubkey,
|
||
);
|
||
}
|
||
|
||
if (parentKindTagName && resolvedParentKind) {
|
||
tags.push([parentKindTagName, resolvedParentKind]);
|
||
}
|
||
|
||
if (parentAuthorTagName && resolvedParentAuthorPubkey) {
|
||
const existingParentAuthorTag = tags.find(
|
||
(tag) => Array.isArray(tag) && tag[0] === parentAuthorTagName && tag[1] === resolvedParentAuthorPubkey,
|
||
);
|
||
if (!existingParentAuthorTag) {
|
||
if (resolvedParentAuthorRelay) {
|
||
tags.push([parentAuthorTagName, resolvedParentAuthorPubkey, resolvedParentAuthorRelay]);
|
||
} else {
|
||
tags.push([parentAuthorTagName, resolvedParentAuthorPubkey]);
|
||
}
|
||
} else if (resolvedParentAuthorRelay) {
|
||
if (existingParentAuthorTag.length < 3) {
|
||
existingParentAuthorTag.push(resolvedParentAuthorRelay);
|
||
} else if (existingParentAuthorTag[2] !== resolvedParentAuthorRelay) {
|
||
existingParentAuthorTag[2] = resolvedParentAuthorRelay;
|
||
}
|
||
}
|
||
}
|
||
|
||
return tags;
|
||
}
|
||
|
||
/**
|
||
* Builds a Video Comment event (Kind 1111).
|
||
* Supports threaded replies via complex tag structures (root, parent, video def).
|
||
*
|
||
* @param {Object} params - Comment params including root/parent references.
|
||
* @param {string} params.content - Comment text.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildCommentEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
additionalTags = [],
|
||
content = "",
|
||
} = params || {};
|
||
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT);
|
||
|
||
const inputs = normalizeCommentInputs(params || {});
|
||
const context = resolveCommentContext(inputs, schema);
|
||
const tags = buildCommentTags(schema, inputs, context);
|
||
|
||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||
if (sanitizedAdditionalTags.length) {
|
||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||
}
|
||
|
||
appendSchemaTags(tags, schema);
|
||
|
||
const resolvedContent = ensureValidUtf8Content(content);
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? 1111,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: resolvedContent,
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.VIDEO_COMMENT, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a Watch History event (Kind 30078 variant).
|
||
* Stores watched video IDs for a specific month.
|
||
*
|
||
* @param {Object} params - Params.
|
||
* @param {string} params.monthIdentifier - 'YYYY-MM' slug.
|
||
* @param {Array<Array<string>>} params.pointerTags - Watched items (v tags).
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildWatchHistoryEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
monthIdentifier,
|
||
pointerTags = [],
|
||
content,
|
||
} = params || {};
|
||
const schema = getNostrEventSchema(NOTE_TYPES.WATCH_HISTORY);
|
||
const tags = [];
|
||
const identifierName = schema?.identifierTag?.name || "d";
|
||
|
||
const defaultMonth = Number.isFinite(created_at)
|
||
? new Date(created_at * 1000).toISOString().slice(0, 7)
|
||
: new Date().toISOString().slice(0, 7);
|
||
|
||
const identifierValue =
|
||
(typeof monthIdentifier === "string" && monthIdentifier.trim()) ||
|
||
schema?.identifierTag?.value ||
|
||
defaultMonth;
|
||
|
||
if (identifierName && identifierValue) {
|
||
tags.push([identifierName, identifierValue]);
|
||
}
|
||
const monthTagName = schema?.monthTagName || "month";
|
||
if (monthTagName && identifierValue) {
|
||
tags.push([monthTagName, identifierValue]);
|
||
}
|
||
if (Array.isArray(pointerTags)) {
|
||
pointerTags.forEach((tag) => {
|
||
if (Array.isArray(tag) && tag.length >= 2) {
|
||
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
|
||
}
|
||
});
|
||
}
|
||
|
||
appendSchemaTags(tags, schema);
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? WATCH_HISTORY_KIND,
|
||
pubkey,
|
||
created_at: Number.isFinite(created_at) ? created_at : Math.floor(Date.now() / 1000),
|
||
tags,
|
||
content: safeStringify(content),
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.WATCH_HISTORY, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a Subscription List event (Kind 30000).
|
||
* Stores user channel subscriptions (NIP-51).
|
||
*
|
||
* @param {Object} params - Params.
|
||
* @param {string} [params.encryption] - NIP-04/44 encryption payload.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildSubscriptionListEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
content,
|
||
encryption,
|
||
} = params || {};
|
||
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);
|
||
const normalizedEncryption =
|
||
typeof encryption === "string" ? encryption.trim() : "";
|
||
if (normalizedEncryption) {
|
||
let updated = false;
|
||
for (const tag of tags) {
|
||
if (Array.isArray(tag) && tag[0] === "encrypted") {
|
||
tag[1] = normalizedEncryption;
|
||
updated = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!updated) {
|
||
tags.push(["encrypted", normalizedEncryption]);
|
||
}
|
||
}
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? 30000,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: safeStringify(content),
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.SUBSCRIPTION_LIST, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a User Block List event (Legacy).
|
||
*
|
||
* @param {Object} params - Params.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildBlockListEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
content,
|
||
encryption,
|
||
} = params || {};
|
||
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]);
|
||
}
|
||
const normalizedEncryption =
|
||
typeof encryption === "string" ? encryption.trim() : "";
|
||
if (normalizedEncryption) {
|
||
tags.push(["encrypted", normalizedEncryption]);
|
||
}
|
||
appendSchemaTags(tags, schema);
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? 10000,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: safeStringify(content),
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.USER_BLOCK_LIST, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
/**
|
||
* Builds a Hashtag Preferences event (Kind 30015).
|
||
* Stores user tag interests/blocks.
|
||
*
|
||
* @param {Object} params - Params.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildHashtagPreferenceEvent(params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
content,
|
||
} = params || {};
|
||
const schema = getNostrEventSchema(NOTE_TYPES.HASHTAG_PREFERENCES);
|
||
const tags = [];
|
||
const identifierName = schema?.identifierTag?.name || "d";
|
||
const identifierValue = schema?.identifierTag?.value || "bitvid:tag-preferences";
|
||
if (identifierName && identifierValue) {
|
||
tags.push([identifierName, identifierValue]);
|
||
}
|
||
appendSchemaTags(tags, schema);
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? 30015,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: safeStringify(content),
|
||
};
|
||
|
||
if (isDevMode) {
|
||
validateEventAgainstSchema(NOTE_TYPES.HASHTAG_PREFERENCES, event);
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
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;
|
||
case "eventBlacklist":
|
||
case "event-blacklist":
|
||
return NOTE_TYPES.ADMIN_EVENT_BLACKLIST;
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Builds an Admin List event (Kind 30000).
|
||
* Used for moderation lists (whitelist, blacklist, etc.).
|
||
*
|
||
* @param {string} listKey - 'moderation', 'whitelist', 'blacklist'.
|
||
* @param {Object} params - Params.
|
||
* @param {Array<string>} params.hexPubkeys - List of affected users.
|
||
* @returns {Object} Unsigned event.
|
||
*/
|
||
export function buildAdminListEvent(listKey, params) {
|
||
const {
|
||
pubkey,
|
||
created_at,
|
||
hexPubkeys = []
|
||
} = params || {};
|
||
const schema = getNostrEventSchema(resolveAdminNoteType(listKey));
|
||
if (!schema) {
|
||
devLogger.warn(
|
||
`[nostrEventSchemas] Unknown admin list key: ${safeStringify(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";
|
||
if (Array.isArray(hexPubkeys)) {
|
||
hexPubkeys.forEach((hex) => {
|
||
if (typeof hex === "string" && hex.trim()) {
|
||
tags.push([participantTagName, hex.trim()]);
|
||
}
|
||
});
|
||
}
|
||
appendSchemaTags(tags, schema);
|
||
|
||
const event = {
|
||
kind: schema?.kind ?? 30000,
|
||
pubkey,
|
||
created_at,
|
||
tags,
|
||
content: "",
|
||
};
|
||
|
||
if (isDevMode) {
|
||
const resolvedType = resolveAdminNoteType(listKey);
|
||
if (resolvedType) {
|
||
validateEventAgainstSchema(resolvedType, event);
|
||
}
|
||
}
|
||
|
||
return event;
|
||
}
|
||
|
||
function hasTag(tags, tagName, tagValue = null) {
|
||
if (!Array.isArray(tags)) return false;
|
||
return tags.some(
|
||
(tag) =>
|
||
Array.isArray(tag) &&
|
||
tag[0] === tagName &&
|
||
(tagValue === null || tag[1] === tagValue)
|
||
);
|
||
}
|
||
|
||
function hasTagName(tags, tagName) {
|
||
if (!Array.isArray(tags)) return false;
|
||
return tags.some((tag) => {
|
||
if (!Array.isArray(tag) || tag[0] !== tagName) {
|
||
return false;
|
||
}
|
||
if (tag.length < 2 || typeof tag[1] !== "string") {
|
||
return false;
|
||
}
|
||
return tag[1].trim().length > 0;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Validates an event object against the schema for a given type.
|
||
* Checks kind, required tags, and content structure.
|
||
*
|
||
* @param {string} type - NOTE_TYPES key.
|
||
* @param {Object} event - The event object to validate.
|
||
* @returns {{valid: boolean, errors: Array<string>}} Validation result.
|
||
*/
|
||
export function validateEventStructure(type, event) {
|
||
const schema = getNostrEventSchema(type);
|
||
const errors = [];
|
||
|
||
if (!event || typeof event !== "object") {
|
||
return { valid: false, errors: ["Event is null or not an object"] };
|
||
}
|
||
|
||
if (!schema) {
|
||
return { valid: false, errors: [`Unknown note type: ${type}`] };
|
||
}
|
||
|
||
if (event.kind !== schema.kind) {
|
||
errors.push(
|
||
`Kind mismatch for ${type}: expected ${schema.kind}, got ${event.kind}`
|
||
);
|
||
}
|
||
|
||
if (schema.topicTag) {
|
||
if (!hasTag(event.tags, schema.topicTag.name, schema.topicTag.value)) {
|
||
errors.push(
|
||
`Missing topic tag for ${type}: ${schema.topicTag.name}=${schema.topicTag.value}`
|
||
);
|
||
}
|
||
}
|
||
|
||
if (schema.identifierTag) {
|
||
const expectedValue = schema.identifierTag.value;
|
||
if (!hasTag(event.tags, schema.identifierTag.name, expectedValue)) {
|
||
errors.push(
|
||
`Missing identifier tag for ${type}: ${schema.identifierTag.name}${
|
||
expectedValue ? "=" + expectedValue : ""
|
||
}`
|
||
);
|
||
}
|
||
}
|
||
|
||
if (Array.isArray(schema.requiredTagNames)) {
|
||
schema.requiredTagNames.forEach((tagName) => {
|
||
if (!hasTagName(event.tags, tagName)) {
|
||
errors.push(`Missing required tag for ${type}: ${tagName}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
if (schema.appendTags) {
|
||
schema.appendTags.forEach((appendTag) => {
|
||
if (Array.isArray(appendTag) && appendTag.length >= 2) {
|
||
const tagName = appendTag[0];
|
||
const tagValue = appendTag[1];
|
||
if (!hasTag(event.tags, tagName, tagValue)) {
|
||
errors.push(
|
||
`Missing append tag for ${type}: ${tagName}=${tagValue}`
|
||
);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
if (schema.content) {
|
||
if (schema.content.format === "json") {
|
||
let parsed = null;
|
||
try {
|
||
parsed = JSON.parse(event.content);
|
||
} catch (e) {
|
||
errors.push(`Content is not valid JSON for ${type}`);
|
||
}
|
||
|
||
if (parsed && schema.content.fields) {
|
||
schema.content.fields.forEach((field) => {
|
||
const value = parsed[field.key];
|
||
if (field.required && (value === undefined || value === null)) {
|
||
errors.push(`Missing required field in JSON content: ${field.key}`);
|
||
}
|
||
if (
|
||
value !== undefined &&
|
||
value !== null &&
|
||
field.type &&
|
||
typeof value !== field.type
|
||
) {
|
||
errors.push(
|
||
`Field '${field.key}' should be of type ${field.type}, got ${typeof value}`
|
||
);
|
||
}
|
||
});
|
||
}
|
||
} else if (schema.content.format === "empty") {
|
||
if (event.content !== "") {
|
||
errors.push(`Content should be empty for ${type}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
return { valid: errors.length === 0, errors };
|
||
}
|
||
|
||
export function validateEventAgainstSchema(type, event) {
|
||
if (!isDevMode || !event) return;
|
||
|
||
const { valid, errors } = validateEventStructure(type, event);
|
||
if (!valid) {
|
||
errors.forEach((msg) => devLogger.warn(`[schema] ${msg}`));
|
||
}
|
||
}
|
||
|
||
if (typeof window !== "undefined") {
|
||
window.bitvidNostrEvents = {
|
||
NOTE_TYPES,
|
||
getSchema: getNostrEventSchema,
|
||
getAllSchemas: getAllNostrEventSchemas,
|
||
setOverrides: setNostrEventSchemaOverrides,
|
||
validateEvent: validateEventAgainstSchema,
|
||
validateEventStructure,
|
||
};
|
||
}
|