Files
bitvid/js/ui/shareNostrController.js
T
Claude 53b8a2609c fix: recognize NIP-07 signer type for extension permission pre-granting
The NIP-07 adapter uses type "nip07" but all permission pre-granting
checks only matched type "extension", causing NIP-07 browser extension
users to fail decryption for block/mute lists, subscriptions, DMs, and
watch history. Hashtag preferences appeared to work because their read
path had a separate fix, masking the systemic issue.

Changes:
- shouldRequestExtensionPermissions() now matches both "extension" and
  "nip07" signer types (central fix affecting DM signals, comments,
  publish helpers, and watch history fetch)
- buildDmDecryptContext() pre-grants extension permissions for NIP-07
- userBlocks.js: remove capabilities-gated decryptor check that failed
  with lazy NIP-07 module injection (same fix already in subscriptions.js);
  always resolve signer regardless of allowPermissionPrompt; increase
  fast-path relay timeout from 6s to 10s
- watchHistoryService: recognize NIP-07 type and always resolve signer
- All publish paths (blocks, subscriptions, hashtags, relays, uploads,
  sharing, moderation) now pre-grant permissions for NIP-07 signers

https://claude.ai/code/session_01PipsdaTQmw7wBTJ1LmJjkk
2026-02-11 19:41:53 +00:00

238 lines
8.7 KiB
JavaScript

