Add Nostr share note publishing

This commit is contained in:
thePR0M3TH3AN committed 2026-01-22 22:46:15 -05:00
1 parent 9f9a23902a
commit 8e5b8b09ce
4 files changed
+291 -1

No files matched your search

+2
View File
@@ -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 |
+165 -1
View File
@@ -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.");
+123
View File
@@ -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,
+1
View File
@@ -177,6 +177,7 @@ export default class ModalManager {
setGlobalModalState,
container: modalContainer,
fallbackThumbnailSrc: this.assets.fallbackThumbnailSrc,
onPost: (payload) => app.handleShareNostrPost(payload),
});
app.shareNostrModal = this.shareNostrModal;