From fd3f9e723a20170e41b5175effd709c5d030bd5a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 8 Feb 2026 14:10:39 -0500 Subject: [PATCH] Split subscriptions/hashtag readiness and expose unavailable preference states --- js/app.js | 44 ++++++++++++- js/app/feedCoordinator.js | 4 ++ js/feedEngine/stages.js | 8 +++ js/services/hashtagPreferencesService.js | 81 +++++++++++++++++++----- js/subscriptions.js | 72 ++++++++++++++++----- js/ui/profileModalController.js | 73 ++++++++++++++++++++- 6 files changed, 246 insertions(+), 36 deletions(-) diff --git a/js/app.js b/js/app.js index 62af99ce..3cbc1e02 100644 --- a/js/app.js +++ b/js/app.js @@ -2223,6 +2223,22 @@ class Application { (detail?.loaded === false ? false : Boolean(service && service.loaded)); + const uiReady = + detail?.uiReady === true || + (detail?.uiReady === false + ? false + : Boolean(service && service.uiReady)); + const dataReady = + detail?.dataReady === true || + (detail?.dataReady === false + ? false + : Boolean(service && service.dataReady)); + const loadedFromCache = + detail?.loadedFromCache === true || + (detail?.loadedFromCache === false + ? false + : Boolean(service && service.loadedFromCache)); + const lastLoadError = detail?.lastLoadError || service?.lastLoadError || null; return { interests: this.normalizeHashtagPreferenceList(sourceInterests), @@ -2230,6 +2246,10 @@ class Application { eventId: rawEventId || null, createdAt, loaded, + uiReady, + dataReady, + loadedFromCache, + lastLoadError, action, }; } @@ -2249,8 +2269,22 @@ class Application { ? Number(snapshot.createdAt) : ""; const loaded = snapshot.loaded === true ? "1" : "0"; + const uiReady = snapshot.uiReady === true ? "1" : "0"; + const dataReady = snapshot.dataReady === true ? "1" : "0"; + const loadedFromCache = snapshot.loadedFromCache === true ? "1" : "0"; + const lastLoadError = snapshot?.lastLoadError?.code || ""; - return [interests, disinterests, eventId, createdAt, loaded].join("|"); + return [ + interests, + disinterests, + eventId, + createdAt, + loaded, + uiReady, + dataReady, + loadedFromCache, + lastLoadError, + ].join("|"); } updateCachedHashtagPreferences(detail = {}) { @@ -2264,6 +2298,10 @@ class Application { eventId: snapshot.eventId, createdAt: snapshot.createdAt, loaded: snapshot.loaded, + uiReady: snapshot.uiReady, + dataReady: snapshot.dataReady, + loadedFromCache: snapshot.loadedFromCache, + lastLoadError: snapshot.lastLoadError, action: snapshot.action, }; this.hashtagPreferencesSnapshotSignature = signature; @@ -2334,6 +2372,10 @@ class Application { ? Number(snapshot.createdAt) : null, loaded: snapshot.loaded === true, + uiReady: snapshot.uiReady === true, + dataReady: snapshot.dataReady === true, + loadedFromCache: snapshot.loadedFromCache === true, + lastLoadError: snapshot.lastLoadError || null, }; } diff --git a/js/app/feedCoordinator.js b/js/app/feedCoordinator.js index 2db852fb..106201b0 100644 --- a/js/app/feedCoordinator.js +++ b/js/app/feedCoordinator.js @@ -523,6 +523,7 @@ export function createFeedCoordinator(deps) { ? this.getHashtagPreferences() : {}; const { interests = [], disinterests = [] } = preferenceSource || {}; + const preferencesAvailable = preferenceSource?.dataReady === true; const moderationThresholds = this.getActiveModerationThresholds(); const watchedKeys = new Set(); @@ -544,6 +545,7 @@ export function createFeedCoordinator(deps) { tagPreferences: { interests: Array.isArray(interests) ? [...interests] : [], disinterests: Array.isArray(disinterests) ? [...disinterests] : [], + available: preferencesAvailable, }, moderationThresholds: moderationThresholds ? { ...moderationThresholds } @@ -606,6 +608,7 @@ export function createFeedCoordinator(deps) { ? this.getHashtagPreferences() : {}; const { interests = [], disinterests = [] } = preferenceSource || {}; + const preferencesAvailable = preferenceSource?.dataReady === true; const moderationThresholds = this.getActiveModerationThresholds(); const watchHistorySource = @@ -636,6 +639,7 @@ export function createFeedCoordinator(deps) { tagPreferences: { interests: Array.isArray(interests) ? [...interests] : [], disinterests: Array.isArray(disinterests) ? [...disinterests] : [], + available: preferencesAvailable, }, watchHistoryTagCounts, exploreTagIdf, diff --git a/js/feedEngine/stages.js b/js/feedEngine/stages.js index ffd7fc18..5ad8e803 100644 --- a/js/feedEngine/stages.js +++ b/js/feedEngine/stages.js @@ -341,6 +341,14 @@ function filterByTagPreferences({ enforceInterests, }) { const tagPreferences = context?.runtime?.tagPreferences; + if (tagPreferences?.available === false) { + context?.addWhy?.({ + stage: stageName, + type: "status", + reason: "preferences-unavailable", + }); + return items; + } const interests = normalizeTagSet(tagPreferences?.interests); const disinterests = normalizeTagSet(tagPreferences?.disinterests); if (!interests.size && !disinterests.size) { diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 72861d8c..6a5d39a5 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -249,7 +249,12 @@ class HashtagPreferencesService { this.activePubkey = null; this.eventId = null; this.eventCreatedAt = null; + this.uiReady = false; + this.dataReady = false; this.loaded = false; + this.loadedFromCache = false; + this.lastSuccessfulSyncAt = null; + this.lastAttemptAt = null; this.backgroundLoading = false; this.preferencesVersion = DEFAULT_VERSION; this.decryptRetryTimeoutId = null; @@ -283,7 +288,12 @@ class HashtagPreferencesService { this.disinterests = new Set(); this.eventId = null; this.eventCreatedAt = null; + this.uiReady = false; + this.dataReady = false; this.loaded = false; + this.loadedFromCache = false; + this.lastSuccessfulSyncAt = null; + this.lastAttemptAt = null; this.backgroundLoading = false; this.preferencesVersion = DEFAULT_VERSION; this.lastLoadError = null; @@ -390,7 +400,10 @@ class HashtagPreferencesService { this.eventId = cached.eventId || null; this.eventCreatedAt = cached.createdAt || null; this.preferencesVersion = cached.version || DEFAULT_VERSION; + this.uiReady = true; + this.dataReady = true; this.loaded = true; + this.loadedFromCache = true; this.emitChange("cache-load"); return true; } @@ -548,7 +561,12 @@ class HashtagPreferencesService { this.loadingPubkey = normalized; try { - return await loadPromise; + await loadPromise; + return { + ok: this.dataReady === true, + error: this.lastLoadError || null, + state: this.getLoadState(), + }; } finally { if (this.loadPromise === loadPromise) { this.loadPromise = null; @@ -560,6 +578,7 @@ class HashtagPreferencesService { async loadInternal(pubkey, options = {}) { const normalized = normalizeHexPubkey(pubkey); const allowPermissionPrompt = options?.allowPermissionPrompt !== false; + this.lastAttemptAt = Date.now(); this.lastLoadError = null; let wasLoadedForUser = this.activePubkey && @@ -573,6 +592,8 @@ class HashtagPreferencesService { if (!normalized) { this.reset(); + this.uiReady = true; + this.dataReady = true; this.loaded = true; return; } @@ -606,8 +627,7 @@ class HashtagPreferencesService { } return; } - this.reset(); - this.loaded = true; + this.uiReady = true; if (wasBackgroundLoading) { this.backgroundLoading = false; this.emitChange("background-loaded", { background: false }); @@ -628,8 +648,7 @@ class HashtagPreferencesService { } return; } - this.reset(); - this.loaded = true; + this.uiReady = true; if (wasBackgroundLoading) { this.backgroundLoading = false; this.emitChange("background-loaded", { background: false }); @@ -720,8 +739,16 @@ class HashtagPreferencesService { return; } - this.reset(); + this.interests = new Set(); + this.disinterests = new Set(); + this.eventId = null; + this.eventCreatedAt = null; + this.preferencesVersion = DEFAULT_VERSION; + this.uiReady = true; + this.dataReady = true; this.loaded = true; + this.loadedFromCache = false; + this.lastSuccessfulSyncAt = Date.now(); if (wasBackgroundLoading) { this.backgroundLoading = false; this.emitChange("background-loaded", { background: false }); @@ -764,8 +791,16 @@ class HashtagPreferencesService { } return; } - this.reset(); + this.interests = new Set(); + this.disinterests = new Set(); + this.eventId = null; + this.eventCreatedAt = null; + this.preferencesVersion = DEFAULT_VERSION; + this.uiReady = true; + this.dataReady = true; this.loaded = true; + this.loadedFromCache = false; + this.lastSuccessfulSyncAt = Date.now(); if (wasBackgroundLoading) { this.backgroundLoading = false; this.emitChange("background-loaded", { background: false }); @@ -815,15 +850,13 @@ class HashtagPreferencesService { if (!decryptResult.ok) { this.lastLoadError = decryptResult.error || null; + this.uiReady = true; userLogger.warn( `${LOG_PREFIX} Failed to decrypt hashtag preferences`, decryptResult.error, ); if (decryptResult.error?.code === "hashtag-preferences-decrypt-timeout") { - if (!wasLoadedForUser && !this.loaded) { - this.loaded = true; - } this.scheduleDecryptRetry(normalized, decryptResult.error, { allowPermissionPrompt, }); @@ -833,9 +866,6 @@ class HashtagPreferencesService { !allowPermissionPrompt && decryptResult.error?.code === "hashtag-preferences-permission-required" ) { - if (!wasLoadedForUser && !this.loaded) { - this.loaded = true; - } this.scheduleDecryptRetry(normalized, decryptResult.error, { allowPermissionPrompt: true, }); @@ -859,8 +889,7 @@ class HashtagPreferencesService { return; } - this.reset(); - this.loaded = true; + this.uiReady = true; if (wasBackgroundLoading) { this.backgroundLoading = false; this.emitChange("background-loaded", { background: false }); @@ -881,7 +910,11 @@ class HashtagPreferencesService { this.eventCreatedAt = Number.isFinite(latest?.created_at) ? latest.created_at : null; + this.uiReady = true; + this.dataReady = true; this.loaded = true; + this.loadedFromCache = false; + this.lastSuccessfulSyncAt = Date.now(); if (this.backgroundLoading) { this.backgroundLoading = false; } @@ -892,11 +925,25 @@ class HashtagPreferencesService { `${LOG_PREFIX} Failed to parse decrypted hashtag preferences`, error, ); - this.reset(); - this.loaded = true; + this.uiReady = true; } } + getLoadState() { + return { + uiReady: this.uiReady === true, + dataReady: this.dataReady === true, + lastLoadError: this.lastLoadError || null, + loadedFromCache: this.loadedFromCache === true, + lastSuccessfulSyncAt: Number.isFinite(this.lastSuccessfulSyncAt) + ? Number(this.lastSuccessfulSyncAt) + : null, + lastAttemptAt: Number.isFinite(this.lastAttemptAt) + ? Number(this.lastAttemptAt) + : null, + }; + } + async decryptEvent(event, userPubkey, options = {}) { const ciphertext = typeof event?.content === "string" ? event.content : ""; if (!ciphertext) { diff --git a/js/subscriptions.js b/js/subscriptions.js index a345c359..3616dcbc 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -349,7 +349,12 @@ class SubscriptionsManager { this.subsEventId = null; this.subsEventCreatedAt = null; this.currentUserPubkey = null; + this.uiReady = false; + this.dataReady = false; this.loaded = false; + this.loadedFromCache = false; + this.lastSuccessfulSyncAt = null; + this.lastAttemptAt = null; this.loadingPromise = null; this.backgroundLoading = false; this.subscriptionListView = null; @@ -394,6 +399,7 @@ class SubscriptionsManager { const allowPermissionPrompt = options?.allowPermissionPrompt !== false; const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey; + this.lastAttemptAt = Date.now(); // 1. Attempt to load from cache first const cached = profileCache.getProfileData(normalizedUserPubkey, "subscriptions"); @@ -404,7 +410,10 @@ class SubscriptionsManager { this.subsEventId = cachedSnapshot.eventId; this.subsEventCreatedAt = cachedSnapshot.createdAt; this.currentUserPubkey = normalizedUserPubkey; + this.uiReady = true; + this.dataReady = true; this.loaded = true; + this.loadedFromCache = true; // Trigger background update if (!allowPermissionPrompt && !this.backgroundLoading) { @@ -468,6 +477,7 @@ class SubscriptionsManager { if (!userPubkey) return; try { + this.lastAttemptAt = Date.now(); const allowPermissionPrompt = options?.allowPermissionPrompt !== false; const wasBackgroundLoading = this.backgroundLoading; const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey; @@ -481,6 +491,7 @@ class SubscriptionsManager { devLogger.warn( "[SubscriptionsManager] No relay URLs available while loading subscriptions.", ); + this.uiReady = true; if (wasBackgroundLoading) { this.backgroundLoading = false; this.emitter.emit("change", { @@ -583,11 +594,20 @@ class SubscriptionsManager { this.subscribedPubkeys.clear(); this.subsEventId = null; this.subsEventCreatedAt = null; + this.uiReady = true; + this.dataReady = true; this.loaded = true; + this.loadedFromCache = false; + this.lastSuccessfulSyncAt = Date.now(); } else { // We have data loaded, and relays returned nothing. // This means no updates. We keep what we have. devLogger.log("[SubscriptionsManager] No updates from relays."); + this.uiReady = true; + this.dataReady = true; + this.loaded = true; + this.loadedFromCache = false; + this.lastSuccessfulSyncAt = Date.now(); } if (wasBackgroundLoading) { this.backgroundLoading = false; @@ -636,10 +656,8 @@ class SubscriptionsManager { if (!decryptResult.ok) { this.lastLoadError = decryptResult.error || null; + this.uiReady = true; if (decryptResult.error?.code === "subscriptions-decrypt-timeout") { - if (!this.loaded && !cachedSnapshot.hasSnapshot) { - this.loaded = true; - } this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, { allowPermissionPrompt, }); @@ -649,9 +667,6 @@ class SubscriptionsManager { !allowPermissionPrompt && decryptResult.error?.code === "subscriptions-permission-required" ) { - if (!this.loaded && !cachedSnapshot.hasSnapshot) { - this.loaded = true; - } // Retry with permission prompt enabled so the signer / window.nostr // path is available once the extension has finished initializing. this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, { @@ -690,6 +705,7 @@ class SubscriptionsManager { this.subscribedPubkeys.clear(); this.subsEventId = null; this.subsEventCreatedAt = null; + this.dataReady = false; this.loaded = true; } this.scheduleDecryptRetry(normalizedUserPubkey, decryptResult.error, { @@ -707,7 +723,11 @@ class SubscriptionsManager { // Update state this.subscribedPubkeys = newSet; this.currentUserPubkey = normalizedUserPubkey; + this.uiReady = true; + this.dataReady = true; this.loaded = true; + this.loadedFromCache = false; + this.lastSuccessfulSyncAt = Date.now(); if (wasBackgroundLoading) { this.backgroundLoading = false; } @@ -747,6 +767,7 @@ class SubscriptionsManager { } catch (err) { this.lastLoadError = err || null; + this.uiReady = true; userLogger.error("[SubscriptionsManager] Failed to update subs from relays:", err); } } @@ -757,7 +778,12 @@ class SubscriptionsManager { this.subsEventId = null; this.subsEventCreatedAt = null; this.currentUserPubkey = null; + this.uiReady = false; + this.dataReady = false; this.loaded = false; + this.loadedFromCache = false; + this.lastSuccessfulSyncAt = null; + this.lastAttemptAt = null; this.backgroundLoading = false; this.lastRunOptions = null; this.lastResult = null; @@ -823,12 +849,12 @@ class SubscriptionsManager { devLogger.log("[SubscriptionsManager] ensureLoaded start", actorHex); const normalizedActor = normalizeHexPubkey(actorHex) || actorHex; if (!normalizedActor) { - return; + return { ok: false, error: new Error("Missing actor pubkey."), state: this.getLoadState() }; } - if (this.loaded && this.currentUserPubkey === normalizedActor) { + if (this.dataReady && this.currentUserPubkey === normalizedActor) { devLogger.log("[SubscriptionsManager] ensureLoaded already loaded"); - return; + return { ok: true, error: null, state: this.getLoadState() }; } if (this.loadingPromise) { @@ -836,9 +862,9 @@ class SubscriptionsManager { devLogger.log("[SubscriptionsManager] ensureLoaded awaiting existing promise"); await this.loadingPromise; } catch (error) { - throw error; + return { ok: false, error, state: this.getLoadState() }; } - return; + return { ok: this.dataReady === true, error: this.lastLoadError || null, state: this.getLoadState() }; } const loader = this.loadSubscriptions(normalizedActor, options); @@ -869,16 +895,30 @@ class SubscriptionsManager { ), ]); devLogger.log("[SubscriptionsManager] ensureLoaded success"); + return { ok: this.dataReady === true, error: this.lastLoadError || null, state: this.getLoadState() }; } catch (error) { userLogger.warn("[SubscriptionsManager] ensureLoaded timed out or failed:", error); - // Mark as loaded so the UI can proceed. The background loader will - // continue and emit a "change" event if it eventually succeeds. - if (!this.loaded) { - this.loaded = true; - } + this.lastLoadError = this.lastLoadError || error; + this.uiReady = true; + return { ok: false, error: this.lastLoadError, state: this.getLoadState() }; } } + getLoadState() { + return { + uiReady: this.uiReady === true, + dataReady: this.dataReady === true, + lastLoadError: this.lastLoadError || null, + loadedFromCache: this.loadedFromCache === true, + lastSuccessfulSyncAt: Number.isFinite(this.lastSuccessfulSyncAt) + ? Number(this.lastSuccessfulSyncAt) + : null, + lastAttemptAt: Number.isFinite(this.lastAttemptAt) + ? Number(this.lastAttemptAt) + : null, + }; + } + isSubscribed(channelHex) { const normalized = normalizeHexPubkey(channelHex); if (!normalized) { diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index d2f01aa9..52fb62ac 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -8066,6 +8066,8 @@ export class ProfileModalController { } const service = this.hashtagPreferencesService || {}; + const loadState = + typeof service.getLoadState === "function" ? service.getLoadState() : null; const interestsSource = Array.isArray(snapshot?.interests) ? snapshot.interests @@ -8081,6 +8083,17 @@ export class ProfileModalController { return { interests: this.sanitizeHashtagList(interestsSource), disinterests: this.sanitizeHashtagList(disinterestsSource), + uiReady: + loadState?.uiReady === true || + service.uiReady === true || + (!loadState && service.uiReady !== false), + dataReady: + loadState?.dataReady === true || + service.dataReady === true || + (!loadState && service.dataReady !== false), + lastLoadError: loadState?.lastLoadError || service.lastLoadError || null, + loadedFromCache: + loadState?.loadedFromCache === true || service.loadedFromCache === true, }; } @@ -8343,11 +8356,37 @@ export class ProfileModalController { populateHashtagPreferences(preferences = null) { const snapshot = this.getResolvedHashtagPreferences(preferences); + if (!snapshot.uiReady) { + this.setHashtagStatus("Loading hashtag preferences…", "info"); + this.renderHashtagList("interest", []); + this.renderHashtagList("disinterest", []); + this.refreshHashtagBackgroundStatus(); + return; + } + + if (!snapshot.dataReady) { + const message = snapshot.lastLoadError + ? "Couldn’t sync hashtag preferences. Retry to load your lists." + : "Hashtag preferences are unavailable right now. Retry to sync."; + this.setHashtagStatus(message, "warning"); + this.renderHashtagList("interest", []); + this.renderHashtagList("disinterest", []); + this.refreshHashtagBackgroundStatus(); + return; + } + this.renderHashtagList("interest", snapshot.interests); this.renderHashtagList("disinterest", snapshot.disinterests); if (!snapshot.interests.length && !snapshot.disinterests.length) { - this.setHashtagStatus("", "muted"); + if (snapshot.loadedFromCache) { + this.setHashtagStatus( + "No hashtag preferences yet (showing cached state).", + "info", + ); + } else { + this.setHashtagStatus("", "muted"); + } } this.refreshHashtagBackgroundStatus(); } @@ -8911,6 +8950,31 @@ export class ProfileModalController { } try { + const loadState = + typeof service.getLoadState === "function" ? service.getLoadState() : {}; + const uiReady = loadState?.uiReady === true || service.uiReady === true; + const dataReady = loadState?.dataReady === true || service.dataReady === true; + const lastLoadError = loadState?.lastLoadError || service.lastLoadError || null; + + if (!uiReady) { + this.clearSubscriptionsList("Loading subscriptions…"); + this.setSubscriptionsStatus("Loading subscriptions…", "info"); + this.refreshSubscriptionsBackgroundStatus(); + return; + } + + if (!dataReady) { + this.clearSubscriptionsList("Subscriptions unavailable. Retry to sync your list."); + this.setSubscriptionsStatus( + lastLoadError + ? "Couldn’t sync subscriptions. Retry to load your list." + : "Subscriptions unavailable right now. Retry to sync your list.", + "warning", + ); + this.refreshSubscriptionsBackgroundStatus(); + return; + } + let sourceEntries = []; if (Array.isArray(subscriptions) && subscriptions.length) { @@ -9003,6 +9067,7 @@ export class ProfileModalController { this.subscriptionList.innerHTML = ""; if (!deduped.length) { + this.setSubscriptionsStatus("No subscriptions yet.", "muted"); this.subscriptionListEmpty.classList.remove("hidden"); this.subscriptionList.classList.add("hidden"); this.refreshSubscriptionsBackgroundStatus(); @@ -9108,13 +9173,17 @@ export class ProfileModalController { } } - clearSubscriptionsList() { + clearSubscriptionsList(emptyMessage = "No subscriptions yet.") { if (this.subscriptionList instanceof HTMLElement) { this.subscriptionList.innerHTML = ""; this.subscriptionList.classList.add("hidden"); } if (this.subscriptionListEmpty instanceof HTMLElement) { + this.subscriptionListEmpty.textContent = + typeof emptyMessage === "string" && emptyMessage.trim() + ? emptyMessage.trim() + : "No subscriptions yet."; this.subscriptionListEmpty.classList.remove("hidden"); } }