import { sanitizeRelayList as defaultSanitizeRelayList } from "../nostr/nip46Client.js";
import { buildShareEvent as defaultBuildShareEvent } from "../nostrEventSchemas.js";
import {
publishEventToRelays as defaultPublishEventToRelays,
assertAnyRelayAccepted as defaultAssertAnyRelayAccepted,
} from "../nostrPublish.js";
import {
getActiveSigner as defaultGetActiveSigner,
} from "../nostrClientRegistry.js";
import { queueSignEvent as defaultQueueSignEvent } from "../nostr/signRequestQueue.js";
import { DEFAULT_NIP07_PERMISSION_METHODS as defaultPermissionMethods } from "../nostr/nip07Permissions.js";
import { nostrClient as defaultNostrClient } from "../nostrClientFacade.js";
import { userLogger as defaultUserLogger, devLogger as defaultDevLogger } from "../utils/logger.js";
export default class ShareNostrController {
constructor({ ui, state, services = {} }) {
this.ui = ui;
this.state = state;
this.services = {
sanitizeRelayList: services.sanitizeRelayList || defaultSanitizeRelayList,
buildShareEvent: services.buildShareEvent || defaultBuildShareEvent,
publishEventToRelays: services.publishEventToRelays || defaultPublishEventToRelays,
assertAnyRelayAccepted: services.assertAnyRelayAccepted || defaultAssertAnyRelayAccepted,
getActiveSigner: services.getActiveSigner || defaultGetActiveSigner,
queueSignEvent: services.queueSignEvent || defaultQueueSignEvent,
permissionMethods: services.permissionMethods || defaultPermissionMethods,
nostrClient: services.nostrClient || defaultNostrClient,
userLogger: services.userLogger || defaultUserLogger,
devLogger: services.devLogger || defaultDevLogger,
};
}
async openModal({ video, triggerElement } = {}) {
const currentVideo = this.state.getCurrentVideo();
const targetVideo =
video && typeof video === "object" ? video : currentVideo || null;
if (!targetVideo) {
this.ui.showError("No video is available to share.");
return;
}
const modal = this.ui.getModal();
if (!modal) {
this.services.devLogger.warn("[ShareNostrController] Share Nostr modal is unavailable.");
this.ui.showError("Share modal is not ready yet.");
return;
}
const shareUrl =
typeof targetVideo.shareUrl === "string" && targetVideo.shareUrl.trim()
? targetVideo.shareUrl.trim()
: this.state.buildShareUrlFromEventId(targetVideo.id);
const payload = {
id: targetVideo.id,
title: targetVideo.title,
pubkey: targetVideo.pubkey,
authorName: targetVideo.creatorName || targetVideo.authorName || "",
thumbnail: targetVideo.thumbnail,
shareUrl,
};
try {
await modal.open({
video: payload,
triggerElement,
});
} catch (error) {
this.services.devLogger.error("[ShareNostrController] Failed to open Share Nostr modal:", error);
this.ui.showError("Unable to open the share modal.");
}
}
async handleShare(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) {
this.services.userLogger.warn("[ShareNostrController] Share post missing video details.");
this.ui.showError("Missing video details for sharing.");
throw new Error("share-missing-video-details");
}
const signer = this.services.getActiveSigner();
if (!signer || typeof signer.signEvent !== "function") {
this.services.userLogger.warn("[ShareNostrController] No active signer available for share.");
this.ui.showError("Connect a Nostr signer to share.");
throw new Error("share-missing-signer");
}
const pubkey = this.state.getPubkey();
const activePubkey = this.state.normalizeHexPubkey(pubkey);
const signerPubkey = this.state.normalizeHexPubkey(signer.pubkey);
const eventPubkey = activePubkey || signerPubkey;
if (!eventPubkey) {
this.services.userLogger.warn("[ShareNostrController] Share post missing active pubkey.");
this.ui.showError("Please log in to share on Nostr.");
throw new Error("share-missing-pubkey");
}
if (activePubkey && signerPubkey && activePubkey !== signerPubkey) {
this.services.userLogger.error(
"[ShareNostrController] Active signer does not match current account for share.",
);
this.ui.showError("Active signer does not match your account.");
throw new Error("share-signer-mismatch");
}
if (!this.services.nostrClient?.pool) {
this.services.userLogger.error("[ShareNostrController] Share publish failed: relays not ready.");
this.ui.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 = this.services.sanitizeRelayList(relayUrls);
if (!relayTargets.length) {
this.services.userLogger.warn("[ShareNostrController] Share post missing relay targets.");
this.ui.showError("Please choose at least one relay to share to.");
throw new Error("share-missing-relays");
}
if ((signer.type === "extension" || signer.type === "nip07") && this.services.nostrClient.ensureExtensionPermissions) {
const permissionResult = await this.services.nostrClient.ensureExtensionPermissions(
this.services.permissionMethods,
);
if (!permissionResult?.ok) {
this.services.userLogger.warn(
"[ShareNostrController] Share publish blocked by signer permissions.",
permissionResult?.error,
);
this.ui.showError("Signer permissions are required to post.");
throw new Error("share-permission-denied");
}
}
const event = this.services.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 this.services.queueSignEvent(signer, event);
} catch (error) {
this.services.userLogger.error("[ShareNostrController] Failed to sign share event.", error);
this.ui.showError("Unable to sign the share event.");
throw error;
}
const publishResults = await this.services.publishEventToRelays(
this.services.nostrClient.pool,
relayTargets,
signedEvent,
);
let publishSummary;
try {
publishSummary = this.services.assertAnyRelayAccepted(publishResults, {
context: "share note",
});
} catch (publishError) {
if (publishError?.relayFailures?.length) {
publishError.relayFailures.forEach(
({ url, error: relayError, reason }) => {
this.services.userLogger.error(
`[ShareNostrController] Relay ${url} rejected share note: ${reason}`,
relayError || reason,
);
},
);
}
this.ui.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";
this.services.userLogger.warn(
`[ShareNostrController] Relay ${url} did not acknowledge share note: ${reason}`,
relayError,
);
});
}
this.services.userLogger.info(
"[ShareNostrController] Share note published.",
publishSummary.accepted.map(({ url }) => url),
);
this.ui.showSuccess("Shared to Nostr!");
return {
ok: true,
event: signedEvent,
accepted: publishSummary.accepted.map(({ url }) => url),
failed: publishSummary.failed.map(({ url }) => url),
};
}
}