diff --git a/debug_no_cards.png b/debug_no_cards.png new file mode 100644 index 00000000..de19160a Binary files /dev/null and b/debug_no_cards.png differ diff --git a/js/app.js b/js/app.js index ee6868ef..b5290d46 100644 --- a/js/app.js +++ b/js/app.js @@ -169,6 +169,7 @@ import SimilarContentController from "./ui/similarContentController.js"; import UrlHealthController from "./ui/urlHealthController.js"; import VideoModalCommentController from "./ui/videoModalCommentController.js"; import TorrentStatusController from "./ui/torrentStatusController.js"; +import ShareNostrController from "./ui/shareNostrController.js"; import ModerationActionController from "./services/moderationActionController.js"; import ModerationDecorator from "./services/moderationDecorator.js"; import { bootstrapTrustedSeeds } from "./services/trustBootstrap.js"; @@ -372,6 +373,20 @@ class Application { }, }); + this.shareNostrController = new ShareNostrController({ + ui: { + showError: (msg) => this.showError(msg), + showSuccess: (msg) => this.showSuccess(msg), + getModal: () => this.shareNostrModal, + }, + state: { + getPubkey: () => this.pubkey, + normalizeHexPubkey: (key) => this.normalizeHexPubkey(key), + getCurrentVideo: () => this.currentVideo, + buildShareUrlFromEventId: (id) => this.buildShareUrlFromEventId(id), + }, + }); + this.initializeModerationActionController(); this.initializeSimilarContentController(); @@ -10116,165 +10131,9 @@ class Application { } 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"); + if (this.shareNostrController) { + return this.shareNostrController.handleShare(payload); } - - 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() { @@ -10296,40 +10155,8 @@ class Application { } async openShareNostrModal({ video, triggerElement } = {}) { - const targetVideo = - video && typeof video === "object" ? video : this.currentVideo || null; - if (!targetVideo) { - this.showError("No video is available to share."); - return; - } - - if (!this.shareNostrModal) { - devLogger.warn("[Application] Share Nostr modal is unavailable."); - this.showError("Share modal is not ready yet."); - return; - } - - const shareUrl = - typeof targetVideo.shareUrl === "string" && targetVideo.shareUrl.trim() - ? targetVideo.shareUrl.trim() - : this.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 this.shareNostrModal.open({ - video: payload, - triggerElement, - }); - } catch (error) { - devLogger.error("[Application] Failed to open Share Nostr modal:", error); - this.showError("Unable to open the share modal."); + if (this.shareNostrController) { + return this.shareNostrController.openModal({ video, triggerElement }); } } diff --git a/js/ui/shareNostrController.js b/js/ui/shareNostrController.js new file mode 100644 index 00000000..93750464 --- /dev/null +++ b/js/ui/shareNostrController.js @@ -0,0 +1,237 @@ +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" && 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), + }; + } +} diff --git a/tests/shareNostrController.test.mjs b/tests/shareNostrController.test.mjs new file mode 100644 index 00000000..1753777a --- /dev/null +++ b/tests/shareNostrController.test.mjs @@ -0,0 +1,146 @@ +import test from 'node:test'; +import { strict as assert } from 'node:assert'; +import ShareNostrController from '../js/ui/shareNostrController.js'; + +test('ShareNostrController', async (t) => { + const mockGetActiveSigner = t.mock.fn(() => ({ + pubkey: 'signerpubkey', + signEvent: async () => ({ id: 'signed_event' }), + type: 'extension' + })); + + const mockQueueSignEvent = t.mock.fn(async (signer, event) => ({ + ...event, + id: 'signed_event', + sig: 'signature' + })); + + const mockPublishEventToRelays = t.mock.fn(async () => ({ + accepted: ['wss://relay.example.com'], + failed: [] + })); + + const mockAssertAnyRelayAccepted = t.mock.fn((summary) => summary); + + const mockSanitizeRelayList = t.mock.fn((list) => list); + + const mockBuildShareEvent = t.mock.fn(({ pubkey, content, video }) => ({ + kind: 1, + pubkey, + content, + tags: [['e', video.id], ['p', video.pubkey]] + })); + + const mockNostrClient = { + pool: {}, + ensureExtensionPermissions: async () => ({ ok: true }) + }; + + const mockLogger = { + warn: t.mock.fn(), + error: t.mock.fn(), + info: t.mock.fn(), + log: t.mock.fn() + }; + + const services = { + sanitizeRelayList: mockSanitizeRelayList, + buildShareEvent: mockBuildShareEvent, + publishEventToRelays: mockPublishEventToRelays, + assertAnyRelayAccepted: mockAssertAnyRelayAccepted, + getActiveSigner: mockGetActiveSigner, + queueSignEvent: mockQueueSignEvent, + permissionMethods: {}, + nostrClient: mockNostrClient, + userLogger: mockLogger, + devLogger: mockLogger + }; + + await t.test('handleShare shares video successfully', async () => { + const ui = { + showError: t.mock.fn(), + showSuccess: t.mock.fn(), + getModal: () => ({}) + }; + const state = { + getPubkey: () => 'signerpubkey', // Match the signer pubkey + normalizeHexPubkey: (k) => k, + getCurrentVideo: () => ({ id: 'video1', title: 'Video 1' }) + }; + + const controller = new ShareNostrController({ ui, state, services }); + + const payload = { + video: { id: 'video1', title: 'Video 1', pubkey: 'creator1' }, + content: 'Check this out', + relays: ['wss://relay.example.com'] + }; + + const result = await controller.handleShare(payload); + + assert.ok(result.ok); + assert.equal(ui.showSuccess.mock.calls.length, 1); + assert.equal(ui.showError.mock.calls.length, 0); + }); + + await t.test('handleShare throws error if missing video details', async () => { + const ui = { + showError: t.mock.fn(), + showSuccess: t.mock.fn(), + getModal: () => ({}) + }; + const state = { + getPubkey: () => 'userpubkey', + normalizeHexPubkey: (k) => k + }; + const controller = new ShareNostrController({ ui, state, services }); + + const payload = { + video: { id: '', title: '' } // Invalid + }; + + await assert.rejects(async () => { + await controller.handleShare(payload); + }, /share-missing-video-details/); + + assert.equal(ui.showError.mock.calls.length, 1); + }); + + await t.test('openModal shows error if no video', async () => { + const ui = { + showError: t.mock.fn(), + getModal: () => ({}) + }; + const state = { + getCurrentVideo: () => null + }; + const controller = new ShareNostrController({ ui, state, services }); + + await controller.openModal({ video: null }); + assert.equal(ui.showError.mock.calls.length, 1); + assert.match(ui.showError.mock.calls[0].arguments[0], /No video/); + }); + + await t.test('openModal opens modal with correct payload', async () => { + const modalMock = { + open: t.mock.fn(async () => {}) + }; + const ui = { + showError: t.mock.fn(), + getModal: () => modalMock + }; + const video = { id: 'v1', title: 'T1', pubkey: 'p1' }; + const state = { + getCurrentVideo: () => video, + buildShareUrlFromEventId: (id) => `https://example.com/${id}` + }; + const controller = new ShareNostrController({ ui, state, services }); + + await controller.openModal(); + + assert.equal(modalMock.open.mock.calls.length, 1); + const openArg = modalMock.open.mock.calls[0].arguments[0]; + assert.equal(openArg.video.id, 'v1'); + assert.equal(openArg.video.shareUrl, 'https://example.com/v1'); + }); +}); diff --git a/verification_failed.png b/verification_failed.png new file mode 100644 index 00000000..b6726d55 Binary files /dev/null and b/verification_failed.png differ