diff --git a/js/nostr/client.js b/js/nostr/client.js index ea45a1e3..f9124a95 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -201,6 +201,7 @@ import { clearActiveSigner as clearActiveSignerInRegistry, logoutSigner as logoutSignerFromRegistry, resolveActiveSigner as resolveActiveSignerFromRegistry, + requestDefaultExtensionPermissions, } from "../nostrClientRegistry.js"; import { createNip07Adapter } from "./adapters/nip07Adapter.js"; import { createNsecAdapter } from "./adapters/nsecAdapter.js"; @@ -1016,6 +1017,63 @@ function buildDmFilters(actorPubkey, { since, until, limit } = {}) { return filters; } +function normalizeDmEncryptionHint(value) { + if (typeof value !== "string") { + return ""; + } + return value.trim().toLowerCase().replace(/[^a-z0-9_.-]/g, ""); +} + +function resolveDmDecryptPermissionMethods(event) { + if (!event || typeof event !== "object") { + return []; + } + + const kind = Number.isFinite(event.kind) ? event.kind : null; + if (kind === 1059) { + return ["nip44.decrypt", "nip44.v2.decrypt"]; + } + + const tags = Array.isArray(event.tags) ? event.tags : []; + let requiresNip44 = false; + let requiresNip04 = false; + + for (const tag of tags) { + if (!Array.isArray(tag) || tag.length < 2) { + continue; + } + const label = typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : ""; + if (label !== "encrypted" && label !== "encryption") { + continue; + } + for (let index = 1; index < tag.length; index += 1) { + const normalized = normalizeDmEncryptionHint(tag[index]); + if (!normalized) { + continue; + } + if (normalized.includes("nip44")) { + requiresNip44 = true; + } + if (normalized.includes("nip04")) { + requiresNip04 = true; + } + } + } + + if (!requiresNip04 && !requiresNip44 && kind === 4) { + requiresNip04 = true; + } + + const methods = []; + if (requiresNip04) { + methods.push("nip04.decrypt"); + } + if (requiresNip44) { + methods.push("nip44.decrypt", "nip44.v2.decrypt"); + } + return methods; +} + const EXTENSION_MIME_MAP = Object.freeze( Object.fromEntries( @@ -4650,7 +4708,10 @@ export class NostrClient { return { actorPubkey: normalizedActor, decryptors }; } - async decryptDirectMessageEvent(event, { actorPubkey } = {}) { + async decryptDirectMessageEvent( + event, + { actorPubkey, onPermissionRequest } = {}, + ) { const eventId = typeof event?.id === "string" ? event.id : ""; if (eventId) { const cached = this.dmDecryptCache.get(eventId); @@ -4661,6 +4722,50 @@ export class NostrClient { const decryptDM = await this.ensureDmDecryptor(); const context = await this.buildDmDecryptContext(actorPubkey); + const permissionMethods = resolveDmDecryptPermissionMethods(event); + const needsExtensionPermission = + permissionMethods.length && + typeof window !== "undefined" && + window?.nostr && + !this.hasRequiredExtensionPermissions(permissionMethods) && + context.decryptors.some( + (candidate) => candidate?.source === "extension", + ); + + if (needsExtensionPermission) { + if (typeof onPermissionRequest === "function") { + onPermissionRequest({ + status: "decrypting", + methods: permissionMethods, + eventId, + }); + } + + const permissionResult = + await requestDefaultExtensionPermissions(permissionMethods); + if (!permissionResult?.ok) { + const error = new Error("extension-permission-denied"); + error.code = "extension-permission-denied"; + error.cause = permissionResult?.error || null; + if (typeof onPermissionRequest === "function") { + onPermissionRequest({ + status: "error", + methods: permissionMethods, + eventId, + error, + }); + } + return { ok: false, error }; + } + + if (typeof onPermissionRequest === "function") { + onPermissionRequest({ + status: "ready", + methods: permissionMethods, + eventId, + }); + } + } let result; try { @@ -4694,6 +4799,10 @@ export class NostrClient { Number.isFinite(options?.decryptLimit) && options.decryptLimit > 0 ? Math.floor(options.decryptLimit) : null; + const onPermissionRequest = + typeof options?.onPermissionRequest === "function" + ? options.onPermissionRequest + : null; const relayCandidates = Array.isArray(options.relays) ? options.relays @@ -4746,6 +4855,7 @@ export class NostrClient { try { const decrypted = await this.decryptDirectMessageEvent(event, { actorPubkey: context.actorPubkey || actorPubkeyInput, + onPermissionRequest, }); if (decrypted?.ok) { messages.push(decrypted); @@ -4830,6 +4940,10 @@ export class NostrClient { const result = await this.decryptDirectMessageEvent(event, { actorPubkey: context.actorPubkey || actorPubkeyInput, + onPermissionRequest: + typeof options?.onPermissionRequest === "function" + ? options.onPermissionRequest + : null, }); if (result?.ok) { diff --git a/js/nostr/nip07Permissions.js b/js/nostr/nip07Permissions.js index 62b2334b..9721b926 100644 --- a/js/nostr/nip07Permissions.js +++ b/js/nostr/nip07Permissions.js @@ -22,12 +22,11 @@ export const DEFAULT_NIP07_ENCRYPTION_METHODS = Object.freeze([ ]); export const DEFAULT_NIP07_PERMISSION_METHODS = Object.freeze([ - // Core auth + relay metadata + // Core auth + relay metadata (encryption permissions requested on demand) "get_public_key", "sign_event", "read_relays", "write_relays", - ...DEFAULT_NIP07_ENCRYPTION_METHODS, ]); export const NIP07_PRIORITY = Object.freeze({ diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 2e4ce5c2..0b063ac3 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -717,7 +717,14 @@ class HashtagPreferencesService { (!signerHasNip04 && requiresNip04) ) { try { - await requestDefaultExtensionPermissions(); + const permissionMethods = []; + if (requiresNip04) { + permissionMethods.push("nip04.decrypt"); + } + if (requiresNip44) { + permissionMethods.push("nip44.decrypt", "nip44.v2.decrypt"); + } + await requestDefaultExtensionPermissions(permissionMethods); } catch (error) { userLogger.warn( `${LOG_PREFIX} Extension permissions request failed while loading preferences`, @@ -920,7 +927,15 @@ class HashtagPreferencesService { } if (signer.type === "extension") { - const permissionResult = await requestDefaultExtensionPermissions(); + const permissionMethods = ["sign_event"]; + if (typeof signer.nip04Encrypt === "function") { + permissionMethods.push("nip04.encrypt"); + } + if (typeof signer.nip44Encrypt === "function") { + permissionMethods.push("nip44.encrypt", "nip44.v2.encrypt"); + } + const permissionResult = + await requestDefaultExtensionPermissions(permissionMethods); if (!permissionResult?.ok) { const error = new Error( "The active signer must allow encryption and signing before publishing preferences.", diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 5d7dfe7e..d3b5af0d 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -958,6 +958,24 @@ export class NostrService { ...options, limit: resolvedLimit, decryptLimit: resolvedDecryptLimit, + onPermissionRequest: (detail = {}) => { + const status = detail?.status; + if (status === "decrypting") { + this.emit("directMessages:status", { + status, + message: "Decrypting direct messages…", + methods: detail?.methods || [], + eventId: detail?.eventId || null, + }); + } else if (status === "ready") { + this.emit("directMessages:status", { status: "ready" }); + } else if (status === "error") { + this.emit("directMessages:status", { + status: "error", + error: detail?.error || null, + }); + } + }, onMessage: (message) => { this.applyDirectMessage(message, { reason: "load-incremental" }); }, @@ -1077,6 +1095,24 @@ export class NostrService { { relays: resolvedRelays, onEvent: handlers.onEvent, + onPermissionRequest: (detail = {}) => { + const status = detail?.status; + if (status === "decrypting") { + this.emit("directMessages:status", { + status, + message: "Decrypting direct messages…", + methods: detail?.methods || [], + eventId: detail?.eventId || null, + }); + } else if (status === "ready") { + this.emit("directMessages:status", { status: "ready" }); + } else if (status === "error") { + this.emit("directMessages:status", { + status: "error", + error: detail?.error || null, + }); + } + }, onMessage: (message, context = {}) => { this.applyDirectMessage(message, { reason: "subscription", diff --git a/js/subscriptions.js b/js/subscriptions.js index af7fe8c2..29f083d4 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -357,6 +357,28 @@ class SubscriptionsManager { }); } + emitStatus(detail = {}) { + this.emitter.emit("status", detail); + + if (detail?.status !== "loading") { + return; + } + + const message = + typeof detail?.message === "string" && detail.message.trim() + ? detail.message.trim() + : "Decrypting subscriptions…"; + const container = + this.lastContainerId && + typeof document !== "undefined" && + document.getElementById + ? document.getElementById(this.lastContainerId) + : null; + if (container) { + container.innerHTML = getSidebarLoadingMarkup(message); + } + } + /** * Decrypt the subscription list from kind=30000 (d="subscriptions"). */ @@ -751,9 +773,21 @@ class SubscriptionsManager { (!signerHasNip04 && !windowHasNip04 && requiresNip04) ) { try { - const hasCachedPermissions = hasDefaultExtensionPermissions(); + const permissionMethods = []; + if (requiresNip04) { + permissionMethods.push("nip04.decrypt"); + } + if (requiresNip44) { + permissionMethods.push("nip44.decrypt", "nip44.v2.decrypt"); + } + const hasCachedPermissions = hasDefaultExtensionPermissions(permissionMethods); if (!hasCachedPermissions) { - const permissionResult = await requestDefaultExtensionPermissions(); + this.emitStatus({ + status: "loading", + message: "Decrypting subscriptions…", + }); + const permissionResult = + await requestDefaultExtensionPermissions(permissionMethods); if (!permissionResult?.ok) { const error = permissionResult?.error instanceof Error @@ -769,6 +803,8 @@ class SubscriptionsManager { const wrapped = error instanceof Error ? error : new Error(String(error)); wrapped.code = "nostr-permission-error"; return { ok: false, error: wrapped }; + } finally { + this.emitStatus({ status: "idle" }); } } @@ -983,9 +1019,18 @@ class SubscriptionsManager { } if (signer.type === "extension") { - const hasCachedPermissions = hasDefaultExtensionPermissions(); + const permissionMethods = ["sign_event"]; + if (typeof signer.nip04Encrypt === "function") { + permissionMethods.push("nip04.encrypt"); + } + if (typeof signer.nip44Encrypt === "function") { + permissionMethods.push("nip44.encrypt", "nip44.v2.encrypt"); + } + const hasCachedPermissions = + hasDefaultExtensionPermissions(permissionMethods); if (!hasCachedPermissions) { - const permissionResult = await requestDefaultExtensionPermissions(); + const permissionResult = + await requestDefaultExtensionPermissions(permissionMethods); if (!permissionResult.ok) { userLogger.warn( "[SubscriptionsManager] Signer permissions denied while updating subscriptions.", diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js index 905d78bb..4dcb0a4f 100644 --- a/js/ui/components/UploadModal.js +++ b/js/ui/components/UploadModal.js @@ -940,7 +940,8 @@ export class UploadModal { try { if (signer?.type === "extension") { - const permissionResult = await requestDefaultExtensionPermissions(); + const permissionResult = + await requestDefaultExtensionPermissions(["nip04.decrypt"]); if (!permissionResult?.ok) { alert("Extension permissions are required to unlock storage."); return; diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 46d0edc5..505da8f5 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -1270,6 +1270,8 @@ export class ProfileModalController { this.blockListEmpty = null; this.blockListStatus = null; this.blockListLoadingState = "idle"; + this.subscriptionsStatus = null; + this.subscriptionsLoadingState = "idle"; this.blockInput = null; this.addBlockedButton = null; this.profileBlockedList = null; @@ -1415,6 +1417,9 @@ export class ProfileModalController { this.subscriptionsService.on("change", () => { void this.populateSubscriptionsList(); }); + this.subscriptionsService.on("status", (detail) => { + this.handleSubscriptionsStatus(detail); + }); } if ( @@ -1424,6 +1429,9 @@ export class ProfileModalController { this.services.userBlocks.on("change", () => { this.populateBlockedList(); }); + this.services.userBlocks.on("status", (detail) => { + this.handleBlockListStatus(detail); + }); } } @@ -1953,6 +1961,9 @@ export class ProfileModalController { subscribe("directMessages:updated", (detail) => { this.handleDirectMessagesUpdated(detail); }); + subscribe("directMessages:status", (detail) => { + this.handleDirectMessagesStatus(detail); + }); subscribe("directMessages:cleared", () => { this.handleDirectMessagesCleared(); }); @@ -5018,6 +5029,38 @@ export class ProfileModalController { }); } + handleDirectMessagesStatus(detail = {}) { + const status = typeof detail?.status === "string" ? detail.status : ""; + if (status === "decrypting") { + this.setMessagesLoadingState("loading", { + message: + typeof detail?.message === "string" && detail.message.trim() + ? detail.message.trim() + : "Decrypting direct messages…", + }); + return; + } + + if (status === "error") { + this.setMessagesLoadingState("error", { + message: + "Unable to decrypt direct messages. Check your extension permissions.", + }); + return; + } + + if (status === "ready" && this.messagesLoadingState === "loading") { + const actor = this.resolveActiveDmActor(); + if (!actor) { + this.setMessagesLoadingState("unauthenticated"); + } else if (!this.directMessagesCache.length) { + this.setMessagesLoadingState("empty"); + } else { + this.setMessagesLoadingState("ready"); + } + } + } + handleDirectMessagesCleared() { if (this.activeMessagesRequest) { return; @@ -7393,6 +7436,144 @@ export class ProfileModalController { } } + handleBlockListStatus(detail = {}) { + const status = typeof detail?.status === "string" ? detail.status : ""; + const message = + typeof detail?.message === "string" && detail.message.trim() + ? detail.message.trim() + : ""; + + if (status === "loading") { + this.setBlockListLoadingState("loading", { message }); + return; + } + + if (status === "error") { + this.setBlockListLoadingState("error", { message }); + return; + } + + if (status) { + this.setBlockListLoadingState("idle"); + } + } + + ensureSubscriptionsStatusElement() { + if (this.subscriptionsStatus instanceof HTMLElement) { + return this.subscriptionsStatus; + } + + const anchor = + this.subscriptionList instanceof HTMLElement + ? this.subscriptionList + : this.subscriptionListEmpty instanceof HTMLElement + ? this.subscriptionListEmpty + : null; + + if (!anchor || !(anchor.parentElement instanceof HTMLElement)) { + return null; + } + + const existing = anchor.parentElement.querySelector( + '[data-role="subscriptions-status"]', + ); + if (existing instanceof HTMLElement) { + this.subscriptionsStatus = existing; + return existing; + } + + const status = document.createElement("div"); + status.dataset.role = "subscriptions-status"; + status.className = "mt-4 flex items-center gap-3 text-sm text-muted hidden"; + status.setAttribute("role", "status"); + status.setAttribute("aria-live", "polite"); + + if (this.subscriptionList instanceof HTMLElement) { + anchor.parentElement.insertBefore(status, this.subscriptionList); + } else { + anchor.parentElement.appendChild(status); + } + + this.subscriptionsStatus = status; + return status; + } + + setSubscriptionsLoadingState(state = "idle", options = {}) { + const statusEl = this.ensureSubscriptionsStatusElement(); + if (!statusEl) { + this.subscriptionsLoadingState = state; + return; + } + + const message = + typeof options.message === "string" && options.message.trim() + ? options.message.trim() + : ""; + + statusEl.innerHTML = ""; + statusEl.classList.remove("text-status-warning"); + statusEl.classList.add("text-muted"); + statusEl.classList.add("hidden"); + + this.subscriptionsLoadingState = state; + + if (state === "loading") { + if (this.subscriptionListEmpty instanceof HTMLElement) { + this.subscriptionListEmpty.classList.add("hidden"); + } + + const spinner = document.createElement("span"); + spinner.className = "status-spinner status-spinner--inline"; + spinner.setAttribute("aria-hidden", "true"); + + const text = document.createElement("span"); + text.textContent = message || "Loading subscriptions…"; + + statusEl.appendChild(spinner); + statusEl.appendChild(text); + statusEl.classList.remove("hidden"); + return; + } + + if (state === "error") { + statusEl.classList.remove("text-muted"); + statusEl.classList.add("text-status-warning"); + + if (this.subscriptionListEmpty instanceof HTMLElement) { + this.subscriptionListEmpty.classList.add("hidden"); + } + + const text = document.createElement("span"); + text.textContent = + message || "Subscriptions may be out of date. Try again later."; + + statusEl.appendChild(text); + statusEl.classList.remove("hidden"); + } + } + + handleSubscriptionsStatus(detail = {}) { + const status = typeof detail?.status === "string" ? detail.status : ""; + const message = + typeof detail?.message === "string" && detail.message.trim() + ? detail.message.trim() + : ""; + + if (status === "loading") { + this.setSubscriptionsLoadingState("loading", { message }); + return; + } + + if (status === "error") { + this.setSubscriptionsLoadingState("error", { message }); + return; + } + + if (status) { + this.setSubscriptionsLoadingState("idle"); + } + } + normalizeHashtagTag(value) { return normalizeHashtag(value); } @@ -8141,6 +8322,9 @@ export class ProfileModalController { if (!deduped.length) { this.subscriptionListEmpty.classList.remove("hidden"); this.subscriptionList.classList.add("hidden"); + if (this.subscriptionsLoadingState === "loading") { + this.setSubscriptionsLoadingState("idle"); + } return; } @@ -8224,6 +8408,10 @@ export class ProfileModalController { this.subscriptionList.appendChild(item); }); + if (this.subscriptionsLoadingState === "loading") { + this.setSubscriptionsLoadingState("idle"); + } + if ( entriesNeedingFetch.size && typeof this.services.batchFetchProfiles === "function" @@ -8251,6 +8439,10 @@ export class ProfileModalController { if (this.subscriptionListEmpty instanceof HTMLElement) { this.subscriptionListEmpty.classList.remove("hidden"); } + + if (this.subscriptionsLoadingState === "loading") { + this.setSubscriptionsLoadingState("idle"); + } } async handleUnsubscribeFromCreator(candidate) { diff --git a/js/userBlocks.js b/js/userBlocks.js index 1687fc2f..a59e995d 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -825,7 +825,20 @@ class UserBlockListManager { let permissionError = null; if ((!signerHasNip44 && requiresNip44) || (!signerHasNip04 && requiresNip04)) { try { - const permissionResult = await requestDefaultExtensionPermissions(); + const permissionMethods = []; + if (requiresNip04) { + permissionMethods.push("nip04.decrypt"); + } + if (requiresNip44) { + permissionMethods.push("nip44.decrypt", "nip44.v2.decrypt"); + } + emitStatus({ + status: "loading", + message: "Decrypting blocked creators…", + reason: "permissions", + }); + const permissionResult = + await requestDefaultExtensionPermissions(permissionMethods); if (!permissionResult?.ok) { permissionError = permissionResult?.error instanceof Error @@ -836,6 +849,8 @@ class UserBlockListManager { } } catch (error) { permissionError = error instanceof Error ? error : new Error(String(error)); + } finally { + emitStatus({ status: "settled", reason: "permissions" }); } } @@ -1789,7 +1804,15 @@ class UserBlockListManager { } if (signer.type === "extension") { - const permissionResult = await requestDefaultExtensionPermissions(); + const permissionMethods = ["sign_event"]; + if (typeof signer.nip04Encrypt === "function") { + permissionMethods.push("nip04.encrypt"); + } + if (typeof signer.nip44Encrypt === "function") { + permissionMethods.push("nip44.encrypt", "nip44.v2.encrypt"); + } + const permissionResult = + await requestDefaultExtensionPermissions(permissionMethods); if (!permissionResult.ok) { userLogger.warn( "[UserBlockList] Signer permissions denied while updating the block list.",