Merge pull request #821 from PR0M3TH3AN/codex/add-video-reaction-support

Add helpers for publishing video reactions
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-23 08:43:19 -04:00
commit b5e67977f6
6 files changed
+320 -20

No files matched your search

+17 -1
View File
@@ -4,7 +4,23 @@
// facades. Prefer importing from the facades directly in new modules so the
// wiring between the default client and helper layers stays explicit.
export { nostrClient, requestDefaultExtensionPermissions } from "./nostrClientFacade.js";
import {
nostrClient as defaultNostrClient,
requestDefaultExtensionPermissions,
} from "./nostrClientFacade.js";
export { requestDefaultExtensionPermissions };
export { defaultNostrClient as nostrClient };
export const publishVideoReaction = (...args) => {
if (
!defaultNostrClient ||
typeof defaultNostrClient.publishVideoReaction !== "function"
) {
throw new Error("Video reaction publishing is unavailable in this build.");
}
return defaultNostrClient.publishVideoReaction(...args);
};
export {
__testExports,
+10
View File
@@ -52,6 +52,7 @@ import {
publishViewEvent as publishViewEventForClient,
recordVideoView as recordVideoViewForClient,
} from "./viewEvents.js";
import { publishVideoReaction as publishVideoReactionForClient } from "./reactionEvents.js";
import {
publishEventToRelay,
publishEventToRelays,
@@ -2747,6 +2748,15 @@ export class NostrClient {
});
}
async publishVideoReaction(pointer, options = {}) {
return publishVideoReactionForClient(this, pointer, options, {
resolveActiveSigner,
shouldRequestExtensionPermissions,
signEventWithPrivateKey,
DEFAULT_NIP07_PERMISSION_METHODS,
});
}
async recordVideoView(videoPointer, options = {}) {
return recordVideoViewForClient(this, videoPointer, options, {
resolveActiveSigner,
+2
View File
@@ -55,3 +55,5 @@ export {
publishViewEvent,
recordVideoView,
} from "./viewEvents.js";
export { publishVideoReaction } from "./reactionEvents.js";
+170
View File
@@ -0,0 +1,170 @@
import { buildReactionEvent } from "../nostrEventSchemas.js";
import { publishEventToRelay } from "../nostrPublish.js";
import { RELAY_URLS } from "./toolkit.js";
import { normalizePointerInput } from "./watchHistory.js";
import { devLogger, userLogger } from "../utils/logger.js";
function sanitizeRelayList(primary, fallback) {
if (Array.isArray(primary) && primary.length) {
return primary;
}
if (Array.isArray(fallback) && fallback.length) {
return fallback;
}
return RELAY_URLS;
}
export async function publishVideoReaction(
client,
pointerInput,
options = {},
{
resolveActiveSigner,
shouldRequestExtensionPermissions,
signEventWithPrivateKey,
DEFAULT_NIP07_PERMISSION_METHODS,
}
) {
if (!client?.pool) {
return { ok: false, error: "nostr-uninitialized" };
}
const pointer = normalizePointerInput(pointerInput);
if (!pointer) {
return { ok: false, error: "invalid-pointer" };
}
const actorPubkey = await client.ensureSessionActor();
if (!actorPubkey) {
return { ok: false, error: "missing-actor" };
}
const createdAt =
typeof options.created_at === "number" && options.created_at > 0
? Math.floor(options.created_at)
: Math.floor(Date.now() / 1000);
const additionalTags = Array.isArray(options.additionalTags)
? options.additionalTags.filter(
(tag) => Array.isArray(tag) && typeof tag[0] === "string"
)
: [];
const pointerTag =
pointer.type === "a"
? pointer.relay
? ["a", pointer.value, pointer.relay]
: ["a", pointer.value]
: pointer.relay
? ["e", pointer.value, pointer.relay]
: ["e", pointer.value];
let content = "+";
if (typeof options.content === "string") {
content = options.content;
} else if (
options.content &&
typeof options.content === "object" &&
!Array.isArray(options.content)
) {
try {
content = JSON.stringify(options.content);
} catch (error) {
devLogger.warn("[nostr] Failed to serialize custom reaction content:", error);
content = "+";
}
} else if (options.content !== undefined && options.content !== null) {
content = String(options.content);
}
const event = buildReactionEvent({
pubkey: actorPubkey,
created_at: createdAt,
pointerValue: pointer.value,
pointerTag,
additionalTags,
content,
});
let signedEvent = null;
const normalizedActor =
typeof actorPubkey === "string" ? actorPubkey.toLowerCase() : "";
const normalizedLogged =
typeof client.pubkey === "string" ? client.pubkey.toLowerCase() : "";
const signer = resolveActiveSigner(actorPubkey);
const canUseActiveSigner =
normalizedActor &&
normalizedActor === normalizedLogged &&
signer &&
typeof signer.signEvent === "function";
if (canUseActiveSigner) {
let permissionResult = { ok: true };
if (shouldRequestExtensionPermissions(signer)) {
permissionResult = await client.ensureExtensionPermissions(
DEFAULT_NIP07_PERMISSION_METHODS
);
}
if (permissionResult.ok) {
try {
signedEvent = await signer.signEvent(event);
} catch (error) {
userLogger.warn(
"[nostr] Failed to sign reaction event with active signer:",
error,
);
return { ok: false, error: "signing-failed", details: error };
}
} else {
userLogger.warn(
"[nostr] Active signer permissions missing; signing reaction with session key.",
permissionResult.error,
);
}
}
if (!signedEvent) {
try {
if (!client.sessionActor || client.sessionActor.pubkey !== actorPubkey) {
await client.ensureSessionActor(true);
}
if (!client.sessionActor || client.sessionActor.pubkey !== actorPubkey) {
throw new Error("session-actor-mismatch");
}
const privateKey = client.sessionActor.privateKey;
signedEvent = signEventWithPrivateKey(event, privateKey);
} catch (error) {
userLogger.warn("[nostr] Failed to sign reaction event with session key:", error);
return { ok: false, error: "signing-failed", details: error };
}
}
const relayList = sanitizeRelayList(options.relays, client.relays);
const publishResults = await Promise.all(
relayList.map((url) => publishEventToRelay(client.pool, url, signedEvent))
);
const acceptedRelays = publishResults
.filter((result) => result.success)
.map((result) => result.url)
.filter((url) => typeof url === "string" && url);
const success = acceptedRelays.length > 0;
if (success) {
devLogger.info(
`[nostr] Reaction event accepted by ${acceptedRelays.length} relay(s):`,
acceptedRelays.join(", ")
);
} else {
userLogger.warn("[nostr] Reaction event rejected by relays:", publishResults);
}
return {
ok: success,
event: signedEvent,
results: publishResults,
acceptedRelays,
};
}
+94 -19
View File
@@ -16,6 +16,7 @@ export const NOTE_TYPES = Object.freeze({
REPOST: "repost",
RELAY_LIST: "relayList",
VIEW_EVENT: "viewEvent",
VIDEO_REACTION: "videoReaction",
WATCH_HISTORY_INDEX: "watchHistoryIndex",
WATCH_HISTORY_CHUNK: "watchHistoryChunk",
SUBSCRIPTION_LIST: "subscriptionList",
@@ -194,6 +195,17 @@ const BASE_SCHEMAS = {
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.WATCH_HISTORY_INDEX]: {
type: NOTE_TYPES.WATCH_HISTORY_INDEX,
label: "Watch history index",
@@ -384,6 +396,41 @@ function appendSchemaTags(tags, schema) {
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;
}
export function buildVideoPostEvent({
pubkey,
created_at,
@@ -590,25 +637,11 @@ export function buildViewEvent({
tags.push([schema.topicTag.name, schema.topicTag.value]);
}
const pointerTagName = schema?.pointerTagName;
if (pointerValue && pointerTagName) {
tags.push([pointerTagName, pointerValue]);
}
const normalizedPointerTags = [];
if (Array.isArray(pointerTag) && pointerTag.length >= 2) {
normalizedPointerTags.push(
pointerTag.map((value) => (typeof value === "string" ? value : String(value)))
);
}
if (Array.isArray(pointerTags)) {
pointerTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
normalizedPointerTags.push(
tag.map((value) => (typeof value === "string" ? value : String(value)))
);
}
});
}
const normalizedPointerTags = collectPointerTags(schema, {
pointerValue,
pointerTag,
pointerTags,
});
normalizedPointerTags.forEach((tag) => {
tags.push(tag);
});
@@ -650,6 +683,48 @@ export function buildViewEvent({
};
}
export function buildReactionEvent({
pubkey,
created_at,
pointerValue,
pointerTag,
pointerTags = [],
additionalTags = [],
content = "",
}) {
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_REACTION);
const tags = [];
const normalizedPointerTags = collectPointerTags(schema, {
pointerValue,
pointerTag,
pointerTags,
});
normalizedPointerTags.forEach((tag) => {
tags.push(tag);
});
if (Array.isArray(additionalTags)) {
additionalTags.forEach((tag) => {
if (Array.isArray(tag) && tag.length >= 2) {
tags.push(tag.map((value) => (typeof value === "string" ? value : String(value))));
}
});
}
appendSchemaTags(tags, schema);
const resolvedContent = ensureValidUtf8Content(content);
return {
kind: schema?.kind ?? 7,
pubkey,
created_at,
tags,
content: resolvedContent,
};
}
export function buildWatchHistoryIndexEvent({
pubkey,
created_at,
+27
View File
@@ -0,0 +1,27 @@
import { publishVideoReaction } from "./nostr.js";
import { devLogger, userLogger } from "./utils/logger.js";
export const reactionCounter = {
async publish(pointer, options = {}) {
try {
const result = await publishVideoReaction(pointer, options);
if (!result || !result.ok) {
userLogger.warn(
"[reactionCounter] Reaction publish rejected by relays:",
result?.results || result
);
} else if (Array.isArray(result.acceptedRelays)) {
devLogger.info(
`[reactionCounter] Reaction accepted by ${result.acceptedRelays.length} relay(s):`,
result.acceptedRelays.join(", ")
);
}
return result;
} catch (error) {
userLogger.warn("[reactionCounter] Failed to publish reaction event:", error);
throw error;
}
},
};
export default reactionCounter;