diff --git a/js/nostr.js b/js/nostr.js index 1c2b412e..4746aaa2 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -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, diff --git a/js/nostr/client.js b/js/nostr/client.js index 97c00b16..b73bd5bc 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -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, diff --git a/js/nostr/index.js b/js/nostr/index.js index 7ace6392..0bf16db5 100644 --- a/js/nostr/index.js +++ b/js/nostr/index.js @@ -55,3 +55,5 @@ export { publishViewEvent, recordVideoView, } from "./viewEvents.js"; + +export { publishVideoReaction } from "./reactionEvents.js"; diff --git a/js/nostr/reactionEvents.js b/js/nostr/reactionEvents.js new file mode 100644 index 00000000..f59d1aaa --- /dev/null +++ b/js/nostr/reactionEvents.js @@ -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, + }; +} diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index f535671d..f54d2959 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -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, diff --git a/js/reactionCounter.js b/js/reactionCounter.js new file mode 100644 index 00000000..0335d55f --- /dev/null +++ b/js/reactionCounter.js @@ -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;