diff --git a/package.json b/package.json index 85ce51d1..f7441c42 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.20.3", + "version": "0.20.4", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/js/app.js b/src/js/app.js index 5f0589bc..65464d6f 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -56,12 +56,11 @@ import { GroupChatLinkService } from "/js/groupChatLinkService.js"; import { ProfileHoverCardService } from "/js/profileHoverCardService.js"; import { InteractionHandlers } from "/js/interactionHandlers.js"; import { hapticsImpactLight } from "/js/haptics.js"; -import { isNative, wait } from "/js/utils.js"; +import { isNative } from "/js/utils.js"; import { effect, untrack } from "/js/signals.js"; import { dispatchNativeRefreshEnded } from "/js/nativeRefresh.js"; import { NOTIFICATIONS_PAGE_SIZE, IN_APP_LINK_DOMAINS } from "/js/config.js"; import { setUpIdentityPrecaching } from "/js/identityPrecaching.js"; -import { showToast } from "/js/toasts.js"; import { getAppViewConfig, handleAppViewResetQueryParam, @@ -126,8 +125,6 @@ export async function main() { constellation, identityResolver, ); - // put dataLayer on window for easy access in dev tools - window.dataLayer = dataLayer; const notificationService = session ? new NotificationService(api) : null; const chatNotificationService = session ? new ChatNotificationService(api) @@ -196,29 +193,10 @@ export async function main() { }); } - // Preload preferences - sometimes this fails, so try it twice. - try { - await dataLayer.initializePreferences(); - } catch (error) { - console.error("Error initializing preferences:", error); - await wait(1000); - try { - await dataLayer.initializePreferences(); - } catch (retryError) { - console.error("Error initializing preferences:", retryError); - throw retryError; - } - } - - if (preferencesProvider.$labelerDefsUnavailable.get()) { - showToast( - "Failed to fetch moderation labels - unmoderated content may be visible", - { - style: "warning", - timeout: 6000, - }, - ); - } + // Preload preferences + preferencesProvider.requirePreferences().catch((error) => { + console.warn("Error preloading preferences:", error); + }); pluginService.loadEnabledPlugins().catch((error) => { console.error("Error loading plugins", error); diff --git a/src/js/dataLayer/dataLayer.js b/src/js/dataLayer/dataLayer.js index 2b6579a6..55da399b 100644 --- a/src/js/dataLayer/dataLayer.js +++ b/src/js/dataLayer/dataLayer.js @@ -56,10 +56,6 @@ export class DataLayer extends EventEmitter { this.subscribers = []; } - async initializePreferences() { - return this.preferencesProvider.fetchPreferences(); - } - hasCachedFeed(feedURI) { return this.dataStore.$feeds.get(feedURI) !== null; } diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index a7cfd3fd..ebe8cebd 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -416,7 +416,7 @@ export class Mutations { feedUri, entryType: "feed", }); - const preferences = this.preferencesProvider.requirePreferences(); + const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.pinFeed(feedUri, "feed"); try { await this.preferencesProvider.updatePreferences(newPreferences); @@ -434,7 +434,7 @@ export class Mutations { feedUri: listUri, entryType: "list", }); - const preferences = this.preferencesProvider.requirePreferences(); + const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.pinFeed(listUri, "list"); try { await this.preferencesProvider.updatePreferences(newPreferences); @@ -451,7 +451,7 @@ export class Mutations { type: "unpinFeed", feedUri, }); - const preferences = this.preferencesProvider.requirePreferences(); + const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.unpinFeed(feedUri); try { await this.preferencesProvider.updatePreferences(newPreferences); @@ -468,7 +468,7 @@ export class Mutations { type: "unpinFeed", feedUri: listUri, }); - const preferences = this.preferencesProvider.requirePreferences(); + const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.unpinFeed(listUri); try { await this.preferencesProvider.updatePreferences(newPreferences); @@ -481,7 +481,7 @@ export class Mutations { } async setPinnedItems(values) { - const preferences = this.preferencesProvider.requirePreferences(); + const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.setPinnedItems(values); try { await this.preferencesProvider.updatePreferences(newPreferences); @@ -509,7 +509,7 @@ export class Mutations { const patchId = this.patchStore.addPostPatch(post.uri, { type: "hidePost", }); - const preferences = this.preferencesProvider.requirePreferences(); + const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.hidePost(post.uri); try { await this.preferencesProvider.updatePreferences(newPreferences); @@ -523,7 +523,7 @@ export class Mutations { } async addRecentSearch(q) { - const preferences = this.preferencesProvider.requirePreferences(); + const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.addRecentSearch(q); await this.preferencesProvider.updatePreferences(newPreferences); } @@ -533,7 +533,7 @@ export class Mutations { type: "removeRecentSearch", q, }); - const preferences = this.preferencesProvider.requirePreferences(); + const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.removeRecentSearch(q); try { await this.preferencesProvider.updatePreferences(newPreferences); @@ -546,13 +546,13 @@ export class Mutations { } async addRecentGif(gif) { - const preferences = this.preferencesProvider.requirePreferences(); + const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.addRecentGif(gif); await this.preferencesProvider.updatePreferences(newPreferences); } async addRecentSearchProfile(did) { - const preferences = this.preferencesProvider.requirePreferences(); + const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.addRecentSearchProfile(did); await this.preferencesProvider.updatePreferences(newPreferences); } @@ -562,7 +562,7 @@ export class Mutations { type: "removeRecentSearchProfile", did, }); - const preferences = this.preferencesProvider.requirePreferences(); + const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.removeRecentSearchProfile(did); try { await this.preferencesProvider.updatePreferences(newPreferences); @@ -575,13 +575,13 @@ export class Mutations { } async removeRecentSearchProfiles(dids) { - const preferences = this.preferencesProvider.requirePreferences(); + const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.removeRecentSearchProfiles(dids); await this.preferencesProvider.updatePreferences(newPreferences); } async addMutedWord({ value, targets, actorTarget, expiresAt }) { - const preferences = this.preferencesProvider.requirePreferences(); + const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.addMutedWord({ value, targets, @@ -592,13 +592,13 @@ export class Mutations { } async removeMutedWord(wordId) { - const preferences = this.preferencesProvider.requirePreferences(); + const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.removeMutedWord(wordId); await this.preferencesProvider.updatePreferences(newPreferences); } async updateMutedWord(wordId, updatedFields) { - const preferences = this.preferencesProvider.requirePreferences(); + const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.updateMutedWord(wordId, updatedFields); await this.preferencesProvider.updatePreferences(newPreferences); } @@ -609,7 +609,7 @@ export class Mutations { did: profile.did, labelerInfo, }); - const preferences = this.preferencesProvider.requirePreferences(); + const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.subscribeLabeler( profile.did, labelerInfo, @@ -630,7 +630,7 @@ export class Mutations { type: "unsubscribeLabeler", did: profile.did, }); - const preferences = this.preferencesProvider.requirePreferences(); + const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.unsubscribeLabeler(profile.did); try { await this.preferencesProvider.updatePreferences(newPreferences); @@ -649,7 +649,7 @@ export class Mutations { visibility, labelerDid, }); - const preferences = this.preferencesProvider.requirePreferences(); + const preferences = await this.preferencesProvider.requirePreferences(); const newPreferences = preferences.setContentLabelPref({ label, visibility, @@ -1179,7 +1179,7 @@ export class Mutations { pinnedItems.filter((item) => item.data?.uri !== list.uri), ); } - const preferences = this.preferencesProvider.requirePreferences(); + const preferences = await this.preferencesProvider.requirePreferences(); if (preferences.isFeedPinned(list.uri)) { const newPreferences = preferences.unpinFeed(list.uri); try { diff --git a/src/js/dataLayer/preferencesProvider.js b/src/js/dataLayer/preferencesProvider.js index f5434913..27c0ffe6 100644 --- a/src/js/dataLayer/preferencesProvider.js +++ b/src/js/dataLayer/preferencesProvider.js @@ -1,22 +1,39 @@ import { Preferences } from "/js/preferences.js"; import { Signal } from "/js/signals.js"; +import { showToast } from "/js/toasts.js"; +import { wait } from "/js/utils.js"; + +const RETRY_DELAY_MS = 1000; export class PreferencesProvider { constructor(api) { this.api = api; this._preferences = null; + this._pendingFetch = null; this.$preferences = new Signal.State(null); - this.$labelerDefsUnavailable = new Signal.State(false); } - requirePreferences() { - if (!this._preferences) { - throw new Error("Preferences not loaded"); - } + async requirePreferences() { + if (this._preferences) return this._preferences; + this._pendingFetch ??= this.fetchPreferences().finally(() => { + this._pendingFetch = null; + }); + await this._pendingFetch; return this._preferences; } + // Fetching preferences sometimes fails, so try it twice. async fetchPreferences() { + try { + await this._fetchPreferences(); + } catch (error) { + console.warn("Error fetching preferences, retrying:", error); + await wait(RETRY_DELAY_MS); + await this._fetchPreferences(); + } + } + + async _fetchPreferences() { if (!this.api.isAuthenticated) { this._setPreferences(Preferences.createLoggedOutPreferences()); return; @@ -29,10 +46,12 @@ export class PreferencesProvider { let labelerDefs = []; try { labelerDefs = await this.api.getLabelers(labelerDids); - this.$labelerDefsUnavailable.set(false); } catch (error) { console.warn("Could not load labeler definitions:", error); - this.$labelerDefsUnavailable.set(true); + showToast( + "Failed to fetch moderation labels - unmoderated content may be visible", + { style: "warning", timeout: 6000 }, + ); } this._setPreferences(new Preferences(preferencesObj, labelerDefs)); } diff --git a/src/js/dataLayer/requests.js b/src/js/dataLayer/requests.js index f6be1cd8..208c5df6 100644 --- a/src/js/dataLayer/requests.js +++ b/src/js/dataLayer/requests.js @@ -266,8 +266,8 @@ export class Requests { this.enableStatus(this.loadMutedProfiles, "loadMutedProfiles"); } - requireLabelers() { - const preferences = this.preferencesProvider.requirePreferences(); + async requireLabelers() { + const preferences = await this.preferencesProvider.requirePreferences(); return preferences.getLabelerDids(); } @@ -306,7 +306,7 @@ export class Requests { } async loadPostThread(postURI, { depth = 6 } = {}) { - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); let [postThread, postThreadOther] = await Promise.all([ this.api.getPostThread(postURI, { labelers, @@ -350,7 +350,7 @@ export class Requests { } async loadPost(postURI) { - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const post = await this.api.getPost(postURI, { labelers }); await this._loadPostDependencies([post]); this.dataStore.setPosts([post]); @@ -358,7 +358,7 @@ export class Requests { async loadPosts(postURIs) { if (postURIs.length === 0) return; - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const posts = await this.api.getPosts(postURIs, { labelers }); await this._loadPostDependencies(posts); this.dataStore.setPosts(posts); @@ -524,7 +524,7 @@ export class Requests { } async loadNextFeedPage({ type, uri }, { reload = false, limit = 31 } = {}) { - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const cursor = reload ? "" : readCollectionCursor(this.dataStore.$feeds, { key: uri }); @@ -578,7 +578,7 @@ export class Requests { async _loadBlockedPosts(blockedPostUris) { if (blockedPostUris.length === 0) return; - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const fetchedBlockedPosts = await this.api.getPosts(blockedPostUris, { labelers, }); @@ -604,7 +604,7 @@ export class Requests { } async loadDetailedProfile(did) { - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const profile = await this.api.getProfile(did, { labelers }); this.dataStore.$profiles.set(did, profile); this.dataStore.$detailedProfiles.set(did, profile); @@ -612,7 +612,7 @@ export class Requests { async loadDetailedProfiles(dids) { if (dids.length === 0) return; - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const profiles = await this.api.getProfiles(dids, { labelers }); for (const profile of profiles) { this.dataStore.$profiles.set(profile.did, profile); @@ -630,9 +630,9 @@ export class Requests { if (!cursor) { this.dataStore.$profileSearchResults.set(null); } - const labelers = this.requireLabelers(); const requestTime = Date.now(); this.dataStore.$latestProfileSearchRequestTime.set(requestTime); + const labelers = await this.requireLabelers(); const searchData = await this.api.searchProfiles(query, { limit, cursor, @@ -660,9 +660,9 @@ export class Requests { $results.set(null); return; } - const labelers = this.requireLabelers(); const requestTime = Date.now(); $latestRequestTime.set(requestTime); + const labelers = await this.requireLabelers(); const searchData = await this.api.searchProfilesTypeahead(query, { limit, labelers, @@ -732,9 +732,9 @@ export class Requests { if (!cursor) { $results.set(null); } - const labelers = this.requireLabelers(); const requestTime = Date.now(); $latestRequestTime.set(requestTime); + const labelers = await this.requireLabelers(); const searchData = await this.api.searchPosts(query, { limit, sort, @@ -857,7 +857,7 @@ export class Requests { const cursor = reload ? "" : readCollectionCursor(this.dataStore.$authorFeeds, { key: feedURI }); - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const params = { limit, cursor, labelers }; let feed; @@ -902,7 +902,7 @@ export class Requests { const cursor = reload ? "" : readCollectionCursor(this.dataStore.$notifications); - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const res = await this.api.getNotifications({ cursor, limit, labelers }); if (cursor === "") { this.dataStore.$notificationsLastSeenAt.set(res.seenAt ?? null); @@ -928,7 +928,7 @@ export class Requests { const cursor = reload ? "" : readCollectionCursor(this.dataStore.$mentionNotifications); - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const res = await this.api.getNotifications({ cursor, limit, @@ -959,7 +959,7 @@ export class Requests { const cursor = reload ? "" : readCollectionCursor(this.dataStore.$convoList); - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const res = await this.api.listConvos({ cursor, limit, labelers }); // Store individual convos for (const convo of res.convos) { @@ -976,7 +976,7 @@ export class Requests { const cursor = reload ? "" : readCollectionCursor(this.dataStore.$convoRequestList); - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const res = await this.api.listConvos({ cursor, limit, @@ -995,7 +995,7 @@ export class Requests { } async loadConvo(convoId) { - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const res = await this.api.getConvo(convoId, { labelers }); this.dataStore.setConvo(res.convo); } @@ -1006,7 +1006,7 @@ export class Requests { : readCollectionCursor(this.dataStore.$convoMemberLists, { key: convoId, }); - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const res = await this.api.getConvoMembers(convoId, { cursor, labelers }); writePageToCollection(this.dataStore.$convoMemberLists, "members", res, { key: convoId, @@ -1044,7 +1044,7 @@ export class Requests { } async loadConvoForProfile(profileDid) { - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const res = await this.api.getConvoForMembers([profileDid], { labelers }); this.dataStore.setConvo(res.convo); } @@ -1053,7 +1053,7 @@ export class Requests { const cursor = reload ? "" : readCollectionCursor(this.dataStore.$convoMessages, { key: convoId }); - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const res = await this.api.getMessages(convoId, { cursor, limit, @@ -1079,7 +1079,7 @@ export class Requests { } async pollConvoMessages(convoId, { cursor = "" } = {}) { - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const res = await this.api.getChatLogs({ cursor, labelers }); const logsForConvo = res.logs.filter((log) => log.convoId === convoId); const newMessages = []; @@ -1159,7 +1159,7 @@ export class Requests { } async loadPostLikes(postUri, { cursor } = {}) { - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const res = await this.api.getLikes(postUri, { cursor, labelers }); this.dataStore.setProfiles(res.likes.map((like) => like.actor)); @@ -1171,7 +1171,7 @@ export class Requests { } async loadPostQuotes(postUri, { cursor } = {}) { - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const res = await this.api.getQuotes(postUri, { cursor, labelers }); // if there are posts that are replies, load the parents @@ -1194,7 +1194,7 @@ export class Requests { } async loadPostReposts(postUri, { cursor } = {}) { - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const res = await this.api.getRepostedBy(postUri, { cursor, labelers }); this.dataStore.setProfiles(res.repostedBy); @@ -1276,7 +1276,7 @@ export class Requests { } async loadPinnedItems() { - const preferences = this.preferencesProvider.requirePreferences(); + const preferences = await this.preferencesProvider.requirePreferences(); const pinnedFeeds = preferences.getPinnedFeeds(); const feedUris = pinnedFeeds @@ -1399,7 +1399,7 @@ export class Requests { async loadHashtagFeed(hashtag, sort, { reload = false, limit = 25 } = {}) { const hashtagKey = `${hashtag}-${sort}`; - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const cursor = reload ? "" @@ -1441,7 +1441,7 @@ export class Requests { ? "" : readCollectionCursor(this.dataStore.$bookmarks); - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const res = await this.api.getBookmarks({ limit, cursor, labelers }); // Extract posts from bookmarks array: [{item: post, ...}] @@ -1483,7 +1483,7 @@ export class Requests { } async loadProfileFollowers(profileDid, { cursor } = {}) { - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const res = await this.api.getFollowers(profileDid, { cursor, labelers }); this.dataStore.setProfiles(res.followers); @@ -1495,7 +1495,7 @@ export class Requests { } async loadKnownFollowers(profileDid, { cursor } = {}) { - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const res = await this.api.getKnownFollowers(profileDid, { cursor, labelers, @@ -1510,7 +1510,7 @@ export class Requests { } async loadProfileFollows(profileDid, { cursor } = {}) { - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const res = await this.api.getFollows(profileDid, { cursor, labelers }); this.dataStore.setProfiles(res.follows); @@ -1522,7 +1522,7 @@ export class Requests { } async loadBlockedProfiles({ cursor } = {}) { - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const res = await this.api.getBlocks({ cursor, labelers }); this.dataStore.setProfiles(res.blocks); @@ -1533,7 +1533,7 @@ export class Requests { } async loadMutedProfiles({ cursor } = {}) { - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const res = await this.api.getMutes({ cursor, labelers }); this.dataStore.setProfiles(res.mutes); @@ -1544,7 +1544,7 @@ export class Requests { } async loadProfileChatStatus(profileDid) { - const labelers = this.requireLabelers(); + const labelers = await this.requireLabelers(); const res = await this.api.getConvoAvailability([profileDid], { labelers, }); diff --git a/src/js/plugins/pluginPreferencesManager.js b/src/js/plugins/pluginPreferencesManager.js index 6ce4bd79..40ca377b 100644 --- a/src/js/plugins/pluginPreferencesManager.js +++ b/src/js/plugins/pluginPreferencesManager.js @@ -28,27 +28,45 @@ export class PluginPreferencesManager extends ReactiveStore { ); } + // The signals above remain empty until preferences load, which is correct for + // reactive consumers (they re-run when necessary) but silently wrong for + // non-reactive consumers. These methods make sure preferences are loaded first. + async getInstalledPlugins() { + await this.preferencesProvider.requirePreferences(); + return this.$installedPlugins.get(); + } + + async getEnabledPlugins() { + await this.preferencesProvider.requirePreferences(); + return this.$enabledPlugins.get(); + } + + async getInstalledPlugin(pluginId) { + await this.preferencesProvider.requirePreferences(); + return this.$installedPlugin.get(pluginId); + } + async setInstalledPlugins(plugins) { - const preferences = this.preferencesProvider - .requirePreferences() - .setInstalledPlugins(plugins); - await this.preferencesProvider.updatePreferences(preferences); + const preferences = await this.preferencesProvider.requirePreferences(); + await this.preferencesProvider.updatePreferences( + preferences.setInstalledPlugins(plugins), + ); } async addInstalledPlugin(plugin) { - const installedPlugins = this.$installedPlugins.get(); + const installedPlugins = await this.getInstalledPlugins(); await this.setInstalledPlugins([...installedPlugins, plugin]); } async removeInstalledPlugin(pluginId) { - const installedPlugins = this.$installedPlugins.get(); + const installedPlugins = await this.getInstalledPlugins(); await this.setInstalledPlugins( installedPlugins.filter((plugin) => plugin.id !== pluginId), ); } async updateInstalledPlugin(pluginId, updateFunc) { - const installedPlugins = this.$installedPlugins.get(); + const installedPlugins = await this.getInstalledPlugins(); if (!installedPlugins.some((plugin) => plugin.id === pluginId)) { throw new Error( `Tried to update preference for uninstalled plugin: ${pluginId}`, @@ -70,7 +88,7 @@ export class PluginPreferencesManager extends ReactiveStore { async setPluginsDisabled(pluginIds) { const ids = new Set(pluginIds); if (ids.size === 0) return; - const installedPlugins = this.$installedPlugins.get(); + const installedPlugins = await this.getInstalledPlugins(); for (const pluginId of ids) { if (!installedPlugins.some((plugin) => plugin.id === pluginId)) { throw new Error( @@ -116,23 +134,22 @@ export class PluginPreferencesManager extends ReactiveStore { })); } - readSettingsForPlugin(pluginId) { - return this.preferencesProvider - .requirePreferences() - .getPluginSettings(pluginId); + async readSettingsForPlugin(pluginId) { + const preferences = await this.preferencesProvider.requirePreferences(); + return preferences.getPluginSettings(pluginId); } async writeSettingsForPlugin(pluginId, data) { - const preferences = this.preferencesProvider - .requirePreferences() - .setPluginSettings(pluginId, data); - await this.preferencesProvider.updatePreferences(preferences); + const preferences = await this.preferencesProvider.requirePreferences(); + await this.preferencesProvider.updatePreferences( + preferences.setPluginSettings(pluginId, data), + ); } async clearSettingsForPlugin(pluginId) { - const preferences = this.preferencesProvider - .requirePreferences() - .clearPluginSettings(pluginId); - await this.preferencesProvider.updatePreferences(preferences); + const preferences = await this.preferencesProvider.requirePreferences(); + await this.preferencesProvider.updatePreferences( + preferences.clearPluginSettings(pluginId), + ); } } diff --git a/src/js/plugins/pluginRequests.js b/src/js/plugins/pluginRequests.js index 6387558e..64246b50 100644 --- a/src/js/plugins/pluginRequests.js +++ b/src/js/plugins/pluginRequests.js @@ -99,7 +99,7 @@ export class PluginRequests { query, headers: { "atproto-accept-labelers": Api.buildAcceptLabelersHeader( - this._getLabelers(), + await this._getLabelers(), ), "atproto-proxy": this.dataLayer.api.bskyAppViewServiceDid, }, @@ -113,11 +113,11 @@ export class PluginRequests { } } - _getLabelers() { - // Preferences may not be loaded yet (or the user is signed out) — - // requests without the labelers header just hydrate fewer labels. + async _getLabelers() { + // Preferences may fail to load (or the user is signed out) — requests + // without the labelers header just hydrate fewer labels. try { - return this.dataLayer.requests.requireLabelers(); + return await this.dataLayer.requests.requireLabelers(); } catch { return []; } diff --git a/src/js/plugins/pluginService.js b/src/js/plugins/pluginService.js index 25bf867f..bf3ebf91 100644 --- a/src/js/plugins/pluginService.js +++ b/src/js/plugins/pluginService.js @@ -671,9 +671,8 @@ export class PluginService extends ReactiveStore { async _loadEnabledPlugins() { if (arePluginsDisabledByQueryParam()) { - const enabledPluginIds = this.prefManager.$enabledPlugins - .get() - .map((entry) => entry.id); + const enabledPlugins = await this.prefManager.getEnabledPlugins(); + const enabledPluginIds = enabledPlugins.map((entry) => entry.id); await this.prefManager.setPluginsDisabled(enabledPluginIds); return; } @@ -692,11 +691,10 @@ export class PluginService extends ReactiveStore { }); } } - const enabledPlugins = this.prefManager.$enabledPlugins - .get() - .filter( - (entry) => this.localPluginsEnabled || !entry.id.endsWith("__LOCAL"), - ); + const allEnabledPlugins = await this.prefManager.getEnabledPlugins(); + const enabledPlugins = allEnabledPlugins.filter( + (entry) => this.localPluginsEnabled || !entry.id.endsWith("__LOCAL"), + ); const { erroredPlugins } = await this.pluginBridge.loadPlugins(enabledPlugins); if (erroredPlugins.length) { @@ -714,7 +712,7 @@ export class PluginService extends ReactiveStore { } // Reconcile against all installed plugins (not just enabled) so disabled // plugins keep their cached assets on re-enable - const installedPlugins = this.prefManager.$installedPlugins.get(); + const installedPlugins = await this.prefManager.getInstalledPlugins(); await this._reconcileCache(installedPlugins); } @@ -770,7 +768,7 @@ export class PluginService extends ReactiveStore { async checkForUpdates() { // Load listings first to ensure we have the latest repo URLs for plugins await this.loadRegistryListings(); - const installedPlugins = this.prefManager.$installedPlugins.get(); + const installedPlugins = await this.prefManager.getInstalledPlugins(); const results = await Promise.allSettled( installedPlugins.map(async (entry) => { const liveManifest = await this.sourceProvider.getLiveManifest( @@ -802,7 +800,7 @@ export class PluginService extends ReactiveStore { } async reloadPlugins() { - const installedPlugins = this.prefManager.$installedPlugins.get(); + const installedPlugins = await this.prefManager.getInstalledPlugins(); const results = await Promise.allSettled( installedPlugins .filter((entry) => entry.enabled === true) @@ -824,7 +822,7 @@ export class PluginService extends ReactiveStore { } async getManifest(pluginId) { - const installedPlugin = this.prefManager.$installedPlugin.get(pluginId); + const installedPlugin = await this.prefManager.getInstalledPlugin(pluginId); return this.sourceProvider .getManifest(pluginId, installedPlugin?.version, installedPlugin?.repo) .catch(() => null); @@ -856,7 +854,7 @@ export class PluginService extends ReactiveStore { } repo = listing.repo; } - const installedPlugins = this.prefManager.$installedPlugins.get(); + const installedPlugins = await this.prefManager.getInstalledPlugins(); if (installedPlugins.some((plugin) => plugin.id === pluginId)) { throw new Error(`Plugin ${pluginId} already installed`); } @@ -930,7 +928,7 @@ export class PluginService extends ReactiveStore { if (this.localRegistry && (await this.localRegistry.getListing(id))) { throw new Error(`Plugin ${id} is in the registry; install it from there`); } - const installedPlugins = this.prefManager.$installedPlugins.get(); + const installedPlugins = await this.prefManager.getInstalledPlugins(); if (installedPlugins.some((plugin) => plugin.id === id)) { throw new Error(`Plugin ${id} already installed`); } @@ -960,12 +958,13 @@ export class PluginService extends ReactiveStore { await this.prefManager.clearSettingsForPlugin(pluginId); this.localDataStore.clear(pluginId); await this.binaryCache.clear(pluginId); - await this._reconcileCache(this.prefManager.$installedPlugins.get()); + const installedPlugins = await this.prefManager.getInstalledPlugins(); + await this._reconcileCache(installedPlugins); } async enablePlugin(pluginId) { await this.prefManager.setPluginEnabled(pluginId); - const installedPlugin = this.prefManager.$installedPlugin.get(pluginId); + const installedPlugin = await this.prefManager.getInstalledPlugin(pluginId); try { await this.pluginBridge.loadPlugin( pluginId, @@ -984,7 +983,7 @@ export class PluginService extends ReactiveStore { } async updatePlugin(pluginId) { - const installedPlugin = this.prefManager.$installedPlugin.get(pluginId); + const installedPlugin = await this.prefManager.getInstalledPlugin(pluginId); if (!installedPlugin) return null; const liveManifest = await this.sourceProvider.getLiveManifest( pluginId, @@ -1070,7 +1069,7 @@ export class PluginService extends ReactiveStore { const listingById = new Map( listings.map((listing) => [listing.id, listing]), ); - const installedPlugins = this.prefManager.$installedPlugins.get(); + const installedPlugins = await this.prefManager.getInstalledPlugins(); let changed = false; const updated = installedPlugins.map((plugin) => { const listing = listingById.get(plugin.id); diff --git a/src/js/reportService.js b/src/js/reportService.js index e462d04f..36f86782 100644 --- a/src/js/reportService.js +++ b/src/js/reportService.js @@ -12,6 +12,7 @@ export class ReportService { console.warn("Report dialog already open"); return; } + await this.dataLayer.preferencesProvider.requirePreferences(); return new Promise((resolve, reject) => { this.currentReportDialog = document.createElement("report-dialog"); const preferences = this.dataLayer.derived.$preferences.get(); diff --git a/tests/unit/specs/components/gif-picker-dialog.test.js b/tests/unit/specs/components/gif-picker-dialog.test.js index dd317acf..967d0945 100644 --- a/tests/unit/specs/components/gif-picker-dialog.test.js +++ b/tests/unit/specs/components/gif-picker-dialog.test.js @@ -238,11 +238,10 @@ describe("gif-picker-dialog", () => { await flushMicrotasks(); assert.deepEqual(selected, [gif]); assert.deepEqual(dialog.querySelector("dialog").open, false); + const preferences = + await dataLayer.preferencesProvider.requirePreferences(); assert.deepEqual( - dataLayer.preferencesProvider - .requirePreferences() - .getRecentGifs() - .map((entry) => entry.id), + preferences.getRecentGifs().map((entry) => entry.id), ["picked"], ); }); diff --git a/tests/unit/specs/dataLayer/dataLayer.test.js b/tests/unit/specs/dataLayer/dataLayer.test.js index af741901..14ee1435 100644 --- a/tests/unit/specs/dataLayer/dataLayer.test.js +++ b/tests/unit/specs/dataLayer/dataLayer.test.js @@ -59,15 +59,14 @@ describe("constructor", () => { }); }); -describe("initializePreferences", () => { - it("should call preferencesProvider.fetchPreferences", async () => { +describe("preferences", () => { + it("should load logged out preferences on demand", async () => { const mockApi = createMockApi({ isAuthenticated: false }); const dataLayer = createDataLayer(mockApi); - await dataLayer.initializePreferences(); + const preferences = + await dataLayer.preferencesProvider.requirePreferences(); - // Verify preferences were loaded (logged out preferences for unauthenticated) - const preferences = dataLayer.preferencesProvider.requirePreferences(); assert(preferences !== null); }); @@ -81,9 +80,9 @@ describe("initializePreferences", () => { }); const dataLayer = createDataLayer(mockApi); - await dataLayer.initializePreferences(); + const preferences = + await dataLayer.preferencesProvider.requirePreferences(); - const preferences = dataLayer.preferencesProvider.requirePreferences(); assert.deepEqual(preferences.obj, mockPreferences); }); }); @@ -162,8 +161,7 @@ describe("component integration", () => { const postURI = "at://post/uri"; const post = { uri: postURI, text: "test", likeCount: 5 }; - // Initialize preferences first (required by derived) - await dataLayer.initializePreferences(); + await dataLayer.preferencesProvider.requirePreferences(); // Set data through dataStore dataLayer.dataStore.$posts.set(postURI, post); @@ -179,8 +177,7 @@ describe("component integration", () => { const postURI = "at://post/uri"; const post = { uri: postURI, likeCount: 5, viewer: { like: null } }; - // Initialize preferences first (required by derived) - await dataLayer.initializePreferences(); + await dataLayer.preferencesProvider.requirePreferences(); dataLayer.dataStore.$posts.set(postURI, post); dataLayer.patchStore.addPostPatch(postURI, { type: "addLike" }); @@ -199,8 +196,7 @@ describe("component integration", () => { }); const dataLayer = createDataLayer(mockApi); - // Initialize preferences first - await dataLayer.initializePreferences(); + await dataLayer.preferencesProvider.requirePreferences(); // Verify declarative can access derived const profile = diff --git a/tests/unit/specs/dataLayer/preferencesProvider.test.js b/tests/unit/specs/dataLayer/preferencesProvider.test.js index fc4493dd..70f6f1cf 100644 --- a/tests/unit/specs/dataLayer/preferencesProvider.test.js +++ b/tests/unit/specs/dataLayer/preferencesProvider.test.js @@ -1,22 +1,150 @@ -import { describe, it } from "node:test"; +import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; import { Preferences } from "/js/preferences.js"; import { PreferencesProvider } from "/js/dataLayer/preferencesProvider.js"; describe("PreferencesProvider", () => { - it("should throw when requirePreferences called before fetch", () => { - const mockApi = { isAuthenticated: true }; + // The retry waits a second before its second attempt. + const realSetTimeout = globalThis.setTimeout; + beforeEach(() => { + globalThis.setTimeout = (callback) => realSetTimeout(callback, 0); + for (const toast of document.querySelectorAll('[data-testid="toast"]')) { + toast.remove(); + } + }); + afterEach(() => { + globalThis.setTimeout = realSetTimeout; + }); + + it("should fetch preferences when requirePreferences is called before fetch", async () => { + let fetchCount = 0; + const mockApi = { + isAuthenticated: true, + getPreferences: async () => { + fetchCount++; + return []; + }, + getLabelers: async () => [], + }; const provider = new PreferencesProvider(mockApi); - let error = null; - try { - provider.requirePreferences(); - } catch (e) { - error = e; - } + const preferences = await provider.requirePreferences(); + + assert.deepEqual(preferences.obj, []); + assert.deepEqual(fetchCount, 1); + }); + + it("should not refetch once preferences are loaded", async () => { + let fetchCount = 0; + const mockApi = { + isAuthenticated: true, + getPreferences: async () => { + fetchCount++; + return []; + }, + getLabelers: async () => [], + }; + const provider = new PreferencesProvider(mockApi); + + await provider.requirePreferences(); + await provider.requirePreferences(); + + assert.deepEqual(fetchCount, 1); + }); + + it("should share one fetch between concurrent requirePreferences calls", async () => { + let fetchCount = 0; + const mockApi = { + isAuthenticated: true, + getPreferences: async () => { + fetchCount++; + return []; + }, + getLabelers: async () => [], + }; + const provider = new PreferencesProvider(mockApi); + + const [first, second] = await Promise.all([ + provider.requirePreferences(), + provider.requirePreferences(), + ]); + + assert.deepEqual(fetchCount, 1); + assert(first === second); + }); + + it("should retry once when the fetch fails", async () => { + let attempts = 0; + const mockApi = { + isAuthenticated: true, + getPreferences: async () => { + attempts++; + if (attempts === 1) throw new Error("500"); + return []; + }, + getLabelers: async () => [], + }; + const provider = new PreferencesProvider(mockApi); + + const preferences = await provider.requirePreferences(); + + assert.deepEqual(attempts, 2); + assert.deepEqual(preferences.obj, []); + }); + + it("should reject when both fetch attempts fail", async () => { + let attempts = 0; + const mockApi = { + isAuthenticated: true, + getPreferences: async () => { + attempts++; + throw new Error("500"); + }, + getLabelers: async () => [], + }; + const provider = new PreferencesProvider(mockApi); + + await assert.rejects(() => provider.requirePreferences()); + + assert.deepEqual(attempts, 2); + }); + + it("should not remember a failed fetch", async () => { + let attempts = 0; + const mockApi = { + isAuthenticated: true, + getPreferences: async () => { + attempts++; + throw new Error("500"); + }, + getLabelers: async () => [], + }; + const provider = new PreferencesProvider(mockApi); - assert(error !== null); - assert.deepEqual(error.message, "Preferences not loaded"); + await assert.rejects(() => provider.requirePreferences(), /500/); + await assert.rejects(() => provider.requirePreferences(), /500/); + + assert.deepEqual(attempts, 4); + }); + + it("should recover once a later call succeeds", async () => { + let shouldFail = true; + const mockApi = { + isAuthenticated: true, + getPreferences: async () => { + if (shouldFail) throw new Error("500"); + return []; + }, + getLabelers: async () => [], + }; + const provider = new PreferencesProvider(mockApi); + + await assert.rejects(() => provider.requirePreferences()); + + shouldFail = false; + const preferences = await provider.requirePreferences(); + + assert.deepEqual(preferences.obj, []); }); it("should create logged out preferences when not authenticated", async () => { @@ -25,7 +153,7 @@ describe("PreferencesProvider", () => { await provider.fetchPreferences(); - const preferences = provider.requirePreferences(); + const preferences = await provider.requirePreferences(); assert.deepEqual(preferences.obj.length, 1); }); @@ -42,7 +170,7 @@ describe("PreferencesProvider", () => { await provider.fetchPreferences(); - const preferences = provider.requirePreferences(); + const preferences = await provider.requirePreferences(); assert.deepEqual(preferences.obj, mockPreferencesObj); }); @@ -61,30 +189,30 @@ describe("PreferencesProvider", () => { await provider.fetchPreferences(); - const preferences = provider.requirePreferences(); + const preferences = await provider.requirePreferences(); assert.deepEqual(preferences.obj, mockPreferencesObj); assert.deepEqual(preferences.labelerDefs, []); - assert.deepEqual(provider.$labelerDefsUnavailable.get(), true); }); - it("should clear the labeler unavailable flag on a later success", async () => { - let shouldFail = true; + it("should warn with a toast when the labeler service fails", async () => { + // The toast's own auto-dismiss timer shouldn't run on the patched clock. + globalThis.setTimeout = realSetTimeout; const mockApi = { isAuthenticated: true, getPreferences: async () => [], getLabelers: async () => { - if (shouldFail) throw new Error("502"); - return []; + throw new Error("502"); }, }; const provider = new PreferencesProvider(mockApi); await provider.fetchPreferences(); - assert.deepEqual(provider.$labelerDefsUnavailable.get(), true); - shouldFail = false; - await provider.fetchPreferences(); - assert.deepEqual(provider.$labelerDefsUnavailable.get(), false); + const toasts = document.querySelectorAll('[data-testid="toast"]'); + assert.deepEqual(toasts.length, 1); + for (const toast of toasts) { + toast.remove(); + } }); it("should update preferences via API", async () => { @@ -107,6 +235,6 @@ describe("PreferencesProvider", () => { await provider.updatePreferences(newPreferences); assert.deepEqual(updatedObj, newPreferences.obj); - assert.deepEqual(provider.requirePreferences(), newPreferences); + assert.deepEqual(await provider.requirePreferences(), newPreferences); }); }); diff --git a/tests/unit/specs/plugins/pluginPreferencesManager.test.js b/tests/unit/specs/plugins/pluginPreferencesManager.test.js index 1fb3fb83..c4dde8ef 100644 --- a/tests/unit/specs/plugins/pluginPreferencesManager.test.js +++ b/tests/unit/specs/plugins/pluginPreferencesManager.test.js @@ -38,6 +38,27 @@ class FakePreferences { } } +// Mirrors a provider whose preferences haven't loaded yet: the signal reads +// null (so the manager's signals read empty) until requirePreferences resolves. +function makeUnloadedProvider({ installedPlugins = [] } = {}) { + const state = { installedPlugins, pluginSettings: {} }; + const preferences = new FakePreferences(state); + const $preferences = new Signal.State(null); + return { + state, + provider: { + $preferences, + requirePreferences: async () => { + $preferences.set(preferences); + return preferences; + }, + updatePreferences: async (prefs) => { + $preferences.set(prefs); + }, + }, + }; +} + function makeProvider({ installedPlugins = [], pluginSettings = {} } = {}) { const state = { installedPlugins, pluginSettings }; const preferences = new FakePreferences(state); @@ -270,14 +291,77 @@ describe("updateInstalledPlugin", () => { }); }); +describe("reads before preferences load", () => { + it("getInstalledPlugins waits for the load instead of reading empty", async () => { + const { provider } = makeUnloadedProvider({ + installedPlugins: [{ id: "a", enabled: true }], + }); + const manager = new PluginPreferencesManager(provider); + + assert.deepEqual(manager.$installedPlugins.get(), []); + assert.deepEqual(await manager.getInstalledPlugins(), [ + { id: "a", enabled: true }, + ]); + }); + + it("getEnabledPlugins waits for the load instead of reading empty", async () => { + const { provider } = makeUnloadedProvider({ + installedPlugins: [ + { id: "a", enabled: true }, + { id: "b", enabled: false }, + ], + }); + const manager = new PluginPreferencesManager(provider); + + const enabled = await manager.getEnabledPlugins(); + assert.deepEqual( + enabled.map((entry) => entry.id), + ["a"], + ); + }); + + it("addInstalledPlugin keeps stored plugins it hasn't loaded yet", async () => { + const { state, provider } = makeUnloadedProvider({ + installedPlugins: [{ id: "a", enabled: true }], + }); + const manager = new PluginPreferencesManager(provider); + + await manager.addInstalledPlugin({ id: "b", enabled: true }); + + assert.deepEqual( + state.installedPlugins.map((entry) => entry.id), + ["a", "b"], + ); + }); + + it("removeInstalledPlugin keeps stored plugins it hasn't loaded yet", async () => { + const { state, provider } = makeUnloadedProvider({ + installedPlugins: [ + { id: "a", enabled: true }, + { id: "b", enabled: true }, + ], + }); + const manager = new PluginPreferencesManager(provider); + + await manager.removeInstalledPlugin("a"); + + assert.deepEqual( + state.installedPlugins.map((entry) => entry.id), + ["b"], + ); + }); +}); + describe("plugin settings", () => { - it("readSettingsForPlugin returns stored settings", () => { + it("readSettingsForPlugin returns stored settings", async () => { const { provider } = makeProvider({ pluginSettings: { a: { color: "red" } }, }); const manager = new PluginPreferencesManager(provider); - assert.deepEqual(manager.readSettingsForPlugin("a"), { color: "red" }); - assert.deepEqual(manager.readSettingsForPlugin("missing"), undefined); + assert.deepEqual(await manager.readSettingsForPlugin("a"), { + color: "red", + }); + assert.deepEqual(await manager.readSettingsForPlugin("missing"), undefined); }); it("writeSettingsForPlugin persists and saves", async () => {