mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Add Nostr share note publishing
This commit is contained in:
1 parent
9f9a23902a
commit
8e5b8b09ce
4 files changed
+291
-1
No files matched your search
@@ -16,6 +16,7 @@ import {
|
||||
setNostrEventSchemaOverrides,
|
||||
buildVideoPostEvent,
|
||||
buildRepostEvent,
|
||||
buildShareEvent,
|
||||
buildProfileMetadataEvent,
|
||||
} from "./nostrEventSchemas.js";
|
||||
|
||||
@@ -147,6 +148,7 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
|
||||
| Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', <stable video identifier>]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `isNsfw`, `isForKids`, `enableComments`, `ws`, `xs`) |
|
||||
| NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description |
|
||||
| Repost (`NOTE_TYPES.REPOST`) | `6` | `['e', <event id>, <relay?>]` with optional address pointer `['a', <kind:pubkey:identifier>, <relay?>]`, and `['p', <pubkey>]` when the origin author is known; inherits schema append tags | JSON-serialized event being reposted |
|
||||
| Share note (`NOTE_TYPES.SHARE`) | `1` | `['e', <video id>, '', 'mention']` plus `['p', <video pubkey>, '', 'mention']` when available; repeating `['r', <relay url>, <read/write?>]` tags for relay hints; inherits schema append tags | Plain text share content from the compose modal |
|
||||
| Video reaction (`NOTE_TYPES.VIDEO_REACTION`) | `7` | `['e', <event id>, <relay?>]` or `['a', <kind:pubkey:identifier>, <relay?>]`, and `['p', <author pubkey>]` | Reaction content (e.g. `+`, `-`, or emoji) |
|
||||
| Video comment (`NOTE_TYPES.VIDEO_COMMENT`) | `1111` | NIP-22 root scope tags `['A'\|`E`\|`I`, <pointer>, <relay?>?], `['K', <root kind>]`, and `['P', <root author pubkey>, <relay?>?]` plus parent metadata `['E'\|`I`, <parent pointer>, <relay?>?, <author?>], `['K', <parent kind>]`, `['P', <parent author>, <relay?>?]`; inherits schema append tags | Plain text body sanitized to valid UTF-8 |
|
||||
| DM attachment (`NOTE_TYPES.DM_ATTACHMENT`) | `15` | `['p', <recipient pubkey>]`, `['x', <sha256 of uploaded bytes>]`, `['url', <download url>]`, optional `['name', <filename>]`, `['type', <mime>]`, `['size', <bytes>]`, optional `['k', <base64 key>]`; inherits schema append tags | Empty content; attachment metadata is represented as tags |
|
||||
|
||||
@@ -72,7 +72,7 @@ import createPopover from "./ui/overlay/popoverEngine.js";
|
||||
import { createVideoSettingsMenuPanel } from "./ui/components/videoMenuRenderers.js";
|
||||
import moderationService from "./services/moderationService.js";
|
||||
import { sanitizeRelayList } from "./nostr/nip46Client.js";
|
||||
import { buildDmRelayListEvent } from "./nostrEventSchemas.js";
|
||||
import { buildDmRelayListEvent, buildShareEvent } from "./nostrEventSchemas.js";
|
||||
import {
|
||||
publishEventToRelays,
|
||||
assertAnyRelayAccepted,
|
||||
@@ -81,6 +81,8 @@ import {
|
||||
getActiveSigner,
|
||||
onActiveSignerChanged,
|
||||
} from "./nostrClientRegistry.js";
|
||||
import { queueSignEvent } from "./nostr/signRequestQueue.js";
|
||||
import { DEFAULT_NIP07_PERMISSION_METHODS } from "./nostr/nip07Permissions.js";
|
||||
import {
|
||||
initViewCounter,
|
||||
subscribeToVideoViewCount,
|
||||
@@ -10208,6 +10210,168 @@ class Application {
|
||||
this.videoListPopularTags = null;
|
||||
}
|
||||
|
||||
async handleShareNostrPost(payload = {}) {
|
||||
const video = payload?.video || null;
|
||||
const videoId = typeof video?.id === "string" ? video.id.trim() : "";
|
||||
const videoTitle =
|
||||
typeof video?.title === "string" ? video.title.trim() : "";
|
||||
const videoPubkey =
|
||||
typeof video?.pubkey === "string" ? video.pubkey.trim() : "";
|
||||
|
||||
if (!videoId || !videoTitle) {
|
||||
userLogger.warn("[Application] Share post missing video details.");
|
||||
this.showError("Missing video details for sharing.");
|
||||
throw new Error("share-missing-video-details");
|
||||
}
|
||||
|
||||
const signer = getActiveSigner();
|
||||
if (!signer || typeof signer.signEvent !== "function") {
|
||||
userLogger.warn("[Application] No active signer available for share.");
|
||||
this.showError("Connect a Nostr signer to share.");
|
||||
throw new Error("share-missing-signer");
|
||||
}
|
||||
|
||||
const activePubkey = this.normalizeHexPubkey(this.pubkey);
|
||||
const signerPubkey = this.normalizeHexPubkey(signer.pubkey);
|
||||
const eventPubkey = activePubkey || signerPubkey;
|
||||
|
||||
if (!eventPubkey) {
|
||||
userLogger.warn("[Application] Share post missing active pubkey.");
|
||||
this.showError("Please log in to share on Nostr.");
|
||||
throw new Error("share-missing-pubkey");
|
||||
}
|
||||
|
||||
if (activePubkey && signerPubkey && activePubkey !== signerPubkey) {
|
||||
userLogger.error(
|
||||
"[Application] Active signer does not match current account for share.",
|
||||
);
|
||||
this.showError("Active signer does not match your account.");
|
||||
throw new Error("share-signer-mismatch");
|
||||
}
|
||||
|
||||
if (!nostrClient?.pool) {
|
||||
userLogger.error("[Application] Share publish failed: relays not ready.");
|
||||
this.showError("Nostr relays are not ready yet. Please try again.");
|
||||
throw new Error("share-relays-unavailable");
|
||||
}
|
||||
|
||||
const relayEntries = Array.isArray(payload?.relays) ? payload.relays : [];
|
||||
const relayUrls = relayEntries
|
||||
.map((entry) => {
|
||||
if (typeof entry === "string") {
|
||||
return entry;
|
||||
}
|
||||
if (Array.isArray(entry) && entry.length) {
|
||||
if (entry[0] === "r") {
|
||||
return typeof entry[1] === "string" ? entry[1] : "";
|
||||
}
|
||||
return typeof entry[0] === "string" ? entry[0] : "";
|
||||
}
|
||||
if (entry && typeof entry === "object") {
|
||||
if (typeof entry.url === "string") {
|
||||
return entry.url;
|
||||
}
|
||||
if (typeof entry.relay === "string") {
|
||||
return entry.relay;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
const relayTargets = sanitizeRelayList(relayUrls);
|
||||
|
||||
if (!relayTargets.length) {
|
||||
userLogger.warn("[Application] Share post missing relay targets.");
|
||||
this.showError("Please choose at least one relay to share to.");
|
||||
throw new Error("share-missing-relays");
|
||||
}
|
||||
|
||||
if (signer.type === "extension" && nostrClient.ensureExtensionPermissions) {
|
||||
const permissionResult = await nostrClient.ensureExtensionPermissions(
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
);
|
||||
if (!permissionResult?.ok) {
|
||||
userLogger.warn(
|
||||
"[Application] Share publish blocked by signer permissions.",
|
||||
permissionResult?.error,
|
||||
);
|
||||
this.showError("Signer permissions are required to post.");
|
||||
throw new Error("share-permission-denied");
|
||||
}
|
||||
}
|
||||
|
||||
const event = buildShareEvent({
|
||||
pubkey: eventPubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: typeof payload?.content === "string" ? payload.content : "",
|
||||
video: { id: videoId, pubkey: videoPubkey },
|
||||
relays: relayEntries,
|
||||
});
|
||||
|
||||
let signedEvent;
|
||||
try {
|
||||
signedEvent = await queueSignEvent(signer, event);
|
||||
} catch (error) {
|
||||
userLogger.error("[Application] Failed to sign share event.", error);
|
||||
this.showError("Unable to sign the share event.");
|
||||
throw error;
|
||||
}
|
||||
|
||||
const publishResults = await publishEventToRelays(
|
||||
nostrClient.pool,
|
||||
relayTargets,
|
||||
signedEvent,
|
||||
);
|
||||
|
||||
let publishSummary;
|
||||
try {
|
||||
publishSummary = assertAnyRelayAccepted(publishResults, {
|
||||
context: "share note",
|
||||
});
|
||||
} catch (publishError) {
|
||||
if (publishError?.relayFailures?.length) {
|
||||
publishError.relayFailures.forEach(
|
||||
({ url, error: relayError, reason }) => {
|
||||
userLogger.error(
|
||||
`[Application] Relay ${url} rejected share note: ${reason}`,
|
||||
relayError || reason,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
this.showError("Failed to share on Nostr. Please try again.");
|
||||
throw publishError;
|
||||
}
|
||||
|
||||
if (publishSummary.failed.length) {
|
||||
publishSummary.failed.forEach(({ url, error: relayError }) => {
|
||||
const reason =
|
||||
relayError instanceof Error
|
||||
? relayError.message
|
||||
: relayError
|
||||
? String(relayError)
|
||||
: "publish failed";
|
||||
userLogger.warn(
|
||||
`[Application] Relay ${url} did not accept share note: ${reason}`,
|
||||
relayError,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
userLogger.info(
|
||||
"[Application] Share note published.",
|
||||
publishSummary.accepted.map(({ url }) => url),
|
||||
);
|
||||
this.showSuccess("Shared to Nostr!");
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
event: signedEvent,
|
||||
accepted: publishSummary.accepted.map(({ url }) => url),
|
||||
failed: publishSummary.failed.map(({ url }) => url),
|
||||
};
|
||||
}
|
||||
|
||||
shareActiveVideo() {
|
||||
if (!this.currentVideo || !this.currentVideo.id) {
|
||||
this.showError("No shareable video is loaded.");
|
||||
|
||||
@@ -16,6 +16,7 @@ export const NOTE_TYPES = Object.freeze({
|
||||
NIP71_VIDEO: "nip71Video",
|
||||
NIP71_SHORT_VIDEO: "nip71ShortVideo",
|
||||
REPOST: "repost",
|
||||
SHARE: "share",
|
||||
RELAY_LIST: "relayList",
|
||||
VIEW_EVENT: "viewEvent",
|
||||
VIDEO_REACTION: "videoReaction",
|
||||
@@ -283,6 +284,19 @@ const BASE_SCHEMAS = {
|
||||
description: "Content field contains the JSON-serialized event being reposted.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.SHARE]: {
|
||||
type: NOTE_TYPES.SHARE,
|
||||
label: "Share note",
|
||||
kind: 1,
|
||||
relayTagName: "r",
|
||||
readMarker: "read",
|
||||
writeMarker: "write",
|
||||
appendTags: DEFAULT_APPEND_TAGS,
|
||||
content: {
|
||||
format: "text",
|
||||
description: "Plaintext share note referencing a video.",
|
||||
},
|
||||
},
|
||||
[NOTE_TYPES.NIP71_VIDEO]: {
|
||||
type: NOTE_TYPES.NIP71_VIDEO,
|
||||
label: "NIP-71 video (normal)",
|
||||
@@ -981,6 +995,115 @@ export function buildRepostEvent(params) {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildShareEvent(params) {
|
||||
const {
|
||||
pubkey,
|
||||
created_at,
|
||||
content = "",
|
||||
video = null,
|
||||
relays = [],
|
||||
additionalTags = [],
|
||||
} = params || {};
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.SHARE);
|
||||
const tags = [];
|
||||
const relayTagName = schema?.relayTagName || "r";
|
||||
const readMarker = schema?.readMarker || "read";
|
||||
const writeMarker = schema?.writeMarker || "write";
|
||||
|
||||
const normalizedVideoId =
|
||||
typeof video?.id === "string" ? video.id.trim() : "";
|
||||
if (normalizedVideoId) {
|
||||
tags.push(["e", normalizedVideoId, "", "mention"]);
|
||||
}
|
||||
|
||||
const normalizedVideoPubkey =
|
||||
typeof video?.pubkey === "string" ? video.pubkey.trim() : "";
|
||||
if (normalizedVideoPubkey) {
|
||||
tags.push(["p", normalizedVideoPubkey, "", "mention"]);
|
||||
}
|
||||
|
||||
const seenRelayTags = new Set();
|
||||
|
||||
const pushRelayTag = (url, marker) => {
|
||||
const normalizedUrl = typeof url === "string" ? url.trim() : "";
|
||||
if (!normalizedUrl) {
|
||||
return;
|
||||
}
|
||||
const normalizedMarker =
|
||||
typeof marker === "string" ? marker.trim().toLowerCase() : "";
|
||||
const resolvedMarker =
|
||||
normalizedMarker === "read" || normalizedMarker === "write"
|
||||
? normalizedMarker
|
||||
: "";
|
||||
const dedupeKey = `${normalizedUrl}|${resolvedMarker}`;
|
||||
if (seenRelayTags.has(dedupeKey)) {
|
||||
return;
|
||||
}
|
||||
seenRelayTags.add(dedupeKey);
|
||||
if (resolvedMarker) {
|
||||
const markerValue = resolvedMarker === "read" ? readMarker : writeMarker;
|
||||
tags.push([relayTagName, normalizedUrl, markerValue]);
|
||||
} else {
|
||||
tags.push([relayTagName, normalizedUrl]);
|
||||
}
|
||||
};
|
||||
|
||||
if (Array.isArray(relays)) {
|
||||
relays.forEach((entry) => {
|
||||
if (typeof entry === "string") {
|
||||
pushRelayTag(entry, "");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(entry) && entry.length) {
|
||||
if (entry[0] === relayTagName) {
|
||||
pushRelayTag(entry[1], entry[2]);
|
||||
return;
|
||||
}
|
||||
pushRelayTag(entry[0], entry[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry && typeof entry === "object") {
|
||||
const url =
|
||||
typeof entry.url === "string"
|
||||
? entry.url
|
||||
: typeof entry.relay === "string"
|
||||
? entry.relay
|
||||
: "";
|
||||
let marker = "";
|
||||
if (typeof entry.mode === "string") {
|
||||
marker = entry.mode;
|
||||
} else if (typeof entry.marker === "string") {
|
||||
marker = entry.marker;
|
||||
} else if (entry.read === true && entry.write === false) {
|
||||
marker = "read";
|
||||
} else if (entry.write === true && entry.read === false) {
|
||||
marker = "write";
|
||||
}
|
||||
pushRelayTag(url, marker);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
appendSchemaTags(tags, schema);
|
||||
|
||||
const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags);
|
||||
if (sanitizedAdditionalTags.length) {
|
||||
tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice()));
|
||||
}
|
||||
|
||||
const resolvedContent = ensureValidUtf8Content(content);
|
||||
|
||||
return {
|
||||
kind: schema?.kind ?? 1,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags,
|
||||
content: resolvedContent,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRelayListEvent(params) {
|
||||
const {
|
||||
pubkey,
|
||||
|
||||
@@ -177,6 +177,7 @@ export default class ModalManager {
|
||||
setGlobalModalState,
|
||||
container: modalContainer,
|
||||
fallbackThumbnailSrc: this.assets.fallbackThumbnailSrc,
|
||||
onPost: (payload) => app.handleShareNostrPost(payload),
|
||||
});
|
||||
app.shareNostrModal = this.shareNostrModal;
|
||||
|
||||
|
||||
Reference in new issue
Block a user