diff --git a/js/app.js b/js/app.js index 45bf9d79..084be04d 100644 --- a/js/app.js +++ b/js/app.js @@ -86,6 +86,8 @@ import { buildDmRelayListEvent, buildShareEvent } from "./nostrEventSchemas.js"; import { publishEventToRelays, assertAnyRelayAccepted, + describePublishOutcome, + readRelayPublishSummary, } from "./nostrPublish.js"; import { getActiveSigner, @@ -3061,8 +3063,15 @@ class Application { return false; } + let relaySummary = null; try { - await this.nostrService.publishVideoNote(publishPayload, this.pubkey); + const publishResult = await this.nostrService.publishVideoNote( + publishPayload, + this.pubkey, + ); + // The legacy (kind 30078) event carries the relay tally attached by + // publishVideo (non-enumerable, so it never hit the wire). + relaySummary = readRelayPublishSummary(publishResult?.legacy) || null; } catch (err) { devLogger.error("Failed to publish video:", err); this.showError("Failed to share video. Please try again later."); @@ -3089,7 +3098,15 @@ class Application { ); } - this.showSuccess("Video shared successfully!"); + // Tell the user how many relays actually accepted the video, so a partial + // (or relay-list-wide) failure isn't hidden behind a generic confirmation. + const outcome = describePublishOutcome(relaySummary || {}); + if (outcome.tone === "warning") { + this.showSuccess(outcome.message); + this.showStatus(outcome.message, { autoHideMs: 8000, showSpinner: false }); + } else { + this.showSuccess(outcome.message); + } if (loadVideosError) { this.showStatus( diff --git a/js/nostr/client.js b/js/nostr/client.js index fb522f14..d4f37555 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -107,6 +107,7 @@ import { publishEventToRelay, publishEventToRelays, assertAnyRelayAccepted, + attachRelayPublishSummary, } from "../nostrPublish.js"; import { signEventWithPrivateKey, @@ -2725,12 +2726,20 @@ export class NostrClient { try { // 1. Publish the primary Video Note (Kind 30078) - const { signedEvent } = await this.signAndPublishEvent(context.event, { - context: "video note", - logName: "Video note", - devLogLabel: "video note", - resolveActiveSigner: (p) => this.signerManager.resolveActiveSigner(p), - }); + const { signedEvent, summary } = await this.signAndPublishEvent( + context.event, + { + context: "video note", + logName: "Video note", + devLogLabel: "video note", + resolveActiveSigner: (p) => this.signerManager.resolveActiveSigner(p), + }, + ); + + // Stash a compact relay tally on the returned event (non-enumerable so it + // never leaks into serialization/relay logic) so the UI can report how + // many relays actually accepted the video. + attachRelayPublishSummary(signedEvent, summary); // 2. Publish NIP-94 Mirror (Kind 1063) if a hosted URL is present. await handlePublishNip94(this, signedEvent, context); diff --git a/js/nostrPublish.js b/js/nostrPublish.js index 87b11c93..5b561d55 100644 --- a/js/nostrPublish.js +++ b/js/nostrPublish.js @@ -263,6 +263,78 @@ export function summarizePublishResults(results = []) { return { accepted, failed }; } +// Non-enumerable key so the tally rides along with a signed event without ever +// polluting JSON.stringify / relay payloads. +export const RELAY_PUBLISH_SUMMARY_KEY = "__relayPublishSummary"; + +/** + * Reduce a {accepted, failed} publish summary to a compact {accepted, total} + * tally and stash it on the event (non-enumerable). Best-effort: a frozen or + * non-object event is left untouched. + */ +export function attachRelayPublishSummary(event, summary) { + if (!event || typeof event !== "object") { + return event; + } + const accepted = Array.isArray(summary?.accepted) ? summary.accepted.length : 0; + const failed = Array.isArray(summary?.failed) ? summary.failed.length : 0; + try { + Object.defineProperty(event, RELAY_PUBLISH_SUMMARY_KEY, { + value: { accepted, total: accepted + failed }, + enumerable: false, + configurable: true, + writable: true, + }); + } catch (_) { + // ignore — the tally is purely informational. + } + return event; +} + +/** Read back the compact tally attached by attachRelayPublishSummary, if any. */ +export function readRelayPublishSummary(event) { + if (!event || typeof event !== "object") { + return null; + } + const tally = event[RELAY_PUBLISH_SUMMARY_KEY]; + return tally && typeof tally === "object" ? tally : null; +} + +/** + * Turn a relay-publish tally into a user-facing message + tone, so the UI can + * tell the user how many relays actually accepted their video instead of an + * opaque "shared successfully" that hides partial/total failure. + * + * @param {{ accepted?: number, total?: number }} tally + * @returns {{ tone: "success"|"warning"|"error", message: string }} + */ +export function describePublishOutcome({ accepted = 0, total = 0 } = {}) { + const a = Number.isFinite(accepted) ? Math.max(0, Math.floor(accepted)) : 0; + const t = Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0; + + // No relay count available — fall back to the generic confirmation. + if (t <= 0) { + return { tone: "success", message: "Video shared successfully!" }; + } + if (a <= 0) { + return { + tone: "error", + message: + "Video could not be published to any relay. Check your relay list and try again.", + }; + } + if (a < t) { + return { + tone: "warning", + message: `Video published to ${a} of ${t} relays — some relays rejected it.`, + }; + } + return { + tone: "success", + message: `Video published to ${a} ${a === 1 ? "relay" : "relays"}!`, + }; +} + export function assertAnyRelayAccepted(results = [], options = {}) { const summary = summarizePublishResults(results); if (summary.accepted.length > 0) { diff --git a/tests/publish-outcome-message.test.mjs b/tests/publish-outcome-message.test.mjs new file mode 100644 index 00000000..cf49cabc --- /dev/null +++ b/tests/publish-outcome-message.test.mjs @@ -0,0 +1,72 @@ +// After publishing a video bitvid must tell the user how many relays actually +// accepted it, instead of an opaque "shared successfully" that hides a partial +// or total relay failure. describePublishOutcome maps the relay tally to a +// message + tone; attach/readRelayPublishSummary carry the tally from the +// signed event to the UI without polluting the serialized event. + +import assert from "node:assert/strict"; +import test from "node:test"; +import { + describePublishOutcome, + attachRelayPublishSummary, + readRelayPublishSummary, + RELAY_PUBLISH_SUMMARY_KEY, +} from "../js/nostrPublish.js"; + +test("full acceptance reports the relay count as success", () => { + const out = describePublishOutcome({ accepted: 4, total: 4 }); + assert.equal(out.tone, "success"); + assert.match(out.message, /4 relays/); +}); + +test("singular relay is not pluralized", () => { + const out = describePublishOutcome({ accepted: 1, total: 1 }); + assert.equal(out.tone, "success"); + assert.match(out.message, /1 relay\b/); + assert.doesNotMatch(out.message, /1 relays/); +}); + +test("partial acceptance is surfaced as a warning with both counts", () => { + const out = describePublishOutcome({ accepted: 2, total: 8 }); + assert.equal(out.tone, "warning"); + assert.match(out.message, /2 of 8/); +}); + +test("zero acceptance is an error telling the user nothing landed", () => { + const out = describePublishOutcome({ accepted: 0, total: 8 }); + assert.equal(out.tone, "error"); + assert.match(out.message, /any relay/i); +}); + +test("missing tally falls back to the generic success message", () => { + const out = describePublishOutcome({}); + assert.equal(out.tone, "success"); + // No fabricated relay count when we don't actually know one. + assert.doesNotMatch(out.message, /\d+ of \d+/); +}); + +test("attached tally round-trips and stays out of serialization", () => { + const event = { id: "abc", kind: 30078, content: "{}" }; + attachRelayPublishSummary(event, { + accepted: [{ url: "wss://a" }, { url: "wss://b" }], + failed: [{ url: "wss://c" }], + }); + + const tally = readRelayPublishSummary(event); + assert.deepEqual(tally, { accepted: 2, total: 3 }); + + // The tally must never leak into the event the relays/JSON see. + assert.ok( + !Object.keys(event).includes(RELAY_PUBLISH_SUMMARY_KEY), + "summary key must be non-enumerable", + ); + assert.ok( + !JSON.stringify(event).includes(RELAY_PUBLISH_SUMMARY_KEY), + "summary must not appear in the serialized event", + ); +}); + +test("reading a summary off an event without one returns null", () => { + assert.equal(readRelayPublishSummary({ id: "x" }), null); + assert.equal(readRelayPublishSummary(null), null); +});