feat(publish): tell the user how many relays accepted their video

Publishing showed a flat "Video shared successfully!" even when most (or all)
relays rejected the event, so users couldn't tell a partial/failed publish from
a clean one. Now publishVideo captures the per-relay accept/reject summary and
stashes a compact {accepted,total} tally on the returned event via a
non-enumerable key (never serialized, never sent to relays). app.js reads it
back and reports the real outcome:
  - all relays accepted  -> "Video published to N relays!"
  - some rejected        -> warning "Video published to N of M relays …"
  - none accepted        -> error (defensive; this path normally throws first)
Falls back to the generic message when no tally is available.

Tests (describePublishOutcome + attach/readRelayPublishSummary): success/partial/
zero/singular/missing-tally messaging, and that the tally round-trips while
staying out of JSON serialization. Mutation-verified — flipping partial→success
and the summary key to enumerable both fail the suite.

Build + full lint green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-20 12:19:09 -04:00
1 parent de515462e6
commit 3f4299a885
4 files changed
+178 -8

No files matched your search

+19 -2
View File
@@ -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(
+15 -6
View File
@@ -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);
+72
View File
@@ -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) {
+72
View File
@@ -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);
});