diff --git a/js/app.js b/js/app.js index e38cb5c9..0a5ea507 100644 --- a/js/app.js +++ b/js/app.js @@ -31,7 +31,7 @@ import { collectVideoTags } from "./utils/videoTags.js"; import { normalizeHashtag } from "./utils/hashtagNormalization.js"; import { sanitizeProfileMediaUrl } from "./utils/profileMedia.js"; import { ADMIN_INITIAL_EVENT_BLACKLIST } from "./lists.js"; -import { userBlocks } from "./userBlocks.js"; +import { userBlocks, USER_BLOCK_EVENTS } from "./userBlocks.js"; import { relayManager } from "./relayManager.js"; import { createFeedEngine, @@ -2134,6 +2134,9 @@ class Application { case "subscriptions-permission-required": this.capturePermissionPromptRequirement("subscriptions"); break; + case "user-blocklist-permission-required": + this.capturePermissionPromptRequirement("user-blocks"); + break; case "hashtag-preferences-permission-required": this.capturePermissionPromptRequirement("hashtag-preferences"); break; @@ -2145,10 +2148,23 @@ class Application { resolvePermissionPromptMessage() { const needsSubscriptions = this.permissionPromptPending.has("subscriptions"); const needsHashtags = this.permissionPromptPending.has("hashtag-preferences"); + const needsBlocks = this.permissionPromptPending.has("user-blocks"); + if (needsBlocks && needsSubscriptions && needsHashtags) { + return "Unlock private lists and enable permissions to load your subscriptions and hashtag preferences."; + } + if (needsBlocks && needsSubscriptions) { + return "Unlock private lists and enable permissions to load your subscriptions."; + } + if (needsBlocks && needsHashtags) { + return "Unlock private lists and enable permissions to load your hashtag preferences."; + } if (needsSubscriptions && needsHashtags) { return "Enable permissions to load your subscriptions and hashtag preferences."; } + if (needsBlocks) { + return "Unlock private lists to load your block list."; + } if (needsSubscriptions) { return "Enable permissions to load your subscriptions."; } @@ -2599,6 +2615,14 @@ class Application { }); } + if (userBlocks && typeof userBlocks.on === "function") { + userBlocks.on(USER_BLOCK_EVENTS.STATUS, (detail) => { + if (detail?.status === "permission-required") { + this.capturePermissionPromptRequirement("user-blocks"); + } + }); + } + if (this.appChromeController) { this.appChromeController.initialize(); return; @@ -4134,8 +4158,9 @@ class Application { const needsSubscriptions = this.permissionPromptPending.has("subscriptions"); const needsHashtags = this.permissionPromptPending.has("hashtag-preferences"); + const needsBlocks = this.permissionPromptPending.has("user-blocks"); - if (!needsSubscriptions && !needsHashtags) { + if (!needsSubscriptions && !needsHashtags && !needsBlocks) { return; } @@ -4175,6 +4200,32 @@ class Application { tasks.push(subscriptionTask); } + if (needsBlocks && userBlocks?.loadBlocks) { + let permissionRequired = false; + const blocksTask = userBlocks + .loadBlocks(activePubkey, { + allowPermissionPrompt: true, + statusCallback: (detail) => { + if (detail?.status === "permission-required") { + permissionRequired = true; + } + }, + }) + .then(() => { + if (!permissionRequired) { + this.permissionPromptPending.delete("user-blocks"); + } + }) + .catch((error) => { + devLogger.warn( + "[Application] Failed to refresh block list after permission prompt:", + error, + ); + this.capturePermissionPromptFromError(error); + }); + tasks.push(blocksTask); + } + if (needsHashtags && this.hashtagPreferences?.load) { const hashtagTask = this.hashtagPreferences .load(activePubkey, { allowPermissionPrompt: true }) @@ -4361,13 +4412,15 @@ class Application { const tasks = []; if (activePubkey && typeof this.authService.loadBlocksForPubkey === "function") { tasks.push( - this.authService.loadBlocksForPubkey(activePubkey).catch((error) => { - devLogger.warn( - "[Application] Failed to load blocks during login:", - error, - ); - throw error; - }), + this.authService + .loadBlocksForPubkey(activePubkey, { allowPermissionPrompt: false }) + .catch((error) => { + devLogger.warn( + "[Application] Failed to load blocks during login:", + error, + ); + throw error; + }), ); } diff --git a/js/services/authService.js b/js/services/authService.js index 531778b4..0e0cf847 100644 --- a/js/services/authService.js +++ b/js/services/authService.js @@ -778,14 +778,18 @@ export default class AuthService { return { detail, completionPromise }; } - createBlocksLoadOperation(activePubkey, schedule = (callback) => Promise.resolve().then(() => callback())) { + createBlocksLoadOperation( + activePubkey, + schedule = (callback) => Promise.resolve().then(() => callback()), + options = {}, + ) { if (!this.userBlocks || typeof this.userBlocks.loadBlocks !== "function") { return null; } return { name: "blocksLoaded", - promise: schedule(() => this.userBlocks.loadBlocks(activePubkey)), + promise: schedule(() => this.userBlocks.loadBlocks(activePubkey, options)), onFulfilled: () => true, onRejected: (error) => { this.log("[AuthService] Failed to load block list", error); @@ -801,14 +805,14 @@ export default class AuthService { }; } - async loadBlocksForPubkey(pubkey) { + async loadBlocksForPubkey(pubkey, options = {}) { const normalized = this.normalizeHexPubkey(pubkey) || (typeof pubkey === "string" ? pubkey.trim() : ""); if (!normalized) { return false; } - const operation = this.createBlocksLoadOperation(normalized); + const operation = this.createBlocksLoadOperation(normalized, undefined, options); if (!operation) { return false; } diff --git a/js/userBlocks.js b/js/userBlocks.js index bb8fc4fa..2ab1b1e7 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -80,6 +80,7 @@ const FAST_BLOCKLIST_RELAY_LIMIT = 3; const FAST_BLOCKLIST_TIMEOUT_MS = 2500; const BACKGROUND_BLOCKLIST_TIMEOUT_MS = 6000; const DECRYPT_TIMEOUT_MS = 15000; +const BACKGROUND_DECRYPT_TIMEOUT_MS = 2500; const DECRYPT_RETRY_DELAY_MS = 10000; const MAX_BLOCKLIST_ENTRIES = 5000; @@ -871,6 +872,15 @@ class UserBlockListManager { : null; const statusCallback = typeof options?.statusCallback === "function" ? options.statusCallback : null; + const loadMode = options?.mode === "background" ? "background" : "interactive"; + const allowPermissionPrompt = + options?.allowPermissionPrompt !== false && loadMode !== "background"; + const decryptTimeoutMs = Number.isFinite(options?.decryptTimeoutMs) + ? Math.max(0, Math.floor(options.decryptTimeoutMs)) + : allowPermissionPrompt + ? DECRYPT_TIMEOUT_MS + : BACKGROUND_DECRYPT_TIMEOUT_MS; + const nip07DecryptTimeoutMs = allowPermissionPrompt ? 12000 : 3000; const emitStatus = (detail) => { if (!detail || typeof detail !== "object") { @@ -968,25 +978,34 @@ class UserBlockListManager { let permissionError = null; if ((!signerHasNip44 && requiresNip44) || (!signerHasNip04 && requiresNip04)) { - try { - const permissionResult = await requestDefaultExtensionPermissions( - DEFAULT_NIP07_ENCRYPTION_METHODS, + if (!allowPermissionPrompt) { + const error = new Error( + "Encryption permissions are required to decrypt the block list.", ); - if (!permissionResult?.ok) { - const error = - permissionResult?.error instanceof Error - ? permissionResult.error - : new Error( - "Extension encryption permissions are required to use the browser decryptor.", - ); - error.code = "user-blocklist-permission-required"; - permissionError = error; + error.code = "user-blocklist-permission-required"; + error.permissionRequestDeferred = true; + permissionError = error; + } else { + try { + const permissionResult = await requestDefaultExtensionPermissions( + DEFAULT_NIP07_ENCRYPTION_METHODS, + ); + if (!permissionResult?.ok) { + const error = + permissionResult?.error instanceof Error + ? permissionResult.error + : new Error( + "Extension encryption permissions are required to use the browser decryptor.", + ); + error.code = "user-blocklist-permission-required"; + permissionError = error; + } + } catch (error) { + const wrapped = + error instanceof Error ? error : new Error(String(error)); + wrapped.code = "user-blocklist-permission-required"; + permissionError = wrapped; } - } catch (error) { - const wrapped = - error instanceof Error ? error : new Error(String(error)); - wrapped.code = "user-blocklist-permission-required"; - permissionError = wrapped; } } @@ -1033,7 +1052,7 @@ class UserBlockListManager { const nostrApi = typeof window !== "undefined" ? window?.nostr : null; const decrypterOptions = { priority: NIP07_PRIORITY.HIGH, - timeoutMs: 12000, + timeoutMs: nip07DecryptTimeoutMs, retryMultiplier: 1, }; @@ -1094,6 +1113,10 @@ class UserBlockListManager { } } + if (permissionError) { + permissionError.signerStatus = signerStatus; + } + return { decryptors, sources, @@ -1291,13 +1314,13 @@ class UserBlockListManager { setTimeout( () => { const timeoutError = new Error( - `Decryption timed out after ${DECRYPT_TIMEOUT_MS / 1000}s`, + `Decryption timed out after ${decryptTimeoutMs / 1000}s`, ); timeoutError.code = "user-blocklist-decrypt-timeout"; timeoutError.signerStatus = resolveSignerStatus(); reject(timeoutError); }, - DECRYPT_TIMEOUT_MS, + decryptTimeoutMs, ), ); @@ -1314,6 +1337,26 @@ class UserBlockListManager { } if (decryptionError) { + if (decryptionError.code === "user-blocklist-permission-required") { + const signerStatus = + decryptionError.signerStatus || resolveSignerStatus(); + this.blockedPubkeys = previousState.blockedPubkeys; + this.blockEventId = previousState.blockEventId; + this.blockEventCreatedAt = previousState.blockEventCreatedAt; + this.loaded = true; + emitStatus({ + status: "permission-required", + signerStatus, + source, + reason: "decryptor-permission", + allowPermissionPrompt, + mode: loadMode, + events: [newestMute?.id, newestBlock?.id].filter(Boolean), + error: decryptionError, + }); + emitStatus({ status: "settled" }); + return; + } if (decryptionError.code === "user-blocklist-decrypt-timeout") { const signerStatus = decryptionError.signerStatus || resolveSignerStatus();