mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
de515462e6
commit
3f4299a885
4 files changed
+178
-8
No files matched your search
@@ -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
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user