diff --git a/js/nostr.js b/js/nostr.js index 79f12ef1..6b280b2f 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -2785,8 +2785,50 @@ class NostrClient { ? ["e", pointer.value, pointer.relay] : ["e", pointer.value]; - const content = - typeof options.content === "string" ? options.content : ""; + 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) { + if (isDevMode) { + console.warn( + "[nostr] Failed to serialize custom view event content:", + error + ); + } + content = ""; + } + } + + if (!content) { + const payload = { + target: { + type: pointer.type, + value: pointer.value, + }, + created_at: createdAt, + }; + if (pointer.relay) { + payload.target.relay = pointer.relay; + } + try { + content = JSON.stringify(payload); + } catch (error) { + if (isDevMode) { + console.warn( + "[nostr] Failed to serialize default view event content:", + error + ); + } + content = ""; + } + } const event = buildViewEvent({ pubkey: actorPubkey, diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 869bf1b3..9f3720b1 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -30,6 +30,69 @@ export const ADMIN_LIST_IDENTIFIERS = Object.freeze({ const DEFAULT_APPEND_TAGS = []; +let cachedUtf8Encoder = null; + +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) { + if (isDevMode) { + console.warn( + "[nostrEventSchemas] Failed to serialize view event content:", + error + ); + } + normalized = ""; + } + } else { + normalized = String(value); + } + + if (!cachedUtf8Encoder && typeof TextEncoder !== "undefined") { + cachedUtf8Encoder = new TextEncoder(); + } + + if (cachedUtf8Encoder) { + try { + cachedUtf8Encoder.encode(normalized); + } catch (error) { + if (isDevMode) { + console.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) { + if (isDevMode) { + console.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, @@ -477,12 +540,14 @@ export function buildViewEvent({ } appendSchemaTags(tags, schema); + const resolvedContent = ensureValidUtf8Content(content); + return { kind: schema?.kind ?? WATCH_HISTORY_KIND, pubkey, created_at, tags, - content: typeof content === "string" ? content : String(content ?? ""), + content: resolvedContent, }; } diff --git a/tests/view-counter.test.mjs b/tests/view-counter.test.mjs index d627b8eb..88e1ff26 100644 --- a/tests/view-counter.test.mjs +++ b/tests/view-counter.test.mjs @@ -506,6 +506,139 @@ async function testUnsubscribeStopsCallbacks() { ); } +async function testRecordVideoViewEmitsJsonPayload() { + localStorage.clear(); + harness.reset(); + harness.resetMetrics(); + + const pointer = { + type: "a", + value: "30078:pub:view-json", + relay: "wss://relay.example", + }; + const createdAt = Math.floor(Date.now() / 1000); + + const originalPool = nostrClient.pool; + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalEnsureSessionActor = nostrClient.ensureSessionActor; + const originalSessionActor = nostrClient.sessionActor; + const originalPubkey = nostrClient.pubkey; + const originalUpdateHistory = nostrClient.updateWatchHistoryList; + const originalWarn = console.warn; + const originalNostrTools = window.NostrTools; + + const warnings = []; + console.warn = (...args) => { + warnings.push( + args + .map((value) => { + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch (error) { + return String(value); + } + }) + .join(" ") + ); + }; + + window.NostrTools = { + ...window.NostrTools, + getEventHash: () => "event-hash-record", // deterministic stub for tests + signEvent: () => "event-sig-record", + }; + + const publishCalls = []; + nostrClient.pool = { + publish(relayUrls, event) { + publishCalls.push({ relays: relayUrls, event }); + return { + on(type, handler) { + if (type === "ok") { + setTimeout(() => handler(), 0); + } + return this; + }, + }; + }, + }; + nostrClient.relays = ["wss://relay.example"]; + nostrClient.ensureSessionActor = async () => { + nostrClient.sessionActor = { + pubkey: "pub-record-json", + privateKey: "priv-record-json", + }; + return "pub-record-json"; + }; + nostrClient.pubkey = ""; + + nostrClient.updateWatchHistoryList = async () => ({ ok: true }); + + try { + const result = await nostrClient.recordVideoView(pointer, { + created_at: createdAt, + }); + + assert.ok(result.ok, "recordVideoView should succeed with stubbed handlers"); + assert.equal( + publishCalls.length, + 1, + "publishViewEvent should be invoked once per recordVideoView call" + ); + + const emittedEvent = publishCalls[0]?.event || null; + assert.ok(emittedEvent, "publishViewEvent should enqueue an event for relays"); + assert.equal( + typeof emittedEvent.content, + "string", + "recordVideoView should provide serialized view content" + ); + + const payload = JSON.parse(emittedEvent.content); + assert.deepEqual( + payload, + { + target: { + type: pointer.type, + value: pointer.value, + relay: pointer.relay, + }, + created_at: createdAt, + }, + "default view content should capture pointer metadata and timestamp" + ); + + assert.equal( + result.view.event.content, + emittedEvent.content, + "serialized payload should persist on the emitted event" + ); + } finally { + nostrClient.pool = originalPool; + nostrClient.relays = originalRelays; + nostrClient.ensureSessionActor = originalEnsureSessionActor; + nostrClient.sessionActor = originalSessionActor; + nostrClient.pubkey = originalPubkey; + nostrClient.updateWatchHistoryList = originalUpdateHistory; + console.warn = originalWarn; + window.NostrTools = originalNostrTools; + } + + const viewCounterWarnings = warnings.filter((message) => + message.includes("[viewCounter] Failed to ingest local view event") + ); + assert.equal( + viewCounterWarnings.length, + 0, + "viewCounter.ingestLocalViewEvent should not reject JSON view payloads" + ); +} + // Cached totals older than VIEW_COUNT_CACHE_TTL_MS should be discarded and rehydrated from relays. async function testHydrationRefreshesAfterCacheTtl() { harness.reset(); @@ -568,5 +701,6 @@ await testHydrationSkipsStaleEventsAndRollsOff(); await testLocalIngestNotifiesImmediately(); await testUnsubscribeStopsCallbacks(); await testRelayCountAggregationUsesBestEstimate(); +await testRecordVideoViewEmitsJsonPayload(); console.log("View counter tests completed successfully.");