Split subscriptions/hashtag readiness and expose unavailable preference states

This commit is contained in:
thePR0M3TH3AN committed 2026-02-08 14:10:39 -05:00
1 parent c785894943
commit fd3f9e723a
6 files changed
+246 -36

No files matched your search

+43 -1
View File
@@ -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,
};
}
+4
View File
@@ -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,
+8
View File
@@ -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) {
+64 -17
View File
@@ -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) {
+56 -16
View File
@@ -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) {
+71 -2
View File
@@ -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");
}
}