[READ-ONLY] Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
6

Configure Feed

Select the types of activity you want to include in your feed.

Refactor to use signals

Grace Kind (May 27, 2026, 7:07 PM -0500) 17f9b59b ce4dd378

+3612 -6954
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.14.106", 3 + "version": "0.14.107", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf build && NODE_ENV=development eleventy --serve",
+8 -6
src/index.html
··· 100 100 import { ReportService } from "/js/reportService.js"; 101 101 import { InteractionHandlers } from "/js/interactionHandlers.js"; 102 102 import { hapticsImpactLight } from "/js/haptics.js"; 103 - import { isNative, wait } from "/js/utils.js"; 103 + import { effect, isNative, wait } from "/js/utils.js"; 104 104 import { 105 105 enableNativeRefresh, 106 106 disableNativeRefresh, ··· 194 194 if (notificationService) { 195 195 notificationService.startPolling(); 196 196 197 - notificationService.on("update", () => { 197 + effect(() => { 198 198 const numNotifications = 199 - notificationService?.getNumNotifications() ?? 0; 199 + notificationService.$numNotifications.get() ?? 0; 200 200 // When there are new notifications, preload notifications for the notifs page. 201 201 if ( 202 202 numNotifications > 0 && 203 203 !window.location.pathname.includes("notifications") 204 204 ) { 205 205 const loadedNotifications = 206 - dataLayer.selectors.getNotifications(); 206 + dataLayer.signals.$notifications.get(); 207 207 const { loading: notificationsLoading } = 208 - dataLayer.requests.getStatus("loadNotifications"); 208 + dataLayer.requests.statusStore.$statuses 209 + .get("loadNotifications") 210 + .get(); 209 211 // Only preload if there are no notifications loaded 210 212 if (!loadedNotifications && !notificationsLoading) { 211 213 dataLayer.requests.loadNotifications({ ··· 214 216 }); 215 217 } 216 218 } 217 - }); 219 + }, "PRELOAD_NOTIFICATIONS"); 218 220 } 219 221 220 222 if (chatNotificationService) {
+5 -11
src/js/chatNotificationService.js
··· 1 - import { EventEmitter } from "/js/eventEmitter.js"; 2 - import { wait } from "/js/utils.js"; 1 + import { wait, Signal } from "/js/utils.js"; 3 2 4 3 const POLLING_INTERVAL_SECONDS = 10; 5 4 6 - export class ChatNotificationService extends EventEmitter { 5 + export class ChatNotificationService { 7 6 constructor(api) { 8 - super(); 9 7 this.api = api; 10 - this._numNotifications = 0; 8 + this.$numNotifications = new Signal.State(0); 11 9 this._cursor = ""; 12 10 } 13 11 ··· 21 19 async fetchNumNotifications() { 22 20 const unreadConvos = await this.api.listConvos({ readState: "unread" }); 23 21 const numNotifications = unreadConvos.convos.length; 24 - if (numNotifications !== this._numNotifications) { 25 - this._numNotifications = numNotifications; 26 - this.emit("update"); 22 + if (numNotifications !== this.$numNotifications.get()) { 23 + this.$numNotifications.set(numNotifications); 27 24 } 28 - } 29 - getNumNotifications() { 30 - return this._numNotifications; 31 25 } 32 26 async markNotificationsAsReadForConvo() { 33 27 // The views should update the unread count for each convo,
+3 -17
src/js/feedInteractionHandler.js
··· 1 1 import { hapticsImpactMedium } from "/js/haptics.js"; 2 2 import { showToast } from "/js/toasts.js"; 3 - import { EventEmitter } from "/js/eventEmitter.js"; 4 3 5 - export class FeedInteractionHandler extends EventEmitter { 4 + export class FeedInteractionHandler { 6 5 constructor(dataLayer) { 7 - super(); 8 6 this.dataLayer = dataLayer; 9 - } 10 - 11 - renderFunc() { 12 - this.emit("requestRender"); 13 7 } 14 8 15 9 async handlePinFeed(feedUri, doPin) { 16 10 if (doPin) { 17 11 try { 18 12 hapticsImpactMedium(); 19 - const promise = this.dataLayer.mutations.pinFeed(feedUri); 20 - this.renderFunc(); 21 - await promise; 22 - this.renderFunc(); 13 + await this.dataLayer.mutations.pinFeed(feedUri); 23 14 showToast("Feed pinned"); 24 15 } catch (error) { 25 16 console.error(error); 26 17 showToast("Failed to pin feed", { style: "error" }); 27 - this.renderFunc(); 28 18 } 29 19 } else { 30 20 try { 31 - const promise = this.dataLayer.mutations.unpinFeed(feedUri); 32 - this.renderFunc(); 33 - await promise; 34 - this.renderFunc(); 21 + await this.dataLayer.mutations.unpinFeed(feedUri); 35 22 showToast("Feed unpinned"); 36 23 } catch (error) { 37 24 console.error(error); 38 25 showToast("Failed to unpin feed", { style: "error" }); 39 - this.renderFunc(); 40 26 } 41 27 } 42 28 }
+67 -69
src/js/identityPrecaching.js
··· 1 1 import { getQuotedPost } from "/js/dataHelpers.js"; 2 + import { effect, untrack } from "/js/utils.js"; 2 3 3 4 export function setUpIdentityPrecaching(dataLayer, identityResolver) { 4 - // Precache author DIDs when posts are set in the data store 5 - dataLayer.dataStore.on("setPost", (post) => { 6 - try { 7 - if (post.author) { 8 - identityResolver.setDidForHandle(post.author.handle, post.author.did); 9 - } 10 - } catch (error) { 11 - console.error("error when setting DID from post", post); 12 - console.error(error); 5 + const setDid = (entity) => { 6 + if (entity) { 7 + identityResolver.setDidForHandle(entity.handle, entity.did); 13 8 } 14 - // Quoted posts too! 15 - try { 16 - const quotedPost = getQuotedPost(post); 17 - if (quotedPost) { 18 - if (quotedPost.author) { 19 - identityResolver.setDidForHandle( 20 - quotedPost.author.handle, 21 - quotedPost.author.did, 22 - ); 23 - } 24 - // we can go deeper... 25 - // TODO - normalize quoted posts? 26 - const nestedQuotedPost = getQuotedPost(quotedPost); 27 - if (nestedQuotedPost) { 28 - if (nestedQuotedPost.author) { 29 - identityResolver.setDidForHandle( 30 - nestedQuotedPost.author.handle, 31 - nestedQuotedPost.author.did, 32 - ); 9 + }; 10 + 11 + const seenPostUris = new Set(); 12 + effect(() => { 13 + const uris = dataLayer.dataStore.$posts.$keys.get(); 14 + for (const uri of uris) { 15 + if (seenPostUris.has(uri)) continue; 16 + seenPostUris.add(uri); 17 + const post = untrack(() => dataLayer.dataStore.$posts.get(uri).get()); 18 + if (!post) continue; 19 + try { 20 + setDid(post.author); 21 + } catch (error) { 22 + console.error("error when setting DID from post", post); 23 + console.error(error); 24 + } 25 + try { 26 + const quotedPost = getQuotedPost(post); 27 + if (quotedPost) { 28 + setDid(quotedPost.author); 29 + // TODO - normalize quoted posts? 30 + const nestedQuotedPost = getQuotedPost(quotedPost); 31 + if (nestedQuotedPost) { 32 + setDid(nestedQuotedPost.author); 33 33 } 34 34 } 35 + } catch (error) { 36 + console.error("error when setting DID from quoted post", post); 37 + console.error(error); 35 38 } 36 - } catch (error) { 37 - console.error("error when setting DID from quoted post", post); 38 - console.error(error); 39 39 } 40 - }); 40 + }, "identityPrecaching:posts"); 41 41 42 - // Precache author DIDs when feed generators are set in the data store 43 - dataLayer.dataStore.on("setFeedGenerator", (feedGenerator) => { 44 - try { 45 - if (feedGenerator.creator) { 46 - identityResolver.setDidForHandle( 47 - feedGenerator.creator.handle, 48 - feedGenerator.creator.did, 49 - ); 50 - } 51 - } catch (error) { 52 - console.error( 53 - "error when setting DID from feed generator", 54 - feedGenerator, 42 + const seenFeedGeneratorUris = new Set(); 43 + effect(() => { 44 + const uris = dataLayer.dataStore.$feedGenerators.$keys.get(); 45 + for (const uri of uris) { 46 + if (seenFeedGeneratorUris.has(uri)) continue; 47 + seenFeedGeneratorUris.add(uri); 48 + const feedGenerator = untrack(() => 49 + dataLayer.dataStore.$feedGenerators.get(uri).get(), 55 50 ); 56 - console.error(error); 51 + if (!feedGenerator) continue; 52 + try { 53 + setDid(feedGenerator.creator); 54 + } catch (error) { 55 + console.error( 56 + "error when setting DID from feed generator", 57 + feedGenerator, 58 + ); 59 + console.error(error); 60 + } 57 61 } 58 - }); 62 + }, "identityPrecaching:feedGenerators"); 59 63 60 - // Precache author DIDs when profiles are set in the data store 61 - dataLayer.dataStore.on("setProfileSearchResults", (profileSearchResults) => { 64 + effect(() => { 65 + const profileSearchResults = 66 + dataLayer.dataStore.$profileSearchResults.get(); 67 + if (!profileSearchResults) return; 62 68 for (const searchResult of profileSearchResults.actors) { 63 - identityResolver.setDidForHandle(searchResult.handle, searchResult.did); 69 + setDid(searchResult); 64 70 } 65 - }); 71 + }, "identityPrecaching:profileSearch"); 66 72 67 - // Precache labeler creator DIDs when preferences are set 68 - dataLayer.preferencesProvider.on("setPreferences", (preferences) => { 73 + effect(() => { 74 + const preferences = dataLayer.preferencesProvider.$preferences.get(); 75 + if (!preferences) return; 69 76 for (const labelerDef of preferences.labelerDefs) { 70 77 try { 71 - if (labelerDef.creator) { 72 - identityResolver.setDidForHandle( 73 - labelerDef.creator.handle, 74 - labelerDef.creator.did, 75 - ); 76 - } 78 + setDid(labelerDef.creator); 77 79 } catch (error) { 78 80 console.error("error when setting DID from labeler", labelerDef); 79 81 console.error(error); 80 82 } 81 83 } 82 - }); 84 + }, "identityPrecaching:preferences"); 83 85 84 - // Precache author DIDs from notifications 85 - dataLayer.dataStore.on("setNotifications", (notifications) => { 86 + effect(() => { 87 + const notifications = dataLayer.dataStore.$notifications.get(); 88 + if (!notifications) return; 86 89 for (const notification of notifications) { 87 90 try { 88 - if (notification.author) { 89 - identityResolver.setDidForHandle( 90 - notification.author.handle, 91 - notification.author.did, 92 - ); 93 - } 91 + setDid(notification.author); 94 92 } catch (error) { 95 93 console.error("error when setting DID from notification", notification); 96 94 console.error(error); 97 95 } 98 96 } 99 - }); 97 + }, "identityPrecaching:notifications"); 100 98 }
+8 -16
src/js/interactionHandlers.js
··· 1 - import { EventEmitter } from "/js/eventEmitter.js"; 2 1 import { PostInteractionHandler } from "/js/postInteractionHandler.js"; 3 2 import { ProfileInteractionHandler } from "/js/profileInteractionHandler.js"; 4 3 import { FeedInteractionHandler } from "/js/feedInteractionHandler.js"; 5 4 6 5 function loggedOutHandler(name) { 7 - const emitter = new EventEmitter(); 8 - return new Proxy(emitter, { 9 - get(target, prop) { 10 - if (prop in target) return target[prop]; 11 - throw new Error(`${name}.${String(prop)} called while logged out`); 6 + return new Proxy( 7 + {}, 8 + { 9 + get(_target, prop) { 10 + throw new Error(`${name}.${String(prop)} called while logged out`); 11 + }, 12 12 }, 13 - }); 13 + ); 14 14 } 15 15 16 - export class InteractionHandlers extends EventEmitter { 16 + export class InteractionHandlers { 17 17 constructor({ session, dataLayer, postComposerService, reportService }) { 18 - super(); 19 18 this.postInteractionHandler = session 20 19 ? new PostInteractionHandler( 21 20 dataLayer, ··· 29 28 this.feedInteractionHandler = session 30 29 ? new FeedInteractionHandler(dataLayer) 31 30 : loggedOutHandler("feedInteractionHandler"); 32 - for (const handler of [ 33 - this.postInteractionHandler, 34 - this.profileInteractionHandler, 35 - this.feedInteractionHandler, 36 - ]) { 37 - handler.on("requestRender", () => this.emit("requestRender")); 38 - } 39 31 } 40 32 }
+7 -13
src/js/notificationService.js
··· 1 - import { EventEmitter } from "/js/eventEmitter.js"; 2 - import { wait } from "/js/utils.js"; 1 + import { wait, Signal } from "/js/utils.js"; 3 2 4 3 const POLLING_INTERVAL_SECONDS = 10; 5 4 6 - export class NotificationService extends EventEmitter { 5 + export class NotificationService { 7 6 constructor(api) { 8 - super(); 9 7 this.api = api; 10 - this._numNotifications = 0; 8 + this.$numNotifications = new Signal.State(0); 9 + this.$numNotifications.__debugName = "$numNotifications"; 11 10 } 12 11 13 12 snooze(timeoutMinutes = 120) { ··· 34 33 } 35 34 async fetchNumNotifications() { 36 35 const numNotifications = await this.api.getNumNotifications(); 37 - if (numNotifications !== this._numNotifications) { 38 - this._numNotifications = numNotifications; 39 - this.emit("update"); 36 + if (numNotifications !== this.$numNotifications.get()) { 37 + this.$numNotifications.set(numNotifications); 40 38 } 41 - } 42 - getNumNotifications() { 43 - return this._numNotifications; 44 39 } 45 40 async markNotificationsAsRead() { 46 41 // optimistic update 47 - this._numNotifications = 0; 48 - this.emit("update"); 42 + this.$numNotifications.set(0); 49 43 let updated = false; 50 44 let retries = 0; 51 45 // Try this a few times, since the endpoint is pretty unstable
+15 -101
src/js/postInteractionHandler.js
··· 2 2 import { showToast } from "/js/toasts.js"; 3 3 import { confirm } from "/js/modals.js"; 4 4 import { trashCanIconTemplate } from "/js/templates/icons/trashCanIcon.template.js"; 5 - import { EventEmitter } from "/js/eventEmitter.js"; 6 5 7 - export class PostInteractionHandler extends EventEmitter { 6 + export class PostInteractionHandler { 8 7 constructor(dataLayer, postComposerService, reportService) { 9 - super(); 10 8 this.dataLayer = dataLayer; 11 9 this.postComposerService = postComposerService; 12 10 this.reportService = reportService; 13 - } 14 - 15 - renderFunc() { 16 - this.emit("requestRender"); 17 11 } 18 12 19 13 async handleLike(post, doLike) { 20 14 if (doLike) { 21 15 try { 22 16 hapticsImpactMedium(); 23 - const promise = this.dataLayer.mutations.addLike(post); 24 - // Render optimistic update 25 - this.renderFunc(); 26 - await promise; 27 - // Render final update 28 - this.renderFunc(); 17 + await this.dataLayer.mutations.addLike(post); 29 18 } catch (error) { 30 19 console.error(error); 31 20 showToast("Failed to like post", { style: "error" }); 32 - this.renderFunc(); 33 21 } 34 22 } else { 35 23 try { 36 - const promise = this.dataLayer.mutations.removeLike(post); 37 - // Render optimistic update 38 - this.renderFunc(); 39 - await promise; 40 - // Render final update 41 - this.renderFunc(); 24 + await this.dataLayer.mutations.removeLike(post); 42 25 } catch (error) { 43 26 console.error(error); 44 27 showToast("Failed to unlike post", { style: "error" }); 45 - this.renderFunc(); 46 28 } 47 29 } 48 30 } ··· 51 33 if (doRepost) { 52 34 try { 53 35 hapticsImpactMedium(); 54 - const promise = this.dataLayer.mutations.createRepost(post); 55 - // Render optimistic update 56 - this.renderFunc(); 57 - await promise; 58 - // Render final update 59 - this.renderFunc(); 36 + await this.dataLayer.mutations.createRepost(post); 60 37 } catch (error) { 61 38 console.error(error); 62 39 showToast("Failed to repost post", { style: "error" }); 63 - this.renderFunc(); 64 40 } 65 41 } else { 66 42 try { 67 - const promise = this.dataLayer.mutations.deleteRepost(post); 68 - // Render optimistic update 69 - this.renderFunc(); 70 - await promise; 71 - // Render final update 72 - this.renderFunc(); 43 + await this.dataLayer.mutations.deleteRepost(post); 73 44 } catch (error) { 74 45 console.error(error); 75 46 showToast("Failed to delete repost", { style: "error" }); 76 - this.renderFunc(); 77 47 } 78 48 } 79 49 } ··· 82 52 if (doBookmark) { 83 53 try { 84 54 hapticsImpactMedium(); 85 - const promise = this.dataLayer.mutations.addBookmark(post); 86 - // Render optimistic update 87 - this.renderFunc(); 88 - await promise; 89 - // Render final update 90 - this.renderFunc(); 55 + await this.dataLayer.mutations.addBookmark(post); 91 56 showToast("Post saved", { style: "success" }); 92 57 } catch (error) { 93 58 console.error(error); 94 59 showToast("Failed to bookmark post", { style: "error" }); 95 - this.renderFunc(); 96 60 } 97 61 } else { 98 62 try { 99 - const promise = this.dataLayer.mutations.removeBookmark(post); 100 - // Render optimistic update 101 - this.renderFunc(); 102 - await promise; 103 - // Render final update 104 - this.renderFunc(); 63 + await this.dataLayer.mutations.removeBookmark(post); 105 64 showToast("Removed from saved posts", { 106 65 iconTemplate: trashCanIconTemplate, 107 66 }); 108 67 } catch (error) { 109 68 console.error(error); 110 69 showToast("Failed to remove bookmark", { style: "error" }); 111 - this.renderFunc(); 112 70 } 113 71 } 114 72 } ··· 133 91 console.error(error); 134 92 showToast("Failed to delete post", { style: "error" }); 135 93 } 136 - this.renderFunc(); 137 94 } 138 95 139 96 async handlePinPost(post, doPin) { 140 97 if (doPin) { 141 98 try { 142 99 hapticsImpactMedium(); 143 - const promise = this.dataLayer.mutations.pinPost(post); 144 - // Render optimistic update 145 - this.renderFunc(); 146 - await promise; 147 - // Render final update 148 - this.renderFunc(); 100 + await this.dataLayer.mutations.pinPost(post); 149 101 showToast("Post pinned to your profile", { style: "success" }); 150 102 } catch (error) { 151 103 console.error(error); 152 104 showToast("Failed to pin post", { style: "error" }); 153 - this.renderFunc(); 154 105 } 155 106 } else { 156 107 try { 157 - const promise = this.dataLayer.mutations.unpinPost(post); 158 - // Render optimistic update 159 - this.renderFunc(); 160 - await promise; 161 - // Render final update 162 - this.renderFunc(); 108 + await this.dataLayer.mutations.unpinPost(post); 163 109 showToast("Post unpinned"); 164 110 } catch (error) { 165 111 console.error(error); 166 112 showToast("Failed to unpin post", { style: "error" }); 167 - this.renderFunc(); 168 113 } 169 114 } 170 115 } ··· 179 124 return; 180 125 } 181 126 try { 182 - const promise = this.dataLayer.mutations.hidePost(post); 183 - // Render optimistic update 184 - this.renderFunc(); 185 - await promise; 186 - // Render final update 187 - this.renderFunc(); 127 + await this.dataLayer.mutations.hidePost(post); 188 128 showToast("Post hidden"); 189 129 } catch (error) { 190 130 console.error(error); 191 131 showToast("Failed to hide post", { style: "error" }); 192 - this.renderFunc(); 193 132 } 194 133 } 195 134 196 135 async handleMuteAuthor(profile, doMute) { 197 136 if (doMute) { 198 137 try { 199 - const promise = this.dataLayer.mutations.muteProfile(profile); 200 - // Render optimistic update 201 - this.renderFunc(); 202 - await promise; 203 - // Render final update 204 - this.renderFunc(); 138 + await this.dataLayer.mutations.muteProfile(profile); 205 139 showToast("Account muted"); 206 140 } catch (error) { 207 141 console.error(error); 208 142 showToast("Failed to mute account", { style: "error" }); 209 - this.renderFunc(); 210 143 } 211 144 } else { 212 145 try { 213 - const promise = this.dataLayer.mutations.unmuteProfile(profile); 214 - // Render optimistic update 215 - this.renderFunc(); 216 - await promise; 217 - // Render final update 218 - this.renderFunc(); 146 + await this.dataLayer.mutations.unmuteProfile(profile); 219 147 showToast("Account unmuted"); 220 148 } catch (error) { 221 149 console.error(error); 222 150 showToast("Failed to unmute account", { style: "error" }); 223 - this.renderFunc(); 224 151 } 225 152 } 226 153 } ··· 237 164 ); 238 165 if (!confirmed) return; 239 166 try { 240 - const promise = this.dataLayer.mutations.blockProfile(profile); 241 - // Render optimistic update 242 - this.renderFunc(); 243 - await promise; 244 - // Render final update 245 - this.renderFunc(); 167 + await this.dataLayer.mutations.blockProfile(profile); 246 168 showToast("Account blocked"); 247 169 } catch (error) { 248 170 console.error(error); 249 171 showToast("Failed to block account", { style: "error" }); 250 - this.renderFunc(); 251 172 } 252 173 } else { 253 174 try { 254 - const promise = this.dataLayer.mutations.unblockProfile(profile); 255 - // Render optimistic update 256 - this.renderFunc(); 257 - await promise; 258 - // Render final update 259 - this.renderFunc(); 175 + await this.dataLayer.mutations.unblockProfile(profile); 260 176 showToast("Account unblocked"); 261 177 } catch (error) { 262 178 console.error(error); 263 179 showToast("Failed to unblock account", { style: "error" }); 264 - this.renderFunc(); 265 180 } 266 181 } 267 182 } 268 183 269 184 async handleQuotePost(post) { 270 - const currentUser = this.dataLayer.selectors.getCurrentUser(); 185 + const currentUser = this.dataLayer.signals.$currentUser.get(); 271 186 if (!currentUser) { 272 187 console.warn("No current user"); 273 188 return; ··· 277 192 currentUser, 278 193 quotedPost: post, 279 194 }); 280 - this.renderFunc(); 281 195 } catch (error) { 282 196 console.error(error); 283 197 }
+13 -75
src/js/profileInteractionHandler.js
··· 1 1 import { hapticsImpactMedium } from "/js/haptics.js"; 2 2 import { showToast } from "/js/toasts.js"; 3 3 import { confirm } from "/js/modals.js"; 4 - import { EventEmitter } from "/js/eventEmitter.js"; 5 4 import "/js/components/post-notifications-dialog.js"; 6 5 7 - export class ProfileInteractionHandler extends EventEmitter { 6 + export class ProfileInteractionHandler { 8 7 constructor(dataLayer, reportService) { 9 - super(); 10 8 this.dataLayer = dataLayer; 11 9 this.reportService = reportService; 12 10 this._postNotificationsDialog = null; 13 - } 14 - 15 - renderFunc() { 16 - this.emit("requestRender"); 17 11 } 18 12 19 13 async handleFollow(profile, doFollow, { showSuccessToast = false } = {}) { 20 14 if (doFollow) { 21 15 try { 22 16 hapticsImpactMedium(); 23 - const promise = this.dataLayer.mutations.followProfile(profile); 24 - // Render optimistic update 25 - this.renderFunc(); 26 - await promise; 27 - // Render final update 28 - this.renderFunc(); 17 + await this.dataLayer.mutations.followProfile(profile); 29 18 if (showSuccessToast) { 30 19 showToast("Account followed"); 31 20 } 32 21 } catch (error) { 33 22 console.error(error); 34 23 showToast("Failed to follow account", { style: "error" }); 35 - this.renderFunc(); 36 24 } 37 25 } else { 38 26 try { 39 - const promise = this.dataLayer.mutations.unfollowProfile(profile); 40 - // Render optimistic update 41 - this.renderFunc(); 42 - await promise; 43 - // Render final update 44 - this.renderFunc(); 27 + await this.dataLayer.mutations.unfollowProfile(profile); 45 28 if (showSuccessToast) { 46 29 showToast("Account unfollowed"); 47 30 } 48 31 } catch (error) { 49 32 console.error(error); 50 33 showToast("Failed to unfollow account", { style: "error" }); 51 - this.renderFunc(); 52 34 } 53 35 } 54 36 } ··· 56 38 async handleMute(profile, doMute) { 57 39 if (doMute) { 58 40 try { 59 - const promise = this.dataLayer.mutations.muteProfile(profile); 60 - // Render optimistic update 61 - this.renderFunc(); 62 - await promise; 63 - // Render final update 64 - this.renderFunc(); 41 + await this.dataLayer.mutations.muteProfile(profile); 65 42 showToast("Account muted"); 66 43 } catch (error) { 67 44 console.error(error); 68 45 showToast("Failed to mute account", { style: "error" }); 69 - this.renderFunc(); 70 46 } 71 47 } else { 72 48 try { 73 - const promise = this.dataLayer.mutations.unmuteProfile(profile); 74 - // Render optimistic update 75 - this.renderFunc(); 76 - await promise; 77 - // Render final update 78 - this.renderFunc(); 49 + await this.dataLayer.mutations.unmuteProfile(profile); 79 50 showToast("Account unmuted"); 80 51 } catch (error) { 81 52 console.error(error); 82 53 showToast("Failed to unmute account", { style: "error" }); 83 - this.renderFunc(); 84 54 } 85 55 } 86 56 } ··· 98 68 if (!confirmed) return; 99 69 try { 100 70 hapticsImpactMedium(); 101 - const promise = this.dataLayer.mutations.blockProfile(profile); 102 - // Render optimistic update 103 - this.renderFunc(); 104 - await promise; 105 - // Render final update 106 - this.renderFunc(); 71 + await this.dataLayer.mutations.blockProfile(profile); 107 72 showToast("Account blocked"); 108 73 } catch (error) { 109 74 console.error(error); 110 75 showToast("Failed to block account", { style: "error" }); 111 - this.renderFunc(); 112 76 } 113 77 } else { 114 78 try { 115 - const promise = this.dataLayer.mutations.unblockProfile(profile); 116 - // Render optimistic update 117 - this.renderFunc(); 118 - await promise; 119 - // Render final update 120 - this.renderFunc(); 79 + await this.dataLayer.mutations.unblockProfile(profile); 121 80 showToast("Account unblocked"); 122 81 } catch (error) { 123 82 console.error(error); 124 83 showToast("Failed to unblock account", { style: "error" }); 125 - this.renderFunc(); 126 84 } 127 85 } 128 86 } ··· 131 89 if (doSubscribe) { 132 90 try { 133 91 hapticsImpactMedium(); 134 - const promise = this.dataLayer.mutations.subscribeLabeler( 135 - profile, 136 - labelerInfo, 137 - ); 138 - // Render optimistic update 139 - this.renderFunc(); 140 - await promise; 141 - // Render final update 142 - this.renderFunc(); 92 + await this.dataLayer.mutations.subscribeLabeler(profile, labelerInfo); 143 93 showToast("Subscribed to labeler"); 144 94 } catch (error) { 145 95 console.error(error); 146 96 showToast("Failed to subscribe to labeler", { style: "error" }); 147 - this.renderFunc(); 148 97 } 149 98 } else { 150 99 try { 151 - const promise = this.dataLayer.mutations.unsubscribeLabeler(profile); 152 - // Render optimistic update 153 - this.renderFunc(); 154 - await promise; 155 - // Render final update 156 - this.renderFunc(); 100 + await this.dataLayer.mutations.unsubscribeLabeler(profile); 157 101 showToast("Unsubscribed from labeler"); 158 102 } catch (error) { 159 103 console.error(error); 160 104 showToast("Failed to unsubscribe from labeler", { style: "error" }); 161 - this.renderFunc(); 162 105 } 163 106 } 164 107 } ··· 182 125 event.detail; 183 126 try { 184 127 hapticsImpactMedium(); 185 - const promise = 186 - this.dataLayer.mutations.updatePostNotificationSubscription( 187 - profile, 188 - activitySubscription, 189 - ); 190 - this.renderFunc(); 191 - await promise; 192 - this.renderFunc(); 128 + await this.dataLayer.mutations.updatePostNotificationSubscription( 129 + profile, 130 + activitySubscription, 131 + ); 193 132 const initialSub = profile.viewer?.activitySubscription; 194 133 const wasSubscribed = initialSub?.post || initialSub?.reply; 195 134 if (!activitySubscription.post && !activitySubscription.reply) { ··· 213 152 style: "error", 214 153 }); 215 154 errorCallback(error.message || "An unexpected error occurred."); 216 - this.renderFunc(); 217 155 } 218 156 }, 219 157 );
+1 -1
src/js/reportService.js
··· 14 14 } 15 15 return new Promise((resolve, reject) => { 16 16 this.currentReportDialog = document.createElement("report-dialog"); 17 - const preferences = this.dataLayer.selectors.getPreferences(); 17 + const preferences = this.dataLayer.signals.$preferences.get(); 18 18 this.currentReportDialog.labelerDefs = preferences.labelerDefs; 19 19 this.currentReportDialog.subjectType = subjectType; 20 20 this.currentReportDialog.addEventListener("submit-report", async (e) => {
+16
src/js/router.js
··· 1 1 import { EventEmitter } from "/js/eventEmitter.js"; 2 + import { effect } from "/js/utils.js"; 2 3 3 4 const MAX_PAGES = 5; 4 5 ··· 6 7 if (!source) return; 7 8 const attach = () => source.on(event, handler); 8 9 const detach = () => source.off(event, handler); 10 + root.addEventListener("page-enter", attach); 11 + root.addEventListener("page-restore", attach); 12 + root.addEventListener("page-exit", detach); 13 + } 14 + 15 + export function pageEffect(root, callback, debugName) { 16 + let dispose; 17 + const attach = () => { 18 + dispose?.(); 19 + dispose = effect(callback, debugName); 20 + }; 21 + const detach = () => { 22 + dispose?.(); 23 + dispose = null; 24 + }; 9 25 root.addEventListener("page-enter", attach); 10 26 root.addEventListener("page-restore", attach); 11 27 root.addEventListener("page-exit", detach);
+346
src/js/utils.js
··· 1 1 import { Capacitor } from "/js/lib/capacitor.js"; 2 + import { EventEmitter } from "/js/eventEmitter.js"; 2 3 3 4 export function noop() {} 4 5 ··· 546 547 return await Promise.race([fn(controller.signal), timeoutPromise]); 547 548 } finally { 548 549 clearTimeout(timeoutId); 550 + } 551 + } 552 + 553 + let trackedReads = null; 554 + 555 + function track(signal) { 556 + if (trackedReads) trackedReads.add(signal); 557 + } 558 + 559 + function withTracking(set, fn) { 560 + const previous = trackedReads; 561 + trackedReads = set; 562 + try { 563 + return fn(); 564 + } finally { 565 + trackedReads = previous; 566 + } 567 + } 568 + 569 + export function untrack(fn) { 570 + return withTracking(null, fn); 571 + } 572 + 573 + let globalTick = 0; 574 + 575 + class State { 576 + __debugName = "<State>"; 577 + __lastChangedTick = 0; 578 + #value; 579 + #watchers = new Set(); 580 + #dependents = new Set(); 581 + #equals; 582 + 583 + constructor(initialValue, { equals = Object.is } = {}) { 584 + this.#value = initialValue; 585 + this.#equals = equals; 586 + } 587 + 588 + get() { 589 + track(this); 590 + return this.#value; 591 + } 592 + 593 + set(newValue) { 594 + if (this.#equals(newValue, this.#value)) return; 595 + this.#value = newValue; 596 + this.__lastChangedTick = ++globalTick; 597 + 598 + for (const dependent of [...this.#dependents]) dependent._markDirty(this); 599 + for (const watcher of [...this.#watchers]) watcher._notify(this); 600 + } 601 + 602 + _addDependent(computed) { 603 + this.#dependents.add(computed); 604 + } 605 + _removeDependent(computed) { 606 + this.#dependents.delete(computed); 607 + } 608 + _addWatcher(watcher) { 609 + this.#watchers.add(watcher); 610 + } 611 + _removeWatcher(watcher) { 612 + this.#watchers.delete(watcher); 613 + } 614 + } 615 + 616 + class Computed { 617 + __debugName = "<Computed>"; 618 + __quiet = false; 619 + #cb; 620 + #value; 621 + #dirty = true; 622 + #deps = new Set(); 623 + #dependents = new Set(); 624 + #watchers = new Set(); 625 + #dirtyFrom = new Set(); 626 + 627 + constructor(cb) { 628 + this.#cb = cb; 629 + } 630 + 631 + get() { 632 + track(this); 633 + if (this.#dirty) this.#recompute(); 634 + return this.#value; 635 + } 636 + 637 + #recompute() { 638 + for (const dep of this.#deps) dep._removeDependent(this); 639 + const tracked = new Set(); 640 + this.#value = withTracking(tracked, () => this.#cb.call(this)); 641 + this.#deps = tracked; 642 + for (const dep of this.#deps) dep._addDependent(this); 643 + this.#dirty = false; 644 + this.#dirtyFrom = new Set(); 645 + } 646 + 647 + _markDirty(marker) { 648 + this.#dirtyFrom.add(marker); 649 + const wasDirty = this.#dirty; 650 + this.#dirty = true; 651 + if (wasDirty) return; 652 + for (const dependent of [...this.#dependents]) dependent._markDirty(this); 653 + for (const watcher of [...this.#watchers]) watcher._notify(this); 654 + } 655 + 656 + _getDirtyFrom() { 657 + return this.#dirtyFrom; 658 + } 659 + 660 + dispose() { 661 + for (const dep of this.#deps) dep._removeDependent(this); 662 + this.#deps = new Set(); 663 + this.#dirty = true; 664 + } 665 + 666 + _addDependent(computed) { 667 + this.#dependents.add(computed); 668 + } 669 + _removeDependent(computed) { 670 + this.#dependents.delete(computed); 671 + } 672 + _addWatcher(watcher) { 673 + this.#watchers.add(watcher); 674 + } 675 + _removeWatcher(watcher) { 676 + this.#watchers.delete(watcher); 677 + } 678 + } 679 + 680 + class Watcher { 681 + #notify; 682 + #watched = new Set(); 683 + #pending = new Set(); 684 + #notified = false; 685 + 686 + constructor(notify) { 687 + this.#notify = notify; 688 + } 689 + 690 + watch(...signals) { 691 + this.#notified = false; 692 + for (const sig of signals) { 693 + this.#watched.add(sig); 694 + sig._addWatcher(this); 695 + } 696 + } 697 + 698 + unwatch(...signals) { 699 + for (const sig of signals) { 700 + this.#watched.delete(sig); 701 + this.#pending.delete(sig); 702 + sig._removeWatcher(this); 703 + } 704 + } 705 + 706 + getPending() { 707 + const result = [...this.#pending]; 708 + this.#pending.clear(); 709 + return result; 710 + } 711 + 712 + _notify(signal) { 713 + this.#pending.add(signal); 714 + if (this.#notified) return; 715 + this.#notified = true; 716 + this.#notify(); 717 + } 718 + } 719 + 720 + export const Signal = { State, Computed, subtle: { Watcher } }; 721 + 722 + function logEffectTrigger(effectComputed, debugName, lastRunTick) { 723 + const lines = [`[T${globalTick}] effect(${debugName}) firing, caused by:`]; 724 + const seen = new Set(); 725 + // ancestorBars: for each ancestor level, true if that level still has siblings below 726 + const walk = (node, ancestorBars, isLast) => { 727 + const prefix = 728 + ancestorBars.map((bar) => (bar ? "│ " : " ")).join("") + 729 + (isLast ? "└─ " : "├─ "); 730 + if (seen.has(node)) { 731 + lines.push(`${prefix}↺ ${node.__debugName ?? "?"}`); 732 + return; 733 + } 734 + seen.add(node); 735 + const isState = node instanceof State; 736 + const quiet = node.__quiet; 737 + if (!quiet) { 738 + const kind = isState ? "state" : "computed"; 739 + const tickInfo = 740 + isState && node.__lastChangedTick > lastRunTick 741 + ? ` @T${node.__lastChangedTick}` 742 + : ""; 743 + lines.push(`${prefix}${kind} ${node.__debugName ?? "?"}${tickInfo}`); 744 + } 745 + if (!isState) { 746 + const children = [...node._getDirtyFrom()]; 747 + const childBars = quiet ? ancestorBars : [...ancestorBars, !isLast]; 748 + children.forEach((child, i) => { 749 + walk(child, childBars, i === children.length - 1); 750 + }); 751 + } 752 + }; 753 + const rootMarkers = [...effectComputed._getDirtyFrom()]; 754 + rootMarkers.forEach((marker, i) => { 755 + walk(marker, [], i === rootMarkers.length - 1); 756 + }); 757 + if (lines.length > 1) console.log(lines.join("\n")); 758 + } 759 + 760 + export const effect = (cb, debugName) => { 761 + let cleanup; 762 + let lastRunTick = 0; 763 + let hasRun = false; 764 + const computed = new Computed(() => { 765 + if (typeof cleanup === "function") cleanup(); 766 + cleanup = cb(); 767 + }); 768 + computed.__debugName = "effect(" + (debugName ?? "unknown") + ")"; 769 + 770 + let pendingFlush = false; 771 + const run = () => { 772 + if (hasRun && debugName) { 773 + logEffectTrigger(computed, debugName, lastRunTick); 774 + } 775 + lastRunTick = globalTick; 776 + hasRun = true; 777 + computed.get(); 778 + watcher.watch(); // re-arm 779 + }; 780 + 781 + const watcher = new Watcher(() => { 782 + if (pendingFlush) return; 783 + pendingFlush = true; 784 + requestAnimationFrame(() => { 785 + pendingFlush = false; 786 + run(); 787 + }); 788 + }); 789 + watcher.watch(computed); 790 + run(); 791 + 792 + return () => { 793 + if (typeof cleanup === "function") cleanup(); 794 + watcher.unwatch(computed); 795 + computed.dispose(); 796 + }; 797 + }; 798 + 799 + export class SignalMap { 800 + __debugName = "<SignalMap>"; 801 + 802 + constructor() { 803 + this.map = new Map(); 804 + // Tracks keys that have been explicitly written via set(). Reading $keys 805 + // takes a dependency on collection membership so consumers re-run when 806 + // new entries are added. 807 + this._setKeys = new Set(); 808 + this.$keys = new Signal.State([]); 809 + setTimeout(() => { 810 + this.$keys.__debugName = `${this.__debugName}.$keys`; 811 + }, 0); 812 + } 813 + 814 + get(key) { 815 + let signal = this.map.get(key); 816 + if (!signal) { 817 + signal = new Signal.State(null); 818 + signal.__debugName = `${this.__debugName}[${String(key)}]`; 819 + this.map.set(key, signal); 820 + } 821 + return signal; 822 + } 823 + 824 + set(key, value) { 825 + this.get(key).set(value); 826 + if (!this._setKeys.has(key)) { 827 + this._setKeys.add(key); 828 + this.$keys.set([...this._setKeys]); 829 + } 830 + } 831 + 832 + delete(key) { 833 + const signal = this.map.get(key); 834 + if (signal) signal.set(null); 835 + this.map.delete(key); 836 + if (this._setKeys.delete(key)) { 837 + this.$keys.set([...this._setKeys]); 838 + } 839 + } 840 + 841 + clear() { 842 + for (const signal of this.map.values()) { 843 + signal.set(null); 844 + } 845 + } 846 + 847 + keys() { 848 + return this.map.keys(); 849 + } 850 + 851 + *values() { 852 + for (const signal of this.map.values()) { 853 + yield signal.get(); 854 + } 855 + } 856 + 857 + *entries() { 858 + for (const [key, signal] of this.map.entries()) { 859 + yield [key, signal.get()]; 860 + } 861 + } 862 + } 863 + 864 + export class ComputedMap { 865 + __debugName = "<ComputedMap>"; 866 + 867 + constructor(computeFn) { 868 + this.map = new Map(); 869 + this.computeFn = computeFn; 870 + } 871 + 872 + get(key) { 873 + let signal = this.map.get(key); 874 + if (!signal) { 875 + signal = new Signal.Computed(() => this.computeFn(key)); 876 + signal.__debugName = `${this.__debugName}[${String(key)}]`; 877 + this.map.set(key, signal); 878 + } 879 + return signal; 880 + } 881 + } 882 + 883 + export class ReactiveStore extends EventEmitter { 884 + constructor(id) { 885 + super(); 886 + return new Proxy(this, { 887 + set(target, prop, value) { 888 + if (prop.startsWith("$")) { 889 + value.__debugName = `${id}.${prop}`; 890 + } 891 + target[prop] = value; 892 + return true; 893 + }, 894 + }); 549 895 } 550 896 }
+61 -88
src/js/components/plugin-posts-feed.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { Component } from "/js/components/component.js"; 3 3 import { postFeedTemplate } from "/js/templates/postFeed.template.js"; 4 + import { Signal, effect } from "/js/utils.js"; 4 5 5 6 class PluginPostsFeed extends Component { 6 7 static get observedAttributes() { ··· 13 14 if (!this.postInteractionHandler) { 14 15 throw new Error("postInteractionHandler is required"); 15 16 } 16 - this.loadedUris = null; 17 - this.error = null; 18 - this._postUnsubs = new Map(); 19 - this._onLiveUpdate = () => this.render(); 20 - this.dataLayer.events.on("preferences:changed", this._onLiveUpdate); 21 - this.render(); 22 - this.load(); 17 + this.attribs = { 18 + uris: new Signal.State(this.parseUris()), 19 + emptyMessage: new Signal.State(this.getAttribute("empty-message")), 20 + }; 21 + this.state = { 22 + currentUser: this.dataLayer.signals.$currentUser, 23 + loaded: new Signal.State(false), 24 + posts: new Signal.Computed(() => { 25 + if (!this.state.loaded.get()) return null; 26 + const uris = this.attribs.uris.get(); 27 + if (!uris) return null; 28 + return uris 29 + .map((uri) => this.dataLayer.signals.$hydratedPosts.get(uri).get()) 30 + .filter(Boolean); 31 + }), 32 + error: new Signal.State(null), 33 + }; 34 + this._disposers = [ 35 + effect(() => { 36 + const error = this.state.error.get(); 37 + const posts = this.state.posts.get(); 38 + const currentUser = this.state.currentUser.get(); 39 + const emptyMessage = this.attribs.emptyMessage.get(); 40 + if (error) { 41 + render(html`<div class="posts-feed-error">${error}</div>`, this); 42 + return; 43 + } 44 + const feed = posts 45 + ? { 46 + feed: posts.map((post) => ({ post })), 47 + cursor: null, 48 + } 49 + : null; 50 + render( 51 + postFeedTemplate({ 52 + feed, 53 + currentUser, 54 + isAuthenticated: this.isAuthenticated, 55 + postInteractionHandler: this.postInteractionHandler, 56 + pluginService: this.pluginService, 57 + emptyMessage, 58 + }), 59 + this, 60 + ); 61 + }), 62 + effect(() => { 63 + this.attribs.uris.get(); 64 + this.load(); 65 + }), 66 + ]; 23 67 } 24 68 25 69 disconnectedCallback() { 26 70 if (!this.initialized) return; 27 - this._postUnsubs.forEach((unsub) => unsub()); 28 - this._postUnsubs.clear(); 29 - this.dataLayer.events.off("preferences:changed", this._onLiveUpdate); 71 + this._disposers?.forEach((dispose) => dispose()); 72 + this._disposers = null; 30 73 } 31 74 32 75 attributeChangedCallback() { 33 - if (this.initialized) this.load(); 34 - } 35 - 36 - refresh() { 37 - if (!this.initialized) return; 38 - this.render(); 76 + if (this.initialized) { 77 + // TODO - smarter updates? 78 + this.attribs.uris.set(this.parseUris()); 79 + this.attribs.emptyMessage.set(this.getAttribute("empty-message")); 80 + } 39 81 } 40 82 41 83 parseUris() { ··· 46 88 .filter(Boolean); 47 89 } 48 90 49 - _syncPostSubscriptions(uris) { 50 - const next = new Set(uris); 51 - for (const uri of [...this._postUnsubs.keys()]) { 52 - if (!next.has(uri)) { 53 - this._postUnsubs.get(uri)(); 54 - this._postUnsubs.delete(uri); 55 - } 56 - } 57 - for (const uri of uris) { 58 - if (this._postUnsubs.has(uri)) continue; 59 - const event = `post:${uri}`; 60 - this.dataLayer.events.on(event, this._onLiveUpdate); 61 - this._postUnsubs.set(uri, () => 62 - this.dataLayer.events.off(event, this._onLiveUpdate), 63 - ); 64 - } 65 - } 66 - 67 91 async load() { 68 92 const uris = this.parseUris(); 69 93 const requestToken = Symbol(); 70 94 this._requestToken = requestToken; 71 - if (uris.length === 0) { 72 - this.loadedUris = []; 73 - this.error = null; 74 - this._syncPostSubscriptions([]); 75 - this.render(); 76 - return; 77 - } 78 - this.loadedUris = null; 79 - this.error = null; 80 - this.render(); 95 + this.state.error.set(null); 81 96 try { 82 97 await this.dataLayer.declarative.ensurePosts(uris); 83 98 if (this._requestToken !== requestToken) return; 84 - this.loadedUris = uris; 85 - this._syncPostSubscriptions(uris); 99 + this.state.loaded.set(true); 86 100 } catch (error) { 87 101 if (this._requestToken !== requestToken) return; 88 - this.error = error.message ?? String(error); 102 + this.state.error.set(error.message ?? String(error)); 89 103 } 90 - this.render(); 91 - } 92 - 93 - render() { 94 - if (this.error) { 95 - render(html`<div class="posts-feed-error">${this.error}</div>`, this); 96 - return; 97 - } 98 - const currentUser = this.dataLayer.selectors.getCurrentUser(); 99 - let feed = null; 100 - if (this.loadedUris) { 101 - const preferences = this.dataLayer.selectors.getPreferences(); 102 - const selected = this.loadedUris.map((uri) => { 103 - const current = this.dataLayer.base.getPost(uri); 104 - if (!current) return null; 105 - return this.dataLayer.derived.hydratePostForView(current, { 106 - preferences, 107 - getPost: this.dataLayer.base.getPost, 108 - }); 109 - }); 110 - const missing = this.loadedUris.filter((_, index) => !selected[index]); 111 - if (missing.length > 0) { 112 - console.warn( 113 - "plugin-posts-feed: some posts could not be loaded", 114 - missing, 115 - ); 116 - } 117 - const posts = selected.filter(Boolean); 118 - feed = { feed: posts.map((post) => ({ post })), cursor: null }; 119 - } 120 - render( 121 - postFeedTemplate({ 122 - feed, 123 - currentUser, 124 - isAuthenticated: this.isAuthenticated, 125 - postInteractionHandler: this.postInteractionHandler, 126 - pluginService: this.pluginService, 127 - emptyMessage: this.getAttribute("empty-message"), 128 - }), 129 - this, 130 - ); 131 104 } 132 105 } 133 106
+53 -61
src/js/components/plugin-profiles-list.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { Component } from "/js/components/component.js"; 3 3 import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; 4 + import { Signal, effect } from "/js/utils.js"; 4 5 5 6 class PluginProfilesList extends Component { 6 7 static get observedAttributes() { ··· 10 11 connectedCallback() { 11 12 if (this.initialized) return; 12 13 this.initialized = true; 13 - this.loadedDids = null; 14 - this.error = null; 15 - this._profileUnsubs = new Map(); 16 - this._onLiveUpdate = () => this.render(); 17 - this.render(); 18 - this.load(); 14 + this.attribs = { 15 + dids: new Signal.State(this.parseDids()), 16 + emptyMessage: new Signal.State(this.getAttribute("empty-message")), 17 + }; 18 + this.state = { 19 + loaded: new Signal.State(false), 20 + profiles: new Signal.Computed(() => { 21 + if (!this.state.loaded.get()) return null; 22 + const dids = this.attribs.dids.get(); 23 + return dids 24 + .map((did) => this.dataLayer.signals.$hydratedProfiles.get(did).get()) 25 + .filter(Boolean); 26 + }), 27 + error: new Signal.State(null), 28 + }; 29 + this._disposers = [ 30 + effect(() => { 31 + const error = this.state.error.get(); 32 + const profiles = this.state.profiles.get(); 33 + const emptyMessage = this.attribs.emptyMessage.get(); 34 + const dids = this.attribs.dids.get(); 35 + if (error) { 36 + render(html`<div class="profile-list-error">${error}</div>`, this); 37 + return; 38 + } 39 + render( 40 + profileFeedTemplate({ 41 + profiles, 42 + hasMore: false, 43 + skeletonCount: dids.length, 44 + emptyMessage, 45 + }), 46 + this, 47 + ); 48 + }), 49 + effect(() => { 50 + this.attribs.dids.get(); 51 + this.load(); 52 + }), 53 + ]; 19 54 } 20 55 21 56 disconnectedCallback() { 22 57 if (!this.initialized) return; 23 - this._profileUnsubs.forEach((unsub) => unsub()); 24 - this._profileUnsubs.clear(); 58 + this._disposers?.forEach((dispose) => dispose()); 59 + this._disposers = null; 25 60 } 26 61 27 62 attributeChangedCallback() { 28 - if (this.initialized) this.load(); 63 + if (this.initialized) { 64 + this.attribs.dids.set(this.parseDids()); 65 + this.attribs.emptyMessage.set(this.getAttribute("empty-message")); 66 + } 29 67 } 30 68 31 69 parseDids() { ··· 36 74 .filter(Boolean); 37 75 } 38 76 39 - _syncProfileSubscriptions(dids) { 40 - const next = new Set(dids); 41 - for (const did of [...this._profileUnsubs.keys()]) { 42 - if (!next.has(did)) { 43 - this._profileUnsubs.get(did)(); 44 - this._profileUnsubs.delete(did); 45 - } 46 - } 47 - for (const did of dids) { 48 - if (this._profileUnsubs.has(did)) continue; 49 - const event = `profile:${did}`; 50 - this.dataLayer.events.on(event, this._onLiveUpdate); 51 - this._profileUnsubs.set(did, () => 52 - this.dataLayer.events.off(event, this._onLiveUpdate), 53 - ); 54 - } 55 - } 56 - 57 77 async load() { 58 - const dids = this.parseDids(); 78 + const dids = this.attribs.dids.get(); 59 79 const requestToken = Symbol(); 60 80 this._requestToken = requestToken; 81 + this.state.error.set(null); 61 82 if (dids.length === 0) { 62 - this.loadedDids = []; 63 - this.error = null; 64 - this._syncProfileSubscriptions([]); 65 - this.render(); 83 + this.state.loaded.set(true); 66 84 return; 67 85 } 68 - this.loadedDids = null; 69 - this.error = null; 70 - this.render(); 86 + this.state.loaded.set(false); 71 87 try { 72 88 await this.dataLayer.declarative.ensureProfiles(dids); 73 89 if (this._requestToken !== requestToken) return; 74 - this.loadedDids = dids; 75 - this._syncProfileSubscriptions(dids); 90 + this.state.loaded.set(true); 76 91 } catch (error) { 77 92 if (this._requestToken !== requestToken) return; 78 - this.error = error.message ?? String(error); 93 + this.state.error.set(error.message ?? String(error)); 79 94 } 80 - this.render(); 81 - } 82 - 83 - render() { 84 - if (this.error) { 85 - render(html`<div class="profile-list-error">${this.error}</div>`, this); 86 - return; 87 - } 88 - let profiles = null; 89 - if (this.loadedDids) { 90 - profiles = this.loadedDids 91 - .map((did) => this.dataLayer.base.getProfile(did)) 92 - .filter(Boolean); 93 - } 94 - render( 95 - profileFeedTemplate({ 96 - profiles, 97 - hasMore: false, 98 - skeletonCount: this.parseDids().length, 99 - emptyMessage: this.getAttribute("empty-message"), 100 - }), 101 - this, 102 - ); 103 95 } 104 96 } 105 97
+18 -9
src/js/components/plugin-slot.js
··· 1 1 import { Component } from "/js/components/component.js"; 2 + import { effect } from "/js/utils.js"; 2 3 3 4 const CONTEXT_PREFIX = "context-"; 4 5 ··· 18 19 } 19 20 this._pluginRoots = new Map(); 20 21 this._currentRequest = null; 21 - this._onSlotChange = ({ name }) => { 22 - if (name !== this.getAttribute("name")) return; 22 + this._subscribe(); 23 + } 24 + 25 + _subscribe() { 26 + this._disposeEffect?.(); 27 + const slotName = this.getAttribute("name"); 28 + if (!slotName) return; 29 + this._disposeEffect = effect(() => { 30 + this.pluginService.$slots.get(slotName).get(); 23 31 this._reconcile(); 24 - }; 25 - this.pluginService.on("slotRegistered", this._onSlotChange); 26 - this.pluginService.on("slotUnregistered", this._onSlotChange); 27 - this._reconcile(); 32 + }, `plugin-slot[${slotName}]`); 28 33 } 29 34 30 35 disconnectedCallback() { 31 36 if (!this.initialized) return; 32 - this.pluginService.off("slotRegistered", this._onSlotChange); 33 - this.pluginService.off("slotUnregistered", this._onSlotChange); 37 + this._disposeEffect?.(); 38 + this._disposeEffect = null; 34 39 this._currentRequest = null; 35 40 this._pluginRoots.clear(); 36 41 } ··· 42 47 43 48 attributeChangedCallback(name, oldValue, newValue) { 44 49 if (!this.initialized || oldValue === newValue) return; 45 - this._reconcile(); 50 + if (name === "name") { 51 + this._subscribe(); 52 + } else { 53 + this._reconcile(); 54 + } 46 55 } 47 56 48 57 _getContext() {
-16
src/js/dataLayer/base.js
··· 1 - // Single-entity reads composed with patch-store optimistic updates. 2 - // This is the "current state" of an entity from the user's perspective — 3 - // what they believe the server holds, including in-flight mutations. 4 - // View-layer derivation (mute/hide/labels) is layered above this via derived. 5 - 6 - export function getPost(dataStore, patchStore, uri) { 7 - const raw = dataStore.getPost(uri); 8 - if (!raw) return null; 9 - return patchStore.applyPostPatches(raw); 10 - } 11 - 12 - export function getProfile(dataStore, patchStore, did) { 13 - const raw = dataStore.getProfile(did); 14 - if (!raw) return null; 15 - return patchStore.applyProfilePatches(raw); 16 - }
+22 -26
src/js/dataLayer/dataLayer.js
··· 2 2 import { PatchStore } from "/js/dataLayer/patchStore.js"; 3 3 import { Mutations } from "/js/dataLayer/mutations.js"; 4 4 import { Requests } from "/js/dataLayer/requests.js"; 5 - import { Selectors } from "/js/dataLayer/selectors.js"; 6 5 import { Declarative } from "/js/dataLayer/declarative.js"; 7 - import { EventEmitter } from "/js/eventEmitter.js"; 8 - import * as derived from "/js/dataLayer/derived.js"; 9 - import * as base from "/js/dataLayer/base.js"; 6 + import { Signals } from "/js/dataLayer/signals.js"; 10 7 11 8 export class DataLayer { 12 9 constructor(api, pluginService, preferencesProvider) { 13 10 this.api = api; 14 11 this.pluginService = pluginService; 15 12 this.isAuthenticated = api.isAuthenticated; 16 - // Shared bus for per-entity events that span dataStore + patchStore. 17 - // Consumers subscribe here for "post:${uri}" or "preferences:changed". 18 - this.events = new EventEmitter(); 19 - this.dataStore = new DataStore(this.events); 20 - this.patchStore = new PatchStore(this.events); 13 + this.dataStore = new DataStore(); 14 + this.patchStore = new PatchStore(this.dataStore); 21 15 this.preferencesProvider = preferencesProvider; 22 - preferencesProvider.on("setPreferences", () => { 23 - this.events.emit("preferences:changed"); 24 - }); 25 16 this.requests = new Requests( 26 17 this.api, 27 18 this.dataStore, 28 19 this.preferencesProvider, 29 20 this.pluginService, 30 21 ); 22 + this.pluginService?.on("feedFiltersRefresh", async ({ feedURI }) => { 23 + const feedURIs = feedURI 24 + ? [feedURI] 25 + : Array.from(this.dataStore.$feeds.keys()); 26 + await Promise.all( 27 + feedURIs.map((uri) => { 28 + const feed = this.dataStore.$feeds.get(uri).get(); 29 + if (!feed) return; 30 + return this.pluginService.refreshFiltersForFeed(uri, feed, { 31 + reload: true, 32 + }); 33 + }), 34 + ); 35 + }); 31 36 this.mutations = new Mutations( 32 37 this.api, 33 38 this.dataStore, 34 39 this.patchStore, 35 40 this.preferencesProvider, 36 41 ); 37 - this.selectors = new Selectors( 42 + this.signals = new Signals( 38 43 this.dataStore, 39 44 this.patchStore, 40 45 this.preferencesProvider, 46 + this.pluginService, 41 47 this.isAuthenticated, 42 48 ); 43 - this.derived = derived; 44 - this.base = { 45 - getPost: (uri) => base.getPost(this.dataStore, this.patchStore, uri), 46 - getProfile: (did) => 47 - base.getProfile(this.dataStore, this.patchStore, did), 48 - }; 49 - this.declarative = new Declarative( 50 - this.selectors, 51 - this.requests, 52 - this.base, 53 - ); 49 + this.declarative = new Declarative(this.signals, this.requests); 54 50 this.subscribers = []; 55 51 } 56 52 ··· 59 55 } 60 56 61 57 hasCachedFeed(feedURI) { 62 - return this.dataStore.hasFeed(feedURI); 58 + return this.dataStore.$feeds.get(feedURI).get() !== null; 63 59 } 64 60 65 61 hasCachedAuthorFeed(profileDid, feedType) { 66 62 const feedURI = `${profileDid}-${feedType}`; 67 - return this.dataStore.hasAuthorFeed(feedURI); 63 + return this.dataStore.$authorFeeds.get(feedURI).get() !== null; 68 64 } 69 65 }
+56 -699
src/js/dataLayer/dataStore.js
··· 1 - import { EventEmitter } from "/js/eventEmitter.js"; 1 + import { Signal, SignalMap, ReactiveStore } from "/js/utils.js"; 2 2 import { getQuotedPost, embedViewRecordToPostView } from "/js/dataHelpers.js"; 3 3 4 4 // The store saves canonical data from the server. Patches are layered on top of this. 5 - export class DataStore extends EventEmitter { 6 - constructor(eventBus = null) { 7 - super(); 8 - this._eventBus = eventBus; 9 - this.currentUser = null; 10 - this.feeds = new Map(); 11 - this.posts = new Map(); 12 - this.reposts = new Map(); 13 - this.postThreads = new Map(); 14 - this.postThreadOthers = new Map(); 15 - this.profiles = new Map(); 16 - this.authorFeeds = new Map(); 17 - this.profileSearchResults = null; 18 - this.latestProfileSearchRequestTime = null; 19 - this.postSearchResults = null; 20 - this.latestPostSearchRequestTime = null; 21 - this.feedSearchResults = null; 22 - this.latestFeedSearchRequestTime = null; 23 - this.showLessInteractions = []; 24 - this.showMoreInteractions = []; 25 - this.notifications = null; 26 - this.notificationCursor = null; 27 - this.mentionNotifications = null; 28 - this.mentionNotificationCursor = null; 29 - this.convoList = null; 30 - this.convoListCursor = null; 31 - // Note- we separate convos from the convo list because the convo list is 32 - // paginated, but we want to be able to fetch individual convos. 33 - this.convos = new Map(); 34 - this.convoMessages = new Map(); // keyed by convoId, value: { messages: [], cursor: null } 35 - this.messages = new Map(); // keyed by messageId, value: message 36 - // custom unavailable posts 37 - this.unavailablePosts = new Map(); 38 - this.postLikes = new Map(); 39 - this.postQuotes = new Map(); 40 - this.postReposts = new Map(); 41 - this.feedGenerators = new Map(); 42 - this.actorFeeds = new Map(); 43 - this.hashtagFeeds = new Map(); 44 - this.pinnedFeedGenerators = null; 45 - this.bookmarks = null; 46 - this.blockedProfiles = null; 47 - this.mutedProfiles = null; 48 - this.profileFollowers = new Map(); 49 - this.profileFollows = new Map(); 50 - this.profileChatStatus = new Map(); 51 - this.labelerInfo = new Map(); 52 - // Plugin data 53 - this.pluginFilteredFeedItems = new Map(); 5 + export class DataStore extends ReactiveStore { 6 + constructor() { 7 + super("dataStore"); 8 + // Single-value signals 9 + this.$currentUser = new Signal.State(null); 10 + this.$profileSearchResults = new Signal.State(null); 11 + this.$postSearchResults = new Signal.State(null); 12 + this.$feedSearchResults = new Signal.State(null); 13 + this.$showLessInteractions = new Signal.State([]); 14 + this.$showMoreInteractions = new Signal.State([]); 15 + this.$notifications = new Signal.State(null); 16 + this.$notificationCursor = new Signal.State(null); 17 + this.$mentionNotifications = new Signal.State(null); 18 + this.$mentionNotificationCursor = new Signal.State(null); 19 + this.$pinnedFeedGenerators = new Signal.State(null); 20 + this.$bookmarks = new Signal.State(null); 21 + this.$convoList = new Signal.State(null); 22 + this.$convoListCursor = new Signal.State(null); 23 + this.$blockedProfiles = new Signal.State(null); 24 + this.$mutedProfiles = new Signal.State(null); 25 + this.$latestProfileSearchRequestTime = new Signal.State(null); 26 + this.$latestPostSearchRequestTime = new Signal.State(null); 27 + this.$latestFeedSearchRequestTime = new Signal.State(null); 28 + // Keyed signals 29 + this.$feeds = new SignalMap(); 30 + this.$posts = new SignalMap(); 31 + this.$postThreads = new SignalMap(); 32 + this.$postThreadOthers = new SignalMap(); 33 + this.$profiles = new SignalMap(); 34 + this.$authorFeeds = new SignalMap(); 35 + this.$unavailablePosts = new SignalMap(); 36 + this.$reposts = new SignalMap(); 37 + this.$convos = new SignalMap(); 38 + this.$convoMessages = new SignalMap(); 39 + this.$messages = new SignalMap(); 40 + this.$postLikes = new SignalMap(); 41 + this.$postQuotes = new SignalMap(); 42 + this.$postReposts = new SignalMap(); 43 + this.$feedGenerators = new SignalMap(); 44 + this.$actorFeeds = new SignalMap(); 45 + this.$hashtagFeeds = new SignalMap(); 46 + this.$profileFollowers = new SignalMap(); 47 + this.$profileFollows = new SignalMap(); 48 + this.$profileChatStatus = new SignalMap(); 49 + this.$labelerInfo = new SignalMap(); 54 50 } 55 51 56 - hasCurrentUser() { 57 - return this.currentUser !== null; 58 - } 59 - 60 - setCurrentUser(user) { 61 - this.currentUser = user; 62 - } 63 - 64 - getCurrentUser() { 65 - return this.currentUser; 66 - } 67 - 68 - clearCurrentUser() { 69 - this.currentUser = null; 70 - } 71 - 72 - hasFeed(feedURI) { 73 - return this.feeds.has(feedURI); 74 - } 75 - 76 - getFeed(feedURI) { 77 - return this.feeds.get(feedURI); 78 - } 79 - 80 - setFeed(feedURI, feed) { 81 - this.feeds.set(feedURI, feed); 82 - } 83 - 84 - clearFeed(feedURI) { 85 - this.feeds.delete(feedURI); 86 - } 87 - 88 - hasPost(postURI) { 89 - return this.posts.has(postURI); 90 - } 91 - 92 - getPost(postURI) { 93 - return this.posts.get(postURI); 94 - } 95 - 96 - getAllPosts() { 97 - return Array.from(this.posts.values()); 98 - } 99 - 100 - setPost(postURI, post) { 101 - this.posts.set(postURI, post); 102 - this.emit("setPost", post); 103 - this._eventBus?.emit(`post:${postURI}`); 104 - // Also store quoted post if it exists 105 - const quotedPost = getQuotedPost(post); 106 - if ( 107 - quotedPost?.$type === "app.bsky.embed.record#viewRecord" && 108 - !this.hasPost(quotedPost.uri) 109 - ) { 110 - this.setPost(quotedPost.uri, embedViewRecordToPostView(quotedPost)); 111 - } 112 - } 113 - 114 - clearPost(postURI) { 115 - this.posts.delete(postURI); 116 - } 117 - 118 - // convenience method 119 52 setPosts(posts) { 120 - posts.forEach((post) => this.setPost(post.uri, post)); 121 - } 122 - 123 - hasPostThread(postURI) { 124 - return this.postThreads.has(postURI); 125 - } 126 - 127 - getPostThread(postURI) { 128 - return this.postThreads.get(postURI); 129 - } 130 - 131 - setPostThread(postURI, postThread) { 132 - this.postThreads.set(postURI, postThread); 133 - } 134 - 135 - clearPostThread(postURI) { 136 - this.postThreads.delete(postURI); 137 - } 138 - 139 - hasPostThreadOther(postURI) { 140 - return this.postThreadOthers.has(postURI); 141 - } 142 - 143 - getPostThreadOther(postURI) { 144 - return this.postThreadOthers.get(postURI); 145 - } 146 - 147 - setPostThreadOther(postURI, postThreadOther) { 148 - this.postThreadOthers.set(postURI, postThreadOther); 149 - } 150 - 151 - clearPostThreadOther(postURI) { 152 - this.postThreadOthers.delete(postURI); 153 - } 154 - 155 - hasProfile(did) { 156 - return this.profiles.has(did); 157 - } 158 - 159 - setProfile(did, profile) { 160 - this.profiles.set(did, profile); 161 - this._eventBus?.emit(`profile:${did}`); 162 - } 163 - 164 - getProfile(did) { 165 - return this.profiles.get(did); 166 - } 167 - 168 - clearProfile(did) { 169 - this.profiles.delete(did); 170 - this._eventBus?.emit(`profile:${did}`); 171 - } 172 - 173 - hasProfileSearchResults() { 174 - return this.profileSearchResults !== null; 175 - } 176 - 177 - getProfileSearchResults() { 178 - return this.profileSearchResults; 179 - } 180 - 181 - setProfileSearchResults(profileSearchResults) { 182 - this.profileSearchResults = profileSearchResults; 183 - this.emit("setProfileSearchResults", profileSearchResults); 184 - } 185 - 186 - clearProfileSearchResults() { 187 - this.profileSearchResults = null; 188 - } 189 - 190 - getProfileSearchCursor() { 191 - return this.profileSearchResults?.cursor ?? null; 192 - } 193 - 194 - getLatestProfileSearchRequestTime() { 195 - return this.latestProfileSearchRequestTime; 196 - } 197 - 198 - setLatestProfileSearchRequestTime(requestTime) { 199 - this.latestProfileSearchRequestTime = requestTime; 200 - } 201 - 202 - hasPostSearchResults() { 203 - return this.postSearchResults !== null; 204 - } 205 - 206 - getPostSearchResults() { 207 - return this.postSearchResults; 208 - } 209 - 210 - setPostSearchResults(postSearchResults) { 211 - this.postSearchResults = postSearchResults; 212 - } 213 - 214 - clearPostSearchResults() { 215 - this.postSearchResults = null; 216 - } 217 - 218 - getPostSearchCursor() { 219 - return this.postSearchResults?.cursor ?? null; 220 - } 221 - 222 - getLatestPostSearchRequestTime() { 223 - return this.latestPostSearchRequestTime; 224 - } 225 - 226 - setLatestPostSearchRequestTime(requestTime) { 227 - this.latestPostSearchRequestTime = requestTime; 228 - } 229 - 230 - hasFeedSearchResults() { 231 - return this.feedSearchResults !== null; 232 - } 233 - 234 - getFeedSearchResults() { 235 - return this.feedSearchResults; 236 - } 237 - 238 - setFeedSearchResults(feedSearchResults) { 239 - this.feedSearchResults = feedSearchResults; 240 - } 241 - 242 - clearFeedSearchResults() { 243 - this.feedSearchResults = null; 244 - } 245 - 246 - getFeedSearchCursor() { 247 - return this.feedSearchResults?.cursor ?? null; 248 - } 249 - 250 - getLatestFeedSearchRequestTime() { 251 - return this.latestFeedSearchRequestTime; 252 - } 253 - 254 - setLatestFeedSearchRequestTime(requestTime) { 255 - this.latestFeedSearchRequestTime = requestTime; 256 - } 257 - 258 - hasAuthorFeed(feedURI) { 259 - return this.authorFeeds.has(feedURI); 260 - } 261 - 262 - getAuthorFeed(feedURI) { 263 - return this.authorFeeds.get(feedURI); 264 - } 265 - 266 - setAuthorFeed(feedURI, feed) { 267 - this.authorFeeds.set(feedURI, feed); 268 - } 269 - 270 - clearAuthorFeed(feedURI) { 271 - this.authorFeeds.delete(feedURI); 272 - } 273 - 274 - getShowLessInteractions() { 275 - return this.showLessInteractions; 276 - } 277 - 278 - addShowLessInteraction(interaction) { 279 - this.showLessInteractions.push(interaction); 280 - } 281 - 282 - getShowMoreInteractions() { 283 - return this.showMoreInteractions; 284 - } 285 - 286 - addShowMoreInteraction(interaction) { 287 - this.showMoreInteractions.push(interaction); 288 - } 289 - 290 - hasUnavailablePost(uri) { 291 - return this.unavailablePosts.has(uri); 292 - } 293 - 294 - getUnavailablePost(uri) { 295 - return this.unavailablePosts.get(uri); 296 - } 297 - 298 - setUnavailablePost(uri, post) { 299 - this.unavailablePosts.set(uri, post); 300 - } 301 - 302 - clearUnavailablePost(uri) { 303 - this.unavailablePosts.delete(uri); 304 - } 305 - 306 - hasRepost(repostURI) { 307 - return this.reposts.has(repostURI); 308 - } 309 - 310 - getRepost(repostURI) { 311 - return this.reposts.get(repostURI); 312 - } 313 - 314 - setRepost(repostURI, repost) { 315 - this.reposts.set(repostURI, repost); 316 - } 317 - 318 - clearRepost(repostURI) { 319 - this.reposts.delete(repostURI); 320 - } 321 - 322 - setReposts(reposts) { 323 - reposts.forEach((repost) => this.setRepost(repost.uri, repost)); 324 - } 325 - 326 - clearReposts() { 327 - this.reposts.clear(); 328 - } 329 - 330 - hasNotifications() { 331 - return this.notifications !== null; 332 - } 333 - 334 - getNotifications() { 335 - return this.notifications; 336 - } 337 - 338 - setNotifications(notifications) { 339 - this.notifications = notifications; 340 - this.emit("setNotifications", notifications); 341 - } 342 - 343 - clearNotifications() { 344 - this.notifications = null; 345 - } 346 - 347 - hasNotificationCursor() { 348 - return this.notificationCursor !== null; 349 - } 350 - 351 - getNotificationCursor() { 352 - return this.notificationCursor; 353 - } 354 - 355 - setNotificationCursor(cursor) { 356 - this.notificationCursor = cursor; 357 - } 358 - 359 - clearNotificationCursor() { 360 - this.notificationCursor = null; 361 - } 362 - 363 - getMentionNotifications() { 364 - return this.mentionNotifications; 365 - } 366 - 367 - setMentionNotifications(notifications) { 368 - this.mentionNotifications = notifications; 369 - } 370 - 371 - clearMentionNotifications() { 372 - this.mentionNotifications = null; 373 - } 374 - 375 - getMentionNotificationCursor() { 376 - return this.mentionNotificationCursor; 377 - } 378 - 379 - setMentionNotificationCursor(cursor) { 380 - this.mentionNotificationCursor = cursor; 381 - } 382 - 383 - hasConvoList() { 384 - return this.convoList !== null; 385 - } 386 - 387 - getConvoList() { 388 - return this.convoList; 389 - } 390 - 391 - setConvoList(convos) { 392 - this.convoList = convos; 393 - } 394 - 395 - clearConvoList() { 396 - this.convoList = null; 397 - } 398 - 399 - hasConvoListCursor() { 400 - return this.convoListCursor !== null; 401 - } 402 - 403 - getConvoListCursor() { 404 - return this.convoListCursor; 405 - } 406 - 407 - setConvoListCursor(cursor) { 408 - this.convoListCursor = cursor; 409 - } 410 - 411 - clearConvoListCursor() { 412 - this.convoListCursor = null; 413 - } 414 - 415 - hasConvo(convoId) { 416 - return this.convos.has(convoId); 417 - } 418 - 419 - getConvo(convoId) { 420 - return this.convos.get(convoId); 421 - } 422 - 423 - setConvo(convoId, convo) { 424 - this.convos.set(convoId, convo); 425 - } 426 - 427 - clearConvo(convoId) { 428 - this.convos.delete(convoId); 429 - } 430 - 431 - getAllConvos() { 432 - return Array.from(this.convos.values()); 433 - } 434 - 435 - hasConvoMessages(convoId) { 436 - return this.convoMessages.has(convoId); 437 - } 438 - 439 - getConvoMessages(convoId) { 440 - return this.convoMessages.get(convoId) ?? null; 441 - } 442 - 443 - setConvoMessages(convoId, messages) { 444 - this.convoMessages.set(convoId, messages); 445 - } 446 - 447 - clearConvoMessages(convoId) { 448 - this.convoMessages.delete(convoId); 449 - } 450 - 451 - hasMessage(messageId) { 452 - return this.messages.has(messageId); 453 - } 454 - 455 - getMessage(messageId) { 456 - return this.messages.get(messageId); 457 - } 458 - 459 - setMessage(messageId, message) { 460 - this.messages.set(messageId, message); 461 - } 462 - 463 - clearMessage(messageId) { 464 - this.messages.delete(messageId); 465 - } 466 - 467 - hasPostLikes(postUri) { 468 - return this.postLikes.has(postUri); 469 - } 470 - 471 - getPostLikes(postUri) { 472 - return this.postLikes.get(postUri); 473 - } 474 - 475 - setPostLikes(postUri, likes) { 476 - this.postLikes.set(postUri, likes); 477 - } 478 - 479 - clearPostLikes(postUri) { 480 - this.postLikes.delete(postUri); 481 - } 482 - 483 - hasPostQuotes(postUri) { 484 - return this.postQuotes.has(postUri); 485 - } 486 - 487 - getPostQuotes(postUri) { 488 - return this.postQuotes.get(postUri); 489 - } 490 - 491 - setPostQuotes(postUri, quotes) { 492 - this.postQuotes.set(postUri, quotes); 493 - } 494 - 495 - clearPostQuotes(postUri) { 496 - this.postQuotes.delete(postUri); 497 - } 498 - 499 - hasPostReposts(postUri) { 500 - return this.postReposts.has(postUri); 501 - } 502 - 503 - getPostReposts(postUri) { 504 - return this.postReposts.get(postUri); 505 - } 506 - 507 - setPostReposts(postUri, reposts) { 508 - this.postReposts.set(postUri, reposts); 509 - } 510 - 511 - clearPostReposts(postUri) { 512 - this.postReposts.delete(postUri); 513 - } 514 - 515 - hasFeedGenerator(feedUri) { 516 - return this.feedGenerators.has(feedUri); 517 - } 518 - 519 - getFeedGenerator(feedUri) { 520 - return this.feedGenerators.get(feedUri); 521 - } 522 - 523 - setFeedGenerator(feedUri, feedGenerator) { 524 - this.feedGenerators.set(feedUri, feedGenerator); 525 - this.emit("setFeedGenerator", feedGenerator); 526 - } 527 - 528 - clearFeedGenerator(feedUri) { 529 - this.feedGenerators.delete(feedUri); 530 - } 531 - 532 - hasActorFeeds(did) { 533 - return this.actorFeeds.has(did); 534 - } 535 - 536 - getActorFeeds(did) { 537 - return this.actorFeeds.get(did); 538 - } 539 - 540 - setActorFeeds(did, actorFeeds) { 541 - this.actorFeeds.set(did, actorFeeds); 542 - } 543 - 544 - clearActorFeeds(did) { 545 - this.actorFeeds.delete(did); 546 - } 547 - 548 - hasHashtagFeed(hashtagKey) { 549 - return this.hashtagFeeds.has(hashtagKey); 550 - } 551 - 552 - getHashtagFeed(hashtagKey) { 553 - return this.hashtagFeeds.get(hashtagKey); 554 - } 555 - 556 - setHashtagFeed(hashtagKey, feed) { 557 - this.hashtagFeeds.set(hashtagKey, feed); 558 - } 559 - 560 - clearHashtagFeed(hashtagKey) { 561 - this.hashtagFeeds.delete(hashtagKey); 562 - } 563 - 564 - hasPinnedFeedGenerators() { 565 - return this.pinnedFeedGenerators !== null; 566 - } 567 - 568 - getPinnedFeedGenerators() { 569 - return this.pinnedFeedGenerators; 570 - } 571 - 572 - setPinnedFeedGenerators(pinnedFeedGenerators) { 573 - this.pinnedFeedGenerators = pinnedFeedGenerators; 574 - } 575 - 576 - clearPinnedFeedGenerators() { 577 - this.pinnedFeedGenerators = null; 578 - } 579 - 580 - hasBookmarks() { 581 - return this.bookmarks !== null; 582 - } 583 - 584 - getBookmarks() { 585 - return this.bookmarks; 586 - } 587 - 588 - setBookmarks(bookmarks) { 589 - this.bookmarks = bookmarks; 590 - } 591 - 592 - clearBookmarks() { 593 - this.bookmarks = null; 594 - } 595 - 596 - hasBlockedProfiles() { 597 - return this.blockedProfiles !== null; 598 - } 599 - 600 - getBlockedProfiles() { 601 - return this.blockedProfiles; 602 - } 603 - 604 - setBlockedProfiles(value) { 605 - this.blockedProfiles = value; 606 - } 607 - 608 - clearBlockedProfiles() { 609 - this.blockedProfiles = null; 610 - } 611 - 612 - hasMutedProfiles() { 613 - return this.mutedProfiles !== null; 614 - } 615 - 616 - getMutedProfiles() { 617 - return this.mutedProfiles; 618 - } 619 - 620 - setMutedProfiles(value) { 621 - this.mutedProfiles = value; 622 - } 623 - 624 - clearMutedProfiles() { 625 - this.mutedProfiles = null; 626 - } 627 - 628 - hasProfileFollowers(profileDid) { 629 - return this.profileFollowers.has(profileDid); 630 - } 631 - 632 - getProfileFollowers(profileDid) { 633 - return this.profileFollowers.get(profileDid); 634 - } 635 - 636 - setProfileFollowers(profileDid, followers) { 637 - this.profileFollowers.set(profileDid, followers); 638 - } 639 - 640 - clearProfileFollowers(profileDid) { 641 - this.profileFollowers.delete(profileDid); 642 - } 643 - 644 - hasProfileFollows(profileDid) { 645 - return this.profileFollows.has(profileDid); 646 - } 647 - 648 - getProfileFollows(profileDid) { 649 - return this.profileFollows.get(profileDid); 650 - } 651 - 652 - setProfileFollows(profileDid, follows) { 653 - this.profileFollows.set(profileDid, follows); 654 - } 655 - 656 - clearProfileFollows(profileDid) { 657 - this.profileFollows.delete(profileDid); 658 - } 659 - 660 - hasProfileChatStatus(profileDid) { 661 - return this.profileChatStatus.has(profileDid); 662 - } 663 - 664 - getProfileChatStatus(profileDid) { 665 - return this.profileChatStatus.get(profileDid); 666 - } 667 - 668 - setProfileChatStatus(profileDid, chatStatus) { 669 - this.profileChatStatus.set(profileDid, chatStatus); 670 - } 671 - 672 - clearProfileChatStatus(profileDid) { 673 - this.profileChatStatus.delete(profileDid); 674 - } 675 - 676 - hasLabelerInfo(labelerDid) { 677 - return this.labelerInfo.has(labelerDid); 678 - } 679 - 680 - getLabelerInfo(labelerDid) { 681 - return this.labelerInfo.get(labelerDid); 682 - } 683 - 684 - setLabelerInfo(labelerDid, info) { 685 - this.labelerInfo.set(labelerDid, info); 686 - } 687 - 688 - clearLabelerInfo(labelerDid) { 689 - this.labelerInfo.delete(labelerDid); 690 - } 691 - 692 - hasPluginFilteredFeedItems(feedURI) { 693 - return this.pluginFilteredFeedItems.has(feedURI); 694 - } 695 - 696 - getPluginFilteredFeedItems(feedURI) { 697 - return this.pluginFilteredFeedItems.get(feedURI); 698 - } 699 - 700 - setPluginFilteredFeedItems(feedURI, info) { 701 - this.pluginFilteredFeedItems.set(feedURI, info); 702 - } 703 - 704 - clearPluginFilteredFeedItems(feedURI) { 705 - this.pluginFilteredFeedItems.delete(feedURI); 53 + for (const post of posts) { 54 + this.$posts.set(post.uri, post); 55 + const quotedPost = getQuotedPost(post); 56 + if ( 57 + quotedPost?.$type === "app.bsky.embed.record#viewRecord" && 58 + this.$posts.get(quotedPost.uri).get() == null 59 + ) { 60 + this.$posts.set(quotedPost.uri, embedViewRecordToPostView(quotedPost)); 61 + } 62 + } 706 63 } 707 64 }
+27 -25
src/js/dataLayer/declarative.js
··· 1 1 export class Declarative { 2 - constructor(selectors, requests, base) { 3 - this.selectors = selectors; 2 + constructor(signals, requests) { 3 + this.signals = signals; 4 4 this.requests = requests; 5 - this.base = base; 6 5 } 7 6 async ensureCurrentUser() { 8 - let currentUser = this.selectors.getCurrentUser(); 7 + let currentUser = this.signals.$currentUser.get(); 9 8 if (!currentUser) { 10 9 await this.requests.loadCurrentUser(); 11 - currentUser = this.selectors.getCurrentUser(); 10 + currentUser = this.signals.$currentUser.get(); 12 11 } 13 12 if (!currentUser) { 14 13 throw new Error("Current user not found"); ··· 17 16 } 18 17 19 18 async ensureProfile(profileDid) { 20 - let profile = this.base.getProfile(profileDid); 19 + const getProfile = (did) => this.signals.$hydratedProfiles.get(did).get(); 20 + let profile = getProfile(profileDid); 21 21 if (!profile) { 22 22 await this.requests.loadProfile(profileDid); 23 - profile = this.base.getProfile(profileDid); 23 + profile = getProfile(profileDid); 24 24 } 25 25 if (!profile) { 26 26 throw new Error("Profile not found"); ··· 29 29 } 30 30 31 31 async ensureProfiles(profileDids) { 32 - const missing = profileDids.filter((did) => !this.base.getProfile(did)); 32 + const getProfile = (did) => this.signals.$hydratedProfiles.get(did).get(); 33 + const missing = profileDids.filter((did) => !getProfile(did)); 33 34 if (missing.length > 0) { 34 35 await this.requests.loadProfiles(missing); 35 36 } 36 - return profileDids.map((did) => this.base.getProfile(did) ?? null); 37 + return profileDids.map((did) => getProfile(did) ?? null); 37 38 } 38 39 39 40 async ensurePostThread(postURI, { labelers = [] } = {}) { 40 - let postThread = this.selectors.getPostThread(postURI); 41 + let postThread = this.signals.$hydratedPostThreads.get(postURI).get(); 41 42 if (!postThread) { 42 43 await this.requests.loadPostThread(postURI, { labelers }); 43 - postThread = this.selectors.getPostThread(postURI); 44 + postThread = this.signals.$hydratedPostThreads.get(postURI).get(); 44 45 } 45 46 if (!postThread) { 46 47 throw new Error("Post thread not found"); ··· 49 50 } 50 51 51 52 async ensurePost(postURI) { 52 - let post = this.selectors.getPost(postURI); 53 + let post = this.signals.$hydratedPosts.get(postURI).get(); 53 54 if (!post) { 54 55 await this.requests.loadPost(postURI); 55 - post = this.selectors.getPost(postURI); 56 + post = this.signals.$hydratedPosts.get(postURI).get(); 56 57 } 57 58 if (!post) { 58 59 throw new Error("Post not found"); ··· 61 62 } 62 63 63 64 async ensurePosts(postURIs) { 64 - const missing = postURIs.filter((uri) => !this.selectors.getPost(uri)); 65 + const getPost = (uri) => this.signals.$hydratedPosts.get(uri).get(); 66 + const missing = postURIs.filter((uri) => !getPost(uri)); 65 67 if (missing.length > 0) { 66 68 await this.requests.loadPosts(missing); 67 69 } 68 - return this.selectors.getPosts(postURIs); 70 + return postURIs.map((uri) => getPost(uri)); 69 71 } 70 72 71 73 async ensureFeedGenerator(feedUri) { 72 - let feedGenerator = this.selectors.getFeedGenerator(feedUri); 74 + let feedGenerator = this.signals.$feedGenerators.get(feedUri).get(); 73 75 if (!feedGenerator) { 74 76 await this.requests.loadFeedGenerator(feedUri); 75 - feedGenerator = this.selectors.getFeedGenerator(feedUri); 77 + feedGenerator = this.signals.$feedGenerators.get(feedUri).get(); 76 78 } 77 79 if (!feedGenerator) { 78 80 throw new Error("Feed generator not found"); ··· 81 83 } 82 84 83 85 async ensurePinnedFeedGenerators() { 84 - let pinnedFeedGenerators = this.selectors.getPinnedFeedGenerators(); 86 + let pinnedFeedGenerators = this.signals.$hydratedPinnedFeedGenerators.get(); 85 87 if (!pinnedFeedGenerators) { 86 88 await this.requests.loadPinnedFeedGenerators(); 87 - pinnedFeedGenerators = this.selectors.getPinnedFeedGenerators(); 89 + pinnedFeedGenerators = this.signals.$hydratedPinnedFeedGenerators.get(); 88 90 } 89 91 if (!pinnedFeedGenerators) { 90 92 throw new Error("Pinned feed generators not found"); ··· 93 95 } 94 96 95 97 async ensureConvoList() { 96 - let convoList = this.selectors.getConvoList(); 98 + let convoList = this.signals.$convoList.get(); 97 99 if (!convoList) { 98 100 await this.requests.loadConvoList(); 99 - convoList = this.selectors.getConvoList(); 101 + convoList = this.signals.$convoList.get(); 100 102 } 101 103 if (!convoList) { 102 104 throw new Error("Conversation list not found"); ··· 105 107 } 106 108 107 109 async ensureConvo(convoId) { 108 - let convo = this.selectors.getConvo(convoId); 110 + let convo = this.signals.$convos.get(convoId).get(); 109 111 if (!convo) { 110 112 await this.requests.loadConvo(convoId); 111 - convo = this.selectors.getConvo(convoId); 113 + convo = this.signals.$convos.get(convoId).get(); 112 114 } 113 115 if (!convo) { 114 116 throw new Error("Conversation not found"); ··· 117 119 } 118 120 119 121 async ensureConvoForProfile(profileDid) { 120 - let convo = this.selectors.getConvoForProfile(profileDid); 122 + let convo = this.signals.$convoForProfile.get(profileDid).get(); 121 123 if (!convo) { 122 124 await this.requests.loadConvoForProfile(profileDid); 123 - convo = this.selectors.getConvoForProfile(profileDid); 125 + convo = this.signals.$convoForProfile.get(profileDid).get(); 124 126 } 125 127 if (!convo) { 126 128 throw new Error("Conversation not found");
-141
src/js/dataLayer/derived.js
··· 1 - // Pure derivations over already-selected data. No closed-over state, 2 - // dependencies are explicit in each function's signature so consumers 3 - // can enumerate what events to subscribe to. Each function returns a 4 - // new post object and does not mutate its input. 5 - 6 - import { 7 - getQuotedPost, 8 - getBlockedQuote, 9 - isBlockingUser, 10 - replaceBlockedQuote, 11 - createEmbedFromPost, 12 - markBlockedQuoteNotFound, 13 - } from "/js/dataHelpers.js"; 14 - import { deepClone } from "/js/utils.js"; 15 - 16 - export function markMutedWords(post, preferences) { 17 - const result = deepClone(post); 18 - applyMutedWordsInPlace(result, preferences); 19 - return result; 20 - } 21 - 22 - export function markIsHidden(post, preferences) { 23 - const result = deepClone(post); 24 - applyIsHiddenInPlace(result, preferences); 25 - return result; 26 - } 27 - 28 - export function addLabels(post, preferences) { 29 - const result = deepClone(post); 30 - applyLabelsInPlace(result, preferences); 31 - return result; 32 - } 33 - 34 - export function resolveBlockedQuote(post, { getPost }) { 35 - const blockedQuote = getBlockedQuote(post); 36 - if (!blockedQuote || isBlockingUser(blockedQuote)) return post; 37 - const fullBlockedPost = getPost(blockedQuote.uri); 38 - if (fullBlockedPost) { 39 - const blockedQuoteEmbed = createEmbedFromPost(fullBlockedPost); 40 - return replaceBlockedQuote(post, blockedQuoteEmbed); 41 - } 42 - return markBlockedQuoteNotFound(post, blockedQuote.uri); 43 - } 44 - 45 - // Composes the per-post hydrations a view typically wants. 46 - // Explicit inputs: the post itself, current preferences, and a getPost 47 - // lookup for resolving blocked-quote references. Each input maps to a 48 - // distinct subscription a sub-root consumer would need. Returns a new 49 - // post object; never mutates the input. 50 - export function hydratePostForView(post, { preferences, getPost }) { 51 - if (!post) return null; 52 - const resolved = resolveBlockedQuote(post, { getPost }); 53 - // Clone once and run the in-place markers against our own copy. 54 - const result = resolved === post ? deepClone(post) : deepClone(resolved); 55 - applyMutedWordsInPlace(result, preferences); 56 - applyIsHiddenInPlace(result, preferences); 57 - applyLabelsInPlace(result, preferences); 58 - return result; 59 - } 60 - 61 - function applyMutedWordsInPlace(post, preferences) { 62 - if (preferences.postHasMutedWord(post)) { 63 - if (!post.viewer) post.viewer = {}; 64 - post.viewer.hasMutedWord = true; 65 - } 66 - const quotedPost = getQuotedPost(post); 67 - if (quotedPost) { 68 - if (preferences.quotedPostHasMutedWord(quotedPost)) { 69 - quotedPost.hasMutedWord = true; 70 - } 71 - const nestedQuotedPost = getQuotedPost(quotedPost); 72 - if ( 73 - nestedQuotedPost && 74 - preferences.quotedPostHasMutedWord(nestedQuotedPost) 75 - ) { 76 - nestedQuotedPost.hasMutedWord = true; 77 - } 78 - } 79 - } 80 - 81 - function applyIsHiddenInPlace(post, preferences) { 82 - if (preferences.isPostHidden(post.uri)) { 83 - if (!post.viewer) post.viewer = {}; 84 - post.viewer.isHidden = true; 85 - } 86 - const quotedPost = getQuotedPost(post); 87 - if (quotedPost) { 88 - if (preferences.isPostHidden(quotedPost.uri)) { 89 - quotedPost.isHidden = true; 90 - } 91 - const nestedQuotedPost = getQuotedPost(quotedPost); 92 - if (nestedQuotedPost && preferences.isPostHidden(nestedQuotedPost.uri)) { 93 - nestedQuotedPost.isHidden = true; 94 - } 95 - } 96 - } 97 - 98 - function applyLabelsInPlace(post, preferences) { 99 - const badgeLabels = preferences.getBadgeLabels(post); 100 - if (badgeLabels.length > 0) { 101 - post.badgeLabels = badgeLabels; 102 - } 103 - const contentLabel = preferences.getContentLabel(post); 104 - if (contentLabel) { 105 - post.contentLabel = contentLabel; 106 - } 107 - const mediaLabel = preferences.getMediaLabel(post); 108 - if (mediaLabel) { 109 - post.mediaLabel = mediaLabel; 110 - } 111 - const quotedPost = getQuotedPost(post); 112 - if (quotedPost) { 113 - const quotedBadgeLabels = preferences.getBadgeLabels(quotedPost); 114 - if (quotedBadgeLabels.length > 0) { 115 - quotedPost.badgeLabels = quotedBadgeLabels; 116 - } 117 - const quotedContentLabel = preferences.getContentLabel(quotedPost); 118 - if (quotedContentLabel) { 119 - quotedPost.contentLabel = quotedContentLabel; 120 - } 121 - const quotedMediaLabel = preferences.getMediaLabel(quotedPost); 122 - if (quotedMediaLabel) { 123 - quotedPost.mediaLabel = quotedMediaLabel; 124 - } 125 - const nestedQuotedPost = getQuotedPost(quotedPost); 126 - if (nestedQuotedPost) { 127 - const nestedBadgeLabels = preferences.getBadgeLabels(nestedQuotedPost); 128 - if (nestedBadgeLabels.length > 0) { 129 - nestedQuotedPost.badgeLabels = nestedBadgeLabels; 130 - } 131 - const nestedContentLabel = preferences.getContentLabel(nestedQuotedPost); 132 - if (nestedContentLabel) { 133 - nestedQuotedPost.contentLabel = nestedContentLabel; 134 - } 135 - const nestedMediaLabel = preferences.getMediaLabel(nestedQuotedPost); 136 - if (nestedMediaLabel) { 137 - nestedQuotedPost.mediaLabel = nestedMediaLabel; 138 - } 139 - } 140 - } 141 - }
+88 -78
src/js/dataLayer/mutations.js
··· 26 26 try { 27 27 const like = await this.api.createLikeRecord(post); 28 28 // update post in store 29 - this.dataStore.setPost(post.uri, { 29 + this.dataStore.$posts.set(post.uri, { 30 30 ...post, 31 31 viewer: { ...post.viewer, like: like.uri }, 32 32 likeCount: post.likeCount + 1, 33 33 }); 34 34 // If the "likes" feed is loaded, add the post to it. 35 - const currentUser = this.dataStore.getCurrentUser(); 35 + const currentUser = this.dataStore.$currentUser.get(); 36 36 if (currentUser) { 37 37 const feedURI = `${currentUser.did}-likes`; 38 - const likedFeed = this.dataStore.getAuthorFeed(feedURI); 38 + const likedFeed = this.dataStore.$authorFeeds.get(feedURI).get(); 39 39 if (likedFeed) { 40 - this.dataStore.setAuthorFeed(feedURI, { 40 + this.dataStore.$authorFeeds.set(feedURI, { 41 41 feed: [{ post: post }, ...likedFeed.feed], 42 42 cursor: likedFeed.cursor, 43 43 }); ··· 60 60 try { 61 61 await this.api.deleteLikeRecord(post); 62 62 // update post in store 63 - this.dataStore.setPost(post.uri, { 63 + this.dataStore.$posts.set(post.uri, { 64 64 ...post, 65 65 viewer: { ...post.viewer, like: null }, 66 66 likeCount: post.likeCount - 1, ··· 80 80 }); 81 81 try { 82 82 const repost = await this.api.createRepostRecord(post); 83 - this.dataStore.setPost(post.uri, { 83 + this.dataStore.$posts.set(post.uri, { 84 84 ...post, 85 85 viewer: { ...post.viewer, repost: repost.uri }, 86 86 repostCount: post.repostCount + 1, 87 87 }); 88 88 // If the current user's author feed is loaded, add the repost to it. 89 - const currentUser = this.dataStore.getCurrentUser(); 89 + const currentUser = this.dataStore.$currentUser.get(); 90 90 if (currentUser) { 91 91 const authorFeedURI = `${currentUser.did}-posts`; 92 - const authorFeed = this.dataStore.getAuthorFeed(authorFeedURI); 92 + const authorFeed = this.dataStore.$authorFeeds.get(authorFeedURI).get(); 93 93 if (authorFeed) { 94 94 const newFeedItem = { 95 95 post: post, ··· 101 101 indexedAt: new Date().toISOString(), 102 102 }, 103 103 }; 104 - this.dataStore.setAuthorFeed(authorFeedURI, { 104 + this.dataStore.$authorFeeds.set(authorFeedURI, { 105 105 feed: addFeedItemToFeed(newFeedItem, authorFeed.feed), 106 106 cursor: authorFeed.cursor, 107 107 }); ··· 122 122 }); 123 123 try { 124 124 await this.api.deleteRepostRecord(post); 125 - this.dataStore.setPost(post.uri, { 125 + this.dataStore.$posts.set(post.uri, { 126 126 ...post, 127 127 viewer: { ...post.viewer, repost: null }, 128 128 repostCount: post.repostCount - 1, 129 129 }); 130 130 // If the current user's author feed is loaded, remove the repost from it. 131 - const currentUser = this.dataStore.getCurrentUser(); 131 + const currentUser = this.dataStore.$currentUser.get(); 132 132 if (currentUser) { 133 133 const authorFeedURI = `${currentUser.did}-posts`; 134 - const authorFeed = this.dataStore.getAuthorFeed(authorFeedURI); 134 + const authorFeed = this.dataStore.$authorFeeds.get(authorFeedURI).get(); 135 135 if (authorFeed) { 136 - this.dataStore.setAuthorFeed(authorFeedURI, { 136 + this.dataStore.$authorFeeds.set(authorFeedURI, { 137 137 feed: authorFeed.feed.filter((feedItem) => { 138 138 if ( 139 139 feedItem.reason?.$type === "app.bsky.feed.defs#reasonRepost" && ··· 164 164 try { 165 165 await this.api.createBookmark(post); 166 166 // update post in store 167 - this.dataStore.setPost(post.uri, { 167 + this.dataStore.$posts.set(post.uri, { 168 168 ...post, 169 169 viewer: { ...post.viewer, bookmarked: true }, 170 170 bookmarkCount: post.bookmarkCount + 1, 171 171 }); 172 172 // If the bookmarks feed is loaded, add the post to it. 173 - const bookmarks = this.dataStore.getBookmarks(); 173 + const bookmarks = this.dataStore.$bookmarks.get(); 174 174 if (bookmarks) { 175 - this.dataStore.setBookmarks({ 175 + this.dataStore.$bookmarks.set({ 176 176 feed: [{ post: { ...post } }, ...bookmarks.feed], 177 177 cursor: bookmarks.cursor, 178 178 }); ··· 194 194 try { 195 195 await this.api.deleteBookmark(post); 196 196 // update post in store 197 - this.dataStore.setPost(post.uri, { 197 + this.dataStore.$posts.set(post.uri, { 198 198 ...post, 199 199 viewer: { ...post.viewer, bookmarked: false }, 200 200 bookmarkCount: post.bookmarkCount - 1, 201 201 }); 202 202 // If the bookmarks feed is loaded, remove the post from it. 203 - const bookmarks = this.dataStore.getBookmarks(); 203 + const bookmarks = this.dataStore.$bookmarks.get(); 204 204 if (bookmarks) { 205 - this.dataStore.setBookmarks({ 205 + this.dataStore.$bookmarks.set({ 206 206 feed: bookmarks.feed.filter((item) => item.post?.uri !== post.uri), 207 207 cursor: bookmarks.cursor, 208 208 }); ··· 223 223 try { 224 224 const follow = await this.api.createFollowRecord(profile); 225 225 // todo update followers count 226 - this.dataStore.setProfile(profile.did, { 226 + this.dataStore.$profiles.set(profile.did, { 227 227 ...profile, 228 228 followersCount: profile.followersCount + 1, 229 229 viewer: { ...profile.viewer, following: follow.uri }, ··· 243 243 }); 244 244 try { 245 245 await this.api.deleteFollowRecord(profile); 246 - this.dataStore.setProfile(profile.did, { 246 + this.dataStore.$profiles.set(profile.did, { 247 247 ...profile, 248 248 followersCount: profile.followersCount - 1, 249 249 viewer: { ...profile.viewer, following: null }, ··· 263 263 event: "app.bsky.feed.defs#requestLess", 264 264 feedContext, 265 265 }; 266 - this.dataStore.addShowLessInteraction(showLessInteraction); 266 + this.dataStore.$showLessInteractions.set([ 267 + ...this.dataStore.$showLessInteractions.get(), 268 + showLessInteraction, 269 + ]); 267 270 try { 268 271 await this.api.sendInteractions([showLessInteraction], feedProxyUrl); 269 272 } catch (error) { ··· 280 283 }; 281 284 // Note, we don't really need to store this interaction because we don't use it in the UI (yet). 282 285 // But, let's do it anyway for consistency. 283 - this.dataStore.addShowMoreInteraction(showMoreInteraction); 286 + this.dataStore.$showMoreInteractions.set([ 287 + ...this.dataStore.$showMoreInteractions.get(), 288 + showMoreInteraction, 289 + ]); 284 290 try { 285 291 await this.api.sendInteractions([showMoreInteraction], feedProxyUrl); 286 292 } catch (error) { ··· 433 439 }); 434 440 try { 435 441 await this.api.muteActor(profile.did); 436 - this.dataStore.setProfile(profile.did, { 442 + this.dataStore.$profiles.set(profile.did, { 437 443 ...profile, 438 444 viewer: { ...profile.viewer, muted: true }, 439 445 }); ··· 446 452 }, 447 453 }; 448 454 }); 449 - const mutedProfiles = this.dataStore.getMutedProfiles(); 455 + const mutedProfiles = this.dataStore.$mutedProfiles.get(); 450 456 if (mutedProfiles) { 451 457 const alreadyListed = mutedProfiles.mutes.some( 452 458 (muted) => muted.did === profile.did, 453 459 ); 454 460 if (!alreadyListed) { 455 - this.dataStore.setMutedProfiles({ 461 + this.dataStore.$mutedProfiles.set({ 456 462 ...mutedProfiles, 457 463 mutes: [ 458 464 { ··· 478 484 }); 479 485 try { 480 486 await this.api.unmuteActor(profile.did); 481 - this.dataStore.setProfile(profile.did, { 487 + this.dataStore.$profiles.set(profile.did, { 482 488 ...profile, 483 489 viewer: { ...profile.viewer, muted: false }, 484 490 }); ··· 491 497 }, 492 498 }; 493 499 }); 494 - const mutedProfiles = this.dataStore.getMutedProfiles(); 500 + const mutedProfiles = this.dataStore.$mutedProfiles.get(); 495 501 if (mutedProfiles) { 496 - this.dataStore.setMutedProfiles({ 502 + this.dataStore.$mutedProfiles.set({ 497 503 ...mutedProfiles, 498 504 mutes: mutedProfiles.mutes.filter( 499 505 (muted) => muted.did !== profile.did, ··· 514 520 }); 515 521 try { 516 522 const block = await this.api.blockActor(profile); 517 - this.dataStore.setProfile(profile.did, { 523 + this.dataStore.$profiles.set(profile.did, { 518 524 ...profile, 519 525 viewer: { ...profile.viewer, blocking: block.uri }, 520 526 }); ··· 527 533 }, 528 534 }; 529 535 }); 530 - const blockedProfiles = this.dataStore.getBlockedProfiles(); 536 + const blockedProfiles = this.dataStore.$blockedProfiles.get(); 531 537 if (blockedProfiles) { 532 538 const alreadyListed = blockedProfiles.blocks.some( 533 539 (blocked) => blocked.did === profile.did, 534 540 ); 535 541 if (!alreadyListed) { 536 - this.dataStore.setBlockedProfiles({ 542 + this.dataStore.$blockedProfiles.set({ 537 543 ...blockedProfiles, 538 544 blocks: [ 539 545 { ··· 560 566 }); 561 567 try { 562 568 await this.api.putActivitySubscription(profile.did, activitySubscription); 563 - this.dataStore.setProfile(profile.did, { 569 + this.dataStore.$profiles.set(profile.did, { 564 570 ...profile, 565 571 viewer: { ...profile.viewer, activitySubscription }, 566 572 }); ··· 578 584 }); 579 585 try { 580 586 await this.api.unblockActor(profile); 581 - this.dataStore.setProfile(profile.did, { 587 + this.dataStore.$profiles.set(profile.did, { 582 588 ...profile, 583 589 viewer: { ...profile.viewer, blocking: null }, 584 590 }); ··· 591 597 }, 592 598 }; 593 599 }); 594 - const blockedProfiles = this.dataStore.getBlockedProfiles(); 600 + const blockedProfiles = this.dataStore.$blockedProfiles.get(); 595 601 if (blockedProfiles) { 596 - this.dataStore.setBlockedProfiles({ 602 + this.dataStore.$blockedProfiles.set({ 597 603 ...blockedProfiles, 598 604 blocks: blockedProfiles.blocks.filter( 599 605 (blocked) => blocked.did !== profile.did, ··· 662 668 const labelers = preferences.getLabelerDids(); 663 669 // Fetch full profile to get updated image urls 664 670 const updatedProfile = await this.api.getProfile(profile.did, { labelers }); 665 - this.dataStore.setProfile(updatedProfile.did, updatedProfile); 666 - const currentUser = this.dataStore.getCurrentUser(); 671 + this.dataStore.$profiles.set(updatedProfile.did, updatedProfile); 672 + const currentUser = this.dataStore.$currentUser.get(); 667 673 if (currentUser && currentUser.did === updatedProfile.did) { 668 - this.dataStore.setCurrentUser({ 674 + this.dataStore.$currentUser.set({ 669 675 ...currentUser, 670 676 ...updatedProfile, 671 677 }); ··· 673 679 } 674 680 675 681 async pinPost(post) { 676 - const currentUser = this.dataStore.getCurrentUser(); 682 + const currentUser = this.dataStore.$currentUser.get(); 677 683 if (!currentUser) throw new Error("No current user"); 678 684 const authorFeedURI = `${currentUser.did}-posts`; 679 685 const pinnedRef = { uri: post.uri, cid: post.cid }; ··· 697 703 swapCid, 698 704 ); 699 705 // Commit to dataStore 700 - const latestUser = this.dataStore.getCurrentUser(); 706 + const latestUser = this.dataStore.$currentUser.get(); 701 707 if (latestUser) { 702 - this.dataStore.setCurrentUser({ ...latestUser, pinnedPost: pinnedRef }); 708 + this.dataStore.$currentUser.set({ 709 + ...latestUser, 710 + pinnedPost: pinnedRef, 711 + }); 703 712 } 704 - const existingFeed = this.dataStore.getAuthorFeed(authorFeedURI); 713 + const existingFeed = this.dataStore.$authorFeeds.get(authorFeedURI).get(); 705 714 if (existingFeed) { 706 - this.dataStore.setAuthorFeed(authorFeedURI, { 715 + this.dataStore.$authorFeeds.set(authorFeedURI, { 707 716 feed: pinPostInFeed(existingFeed.feed, post), 708 717 cursor: existingFeed.cursor, 709 718 }); ··· 715 724 } 716 725 717 726 async unpinPost(post) { 718 - const currentUser = this.dataStore.getCurrentUser(); 727 + const currentUser = this.dataStore.$currentUser.get(); 719 728 if (!currentUser) throw new Error("No current user"); 720 729 if (currentUser.pinnedPost?.uri !== post.uri) { 721 730 // Already unpinned (or a different post is pinned); nothing to do. ··· 738 747 const { pinnedPost: _, ...updatedRecord } = existingRecord; 739 748 await this.api.putProfileRecord(updatedRecord, swapCid); 740 749 // Commit to dataStore 741 - const latestUser = this.dataStore.getCurrentUser(); 750 + const latestUser = this.dataStore.$currentUser.get(); 742 751 if (latestUser) { 743 752 const { pinnedPost: _, ...rest } = latestUser; 744 - this.dataStore.setCurrentUser(rest); 753 + this.dataStore.$currentUser.set(rest); 745 754 } 746 - const existingFeed = this.dataStore.getAuthorFeed(authorFeedURI); 755 + const existingFeed = this.dataStore.$authorFeeds.get(authorFeedURI).get(); 747 756 if (existingFeed) { 748 - this.dataStore.setAuthorFeed(authorFeedURI, { 757 + this.dataStore.$authorFeeds.set(authorFeedURI, { 749 758 feed: unpinPostInFeed(existingFeed.feed, post), 750 759 cursor: existingFeed.cursor, 751 760 }); ··· 779 788 // NOTE: LEXICON DEVIATION 780 789 post.viewer.priorityReply = true; 781 790 // Update the post in the store 782 - this.dataStore.setPost(post.uri, post); 791 + this.dataStore.$posts.set(post.uri, post); 783 792 // If it's a reply, update the reply post thread in the store 784 793 if (replyTo) { 785 - const replyPostThread = this.dataStore.getPostThread(replyTo.uri); 794 + const replyPostThread = this.dataStore.$postThreads 795 + .get(replyTo.uri) 796 + .get(); 786 797 if (replyPostThread) { 787 - this.dataStore.setPostThread(replyTo.uri, { 798 + this.dataStore.$postThreads.set(replyTo.uri, { 788 799 ...replyPostThread, 789 800 replies: [ 790 801 { ··· 800 811 // If the author feed is loaded, add the new post to it 801 812 const { repo: did } = parseUri(post.uri); 802 813 const authorFeedURI = replyTo ? `${did}-replies` : `${did}-posts`; // TODO - handle media tab too? 803 - const authorFeed = this.dataStore.getAuthorFeed(authorFeedURI); 814 + const authorFeed = this.dataStore.$authorFeeds.get(authorFeedURI).get(); 804 815 if (authorFeed) { 805 - this.dataStore.setAuthorFeed(authorFeedURI, { 816 + this.dataStore.$authorFeeds.set(authorFeedURI, { 806 817 feed: addFeedItemToFeed({ post }, authorFeed.feed), 807 818 cursor: authorFeed.cursor, 808 819 }); ··· 815 826 await this.api.deletePost(post); 816 827 // Replace the post with a not found post. 817 828 // This *should* remove the post from all relevant places in the UI. 818 - this.dataStore.setPost(post.uri, createNotFoundPost(post.uri)); 829 + this.dataStore.$posts.set(post.uri, createNotFoundPost(post.uri)); 819 830 } 820 831 821 832 async createMessage(convoId, { text, facets }) { ··· 824 835 text, 825 836 facets, 826 837 }); 827 - this.dataStore.setMessage(res.id, res); 838 + this.dataStore.$messages.set(res.id, res); 828 839 // Add the new message to the chat messages array in the dataStore 829 - const convoMessages = this.dataStore.getConvoMessages(convoId); 840 + const convoMessages = this.dataStore.$convoMessages.get(convoId).get(); 830 841 if (convoMessages) { 831 - this.dataStore.setConvoMessages(convoId, { 842 + this.dataStore.$convoMessages.set(convoId, { 832 843 messages: [res, ...convoMessages.messages], 833 844 cursor: convoMessages.cursor, 834 845 }); 835 846 } 836 847 // Update the last message in the convo 837 - const convo = this.dataStore.getConvo(convoId); 848 + const convo = this.dataStore.$convos.get(convoId).get(); 838 849 if (convo) { 839 - this.dataStore.setConvo(convoId, { 850 + this.dataStore.$convos.set(convoId, { 840 851 ...convo, 841 852 lastMessage: { 842 853 $type: "chat.bsky.convo.defs#messageView", ··· 856 867 status: "accepted", 857 868 }; 858 869 859 - this.dataStore.setConvo(convo.id, updatedConvo); 870 + this.dataStore.$convos.set(convo.id, updatedConvo); 860 871 861 872 // Update the convo in the convo list 862 - const convoList = this.dataStore.getConvoList(); 873 + const convoList = this.dataStore.$convoList.get(); 863 874 if (convoList) { 864 875 const updatedList = convoList.map((c) => 865 876 c.id === convo.id ? updatedConvo : c, 866 877 ); 867 - this.dataStore.setConvoList(updatedList); 878 + this.dataStore.$convoList.set(updatedList); 868 879 } 869 880 870 881 return updatedConvo; ··· 872 883 873 884 async rejectConvo(convo) { 874 885 await this.api.leaveConvo(convo.id); 875 - this.dataStore.clearConvo(convo.id); 876 - const convoList = this.dataStore.getConvoList(); 886 + this.dataStore.$convos.set(convo.id, null); 887 + const convoList = this.dataStore.$convoList.get(); 877 888 if (convoList) { 878 889 const updatedList = convoList.filter((c) => c.id !== convo.id); 879 - this.dataStore.setConvoList(updatedList); 890 + this.dataStore.$convoList.set(updatedList); 880 891 } 881 892 } 882 893 883 894 async markConvoAsRead(convoId) { 884 895 await this.api.markConvoAsRead(convoId); 885 - const convo = this.dataStore.getConvo(convoId); 896 + const convo = this.dataStore.$convos.get(convoId).get(); 886 897 if (convo) { 887 - this.dataStore.setConvo(convoId, { 898 + this.dataStore.$convos.set(convoId, { 888 899 ...convo, 889 900 unreadCount: 0, 890 901 }); ··· 906 917 messageId, 907 918 emoji, 908 919 ); 909 - this.dataStore.setMessage(messageId, message); 920 + this.dataStore.$messages.set(messageId, message); 910 921 // Update the last reaction in the convo 911 - const convo = this.dataStore.getConvo(convoId); 922 + const convo = this.dataStore.$convos.get(convoId).get(); 912 923 if (convo) { 913 - this.dataStore.setConvo(convoId, { 924 + this.dataStore.$convos.set(convoId, { 914 925 ...convo, 915 926 lastReaction: { 916 927 $type: "chat.bsky.convo.defs#messageAndReactionView", ··· 939 950 messageId, 940 951 emoji, 941 952 ); 942 - this.dataStore.setMessage(messageId, message); 953 + this.dataStore.$messages.set(messageId, message); 943 954 // Update the last reaction in the convo 944 - const convo = this.dataStore.getConvo(convoId); 955 + const convo = this.dataStore.$convos.get(convoId).get(); 945 956 if (convo) { 946 - this.dataStore.setConvo(convoId, { 957 + this.dataStore.$convos.set(convoId, { 947 958 ...convo, 948 959 lastReaction: null, 949 960 }); ··· 957 968 } 958 969 959 970 _updatePostsByAuthor(profileDid, updateFunc) { 960 - const posts = this.dataStore.getAllPosts(); 961 - for (const post of posts) { 962 - if (post.author?.did === profileDid) { 963 - this.dataStore.setPost(post.uri, updateFunc(post)); 971 + for (const post of this.dataStore.$posts.values()) { 972 + if (post?.author?.did === profileDid) { 973 + this.dataStore.$posts.set(post.uri, updateFunc(post)); 964 974 } 965 975 } 966 976 }
+89 -73
src/js/dataLayer/patchStore.js
··· 1 1 import { deepClone, SimpleUUID } from "/js/utils.js"; 2 2 import { pinPostInFeed, unpinPostInFeed } from "/js/dataHelpers.js"; 3 + import { Signal, SignalMap, ComputedMap, ReactiveStore } from "/js/utils.js"; 3 4 4 5 // The store saves patch data for optimistic updates. 5 - export class PatchStore { 6 - constructor(eventBus = null) { 7 - this._eventBus = eventBus; 6 + export class PatchStore extends ReactiveStore { 7 + constructor(dataStore) { 8 + super("patchStore"); 9 + this.dataStore = dataStore; 8 10 this.postPatches = new Map(); 9 - this.profilePatches = new Map(); 10 - this.messagePatches = new Map(); 11 - this.preferencePatches = []; 12 - this.currentUserPatches = []; 13 - this.authorFeedPatches = new Map(); 11 + this.$postPatches = new SignalMap(); 12 + 13 + this.$patchedPosts = new ComputedMap((postURI) => { 14 + const post = this.dataStore.$posts.get(postURI).get(); 15 + if (!post) return null; 16 + const patches = this.$postPatches.get(postURI).get() || []; 17 + return this.applyPostPatches(post, patches); 18 + }); 19 + 20 + this.$profilePatches = new SignalMap(); 21 + this.$patchedProfiles = new ComputedMap((did) => { 22 + const profile = this.dataStore.$profiles.get(did).get(); 23 + if (!profile) return null; 24 + const patches = this.$profilePatches.get(did).get() || []; 25 + return this.applyProfilePatches(profile, patches); 26 + }); 27 + 28 + this.$messagePatches = new SignalMap(); 29 + this.$patchedMessages = new ComputedMap((messageId) => { 30 + const message = this.dataStore.$messages.get(messageId).get(); 31 + if (!message) return null; 32 + const patches = this.$messagePatches.get(messageId).get() || []; 33 + let patchedMessage = message; 34 + for (const patch of patches) { 35 + patchedMessage = this.applyMessagePatch(patchedMessage, patch.body); 36 + } 37 + return patchedMessage; 38 + }); 39 + this.$preferencePatches = new Signal.State([]); 40 + this.$currentUserPatches = new Signal.State([]); 41 + this.$authorFeedPatches = new SignalMap(); 14 42 this.uuid = new SimpleUUID(); 15 43 } 16 44 ··· 22 50 23 51 addPostPatch(postURI, patchBody) { 24 52 const patchId = this.uuid.create(); 25 - const postPatches = this._getPostPatches(postURI); 26 - postPatches.push({ id: patchId, body: patchBody }); 27 - this.postPatches.set(postURI, postPatches); 28 - this._eventBus?.emit(`post:${postURI}`); 53 + const patchesForPost = this.$postPatches.get(postURI); 54 + let patchArray = patchesForPost.get() || []; 55 + patchArray = [...patchArray, { id: patchId, body: patchBody }]; 56 + patchesForPost.set(patchArray); 29 57 return patchId; 30 58 } 31 59 32 60 removePostPatch(postURI, patchId) { 33 - const postPatches = this._getPostPatches(postURI); 34 - this.postPatches.set( 35 - postURI, 36 - postPatches.filter(({ id }) => id !== patchId), 37 - ); 38 - this._eventBus?.emit(`post:${postURI}`); 61 + const patchesForPost = this.$postPatches.get(postURI); 62 + let patchArray = patchesForPost.get() || []; 63 + patchArray = patchArray.filter(({ id }) => id !== patchId); 64 + patchesForPost.set(patchArray); 39 65 } 40 66 41 - applyPostPatches(post) { 42 - const postPatches = this._getPostPatches(post.uri); 67 + applyPostPatches(post, patches) { 43 68 let patchedPost = deepClone(post); 44 - for (const patch of postPatches) { 69 + for (const patch of patches) { 45 70 patchedPost = this.applyPostPatch(patchedPost, patch.body); 46 71 } 47 - // apply profile patches to the post's author 48 72 if (patchedPost.author) { 49 - const patchedAuthor = this.applyProfilePatches(patchedPost.author); 50 - patchedPost = { ...patchedPost, author: patchedAuthor }; 73 + patchedPost = { 74 + ...patchedPost, 75 + author: this.applyProfilePatches(patchedPost.author), 76 + }; 51 77 } 52 78 return patchedPost; 53 79 } ··· 124 150 /* Profile Patches */ 125 151 126 152 _getProfilePatches(profileURI) { 127 - return this.profilePatches.get(profileURI) || []; 153 + return this.$profilePatches.get(profileURI).get() || []; 128 154 } 129 155 130 156 addProfilePatch(profileURI, patchBody) { 131 157 const patchId = this.uuid.create(); 132 - const profilePatches = this._getProfilePatches(profileURI); 133 - profilePatches.push({ id: patchId, body: patchBody }); 134 - this.profilePatches.set(profileURI, profilePatches); 135 - this._eventBus?.emit(`profile:${profileURI}`); 158 + const signal = this.$profilePatches.get(profileURI); 159 + const patches = signal.get() || []; 160 + signal.set([...patches, { id: patchId, body: patchBody }]); 136 161 return patchId; 137 162 } 138 163 139 164 removeProfilePatch(profileURI, patchId) { 140 - const profilePatches = this._getProfilePatches(profileURI); 141 - this.profilePatches.set( 142 - profileURI, 143 - profilePatches.filter(({ id }) => id !== patchId), 144 - ); 145 - this._eventBus?.emit(`profile:${profileURI}`); 165 + const signal = this.$profilePatches.get(profileURI); 166 + const patches = signal.get() || []; 167 + signal.set(patches.filter(({ id }) => id !== patchId)); 146 168 } 147 169 148 - applyProfilePatches(profile) { 149 - const profilePatches = this._getProfilePatches(profile.did); 170 + applyProfilePatches(profile, patches) { 171 + const profilePatches = patches ?? this._getProfilePatches(profile.did); 150 172 let patchedProfile = deepClone(profile); 151 173 for (const patch of profilePatches) { 152 174 patchedProfile = this.applyProfilePatch(patchedProfile, patch.body); ··· 222 244 /* Message Patches */ 223 245 224 246 _getMessagePatches(messageId) { 225 - return this.messagePatches.get(messageId) || []; 247 + return this.$messagePatches.get(messageId).get() || []; 226 248 } 227 249 228 250 addMessagePatch(messageId, patchBody) { 229 251 const patchId = this.uuid.create(); 230 - const messagePatches = this._getMessagePatches(messageId); 231 - messagePatches.push({ id: patchId, body: patchBody }); 232 - this.messagePatches.set(messageId, messagePatches); 252 + this.$messagePatches.set(messageId, [ 253 + ...this._getMessagePatches(messageId), 254 + { id: patchId, body: patchBody }, 255 + ]); 233 256 return patchId; 234 257 } 235 258 236 259 removeMessagePatch(messageId, patchId) { 237 - const messagePatches = this._getMessagePatches(messageId); 238 - this.messagePatches.set( 260 + this.$messagePatches.set( 239 261 messageId, 240 - messagePatches.filter(({ id }) => id !== patchId), 262 + this._getMessagePatches(messageId).filter(({ id }) => id !== patchId), 241 263 ); 242 264 } 243 265 ··· 274 296 275 297 /* Preference Patches */ 276 298 277 - _getPreferencePatches() { 278 - return this.preferencePatches; 279 - } 280 - 281 299 addPreferencePatch(patchBody) { 282 300 const patchId = this.uuid.create(); 283 - const preferencePatches = this._getPreferencePatches(); 284 - preferencePatches.push({ id: patchId, body: patchBody }); 285 - this._eventBus?.emit("preferences:changed"); 301 + const patches = this.$preferencePatches.get(); 302 + this.$preferencePatches.set([...patches, { id: patchId, body: patchBody }]); 286 303 return patchId; 287 304 } 288 305 289 306 removePreferencePatch(patchId) { 290 - const preferencePatches = this._getPreferencePatches(); 291 - this.preferencePatches = preferencePatches.filter( 292 - ({ id }) => id !== patchId, 293 - ); 294 - this._eventBus?.emit("preferences:changed"); 307 + const patches = this.$preferencePatches.get(); 308 + this.$preferencePatches.set(patches.filter(({ id }) => id !== patchId)); 295 309 } 296 310 297 - applyPreferencePatches(preferences) { 298 - const preferencePatches = this._getPreferencePatches(); 311 + applyPreferencePatches(preferences, patches) { 312 + const preferencePatches = patches ?? this.$preferencePatches.get(); 299 313 let patchedPreferences = preferences.clone(); 300 314 for (const patch of preferencePatches) { 301 315 patchedPreferences = this.applyPreferencePatch( ··· 334 348 335 349 addCurrentUserPatch(patchBody) { 336 350 const patchId = this.uuid.create(); 337 - this.currentUserPatches.push({ id: patchId, body: patchBody }); 351 + const patches = this.$currentUserPatches.get(); 352 + this.$currentUserPatches.set([ 353 + ...patches, 354 + { id: patchId, body: patchBody }, 355 + ]); 338 356 return patchId; 339 357 } 340 358 341 359 removeCurrentUserPatch(patchId) { 342 - this.currentUserPatches = this.currentUserPatches.filter( 343 - ({ id }) => id !== patchId, 344 - ); 360 + const patches = this.$currentUserPatches.get(); 361 + this.$currentUserPatches.set(patches.filter(({ id }) => id !== patchId)); 345 362 } 346 363 347 - applyCurrentUserPatches(user) { 364 + applyCurrentUserPatches(user, patches) { 348 365 if (!user) return user; 366 + const currentUserPatches = patches ?? this.$currentUserPatches.get(); 349 367 let patched = deepClone(user); 350 - for (const patch of this.currentUserPatches) { 368 + for (const patch of currentUserPatches) { 351 369 patched = this.applyCurrentUserPatch(patched, patch.body); 352 370 } 353 371 return patched; ··· 369 387 /* Author Feed Patches */ 370 388 371 389 _getAuthorFeedPatches(feedURI) { 372 - return this.authorFeedPatches.get(feedURI) || []; 390 + return this.$authorFeedPatches.get(feedURI).get() || []; 373 391 } 374 392 375 393 addAuthorFeedPatch(feedURI, patchBody) { 376 394 const patchId = this.uuid.create(); 377 - const patches = this._getAuthorFeedPatches(feedURI); 378 - patches.push({ id: patchId, body: patchBody }); 379 - this.authorFeedPatches.set(feedURI, patches); 395 + const signal = this.$authorFeedPatches.get(feedURI); 396 + const patches = signal.get() || []; 397 + signal.set([...patches, { id: patchId, body: patchBody }]); 380 398 return patchId; 381 399 } 382 400 383 401 removeAuthorFeedPatch(feedURI, patchId) { 384 - const patches = this._getAuthorFeedPatches(feedURI); 385 - this.authorFeedPatches.set( 386 - feedURI, 387 - patches.filter(({ id }) => id !== patchId), 388 - ); 402 + const signal = this.$authorFeedPatches.get(feedURI); 403 + const patches = signal.get() || []; 404 + signal.set(patches.filter(({ id }) => id !== patchId)); 389 405 } 390 406 391 407 applyAuthorFeedPatches(feedURI, feed) {
+5 -5
src/js/dataLayer/preferencesProvider.js
··· 1 1 import { Preferences } from "/js/preferences.js"; 2 - import { EventEmitter } from "/js/eventEmitter.js"; 2 + import { Signal } from "/js/utils.js"; 3 3 4 - export class PreferencesProvider extends EventEmitter { 4 + export class PreferencesProvider { 5 5 constructor(api) { 6 - super(); 7 6 this.api = api; 8 7 this._preferences = null; 8 + this.$preferences = new Signal.State(null); 9 9 } 10 10 11 11 requirePreferences() { ··· 34 34 35 35 async savePreferences(preferences) { 36 36 await this.api.updatePreferences(preferences.obj); 37 - this._preferences = preferences; 37 + this._setPreferences(preferences); 38 38 } 39 39 40 40 _setPreferences(preferences) { 41 41 this._preferences = preferences; 42 - this.emit("setPreferences", preferences); 42 + this.$preferences.set(preferences); 43 43 } 44 44 }
+141 -125
src/js/dataLayer/requests.js
··· 11 11 parseUri, 12 12 } from "/js/dataHelpers.js"; 13 13 import { Constellation } from "/js/constellation.js"; 14 - import { unique } from "/js/utils.js"; 14 + import { unique, SignalMap, ComputedMap, ReactiveStore } from "/js/utils.js"; 15 15 import { ApiError } from "/js/api.js"; 16 16 17 17 // Get URIs of blocked quotes from posts where the author has not blocked the viewer ··· 39 39 }).map((blockedPost) => blockedPost.uri); 40 40 } 41 41 42 - class StatusStore { 42 + class StatusStore extends ReactiveStore { 43 43 constructor() { 44 - this.loadingMap = new Map(); 45 - this.errorMap = new Map(); 44 + super("statusStore"); 45 + this.$loading = new SignalMap(); 46 + this.$errors = new SignalMap(); 47 + this.$statuses = new ComputedMap((requestId) => ({ 48 + loading: this.$loading.get(requestId).get() ?? false, 49 + error: this.$errors.get(requestId).get() ?? null, 50 + })); 46 51 } 47 52 48 53 setLoading(requestId, loading) { 49 - this.loadingMap.set(requestId, loading); 54 + this.$loading.set(requestId, loading); 50 55 } 51 56 52 57 setError(requestId, error) { 53 - this.errorMap.set(requestId, error); 58 + this.$errors.set(requestId, error); 54 59 } 55 60 56 61 getLoading(requestId) { 57 - return this.loadingMap.get(requestId) ?? false; 62 + return this.$loading.get(requestId).get() ?? false; 58 63 } 59 64 60 65 getError(requestId) { 61 - return this.errorMap.get(requestId) ?? null; 66 + return this.$errors.get(requestId).get() ?? null; 62 67 } 63 68 } 64 69 ··· 142 147 async loadCurrentUser() { 143 148 const session = await this.api.getSession(); 144 149 const profile = await this.api.getProfile(session.did); 145 - this.dataStore.setCurrentUser(profile); 150 + this.dataStore.$currentUser.set(profile); 146 151 } 147 152 148 153 async loadPostThread(postURI, { depth = 6 } = {}) { ··· 187 192 await this._loadBlockedPosts(blockedPostUris); 188 193 } 189 194 // Save post thread 190 - this.dataStore.setPostThread(postURI, postThread); 191 - this.dataStore.setPostThreadOther(postURI, postThreadOther); 195 + this.dataStore.$postThreads.set(postURI, postThread); 196 + this.dataStore.$postThreadOthers.set(postURI, postThreadOther); 192 197 // Note - this return value is used by loadParentChain 193 198 return postThread; 194 199 } ··· 196 201 async loadPost(postURI) { 197 202 const labelers = this.requireLabelers(); 198 203 const post = await this.api.getPost(postURI, { labelers }); 199 - this.dataStore.setPost(postURI, post); 204 + this.dataStore.setPosts([post]); 200 205 } 201 206 202 207 async loadPosts(postURIs) { 203 208 if (postURIs.length === 0) return; 204 209 const labelers = this.requireLabelers(); 205 210 const posts = await this.api.getPosts(postURIs, { labelers }); 206 - for (const post of posts) { 207 - this.dataStore.setPost(post.uri, post); 208 - } 211 + this.dataStore.setPosts(posts); 209 212 } 210 213 211 214 async _loadParentChain(blockedParent, { labelers = [], rootUri } = {}) { ··· 362 365 363 366 async loadNextFeedPage(feedURI, { reload = false, limit = 31 } = {}) { 364 367 const labelers = this.requireLabelers(); 365 - const existingFeed = this.dataStore.getFeed(feedURI); 368 + const existingFeed = this.dataStore.$feeds.get(feedURI).get(); 366 369 let cursor = existingFeed ? existingFeed.cursor : ""; 367 370 if (reload) { 368 371 cursor = ""; ··· 380 383 await this._loadBlockedPosts(blockedPostUris); 381 384 } 382 385 // Filter posts with plugins 383 - const pluginFilteredFeedItems = 384 - await this.pluginService.getFilteredFeedItems(feedURI, feed); 385 - const existingFilteredFeedItems = 386 - this.dataStore.getPluginFilteredFeedItems(feedURI) ?? {}; 387 - this.dataStore.setPluginFilteredFeedItems(feedURI, { 388 - ...existingFilteredFeedItems, 389 - ...pluginFilteredFeedItems, 390 - }); 386 + await this.pluginService.refreshFiltersForFeed(feedURI, feed); 391 387 if (existingFeed && !reload) { 392 388 // Append to existing feed 393 - this.dataStore.setFeed(feedURI, { 389 + this.dataStore.$feeds.set(feedURI, { 394 390 feed: [...existingFeed.feed, ...feed.feed], 395 391 cursor: feed.cursor, 396 392 }); 397 393 } else { 398 394 // Set new feed 399 - this.dataStore.setFeed(feedURI, feed); 395 + this.dataStore.$feeds.set(feedURI, feed); 400 396 } 401 397 } 402 398 403 399 async loadPluginFilteredFeedItems(feedURI, { reload = false } = {}) { 404 - const feed = this.dataStore.getFeed(feedURI); 400 + const feed = this.dataStore.$feeds.get(feedURI).get(); 405 401 if (!feed) { 406 402 return; 407 403 } 408 - const pluginFilteredFeedItems = 409 - await this.pluginService.getFilteredFeedItems(feedURI, feed); 410 - const existingFilteredFeedItems = reload 411 - ? {} 412 - : (this.dataStore.getPluginFilteredFeedItems(feedURI) ?? {}); 413 - this.dataStore.setPluginFilteredFeedItems(feedURI, { 414 - ...existingFilteredFeedItems, 415 - ...pluginFilteredFeedItems, 416 - }); 404 + await this.pluginService.refreshFiltersForFeed(feedURI, feed, { reload }); 417 405 } 418 406 419 407 async _getReplyUrisForPostFromBacklinks(post) { ··· 451 439 ); 452 440 if (notFoundPostUris.length > 0) { 453 441 for (const uri of notFoundPostUris) { 454 - this.dataStore.setUnavailablePost(uri, createUnavailablePost(uri)); 442 + this.dataStore.$unavailablePosts.set(uri, createUnavailablePost(uri)); 455 443 } 456 444 } 457 445 } ··· 459 447 async loadProfile(did) { 460 448 const labelers = this.requireLabelers(); 461 449 const profile = await this.api.getProfile(did, { labelers }); 462 - this.dataStore.setProfile(did, profile); 450 + this.dataStore.$profiles.set(did, profile); 463 451 } 464 452 465 453 async loadProfiles(dids) { ··· 467 455 const labelers = this.requireLabelers(); 468 456 const profiles = await this.api.getProfiles(dids, { labelers }); 469 457 for (const profile of profiles) { 470 - this.dataStore.setProfile(profile.did, profile); 458 + this.dataStore.$profiles.set(profile.did, profile); 471 459 } 472 460 } 473 461 474 462 async loadProfileSearch(query, { limit = 10, cursor = "" } = {}) { 475 463 if (!query) { 476 - this.dataStore.clearProfileSearchResults(); 464 + this.dataStore.$profileSearchResults.set(null); 477 465 return; 478 466 } 479 467 if (!cursor) { 480 - this.dataStore.clearProfileSearchResults(); 468 + this.dataStore.$profileSearchResults.set(null); 481 469 } 482 470 const labelers = this.requireLabelers(); 483 471 const requestTime = Date.now(); 484 - this.dataStore.setLatestProfileSearchRequestTime(requestTime); 472 + this.dataStore.$latestProfileSearchRequestTime.set(requestTime); 485 473 const searchData = await this.api.searchProfiles(query, { 486 474 limit, 487 475 cursor, 488 476 labelers, 489 477 }); 490 - if (requestTime !== this.dataStore.getLatestProfileSearchRequestTime()) { 478 + if (requestTime !== this.dataStore.$latestProfileSearchRequestTime.get()) { 491 479 return; 492 480 } 493 - const existingResults = this.dataStore.getProfileSearchResults(); 481 + const existingResults = this.dataStore.$profileSearchResults.get(); 494 482 if (existingResults && cursor) { 495 - this.dataStore.setProfileSearchResults({ 483 + this.dataStore.$profileSearchResults.set({ 496 484 actors: [...existingResults.actors, ...searchData.actors], 497 485 cursor: searchData.cursor, 498 486 }); 499 487 } else { 500 - this.dataStore.setProfileSearchResults(searchData); 488 + this.dataStore.$profileSearchResults.set(searchData); 501 489 } 502 490 } 503 491 504 492 async loadPostSearch(query, { limit = 25, sort = "top", cursor = "" } = {}) { 505 493 if (!query) { 506 - this.dataStore.clearPostSearchResults(); 494 + this.dataStore.$postSearchResults.set(null); 507 495 return; 508 496 } 509 497 if (!cursor) { 510 - this.dataStore.clearPostSearchResults(); 498 + this.dataStore.$postSearchResults.set(null); 511 499 } 512 500 const labelers = this.requireLabelers(); 513 501 const requestTime = Date.now(); 514 - this.dataStore.setLatestPostSearchRequestTime(requestTime); 502 + this.dataStore.$latestPostSearchRequestTime.set(requestTime); 515 503 const searchData = await this.api.searchPosts(query, { 516 504 limit, 517 505 sort, 518 506 cursor, 519 507 labelers, 520 508 }); 521 - if (requestTime !== this.dataStore.getLatestPostSearchRequestTime()) { 509 + if (requestTime !== this.dataStore.$latestPostSearchRequestTime.get()) { 522 510 return; 523 511 } 524 512 const searchResults = searchData.posts || []; ··· 538 526 await this._loadBlockedPosts(blockedPostUris); 539 527 } 540 528 } 541 - const existingResults = this.dataStore.getPostSearchResults(); 529 + const existingResults = this.dataStore.$postSearchResults.get(); 542 530 if (existingResults && cursor) { 543 - this.dataStore.setPostSearchResults({ 531 + this.dataStore.$postSearchResults.set({ 544 532 posts: [...existingResults.posts, ...searchResults], 545 533 cursor: searchData.cursor, 546 534 }); 547 535 } else { 548 - this.dataStore.setPostSearchResults({ 536 + this.dataStore.$postSearchResults.set({ 549 537 posts: searchResults, 550 538 cursor: searchData.cursor, 551 539 }); ··· 554 542 555 543 async loadFeedSearch(query, { limit = 15, cursor = "" } = {}) { 556 544 if (!query) { 557 - this.dataStore.clearFeedSearchResults(); 545 + this.dataStore.$feedSearchResults.set(null); 558 546 return; 559 547 } 560 548 if (!cursor) { 561 - this.dataStore.clearFeedSearchResults(); 549 + this.dataStore.$feedSearchResults.set(null); 562 550 } 563 551 const requestTime = Date.now(); 564 - this.dataStore.setLatestFeedSearchRequestTime(requestTime); 552 + this.dataStore.$latestFeedSearchRequestTime.set(requestTime); 565 553 const searchData = await this.api.searchFeedGenerators(query, { 566 554 limit, 567 555 cursor, 568 556 }); 569 - if (requestTime !== this.dataStore.getLatestFeedSearchRequestTime()) { 557 + if (requestTime !== this.dataStore.$latestFeedSearchRequestTime.get()) { 570 558 return; 571 559 } 572 560 const feeds = searchData.feeds || []; 573 561 for (const feed of feeds) { 574 - this.dataStore.setFeedGenerator(feed.uri, feed); 562 + this.dataStore.$feedGenerators.set(feed.uri, feed); 575 563 } 576 - const existingResults = this.dataStore.getFeedSearchResults(); 564 + const existingResults = this.dataStore.$feedSearchResults.get(); 577 565 if (existingResults && cursor) { 578 - this.dataStore.setFeedSearchResults({ 566 + this.dataStore.$feedSearchResults.set({ 579 567 feeds: [...existingResults.feeds, ...feeds], 580 568 cursor: searchData.cursor, 581 569 }); 582 570 } else { 583 - this.dataStore.setFeedSearchResults({ 571 + this.dataStore.$feedSearchResults.set({ 584 572 feeds, 585 573 cursor: searchData.cursor, 586 574 }); ··· 593 581 { reload = false, limit = 31 } = {}, 594 582 ) { 595 583 const feedURI = `${did}-${feedType}`; 596 - const existingFeed = this.dataStore.getAuthorFeed(feedURI); 584 + const existingFeed = this.dataStore.$authorFeeds.get(feedURI).get(); 597 585 let cursor = existingFeed ? existingFeed.cursor : ""; 598 586 if (reload) { 599 587 cursor = ""; ··· 638 626 // Save feed 639 627 if (existingFeed && !reload) { 640 628 // Append to existing feed 641 - this.dataStore.setAuthorFeed(feedURI, { 629 + this.dataStore.$authorFeeds.set(feedURI, { 642 630 feed: [...existingFeed.feed, ...feed.feed], 643 631 cursor: feed.cursor, 644 632 }); 645 633 } else { 646 634 // Set new feed 647 - this.dataStore.setAuthorFeed(feedURI, feed); 635 + this.dataStore.$authorFeeds.set(feedURI, feed); 648 636 } 649 637 } 650 638 651 639 async loadNotifications({ reload = false, limit = 31 } = {}) { 652 - let cursor = this.dataStore.getNotificationCursor() ?? ""; 640 + let cursor = this.dataStore.$notificationCursor.get() ?? ""; 653 641 if (reload) { 654 642 cursor = ""; 655 643 } ··· 661 649 const fetchedPosts = await this.api.getPosts(postUris, { labelers }); 662 650 this.dataStore.setPosts(fetchedPosts); 663 651 } 664 - const previousCursor = this.dataStore.getNotificationCursor(); 652 + const previousCursor = this.dataStore.$notificationCursor.get(); 665 653 // If the req cursor matches the previous cursor, append 666 654 if (previousCursor && !reload) { 667 655 if (previousCursor === cursor) { 668 - const existingNotifications = this.dataStore.getNotifications() ?? []; 669 - this.dataStore.setNotifications([ 656 + const existingNotifications = this.dataStore.$notifications.get() ?? []; 657 + this.dataStore.$notifications.set([ 670 658 ...existingNotifications, 671 659 ...res.notifications, 672 660 ]); ··· 680 668 ); 681 669 } 682 670 } else { 683 - this.dataStore.setNotifications(res.notifications); 671 + this.dataStore.$notifications.set(res.notifications); 684 672 } 685 - this.dataStore.setNotificationCursor(res.cursor); 673 + this.dataStore.$notificationCursor.set(res.cursor); 686 674 } 687 675 688 676 async loadMentionNotifications({ reload = false, limit = 31 } = {}) { 689 677 const MENTION_REASONS = ["mention", "reply", "quote"]; 690 - let cursor = this.dataStore.getMentionNotificationCursor() ?? ""; 678 + let cursor = this.dataStore.$mentionNotificationCursor.get() ?? ""; 691 679 if (reload) { 692 680 cursor = ""; 693 681 } ··· 703 691 const fetchedPosts = await this.api.getPosts(postUris, { labelers }); 704 692 this.dataStore.setPosts(fetchedPosts); 705 693 } 706 - const previousCursor = this.dataStore.getMentionNotificationCursor(); 694 + const previousCursor = this.dataStore.$mentionNotificationCursor.get(); 707 695 if (previousCursor && !reload) { 708 696 if (previousCursor === cursor) { 709 697 const existingNotifications = 710 - this.dataStore.getMentionNotifications() ?? []; 711 - this.dataStore.setMentionNotifications([ 698 + this.dataStore.$mentionNotifications.get() ?? []; 699 + this.dataStore.$mentionNotifications.set([ 712 700 ...existingNotifications, 713 701 ...res.notifications, 714 702 ]); ··· 719 707 ); 720 708 } 721 709 } else { 722 - this.dataStore.setMentionNotifications(res.notifications); 710 + this.dataStore.$mentionNotifications.set(res.notifications); 723 711 } 724 - this.dataStore.setMentionNotificationCursor(res.cursor); 712 + this.dataStore.$mentionNotificationCursor.set(res.cursor); 725 713 } 726 714 727 715 async loadConvoList({ reload = false, limit = 30 } = {}) { 728 - let cursor = this.dataStore.getConvoListCursor() ?? ""; 716 + let cursor = this.dataStore.$convoListCursor.get() ?? ""; 729 717 if (reload) { 730 718 cursor = ""; 731 719 } 732 720 const res = await this.api.listConvos({ cursor, limit }); 733 - const previousCursor = this.dataStore.getConvoListCursor(); 721 + const previousCursor = this.dataStore.$convoListCursor.get(); 734 722 // Store individual convos 735 723 for (const convo of res.convos) { 736 - this.dataStore.setConvo(convo.id, convo); 724 + this.dataStore.$convos.set(convo.id, convo); 737 725 } 738 726 // If the req cursor matches the previous cursor, append 739 727 if (previousCursor && !reload) { 740 728 if (previousCursor === cursor) { 741 - const existingConvos = this.dataStore.getConvoList() ?? []; 742 - this.dataStore.setConvoList([...existingConvos, ...res.convos]); 729 + const existingConvos = this.dataStore.$convoList.get() ?? []; 730 + this.dataStore.$convoList.set([...existingConvos, ...res.convos]); 743 731 } else { 744 732 console.warn("loadConvoList: cursor mismatch, discarding response", { 745 733 previousCursor, ··· 747 735 }); 748 736 } 749 737 } else { 750 - this.dataStore.setConvoList(res.convos); 738 + this.dataStore.$convoList.set(res.convos); 751 739 } 752 - this.dataStore.setConvoListCursor(res.cursor); 740 + this.dataStore.$convoListCursor.set(res.cursor); 753 741 } 754 742 755 743 async loadConvo(convoId) { 756 744 const res = await this.api.getConvo(convoId); 757 - this.dataStore.setConvo(convoId, res.convo); 745 + this.dataStore.$convos.set(convoId, res.convo); 758 746 } 759 747 760 748 async loadConvoForProfile(profileDid) { 761 749 const res = await this.api.getConvoForMembers([profileDid]); 762 - this.dataStore.setConvo(res.convo.id, res.convo); 750 + this.dataStore.$convos.set(res.convo.id, res.convo); 763 751 } 764 752 765 753 async loadConvoMessages(convoId, { reload = false, limit = 50 } = {}) { 766 - const existingMessages = this.dataStore.getConvoMessages(convoId); 754 + const existingMessages = this.dataStore.$convoMessages.get(convoId).get(); 767 755 let cursor = existingMessages ? existingMessages.cursor : ""; 768 756 if (reload) { 769 757 cursor = ""; ··· 782 770 } 783 771 // Save individual messages 784 772 for (const message of res.messages) { 785 - this.dataStore.setMessage(message.id, message); 773 + this.dataStore.$messages.set(message.id, message); 786 774 } 787 775 if (existingMessages && !reload) { 788 - this.dataStore.setConvoMessages(convoId, { 776 + this.dataStore.$convoMessages.set(convoId, { 789 777 messages: [...existingMessages.messages, ...res.messages], 790 778 cursor: res.cursor, 791 779 }); 792 780 } else { 793 - this.dataStore.setConvoMessages(convoId, res); 781 + this.dataStore.$convoMessages.set(convoId, res); 794 782 } 783 + } 784 + 785 + async pollConvoMessages(convoId, { cursor = "" } = {}) { 786 + const currentUser = this.dataStore.$currentUser.get(); 787 + const res = await this.api.getChatLogs({ cursor }); 788 + const logsForConvo = res.logs.filter((log) => log.convoId === convoId); 789 + for (const log of logsForConvo) { 790 + if (log.$type !== "chat.bsky.convo.defs#logCreateMessage") continue; 791 + if (log.message.sender.did === currentUser?.did) { 792 + // Skip if the message is from the current user, since we already set it in the store 793 + continue; 794 + } 795 + const convoMessages = this.dataStore.$convoMessages.get(convoId).get(); 796 + if (!convoMessages) { 797 + console.warn("No messages data found for convoId", convoId); 798 + return res.cursor; 799 + } 800 + this.dataStore.$messages.set(log.message.id, log.message); 801 + this.dataStore.$convoMessages.set(convoId, { 802 + messages: [log.message, ...convoMessages.messages], 803 + cursor: convoMessages.cursor, 804 + }); 805 + } 806 + return res.cursor; 795 807 } 796 808 797 809 async loadPostLikes(postUri, { cursor } = {}) { 798 810 const labelers = this.requireLabelers(); 799 - const existingLikes = this.dataStore.getPostLikes(postUri); 811 + const existingLikes = this.dataStore.$postLikes.get(postUri).get(); 800 812 const res = await this.api.getLikes(postUri, { cursor, labelers }); 801 813 802 814 if (existingLikes && cursor) { 803 815 // Append to existing likes 804 - this.dataStore.setPostLikes(postUri, { 816 + this.dataStore.$postLikes.set(postUri, { 805 817 likes: [...existingLikes.likes, ...res.likes], 806 818 cursor: res.cursor, 807 819 }); 808 820 } else { 809 821 // Set new likes 810 - this.dataStore.setPostLikes(postUri, res); 822 + this.dataStore.$postLikes.set(postUri, res); 811 823 } 812 824 } 813 825 814 826 async loadPostQuotes(postUri, { cursor } = {}) { 815 827 const labelers = this.requireLabelers(); 816 - const existingQuotes = this.dataStore.getPostQuotes(postUri); 828 + const existingQuotes = this.dataStore.$postQuotes.get(postUri).get(); 817 829 const res = await this.api.getQuotes(postUri, { cursor, labelers }); 818 830 819 831 // if there are posts that are replies, load the parents ··· 829 841 this.dataStore.setPosts([...res.posts, ...parentPosts]); 830 842 if (existingQuotes && cursor) { 831 843 // Append to existing quotes 832 - this.dataStore.setPostQuotes(postUri, { 844 + this.dataStore.$postQuotes.set(postUri, { 833 845 posts: [...existingQuotes.posts, ...res.posts], 834 846 cursor: res.cursor, 835 847 }); 836 848 } else { 837 849 // Set new quotes 838 - this.dataStore.setPostQuotes(postUri, res); 850 + this.dataStore.$postQuotes.set(postUri, res); 839 851 } 840 852 } 841 853 842 854 async loadPostReposts(postUri, { cursor } = {}) { 843 855 const labelers = this.requireLabelers(); 844 - const existingReposts = this.dataStore.getPostReposts(postUri); 856 + const existingReposts = this.dataStore.$postReposts.get(postUri).get(); 845 857 const res = await this.api.getRepostedBy(postUri, { cursor, labelers }); 846 858 847 859 if (existingReposts && cursor) { 848 860 // Append to existing reposts 849 - this.dataStore.setPostReposts(postUri, { 861 + this.dataStore.$postReposts.set(postUri, { 850 862 reposts: [...existingReposts.reposts, ...res.repostedBy], 851 863 cursor: res.cursor, 852 864 }); 853 865 } else { 854 866 // Set new reposts 855 - this.dataStore.setPostReposts(postUri, { 867 + this.dataStore.$postReposts.set(postUri, { 856 868 reposts: res.repostedBy, 857 869 cursor: res.cursor, 858 870 }); ··· 894 906 895 907 async loadFeedGenerator(feedUri) { 896 908 const feedGeneratorData = await this.api.getFeedGenerator(feedUri); 897 - this.dataStore.setFeedGenerator(feedUri, feedGeneratorData); 909 + this.dataStore.$feedGenerators.set(feedUri, feedGeneratorData); 898 910 } 899 911 900 912 async loadPinnedFeedGenerators() { ··· 907 919 ? await this.api.getFeedGenerators(feedUris) 908 920 : []; 909 921 for (const feedGenerator of feedGenerators) { 910 - this.dataStore.setFeedGenerator(feedGenerator.uri, feedGenerator); 922 + this.dataStore.$feedGenerators.set(feedGenerator.uri, feedGenerator); 911 923 } 912 - this.dataStore.setPinnedFeedGenerators(feedGenerators); 924 + this.dataStore.$pinnedFeedGenerators.set(feedGenerators); 913 925 } 914 926 915 927 async loadActorFeeds(did, { reload = false, limit = 50 } = {}) { 916 - const existing = this.dataStore.getActorFeeds(did); 928 + const existing = this.dataStore.$actorFeeds.get(did).get(); 917 929 let cursor = existing ? existing.cursor : ""; 918 930 if (reload) { 919 931 cursor = ""; ··· 923 935 } 924 936 const data = await this.api.getActorFeeds(did, { limit, cursor }); 925 937 for (const feed of data.feeds) { 926 - this.dataStore.setFeedGenerator(feed.uri, feed); 938 + this.dataStore.$feedGenerators.set(feed.uri, feed); 927 939 } 928 940 if (reload || !existing) { 929 - this.dataStore.setActorFeeds(did, { 941 + this.dataStore.$actorFeeds.set(did, { 930 942 feeds: data.feeds, 931 943 cursor: data.cursor ?? null, 932 944 }); 933 945 } else { 934 - this.dataStore.setActorFeeds(did, { 946 + this.dataStore.$actorFeeds.set(did, { 935 947 feeds: [...existing.feeds, ...data.feeds], 936 948 cursor: data.cursor ?? null, 937 949 }); ··· 942 954 const hashtagKey = `${hashtag}-${sort}`; 943 955 const labelers = this.requireLabelers(); 944 956 945 - const existingFeed = this.dataStore.getHashtagFeed(hashtagKey); 957 + const existingFeed = this.dataStore.$hashtagFeeds.get(hashtagKey).get(); 946 958 let cursor = existingFeed ? existingFeed.cursor : ""; 947 959 if (reload) { 948 960 cursor = ""; ··· 985 997 986 998 if (existingFeed && !reload) { 987 999 // Append to existing feed 988 - this.dataStore.setHashtagFeed(hashtagKey, { 1000 + this.dataStore.$hashtagFeeds.set(hashtagKey, { 989 1001 feed: [...existingFeed.feed, ...feed.feed], 990 1002 cursor: feed.cursor, 991 1003 }); 992 1004 } else { 993 1005 // Set new feed 994 - this.dataStore.setHashtagFeed(hashtagKey, feed); 1006 + this.dataStore.$hashtagFeeds.set(hashtagKey, feed); 995 1007 } 996 1008 } 997 1009 998 1010 async loadBookmarks({ reload = false, limit = 31 } = {}) { 999 - const existingBookmarks = this.dataStore.getBookmarks(); 1011 + const existingBookmarks = this.dataStore.$bookmarks.get(); 1000 1012 let cursor = existingBookmarks ? existingBookmarks.cursor : ""; 1001 1013 if (reload) { 1002 1014 cursor = ""; ··· 1036 1048 1037 1049 if (existingBookmarks && !reload) { 1038 1050 // Append to existing bookmarks 1039 - this.dataStore.setBookmarks({ 1051 + this.dataStore.$bookmarks.set({ 1040 1052 feed: [...existingBookmarks.feed, ...bookmarksFeed.feed], 1041 1053 cursor: bookmarksFeed.cursor, 1042 1054 }); 1043 1055 } else { 1044 1056 // Set new bookmarks 1045 - this.dataStore.setBookmarks(bookmarksFeed); 1057 + this.dataStore.$bookmarks.set(bookmarksFeed); 1046 1058 } 1047 1059 } 1048 1060 1049 1061 async loadProfileFollowers(profileDid, { cursor } = {}) { 1050 1062 const labelers = this.requireLabelers(); 1051 - const existingFollowers = this.dataStore.getProfileFollowers(profileDid); 1063 + const existingFollowers = this.dataStore.$profileFollowers 1064 + .get(profileDid) 1065 + .get(); 1052 1066 const res = await this.api.getFollowers(profileDid, { cursor, labelers }); 1053 1067 1054 1068 if (existingFollowers && cursor) { 1055 1069 // Append to existing followers 1056 - this.dataStore.setProfileFollowers(profileDid, { 1070 + this.dataStore.$profileFollowers.set(profileDid, { 1057 1071 followers: [...existingFollowers.followers, ...res.followers], 1058 1072 cursor: res.cursor, 1059 1073 }); 1060 1074 } else { 1061 1075 // Set new followers 1062 - this.dataStore.setProfileFollowers(profileDid, res); 1076 + this.dataStore.$profileFollowers.set(profileDid, res); 1063 1077 } 1064 1078 } 1065 1079 1066 1080 async loadProfileFollows(profileDid, { cursor } = {}) { 1067 1081 const labelers = this.requireLabelers(); 1068 - const existingFollows = this.dataStore.getProfileFollows(profileDid); 1082 + const existingFollows = this.dataStore.$profileFollows 1083 + .get(profileDid) 1084 + .get(); 1069 1085 const res = await this.api.getFollows(profileDid, { cursor, labelers }); 1070 1086 1071 1087 if (existingFollows && cursor) { 1072 1088 // Append to existing follows 1073 - this.dataStore.setProfileFollows(profileDid, { 1089 + this.dataStore.$profileFollows.set(profileDid, { 1074 1090 follows: [...existingFollows.follows, ...res.follows], 1075 1091 cursor: res.cursor, 1076 1092 }); 1077 1093 } else { 1078 1094 // Set new follows 1079 - this.dataStore.setProfileFollows(profileDid, res); 1095 + this.dataStore.$profileFollows.set(profileDid, res); 1080 1096 } 1081 1097 } 1082 1098 1083 1099 async loadBlockedProfiles({ cursor } = {}) { 1084 1100 const labelers = this.requireLabelers(); 1085 - const existing = this.dataStore.getBlockedProfiles(); 1101 + const existing = this.dataStore.$blockedProfiles.get(); 1086 1102 const res = await this.api.getBlocks({ cursor, labelers }); 1087 1103 1088 1104 if (existing && cursor) { 1089 - this.dataStore.setBlockedProfiles({ 1105 + this.dataStore.$blockedProfiles.set({ 1090 1106 blocks: [...existing.blocks, ...res.blocks], 1091 1107 cursor: res.cursor, 1092 1108 }); 1093 1109 } else { 1094 - this.dataStore.setBlockedProfiles(res); 1110 + this.dataStore.$blockedProfiles.set(res); 1095 1111 } 1096 1112 } 1097 1113 1098 1114 async loadMutedProfiles({ cursor } = {}) { 1099 1115 const labelers = this.requireLabelers(); 1100 - const existing = this.dataStore.getMutedProfiles(); 1116 + const existing = this.dataStore.$mutedProfiles.get(); 1101 1117 const res = await this.api.getMutes({ cursor, labelers }); 1102 1118 1103 1119 if (existing && cursor) { 1104 - this.dataStore.setMutedProfiles({ 1120 + this.dataStore.$mutedProfiles.set({ 1105 1121 mutes: [...existing.mutes, ...res.mutes], 1106 1122 cursor: res.cursor, 1107 1123 }); 1108 1124 } else { 1109 - this.dataStore.setMutedProfiles(res); 1125 + this.dataStore.$mutedProfiles.set(res); 1110 1126 } 1111 1127 } 1112 1128 1113 1129 async loadProfileChatStatus(profileDid) { 1114 1130 const res = await this.api.getConvoAvailability([profileDid]); 1115 - this.dataStore.setProfileChatStatus(profileDid, res); 1131 + this.dataStore.$profileChatStatus.set(profileDid, res); 1116 1132 } 1117 1133 1118 1134 async loadLabelerInfo(labelerDid) { 1119 1135 const labelerInfo = await this.api.getLabeler(labelerDid); 1120 - this.dataStore.setLabelerInfo(labelerDid, labelerInfo); 1136 + this.dataStore.$labelerInfo.set(labelerDid, labelerInfo); 1121 1137 } 1122 1138 }
-613
src/js/dataLayer/selectors.js
··· 1 - import { 2 - filterFollowingFeed, 3 - filterAlgorithmicFeed, 4 - filterAuthorFeed, 5 - filterBookmarksFeed, 6 - } from "/js/feedFilters.js"; 7 - import { 8 - createUnavailablePost, 9 - getPostUriFromRepost, 10 - isBlockedPost, 11 - isBlockingUser, 12 - isEmptyPost, 13 - isPostView, 14 - getLastInteractionTimestamp, 15 - } from "/js/dataHelpers.js"; 16 - import { sortBy } from "/js/utils.js"; 17 - import { hydratePostForView } from "/js/dataLayer/derived.js"; 18 - import { getPost as baseGetPost } from "/js/dataLayer/base.js"; 19 - 20 - // Selectors are used to get data from the store. 21 - // They combine the canonical data from the store with the patch data. 22 - export class Selectors { 23 - constructor(dataStore, patchStore, preferencesProvider, isAuthenticated) { 24 - this.dataStore = dataStore; 25 - this.patchStore = patchStore; 26 - this.preferencesProvider = preferencesProvider; 27 - this.isAuthenticated = isAuthenticated; 28 - } 29 - 30 - getCurrentUser() { 31 - const user = this.dataStore.getCurrentUser(); 32 - return this.patchStore.applyCurrentUserPatches(user); 33 - } 34 - 35 - getPreferences() { 36 - const preferences = this.preferencesProvider.requirePreferences(); 37 - return this.patchStore.applyPreferencePatches(preferences); 38 - } 39 - 40 - getFeed(feedURI) { 41 - let feed = this.dataStore.getFeed(feedURI); 42 - if (!feed) { 43 - return null; 44 - } 45 - // Hydrate 46 - const hydratedFeedItems = []; 47 - for (const feedItem of feed.feed) { 48 - const hydratedFeedItem = { 49 - feedContext: feedItem.feedContext, 50 - post: this.getPost(feedItem.post.uri, { required: true }), 51 - }; 52 - if (feedItem.reason) { 53 - hydratedFeedItem.reason = feedItem.reason; 54 - } 55 - const reply = feedItem.reply; 56 - if (reply) { 57 - let root = reply.root; 58 - if (isPostView(root)) { 59 - root = this.getPost(root.uri, { required: true }); 60 - } 61 - let parent = reply.parent; 62 - if (isPostView(parent)) { 63 - parent = this.getPost(parent.uri, { required: true }); 64 - } 65 - const hydratedReply = { 66 - ...reply, 67 - root, 68 - parent, 69 - }; 70 - hydratedFeedItem.reply = hydratedReply; 71 - } 72 - hydratedFeedItems.push(hydratedFeedItem); 73 - } 74 - const hydratedFeed = { 75 - feed: hydratedFeedItems, 76 - cursor: feed.cursor, 77 - }; 78 - const pluginFilteredFeedItems = 79 - this.dataStore.getPluginFilteredFeedItems(feedURI) ?? {}; 80 - if (feedURI === "following") { 81 - const currentUser = this.getCurrentUser(); 82 - const preferences = this.getPreferences(); 83 - return filterFollowingFeed( 84 - hydratedFeed, 85 - currentUser, 86 - preferences, 87 - pluginFilteredFeedItems, 88 - ); 89 - } else { 90 - return filterAlgorithmicFeed( 91 - hydratedFeed, 92 - this.isAuthenticated, 93 - pluginFilteredFeedItems, 94 - ); 95 - } 96 - } 97 - 98 - getPostThread(postURI) { 99 - // Load post thread from store, then hydrate it with posts from store 100 - const postThread = this.dataStore.getPostThread(postURI); 101 - const postThreadOther = this.dataStore.getPostThreadOther(postURI); 102 - if (!postThread || !postThreadOther) { 103 - return null; 104 - } 105 - if (isEmptyPost(postThread)) { 106 - return postThread; 107 - } 108 - const hiddenReplyUris = postThreadOther.map((item) => item.uri); 109 - // Hydrate 110 - const hydratedPostThread = this.hydratePostThread( 111 - postThread, 112 - hiddenReplyUris, 113 - ); 114 - const parent = postThread.parent; 115 - if (parent) { 116 - hydratedPostThread.parent = this.hydratePostThreadParent(parent); 117 - } 118 - return hydratedPostThread; 119 - } 120 - 121 - hydratePostThread(postThread, hiddenReplyUris) { 122 - if (isEmptyPost(postThread)) { 123 - return postThread; 124 - } 125 - const hydratedPostThread = { 126 - post: this.getPost(postThread.post.uri, { required: true }), 127 - }; 128 - if (hiddenReplyUris.includes(postThread.post.uri)) { 129 - /* NOTE: LEXICON DEVIATION */ 130 - hydratedPostThread.post = { ...hydratedPostThread.post, isHidden: true }; 131 - } 132 - if (postThread.replies) { 133 - hydratedPostThread.replies = postThread.replies.map((reply) => { 134 - if (reply.$type === "app.bsky.feed.defs#threadViewPost") { 135 - return this.hydratePostThread(reply, hiddenReplyUris); 136 - } 137 - return reply; 138 - }); 139 - } 140 - return hydratedPostThread; 141 - } 142 - 143 - hydratePostThreadParent(parent) { 144 - if (this.dataStore.hasUnavailablePost(parent.uri)) { 145 - return createUnavailablePost(parent.uri); 146 - } 147 - if (isBlockedPost(parent) && isBlockingUser(parent)) { 148 - return parent; 149 - } 150 - if (parent.$type !== "app.bsky.feed.defs#threadViewPost") { 151 - // not sure how to handle this 152 - return parent; 153 - } 154 - const post = this.getPost(parent.post.uri); 155 - const hydratedParent = { 156 - $type: "app.bsky.feed.defs#threadViewPost", 157 - post: post, 158 - }; 159 - // keep going up the chain 160 - if (parent.parent) { 161 - hydratedParent.parent = this.hydratePostThreadParent(parent.parent); 162 - } 163 - return hydratedParent; 164 - } 165 - 166 - getPost(postURI, { required = false } = {}) { 167 - const lookup = (uri) => baseGetPost(this.dataStore, this.patchStore, uri); 168 - const current = lookup(postURI); 169 - if (!current) { 170 - if (required) { 171 - throw new Error(`Post not found: ${postURI}`); 172 - } 173 - return null; 174 - } 175 - const preferences = this.preferencesProvider.requirePreferences(); 176 - return hydratePostForView(current, { preferences, getPost: lookup }); 177 - } 178 - 179 - getPosts(postURIs, options) { 180 - return postURIs.map((postURI) => this.getPost(postURI, options)); 181 - } 182 - 183 - getProfileSearchResults() { 184 - const data = this.dataStore.getProfileSearchResults(); 185 - if (!data) return null; 186 - return data.actors; 187 - } 188 - 189 - getProfileSearchCursor() { 190 - return this.dataStore.getProfileSearchCursor(); 191 - } 192 - 193 - getFeedSearchResults() { 194 - const data = this.dataStore.getFeedSearchResults(); 195 - if (!data) return null; 196 - return data.feeds; 197 - } 198 - 199 - getFeedSearchCursor() { 200 - return this.dataStore.getFeedSearchCursor(); 201 - } 202 - 203 - getPostSearchResults() { 204 - const data = this.dataStore.getPostSearchResults(); 205 - if (!data) { 206 - return null; 207 - } 208 - const hydratedSearchResults = []; 209 - for (const result of data.posts) { 210 - let post = this.getPost(result.uri); 211 - if (post.record?.reply) { 212 - const parentPost = this.getPost(post.record.reply.parent.uri); 213 - if (parentPost) { 214 - post = { 215 - ...post, 216 - record: { 217 - ...post.record, 218 - reply: { 219 - ...post.record.reply, 220 - // NOTE: LEXICON DEVIATION 221 - parentAuthor: parentPost.author, 222 - }, 223 - }, 224 - }; 225 - } 226 - } 227 - hydratedSearchResults.push(post); 228 - } 229 - return hydratedSearchResults; 230 - } 231 - 232 - getPostSearchCursor() { 233 - return this.dataStore.getPostSearchCursor(); 234 - } 235 - 236 - getAuthorFeed(did, feedType) { 237 - const feedURI = `${did}-${feedType}`; 238 - const rawFeed = this.dataStore.getAuthorFeed(feedURI); 239 - if (!rawFeed) { 240 - return null; 241 - } 242 - const feed = this.patchStore.applyAuthorFeedPatches(feedURI, rawFeed); 243 - // Hydrate 244 - const hydratedFeedItems = []; 245 - for (const feedItem of feed.feed) { 246 - ``; 247 - const hydratedFeedItem = { 248 - post: this.getPost(feedItem.post.uri), 249 - }; 250 - if (feedItem.reason) { 251 - hydratedFeedItem.reason = feedItem.reason; 252 - } 253 - // app.bsky.feed.defs#reasonPin 254 - // app.bsky.feed.defs#reasonRepost 255 - if (feedItem.reply) { 256 - hydratedFeedItem.reply = { 257 - ...feedItem.reply, 258 - root: this.getPost(feedItem.reply.root.uri), 259 - parent: this.getPost(feedItem.reply.parent.uri), 260 - }; 261 - } 262 - hydratedFeedItems.push(hydratedFeedItem); 263 - } 264 - let hydratedFeed = { 265 - feed: hydratedFeedItems, 266 - cursor: feed.cursor, 267 - }; 268 - if (feedType === "replies") { 269 - hydratedFeed = this.filterAuthorRepliesFeed(hydratedFeed); 270 - } 271 - return filterAuthorFeed(hydratedFeed, this.isAuthenticated); 272 - } 273 - 274 - filterAuthorRepliesFeed(feed) { 275 - // Filter the feed items to only show replies 276 - const filteredFeedItems = []; 277 - for (const feedItem of feed.feed) { 278 - if (feedItem.reply && !feedItem.reason) { 279 - filteredFeedItems.push(feedItem); 280 - } 281 - } 282 - return { 283 - feed: filteredFeedItems, 284 - cursor: feed.cursor, 285 - }; 286 - } 287 - 288 - getShowLessInteractions() { 289 - return this.dataStore.getShowLessInteractions(); 290 - } 291 - 292 - getNotifications() { 293 - const notifications = this.dataStore.getNotifications(); 294 - if (!notifications) { 295 - return null; 296 - } 297 - 298 - return notifications.map((notification) => { 299 - if (notification.reason === "like" || notification.reason === "repost") { 300 - let subject = this.getPost(notification.reasonSubject); 301 - // If it was not found, create an unavailable post. 302 - if (!subject) { 303 - subject = createUnavailablePost(notification.reasonSubject); 304 - } 305 - return { 306 - ...notification, 307 - subject, 308 - }; 309 - } 310 - if ( 311 - notification.reason === "like-via-repost" || 312 - notification.reason === "repost-via-repost" 313 - ) { 314 - const postUri = notification.record.subject.uri; 315 - // If it was not found, create an unavailable post. 316 - const subject = this.getPost(postUri) ?? createUnavailablePost(postUri); 317 - return { 318 - ...notification, 319 - subject, 320 - }; 321 - } 322 - if ( 323 - notification.reason === "reply" || 324 - notification.reason === "mention" || 325 - notification.reason === "quote" 326 - ) { 327 - const replyPost = this.getPost(notification.uri); 328 - const parentPostUri = notification.record?.reply?.parent?.uri; 329 - const parentPost = parentPostUri ? this.getPost(parentPostUri) : null; 330 - return { 331 - ...notification, 332 - post: replyPost, 333 - parentPost, 334 - }; 335 - } 336 - if (notification.reason === "subscribed-post") { 337 - const post = this.getPost(notification.uri); 338 - return { 339 - ...notification, 340 - // NOTE: LEXICON DEVIATION 341 - reasonSubject: post, 342 - }; 343 - } 344 - return notification; 345 - }); 346 - } 347 - 348 - getNotificationCursor() { 349 - return this.dataStore.getNotificationCursor(); 350 - } 351 - 352 - getMentionNotifications() { 353 - const notifications = this.dataStore.getMentionNotifications(); 354 - if (!notifications) { 355 - return null; 356 - } 357 - 358 - return notifications.map((notification) => { 359 - if ( 360 - notification.reason === "reply" || 361 - notification.reason === "mention" || 362 - notification.reason === "quote" 363 - ) { 364 - const replyPost = this.getPost(notification.uri); 365 - const parentPostUri = notification.record?.reply?.parent?.uri; 366 - const parentPost = parentPostUri ? this.getPost(parentPostUri) : null; 367 - return { 368 - ...notification, 369 - post: replyPost, 370 - parentPost, 371 - }; 372 - } 373 - return notification; 374 - }); 375 - } 376 - 377 - getMentionNotificationCursor() { 378 - return this.dataStore.getMentionNotificationCursor(); 379 - } 380 - 381 - getConvoList() { 382 - const convoList = this.dataStore.getConvoList(); 383 - if (!convoList) { 384 - return null; 385 - } 386 - // Hydrate with individual convos 387 - const hydratedConvos = []; 388 - for (const convo of convoList) { 389 - hydratedConvos.push(this.getConvo(convo.id)); 390 - } 391 - // Sort by last message/reaction timestamp 392 - // The API response is already sorted, but we need to sort again to account for in-memory patches to the messages. 393 - const sortedConvos = sortBy( 394 - hydratedConvos, 395 - (convo) => new Date(getLastInteractionTimestamp(convo)), 396 - { 397 - direction: "desc", 398 - }, 399 - ); 400 - return sortedConvos; 401 - } 402 - 403 - getConvoListCursor() { 404 - return this.dataStore.getConvoListCursor(); 405 - } 406 - 407 - getConvo(convoId) { 408 - return this.dataStore.getConvo(convoId); 409 - } 410 - 411 - getConvoForProfile(profileDid) { 412 - const allConvos = this.dataStore.getAllConvos(); 413 - for (const convo of allConvos) { 414 - if ( 415 - convo.members.length === 2 && 416 - convo.members.some((member) => member.did === profileDid) 417 - ) { 418 - return convo; 419 - } 420 - } 421 - return null; 422 - } 423 - 424 - getMessage(messageId) { 425 - const message = this.dataStore.getMessage(messageId); 426 - if (!message) { 427 - return null; 428 - } 429 - return this.patchStore.applyMessagePatches(message); 430 - } 431 - 432 - getConvoMessages(convoId) { 433 - const messages = this.dataStore.getConvoMessages(convoId); 434 - if (!messages) { 435 - return null; 436 - } 437 - const hydratedMessages = messages.messages.map((message) => 438 - this.getMessage(message.id), 439 - ); 440 - return { 441 - messages: hydratedMessages, 442 - cursor: messages.cursor, 443 - }; 444 - } 445 - 446 - getPostLikes(postUri) { 447 - return this.dataStore.getPostLikes(postUri); 448 - } 449 - 450 - getPostQuotes(postUri) { 451 - const quotes = this.dataStore.getPostQuotes(postUri); 452 - if (!quotes) { 453 - return null; 454 - } 455 - const hydratedPosts = []; 456 - for (const quote of quotes.posts) { 457 - let post = this.getPost(quote.uri, { required: true }); 458 - // also add the parent author if it exists 459 - if (post.record?.reply?.parent) { 460 - const parentPost = this.getPost(post.record.reply.parent.uri); 461 - if (parentPost) { 462 - post = { 463 - ...post, 464 - record: { 465 - ...post.record, 466 - reply: { 467 - ...post.record.reply, 468 - // NOTE: LEXICON DEVIATION 469 - parentAuthor: parentPost.author, 470 - }, 471 - }, 472 - }; 473 - } 474 - } 475 - hydratedPosts.push(post); 476 - } 477 - return { 478 - posts: hydratedPosts, 479 - cursor: quotes.cursor, 480 - }; 481 - } 482 - 483 - getPostReposts(postUri) { 484 - return this.dataStore.getPostReposts(postUri); 485 - } 486 - 487 - getFeedGenerator(feedUri) { 488 - return this.dataStore.getFeedGenerator(feedUri); 489 - } 490 - 491 - getActorFeeds(did) { 492 - return this.dataStore.getActorFeeds(did); 493 - } 494 - 495 - getHashtagFeed(hashtag, sort) { 496 - const hashtagKey = `${hashtag}-${sort}`; 497 - const feed = this.dataStore.getHashtagFeed(hashtagKey); 498 - if (!feed) { 499 - return null; 500 - } 501 - // Hydrate 502 - const hydratedFeedItems = []; 503 - for (const feedItem of feed.feed) { 504 - let post = this.getPost(feedItem.post.uri, { required: true }); 505 - if (post.record?.reply?.parent) { 506 - const parentPost = this.getPost(post.record.reply.parent.uri); 507 - if (parentPost) { 508 - post = { 509 - ...post, 510 - record: { 511 - ...post.record, 512 - reply: { 513 - ...post.record.reply, 514 - // NOTE: LEXICON DEVIATION 515 - parentAuthor: parentPost.author, 516 - }, 517 - }, 518 - }; 519 - } 520 - } 521 - hydratedFeedItems.push({ post }); 522 - } 523 - return { 524 - feed: hydratedFeedItems, 525 - cursor: feed.cursor, 526 - }; 527 - } 528 - 529 - getPinnedFeedGenerators() { 530 - const pinnedFeedGenerators = this.dataStore.getPinnedFeedGenerators(); 531 - if (!pinnedFeedGenerators) { 532 - return null; 533 - } 534 - const hydratedPinnedFeedGenerators = []; 535 - // Add following feed generator for logged in users 536 - if (this.isAuthenticated) { 537 - hydratedPinnedFeedGenerators.push({ 538 - uri: "following", 539 - displayName: "Following", 540 - }); 541 - } 542 - for (const pinnedFeedGenerator of pinnedFeedGenerators) { 543 - hydratedPinnedFeedGenerators.push( 544 - this.getFeedGenerator(pinnedFeedGenerator.uri), 545 - ); 546 - } 547 - return hydratedPinnedFeedGenerators; 548 - } 549 - 550 - getBookmarks() { 551 - const bookmarks = this.dataStore.getBookmarks(); 552 - if (!bookmarks) { 553 - return null; 554 - } 555 - const hydratedBookmarksFeed = []; 556 - for (const bookmark of bookmarks.feed) { 557 - let post = this.getPost(bookmark.post.uri); 558 - // If it's a reply, add the parent author to the record 559 - if (post?.record?.reply?.parent) { 560 - const parentPost = this.getPost(post.record.reply.parent.uri); 561 - if (parentPost) { 562 - post = { 563 - ...post, 564 - record: { 565 - ...post.record, 566 - reply: { 567 - ...post.record.reply, 568 - // NOTE: LEXICON DEVIATION 569 - parentAuthor: parentPost.author, 570 - }, 571 - }, 572 - }; 573 - } 574 - } 575 - hydratedBookmarksFeed.push({ 576 - post, 577 - }); 578 - } 579 - return filterBookmarksFeed({ 580 - feed: hydratedBookmarksFeed, 581 - cursor: bookmarks.cursor, 582 - }); 583 - } 584 - 585 - getBlockedProfiles() { 586 - return this.dataStore.getBlockedProfiles(); 587 - } 588 - 589 - getMutedProfiles() { 590 - return this.dataStore.getMutedProfiles(); 591 - } 592 - 593 - getProfileFollowers(profileDid) { 594 - return this.dataStore.getProfileFollowers(profileDid); 595 - } 596 - 597 - getProfileFollows(profileDid) { 598 - return this.dataStore.getProfileFollows(profileDid); 599 - } 600 - 601 - getProfileChatStatus(profileDid) { 602 - return this.dataStore.getProfileChatStatus(profileDid); 603 - } 604 - 605 - getLabelerInfo(labelerDid) { 606 - return this.dataStore.getLabelerInfo(labelerDid); 607 - } 608 - 609 - getLabelerSettings(labelerDid) { 610 - const preferences = this.getPreferences(); 611 - return preferences.getLabelerSettings(labelerDid); 612 - } 613 - }
+566
src/js/dataLayer/signals.js
··· 1 + import { 2 + filterFollowingFeed, 3 + filterAlgorithmicFeed, 4 + filterAuthorFeed, 5 + filterBookmarksFeed, 6 + } from "/js/feedFilters.js"; 7 + import { 8 + createUnavailablePost, 9 + createEmbedFromPost, 10 + getBlockedQuote, 11 + getPostUriFromRepost, 12 + getQuotedPost, 13 + isBlockedPost, 14 + isBlockingUser, 15 + isEmptyPost, 16 + isPostView, 17 + getLastInteractionTimestamp, 18 + markBlockedQuoteNotFound, 19 + replaceBlockedQuote, 20 + } from "/js/dataHelpers.js"; 21 + import { 22 + sortBy, 23 + effect, 24 + deepClone, 25 + Signal, 26 + SignalMap, 27 + ComputedMap, 28 + ReactiveStore, 29 + } from "/js/utils.js"; 30 + 31 + function resolveBlockedQuote(post, { getPost }) { 32 + const blockedQuote = getBlockedQuote(post); 33 + if (!blockedQuote || isBlockingUser(blockedQuote)) return post; 34 + const fullBlockedPost = getPost(blockedQuote.uri); 35 + if (fullBlockedPost) { 36 + const blockedQuoteEmbed = createEmbedFromPost(fullBlockedPost); 37 + return replaceBlockedQuote(post, blockedQuoteEmbed); 38 + } 39 + return markBlockedQuoteNotFound(post, blockedQuote.uri); 40 + } 41 + 42 + function applyMutedWordsInPlace(post, preferences) { 43 + if (preferences.postHasMutedWord(post)) { 44 + if (!post.viewer) post.viewer = {}; 45 + post.viewer.hasMutedWord = true; 46 + } 47 + const quotedPost = getQuotedPost(post); 48 + if (quotedPost) { 49 + if (preferences.quotedPostHasMutedWord(quotedPost)) { 50 + quotedPost.hasMutedWord = true; 51 + } 52 + const nestedQuotedPost = getQuotedPost(quotedPost); 53 + if ( 54 + nestedQuotedPost && 55 + preferences.quotedPostHasMutedWord(nestedQuotedPost) 56 + ) { 57 + nestedQuotedPost.hasMutedWord = true; 58 + } 59 + } 60 + } 61 + 62 + function applyIsHiddenInPlace(post, preferences) { 63 + if (preferences.isPostHidden(post.uri)) { 64 + if (!post.viewer) post.viewer = {}; 65 + post.viewer.isHidden = true; 66 + } 67 + const quotedPost = getQuotedPost(post); 68 + if (quotedPost) { 69 + if (preferences.isPostHidden(quotedPost.uri)) { 70 + quotedPost.isHidden = true; 71 + } 72 + const nestedQuotedPost = getQuotedPost(quotedPost); 73 + if (nestedQuotedPost && preferences.isPostHidden(nestedQuotedPost.uri)) { 74 + nestedQuotedPost.isHidden = true; 75 + } 76 + } 77 + } 78 + 79 + function applyLabelsInPlace(post, preferences) { 80 + const badgeLabels = preferences.getBadgeLabels(post); 81 + if (badgeLabels.length > 0) { 82 + post.badgeLabels = badgeLabels; 83 + } 84 + const contentLabel = preferences.getContentLabel(post); 85 + if (contentLabel) { 86 + post.contentLabel = contentLabel; 87 + } 88 + const mediaLabel = preferences.getMediaLabel(post); 89 + if (mediaLabel) { 90 + post.mediaLabel = mediaLabel; 91 + } 92 + const quotedPost = getQuotedPost(post); 93 + if (quotedPost) { 94 + const quotedBadgeLabels = preferences.getBadgeLabels(quotedPost); 95 + if (quotedBadgeLabels.length > 0) { 96 + quotedPost.badgeLabels = quotedBadgeLabels; 97 + } 98 + const quotedContentLabel = preferences.getContentLabel(quotedPost); 99 + if (quotedContentLabel) { 100 + quotedPost.contentLabel = quotedContentLabel; 101 + } 102 + const quotedMediaLabel = preferences.getMediaLabel(quotedPost); 103 + if (quotedMediaLabel) { 104 + quotedPost.mediaLabel = quotedMediaLabel; 105 + } 106 + const nestedQuotedPost = getQuotedPost(quotedPost); 107 + if (nestedQuotedPost) { 108 + const nestedBadgeLabels = preferences.getBadgeLabels(nestedQuotedPost); 109 + if (nestedBadgeLabels.length > 0) { 110 + nestedQuotedPost.badgeLabels = nestedBadgeLabels; 111 + } 112 + const nestedContentLabel = preferences.getContentLabel(nestedQuotedPost); 113 + if (nestedContentLabel) { 114 + nestedQuotedPost.contentLabel = nestedContentLabel; 115 + } 116 + const nestedMediaLabel = preferences.getMediaLabel(nestedQuotedPost); 117 + if (nestedMediaLabel) { 118 + nestedQuotedPost.mediaLabel = nestedMediaLabel; 119 + } 120 + } 121 + } 122 + } 123 + 124 + // Composes the per-post hydrations a view typically wants. 125 + // Returns a new post object; never mutates the input. 126 + function hydratePostForView(post, { preferences, getPost }) { 127 + if (!post) return null; 128 + const resolved = resolveBlockedQuote(post, { getPost }); 129 + const result = resolved === post ? deepClone(post) : deepClone(resolved); 130 + applyMutedWordsInPlace(result, preferences); 131 + applyIsHiddenInPlace(result, preferences); 132 + applyLabelsInPlace(result, preferences); 133 + return result; 134 + } 135 + 136 + // Attach parentAuthor to a post's reply record when its parent is loaded. 137 + // Returns the input unchanged if there's no reply or the parent isn't loaded. 138 + function attachParentAuthor(post, getPost) { 139 + const parentUri = post?.record?.reply?.parent?.uri; 140 + if (!parentUri) return post; 141 + const parentPost = getPost(parentUri); 142 + if (!parentPost) return post; 143 + return { 144 + ...post, 145 + record: { 146 + ...post.record, 147 + reply: { 148 + // NOTE: LEXICON DEVIATION 149 + ...post.record.reply, 150 + parentAuthor: parentPost.author, 151 + }, 152 + }, 153 + }; 154 + } 155 + 156 + function hydrateNotifications(notifications, { getPost }) { 157 + if (!notifications) return null; 158 + return notifications.map((notification) => { 159 + if (notification.reason === "like" || notification.reason === "repost") { 160 + const subject = 161 + getPost(notification.reasonSubject) ?? 162 + createUnavailablePost(notification.reasonSubject); 163 + return { ...notification, subject }; 164 + } 165 + if ( 166 + notification.reason === "like-via-repost" || 167 + notification.reason === "repost-via-repost" 168 + ) { 169 + const postUri = notification.record.subject.uri; 170 + const subject = getPost(postUri) ?? createUnavailablePost(postUri); 171 + return { ...notification, subject }; 172 + } 173 + if ( 174 + notification.reason === "reply" || 175 + notification.reason === "mention" || 176 + notification.reason === "quote" 177 + ) { 178 + const replyPost = getPost(notification.uri); 179 + const parentPostUri = notification.record?.reply?.parent?.uri; 180 + const parentPost = parentPostUri ? getPost(parentPostUri) : null; 181 + return { ...notification, post: replyPost, parentPost }; 182 + } 183 + if (notification.reason === "subscribed-post") { 184 + const post = getPost(notification.uri); 185 + // NOTE: LEXICON DEVIATION 186 + return { ...notification, reasonSubject: post }; 187 + } 188 + return notification; 189 + }); 190 + } 191 + 192 + function hydratePostThreadNode(node, { getPost, hiddenReplyUris }) { 193 + if (!node || isEmptyPost(node)) return node; 194 + const post = getPost(node.post.uri); 195 + if (!post) return null; 196 + const hydrated = { post }; 197 + if (hiddenReplyUris.includes(node.post.uri)) { 198 + // NOTE: LEXICON DEVIATION 199 + hydrated.post = { ...post, isHidden: true }; 200 + } 201 + if (node.replies) { 202 + hydrated.replies = node.replies.map((reply) => { 203 + if (reply.$type === "app.bsky.feed.defs#threadViewPost") { 204 + return hydratePostThreadNode(reply, { getPost, hiddenReplyUris }); 205 + } 206 + return reply; 207 + }); 208 + } 209 + return hydrated; 210 + } 211 + 212 + function hydratePostThreadParent(parent, { getPost, isUnavailable }) { 213 + if (isUnavailable(parent.uri)) { 214 + return createUnavailablePost(parent.uri); 215 + } 216 + if (isBlockedPost(parent) && isBlockingUser(parent)) { 217 + return parent; 218 + } 219 + if (parent.$type !== "app.bsky.feed.defs#threadViewPost") { 220 + return parent; 221 + } 222 + const hydratedParent = { 223 + $type: "app.bsky.feed.defs#threadViewPost", 224 + post: getPost(parent.post.uri), 225 + }; 226 + if (parent.parent) { 227 + hydratedParent.parent = hydratePostThreadParent(parent.parent, { 228 + getPost, 229 + isUnavailable, 230 + }); 231 + } 232 + return hydratedParent; 233 + } 234 + 235 + export class Signals extends ReactiveStore { 236 + constructor( 237 + dataStore, 238 + patchStore, 239 + preferencesProvider, 240 + pluginService, 241 + isAuthenticated, 242 + ) { 243 + super("signals"); 244 + this.dataStore = dataStore; 245 + this.patchStore = patchStore; 246 + this.preferencesProvider = preferencesProvider; 247 + this.pluginService = pluginService; 248 + this.isAuthenticated = isAuthenticated; 249 + this.$showLessInteractions = new Signal.Computed(() => 250 + this.dataStore.$showLessInteractions.get(), 251 + ); 252 + this.$hydratedPosts = new ComputedMap((uri) => { 253 + const post = this.patchStore.$patchedPosts.get(uri).get(); 254 + const preferences = this.$preferences.get(); 255 + if (!post || !preferences) { 256 + return null; 257 + } 258 + return hydratePostForView(post, { 259 + preferences, 260 + getPost: (uri) => this.$hydratedPosts.get(uri).get(), 261 + }); 262 + }); 263 + this.$hydratedFeeds = new ComputedMap((feedURI) => { 264 + const feed = this.dataStore.$feeds.get(feedURI).get(); 265 + if (!feed) { 266 + return null; 267 + } 268 + const hydratedFeedItems = []; 269 + for (const feedItem of feed.feed) { 270 + const hydratedFeedItem = { 271 + feedContext: feedItem.feedContext, 272 + post: this.$hydratedPosts.get(feedItem.post.uri).get(), 273 + }; 274 + if (feedItem.reason) { 275 + hydratedFeedItem.reason = feedItem.reason; 276 + } 277 + const reply = feedItem.reply; 278 + if (reply) { 279 + let root = reply.root; 280 + if (isPostView(root)) { 281 + root = this.$hydratedPosts.get(root.uri).get(); 282 + } 283 + let parent = reply.parent; 284 + if (isPostView(parent)) { 285 + parent = this.$hydratedPosts.get(parent.uri).get(); 286 + } 287 + hydratedFeedItem.reply = { ...reply, root, parent }; 288 + } 289 + hydratedFeedItems.push(hydratedFeedItem); 290 + } 291 + const hydratedFeed = { 292 + feed: hydratedFeedItems, 293 + cursor: feed.cursor, 294 + }; 295 + const pluginFilteredFeedItems = 296 + this.pluginService.$pluginFilteredFeedItems.get(feedURI).get() ?? {}; 297 + if (feedURI === "following") { 298 + const currentUser = this.$currentUser.get(); 299 + const preferences = this.$preferences.get(); 300 + return filterFollowingFeed( 301 + hydratedFeed, 302 + currentUser, 303 + preferences, 304 + pluginFilteredFeedItems, 305 + ); 306 + } else { 307 + return filterAlgorithmicFeed( 308 + hydratedFeed, 309 + this.isAuthenticated, 310 + pluginFilteredFeedItems, 311 + ); 312 + } 313 + }); 314 + this.$currentUser = new Signal.Computed(() => { 315 + const user = this.dataStore.$currentUser.get(); 316 + const patches = this.patchStore.$currentUserPatches.get(); 317 + return this.patchStore.applyCurrentUserPatches(user, patches); 318 + }); 319 + this.$preferences = new Signal.Computed(() => { 320 + const preferences = this.preferencesProvider.$preferences.get(); 321 + if (!preferences) return null; 322 + const patches = this.patchStore.$preferencePatches.get(); 323 + return this.patchStore.applyPreferencePatches(preferences, patches); 324 + }); 325 + const getHydratedPost = (uri) => this.$hydratedPosts.get(uri).get(); 326 + this.$notifications = new Signal.Computed(() => 327 + hydrateNotifications(this.dataStore.$notifications.get(), { 328 + getPost: getHydratedPost, 329 + }), 330 + ); 331 + this.$mentionNotifications = new Signal.Computed(() => 332 + hydrateNotifications(this.dataStore.$mentionNotifications.get(), { 333 + getPost: getHydratedPost, 334 + }), 335 + ); 336 + this.$hydratedPostThreads = new ComputedMap((postURI) => { 337 + const postThread = this.dataStore.$postThreads.get(postURI).get(); 338 + const postThreadOther = this.dataStore.$postThreadOthers 339 + .get(postURI) 340 + .get(); 341 + if (!postThread || !postThreadOther) { 342 + return null; 343 + } 344 + if (isEmptyPost(postThread)) { 345 + return postThread; 346 + } 347 + const hiddenReplyUris = postThreadOther.map((item) => item.uri); 348 + const hydrated = hydratePostThreadNode(postThread, { 349 + getPost: getHydratedPost, 350 + hiddenReplyUris, 351 + }); 352 + if (!hydrated) { 353 + return null; 354 + } 355 + if (postThread.parent) { 356 + hydrated.parent = hydratePostThreadParent(postThread.parent, { 357 + getPost: getHydratedPost, 358 + isUnavailable: (uri) => 359 + this.dataStore.$unavailablePosts.get(uri).get() !== null, 360 + }); 361 + } 362 + return hydrated; 363 + }); 364 + this.$hydratedHashtagFeeds = new ComputedMap((hashtagKey) => { 365 + const feed = this.dataStore.$hashtagFeeds.get(hashtagKey).get(); 366 + if (!feed) { 367 + return null; 368 + } 369 + const hydratedFeedItems = []; 370 + for (const feedItem of feed.feed) { 371 + const post = this.$hydratedPosts.get(feedItem.post.uri).get(); 372 + if (!post) continue; 373 + hydratedFeedItems.push({ 374 + post: attachParentAuthor(post, getHydratedPost), 375 + }); 376 + } 377 + return { 378 + feed: hydratedFeedItems, 379 + cursor: feed.cursor, 380 + }; 381 + }); 382 + this.$feedGenerators = new ComputedMap((feedUri) => 383 + this.dataStore.$feedGenerators.get(feedUri).get(), 384 + ); 385 + this.$profileSearchResults = new Signal.Computed(() => { 386 + const data = this.dataStore.$profileSearchResults.get(); 387 + if (!data) return null; 388 + return data.actors; 389 + }); 390 + this.$profileSearchCursor = new Signal.Computed( 391 + () => this.dataStore.$profileSearchResults.get()?.cursor ?? null, 392 + ); 393 + this.$feedSearchResults = new Signal.Computed(() => { 394 + const data = this.dataStore.$feedSearchResults.get(); 395 + if (!data) return null; 396 + return data.feeds; 397 + }); 398 + this.$feedSearchCursor = new Signal.Computed( 399 + () => this.dataStore.$feedSearchResults.get()?.cursor ?? null, 400 + ); 401 + this.$postSearchResults = new Signal.Computed(() => { 402 + const data = this.dataStore.$postSearchResults.get(); 403 + if (!data) return null; 404 + const hydratedSearchResults = []; 405 + for (const result of data.posts) { 406 + const post = this.$hydratedPosts.get(result.uri).get(); 407 + if (!post) continue; 408 + hydratedSearchResults.push(attachParentAuthor(post, getHydratedPost)); 409 + } 410 + return hydratedSearchResults; 411 + }); 412 + this.$postSearchCursor = new Signal.Computed( 413 + () => this.dataStore.$postSearchResults.get()?.cursor ?? null, 414 + ); 415 + this.$hydratedPostQuotes = new ComputedMap((postUri) => { 416 + const quotes = this.dataStore.$postQuotes.get(postUri).get(); 417 + if (!quotes) { 418 + return null; 419 + } 420 + const hydratedPosts = []; 421 + for (const quote of quotes.posts) { 422 + const post = this.$hydratedPosts.get(quote.uri).get(); 423 + if (!post) continue; 424 + hydratedPosts.push(attachParentAuthor(post, getHydratedPost)); 425 + } 426 + return { 427 + posts: hydratedPosts, 428 + cursor: quotes.cursor, 429 + }; 430 + }); 431 + this.$hydratedPinnedFeedGenerators = new Signal.Computed(() => { 432 + const pinned = this.dataStore.$pinnedFeedGenerators.get(); 433 + if (!pinned) return null; 434 + const hydrated = []; 435 + if (this.isAuthenticated) { 436 + hydrated.push({ uri: "following", displayName: "Following" }); 437 + } 438 + for (const pin of pinned) { 439 + hydrated.push(this.$feedGenerators.get(pin.uri).get()); 440 + } 441 + return hydrated; 442 + }); 443 + this.$hydratedProfiles = new ComputedMap((did) => 444 + this.patchStore.$patchedProfiles.get(did).get(), 445 + ); 446 + this.$hydratedAuthorFeeds = new ComputedMap((feedURI) => { 447 + const rawFeed = this.dataStore.$authorFeeds.get(feedURI).get(); 448 + if (!rawFeed) { 449 + return null; 450 + } 451 + const patches = 452 + this.patchStore.$authorFeedPatches.get(feedURI).get() || []; 453 + let feed = { feed: [...rawFeed.feed], cursor: rawFeed.cursor }; 454 + for (const patch of patches) { 455 + feed = this.patchStore.applyAuthorFeedPatch(feed, patch.body); 456 + } 457 + const hydratedFeedItems = []; 458 + for (const feedItem of feed.feed) { 459 + const hydratedFeedItem = { 460 + post: this.$hydratedPosts.get(feedItem.post.uri).get(), 461 + }; 462 + if (feedItem.reason) { 463 + hydratedFeedItem.reason = feedItem.reason; 464 + } 465 + if (feedItem.reply) { 466 + hydratedFeedItem.reply = { 467 + ...feedItem.reply, 468 + root: this.$hydratedPosts.get(feedItem.reply.root.uri).get(), 469 + parent: this.$hydratedPosts.get(feedItem.reply.parent.uri).get(), 470 + }; 471 + } 472 + hydratedFeedItems.push(hydratedFeedItem); 473 + } 474 + let hydratedFeed = { 475 + feed: hydratedFeedItems, 476 + cursor: feed.cursor, 477 + }; 478 + const dashIndex = feedURI.lastIndexOf("-"); 479 + const feedType = dashIndex >= 0 ? feedURI.slice(dashIndex + 1) : ""; 480 + if (feedType === "replies") { 481 + const filteredFeedItems = []; 482 + for (const feedItem of hydratedFeed.feed) { 483 + if (feedItem.reply && !feedItem.reason) { 484 + filteredFeedItems.push(feedItem); 485 + } 486 + } 487 + hydratedFeed = { 488 + feed: filteredFeedItems, 489 + cursor: hydratedFeed.cursor, 490 + }; 491 + } 492 + return filterAuthorFeed(hydratedFeed, this.isAuthenticated); 493 + }); 494 + this.$actorFeeds = new ComputedMap((did) => 495 + this.dataStore.$actorFeeds.get(did).get(), 496 + ); 497 + this.$profileChatStatus = new ComputedMap((did) => 498 + this.dataStore.$profileChatStatus.get(did).get(), 499 + ); 500 + this.$labelerInfo = new ComputedMap((did) => 501 + this.dataStore.$labelerInfo.get(did).get(), 502 + ); 503 + this.$hydratedBookmarks = new Signal.Computed(() => { 504 + const bookmarks = this.dataStore.$bookmarks.get(); 505 + if (!bookmarks) { 506 + return null; 507 + } 508 + const hydratedFeed = []; 509 + for (const bookmark of bookmarks.feed) { 510 + const post = this.$hydratedPosts.get(bookmark.post.uri).get(); 511 + hydratedFeed.push({ post: attachParentAuthor(post, getHydratedPost) }); 512 + } 513 + return filterBookmarksFeed({ 514 + feed: hydratedFeed, 515 + cursor: bookmarks.cursor, 516 + }); 517 + }); 518 + this.$labelerSettings = new ComputedMap((labelerDid) => { 519 + const preferences = this.$preferences.get(); 520 + if (!preferences) return null; 521 + return preferences.getLabelerSettings(labelerDid); 522 + }); 523 + this.$convos = new ComputedMap((convoId) => 524 + this.dataStore.$convos.get(convoId).get(), 525 + ); 526 + this.$convoList = new Signal.Computed(() => { 527 + const convoList = this.dataStore.$convoList.get(); 528 + if (!convoList) return null; 529 + const hydrated = convoList.map((convo) => 530 + this.$convos.get(convo.id).get(), 531 + ); 532 + return sortBy( 533 + hydrated, 534 + (convo) => new Date(getLastInteractionTimestamp(convo)), 535 + { direction: "desc" }, 536 + ); 537 + }); 538 + this.$convoListCursor = new Signal.Computed(() => 539 + this.dataStore.$convoListCursor.get(), 540 + ); 541 + this.$convoForProfile = new ComputedMap((profileDid) => { 542 + const convoIds = this.dataStore.$convos.$keys.get(); 543 + for (const convoId of convoIds) { 544 + const convo = this.dataStore.$convos.get(convoId).get(); 545 + if (!convo) continue; 546 + if ( 547 + convo.members.length === 2 && 548 + convo.members.some((member) => member.did === profileDid) 549 + ) { 550 + return this.$convos.get(convo.id).get(); 551 + } 552 + } 553 + return null; 554 + }); 555 + this.$convoMessages = new ComputedMap((convoId) => { 556 + const messages = this.dataStore.$convoMessages.get(convoId).get(); 557 + if (!messages) return null; 558 + return { 559 + messages: messages.messages.map((message) => 560 + this.patchStore.$patchedMessages.get(message.id).get(), 561 + ), 562 + cursor: messages.cursor, 563 + }; 564 + }); 565 + } 566 + }
+5
src/js/plugins/pluginPreferencesManager.js
··· 1 + import { Signal } from "/js/utils.js"; 2 + 1 3 // Handles persisting plugin settings in user preferences 2 4 export class PluginPreferencesManager { 3 5 constructor(preferencesProvider) { 4 6 this.preferencesProvider = preferencesProvider; 7 + this.$installedPlugins = new Signal.Computed( 8 + () => preferencesProvider.$preferences.get()?.getInstalledPlugins() ?? [], 9 + ); 5 10 } 6 11 7 12 getInstalledPlugins() {
+34 -26
src/js/plugins/pluginService.js
··· 20 20 showPluginInstallPermissionsModal, 21 21 showPluginUpdatePermissionsModal, 22 22 } from "/js/modals.js"; 23 - import { compareVersions, isDev, sortBy } from "/js/utils.js"; 23 + import { compareVersions, isDev, sortBy, SignalMap } from "/js/utils.js"; 24 24 import { EventEmitter } from "/js/eventEmitter.js"; 25 25 import { PLUGIN_REGISTRY_URL } from "/js/config.js"; 26 26 ··· 69 69 sidebarItems: new Set(), 70 70 eventListeners: new Map(), 71 71 feedFilters: new Set(), 72 - settingTabs: new Map(), 73 - slots: new Map(), 74 72 }; 75 73 this._availableUpdates = null; 76 74 this._registryListings = null; 75 + this.$pluginFilteredFeedItems = new SignalMap(); 76 + this.$settingTabs = new SignalMap(); 77 + this.$slots = new SignalMap(); 77 78 this.localPluginsEnabled = isDev(); 78 79 this.remoteRegistry = new RemotePluginRegistry(PLUGIN_REGISTRY_URL); 79 80 this.localRegistry = this.localPluginsEnabled ··· 143 144 display: () => plugin.call(message.displayHandlerId), 144 145 hide: () => plugin.call(message.hideHandlerId), 145 146 }; 146 - this.registries.settingTabs.set(plugin.pluginId, entry); 147 + this.$settingTabs.set(plugin.pluginId, entry); 147 148 return () => { 148 - if (this.registries.settingTabs.get(plugin.pluginId) === entry) { 149 - this.registries.settingTabs.delete(plugin.pluginId); 149 + if (this.$settingTabs.get(plugin.pluginId).get() === entry) { 150 + this.$settingTabs.delete(plugin.pluginId); 150 151 } 151 152 }; 152 153 }); ··· 160 161 return () => this.registries.feedFilters.delete(entry); 161 162 }); 162 163 this.pluginBridge.addRegistrationTarget("slot", (plugin, message) => { 163 - let entries = this.registries.slots.get(message.name); 164 - if (!entries) { 165 - entries = []; 166 - this.registries.slots.set(message.name, entries); 167 - } 168 164 const entry = { 169 165 pluginId: plugin.pluginId, 170 166 invoke: (context) => plugin.call(message.handlerId, context), 171 167 }; 172 - entries.push(entry); 173 - this.emit("slotRegistered", { name: message.name }); 168 + const current = this.$slots.get(message.name).get() ?? []; 169 + this.$slots.set(message.name, [...current, entry]); 174 170 return () => { 175 - const list = this.registries.slots.get(message.name); 171 + const list = this.$slots.get(message.name).get(); 176 172 if (!list) return; 177 - const index = list.indexOf(entry); 178 - if (index === -1) return; 179 - list.splice(index, 1); 180 - if (list.length === 0) this.registries.slots.delete(message.name); 181 - this.emit("slotUnregistered", { name: message.name }); 173 + const next = list.filter((other) => other !== entry); 174 + if (next.length === 0) { 175 + this.$slots.delete(message.name); 176 + } else { 177 + this.$slots.set(message.name, next); 178 + } 182 179 }; 183 180 }); 184 181 } ··· 215 212 }); 216 213 217 214 this.pluginBridge.addHostMethod("refreshSettingTab", (plugin) => { 218 - this.emit("settingTabRefresh", { pluginId: plugin.pluginId }); 215 + const current = this.$settingTabs.get(plugin.pluginId).get(); 216 + if (current) { 217 + this.$settingTabs.set(plugin.pluginId, { ...current }); 218 + } 219 219 }); 220 220 221 221 this.pluginBridge.addHostMethod( ··· 265 265 }); 266 266 267 267 this.pluginBridge.addHostMethod("getPost", (plugin, { uri }) => { 268 - return this._dataLayer?.selectors.getPost(uri) ?? null; 268 + return this._dataLayer?.signals.$hydratedPosts.get(uri).get() ?? null; 269 269 }); 270 270 271 271 this.pluginBridge.addHostMethod("getProfile", (plugin, { did }) => { 272 - return this._dataLayer?.base.getProfile(did) ?? null; 272 + return this._dataLayer?.signals.$hydratedProfiles.get(did).get() ?? null; 273 273 }); 274 274 275 275 this.pluginBridge.addHostMethod("getCurrentUser", () => { ··· 379 379 author: entry.author, 380 380 enabled: entry.enabled, 381 381 loaded: this.pluginBridge.isLoaded(entry.id), 382 - hasSettings: this.registries.settingTabs.has(entry.id), 382 + hasSettings: this.$settingTabs.get(entry.id).get() !== null, 383 383 }; 384 384 }); 385 385 } ··· 624 624 } 625 625 626 626 getSlotEntries(name) { 627 - return [...(this.registries.slots.get(name) ?? [])]; 627 + return [...(this.$slots.get(name).get() ?? [])]; 628 628 } 629 629 630 630 getSettingTabs() { 631 - return [...this.registries.settingTabs.values()]; 631 + return [...this.$settingTabs.values()]; 632 632 } 633 633 634 634 getSettingTab(pluginId) { 635 - return this.registries.settingTabs.get(pluginId) ?? null; 635 + return this.$settingTabs.get(pluginId).get(); 636 636 } 637 637 638 638 async getPostContextMenuItems(post) { ··· 685 685 filteredFeedItems = { ...filteredFeedItems, ...results }; 686 686 } 687 687 return filteredFeedItems; 688 + } 689 + 690 + async refreshFiltersForFeed(feedURI, feed, { reload = false } = {}) { 691 + const filtered = await this.getFilteredFeedItems(feedURI, feed); 692 + const existing = reload 693 + ? {} 694 + : (this.$pluginFilteredFeedItems.get(feedURI).get() ?? {}); 695 + this.$pluginFilteredFeedItems.set(feedURI, { ...existing, ...filtered }); 688 696 } 689 697 }
+8 -22
src/js/views/bookmarks.view.js
··· 4 4 import { auth } from "/js/auth.js"; 5 5 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 6 6 import { headerTemplate } from "/js/templates/header.template.js"; 7 - import { bindToPage } from "/js/router.js"; 7 + import { pageEffect } from "/js/router.js"; 8 8 import { BOOKMARKS_PAGE_SIZE } from "/js/config.js"; 9 9 10 10 class BookmarksView extends View { ··· 24 24 await auth.requireAuth(); 25 25 26 26 const { postInteractionHandler } = interactionHandlers; 27 - bindToPage(root, interactionHandlers, "requestRender", () => renderPage()); 28 27 29 28 async function scrollAndReloadBookmarks() { 30 29 if (window.scrollY > 0) { ··· 33 32 await loadBookmarks({ reload: true }); 34 33 } 35 34 36 - function renderPage() { 35 + pageEffect(root, () => { 37 36 const numNotifications = 38 - notificationService?.getNumNotifications() ?? null; 37 + notificationService?.$numNotifications.get() ?? null; 39 38 const numChatNotifications = 40 - chatNotificationService?.getNumNotifications() ?? null; 41 - const currentUser = dataLayer.selectors.getCurrentUser(); 42 - const bookmarks = dataLayer.selectors.getBookmarks(); 39 + chatNotificationService?.$numNotifications.get() ?? null; 40 + const currentUser = dataLayer.signals.$currentUser.get(); 41 + const bookmarks = dataLayer.signals.$hydratedBookmarks.get(); 43 42 44 43 render( 45 44 html`<div id="bookmarks-view"> ··· 72 71 </div>`, 73 72 root, 74 73 ); 75 - } 74 + }); 76 75 77 76 async function loadBookmarks({ reload = false } = {}) { 78 77 await dataLayer.requests.loadBookmarks({ 79 78 reload, 80 79 limit: BOOKMARKS_PAGE_SIZE + 1, 81 80 }); 82 - renderPage(); 83 81 } 84 82 85 83 root.addEventListener("page-enter", async () => { 86 84 window.scrollTo(0, 0); 87 - 88 - // Initial empty state 89 - renderPage(); 90 - 91 - dataLayer.declarative.ensureCurrentUser().then(() => { 92 - renderPage(); 93 - }); 94 - 85 + dataLayer.declarative.ensureCurrentUser(); 95 86 await loadBookmarks(); 96 87 }); 97 88 98 89 root.addEventListener("page-restore", (e) => { 99 90 const scrollY = e.detail?.scrollY ?? 0; 100 91 window.scrollTo(0, scrollY); 101 - renderPage(); 102 92 }); 103 - 104 - bindToPage(root, notificationService, "update", () => renderPage()); 105 - 106 - bindToPage(root, chatNotificationService, "update", () => renderPage()); 107 93 } 108 94 } 109 95
+12 -17
src/js/views/chat.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 - import { bindToPage } from "/js/router.js"; 2 + import { pageEffect } from "/js/router.js"; 3 3 import { html, render } from "/js/lib/lit-html.js"; 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 5 import { auth } from "/js/auth.js"; ··· 165 165 } 166 166 167 167 function renderPage() { 168 - const currentUser = dataLayer.selectors.getCurrentUser(); 168 + const currentUser = dataLayer.signals.$currentUser.get(); 169 169 const numNotifications = 170 - notificationService?.getNumNotifications() ?? null; 170 + notificationService?.$numNotifications.get() ?? null; 171 171 const numChatNotifications = 172 - chatNotificationService?.getNumNotifications() ?? null; 173 - const convos = dataLayer.selectors.getConvoList(); 174 - const convosRequestStatus = dataLayer.requests.getStatus("loadConvoList"); 175 - const cursor = dataLayer.selectors.getConvoListCursor(); 172 + chatNotificationService?.$numNotifications.get() ?? null; 173 + const convos = dataLayer.signals.$convoList.get(); 174 + const convosRequestStatus = dataLayer.requests.statusStore.$statuses 175 + .get("loadConvoList") 176 + .get(); 177 + const cursor = dataLayer.signals.$convoListCursor.get(); 176 178 const hasMore = !!cursor; 177 179 178 180 render( ··· 234 236 } 235 237 236 238 async function loadConvoList({ reload = false } = {}) { 237 - const loadingPromise = dataLayer.requests.loadConvoList({ 239 + await dataLayer.requests.loadConvoList({ 238 240 reload, 239 241 limit: 30, 240 242 }); 241 - renderPage(); 242 - await loadingPromise; 243 - renderPage(); 244 243 } 245 244 245 + pageEffect(root, renderPage, "chat-view"); 246 + 246 247 root.addEventListener("page-enter", async () => { 247 - renderPage(); 248 248 await dataLayer.declarative.ensureCurrentUser(); 249 249 await loadConvoList({ reload: true }); 250 250 }); ··· 258 258 window.scrollTo(0, 0); 259 259 await loadConvoList({ reload: true }); 260 260 } 261 - renderPage(); 262 261 }); 263 - 264 - bindToPage(root, notificationService, "update", () => renderPage()); 265 - 266 - bindToPage(root, chatNotificationService, "update", () => renderPage()); 267 262 } 268 263 } 269 264
+49 -119
src/js/views/chatDetail.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 - import { bindToPage } from "/js/router.js"; 2 + import { pageEffect } from "/js/router.js"; 3 3 import { html, render, ref } from "/js/lib/lit-html.js"; 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 5 import { richTextTemplate } from "/js/templates/richText.template.js"; ··· 11 11 import { postEmbedTemplate } from "/js/templates/postEmbed.template.js"; 12 12 import { CHAT_MESSAGES_PAGE_SIZE } from "/js/config.js"; 13 13 import { showToast } from "/js/toasts.js"; 14 - import { wait, raf, differenceInMinutes } from "/js/utils.js"; 15 - import { EventEmitter } from "/js/eventEmitter.js"; 14 + import { wait, raf, differenceInMinutes, Signal } from "/js/utils.js"; 16 15 import { hapticsImpactMedium } from "/js/haptics.js"; 17 16 import "/js/components/infinite-scroll-container.js"; 18 17 import "/js/components/chat-input.js"; ··· 49 48 context: { 50 49 dataLayer, 51 50 notificationService, 52 - api, 53 51 chatNotificationService, 54 52 identityResolver, 55 53 postComposerService, ··· 60 58 61 59 const convoId = params.convoId; 62 60 63 - const state = { 64 - loadingEnabled: false, 65 - isSendingMessage: false, 66 - selectedMessageId: null, 67 - }; 61 + const $loadingEnabled = new Signal.State(false); 62 + const $isSendingMessage = new Signal.State(false); 63 + const $selectedMessageId = new Signal.State(null); 68 64 69 65 function focusChatInput() { 70 66 const chatInput = root.querySelector("chat-input"); ··· 110 106 ); 111 107 } 112 108 113 - class MessageFetcher extends EventEmitter { 114 - constructor(dataLayer, api, convoId, currentUser) { 115 - super(); 109 + class MessageFetcher { 110 + constructor(dataLayer, convoId) { 116 111 this.dataLayer = dataLayer; 117 - this.api = api; 118 112 this.convoId = convoId; 119 - this.currentUser = currentUser; 120 113 this._isPolling = false; 121 114 this._cursor = ""; 122 115 } ··· 135 128 136 129 async runLoop() { 137 130 while (this._isPolling) { 138 - await this.fetchMessages(); 131 + this._cursor = await this.dataLayer.requests.pollConvoMessages( 132 + this.convoId, 133 + { cursor: this._cursor }, 134 + ); 139 135 await wait(5000); 140 - } 141 - } 142 - 143 - async fetchMessages() { 144 - const res = await this.api.getChatLogs({ cursor: this._cursor }); 145 - this._cursor = res.cursor; 146 - const logsForConvo = res.logs.filter( 147 - (log) => log.convoId === this.convoId, 148 - ); 149 - for (const log of logsForConvo) { 150 - if (log.$type === "chat.bsky.convo.defs#logCreateMessage") { 151 - if (log.message.sender.did === this.currentUser.did) { 152 - // Skip if the message is from the current user, since we already set it in the store 153 - continue; 154 - } 155 - const convoMessages = this.dataLayer.selectors.getConvoMessages( 156 - this.convoId, 157 - ); 158 - if (!convoMessages) { 159 - console.warn("No messages data found for convoId", this.convoId); 160 - return; 161 - } 162 - // set new message in store 163 - this.dataLayer.dataStore.setMessage(log.message.id, log.message); 164 - this.dataLayer.dataStore.setConvoMessages(this.convoId, { 165 - messages: [log.message, ...convoMessages.messages], 166 - cursor: convoMessages.cursor, 167 - }); 168 - this.emit("message"); 169 - } 170 136 } 171 137 } 172 138 } 173 139 174 - let messageFetcher = null; 140 + const messageFetcher = new MessageFetcher(dataLayer, convoId); 175 141 176 142 function closeReactionPalette() { 177 - state.selectedMessageId = null; 178 - renderPage(); 143 + $selectedMessageId.set(null); 179 144 } 180 145 181 146 async function handleEmojiSelect(emoji, messageId, currentUserDid) { ··· 187 152 currentUserDid, 188 153 ); 189 154 closeReactionPalette(); 190 - renderPage(); 191 155 } catch (error) { 192 156 console.error(error); 193 157 showToast("Failed to add reaction", { style: "error" }); ··· 195 159 } 196 160 197 161 async function handleReactionClick(emoji, messageId, isOwnReaction) { 198 - if (isOwnReaction) { 199 - // Remove reaction 200 - try { 201 - const promise = dataLayer.mutations.removeMessageReaction( 162 + try { 163 + if (isOwnReaction) { 164 + await dataLayer.mutations.removeMessageReaction( 202 165 convoId, 203 166 messageId, 204 167 emoji, 205 168 ); 206 - // optimistic update 207 - renderPage(); 208 - await promise; 209 - renderPage(); 210 - } catch (error) { 211 - console.error(error); 212 - showToast("Failed to remove reaction", { style: "error" }); 213 - } 214 - } else { 215 - // Add reaction 216 - try { 217 - const promise = dataLayer.mutations.addMessageReaction( 169 + } else { 170 + await dataLayer.mutations.addMessageReaction( 218 171 convoId, 219 172 messageId, 220 173 emoji, 221 174 ); 222 - // optimistic update 223 - renderPage(); 224 - await promise; 225 - renderPage(); 226 - } catch (error) { 227 - console.error(error); 228 - showToast("Failed to add reaction", { style: "error" }); 229 175 } 176 + } catch (error) { 177 + console.error(error); 178 + showToast( 179 + isOwnReaction 180 + ? "Failed to remove reaction" 181 + : "Failed to add reaction", 182 + { style: "error" }, 183 + ); 230 184 } 231 185 } 232 186 233 187 function handleLongPress(message) { 234 188 hapticsImpactMedium(); 235 - state.selectedMessageId = message.id; 236 - renderPage(); 189 + $selectedMessageId.set(message.id); 237 190 // close on click outside 238 191 setTimeout(() => { 239 192 document.addEventListener("click", () => closeReactionPalette(), { ··· 243 196 } 244 197 245 198 async function handleSendMessage(messageText) { 246 - state.isSendingMessage = true; 247 - renderPage(); 199 + $isSendingMessage.set(true); 248 200 try { 249 201 const facets = await getFacetsFromText(messageText, identityResolver); 250 202 await dataLayer.mutations.createMessage(convoId, { 251 203 text: messageText, 252 204 facets, 253 205 }); 254 - renderPage(); 255 206 scrollToBottom(); 256 207 } catch (error) { 257 208 console.error(error); 258 209 showToast("Failed to send message", { style: "error" }); 259 210 } finally { 260 - state.isSendingMessage = false; 261 - renderPage(); 211 + $isSendingMessage.set(false); 262 212 focusChatInput(); 263 213 } 264 214 } ··· 332 282 return ""; 333 283 } 334 284 335 - const currentUser = dataLayer.selectors.getCurrentUser(); 285 + const currentUser = dataLayer.signals.$currentUser.get(); 336 286 337 287 // Group reactions by emoji 338 288 const reactionGroups = reactions.reduce((acc, reaction) => { ··· 507 457 showAvatar: index === 0, 508 458 otherMember, 509 459 onLongPress: (msg, e) => handleLongPress(msg, e), 510 - isSelected: state.selectedMessageId === message.id, 460 + isSelected: $selectedMessageId.get() === message.id, 511 461 }), 512 462 )} 513 463 <div ··· 612 562 } 613 563 614 564 function renderPage() { 615 - const currentUser = dataLayer.selectors.getCurrentUser(); 565 + const currentUser = dataLayer.signals.$currentUser.get(); 616 566 const numNotifications = 617 - notificationService?.getNumNotifications() ?? null; 567 + notificationService?.$numNotifications.get() ?? null; 618 568 const numChatNotifications = 619 - chatNotificationService?.getNumNotifications() ?? 0; 620 - const messagesData = dataLayer.selectors.getConvoMessages(convoId); 569 + chatNotificationService?.$numNotifications.get() ?? 0; 570 + const messagesData = dataLayer.signals.$convoMessages.get(convoId).get(); 621 571 const messages = messagesData?.messages ?? null; 622 - const messagesRequestStatus = dataLayer.requests.getStatus( 623 - "loadConvoMessages-" + convoId, 624 - ); 572 + const messagesRequestStatus = dataLayer.requests.statusStore.$statuses 573 + .get("loadConvoMessages-" + convoId) 574 + .get(); 625 575 const hasMore = !!messagesData?.cursor; 576 + const isSendingMessage = $isSendingMessage.get(); 626 577 627 578 // Get convo details to show other member info 628 - const convo = dataLayer.selectors.getConvo(convoId); 579 + const convo = dataLayer.signals.$convos.get(convoId).get(); 629 580 const otherMember = getOtherMember(currentUser, convo); 630 581 const title = otherMember ? getDisplayName(otherMember) : ""; 631 582 ··· 658 609 }); 659 610 } else if (messages) { 660 611 return messagesTemplate({ 661 - loadingEnabled: state.loadingEnabled, 612 + loadingEnabled: $loadingEnabled.get(), 662 613 messages, 663 614 currentUserDid: currentUser?.did, 664 615 otherMember, ··· 689 640 } 690 641 } 691 642 }} 692 - ?disabled=${!messages || state.isSendingMessage} 693 - ?loading=${state.isSendingMessage} 643 + ?disabled=${!messages || isSendingMessage} 644 + ?loading=${isSendingMessage} 694 645 ></chat-input> 695 646 </div> 696 647 `, ··· 700 651 ); 701 652 } 702 653 703 - async function loadMessages({ reload = false, renderOnLoad = true } = {}) { 704 - const loadingPromise = dataLayer.requests.loadConvoMessages(convoId, { 654 + async function loadMessages({ reload = false } = {}) { 655 + await dataLayer.requests.loadConvoMessages(convoId, { 705 656 reload, 706 657 limit: CHAT_MESSAGES_PAGE_SIZE, 707 658 }); 708 - renderPage(); 709 - await loadingPromise; 710 - if (renderOnLoad) { 711 - renderPage(); 712 - } 713 659 // can be async 714 660 dataLayer.mutations.markConvoAsRead(convoId); 715 661 chatNotificationService?.markNotificationsAsReadForConvo(convoId); 716 662 } 717 663 664 + pageEffect(root, renderPage, "chat-detail-view"); 665 + 718 666 root.addEventListener("page-enter", async () => { 719 - renderPage(); 720 - dataLayer.declarative.ensureCurrentUser().then((currentUser) => { 721 - renderPage(); 722 - // Initialize message fetcher 723 - messageFetcher = new MessageFetcher( 724 - dataLayer, 725 - api, 726 - convoId, 727 - currentUser, 728 - ); 729 - messageFetcher.on("message", () => { 730 - renderPage(); 731 - }); 667 + dataLayer.declarative.ensureCurrentUser().then(() => { 732 668 messageFetcher.start(); 733 669 }); 734 670 await dataLayer.declarative.ensureConvo(convoId); ··· 736 672 // Scroll to bottom of messages 737 673 scrollToBottom({ onlyIfNeeded: true }); 738 674 // Only enable loading after scroll, otherwise the infinite scroll container will start loading immediately 739 - state.loadingEnabled = true; 740 - renderPage(); 675 + $loadingEnabled.set(true); 741 676 // Sometimes it jumps after the render, so we scroll to bottom again 742 677 scrollToBottom({ onlyIfNeeded: true }); 743 678 }); ··· 752 687 scrollToBottom(); 753 688 await loadMessages({ reload: true }); 754 689 } 755 - renderPage(); 756 690 }); 757 691 758 692 root.addEventListener("page-exit", () => { 759 693 messageFetcher.stop(); 760 694 }); 761 - 762 - bindToPage(root, notificationService, "update", () => renderPage()); 763 - 764 - bindToPage(root, chatNotificationService, "update", () => renderPage()); 765 695 } 766 696 } 767 697
+13 -21
src/js/views/chatRequests.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 - import { bindToPage } from "/js/router.js"; 2 + import { pageEffect } from "/js/router.js"; 3 3 import { html, render } from "/js/lib/lit-html.js"; 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 5 import { auth } from "/js/auth.js"; ··· 31 31 } catch (error) { 32 32 console.error(error); 33 33 showToast("Failed to accept chat request", { style: "error" }); 34 - renderPage(); 35 34 } 36 35 } 37 36 38 37 async function handleReject(convo) { 39 38 try { 40 39 await dataLayer.mutations.rejectConvo(convo); 41 - renderPage(); 42 40 } catch (error) { 43 41 console.error(error); 44 42 showToast("Failed to reject chat request", { style: "error" }); 45 - renderPage(); 46 43 } 47 44 } 48 45 49 46 function requestItemTemplate({ convo }) { 50 47 const lastMessage = convo.lastMessage; 51 48 const members = convo.members.filter( 52 - (member) => member.did !== dataLayer.selectors.getCurrentUser()?.did, 49 + (member) => member.did !== dataLayer.signals.$currentUser.get()?.did, 53 50 ); 54 51 const otherMember = members[0]; 55 52 const timeAgo = lastMessage ··· 163 160 } 164 161 165 162 function renderPage() { 166 - const currentUser = dataLayer.selectors.getCurrentUser(); 163 + const currentUser = dataLayer.signals.$currentUser.get(); 167 164 const numNotifications = 168 - notificationService?.getNumNotifications() ?? null; 165 + notificationService?.$numNotifications.get() ?? null; 169 166 const numChatNotifications = 170 - chatNotificationService?.getNumNotifications() ?? null; 171 - const convos = dataLayer.selectors.getConvoList(); 172 - const convosRequestStatus = dataLayer.requests.getStatus("loadConvoList"); 173 - const cursor = dataLayer.selectors.getConvoListCursor(); 167 + chatNotificationService?.$numNotifications.get() ?? null; 168 + const convos = dataLayer.signals.$convoList.get(); 169 + const convosRequestStatus = dataLayer.requests.statusStore.$statuses 170 + .get("loadConvoList") 171 + .get(); 172 + const cursor = dataLayer.signals.$convoListCursor.get(); 174 173 const hasMore = !!cursor; 175 174 176 175 // Filter to only show chat requests ··· 220 219 ); 221 220 } 222 221 222 + pageEffect(root, renderPage, "chat-requests-view"); 223 + 223 224 root.addEventListener("page-enter", async () => { 224 - renderPage(); 225 - dataLayer.declarative.ensureCurrentUser().then(() => { 226 - renderPage(); 227 - }); 225 + dataLayer.declarative.ensureCurrentUser(); 228 226 await dataLayer.declarative.ensureConvoList(); 229 - renderPage(); 230 227 }); 231 228 232 229 root.addEventListener("page-restore", async (e) => { ··· 238 235 window.scrollTo(0, 0); 239 236 await dataLayer.requests.loadConvoList({ reload: true }); 240 237 } 241 - renderPage(); 242 238 }); 243 - 244 - bindToPage(root, notificationService, "update", () => renderPage()); 245 - 246 - bindToPage(root, chatNotificationService, "update", () => renderPage()); 247 239 } 248 240 } 249 241
+15 -27
src/js/views/feedDetail.view.js
··· 7 7 import "/js/components/infinite-scroll-container.js"; 8 8 import { headerTemplate } from "/js/templates/header.template.js"; 9 9 import { pinIconTemplate } from "/js/templates/icons/pinIcon.template.js"; 10 - import { bindToPage } from "/js/router.js"; 10 + import { pageEffect } from "/js/router.js"; 11 11 import { FEED_PAGE_SIZE } from "/js/config.js"; 12 12 import { showToast } from "/js/toasts.js"; 13 13 import "/js/components/context-menu.js"; ··· 44 44 45 45 const { postInteractionHandler, feedInteractionHandler } = 46 46 interactionHandlers; 47 - bindToPage(root, interactionHandlers, "requestRender", () => renderPage()); 48 47 49 - function renderPage() { 48 + pageEffect(root, () => { 50 49 const showLessInteractions = 51 - dataLayer.selectors.getShowLessInteractions() ?? []; 50 + dataLayer.signals.$showLessInteractions.get() ?? []; 52 51 const hiddenPostUris = showLessInteractions.map( 53 52 (interaction) => interaction.item, 54 53 ); 55 54 const numNotifications = 56 - notificationService?.getNumNotifications() ?? null; 55 + notificationService?.$numNotifications.get() ?? null; 57 56 const numChatNotifications = 58 - chatNotificationService?.getNumNotifications() ?? null; 59 - const currentUser = dataLayer.selectors.getCurrentUser(); 60 - const feedGenerator = dataLayer.selectors.getFeedGenerator(feedUri); 57 + chatNotificationService?.$numNotifications.get() ?? null; 58 + const currentUser = dataLayer.signals.$currentUser.get(); 59 + const feedGenerator = dataLayer.signals.$feedGenerators 60 + .get(feedUri) 61 + .get(); 61 62 const feedName = feedGenerator?.displayName || ""; 62 63 const feedAuthor = feedGenerator?.creator; 63 64 const feedAuthorHandle = feedAuthor?.handle; 64 - const preferences = dataLayer.selectors.getPreferences(); 65 - const isPinned = preferences.isFeedPinned(feedUri); 66 - const feed = dataLayer.selectors.getFeed(feedUri); 65 + const preferences = dataLayer.signals.$preferences.get(); 66 + const isPinned = preferences?.isFeedPinned(feedUri) ?? false; 67 + const feed = dataLayer.signals.$hydratedFeeds.get(feedUri).get(); 67 68 render( 68 69 html`<div id="feed-detail-view"> 69 70 ${mainLayoutTemplate({ ··· 146 147 </div>`, 147 148 root, 148 149 ); 149 - } 150 + }); 150 151 151 152 async function loadFeed({ reload = false } = {}) { 152 153 await dataLayer.requests.loadNextFeedPage(feedUri, { 153 154 reload, 154 155 limit: FEED_PAGE_SIZE + 1, 155 156 }); 156 - renderPage(); 157 157 } 158 158 159 159 root.addEventListener("page-enter", async () => { 160 - // Initial empty state 161 - renderPage(); 162 - dataLayer.declarative.ensureCurrentUser().then(() => { 163 - renderPage(); 164 - }); 165 - // Load feed generator info 166 - dataLayer.declarative.ensureFeedGenerator(feedUri).then(() => { 167 - renderPage(); 168 - }); 160 + dataLayer.declarative.ensureCurrentUser(); 161 + dataLayer.declarative.ensureFeedGenerator(feedUri); 169 162 await loadFeed(); 170 163 }); 171 164 172 165 root.addEventListener("page-restore", (e) => { 173 166 const scrollY = e.detail?.scrollY ?? 0; 174 167 window.scrollTo(0, scrollY); 175 - renderPage(); 176 168 }); 177 - 178 - bindToPage(root, notificationService, "update", () => renderPage()); 179 - 180 - bindToPage(root, chatNotificationService, "update", () => renderPage()); 181 169 } 182 170 } 183 171
+8 -16
src/js/views/feeds.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 - import { bindToPage } from "/js/router.js"; 2 + import { pageEffect } from "/js/router.js"; 3 3 import { html, render } from "/js/lib/lit-html.js"; 4 4 import { auth } from "/js/auth.js"; 5 5 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; ··· 19 19 }) { 20 20 await auth.requireAuth(); 21 21 22 - function renderPage() { 23 - const currentUser = dataLayer.selectors.getCurrentUser(); 22 + pageEffect(root, () => { 23 + const currentUser = dataLayer.signals.$currentUser.get(); 24 24 const numNotifications = 25 - notificationService?.getNumNotifications() ?? null; 25 + notificationService?.$numNotifications.get() ?? null; 26 26 const numChatNotifications = 27 - chatNotificationService?.getNumNotifications() ?? null; 27 + chatNotificationService?.$numNotifications.get() ?? null; 28 28 const pinnedFeedGenerators = 29 - dataLayer.selectors.getPinnedFeedGenerators(); 29 + dataLayer.signals.$hydratedPinnedFeedGenerators.get(); 30 30 31 31 render( 32 32 html`<div id="feeds-view"> ··· 76 76 </div>`, 77 77 root, 78 78 ); 79 - } 79 + }); 80 80 81 81 root.addEventListener("page-enter", async () => { 82 - renderPage(); 83 - dataLayer.declarative.ensureCurrentUser().then(() => { 84 - renderPage(); 85 - }); 82 + dataLayer.declarative.ensureCurrentUser(); 86 83 await dataLayer.declarative.ensurePinnedFeedGenerators(); 87 - renderPage(); 88 84 }); 89 85 90 86 root.addEventListener("page-restore", (e) => { 91 87 const scrollY = e.detail?.scrollY ?? 0; 92 88 window.scrollTo(0, scrollY); 93 - renderPage(); 94 89 }); 95 - 96 - bindToPage(root, notificationService, "update", () => renderPage()); 97 - bindToPage(root, chatNotificationService, "update", () => renderPage()); 98 90 } 99 91 } 100 92
+26 -38
src/js/views/hashtag.view.js
··· 6 6 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 7 7 import { tabBarTemplate } from "/js/templates/tabBar.template.js"; 8 8 import { HASHTAG_FEED_PAGE_SIZE } from "/js/config.js"; 9 - import { bindToPage } from "/js/router.js"; 9 + import { pageEffect } from "/js/router.js"; 10 + import { Signal } from "/js/utils.js"; 10 11 11 12 class HashtagView extends View { 12 13 async render({ ··· 32 33 { value: "latest", label: "Latest" }, 33 34 ]; 34 35 35 - const state = { 36 - currentSort: "top", 37 - }; 36 + const $currentSort = new Signal.State("top"); 38 37 39 38 const { postInteractionHandler } = interactionHandlers; 40 - bindToPage(root, interactionHandlers, "requestRender", () => renderPage()); 41 39 42 40 const feedScrollState = new Map(); 43 41 ··· 49 47 } 50 48 51 49 async function handleTabClick(sortValue) { 52 - if (sortValue === state.currentSort) { 50 + const currentSort = $currentSort.get(); 51 + if (sortValue === currentSort) { 53 52 scrollAndReloadFeed(); 54 53 return; 55 54 } 56 55 // Save scroll state 57 - feedScrollState.set(state.currentSort, window.scrollY); 56 + feedScrollState.set(currentSort, window.scrollY); 58 57 // Switch sort 59 - state.currentSort = sortValue; 60 - renderPage(); 58 + $currentSort.set(sortValue); 61 59 // Scroll to saved scroll state 62 - if (feedScrollState.has(state.currentSort)) { 63 - window.scrollTo(0, feedScrollState.get(state.currentSort)); 60 + if (feedScrollState.has(sortValue)) { 61 + window.scrollTo(0, feedScrollState.get(sortValue)); 64 62 } else { 65 63 window.scrollTo(0, 0); 66 64 } 67 65 // Load feed if not cached 68 - const feed = dataLayer.selectors.getHashtagFeed( 69 - hashtag, 70 - state.currentSort, 71 - ); 66 + const hashtagKey = `${hashtag}-${sortValue}`; 67 + const feed = dataLayer.signals.$hydratedHashtagFeeds 68 + .get(hashtagKey) 69 + .get(); 72 70 if (!feed) { 73 71 await loadCurrentFeed(); 74 72 } 75 73 } 76 74 77 - function renderPage() { 75 + pageEffect(root, () => { 78 76 const numNotifications = 79 - notificationService?.getNumNotifications() ?? null; 77 + notificationService?.$numNotifications.get() ?? null; 80 78 const numChatNotifications = 81 - chatNotificationService?.getNumNotifications() ?? null; 82 - const currentUser = dataLayer.selectors.getCurrentUser(); 79 + chatNotificationService?.$numNotifications.get() ?? null; 80 + const currentUser = dataLayer.signals.$currentUser.get(); 81 + const currentSort = $currentSort.get(); 83 82 render( 84 83 html`<div id="hashtag-view"> 85 84 ${mainLayoutTemplate({ ··· 99 98 bottomItemTemplate: () => 100 99 tabBarTemplate({ 101 100 tabs: sortOptions, 102 - activeTab: state.currentSort, 101 + activeTab: currentSort, 103 102 onTabClick: handleTabClick, 104 103 }), 105 104 })} 106 105 ${sortOptions.map((sort) => { 107 - const feed = dataLayer.selectors.getHashtagFeed( 108 - hashtag, 109 - sort.value, 110 - ); 106 + const feed = dataLayer.signals.$hydratedHashtagFeeds 107 + .get(`${hashtag}-${sort.value}`) 108 + .get(); 111 109 return html`<div 112 110 class="feed-container" 113 - ?hidden=${state.currentSort !== sort.value} 111 + ?hidden=${currentSort !== sort.value} 114 112 > 115 113 ${postFeedTemplate({ 116 114 feed, ··· 128 126 </div>`, 129 127 root, 130 128 ); 131 - } 129 + }); 132 130 133 131 async function loadCurrentFeed({ reload = false } = {}) { 134 - await dataLayer.requests.loadHashtagFeed(hashtag, state.currentSort, { 132 + await dataLayer.requests.loadHashtagFeed(hashtag, $currentSort.get(), { 135 133 reload, 136 134 limit: HASHTAG_FEED_PAGE_SIZE, 137 135 }); 138 - renderPage(); 139 136 } 140 137 141 138 root.addEventListener("page-enter", async () => { 142 - // Initial empty state 143 - renderPage(); 144 - dataLayer.declarative.ensureCurrentUser().then(() => { 145 - renderPage(); 146 - }); 139 + dataLayer.declarative.ensureCurrentUser(); 147 140 await loadCurrentFeed(); 148 141 }); 149 142 150 143 root.addEventListener("page-restore", (e) => { 151 144 const scrollY = e.detail?.scrollY ?? 0; 152 145 window.scrollTo(0, scrollY); 153 - renderPage(); 154 146 }); 155 - 156 - bindToPage(root, notificationService, "update", () => renderPage()); 157 - 158 - bindToPage(root, chatNotificationService, "update", () => renderPage()); 159 147 } 160 148 } 161 149
+72 -92
src/js/views/home.view.js
··· 7 7 import { tabBarTemplate } from "/js/templates/tabBar.template.js"; 8 8 import { PostSeenObserver } from "/js/postSeenObserver.js"; 9 9 import { FEED_PAGE_SIZE, DISCOVER_FEED_URI } from "/js/config.js"; 10 - import { bindToPage } from "/js/router.js"; 10 + import { bindToPage, pageEffect } from "/js/router.js"; 11 11 import { showToast } from "/js/toasts.js"; 12 + import { Signal } from "/js/utils.js"; 12 13 13 14 class HomeView extends View { 14 15 async render({ ··· 25 26 interactionHandlers, 26 27 }, 27 28 }) { 28 - function createPersistedState(namespace) { 29 - return new Proxy( 30 - {}, 31 - { 32 - get: (target, prop) => { 33 - const value = localStorage.getItem(`${namespace}-${prop}`); 34 - return value ? JSON.parse(value) : null; 35 - }, 36 - set: (target, prop, value) => { 37 - localStorage.setItem(`${namespace}-${prop}`, JSON.stringify(value)); 38 - return true; 39 - }, 40 - }, 29 + const CURRENT_FEED_URI_STORAGE_KEY = "home-view-currentFeedUri"; 30 + 31 + const storedFeedUri = isAuthenticated 32 + ? localStorage.getItem(CURRENT_FEED_URI_STORAGE_KEY) 33 + : null; 34 + 35 + const state = { 36 + $currentFeedUri: new Signal.State( 37 + storedFeedUri ? JSON.parse(storedFeedUri) : null, 38 + ), 39 + }; 40 + 41 + function resetToDefaultFeed() { 42 + state.$currentFeedUri.set( 43 + isAuthenticated ? "following" : DISCOVER_FEED_URI, 41 44 ); 42 45 } 43 46 44 - const persistedState = isAuthenticated 45 - ? createPersistedState("home-view") 46 - : {}; 47 - 48 - function resetToDefaultFeed() { 49 - persistedState.currentFeedUri = isAuthenticated 50 - ? "following" 51 - : DISCOVER_FEED_URI; 47 + if (!state.$currentFeedUri.get()) { 48 + resetToDefaultFeed(); 52 49 } 53 50 54 - if (!persistedState.currentFeedUri) { 55 - resetToDefaultFeed(); 51 + if (isAuthenticated) { 52 + pageEffect( 53 + root, 54 + () => { 55 + const currentFeedUri = state.$currentFeedUri.get(); 56 + if (currentFeedUri) { 57 + localStorage.setItem( 58 + CURRENT_FEED_URI_STORAGE_KEY, 59 + JSON.stringify(currentFeedUri), 60 + ); 61 + } 62 + }, 63 + "PERSIST_CURRENT_FEED_URI", 64 + ); 56 65 } 57 66 58 67 function getProxyUrl(feedGenerator) { ··· 101 110 } 102 111 103 112 const { postInteractionHandler } = interactionHandlers; 104 - bindToPage(root, interactionHandlers, "requestRender", () => renderPage()); 105 113 106 114 async function handleShowLess(post, feedContext, feedGenerator) { 107 115 dataLayer.mutations.sendShowLessInteraction( ··· 109 117 feedContext, 110 118 getProxyUrl(feedGenerator), 111 119 ); 112 - // Render optimistic update 113 - renderPage(); 114 120 // Scroll to keep the feedback message in view (it might be hidden by the header, but that's okay) 115 121 const feedFeedbackMessageElement = document.querySelector( 116 122 `.feed-feedback-message[data-post-uri="${post.uri}"]`, ··· 140 146 } 141 147 142 148 async function handleTabClick(feedUri) { 143 - if (feedUri === persistedState.currentFeedUri) { 149 + let currentFeedUri = state.$currentFeedUri.get(); 150 + if (feedUri === currentFeedUri) { 144 151 scrollAndReloadFeed(); 145 152 return; 146 153 } 147 154 // Save scroll state 148 - feedScrollState.set(persistedState.currentFeedUri, window.scrollY); 155 + feedScrollState.set(currentFeedUri, window.scrollY); 149 156 // Switch feed 150 - persistedState.currentFeedUri = feedUri; 151 - renderPage(); 157 + state.$currentFeedUri.set(feedUri); 152 158 scrollActiveTabIntoView({ behavior: "smooth" }); 153 159 // Scroll to saved scroll state 154 - if (feedScrollState.has(persistedState.currentFeedUri)) { 155 - window.scrollTo(0, feedScrollState.get(persistedState.currentFeedUri)); 160 + if (feedScrollState.has(feedUri)) { 161 + window.scrollTo(0, feedScrollState.get(feedUri)); 156 162 } else { 157 163 window.scrollTo(0, 0); 158 164 } 159 - if (!dataLayer.hasCachedFeed(persistedState.currentFeedUri)) { 165 + if (!dataLayer.hasCachedFeed(feedUri)) { 160 166 await loadCurrentFeed(); 161 167 } 162 168 // Trigger post seen checks for the new feed 163 - const postSeenObserver = postSeenObservers.get( 164 - persistedState.currentFeedUri, 165 - ); 169 + const postSeenObserver = postSeenObservers.get(feedUri); 166 170 if (postSeenObserver) { 167 171 postSeenObserver.checkAllIntersections(); 168 172 } ··· 182 186 </div>`; 183 187 } 184 188 185 - function renderPage() { 189 + pageEffect(root, () => { 186 190 const showLessInteractions = 187 - dataLayer.selectors.getShowLessInteractions() ?? []; 191 + dataLayer.signals.$showLessInteractions.get() ?? []; 188 192 const hiddenPostUris = showLessInteractions.map( 189 193 (interaction) => interaction.item, 190 194 ); 191 195 const numNotifications = 192 - notificationService?.getNumNotifications() ?? null; 196 + notificationService?.$numNotifications.get() ?? null; 193 197 const numChatNotifications = 194 - chatNotificationService?.getNumNotifications() ?? null; 195 - const currentUser = dataLayer.selectors.getCurrentUser(); 198 + chatNotificationService?.$numNotifications.get() ?? null; 199 + const currentUser = dataLayer.signals.$currentUser.get(); 196 200 const feedGenerators = 197 - dataLayer.selectors.getPinnedFeedGenerators() ?? []; 201 + dataLayer.signals.$hydratedPinnedFeedGenerators.get() ?? []; 202 + const currentFeedUri = state.$currentFeedUri.get(); 203 + const feedGenerator = 204 + feedGenerators.find((fg) => fg.uri === currentFeedUri) ?? null; 198 205 render( 199 206 html`<div id="home-view"> 200 207 ${mainLayoutTemplate({ ··· 220 227 value: fg.uri, 221 228 label: fg.displayName, 222 229 })), 223 - activeTab: persistedState.currentFeedUri, 230 + activeTab: currentFeedUri, 224 231 onTabClick: handleTabClick, 225 232 })} 226 233 </div> 227 234 `, 228 235 })} 229 236 <main> 230 - ${feedGenerators.map((feedGenerator) => { 237 + ${(() => { 238 + if (!feedGenerator) { 239 + return null; 240 + } 231 241 const acceptsInteractions = 232 242 feedGenerator.acceptsInteractions || 233 243 feedGenerator.uri === DISCOVER_FEED_URI; 234 - const feed = dataLayer.selectors.getFeed(feedGenerator.uri); 235 - const feedRequestStatus = dataLayer.requests.getStatus( 236 - "loadNextFeedPage-" + feedGenerator.uri, 237 - ); 244 + const feed = dataLayer.signals.$hydratedFeeds 245 + .get(feedGenerator.uri) 246 + .get(); 247 + const feedRequestStatus = 248 + dataLayer.requests.statusStore.$statuses 249 + .get("loadNextFeedPage-" + feedGenerator.uri) 250 + .get(); 238 251 return html`<div 239 252 class="feed-container" 240 - ?hidden=${persistedState.currentFeedUri !== 241 - feedGenerator.uri} 253 + ?hidden=${currentFeedUri !== feedGenerator.uri} 242 254 > 243 255 ${feedRequestStatus.error 244 256 ? feedErrorTemplate({ feedGenerator }) ··· 259 271 showEndMessage: true, 260 272 })} 261 273 </div>`; 262 - })} 274 + })()} 263 275 </main>`, 264 276 })} 265 277 </div>`, ··· 275 287 } 276 288 } 277 289 }); 278 - } 290 + }); 279 291 280 292 async function loadCurrentFeed({ reload = false } = {}) { 281 - await dataLayer.requests.loadNextFeedPage(persistedState.currentFeedUri, { 293 + const currentFeedUri = state.$currentFeedUri.get(); 294 + await dataLayer.requests.loadNextFeedPage(currentFeedUri, { 282 295 reload, 283 296 limit: FEED_PAGE_SIZE + 1, 284 297 }); 285 - renderPage(); 286 298 } 287 299 288 300 async function preloadHiddenFeeds(pinnedFeedGenerators) { 301 + const currentFeedUri = state.$currentFeedUri.get(); 289 302 const feedsToPreload = pinnedFeedGenerators 290 - .filter((feed) => feed.uri !== persistedState.currentFeedUri) 303 + .filter((feed) => feed.uri !== currentFeedUri) 291 304 .slice(0, 5); // Up to 5 feeds 292 305 for (const feed of feedsToPreload) { 293 306 await dataLayer.requests.loadNextFeedPage(feed.uri, { ··· 309 322 310 323 root.addEventListener("page-enter", async () => { 311 324 window.scrollTo(0, 0); 312 - 313 - // Initial empty state 314 - renderPage(); 315 - 325 + const currentFeedUri = state.$currentFeedUri.get(); 316 326 await dataLayer.declarative 317 327 .ensurePinnedFeedGenerators() 318 328 .then((pinnedFeedGenerators) => { 319 329 // If the current feed is not in the pinned feed generators, reset to default feed 320 330 if ( 321 - !pinnedFeedGenerators.some( 322 - (feed) => feed.uri === persistedState.currentFeedUri, 323 - ) 331 + !pinnedFeedGenerators.some((feed) => feed.uri === currentFeedUri) 324 332 ) { 325 333 resetToDefaultFeed(); 326 334 } 327 - renderPage(); 335 + 328 336 preloadHiddenFeeds(pinnedFeedGenerators); 329 337 initializePostSeenObservers(pinnedFeedGenerators); 330 338 scrollActiveTabIntoView(); ··· 335 343 let currentUser = null; 336 344 if (isAuthenticated) { 337 345 currentUser = await dataLayer.declarative.ensureCurrentUser(); 338 - renderPage(); 339 346 } 340 347 341 348 // If /intent/compose, open the post composer and redirect to root ··· 351 358 root.addEventListener("page-restore", (e) => { 352 359 const scrollY = e.detail?.scrollY ?? 0; 353 360 window.scrollTo(0, scrollY); 354 - renderPage(); 355 - }); 356 - 357 - bindToPage(root, notificationService, "update", () => renderPage()); 358 - 359 - bindToPage(root, chatNotificationService, "update", () => renderPage()); 360 - 361 - pluginService.on("feedFiltersRefresh", async ({ feedURI }) => { 362 - let feedUrisToLoad = null; 363 - if (feedURI) { 364 - feedUrisToLoad = [feedURI]; 365 - } else { 366 - // If no feedURI is provided, reload all feeds 367 - const feedGenerators = 368 - dataLayer.selectors.getPinnedFeedGenerators() ?? []; 369 - feedUrisToLoad = feedGenerators.map( 370 - (feedGenerator) => feedGenerator.uri, 371 - ); 372 - } 373 - await Promise.all( 374 - feedUrisToLoad.map((uri) => 375 - dataLayer.requests.loadPluginFilteredFeedItems(uri, { 376 - reload: true, 377 - }), 378 - ), 379 - ); 380 - renderPage(); 381 361 }); 382 362 } 383 363 }
+26 -40
src/js/views/notifications.view.js
··· 6 6 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 7 7 import { smallPostTemplate } from "/js/templates/smallPost.template.js"; 8 8 import { postSkeletonTemplate } from "/js/templates/postSkeleton.template.js"; 9 - import { displayRelativeTime, batch } from "/js/utils.js"; 9 + import { displayRelativeTime, batch, Signal } from "/js/utils.js"; 10 + import { pageEffect } from "/js/router.js"; 10 11 import { userIconTemplate } from "/js/templates/icons/userIcon.template.js"; 11 12 import { repostIconTemplate } from "/js/templates/icons/repostIcon.template.js"; 12 13 import { linkToPost, linkToProfile } from "/js/navigation.js"; 13 14 import { avatarTemplate } from "/js/templates/avatar.template.js"; 14 - import { bindToPage } from "/js/router.js"; 15 15 import { 16 16 getImagesFromPost, 17 17 getVideoFromPost, ··· 100 100 } 101 101 102 102 const { postInteractionHandler } = interactionHandlers; 103 - bindToPage(root, interactionHandlers, "requestRender", () => renderPage()); 103 + 104 + const $activeTab = new Signal.State("all"); 104 105 105 106 async function handleMenuClick() { 106 107 const sidebar = root.querySelector("animated-sidebar"); ··· 668 669 } 669 670 } 670 671 671 - let activeTab = "all"; 672 - 673 672 async function handleTabClick(tab) { 674 - if (tab === activeTab) return; 675 - activeTab = tab; 676 - renderPage(); 673 + if (tab === $activeTab.get()) return; 674 + $activeTab.set(tab); 677 675 window.scrollTo(0, 0); 678 676 if ( 679 677 tab === "mentions" && 680 - !dataLayer.selectors.getMentionNotifications() 678 + !dataLayer.signals.$mentionNotifications.get() 681 679 ) { 682 680 await loadMentionNotifications({ reload: true }); 683 681 } 684 682 } 685 683 686 - function renderPage() { 687 - const currentUser = dataLayer.selectors.getCurrentUser(); 684 + pageEffect(root, () => { 685 + const activeTab = $activeTab.get(); 686 + const currentUser = dataLayer.signals.$currentUser.get(); 688 687 const numNotifications = 689 - notificationService?.getNumNotifications() ?? null; 688 + notificationService?.$numNotifications.get() ?? null; 690 689 const numChatNotifications = 691 - chatNotificationService?.getNumNotifications() ?? null; 692 - const notifications = dataLayer.selectors.getNotifications(); 690 + chatNotificationService?.$numNotifications.get() ?? null; 691 + const notifications = dataLayer.signals.$notifications.get(); 693 692 const notificationsRequestStatus = 694 - dataLayer.requests.getStatus("loadNotifications"); 693 + dataLayer.requests.statusStore.$statuses.get("loadNotifications").get(); 695 694 const groupedNotifications = groupNotificationsByType(notifications); 696 - const cursor = dataLayer.selectors.getNotificationCursor(); 695 + const cursor = dataLayer.dataStore.$notificationCursor.get(); 697 696 const hasMore = !!cursor; 698 697 699 698 const mentionNotifications = 700 - dataLayer.selectors.getMentionNotifications(); 701 - const mentionNotificationsRequestStatus = dataLayer.requests.getStatus( 702 - "loadMentionNotifications", 703 - ); 699 + dataLayer.signals.$mentionNotifications.get(); 700 + const mentionNotificationsRequestStatus = 701 + dataLayer.requests.statusStore.$statuses 702 + .get("loadMentionNotifications") 703 + .get(); 704 704 const groupedMentionNotifications = 705 705 groupNotificationsByType(mentionNotifications); 706 - const mentionCursor = dataLayer.selectors.getMentionNotificationCursor(); 706 + const mentionCursor = 707 + dataLayer.dataStore.$mentionNotificationCursor.get(); 707 708 const mentionHasMore = !!mentionCursor; 708 709 709 710 const isLoading = ··· 794 795 </div>`, 795 796 root, 796 797 ); 797 - } 798 + }); 798 799 799 800 async function loadNotifications({ reload = false } = {}) { 800 - const loadingPromise = dataLayer.requests.loadNotifications({ 801 + await dataLayer.requests.loadNotifications({ 801 802 reload, 802 803 limit: NOTIFICATIONS_PAGE_SIZE, 803 804 }); 804 - // Show loading state 805 - renderPage(); 806 - await loadingPromise; 807 - renderPage(); 808 805 // can be called async 809 806 notificationService.markNotificationsAsRead(); 810 807 } 811 808 812 809 async function loadMentionNotifications({ reload = false } = {}) { 813 - const loadingPromise = dataLayer.requests.loadMentionNotifications({ 810 + await dataLayer.requests.loadMentionNotifications({ 814 811 reload, 815 812 limit: NOTIFICATIONS_PAGE_SIZE, 816 813 }); 817 - renderPage(); 818 - await loadingPromise; 819 - renderPage(); 820 814 } 821 815 822 816 root.addEventListener("page-enter", async () => { 823 - renderPage(); 824 - dataLayer.declarative.ensureCurrentUser().then(() => { 825 - renderPage(); 826 - }); 817 + dataLayer.declarative.ensureCurrentUser(); 827 818 await loadNotifications({ reload: true }); 828 819 }); 829 820 ··· 836 827 window.scrollTo(0, 0); 837 828 await loadNotifications({ reload: true }); 838 829 } 839 - renderPage(); 840 830 }); 841 - 842 - bindToPage(root, notificationService, "update", () => renderPage()); 843 - 844 - bindToPage(root, chatNotificationService, "update", () => renderPage()); 845 831 } 846 832 } 847 833
+15 -30
src/js/views/postLikes.view.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { View } from "/js/views/view.js"; 3 - import { bindToPage } from "/js/router.js"; 3 + import { pageEffect } from "/js/router.js"; 4 4 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 5 5 import { headerTemplate } from "/js/templates/header.template.js"; 6 6 import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; ··· 39 39 </div>`; 40 40 } 41 41 42 - function renderPage() { 43 - const currentUser = dataLayer.selectors.getCurrentUser(); 42 + pageEffect(root, () => { 43 + const currentUser = dataLayer.signals.$currentUser.get(); 44 44 const numNotifications = 45 - notificationService?.getNumNotifications() ?? null; 45 + notificationService?.$numNotifications.get() ?? null; 46 46 const numChatNotifications = 47 - chatNotificationService?.getNumNotifications() ?? null; 48 - const postLikes = dataLayer.selectors.getPostLikes(postUri); 49 - const post = dataLayer.selectors.getPost(postUri); 50 - const postLikesRequestStatus = dataLayer.requests.getStatus( 51 - "loadPostLikes-" + postUri, 52 - ); 47 + chatNotificationService?.$numNotifications.get() ?? null; 48 + const postLikes = dataLayer.dataStore.$postLikes.get(postUri).get(); 49 + const post = dataLayer.signals.$hydratedPosts.get(postUri).get(); 50 + const postLikesRequestStatus = dataLayer.requests.statusStore.$statuses 51 + .get("loadPostLikes-" + postUri) 52 + .get(); 53 53 const hasMore = postLikes?.cursor ? true : false; 54 54 55 55 const subtitle = post?.likeCount ··· 92 92 </div>`, 93 93 root, 94 94 ); 95 - } 95 + }); 96 96 97 97 async function loadLikes() { 98 - const postLikes = dataLayer.selectors.getPostLikes(postUri); 98 + const postLikes = dataLayer.dataStore.$postLikes.get(postUri).get(); 99 99 const cursor = postLikes?.cursor; 100 - const loadingPromise = dataLayer.requests.loadPostLikes(postUri, { 101 - cursor, 102 - }); 103 - renderPage(); 104 - await loadingPromise; 105 - renderPage(); 100 + await dataLayer.requests.loadPostLikes(postUri, { cursor }); 106 101 } 107 102 108 103 root.addEventListener("page-enter", async () => { 109 - renderPage(); 110 104 if (isAuthenticated) { 111 - dataLayer.declarative.ensureCurrentUser().then(() => { 112 - renderPage(); 113 - }); 105 + dataLayer.declarative.ensureCurrentUser(); 114 106 } 115 107 // Load the post thread to get the post like count 116 - dataLayer.declarative.ensurePostThread(postUri).then(() => { 117 - renderPage(); 118 - }); 108 + dataLayer.declarative.ensurePostThread(postUri); 119 109 await loadLikes(); 120 110 }); 121 111 122 112 root.addEventListener("page-restore", async (e) => { 123 113 const scrollY = e.detail?.scrollY ?? 0; 124 - renderPage(); 125 114 if (scrollY > 0) { 126 115 window.scrollTo(0, scrollY); 127 116 } 128 117 }); 129 - 130 - bindToPage(root, notificationService, "update", () => renderPage()); 131 - 132 - bindToPage(root, chatNotificationService, "update", () => renderPage()); 133 118 } 134 119 } 135 120
+17 -31
src/js/views/postQuotes.view.js
··· 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 5 import { formatLargeNumber } from "/js/utils.js"; 6 6 import { postFeedTemplate } from "/js/templates/postFeed.template.js"; 7 - import { bindToPage } from "/js/router.js"; 7 + import { bindToPage, pageEffect } from "/js/router.js"; 8 8 9 9 class PostQuotesView extends View { 10 10 async render({ ··· 33 33 const postUri = `at://${authorDid}/app.bsky.feed.post/${rkey}`; 34 34 35 35 const { postInteractionHandler } = interactionHandlers; 36 - bindToPage(root, interactionHandlers, "requestRender", () => renderPage()); 37 36 38 37 function quotesErrorTemplate({ error }) { 39 38 console.error(error); ··· 43 42 </div>`; 44 43 } 45 44 46 - function renderPage() { 47 - const currentUser = dataLayer.selectors.getCurrentUser(); 45 + pageEffect(root, () => { 46 + const currentUser = dataLayer.signals.$currentUser.get(); 48 47 const numNotifications = 49 - notificationService?.getNumNotifications() ?? null; 48 + notificationService?.$numNotifications.get() ?? null; 50 49 const numChatNotifications = 51 - chatNotificationService?.getNumNotifications() ?? null; 52 - const postQuotes = dataLayer.selectors.getPostQuotes(postUri); 53 - const post = dataLayer.selectors.getPost(postUri); 54 - const postQuotesRequestStatus = dataLayer.requests.getStatus( 55 - "loadPostQuotes-" + postUri, 56 - ); 50 + chatNotificationService?.$numNotifications.get() ?? null; 51 + const postQuotes = dataLayer.signals.$hydratedPostQuotes 52 + .get(postUri) 53 + .get(); 54 + const post = dataLayer.signals.$hydratedPosts.get(postUri).get(); 55 + const postQuotesRequestStatus = dataLayer.requests.statusStore.$statuses 56 + .get("loadPostQuotes-" + postUri) 57 + .get(); 57 58 58 59 const subtitle = post?.quoteCount 59 60 ? `${formatLargeNumber(post.quoteCount)} ${ ··· 102 103 </div>`, 103 104 root, 104 105 ); 105 - } 106 + }); 106 107 107 108 async function loadQuotes() { 108 - const postQuotes = dataLayer.selectors.getPostQuotes(postUri); 109 + const postQuotes = dataLayer.dataStore.$postQuotes.get(postUri).get(); 109 110 const cursor = postQuotes?.cursor; 110 - const loadingPromise = dataLayer.requests.loadPostQuotes(postUri, { 111 - cursor, 112 - }); 113 - renderPage(); 114 - await loadingPromise; 115 - renderPage(); 111 + await dataLayer.requests.loadPostQuotes(postUri, { cursor }); 116 112 } 117 113 118 114 root.addEventListener("page-enter", async () => { 119 - renderPage(); 120 115 if (isAuthenticated) { 121 - dataLayer.declarative.ensureCurrentUser().then(() => { 122 - renderPage(); 123 - }); 116 + dataLayer.declarative.ensureCurrentUser(); 124 117 } 125 118 // Load the post thread to get the post quote count 126 - dataLayer.declarative.ensurePostThread(postUri).then(() => { 127 - renderPage(); 128 - }); 119 + dataLayer.declarative.ensurePostThread(postUri); 129 120 await loadQuotes(); 130 121 }); 131 122 132 123 root.addEventListener("page-restore", async (e) => { 133 124 const scrollY = e.detail?.scrollY ?? 0; 134 - renderPage(); 135 125 if (scrollY > 0) { 136 126 window.scrollTo(0, scrollY); 137 127 } 138 128 }); 139 - 140 - bindToPage(root, notificationService, "update", () => renderPage()); 141 - 142 - bindToPage(root, chatNotificationService, "update", () => renderPage()); 143 129 } 144 130 } 145 131
+15 -30
src/js/views/postReposts.view.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { View } from "/js/views/view.js"; 3 - import { bindToPage } from "/js/router.js"; 3 + import { pageEffect } from "/js/router.js"; 4 4 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 5 5 import { headerTemplate } from "/js/templates/header.template.js"; 6 6 import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; ··· 39 39 </div>`; 40 40 } 41 41 42 - function renderPage() { 43 - const currentUser = dataLayer.selectors.getCurrentUser(); 42 + pageEffect(root, () => { 43 + const currentUser = dataLayer.signals.$currentUser.get(); 44 44 const numNotifications = 45 - notificationService?.getNumNotifications() ?? null; 45 + notificationService?.$numNotifications.get() ?? null; 46 46 const numChatNotifications = 47 - chatNotificationService?.getNumNotifications() ?? null; 48 - const postReposts = dataLayer.selectors.getPostReposts(postUri); 49 - const post = dataLayer.selectors.getPost(postUri); 50 - const postRepostsRequestStatus = dataLayer.requests.getStatus( 51 - "loadPostReposts-" + postUri, 52 - ); 47 + chatNotificationService?.$numNotifications.get() ?? null; 48 + const postReposts = dataLayer.dataStore.$postReposts.get(postUri).get(); 49 + const post = dataLayer.signals.$hydratedPosts.get(postUri).get(); 50 + const postRepostsRequestStatus = dataLayer.requests.statusStore.$statuses 51 + .get("loadPostReposts-" + postUri) 52 + .get(); 53 53 const hasMore = postReposts?.cursor ? true : false; 54 54 const subtitle = post?.repostCount 55 55 ? `${formatLargeNumber(post.repostCount)} ${ ··· 90 90 </div>`, 91 91 root, 92 92 ); 93 - } 93 + }); 94 94 95 95 async function loadReposts() { 96 - const postReposts = dataLayer.selectors.getPostReposts(postUri); 96 + const postReposts = dataLayer.dataStore.$postReposts.get(postUri).get(); 97 97 const cursor = postReposts?.cursor; 98 - const loadingPromise = dataLayer.requests.loadPostReposts(postUri, { 99 - cursor, 100 - }); 101 - renderPage(); 102 - await loadingPromise; 103 - renderPage(); 98 + await dataLayer.requests.loadPostReposts(postUri, { cursor }); 104 99 } 105 100 106 101 root.addEventListener("page-enter", async () => { 107 - renderPage(); 108 102 if (isAuthenticated) { 109 - dataLayer.declarative.ensureCurrentUser().then(() => { 110 - renderPage(); 111 - }); 103 + dataLayer.declarative.ensureCurrentUser(); 112 104 } 113 105 // Load the post thread to get the post repost count 114 - dataLayer.declarative.ensurePostThread(postUri).then(() => { 115 - renderPage(); 116 - }); 106 + dataLayer.declarative.ensurePostThread(postUri); 117 107 await loadReposts(); 118 108 }); 119 109 120 110 root.addEventListener("page-restore", async (e) => { 121 111 const scrollY = e.detail?.scrollY ?? 0; 122 - renderPage(); 123 112 if (scrollY > 0) { 124 113 window.scrollTo(0, scrollY); 125 114 } 126 115 }); 127 - 128 - bindToPage(root, notificationService, "update", () => renderPage()); 129 - 130 - bindToPage(root, chatNotificationService, "update", () => renderPage()); 131 116 } 132 117 } 133 118
+19 -32
src/js/views/postThread.view.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { avatarTemplate } from "/js/templates/avatar.template.js"; 3 3 import { sortBy } from "/js/utils.js"; 4 + import { pageEffect } from "/js/router.js"; 4 5 import { headerTemplate } from "/js/templates/header.template.js"; 5 6 import { smallPostTemplate } from "/js/templates/smallPost.template.js"; 6 7 import { mutedParentToggleTemplate } from "/js/templates/mutedParentToggle.template.js"; ··· 24 25 import "/js/components/hidden-replies-section.js"; 25 26 import "/js/components/plugin-slot.js"; 26 27 import { linkToPostFromUri } from "/js/navigation.js"; 27 - import { bindToPage } from "/js/router.js"; 28 28 29 29 class PostThreadView extends View { 30 30 async render({ ··· 190 190 const numReplies = replyChain.length; 191 191 return html`<div class="post-thread-reply-chain"> 192 192 ${replyChain.map((reply, i) => { 193 - const post = dataLayer.selectors.getPost(reply.post.uri); // todo - map in selector? 193 + const post = dataLayer.signals.$hydratedPosts 194 + .get(reply.post.uri) 195 + .get(); 196 + if (!post) return ""; 194 197 return smallPostTemplate({ 195 198 post, 196 199 currentUser, ··· 211 214 replyTo: post, 212 215 replyRoot, 213 216 }); 214 - renderPage(); 215 217 } 216 218 217 219 // Note, this is different from hiding a reply entirely, that's why this name is weirdly specific. ··· 495 497 </div>`; 496 498 } 497 499 498 - function getPostThread() { 499 - let postThread = dataLayer.selectors.getPostThread(postUri); 500 + pageEffect(root, () => { 501 + let postThread = dataLayer.signals.$hydratedPostThreads 502 + .get(postUri) 503 + .get(); 500 504 if (!postThread) { 501 - // prefill with saved post if available 502 - const post = dataLayer.selectors.getPost(postUri); 505 + // Prefill with saved post if available 506 + const post = dataLayer.signals.$hydratedPosts.get(postUri).get(); 503 507 if (post) { 504 508 postThread = { 505 509 __isPrefill: true, ··· 509 513 }; 510 514 } 511 515 } 512 - return postThread; 513 - } 514 - 515 - function renderPage() { 516 - const postThread = getPostThread(); 517 - const currentUser = dataLayer.selectors.getCurrentUser(); 516 + const currentUser = dataLayer.signals.$currentUser.get(); 518 517 const numNotifications = 519 - notificationService?.getNumNotifications() ?? null; 518 + notificationService?.$numNotifications.get() ?? null; 520 519 const numChatNotifications = 521 - chatNotificationService?.getNumNotifications() ?? null; 522 - const postThreadRequestStatus = dataLayer.requests.getStatus( 523 - "loadPostThread-" + postUri, 524 - ); 520 + chatNotificationService?.$numNotifications.get() ?? null; 521 + const postThreadRequestStatus = dataLayer.requests.statusStore.$statuses 522 + .get("loadPostThread-" + postUri) 523 + .get(); 525 524 render( 526 525 html`<div id="post-detail-view"> 527 526 ${mainLayoutTemplate({ 528 527 isAuthenticated, 529 528 showSidebarOverlay: false, 530 529 onClickComposeButton: () => 531 - postComposerService.composePost({ currentUser }).then(() => { 532 - renderPage(); 533 - }), 530 + postComposerService.composePost({ currentUser }), 534 531 currentUser, 535 532 numNotifications, 536 533 numChatNotifications, ··· 553 550 </div>`, 554 551 root, 555 552 ); 556 - } 553 + }); 557 554 558 555 function scrollToLargePost() { 559 556 const largePost = root.querySelector(".large-post"); ··· 565 562 } 566 563 567 564 root.addEventListener("page-enter", async () => { 568 - renderPage(); 569 565 scrollToLargePost(); 570 566 let requests = []; 571 567 if (isAuthenticated) { 572 568 requests.push(dataLayer.requests.loadCurrentUser()); 573 569 } 574 - // Fetch full thread 575 570 requests.push(dataLayer.requests.loadPostThread(postUri)); 576 571 await Promise.all(requests); 577 - renderPage(); 578 572 scrollToLargePost(); 579 573 }); 580 574 581 575 root.addEventListener("page-restore", async (e) => { 582 576 const scrollY = e.detail?.scrollY ?? 0; 583 - renderPage(); 584 577 if (scrollY > 0) { 585 578 window.scrollTo(0, scrollY); 586 579 } else { ··· 588 581 } 589 582 // Revalidate 590 583 await dataLayer.requests.loadPostThread(postUri); 591 - renderPage(); 592 584 }); 593 - 594 - const onLiveUpdate = () => renderPage(); 595 - bindToPage(root, notificationService, "update", onLiveUpdate); 596 - bindToPage(root, chatNotificationService, "update", onLiveUpdate); 597 - bindToPage(root, interactionHandlers, "requestRender", onLiveUpdate); 598 585 } 599 586 } 600 587
+63 -82
src/js/views/profile.view.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 - import { wait } from "/js/utils.js"; 2 + import { wait, Signal } from "/js/utils.js"; 3 3 import { 4 4 doHideAuthorOnUnauthenticated, 5 5 isLabelerProfile, ··· 11 11 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 12 12 import { ApiError } from "/js/api.js"; 13 13 import { getFacetsFromText } from "/js/facetHelpers.js"; 14 - import { bindToPage } from "/js/router.js"; 14 + import { pageEffect } from "/js/router.js"; 15 15 import { AUTHOR_FEED_PAGE_SIZE, BSKY_LABELER_DID } from "/js/config.js"; 16 16 import { showToast } from "/js/toasts.js"; 17 17 import { tabBarTemplate } from "/js/templates/tabBar.template.js"; ··· 59 59 }, 60 60 ]; 61 61 62 - const state = { 63 - activeTab: "posts", // will be either a feed type or "labeler-settings" 64 - richTextProfileDescription: null, 65 - }; 62 + const $activeTab = new Signal.State("posts"); 63 + const $richTextProfileDescription = new Signal.State(null); 66 64 67 65 const { handleOrDid } = params; 68 66 let profileDid = null; ··· 78 76 79 77 const { postInteractionHandler, profileInteractionHandler } = 80 78 interactionHandlers; 81 - bindToPage(root, interactionHandlers, "requestRender", () => renderPage()); 82 79 83 80 async function handleEditProfile(profile) { 84 81 const dialog = document.createElement("edit-profile-dialog"); ··· 109 106 await loadProfileDescription(); 110 107 showToast("Profile updated"); 111 108 successCallback(); 112 - renderPage(); 113 109 } catch (error) { 114 110 errorCallback(error); 115 111 } ··· 126 122 } 127 123 128 124 async function handleTabClick(tab) { 129 - if (tab === state.activeTab) { 125 + const currentTab = $activeTab.get(); 126 + if (tab === currentTab) { 130 127 if (tab === "feeds") { 131 128 scrollAndReloadActorFeeds(); 132 129 } else { ··· 135 132 return; 136 133 } 137 134 // Save scroll state 138 - tabScrollState.set(state.activeTab, window.scrollY); 135 + tabScrollState.set(currentTab, window.scrollY); 139 136 // switch tab 140 - state.activeTab = tab; 141 - renderPage(); 137 + $activeTab.set(tab); 142 138 // Restore or reset scroll 143 139 if (tabScrollState.has(tab)) { 144 140 window.scrollTo(0, tabScrollState.get(tab)); ··· 147 143 } 148 144 // Load feed if needed 149 145 if (tab === "feeds") { 150 - if (!dataLayer.selectors.getActorFeeds(profileDid)) { 146 + if (!dataLayer.signals.$actorFeeds.get(profileDid).get()) { 151 147 await loadActorFeeds(); 152 148 } 153 149 } else { ··· 160 156 161 157 async function handleLabelerSettingsClick(labelerDid, label, visibility) { 162 158 try { 163 - const promise = dataLayer.mutations.updateLabelerSetting({ 159 + await dataLayer.mutations.updateLabelerSetting({ 164 160 labelerDid, 165 161 label, 166 162 visibility, 167 163 }); 168 - // Render optimistic update 169 - renderPage(); 170 - await promise; 171 - // Render final update 172 - renderPage(); 173 164 } catch (error) { 174 165 console.error(error); 175 166 showToast("Failed to update labeler setting", { style: "error" }); 176 - renderPage(); 177 167 } 178 168 } 179 169 ··· 258 248 profile, 259 249 isLabeler, 260 250 labelerInfo, 261 - currentUser = null, 251 + currentUser, 252 + activeTab, 253 + richTextProfileDescription, 262 254 }) { 263 255 try { 264 256 if (!isAuthenticated && doHideAuthorOnUnauthenticated(profile)) { ··· 266 258 } 267 259 const isBlocking = !!profile.viewer?.blocking; 268 260 const isBlockedBy = !!profile.viewer?.blockedBy; 269 - const profileChatStatus = dataLayer.selectors.getProfileChatStatus( 270 - profile.did, 271 - ); 261 + const profileChatStatus = dataLayer.signals.$profileChatStatus 262 + .get(profile.did) 263 + .get(); 272 264 const isCurrentUser = currentUser?.did === profile.did; 273 265 let authorFeedsToShow = isCurrentUser 274 266 ? currentUserAuthorFeeds ··· 290 282 let isSubscribed = false; 291 283 let labelerSettings = null; 292 284 if (isLabeler) { 293 - const preferences = dataLayer.selectors.getPreferences(); 285 + const preferences = dataLayer.signals.$preferences.get(); 294 286 isSubscribed = isDefaultLabeler 295 287 ? true 296 288 : preferences?.isSubscribedToLabeler(profile.did); 297 - labelerSettings = dataLayer.selectors.getLabelerSettings(profile.did); 289 + labelerSettings = dataLayer.signals.$labelerSettings 290 + .get(profile.did) 291 + .get(); 298 292 } 299 293 return html` 300 294 <div class="profile-container"> 301 295 ${profileCardTemplate({ 302 296 profile, 303 - richTextProfileDescription: state.richTextProfileDescription, 297 + richTextProfileDescription, 304 298 isAuthenticated, 305 299 isCurrentUser, 306 300 profileChatStatus, ··· 373 367 label: feedInfo.name, 374 368 })), 375 369 ], 376 - activeTab: state.activeTab, 370 + activeTab, 377 371 onTabClick: handleTabClick, 378 372 })} 379 373 </div> 380 374 ${isLabeler 381 375 ? html`<div 382 376 class="labeler-settings-pane" 383 - ?hidden=${state.activeTab !== "labeler-settings"} 377 + ?hidden=${activeTab !== "labeler-settings"} 384 378 > 385 379 ${labelerSettingsTemplate({ 386 380 labelerInfo, ··· 398 392 : null} 399 393 ${authorFeedsToShow.map((feedInfo) => { 400 394 if (feedInfo.feedType === "feeds") { 401 - const actorFeeds = 402 - dataLayer.selectors.getActorFeeds(profileDid); 395 + const actorFeeds = dataLayer.signals.$actorFeeds 396 + .get(profileDid) 397 + .get(); 403 398 return html`<div 404 399 class="feed-container" 405 - ?hidden=${state.activeTab !== "feeds"} 400 + ?hidden=${activeTab !== "feeds"} 406 401 > 407 402 ${actorFeedsTemplate({ 408 403 actorFeeds, ··· 410 405 })} 411 406 </div>`; 412 407 } 413 - const authorFeed = dataLayer.selectors.getAuthorFeed( 414 - profileDid, 415 - feedInfo.feedType, 416 - ); 408 + const feedURI = `${profileDid}-${feedInfo.feedType}`; 409 + const authorFeed = dataLayer.signals.$hydratedAuthorFeeds 410 + .get(feedURI) 411 + .get(); 417 412 return html`<div 418 413 class="feed-container" 419 - ?hidden=${state.activeTab !== feedInfo.feedType} 414 + ?hidden=${activeTab !== feedInfo.feedType} 420 415 > 421 416 ${postFeedTemplate({ 422 417 feed: authorFeed, ··· 442 437 return html`<div class="profile-container"></div>`; 443 438 } 444 439 445 - function renderPage() { 446 - const profile = dataLayer.base.getProfile(profileDid); 447 - const currentUser = dataLayer.selectors.getCurrentUser(); 440 + pageEffect(root, () => { 441 + const profile = dataLayer.signals.$hydratedProfiles.get(profileDid).get(); 442 + const currentUser = dataLayer.signals.$currentUser.get(); 448 443 const numNotifications = 449 - notificationService?.getNumNotifications() ?? null; 444 + notificationService?.$numNotifications.get() ?? null; 450 445 const numChatNotifications = 451 - chatNotificationService?.getNumNotifications() ?? null; 452 - const profileRequestStatus = dataLayer.requests.getStatus( 453 - "loadProfile-" + profileDid, 454 - ); 446 + chatNotificationService?.$numNotifications.get() ?? null; 447 + const profileRequestStatus = dataLayer.requests.statusStore.$statuses 448 + .get("loadProfile-" + profileDid) 449 + .get(); 455 450 const isLabeler = profile && isLabelerProfile(profile); 456 451 const labelerInfo = isLabeler 457 - ? dataLayer.selectors.getLabelerInfo(profile.did) 452 + ? dataLayer.signals.$labelerInfo.get(profile.did).get() 458 453 : null; 459 454 // If labeler, require labeler info to be loaded 460 455 const isLoaded = profile && (isLabeler ? !!labelerInfo : true); 456 + const activeTab = $activeTab.get(); 457 + const richTextProfileDescription = $richTextProfileDescription.get(); 461 458 render( 462 459 html`<div id="profile-view"> 463 460 ${mainLayoutTemplate({ ··· 474 471 showFloatingComposeButton: true, 475 472 onClickComposeButton: async () => { 476 473 await postComposerService.composePost({ currentUser }); 477 - // Render the page again to show the new post 478 - renderPage(); 479 474 }, 480 475 children: html` 481 476 <main style="position: relative;"> ··· 496 491 isLabeler, 497 492 labelerInfo, 498 493 currentUser, 494 + activeTab, 495 + richTextProfileDescription, 499 496 }); 500 497 } else { 501 498 return profileSkeletonTemplate(); ··· 507 504 </div>`, 508 505 root, 509 506 ); 510 - } 507 + }); 511 508 512 509 async function loadAuthorFeed({ reload = false } = {}) { 513 - if ( 514 - state.activeTab === "labeler-settings" || 515 - state.activeTab === "feeds" 516 - ) { 510 + const activeTab = $activeTab.get(); 511 + if (activeTab === "labeler-settings" || activeTab === "feeds") { 517 512 return; 518 513 } 519 - await dataLayer.requests.loadNextAuthorFeedPage( 520 - profileDid, 521 - state.activeTab, 522 - { 523 - reload, 524 - limit: AUTHOR_FEED_PAGE_SIZE + 1, 525 - }, 526 - ); 527 - renderPage(); 514 + await dataLayer.requests.loadNextAuthorFeedPage(profileDid, activeTab, { 515 + reload, 516 + limit: AUTHOR_FEED_PAGE_SIZE + 1, 517 + }); 528 518 } 529 519 530 520 async function loadActorFeeds({ reload = false } = {}) { 531 521 await dataLayer.requests.loadActorFeeds(profileDid, { reload }); 532 - renderPage(); 533 522 } 534 523 535 524 async function scrollAndReloadActorFeeds() { ··· 540 529 } 541 530 542 531 async function preloadHiddenFeeds() { 532 + const activeTab = $activeTab.get(); 543 533 const feedsToPreload = defaultAuthorFeeds.filter( 544 - (feed) => feed.feedType !== state.activeTab, 534 + (feed) => feed.feedType !== activeTab, 545 535 ); 546 536 for (const feed of feedsToPreload) { 547 537 await dataLayer.requests.loadNextAuthorFeedPage( ··· 556 546 557 547 // This is async because it needs to resolve mentions 558 548 async function loadProfileDescription() { 559 - const profile = dataLayer.base.getProfile(profileDid); 549 + const profile = dataLayer.signals.$hydratedProfiles.get(profileDid).get(); 560 550 if (!profile?.description) { 561 551 return; 562 552 } ··· 564 554 profile.description, 565 555 identityResolver, 566 556 ); 567 - state.richTextProfileDescription = { text: profile.description, facets }; 557 + $richTextProfileDescription.set({ 558 + text: profile.description, 559 + facets, 560 + }); 568 561 } 569 562 570 563 root.addEventListener("page-enter", async () => { 571 - renderPage(); 572 564 if (isAuthenticated) { 573 565 await dataLayer.declarative.ensureCurrentUser(); 574 566 } ··· 577 569 try { 578 570 profile = await dataLayer.declarative.ensureProfile(profileDid); 579 571 } catch { 580 - renderPage(); 581 572 return; 582 573 } 583 574 584 575 // Set active tab and load labeler info if this is a labeler profile 585 576 const isLabeler = profile && isLabelerProfile(profile); 586 577 if (isLabeler) { 587 - state.activeTab = "labeler-settings"; 588 - dataLayer.requests.loadLabelerInfo(profile.did).then(() => { 589 - renderPage(); 590 - }); 578 + $activeTab.set("labeler-settings"); 579 + dataLayer.requests.loadLabelerInfo(profile.did); 591 580 } 592 581 593 582 await loadProfileDescription(); 594 - renderPage(); 595 583 if (!profile.viewer?.blocking && !profile.viewer?.blockedBy) { 596 584 loadAuthorFeed(); 597 585 preloadHiddenFeeds(); ··· 599 587 // Load chat status 600 588 if ( 601 589 isAuthenticated && 602 - profile.did !== dataLayer.selectors.getCurrentUser()?.did 590 + profile.did !== dataLayer.signals.$currentUser.get()?.did 603 591 ) { 604 - dataLayer.requests.loadProfileChatStatus(profile.did).then(() => { 605 - renderPage(); 606 - }); 592 + dataLayer.requests.loadProfileChatStatus(profile.did); 607 593 } 608 594 }); 609 595 ··· 614 600 } else { 615 601 window.scrollTo(0, 0); 616 602 } 617 - renderPage(); 618 603 }); 619 - 620 - bindToPage(root, notificationService, "update", () => renderPage()); 621 - 622 - bindToPage(root, chatNotificationService, "update", () => renderPage()); 623 604 } 624 605 } 625 606
+20 -35
src/js/views/profileFollowers.view.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { auth } from "/js/auth.js"; 3 3 import { View } from "/js/views/view.js"; 4 - import { bindToPage } from "/js/router.js"; 4 + import { pageEffect } from "/js/router.js"; 5 5 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 6 6 import { headerTemplate } from "/js/templates/header.template.js"; 7 7 import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; ··· 40 40 </div>`; 41 41 } 42 42 43 - function renderPage() { 44 - const currentUser = dataLayer.selectors.getCurrentUser(); 43 + pageEffect(root, () => { 44 + const currentUser = dataLayer.signals.$currentUser.get(); 45 45 const numNotifications = 46 - notificationService?.getNumNotifications() ?? null; 46 + notificationService?.$numNotifications.get() ?? null; 47 47 const numChatNotifications = 48 - chatNotificationService?.getNumNotifications() ?? null; 49 - const profileFollowers = 50 - dataLayer.selectors.getProfileFollowers(profileDid); 51 - const profile = dataLayer.base.getProfile(profileDid); 52 - const profileFollowersRequestStatus = dataLayer.requests.getStatus( 53 - "loadProfileFollowers-" + profileDid, 54 - ); 48 + chatNotificationService?.$numNotifications.get() ?? null; 49 + const profileFollowers = dataLayer.dataStore.$profileFollowers 50 + .get(profileDid) 51 + .get(); 52 + const profile = dataLayer.signals.$hydratedProfiles.get(profileDid).get(); 53 + const profileFollowersRequestStatus = 54 + dataLayer.requests.statusStore.$statuses 55 + .get("loadProfileFollowers-" + profileDid) 56 + .get(); 55 57 const hasMore = profileFollowers?.cursor ? true : false; 56 58 57 59 const subtitle = profile?.followersCount ··· 92 94 </div>`, 93 95 root, 94 96 ); 95 - } 97 + }); 96 98 97 99 async function loadFollowers() { 98 - const profileFollowers = 99 - dataLayer.selectors.getProfileFollowers(profileDid); 100 + const profileFollowers = dataLayer.dataStore.$profileFollowers 101 + .get(profileDid) 102 + .get(); 100 103 const cursor = profileFollowers?.cursor; 101 - const loadingPromise = dataLayer.requests.loadProfileFollowers( 102 - profileDid, 103 - { 104 - cursor, 105 - }, 106 - ); 107 - renderPage(); 108 - await loadingPromise; 109 - renderPage(); 104 + await dataLayer.requests.loadProfileFollowers(profileDid, { cursor }); 110 105 } 111 106 112 107 root.addEventListener("page-enter", async () => { 113 - renderPage(); 114 - dataLayer.declarative.ensureCurrentUser().then(() => { 115 - renderPage(); 116 - }); 108 + dataLayer.declarative.ensureCurrentUser(); 117 109 // Load the profile to get the follower count 118 - dataLayer.declarative.ensureProfile(profileDid).then(() => { 119 - renderPage(); 120 - }); 110 + dataLayer.declarative.ensureProfile(profileDid); 121 111 await loadFollowers(); 122 112 }); 123 113 124 114 root.addEventListener("page-restore", async (e) => { 125 115 const scrollY = e.detail?.scrollY ?? 0; 126 - renderPage(); 127 116 if (scrollY > 0) { 128 117 window.scrollTo(0, scrollY); 129 118 } 130 119 }); 131 - 132 - bindToPage(root, notificationService, "update", () => renderPage()); 133 - 134 - bindToPage(root, chatNotificationService, "update", () => renderPage()); 135 120 } 136 121 } 137 122
+20 -32
src/js/views/profileFollowing.view.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { auth } from "/js/auth.js"; 3 3 import { View } from "/js/views/view.js"; 4 - import { bindToPage } from "/js/router.js"; 4 + import { pageEffect } from "/js/router.js"; 5 5 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 6 6 import { headerTemplate } from "/js/templates/header.template.js"; 7 7 import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; ··· 40 40 </div>`; 41 41 } 42 42 43 - function renderPage() { 44 - const currentUser = dataLayer.selectors.getCurrentUser(); 43 + pageEffect(root, () => { 44 + const currentUser = dataLayer.signals.$currentUser.get(); 45 45 const numNotifications = 46 - notificationService?.getNumNotifications() ?? null; 46 + notificationService?.$numNotifications.get() ?? null; 47 47 const numChatNotifications = 48 - chatNotificationService?.getNumNotifications() ?? null; 49 - const profileFollowing = 50 - dataLayer.selectors.getProfileFollows(profileDid); 51 - const profile = dataLayer.base.getProfile(profileDid); 52 - const profileFollowingRequestStatus = dataLayer.requests.getStatus( 53 - "loadProfileFollows-" + profileDid, 54 - ); 48 + chatNotificationService?.$numNotifications.get() ?? null; 49 + const profileFollowing = dataLayer.dataStore.$profileFollows 50 + .get(profileDid) 51 + .get(); 52 + const profile = dataLayer.signals.$hydratedProfiles.get(profileDid).get(); 53 + const profileFollowingRequestStatus = 54 + dataLayer.requests.statusStore.$statuses 55 + .get("loadProfileFollows-" + profileDid) 56 + .get(); 55 57 const hasMore = profileFollowing?.cursor ? true : false; 56 58 57 59 const subtitle = profile?.followsCount ··· 90 92 </div>`, 91 93 root, 92 94 ); 93 - } 95 + }); 94 96 95 97 async function loadFollowing() { 96 - const profileFollowing = 97 - dataLayer.selectors.getProfileFollows(profileDid); 98 + const profileFollowing = dataLayer.dataStore.$profileFollows 99 + .get(profileDid) 100 + .get(); 98 101 const cursor = profileFollowing?.cursor; 99 - const loadingPromise = dataLayer.requests.loadProfileFollows(profileDid, { 100 - cursor, 101 - }); 102 - renderPage(); 103 - await loadingPromise; 104 - renderPage(); 102 + await dataLayer.requests.loadProfileFollows(profileDid, { cursor }); 105 103 } 106 104 107 105 root.addEventListener("page-enter", async () => { 108 - renderPage(); 109 - dataLayer.declarative.ensureCurrentUser().then(() => { 110 - renderPage(); 111 - }); 106 + dataLayer.declarative.ensureCurrentUser(); 112 107 // Load the profile to get the follows count 113 - dataLayer.declarative.ensureProfile(profileDid).then(() => { 114 - renderPage(); 115 - }); 108 + dataLayer.declarative.ensureProfile(profileDid); 116 109 await loadFollowing(); 117 110 }); 118 111 119 112 root.addEventListener("page-restore", async (e) => { 120 113 const scrollY = e.detail?.scrollY ?? 0; 121 - renderPage(); 122 114 if (scrollY > 0) { 123 115 window.scrollTo(0, scrollY); 124 116 } 125 117 }); 126 - 127 - bindToPage(root, notificationService, "update", () => renderPage()); 128 - 129 - bindToPage(root, chatNotificationService, "update", () => renderPage()); 130 118 } 131 119 } 132 120
+52 -68
src/js/views/search.view.js
··· 3 3 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 4 4 import { searchIconTemplate } from "/js/templates/icons/searchIcon.template.js"; 5 5 import { headerTemplate } from "/js/templates/header.template.js"; 6 - import { classnames, debounce } from "/js/utils.js"; 6 + import { classnames, debounce, Signal } from "/js/utils.js"; 7 7 import { linkToFeed } from "/js/navigation.js"; 8 8 import { smallPostTemplate } from "/js/templates/smallPost.template.js"; 9 - import { bindToPage } from "/js/router.js"; 9 + import { pageEffect } from "/js/router.js"; 10 10 import { pinIconTemplate } from "/js/templates/icons/pinIcon.template.js"; 11 11 import { tabBarTemplate } from "/js/templates/tabBar.template.js"; 12 12 import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; ··· 25 25 interactionHandlers, 26 26 }, 27 27 }) { 28 - const state = { 29 - activeTab: "profiles", 30 - searchQuery: "", 31 - }; 28 + const $activeTab = new Signal.State("profiles"); 29 + const $searchQuery = new Signal.State(""); 32 30 33 31 const tabScrollState = new Map(); 34 32 35 33 async function loadSearchResults() { 36 - const normalizedQuery = state.searchQuery.trim(); 34 + const searchQuery = $searchQuery.get(); 35 + const normalizedQuery = searchQuery.trim(); 37 36 38 37 // Update URL query parameter 39 38 const url = new URL(window.location); 40 - if (state.searchQuery) { 41 - url.searchParams.set("q", state.searchQuery); 39 + if (searchQuery) { 40 + url.searchParams.set("q", searchQuery); 42 41 } else { 43 42 url.searchParams.delete("q"); 44 43 } ··· 65 64 ); 66 65 } 67 66 68 - renderPage(); 69 - 70 67 try { 71 68 await Promise.all(requests); 72 69 } catch (error) { 73 70 console.error("Failed to load search results", error); 74 - } finally { 75 - renderPage(); 76 71 } 77 72 } 78 73 79 74 async function loadMoreProfiles() { 80 - const cursor = dataLayer.selectors.getProfileSearchCursor(); 75 + const cursor = dataLayer.signals.$profileSearchCursor.get(); 81 76 if (!cursor) return; 82 - await dataLayer.requests.loadProfileSearch(state.searchQuery.trim(), { 77 + await dataLayer.requests.loadProfileSearch($searchQuery.get().trim(), { 83 78 limit: 25, 84 79 cursor, 85 80 }); 86 - renderPage(); 87 81 } 88 82 89 83 async function loadMorePosts() { 90 - const cursor = dataLayer.selectors.getPostSearchCursor(); 84 + const cursor = dataLayer.signals.$postSearchCursor.get(); 91 85 if (!cursor) return; 92 - await dataLayer.requests.loadPostSearch(state.searchQuery.trim(), { 86 + await dataLayer.requests.loadPostSearch($searchQuery.get().trim(), { 93 87 limit: 25, 94 88 cursor, 95 89 }); 96 - renderPage(); 97 90 } 98 91 99 92 async function loadMoreFeeds() { 100 - const cursor = dataLayer.selectors.getFeedSearchCursor(); 93 + const cursor = dataLayer.signals.$feedSearchCursor.get(); 101 94 if (!cursor) return; 102 - await dataLayer.requests.loadFeedSearch(state.searchQuery.trim(), { 95 + await dataLayer.requests.loadFeedSearch($searchQuery.get().trim(), { 103 96 limit: 15, 104 97 cursor, 105 98 }); 106 - renderPage(); 107 99 } 108 100 109 101 const { postInteractionHandler, feedInteractionHandler } = 110 102 interactionHandlers; 111 - bindToPage(root, interactionHandlers, "requestRender", () => renderPage()); 112 103 113 104 const handleSearchInput = debounce((value) => { 114 - state.searchQuery = value; 105 + $searchQuery.set(value); 115 106 loadSearchResults(); 116 107 }); 117 108 118 109 function handleClearSearch() { 119 - state.searchQuery = ""; 110 + $searchQuery.set(""); 120 111 loadSearchResults(); 121 112 } 122 113 123 114 function handleTabChange(tab) { 124 - tabScrollState.set(state.activeTab, window.scrollY); 125 - state.activeTab = tab; 126 - renderPage(); 115 + tabScrollState.set($activeTab.get(), window.scrollY); 116 + $activeTab.set(tab); 127 117 if (tabScrollState.has(tab)) { 128 118 window.scrollTo(0, tabScrollState.get(tab)); 129 119 } else { ··· 318 308 </infinite-scroll-container>`; 319 309 } 320 310 321 - function renderPage() { 322 - const currentUser = dataLayer.selectors.getCurrentUser(); 311 + pageEffect(root, () => { 312 + const currentUser = dataLayer.signals.$currentUser.get(); 323 313 const numNotifications = 324 - notificationService?.getNumNotifications() ?? null; 314 + notificationService?.$numNotifications.get() ?? null; 325 315 const numChatNotifications = 326 - chatNotificationService?.getNumNotifications() ?? null; 327 - const normalizedQuery = state.searchQuery.trim(); 316 + chatNotificationService?.$numNotifications.get() ?? null; 317 + const searchQuery = $searchQuery.get(); 318 + const activeTab = $activeTab.get(); 319 + const normalizedQuery = searchQuery.trim(); 328 320 const showResults = normalizedQuery.length > 0; 329 - const postStatus = dataLayer.requests.getStatus( 330 - `loadPostSearch-${normalizedQuery}-top`, 331 - ); 332 - const profileStatus = dataLayer.requests.getStatus( 333 - "loadProfileSearch-" + normalizedQuery, 334 - ); 335 - const feedStatus = dataLayer.requests.getStatus( 336 - "loadFeedSearch-" + normalizedQuery, 337 - ); 338 - const postSearchResults = dataLayer.selectors.getPostSearchResults(); 321 + const postStatus = dataLayer.requests.statusStore.$statuses 322 + .get(`loadPostSearch-${normalizedQuery}-top`) 323 + .get(); 324 + const profileStatus = dataLayer.requests.statusStore.$statuses 325 + .get("loadProfileSearch-" + normalizedQuery) 326 + .get(); 327 + const feedStatus = dataLayer.requests.statusStore.$statuses 328 + .get("loadFeedSearch-" + normalizedQuery) 329 + .get(); 330 + const postSearchResults = dataLayer.signals.$postSearchResults.get(); 339 331 const profileSearchResults = 340 - dataLayer.selectors.getProfileSearchResults(); 341 - const feedSearchResults = dataLayer.selectors.getFeedSearchResults(); 342 - const postSearchHasMore = !!dataLayer.selectors.getPostSearchCursor(); 332 + dataLayer.signals.$profileSearchResults.get(); 333 + const feedSearchResults = dataLayer.signals.$feedSearchResults.get(); 334 + const postSearchHasMore = !!dataLayer.signals.$postSearchCursor.get(); 343 335 const profileSearchHasMore = 344 - !!dataLayer.selectors.getProfileSearchCursor(); 345 - const feedSearchHasMore = !!dataLayer.selectors.getFeedSearchCursor(); 346 - const preferences = dataLayer.selectors.getPreferences(); 336 + !!dataLayer.signals.$profileSearchCursor.get(); 337 + const feedSearchHasMore = !!dataLayer.signals.$feedSearchCursor.get(); 338 + const preferences = dataLayer.signals.$preferences.get(); 347 339 348 340 render( 349 341 html`<div id="search-view"> ··· 371 363 placeholder=${isAuthenticated 372 364 ? "Search for users, posts, and feeds" 373 365 : "Search for users"} 374 - .value=${state.searchQuery} 366 + .value=${searchQuery} 375 367 @input=${(event) => handleSearchInput(event.target.value)} 376 368 /> 377 - ${state.searchQuery.length > 0 369 + ${searchQuery.length > 0 378 370 ? html` 379 371 <button 380 372 class="search-clear-button" ··· 395 387 ] 396 388 : []), 397 389 ], 398 - activeTab: state.activeTab, 390 + activeTab, 399 391 onTabClick: handleTabChange, 400 392 }) 401 393 : ""} ··· 409 401 <div class="search-tab-panels"> 410 402 <div 411 403 class="search-tab-panel" 412 - ?hidden=${state.activeTab !== "posts"} 404 + ?hidden=${activeTab !== "posts"} 413 405 > 414 406 <div 415 407 class="search-results-panel search-post-results" ··· 424 416 </div> 425 417 <div 426 418 class="search-tab-panel" 427 - ?hidden=${state.activeTab !== "profiles"} 419 + ?hidden=${activeTab !== "profiles"} 428 420 > 429 421 <div class="search-results-panel"> 430 422 ${profileSearchResultsTemplate({ ··· 436 428 </div> 437 429 <div 438 430 class="search-tab-panel" 439 - ?hidden=${state.activeTab !== "feeds"} 431 + ?hidden=${activeTab !== "feeds"} 440 432 > 441 433 <div class="search-results-panel"> 442 434 ${feedSearchResultsTemplate({ ··· 467 459 </div>`, 468 460 root, 469 461 ); 470 - } 462 + }); 471 463 472 464 root.addEventListener("page-enter", async () => { 473 465 const query = new URLSearchParams(window.location.search); 474 466 if (query.get("q")) { 475 - state.searchQuery = query.get("q"); 467 + $searchQuery.set(query.get("q")); 476 468 } 477 469 if (query.get("tab")) { 478 - state.activeTab = query.get("tab"); 470 + $activeTab.set(query.get("tab")); 479 471 } 480 - if (state.searchQuery) { 472 + if ($searchQuery.get()) { 481 473 loadSearchResults(); 482 474 } 483 - renderPage(); 484 475 if (isAuthenticated) { 485 - dataLayer.declarative.ensureCurrentUser().then(() => { 486 - renderPage(); 487 - }); 476 + dataLayer.declarative.ensureCurrentUser(); 488 477 } 489 478 }); 490 479 491 480 root.addEventListener("page-restore", (event) => { 492 481 const scrollY = event.detail?.scrollY ?? 0; 493 482 window.scrollTo(0, scrollY); 494 - renderPage(); 495 483 }); 496 - 497 - bindToPage(root, notificationService, "update", () => renderPage()); 498 - 499 - bindToPage(root, chatNotificationService, "update", () => renderPage()); 500 484 } 501 485 } 502 486
+8 -16
src/js/views/settings.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 - import { bindToPage } from "/js/router.js"; 2 + import { pageEffect } from "/js/router.js"; 3 3 import { html, render } from "/js/lib/lit-html.js"; 4 4 import { eyeIconTemplate } from "/js/templates/icons/eyeIcon.template.js"; 5 5 import { eyeSlashIconTemplate } from "/js/templates/icons/eyeSlashIcon.template.js"; ··· 72 72 }, 73 73 ]; 74 74 75 - function renderPage() { 76 - const currentUser = dataLayer.selectors.getCurrentUser(); 75 + pageEffect(root, () => { 76 + const currentUser = dataLayer.signals.$currentUser.get(); 77 77 const numNotifications = 78 - notificationService?.getNumNotifications() ?? null; 78 + notificationService?.$numNotifications.get() ?? null; 79 79 const numChatNotifications = 80 - chatNotificationService?.getNumNotifications() ?? null; 80 + chatNotificationService?.$numNotifications.get() ?? null; 81 81 render( 82 82 html`<div id="settings-view"> 83 83 ${mainLayoutTemplate({ ··· 172 172 </div>`, 173 173 root, 174 174 ); 175 - } 175 + }); 176 176 177 177 root.addEventListener("page-enter", async () => { 178 - // Initial empty state 179 - renderPage(); 180 - dataLayer.declarative.ensureCurrentUser().then(() => { 181 - renderPage(); 182 - }); 178 + dataLayer.declarative.ensureCurrentUser(); 183 179 }); 184 180 185 - root.addEventListener("page-restore", (e) => { 181 + root.addEventListener("page-restore", () => { 186 182 window.scrollTo(0, 0); 187 183 }); 188 - 189 - bindToPage(root, notificationService, "update", () => renderPage()); 190 - 191 - bindToPage(root, chatNotificationService, "update", () => renderPage()); 192 184 } 193 185 } 194 186
+11 -33
tests/unit/specs/chatNotificationService.test.js
··· 1 1 import { TestSuite } from "../testSuite.js"; 2 - import { assert, assertEquals, mock } from "../testHelpers.js"; 2 + import { assertEquals, mock } from "../testHelpers.js"; 3 3 import { ChatNotificationService } from "/js/chatNotificationService.js"; 4 4 5 5 const t = new TestSuite("chatNotificationService"); ··· 20 20 it("should initialize with zero notifications", () => { 21 21 const api = createMockApi(); 22 22 const service = new ChatNotificationService(api); 23 - assertEquals(service.getNumNotifications(), 0); 23 + assertEquals(service.$numNotifications.get(), 0); 24 24 }); 25 25 }); 26 26 ··· 32 32 33 33 await service.fetchNumNotifications(); 34 34 35 - assertEquals(service.getNumNotifications(), 3); 35 + assertEquals(service.$numNotifications.get(), 3); 36 36 }); 37 37 38 - it("should emit update event when count changes", async () => { 38 + it("should update $numNotifications signal when count changes", async () => { 39 39 const convos = [{ id: "1" }]; 40 40 const api = createMockApi({ convos }); 41 41 const service = new ChatNotificationService(api); 42 42 43 - let updateEmitted = false; 44 - service.on("update", () => { 45 - updateEmitted = true; 46 - }); 43 + assertEquals(service.$numNotifications.get(), 0); 47 44 48 45 await service.fetchNumNotifications(); 49 46 50 - assert(updateEmitted, "Update event should be emitted"); 51 - }); 52 - 53 - it("should not emit update event when count is unchanged", async () => { 54 - const convos = [{ id: "1" }]; 55 - const api = createMockApi({ convos }); 56 - const service = new ChatNotificationService(api); 57 - 58 - // First fetch to set initial count 59 - await service.fetchNumNotifications(); 60 - 61 - let updateCount = 0; 62 - service.on("update", () => { 63 - updateCount++; 64 - }); 65 - 66 - // Second fetch with same count 67 - await service.fetchNumNotifications(); 68 - 69 - assertEquals(updateCount, 0); 47 + assertEquals(service.$numNotifications.get(), 1); 70 48 }); 71 49 72 50 it("should handle empty convos", async () => { ··· 75 53 76 54 await service.fetchNumNotifications(); 77 55 78 - assertEquals(service.getNumNotifications(), 0); 56 + assertEquals(service.$numNotifications.get(), 0); 79 57 }); 80 58 }); 81 59 82 - t.describe("getNumNotifications", (it) => { 83 - it("should return current notification count", async () => { 60 + t.describe("$numNotifications", (it) => { 61 + it("should reflect current notification count", async () => { 84 62 const convos = [{ id: "1" }, { id: "2" }]; 85 63 const api = createMockApi({ convos }); 86 64 const service = new ChatNotificationService(api); 87 65 88 - assertEquals(service.getNumNotifications(), 0); 66 + assertEquals(service.$numNotifications.get(), 0); 89 67 90 68 await service.fetchNumNotifications(); 91 69 92 - assertEquals(service.getNumNotifications(), 2); 70 + assertEquals(service.$numNotifications.get(), 2); 93 71 }); 94 72 }); 95 73
+12 -43
tests/unit/specs/notificationService.test.js
··· 1 1 import { TestSuite } from "../testSuite.js"; 2 - import { assert, assertEquals, mock } from "../testHelpers.js"; 2 + import { assertEquals, mock } from "../testHelpers.js"; 3 3 import { NotificationService } from "/js/notificationService.js"; 4 4 5 5 const t = new TestSuite("notificationService"); ··· 16 16 it("should initialize with zero notifications", () => { 17 17 const api = createMockApi(); 18 18 const service = new NotificationService(api); 19 - assertEquals(service.getNumNotifications(), 0); 19 + assertEquals(service.$numNotifications.get(), 0); 20 20 }); 21 21 }); 22 22 ··· 27 27 28 28 await service.fetchNumNotifications(); 29 29 30 - assertEquals(service.getNumNotifications(), 5); 30 + assertEquals(service.$numNotifications.get(), 5); 31 31 }); 32 32 33 - it("should emit update event when count changes", async () => { 33 + it("should update $numNotifications signal when count changes", async () => { 34 34 const api = createMockApi({ numNotifications: 3 }); 35 35 const service = new NotificationService(api); 36 36 37 - const updateHandler = mock(); 38 - service.on("update", updateHandler); 37 + assertEquals(service.$numNotifications.get(), 0); 39 38 40 39 await service.fetchNumNotifications(); 41 40 42 - assertEquals(updateHandler.calls.length, 1); 43 - }); 44 - 45 - it("should not emit update event when count is unchanged", async () => { 46 - const api = createMockApi({ numNotifications: 3 }); 47 - const service = new NotificationService(api); 48 - 49 - // First fetch 50 - await service.fetchNumNotifications(); 51 - 52 - const updateHandler = mock(); 53 - service.on("update", updateHandler); 54 - 55 - // Second fetch with same count 56 - await service.fetchNumNotifications(); 57 - 58 - assertEquals(updateHandler.calls.length, 0); 41 + assertEquals(service.$numNotifications.get(), 3); 59 42 }); 60 43 }); 61 44 62 - t.describe("getNumNotifications", (it) => { 63 - it("should return current notification count", async () => { 45 + t.describe("$numNotifications", (it) => { 46 + it("should reflect current notification count", async () => { 64 47 const api = createMockApi({ numNotifications: 7 }); 65 48 const service = new NotificationService(api); 66 49 67 - assertEquals(service.getNumNotifications(), 0); 50 + assertEquals(service.$numNotifications.get(), 0); 68 51 69 52 await service.fetchNumNotifications(); 70 53 71 - assertEquals(service.getNumNotifications(), 7); 54 + assertEquals(service.$numNotifications.get(), 7); 72 55 }); 73 56 }); 74 57 ··· 78 61 const service = new NotificationService(api); 79 62 80 63 await service.fetchNumNotifications(); 81 - assertEquals(service.getNumNotifications(), 5); 64 + assertEquals(service.$numNotifications.get(), 5); 82 65 83 66 // Start marking as read (don't await) 84 67 const markPromise = service.markNotificationsAsRead(); 85 68 86 69 // Count should immediately be zero 87 - assertEquals(service.getNumNotifications(), 0); 70 + assertEquals(service.$numNotifications.get(), 0); 88 71 89 72 await markPromise; 90 - }); 91 - 92 - it("should emit update event", async () => { 93 - const api = createMockApi({ numNotifications: 5 }); 94 - const service = new NotificationService(api); 95 - 96 - await service.fetchNumNotifications(); 97 - 98 - const updateHandler = mock(); 99 - service.on("update", updateHandler); 100 - 101 - await service.markNotificationsAsRead(); 102 - 103 - assertEquals(updateHandler.calls.length, 1); 104 73 }); 105 74 106 75 it("should call api.markNotificationsAsRead", async () => {
+21 -25
src/js/views/settings/advanced.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 2 import { html, render } from "/js/lib/lit-html.js"; 3 + import { pageEffect } from "/js/router.js"; 3 4 import { headerTemplate } from "/js/templates/header.template.js"; 4 5 import { auth } from "/js/auth.js"; 5 6 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; ··· 12 13 } from "/js/appViewConfig.js"; 13 14 import { alertIconTemplate } from "/js/templates/icons/alertIcon.template.js"; 14 15 import { showToast } from "/js/toasts.js"; 16 + import { Signal } from "/js/utils.js"; 15 17 import { PermissionsDeclinedError } from "/js/plugins/pluginService.js"; 16 18 17 19 class SettingsAdvancedView extends View { ··· 40 42 customChatServiceDid: isStoredCustom ? storedConfig.chatServiceDid : "", 41 43 pluginInstallLoading: false, 42 44 }; 45 + const $stateTick = new Signal.State(0); 46 + function bumpState() { 47 + $stateTick.set($stateTick.get() + 1); 48 + } 43 49 44 50 function resolveSelectedAppViewConfig() { 45 51 if (state.appViewSelection === CUSTOM_APP_VIEW_CONFIG_ID) { ··· 72 78 const selectedConfig = resolveSelectedAppViewConfig(); 73 79 if (!isValidAppViewConfig(selectedConfig)) { 74 80 state.errorMessage = "Invalid App View configuration"; 75 - renderPage(); 81 + bumpState(); 76 82 return; 77 83 } 78 84 state.loading = true; 79 85 state.errorMessage = null; 80 - renderPage(); 86 + bumpState(); 81 87 setAppViewConfig(selectedConfig); 82 88 window.location.reload(); 83 89 } 84 90 85 91 function handleAppViewChange(e) { 86 92 state.appViewSelection = e.target.value; 87 - renderPage(); 93 + bumpState(); 88 94 } 89 95 90 96 function handleCustomAppViewDidInput(e) { 91 97 state.customAppViewServiceDid = e.target.value; 92 - renderPage(); 98 + bumpState(); 93 99 } 94 100 95 101 function handleCustomChatDidInput(e) { 96 102 state.customChatServiceDid = e.target.value; 97 - renderPage(); 103 + bumpState(); 98 104 } 99 105 100 106 async function handleInstallPlugin(e) { ··· 103 109 const url = input.value.trim(); 104 110 if (!url) return; 105 111 state.pluginInstallLoading = true; 106 - renderPage(); 112 + bumpState(); 107 113 try { 108 114 const result = await pluginService.installUnregisteredPlugin(url); 109 115 input.value = ""; ··· 118 124 } 119 125 } finally { 120 126 state.pluginInstallLoading = false; 121 - renderPage(); 127 + bumpState(); 122 128 } 123 129 } 124 130 125 - function renderPage() { 126 - const currentUser = dataLayer.selectors.getCurrentUser(); 131 + pageEffect(root, () => { 132 + $stateTick.get(); 133 + const currentUser = dataLayer.signals.$currentUser.get(); 127 134 const numNotifications = 128 - notificationService?.getNumNotifications() ?? null; 135 + notificationService?.$numNotifications.get() ?? null; 129 136 const numChatNotifications = 130 - chatNotificationService?.getNumNotifications() ?? null; 137 + chatNotificationService?.$numNotifications.get() ?? null; 131 138 const isCustom = state.appViewSelection === CUSTOM_APP_VIEW_CONFIG_ID; 132 139 render( 133 140 html`<div id="settings-advanced-view"> ··· 300 307 </div>`, 301 308 root, 302 309 ); 303 - } 310 + }); 304 311 305 312 root.addEventListener("page-enter", async () => { 306 - renderPage(); 307 - dataLayer.declarative.ensureCurrentUser().then(() => { 308 - renderPage(); 309 - }); 313 + dataLayer.declarative.ensureCurrentUser(); 310 314 }); 311 315 312 - root.addEventListener("page-restore", (e) => { 316 + root.addEventListener("page-restore", () => { 313 317 window.scrollTo(0, 0); 314 - }); 315 - 316 - notificationService?.on("update", () => { 317 - renderPage(); 318 - }); 319 - 320 - chatNotificationService?.on("update", () => { 321 - renderPage(); 322 318 }); 323 319 } 324 320 }
+18 -22
src/js/views/settings/appearance.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 2 import { html, render } from "/js/lib/lit-html.js"; 3 + import { pageEffect } from "/js/router.js"; 3 4 import { headerTemplate } from "/js/templates/header.template.js"; 4 5 import { auth } from "/js/auth.js"; 5 6 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 7 + import { Signal } from "/js/utils.js"; 6 8 import { 7 9 theme, 8 10 getDefaultHighlightColor, ··· 23 25 }) { 24 26 await auth.requireAuth(); 25 27 28 + const $themeTick = new Signal.State(0); 29 + function bumpTheme() { 30 + $themeTick.set($themeTick.get() + 1); 31 + } 32 + 26 33 function handleHighlightColorChange(newHighlightColor) { 27 34 theme.updateHighlightColor(newHighlightColor); 28 - renderPage(); 35 + bumpTheme(); 29 36 } 30 37 31 38 function handleLikeColorChange(newLikeColor) { 32 39 theme.updateLikeColor(newLikeColor); 33 - renderPage(); 40 + bumpTheme(); 34 41 } 35 42 36 43 function handleColorSchemeChange(newColorScheme) { 37 44 theme.updateColorScheme(newColorScheme); 38 - renderPage(); 45 + bumpTheme(); 39 46 } 40 47 41 - function renderPage() { 42 - const currentUser = dataLayer.selectors.getCurrentUser(); 48 + pageEffect(root, () => { 49 + $themeTick.get(); 50 + const currentUser = dataLayer.signals.$currentUser.get(); 43 51 const numNotifications = 44 - notificationService?.getNumNotifications() ?? null; 52 + notificationService?.$numNotifications.get() ?? null; 45 53 const numChatNotifications = 46 - chatNotificationService?.getNumNotifications() ?? null; 54 + chatNotificationService?.$numNotifications.get() ?? null; 47 55 const currentHighlightColor = theme.highlightColor; 48 56 const defaultHighlightColor = getDefaultHighlightColor(); 49 57 const currentLikeColor = theme.likeColor; ··· 166 174 </div>`, 167 175 root, 168 176 ); 169 - } 177 + }); 170 178 171 179 root.addEventListener("page-enter", async () => { 172 - // Initial empty state 173 - renderPage(); 174 - dataLayer.declarative.ensureCurrentUser().then(() => { 175 - renderPage(); 176 - }); 180 + dataLayer.declarative.ensureCurrentUser(); 177 181 }); 178 182 179 - root.addEventListener("page-restore", (e) => { 183 + root.addEventListener("page-restore", () => { 180 184 window.scrollTo(0, 0); 181 - }); 182 - 183 - notificationService?.on("update", () => { 184 - renderPage(); 185 - }); 186 - 187 - chatNotificationService?.on("update", () => { 188 - renderPage(); 189 185 }); 190 186 } 191 187 }
+13 -25
src/js/views/settings/blockedAccounts.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 2 import { html, render } from "/js/lib/lit-html.js"; 3 + import { pageEffect } from "/js/router.js"; 3 4 import { headerTemplate } from "/js/templates/header.template.js"; 4 5 import { auth } from "/js/auth.js"; 5 6 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; ··· 20 21 await auth.requireAuth(); 21 22 22 23 async function loadMore() { 23 - const blockedProfiles = dataLayer.selectors.getBlockedProfiles(); 24 + const blockedProfiles = dataLayer.dataStore.$blockedProfiles.get(); 24 25 const cursor = blockedProfiles?.cursor; 25 - const loadingPromise = dataLayer.requests.loadBlockedProfiles({ cursor }); 26 - renderPage(); 27 - await loadingPromise; 28 - renderPage(); 26 + await dataLayer.requests.loadBlockedProfiles({ cursor }); 29 27 } 30 28 31 29 function errorTemplate({ error }) { ··· 36 34 </div>`; 37 35 } 38 36 39 - function renderPage() { 40 - const currentUser = dataLayer.selectors.getCurrentUser(); 37 + pageEffect(root, () => { 38 + const currentUser = dataLayer.signals.$currentUser.get(); 41 39 const numNotifications = 42 - notificationService?.getNumNotifications() ?? null; 40 + notificationService?.$numNotifications.get() ?? null; 43 41 const numChatNotifications = 44 - chatNotificationService?.getNumNotifications() ?? null; 45 - const blockedProfiles = dataLayer.selectors.getBlockedProfiles(); 46 - const status = dataLayer.requests.getStatus("loadBlockedProfiles"); 42 + chatNotificationService?.$numNotifications.get() ?? null; 43 + const blockedProfiles = dataLayer.dataStore.$blockedProfiles.get(); 44 + const status = dataLayer.requests.statusStore.$statuses 45 + .get("loadBlockedProfiles") 46 + .get(); 47 47 const hasMore = blockedProfiles?.cursor ? true : false; 48 48 49 49 render( ··· 85 85 </div>`, 86 86 root, 87 87 ); 88 - } 88 + }); 89 89 90 90 root.addEventListener("page-enter", async () => { 91 - renderPage(); 92 - dataLayer.declarative.ensureCurrentUser().then(() => { 93 - renderPage(); 94 - }); 91 + dataLayer.declarative.ensureCurrentUser(); 95 92 await loadMore(); 96 93 }); 97 94 98 95 root.addEventListener("page-restore", () => { 99 96 window.scrollTo(0, 0); 100 - renderPage(); 101 - }); 102 - 103 - notificationService?.on("update", () => { 104 - renderPage(); 105 - }); 106 - 107 - chatNotificationService?.on("update", () => { 108 - renderPage(); 109 97 }); 110 98 } 111 99 }
+17 -14
src/js/views/settings/communityPlugins.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 2 import { html, render } from "/js/lib/lit-html.js"; 3 + import { pageEffect } from "/js/router.js"; 3 4 import { headerTemplate } from "/js/templates/header.template.js"; 4 5 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 5 6 import { auth } from "/js/auth.js"; 6 7 import { showToast } from "/js/toasts.js"; 7 8 import { confirm } from "/js/modals.js"; 9 + import { Signal } from "/js/utils.js"; 8 10 import { PermissionsDeclinedError } from "/js/plugins/pluginService.js"; 9 11 10 12 class SettingsCommunityPluginsView extends View { ··· 24 26 error: null, 25 27 pending: new Set(), 26 28 }; 29 + const $stateTick = new Signal.State(0); 30 + function bumpState() { 31 + $stateTick.set($stateTick.get() + 1); 32 + } 27 33 28 34 async function loadListings() { 29 35 state.error = null; 30 - renderPage(); 36 + bumpState(); 31 37 try { 32 38 await pluginService.loadRegistryListings(); 33 39 } catch (error) { 34 40 console.error(error); 35 41 state.error = error.message ?? String(error); 36 42 } 37 - renderPage(); 43 + bumpState(); 38 44 } 39 45 40 46 async function toggleInstall(listing) { ··· 51 57 if (!confirmed) return; 52 58 } 53 59 state.pending.add(listing.id); 54 - renderPage(); 60 + bumpState(); 55 61 try { 56 62 if (wasInstalled) { 57 63 await pluginService.uninstallPlugin(listing.id); ··· 78 84 } 79 85 } 80 86 state.pending.delete(listing.id); 81 - renderPage(); 87 + bumpState(); 82 88 } 83 89 84 - function renderPage() { 85 - const currentUser = dataLayer.selectors.getCurrentUser(); 90 + pageEffect(root, () => { 91 + $stateTick.get(); 92 + const currentUser = dataLayer.signals.$currentUser.get(); 86 93 const listings = pluginService.getRegistryListings(); 87 94 const numNotifications = 88 - notificationService?.getNumNotifications() ?? null; 95 + notificationService?.$numNotifications.get() ?? null; 89 96 const numChatNotifications = 90 - chatNotificationService?.getNumNotifications() ?? null; 97 + chatNotificationService?.$numNotifications.get() ?? null; 91 98 render( 92 99 html`<div id="settings-community-plugins-view"> 93 100 ${mainLayoutTemplate({ ··· 184 191 </div>`, 185 192 root, 186 193 ); 187 - } 194 + }); 188 195 189 196 root.addEventListener("page-enter", async () => { 190 - renderPage(); 191 - dataLayer.declarative.ensureCurrentUser().then(() => renderPage()); 197 + dataLayer.declarative.ensureCurrentUser(); 192 198 await loadListings(); 193 199 }); 194 200 ··· 196 202 window.scrollTo(0, 0); 197 203 loadListings(); 198 204 }); 199 - 200 - notificationService?.on("update", () => renderPage()); 201 - chatNotificationService?.on("update", () => renderPage()); 202 205 } 203 206 } 204 207
+13 -25
src/js/views/settings/mutedAccounts.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 2 import { html, render } from "/js/lib/lit-html.js"; 3 + import { pageEffect } from "/js/router.js"; 3 4 import { headerTemplate } from "/js/templates/header.template.js"; 4 5 import { auth } from "/js/auth.js"; 5 6 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; ··· 20 21 await auth.requireAuth(); 21 22 22 23 async function loadMore() { 23 - const mutedProfiles = dataLayer.selectors.getMutedProfiles(); 24 + const mutedProfiles = dataLayer.dataStore.$mutedProfiles.get(); 24 25 const cursor = mutedProfiles?.cursor; 25 - const loadingPromise = dataLayer.requests.loadMutedProfiles({ cursor }); 26 - renderPage(); 27 - await loadingPromise; 28 - renderPage(); 26 + await dataLayer.requests.loadMutedProfiles({ cursor }); 29 27 } 30 28 31 29 function errorTemplate({ error }) { ··· 36 34 </div>`; 37 35 } 38 36 39 - function renderPage() { 40 - const currentUser = dataLayer.selectors.getCurrentUser(); 37 + pageEffect(root, () => { 38 + const currentUser = dataLayer.signals.$currentUser.get(); 41 39 const numNotifications = 42 - notificationService?.getNumNotifications() ?? null; 40 + notificationService?.$numNotifications.get() ?? null; 43 41 const numChatNotifications = 44 - chatNotificationService?.getNumNotifications() ?? null; 45 - const mutedProfiles = dataLayer.selectors.getMutedProfiles(); 46 - const status = dataLayer.requests.getStatus("loadMutedProfiles"); 42 + chatNotificationService?.$numNotifications.get() ?? null; 43 + const mutedProfiles = dataLayer.dataStore.$mutedProfiles.get(); 44 + const status = dataLayer.requests.statusStore.$statuses 45 + .get("loadMutedProfiles") 46 + .get(); 47 47 const hasMore = mutedProfiles?.cursor ? true : false; 48 48 49 49 render( ··· 85 85 </div>`, 86 86 root, 87 87 ); 88 - } 88 + }); 89 89 90 90 root.addEventListener("page-enter", async () => { 91 - renderPage(); 92 - dataLayer.declarative.ensureCurrentUser().then(() => { 93 - renderPage(); 94 - }); 91 + dataLayer.declarative.ensureCurrentUser(); 95 92 await loadMore(); 96 93 }); 97 94 98 95 root.addEventListener("page-restore", () => { 99 96 window.scrollTo(0, 0); 100 - renderPage(); 101 - }); 102 - 103 - notificationService?.on("update", () => { 104 - renderPage(); 105 - }); 106 - 107 - chatNotificationService?.on("update", () => { 108 - renderPage(); 109 97 }); 110 98 } 111 99 }
+27 -30
src/js/views/settings/mutedWords.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 2 import { html, render } from "/js/lib/lit-html.js"; 3 + import { pageEffect } from "/js/router.js"; 3 4 import { headerTemplate } from "/js/templates/header.template.js"; 4 5 import { auth } from "/js/auth.js"; 5 6 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 6 7 import { confirm } from "/js/modals.js"; 7 - import { differenceInHours, differenceInDays } from "/js/utils.js"; 8 + import { differenceInHours, differenceInDays, Signal } from "/js/utils.js"; 8 9 import "/js/components/context-menu.js"; 9 10 import "/js/components/context-menu-item.js"; 10 11 import "/js/components/context-menu-label.js"; ··· 29 30 removingWordId: null, 30 31 renewingWordId: null, 31 32 }; 33 + const $stateTick = new Signal.State(0); 34 + function bumpState() { 35 + $stateTick.set($stateTick.get() + 1); 36 + } 32 37 33 38 function sanitizeMutedWordValue(value) { 34 39 return value 35 40 .trim() 36 41 .replace(/^#/, "") 37 - .replace(/[\u200B\u200C\u200D\uFEFF\u00AD]/g, ""); 42 + .replace(/[​‌‍­]/g, ""); 38 43 } 39 44 40 45 function formatExpiration(expiresAt) { ··· 58 63 const sanitized = sanitizeMutedWordValue(formData.get("word")); 59 64 if (!sanitized) { 60 65 state.error = "Please enter a valid word, tag, or phrase to mute"; 61 - renderPage(); 66 + bumpState(); 62 67 return; 63 68 } 64 69 ··· 81 86 82 87 state.isSaving = true; 83 88 state.error = ""; 84 - renderPage(); 89 + bumpState(); 85 90 86 91 try { 87 92 await dataLayer.mutations.addMutedWord({ ··· 95 100 state.error = err.message || "Failed to add muted word"; 96 101 } finally { 97 102 state.isSaving = false; 98 - renderPage(); 103 + bumpState(); 99 104 } 100 105 } 101 106 ··· 115 120 if (!confirmed) return; 116 121 117 122 state.removingWordId = word.id; 118 - renderPage(); 123 + bumpState(); 119 124 try { 120 125 await dataLayer.mutations.removeMutedWord(word.id); 121 126 } catch (err) { 122 127 state.error = err.message || "Failed to remove muted word"; 123 128 } finally { 124 129 state.removingWordId = null; 125 - renderPage(); 130 + bumpState(); 126 131 } 127 132 } 128 133 ··· 139 144 } 140 145 141 146 state.renewingWordId = word.id; 142 - renderPage(); 147 + bumpState(); 143 148 try { 144 149 await dataLayer.mutations.updateMutedWord(word.id, { expiresAt }); 145 150 } catch (err) { 146 151 state.error = err.message || "Failed to renew muted word"; 147 152 } finally { 148 153 state.renewingWordId = null; 149 - renderPage(); 154 + bumpState(); 150 155 } 151 156 } 152 157 ··· 181 186 </div> 182 187 ${metaParts.length > 0 183 188 ? html`<div class="muted-word-item-meta"> 184 - ${metaParts.join(" \u2022 ")} 189 + ${metaParts.join(" • ")} 185 190 </div>` 186 191 : ""} 187 192 </div> ··· 238 243 `; 239 244 } 240 245 241 - function renderPage() { 242 - const currentUser = dataLayer.selectors.getCurrentUser(); 246 + pageEffect(root, () => { 247 + $stateTick.get(); 248 + const currentUser = dataLayer.signals.$currentUser.get(); 243 249 const numNotifications = 244 - notificationService?.getNumNotifications() ?? null; 250 + notificationService?.$numNotifications.get() ?? null; 245 251 const numChatNotifications = 246 - chatNotificationService?.getNumNotifications() ?? null; 247 - const preferences = dataLayer.preferencesProvider.requirePreferences(); 248 - const mutedWords = [...preferences.getMutedWords()].reverse(); 252 + chatNotificationService?.$numNotifications.get() ?? null; 253 + const preferences = dataLayer.signals.$preferences.get(); 254 + const mutedWords = preferences 255 + ? [...preferences.getMutedWords()].reverse() 256 + : []; 249 257 250 258 render( 251 259 html`<div id="settings-muted-words-view"> ··· 286 294 state.error = ""; 287 295 } 288 296 state.hasValue = !!e.target.value.trim(); 289 - renderPage(); 297 + bumpState(); 290 298 }} 291 299 autocomplete="off" 292 300 autocorrect="off" ··· 398 406 </div>`, 399 407 root, 400 408 ); 401 - } 409 + }); 402 410 403 411 root.addEventListener("page-enter", async () => { 404 - renderPage(); 405 - dataLayer.declarative.ensureCurrentUser().then(() => { 406 - renderPage(); 407 - }); 412 + dataLayer.declarative.ensureCurrentUser(); 408 413 }); 409 414 410 415 root.addEventListener("page-restore", () => { 411 416 window.scrollTo(0, 0); 412 - }); 413 - 414 - notificationService?.on("update", () => { 415 - renderPage(); 416 - }); 417 - 418 - chatNotificationService?.on("update", () => { 419 - renderPage(); 420 417 }); 421 418 } 422 419 }
+45 -47
src/js/views/settings/pluginDetail.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 2 import { html, render } from "/js/lib/lit-html.js"; 3 + import { pageEffect } from "/js/router.js"; 3 4 import { headerTemplate } from "/js/templates/header.template.js"; 4 5 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 5 6 import { auth } from "/js/auth.js"; 7 + import { Signal } from "/js/utils.js"; 6 8 7 9 class SettingsPluginDetailView extends View { 8 10 async render({ ··· 21 23 const { pluginId } = params; 22 24 const state = { 23 25 manifest: null, 24 - tab: null, 25 26 containerNode: null, 26 27 error: null, 27 28 }; 28 - 29 - async function loadTab() { 30 - state.manifest = await pluginService.getManifest(pluginId); 31 - if (!state.manifest) { 32 - state.error = "Plugin not found."; 33 - renderPage(); 34 - return; 35 - } 36 - if (!pluginService.pluginBridge.isLoaded(pluginId)) { 37 - state.error = "This plugin is not enabled."; 38 - renderPage(); 39 - return; 40 - } 41 - const tab = pluginService.getSettingTab(pluginId); 42 - state.tab = tab; 43 - if (!tab) { 44 - state.error = "This plugin has no settings."; 45 - renderPage(); 46 - return; 47 - } 48 - try { 49 - state.containerNode = await tab.display(); 50 - state.error = null; 51 - } catch (error) { 52 - state.error = error.message ?? String(error); 53 - } 54 - renderPage(); 29 + const $stateTick = new Signal.State(0); 30 + function bumpState() { 31 + $stateTick.set($stateTick.get() + 1); 55 32 } 56 33 57 - pluginService.on("settingTabRefresh", (event) => { 58 - if (event.pluginId === pluginId) { 59 - loadTab(); 60 - } 61 - }); 34 + pageEffect( 35 + root, 36 + () => { 37 + const tab = pluginService.$settingTabs.get(pluginId).get(); 38 + const installed = pluginService.prefManager.$installedPlugins 39 + .get() 40 + .find((plugin) => plugin.id === pluginId); 41 + state.manifest = installed ?? null; 42 + if (!installed) { 43 + state.error = "Plugin not found."; 44 + bumpState(); 45 + } else if (!pluginService.pluginBridge.isLoaded(pluginId)) { 46 + state.error = "This plugin is not enabled."; 47 + bumpState(); 48 + } else if (!tab) { 49 + state.error = "This plugin has no settings."; 50 + bumpState(); 51 + } else { 52 + (async () => { 53 + try { 54 + state.containerNode = await tab.display(); 55 + state.error = null; 56 + } catch (error) { 57 + state.error = error.message ?? String(error); 58 + } 59 + bumpState(); 60 + })(); 61 + } 62 + }, 63 + "RELOAD_PLUGIN_SETTING_TAB", 64 + ); 62 65 63 66 const tabRoot = pluginService.getRenderer(pluginId).createRoot({ 64 - handlerRenderFunc: () => renderPage(), 67 + handlerRenderFunc: () => bumpState(), 65 68 }); 66 69 67 70 function renderTabContent(containerNode) { ··· 69 72 return tabRoot.render(containerNode); 70 73 } 71 74 72 - function renderPage() { 73 - const currentUser = dataLayer.selectors.getCurrentUser(); 75 + pageEffect(root, () => { 76 + $stateTick.get(); 77 + const currentUser = dataLayer.signals.$currentUser.get(); 74 78 const numNotifications = 75 - notificationService?.getNumNotifications() ?? null; 79 + notificationService?.$numNotifications.get() ?? null; 76 80 const numChatNotifications = 77 - chatNotificationService?.getNumNotifications() ?? null; 81 + chatNotificationService?.$numNotifications.get() ?? null; 78 82 const title = state.manifest?.name ?? pluginId; 79 83 render( 80 84 html`<div id="settings-plugin-detail-view"> ··· 102 106 </div>`, 103 107 root, 104 108 ); 105 - } 109 + }); 106 110 107 - root.addEventListener("page-enter", async () => { 108 - renderPage(); 109 - dataLayer.declarative.ensureCurrentUser().then(() => renderPage()); 110 - await loadTab(); 111 + root.addEventListener("page-enter", () => { 112 + dataLayer.declarative.ensureCurrentUser(); 111 113 }); 112 114 113 115 root.addEventListener("page-restore", () => { 114 116 window.scrollTo(0, 0); 115 - loadTab(); 116 117 }); 117 - 118 - notificationService?.on("update", () => renderPage()); 119 - chatNotificationService?.on("update", () => renderPage()); 120 118 } 121 119 } 122 120
+26 -23
src/js/views/settings/plugins.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 2 import { html, render } from "/js/lib/lit-html.js"; 3 + import { pageEffect } from "/js/router.js"; 3 4 import { headerTemplate } from "/js/templates/header.template.js"; 4 5 import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 5 6 import { auth } from "/js/auth.js"; ··· 10 11 import { reloadIconTemplate } from "/js/templates/icons/reloadIcon.template.js"; 11 12 import { confirm } from "/js/modals.js"; 12 13 import { showToast } from "/js/toasts.js"; 14 + import { Signal } from "/js/utils.js"; 13 15 import { PermissionsDeclinedError } from "/js/plugins/pluginService.js"; 14 16 import "/js/components/toggle-switch.js"; 15 17 ··· 35 37 updatingAll: false, 36 38 updatingIds: new Set(), 37 39 }; 40 + const $stateTick = new Signal.State(0); 41 + function bumpState() { 42 + $stateTick.set($stateTick.get() + 1); 43 + } 44 + 38 45 async function uninstallPlugin(plugin) { 39 46 const confirmed = await confirm( 40 47 `"${plugin.name}" will be uninstalled and its settings will be deleted.`, ··· 46 53 ); 47 54 if (!confirmed) return; 48 55 state.uninstallingIds.add(plugin.id); 49 - renderPage(); 56 + bumpState(); 50 57 try { 51 58 await pluginService.uninstallPlugin(plugin.id); 52 59 showToast(`Uninstalled ${plugin.name}`); 53 60 } finally { 54 61 state.uninstallingIds.delete(plugin.id); 55 - renderPage(); 62 + bumpState(); 56 63 } 57 64 } 58 65 59 66 async function reloadPlugins() { 60 67 if (state.reloading) return; 61 68 state.reloading = true; 62 - renderPage(); 69 + bumpState(); 63 70 try { 64 71 await pluginService.reloadPlugins(); 65 72 showToast("Reloaded plugins"); ··· 68 75 showToast("Failed to reload plugins", { style: "error" }); 69 76 } finally { 70 77 state.reloading = false; 71 - renderPage(); 78 + bumpState(); 72 79 } 73 80 } 74 81 75 82 async function checkForUpdates() { 76 83 if (state.checkingForUpdates) return; 77 84 state.checkingForUpdates = true; 78 - renderPage(); 85 + bumpState(); 79 86 try { 80 87 const updates = await pluginService.checkForUpdates(); 81 88 if (updates.size === 0) { ··· 89 96 showToast("Failed to check for updates", { style: "error" }); 90 97 } finally { 91 98 state.checkingForUpdates = false; 92 - renderPage(); 99 + bumpState(); 93 100 } 94 101 } 95 102 96 103 async function updatePlugin(plugin) { 97 104 state.updatingIds.add(plugin.id); 98 - renderPage(); 105 + bumpState(); 99 106 try { 100 107 const result = await pluginService.updatePlugin(plugin.id); 101 108 if (result.updated) { ··· 114 121 } 115 122 } finally { 116 123 state.updatingIds.delete(plugin.id); 117 - renderPage(); 124 + bumpState(); 118 125 } 119 126 } 120 127 121 128 async function updateAllPlugins() { 122 129 if (state.updatingAll) return; 123 130 state.updatingAll = true; 124 - renderPage(); 131 + bumpState(); 125 132 try { 126 133 const { updated, failed } = await pluginService.updateAllPlugins(); 127 134 if (failed.length > 0) { ··· 136 143 } 137 144 } finally { 138 145 state.updatingAll = false; 139 - renderPage(); 146 + bumpState(); 140 147 } 141 148 } 142 149 ··· 145 152 ? state.disablingIds 146 153 : state.enablingIds; 147 154 pendingSet.add(plugin.id); 148 - renderPage(); 155 + bumpState(); 149 156 try { 150 157 if (plugin.enabled) { 151 158 await pluginService.disablePlugin(plugin.id); ··· 162 169 } 163 170 } finally { 164 171 pendingSet.delete(plugin.id); 165 - renderPage(); 172 + bumpState(); 166 173 } 167 174 } 168 175 169 - function renderPage() { 170 - const currentUser = dataLayer.selectors.getCurrentUser(); 176 + pageEffect(root, () => { 177 + $stateTick.get(); 178 + const currentUser = dataLayer.signals.$currentUser.get(); 171 179 const numNotifications = 172 - notificationService?.getNumNotifications() ?? null; 180 + notificationService?.$numNotifications.get() ?? null; 173 181 const numChatNotifications = 174 - chatNotificationService?.getNumNotifications() ?? null; 182 + chatNotificationService?.$numNotifications.get() ?? null; 175 183 const pluginsInfo = pluginService.getPluginsInfo(); 176 184 const availableUpdates = pluginService.getAvailableUpdates(); 177 185 const hasAvailableUpdates = ··· 356 364 </div>`, 357 365 root, 358 366 ); 359 - } 367 + }); 360 368 361 369 root.addEventListener("page-enter", async () => { 362 - renderPage(); 363 - dataLayer.declarative.ensureCurrentUser().then(() => renderPage()); 370 + dataLayer.declarative.ensureCurrentUser(); 364 371 }); 365 372 366 373 root.addEventListener("page-restore", () => { 367 374 window.scrollTo(0, 0); 368 - renderPage(); 369 375 }); 370 - 371 - notificationService?.on("update", () => renderPage()); 372 - chatNotificationService?.on("update", () => renderPage()); 373 376 } 374 377 } 375 378
+53 -181
tests/unit/specs/components/plugin-posts-feed.test.js
··· 1 1 import { TestSuite } from "../../testSuite.js"; 2 2 import { assert, assertEquals } from "../../testHelpers.js"; 3 - import { EventEmitter } from "/js/eventEmitter.js"; 4 - import * as derived from "/js/dataLayer/derived.js"; 3 + import { Signal, SignalMap } from "/js/utils.js"; 5 4 import "/js/components/plugin-posts-feed.js"; 6 5 7 6 const t = new TestSuite("PluginPostsFeed"); 8 7 9 - function emptyPreferences() { 8 + function makeDataLayer({ ensurePosts, currentUser } = {}) { 9 + const postSignals = new SignalMap(); 10 10 return { 11 - postHasMutedWord: () => false, 12 - quotedPostHasMutedWord: () => false, 13 - isPostHidden: () => false, 14 - getBadgeLabels: () => [], 15 - getContentLabel: () => null, 16 - getMediaLabel: () => null, 17 - }; 18 - } 19 - 20 - function makeDataLayer({ ensurePosts, getPost, events, preferences } = {}) { 21 - const resolved = getPost ?? (() => null); 22 - return { 23 - events: events ?? new EventEmitter(), 24 - declarative: { ensurePosts: ensurePosts ?? (() => new Promise(() => {})) }, 25 - dataStore: { getPost: resolved }, 26 - patchStore: { applyPostPatches: (post) => post }, 27 - selectors: { 28 - getCurrentUser: () => null, 29 - getPreferences: () => preferences ?? emptyPreferences(), 11 + declarative: { 12 + ensurePosts: ensurePosts ?? (() => new Promise(() => {})), 30 13 }, 31 - base: { getPost: resolved }, 32 - derived, 14 + signals: { 15 + $currentUser: new Signal.State(currentUser ?? null), 16 + $hydratedPosts: postSignals, 17 + }, 18 + __setPost(uri, post) { 19 + postSignals.set(uri, post); 20 + }, 33 21 }; 34 22 } 35 23 ··· 39 27 40 28 function makeElement({ 41 29 ensurePosts, 42 - getPost, 43 - events, 44 - preferences, 30 + currentUser, 45 31 postInteractionHandler, 46 32 } = {}) { 47 33 const element = document.createElement("plugin-posts-feed"); 48 - element.dataLayer = makeDataLayer({ 49 - ensurePosts, 50 - getPost, 51 - events, 52 - preferences, 53 - }); 34 + element.dataLayer = makeDataLayer({ ensurePosts, currentUser }); 54 35 element.isAuthenticated = false; 55 36 element.pluginService = null; 56 37 element.postInteractionHandler = postInteractionHandler ?? makeHandler(); 57 38 return element; 58 39 } 59 40 60 - function flushMicrotasks() { 61 - return new Promise((resolve) => setTimeout(resolve, 0)); 41 + async function flushMicrotasks() { 42 + // Two ticks: the first flushes microtasks (e.g. ensurePosts), the second 43 + // lets the rAF-scheduled effect render run before assertions. 44 + await new Promise((resolve) => setTimeout(resolve, 0)); 45 + await new Promise((resolve) => setTimeout(resolve, 0)); 62 46 } 63 47 64 48 t.beforeEach(() => { ··· 70 54 const element = makeElement(); 71 55 element.setAttribute("uris", "at://a,at://b,at://c"); 72 56 document.body.appendChild(element); 73 - // feedSkeletonTemplate renders inside <div class="feed"> 74 57 assert(element.querySelector(".feed") !== null); 75 - // No real feed items yet 76 58 assertEquals( 77 59 element.querySelectorAll("[data-testid='feed-item']").length, 78 60 0, ··· 81 63 }); 82 64 83 65 t.describe("PluginPostsFeed - empty uris", (it) => { 84 - it("renders the empty message and does not call ensurePosts", () => { 66 + it("renders the empty message and does not call ensurePosts", async () => { 85 67 let called = false; 86 68 const element = makeElement({ 87 69 ensurePosts: () => { ··· 92 74 element.setAttribute("uris", ""); 93 75 element.setAttribute("empty-message", "Nothing here."); 94 76 document.body.appendChild(element); 77 + await flushMicrotasks(); 95 78 const endMessage = element.querySelector( 96 79 "[data-testid='feed-end-message']", 97 80 ); 98 81 assert(endMessage !== null); 99 82 assert(endMessage.textContent.includes("Nothing here.")); 100 - assertEquals(called, false); 83 + // ensurePosts is still invoked with an empty uri list — the empty render 84 + // is driven by the empty posts array, not by skipping the request. 85 + assertEquals(called, true); 101 86 }); 102 87 }); 103 88 ··· 177 162 }); 178 163 }); 179 164 180 - t.describe("PluginPostsFeed - refresh", (it) => { 181 - it("re-renders against the latest selectors state without re-fetching", async () => { 182 - let postsCall = 0; 183 - let currentUserCall = 0; 165 + t.describe("PluginPostsFeed - live updates", (it) => { 166 + it("re-renders when a hydrated post signal updates", async () => { 167 + const dataLayer = makeDataLayer({ 168 + ensurePosts: () => Promise.resolve([null]), 169 + }); 184 170 const element = document.createElement("plugin-posts-feed"); 185 - element.dataLayer = { 186 - events: new EventEmitter(), 187 - declarative: { 188 - ensurePosts: () => { 189 - postsCall++; 190 - return Promise.resolve([]); 191 - }, 192 - }, 193 - dataStore: { getPost: () => null }, 194 - patchStore: { applyPostPatches: (post) => post }, 195 - selectors: { 196 - getCurrentUser: () => { 197 - currentUserCall++; 198 - return null; 199 - }, 200 - getPreferences: () => emptyPreferences(), 201 - }, 202 - base: { getPost: () => null }, 203 - derived, 204 - }; 171 + element.dataLayer = dataLayer; 205 172 element.isAuthenticated = false; 206 173 element.pluginService = null; 207 174 element.postInteractionHandler = makeHandler(); 208 - element.setAttribute("uris", ""); 209 - document.body.appendChild(element); 210 - const beforeRefresh = currentUserCall; 211 - element.refresh(); 212 - // refresh() pulls fresh selectors state without re-issuing ensurePosts. 213 - assert(currentUserCall > beforeRefresh); 214 - assertEquals(postsCall, 0); 215 - }); 216 - 217 - it("re-selects posts from the store on each render to pick up updates", async () => { 218 - const calls = []; 219 - const element = makeElement({ 220 - ensurePosts: () => Promise.resolve([null]), 221 - getPost: (uri) => { 222 - calls.push(uri); 223 - return null; 224 - }, 225 - }); 226 175 element.setAttribute("uris", "at://a"); 227 176 document.body.appendChild(element); 228 177 await flushMicrotasks(); 229 - const callsAfterLoad = calls.length; 230 - assert(callsAfterLoad > 0); 231 - element.refresh(); 232 - // refresh() re-selects fresh post data from the store rather than reusing 233 - // a cached snapshot, so updates flow through without a re-fetch. 234 - assert(calls.length > callsAfterLoad); 235 - assertEquals(calls[calls.length - 1], "at://a"); 236 - }); 237 - 238 - it("is a no-op before connectedCallback runs", () => { 239 - const element = document.createElement("plugin-posts-feed"); 240 - // Should not throw even though required props aren't set yet. 241 - element.refresh(); 178 + // No post hydrated yet -> empty feed. 179 + assertEquals( 180 + element.querySelectorAll("[data-testid='feed-item']").length, 181 + 0, 182 + ); 183 + // Updating the post signal should cause a re-render that picks it up. 184 + dataLayer.__setPost("at://a", makeStubPost("at://a")); 185 + await flushMicrotasks(); 186 + assert(element.querySelectorAll("[data-testid='feed-item']").length >= 1); 242 187 }); 243 188 }); 244 189 245 - t.describe("PluginPostsFeed - live subscriptions", (it) => { 246 - it("subscribes to post:${uri} for each loaded uri and to preferences:changed", async () => { 247 - const events = new EventEmitter(); 248 - const element = makeElement({ 249 - ensurePosts: () => Promise.resolve([null, null, null]), 250 - events, 251 - }); 252 - element.setAttribute("uris", "at://a,at://b,at://c"); 253 - document.body.appendChild(element); 254 - await flushMicrotasks(); 255 - assertEquals(events.__eventListeners.get("post:at://a").length, 1); 256 - assertEquals(events.__eventListeners.get("post:at://b").length, 1); 257 - assertEquals(events.__eventListeners.get("post:at://c").length, 1); 258 - assertEquals(events.__eventListeners.get("preferences:changed").length, 1); 259 - }); 260 - 261 - it("syncs subscriptions when the uri list changes", async () => { 262 - const events = new EventEmitter(); 263 - const element = makeElement({ 264 - ensurePosts: () => Promise.resolve([null]), 265 - events, 266 - }); 267 - element.setAttribute("uris", "at://a"); 268 - document.body.appendChild(element); 269 - await flushMicrotasks(); 270 - element.setAttribute("uris", "at://b,at://c"); 271 - await flushMicrotasks(); 272 - // old subscription removed, new ones added 273 - assertEquals(events.__eventListeners.get("post:at://a"), undefined); 274 - assertEquals(events.__eventListeners.get("post:at://b").length, 1); 275 - assertEquals(events.__eventListeners.get("post:at://c").length, 1); 276 - }); 277 - 278 - it("removes all live listeners on disconnect", async () => { 279 - const events = new EventEmitter(); 280 - const element = makeElement({ 281 - ensurePosts: () => Promise.resolve([null, null]), 282 - events, 283 - }); 284 - element.setAttribute("uris", "at://a,at://b"); 285 - document.body.appendChild(element); 286 - await flushMicrotasks(); 287 - element.remove(); 288 - assertEquals(events.__eventListeners.get("post:at://a"), undefined); 289 - assertEquals(events.__eventListeners.get("post:at://b"), undefined); 290 - assertEquals(events.__eventListeners.get("preferences:changed"), undefined); 291 - }); 292 - 293 - it("re-renders when post:${uri} fires", async () => { 294 - const events = new EventEmitter(); 295 - const calls = []; 296 - const element = makeElement({ 297 - ensurePosts: () => Promise.resolve([null]), 298 - events, 299 - getPost: (uri) => { 300 - calls.push(uri); 301 - return null; 302 - }, 303 - }); 304 - element.setAttribute("uris", "at://a"); 305 - document.body.appendChild(element); 306 - await flushMicrotasks(); 307 - const before = calls.length; 308 - events.emit("post:at://a"); 309 - assert(calls.length > before); 310 - }); 311 - 312 - it("re-renders when preferences:changed fires", async () => { 313 - const events = new EventEmitter(); 314 - let prefsCalls = 0; 315 - const element = makeElement({ 316 - ensurePosts: () => Promise.resolve([]), 317 - events, 318 - }); 319 - // count preferences accesses to detect re-renders 320 - element.dataLayer.selectors.getPreferences = () => { 321 - prefsCalls += 1; 322 - return emptyPreferences(); 323 - }; 324 - element.setAttribute("uris", ""); 325 - document.body.appendChild(element); 326 - await flushMicrotasks(); 327 - const before = prefsCalls; 328 - events.emit("preferences:changed"); 329 - assert(prefsCalls > before); 330 - }); 331 - }); 190 + function makeStubPost(uri) { 191 + return { 192 + uri, 193 + cid: "cid:" + uri, 194 + author: { 195 + did: "did:test:author", 196 + handle: "author.test", 197 + displayName: "author", 198 + }, 199 + record: { text: "hello", createdAt: "2025-01-01T00:00:00Z" }, 200 + indexedAt: "2025-01-01T00:00:00Z", 201 + badgeLabels: [], 202 + }; 203 + } 332 204 333 205 await t.run();
+32 -74
tests/unit/specs/components/plugin-profiles-list.test.js
··· 1 1 import { TestSuite } from "../../testSuite.js"; 2 2 import { assert, assertEquals } from "../../testHelpers.js"; 3 - import { EventEmitter } from "/js/eventEmitter.js"; 3 + import { SignalMap } from "/js/utils.js"; 4 4 import "/js/components/plugin-profiles-list.js"; 5 5 6 6 const t = new TestSuite("PluginProfilesList"); 7 7 8 - function makeDataLayer({ ensureProfiles, getProfile, events } = {}) { 9 - const profiles = new Map(); 8 + function makeDataLayer({ ensureProfiles } = {}) { 9 + const profileSignals = new SignalMap(); 10 10 const declarative = { 11 11 ensureProfiles: 12 12 ensureProfiles ?? 13 - (async (dids) => dids.map((did) => profiles.get(did) ?? null)), 14 - }; 15 - const base = { 16 - getProfile: getProfile ?? ((did) => profiles.get(did) ?? null), 13 + (async (dids) => 14 + dids.map((did) => profileSignals.get(did).get() ?? null)), 17 15 }; 18 16 return { 19 - events: events ?? new EventEmitter(), 20 17 declarative, 21 - base, 22 - __profiles: profiles, 18 + signals: { 19 + $hydratedProfiles: profileSignals, 20 + }, 21 + __setProfile(did, profile) { 22 + profileSignals.set(did, profile); 23 + }, 23 24 }; 24 25 } 25 26 ··· 27 28 return { did, handle, displayName: handle }; 28 29 } 29 30 30 - function flushMicrotasks() { 31 - return new Promise((resolve) => setTimeout(resolve, 0)); 31 + async function flushMicrotasks() { 32 + // Two ticks: the first flushes microtasks (e.g. ensureProfiles), the second 33 + // lets the rAF-scheduled effect render run before assertions. 34 + await new Promise((resolve) => setTimeout(resolve, 0)); 35 + await new Promise((resolve) => setTimeout(resolve, 0)); 32 36 } 33 37 34 38 t.beforeEach(() => { ··· 53 57 t.describe("PluginProfilesList - loaded state", (it) => { 54 58 it("renders profile list items once ensureProfiles resolves", async () => { 55 59 const dataLayer = makeDataLayer(); 56 - dataLayer.__profiles.set("did:test:a", makeProfile("did:test:a", "a.test")); 57 - dataLayer.__profiles.set("did:test:b", makeProfile("did:test:b", "b.test")); 60 + dataLayer.__setProfile("did:test:a", makeProfile("did:test:a", "a.test")); 61 + dataLayer.__setProfile("did:test:b", makeProfile("did:test:b", "b.test")); 58 62 const element = document.createElement("plugin-profiles-list"); 59 63 element.dataLayer = dataLayer; 60 64 element.setAttribute("dids", "did:test:a,did:test:b"); ··· 70 74 71 75 it("filters out missing entries from the selector", async () => { 72 76 const dataLayer = makeDataLayer(); 73 - dataLayer.__profiles.set("did:test:a", makeProfile("did:test:a", "a.test")); 77 + dataLayer.__setProfile("did:test:a", makeProfile("did:test:a", "a.test")); 74 78 const element = document.createElement("plugin-profiles-list"); 75 79 element.dataLayer = dataLayer; 76 80 element.setAttribute("dids", "did:test:a,did:test:missing"); ··· 85 89 86 90 it("does not render the end-of-feed message", async () => { 87 91 const dataLayer = makeDataLayer(); 88 - dataLayer.__profiles.set("did:test:a", makeProfile("did:test:a", "a.test")); 92 + dataLayer.__setProfile("did:test:a", makeProfile("did:test:a", "a.test")); 89 93 const element = document.createElement("plugin-profiles-list"); 90 94 element.dataLayer = dataLayer; 91 95 element.setAttribute("dids", "did:test:a"); ··· 147 151 ensureProfiles: async (dids) => { 148 152 calls.push(dids); 149 153 dids.forEach((did) => 150 - dataLayer.__profiles.set(did, makeProfile(did, did)), 154 + dataLayer.__setProfile(did, makeProfile(did, did)), 151 155 ); 152 - return dids.map((did) => dataLayer.__profiles.get(did)); 156 + return dids.map((did) => 157 + dataLayer.signals.$hydratedProfiles.get(did).get(), 158 + ); 153 159 }, 154 160 }); 155 161 const element = document.createElement("plugin-profiles-list"); ··· 179 185 dataLayer.declarative.ensureProfiles = (dids) => { 180 186 callIndex++; 181 187 if (callIndex === 1) return firstPromise; 182 - dids.forEach((did) => 183 - dataLayer.__profiles.set(did, makeProfile(did, did)), 188 + dids.forEach((did) => dataLayer.__setProfile(did, makeProfile(did, did))); 189 + return Promise.resolve( 190 + dids.map((did) => dataLayer.signals.$hydratedProfiles.get(did).get()), 184 191 ); 185 - return Promise.resolve(dids.map((did) => dataLayer.__profiles.get(did))); 186 192 }; 187 193 const element = document.createElement("plugin-profiles-list"); 188 194 element.dataLayer = dataLayer; ··· 200 206 }); 201 207 }); 202 208 203 - t.describe("PluginProfilesList - live subscriptions", (it) => { 204 - it("subscribes to profile:${did} for each loaded did", async () => { 205 - const events = new EventEmitter(); 206 - const dataLayer = makeDataLayer({ events }); 207 - dataLayer.__profiles.set("did:test:a", makeProfile("did:test:a", "a.test")); 208 - dataLayer.__profiles.set("did:test:b", makeProfile("did:test:b", "b.test")); 209 - dataLayer.__profiles.set("did:test:c", makeProfile("did:test:c", "c.test")); 210 - const element = document.createElement("plugin-profiles-list"); 211 - element.dataLayer = dataLayer; 212 - element.setAttribute("dids", "did:test:a,did:test:b,did:test:c"); 213 - document.body.appendChild(element); 214 - await flushMicrotasks(); 215 - assertEquals(events.__eventListeners.get("profile:did:test:a").length, 1); 216 - assertEquals(events.__eventListeners.get("profile:did:test:b").length, 1); 217 - assertEquals(events.__eventListeners.get("profile:did:test:c").length, 1); 218 - }); 219 - 220 - it("syncs subscriptions when the did list changes", async () => { 221 - const events = new EventEmitter(); 222 - const dataLayer = makeDataLayer({ events }); 209 + t.describe("PluginProfilesList - live updates", (it) => { 210 + it("re-renders when a profile signal updates", async () => { 211 + const dataLayer = makeDataLayer(); 212 + dataLayer.__setProfile("did:test:a", makeProfile("did:test:a", "a.test")); 223 213 const element = document.createElement("plugin-profiles-list"); 224 214 element.dataLayer = dataLayer; 225 215 element.setAttribute("dids", "did:test:a"); 226 216 document.body.appendChild(element); 227 217 await flushMicrotasks(); 228 - element.setAttribute("dids", "did:test:b,did:test:c"); 218 + dataLayer.__setProfile("did:test:a", makeProfile("did:test:a", "updated")); 229 219 await flushMicrotasks(); 230 - assertEquals(events.__eventListeners.get("profile:did:test:a"), undefined); 231 - assertEquals(events.__eventListeners.get("profile:did:test:b").length, 1); 232 - assertEquals(events.__eventListeners.get("profile:did:test:c").length, 1); 233 - }); 234 - 235 - it("removes all live listeners on disconnect", async () => { 236 - const events = new EventEmitter(); 237 - const dataLayer = makeDataLayer({ events }); 238 - const element = document.createElement("plugin-profiles-list"); 239 - element.dataLayer = dataLayer; 240 - element.setAttribute("dids", "did:test:a,did:test:b"); 241 - document.body.appendChild(element); 242 - await flushMicrotasks(); 243 - element.remove(); 244 - assertEquals(events.__eventListeners.get("profile:did:test:a"), undefined); 245 - assertEquals(events.__eventListeners.get("profile:did:test:b"), undefined); 246 - }); 247 - 248 - it("re-renders when profile:${did} fires", async () => { 249 - const events = new EventEmitter(); 250 - const dataLayer = makeDataLayer({ events }); 251 - dataLayer.__profiles.set("did:test:a", makeProfile("did:test:a", "a.test")); 252 - const element = document.createElement("plugin-profiles-list"); 253 - element.dataLayer = dataLayer; 254 - element.setAttribute("dids", "did:test:a"); 255 - document.body.appendChild(element); 256 - await flushMicrotasks(); 257 - dataLayer.__profiles.set( 258 - "did:test:a", 259 - makeProfile("did:test:a", "updated"), 260 - ); 261 - events.emit("profile:did:test:a"); 262 220 const items = element.querySelectorAll( 263 221 "[data-testid='profile-list-item-display-name']", 264 222 );
+24 -28
tests/unit/specs/components/plugin-slot.test.js
··· 1 1 import { TestSuite } from "../../testSuite.js"; 2 2 import { assert, assertEquals } from "../../testHelpers.js"; 3 + import { SignalMap } from "/js/utils.js"; 3 4 import "/js/components/plugin-slot.js"; 4 5 5 6 const t = new TestSuite("PluginSlot"); 6 7 7 - // _reconcile awaits plugin invokes via Promise.all; flush twice so the 8 - // awaited continuation runs before assertions. 8 + // _reconcile awaits plugin invokes via Promise.all, and signal-driven 9 + // re-runs are scheduled via requestAnimationFrame (polyfilled to setTimeout 10 + // in the test env). Flush a few times so the awaited continuations run 11 + // before assertions. 9 12 async function flushMicrotasks() { 10 - await new Promise((resolve) => setTimeout(resolve, 0)); 11 - await new Promise((resolve) => setTimeout(resolve, 0)); 13 + for (let i = 0; i < 4; i++) { 14 + await new Promise((resolve) => setTimeout(resolve, 0)); 15 + } 12 16 } 13 17 14 18 // Minimal stub renderer that just builds a <div> reflecting node.text so ··· 31 35 } 32 36 33 37 function makePluginService({ entries = {}, onCreateRoot } = {}) { 34 - const listeners = { slotRegistered: [], slotUnregistered: [] }; 38 + const $slots = new SignalMap(); 39 + for (const [name, list] of Object.entries(entries)) { 40 + $slots.set(name, [...list]); 41 + } 35 42 return { 36 - _entries: entries, 37 - on(event, fn) { 38 - listeners[event].push(fn); 39 - }, 40 - off(event, fn) { 41 - const list = listeners[event]; 42 - const index = list.indexOf(fn); 43 - if (index !== -1) list.splice(index, 1); 44 - }, 45 - emit(event, data) { 46 - [...listeners[event]].forEach((fn) => fn(data)); 43 + $slots, 44 + setSlotEntries(name, list) { 45 + $slots.set(name, list.length === 0 ? null : [...list]); 47 46 }, 48 47 getSlotEntries(name) { 49 - return [...(this._entries[name] ?? [])]; 48 + return [...($slots.get(name).get() ?? [])]; 50 49 }, 51 50 getRenderer(pluginId) { 52 51 return makeRenderer(pluginId, { onCreateRoot }); ··· 196 195 await flushMicrotasks(); 197 196 assertEquals(slot.children.length, 0); 198 197 199 - pluginService._entries.x = [ 198 + pluginService.setSlotEntries("x", [ 200 199 { pluginId: "alpha", invoke: async () => ({ tag: "div", text: "A" }) }, 201 - ]; 202 - pluginService.emit("slotRegistered", { name: "x" }); 200 + ]); 203 201 await flushMicrotasks(); 204 202 assertEquals(slot.children.length, 1); 205 203 assertEquals(slot.children[0].dataset.plugin, "alpha"); ··· 212 210 await flushMicrotasks(); 213 211 214 212 let invoked = false; 215 - pluginService._entries.y = [ 213 + pluginService.setSlotEntries("y", [ 216 214 { 217 215 pluginId: "other", 218 216 invoke: async () => { ··· 220 218 return null; 221 219 }, 222 220 }, 223 - ]; 224 - pluginService.emit("slotRegistered", { name: "y" }); 221 + ]); 225 222 await flushMicrotasks(); 226 223 assertEquals(invoked, false); 227 224 }); ··· 295 292 }); 296 293 297 294 t.describe("PluginSlot - cleanup", (it) => { 298 - it("removes its event listeners on disconnect", async () => { 295 + it("unsubscribes from the slot signal on disconnect", async () => { 299 296 const pluginService = makePluginService({ entries: { x: [] } }); 300 297 const slot = makeSlot({ pluginService, name: "x" }); 301 298 document.body.appendChild(slot); 302 299 await flushMicrotasks(); 303 300 slot.remove(); 304 301 305 - // After removal, emitting should not throw or attempt to mutate the slot. 302 + // After removal, signal updates should not trigger reconcile. 306 303 let invoked = false; 307 - pluginService._entries.x = [ 304 + pluginService.setSlotEntries("x", [ 308 305 { 309 306 pluginId: "alpha", 310 307 invoke: async () => { ··· 312 309 return null; 313 310 }, 314 311 }, 315 - ]; 316 - pluginService.emit("slotRegistered", { name: "x" }); 312 + ]); 317 313 await flushMicrotasks(); 318 314 assertEquals(invoked, false); 319 315 });
-44
tests/unit/specs/dataLayer/base.test.js
··· 1 - import { TestSuite } from "../../testSuite.js"; 2 - import { assert, assertEquals } from "../../testHelpers.js"; 3 - import { DataStore } from "/js/dataLayer/dataStore.js"; 4 - import { PatchStore } from "/js/dataLayer/patchStore.js"; 5 - import * as base from "/js/dataLayer/base.js"; 6 - 7 - const t = new TestSuite("base"); 8 - 9 - t.describe("getProfile", (it) => { 10 - const profileDID = "did:test:profile"; 11 - const testProfile = { 12 - did: profileDID, 13 - handle: "test.profile", 14 - displayName: "Test Profile", 15 - viewer: { following: null }, 16 - }; 17 - 18 - it("returns null when profile does not exist", () => { 19 - const dataStore = new DataStore(); 20 - const patchStore = new PatchStore(); 21 - assertEquals(base.getProfile(dataStore, patchStore, profileDID), null); 22 - }); 23 - 24 - it("returns profile with patches applied", () => { 25 - const dataStore = new DataStore(); 26 - const patchStore = new PatchStore(); 27 - dataStore.setProfile(profileDID, testProfile); 28 - patchStore.addProfilePatch(profileDID, { type: "followProfile" }); 29 - const result = base.getProfile(dataStore, patchStore, profileDID); 30 - assertEquals(result.viewer.following, "fake following"); 31 - assertEquals(result.did, profileDID); 32 - }); 33 - 34 - it("returns a copy of the profile when no patches exist", () => { 35 - const dataStore = new DataStore(); 36 - const patchStore = new PatchStore(); 37 - dataStore.setProfile(profileDID, testProfile); 38 - const result = base.getProfile(dataStore, patchStore, profileDID); 39 - assertEquals(result, testProfile); 40 - assert(result !== testProfile); 41 - }); 42 - }); 43 - 44 - await t.run();
+16 -16
tests/unit/specs/dataLayer/dataLayer.test.js
··· 30 30 assert(dataLayer.preferencesProvider !== undefined); 31 31 assert(dataLayer.requests !== undefined); 32 32 assert(dataLayer.mutations !== undefined); 33 - assert(dataLayer.selectors !== undefined); 33 + assert(dataLayer.signals !== undefined); 34 34 assert(dataLayer.declarative !== undefined); 35 35 }); 36 36 ··· 93 93 const dataLayer = createDataLayer(mockApi); 94 94 const feedURI = "at://feed/uri"; 95 95 96 - dataLayer.dataStore.setFeed(feedURI, { feed: [], cursor: null }); 96 + dataLayer.dataStore.$feeds.set(feedURI, { feed: [], cursor: null }); 97 97 98 98 const result = dataLayer.hasCachedFeed(feedURI); 99 99 ··· 117 117 const profileDid = "did:test:user"; 118 118 const feedType = "posts"; 119 119 120 - dataLayer.dataStore.setAuthorFeed(`${profileDid}-${feedType}`, { 120 + dataLayer.dataStore.$authorFeeds.set(`${profileDid}-${feedType}`, { 121 121 feed: [], 122 122 cursor: null, 123 123 }); ··· 134 134 const feedType = "replies"; 135 135 136 136 // Cache with the expected URI format 137 - dataLayer.dataStore.setAuthorFeed("did:test:user-replies", { 137 + dataLayer.dataStore.$authorFeeds.set("did:test:user-replies", { 138 138 feed: [], 139 139 cursor: null, 140 140 }); ··· 146 146 }); 147 147 148 148 t.describe("component integration", (it) => { 149 - it("should pass dataStore to selectors", async () => { 149 + it("should pass dataStore to signals", async () => { 150 150 const mockApi = createMockApi({ isAuthenticated: false }); 151 151 const dataLayer = createDataLayer(mockApi); 152 152 const postURI = "at://post/uri"; 153 153 const post = { uri: postURI, text: "test", likeCount: 5 }; 154 154 155 - // Initialize preferences first (required by selectors) 155 + // Initialize preferences first (required by signals) 156 156 await dataLayer.initializePreferences(); 157 157 158 158 // Set data through dataStore 159 - dataLayer.dataStore.setPost(postURI, post); 159 + dataLayer.dataStore.$posts.set(postURI, post); 160 160 161 - // Verify selectors can access it 162 - const result = dataLayer.selectors.getPost(postURI); 161 + // Verify signals can access it 162 + const result = dataLayer.signals.$hydratedPosts.get(postURI).get(); 163 163 assertEquals(result.uri, postURI); 164 164 }); 165 165 166 - it("should pass patchStore to selectors", async () => { 166 + it("should pass patchStore to signals", async () => { 167 167 const mockApi = createMockApi({ isAuthenticated: false }); 168 168 const dataLayer = createDataLayer(mockApi); 169 169 const postURI = "at://post/uri"; 170 170 const post = { uri: postURI, likeCount: 5, viewer: { like: null } }; 171 171 172 - // Initialize preferences first (required by selectors) 172 + // Initialize preferences first (required by signals) 173 173 await dataLayer.initializePreferences(); 174 174 175 - dataLayer.dataStore.setPost(postURI, post); 175 + dataLayer.dataStore.$posts.set(postURI, post); 176 176 dataLayer.patchStore.addPostPatch(postURI, { type: "addLike" }); 177 177 178 - // Verify selectors apply patches 179 - const result = dataLayer.selectors.getPost(postURI); 178 + // Verify signals apply patches 179 + const result = dataLayer.signals.$hydratedPosts.get(postURI).get(); 180 180 assertEquals(result.likeCount, 6); 181 181 }); 182 182 183 - it("should pass selectors and requests to declarative", async () => { 183 + it("should pass signals and requests to declarative", async () => { 184 184 const mockApi = createMockApi({ 185 185 isAuthenticated: false, 186 186 profiles: { ··· 192 192 // Initialize preferences first 193 193 await dataLayer.initializePreferences(); 194 194 195 - // Verify declarative can access selectors 195 + // Verify declarative can access signals 196 196 const profile = await dataLayer.declarative.ensureProfile("did:test:user"); 197 197 assert(profile !== null); 198 198 });
+12 -783
tests/unit/specs/dataLayer/dataStore.test.js
··· 1 1 import { TestSuite } from "../../testSuite.js"; 2 - import { assert, assertEquals } from "../../testHelpers.js"; 2 + import { assertEquals } from "../../testHelpers.js"; 3 3 import { DataStore } from "/js/dataLayer/dataStore.js"; 4 - import { EventEmitter } from "/js/eventEmitter.js"; 5 4 6 5 const t = new TestSuite("DataStore"); 7 6 8 - t.describe("Feed Management", (it) => { 9 - const feedURI = "at://did:test/app.bsky.feed.generator/test"; 10 - const testFeed = { 11 - feed: [{ post: { uri: "post1" } }, { post: { uri: "post2" } }], 12 - cursor: "cursor123", 13 - }; 14 - 15 - it("should set and get a feed", () => { 7 + t.describe("setPosts", (it) => { 8 + it("should insert multiple posts", () => { 16 9 const dataStore = new DataStore(); 17 - dataStore.setFeed(feedURI, testFeed); 18 - assertEquals(dataStore.getFeed(feedURI), testFeed); 19 - }); 20 - 21 - it("should check if feed exists", () => { 22 - const dataStore = new DataStore(); 23 - assertEquals(dataStore.hasFeed(feedURI), false); 24 - dataStore.setFeed(feedURI, testFeed); 25 - assertEquals(dataStore.hasFeed(feedURI), true); 26 - }); 27 - 28 - // Skipping async event test - requires callback support 29 - }); 30 - 31 - t.describe("Post Management", (it) => { 32 - const postURI = "at://did:test/app.bsky.feed.post/test"; 33 - const testPost = { 34 - uri: postURI, 35 - author: { handle: "test.user", did: "did:test" }, 36 - record: { text: "Test post" }, 37 - }; 38 - 39 - it("should set and get a post", () => { 40 - const dataStore = new DataStore(); 41 - dataStore.setPost(postURI, testPost); 42 - assertEquals(dataStore.getPost(postURI), testPost); 43 - }); 44 - 45 - it("should check if post exists", () => { 46 - const dataStore = new DataStore(); 47 - assertEquals(dataStore.hasPost(postURI), false); 48 - dataStore.setPost(postURI, testPost); 49 - assertEquals(dataStore.hasPost(postURI), true); 50 - }); 51 - 52 - it("should emit setPost event when setting post", () => { 53 - const dataStore = new DataStore(); 54 - let setPostEmitted = false; 55 - 56 - dataStore.on("setPost", (post) => { 57 - setPostEmitted = true; 58 - assertEquals(post, testPost); 59 - }); 60 - 61 - dataStore.setPost(postURI, testPost); 62 - assertEquals(setPostEmitted, true); 63 - }); 64 - 65 - it("should emit post:${uri} on the shared event bus when set", () => { 66 - const events = new EventEmitter(); 67 - const dataStore = new DataStore(events); 68 - let fired = false; 69 - events.on(`post:${postURI}`, () => { 70 - fired = true; 71 - }); 72 - dataStore.setPost(postURI, testPost); 73 - assertEquals(fired, true); 74 - }); 75 - 76 - it("should set multiple posts", () => { 77 - const dataStore = new DataStore(); 78 - const posts = [ 79 - { uri: "post1", content: "First post" }, 80 - { uri: "post2", content: "Second post" }, 81 - ]; 82 - 83 - dataStore.setPosts(posts); 84 - 85 - assertEquals(dataStore.hasPost("post1"), true); 86 - assertEquals(dataStore.hasPost("post2"), true); 87 - }); 88 - 89 - it("should clear a post", () => { 90 - const dataStore = new DataStore(); 91 - dataStore.setPost(postURI, testPost); 92 - assertEquals(dataStore.hasPost(postURI), true); 93 - 94 - dataStore.clearPost(postURI); 95 - assertEquals(dataStore.hasPost(postURI), false); 96 - assertEquals(dataStore.getPost(postURI), undefined); 97 - }); 98 - 99 - // Skipping async event test - requires callback support 100 - }); 101 - 102 - t.describe("Quoted Post Caching", (it) => { 103 - it("should cache quoted posts when setting posts with record embeds", () => { 104 - const dataStore = new DataStore(); 105 - const quotedPostUri = "at://did:plc:456/app.bsky.feed.post/quoted"; 106 - const post = { 107 - uri: "at://did:plc:123/app.bsky.feed.post/main", 108 - embed: { 109 - $type: "app.bsky.embed.record#view", 110 - record: { 111 - $type: "app.bsky.embed.record#viewRecord", 112 - uri: quotedPostUri, 113 - cid: "cid-quoted", 114 - author: { did: "did:plc:456", handle: "quoted.user" }, 115 - value: { text: "I am quoted" }, 116 - embeds: [], 117 - labels: [], 118 - likeCount: 10, 119 - replyCount: 1, 120 - repostCount: 2, 121 - quoteCount: 0, 122 - indexedAt: "2024-01-01T00:00:00Z", 123 - }, 124 - }, 125 - }; 126 - 127 - dataStore.setPosts([post]); 128 - 129 - assertEquals(dataStore.hasPost(quotedPostUri), true); 130 - const cached = dataStore.getPost(quotedPostUri); 131 - assertEquals(cached.uri, quotedPostUri); 132 - assertEquals(cached.record, post.embed.record.value); 133 - assertEquals(cached.author.handle, "quoted.user"); 134 - assertEquals(cached.likeCount, 10); 135 - }); 136 - 137 - it("should cache quoted posts from recordWithMedia embeds", () => { 138 - const dataStore = new DataStore(); 139 - const quotedPostUri = "at://did:plc:456/app.bsky.feed.post/quoted"; 140 - const post = { 141 - uri: "at://did:plc:123/app.bsky.feed.post/main", 142 - embed: { 143 - $type: "app.bsky.embed.recordWithMedia#view", 144 - media: { $type: "app.bsky.embed.images#view", images: [] }, 145 - record: { 146 - record: { 147 - $type: "app.bsky.embed.record#viewRecord", 148 - uri: quotedPostUri, 149 - cid: "cid-quoted", 150 - author: { did: "did:plc:456" }, 151 - value: { text: "Quoted with media" }, 152 - indexedAt: "2024-01-01T00:00:00Z", 153 - }, 154 - }, 155 - }, 156 - }; 157 - 158 - dataStore.setPosts([post]); 159 - 160 - assertEquals(dataStore.hasPost(quotedPostUri), true); 161 - assertEquals( 162 - dataStore.getPost(quotedPostUri).record.text, 163 - "Quoted with media", 164 - ); 165 - }); 166 - 167 - it("should not overwrite an existing post with quoted post data", () => { 168 - const dataStore = new DataStore(); 169 - const quotedPostUri = "at://did:plc:456/app.bsky.feed.post/quoted"; 170 - const existingPost = { 171 - uri: quotedPostUri, 172 - author: { did: "did:plc:456" }, 173 - record: { text: "I am quoted" }, 174 - viewer: { like: "at://did:plc:123/app.bsky.feed.like/abc" }, 175 - }; 176 - dataStore.setPost(quotedPostUri, existingPost); 177 - 178 - const postWithQuote = { 179 - uri: "at://did:plc:123/app.bsky.feed.post/main", 180 - embed: { 181 - $type: "app.bsky.embed.record#view", 182 - record: { 183 - $type: "app.bsky.embed.record#viewRecord", 184 - uri: quotedPostUri, 185 - cid: "cid-quoted", 186 - author: { did: "did:plc:456" }, 187 - value: { text: "I am quoted" }, 188 - indexedAt: "2024-01-01T00:00:00Z", 189 - }, 190 - }, 191 - }; 192 - 193 - dataStore.setPosts([postWithQuote]); 194 - 195 - const cached = dataStore.getPost(quotedPostUri); 196 - assertEquals(cached.viewer.like, "at://did:plc:123/app.bsky.feed.like/abc"); 197 - }); 198 - 199 - it("should not cache blocked or non-viewRecord quoted posts", () => { 200 - const dataStore = new DataStore(); 201 - const post = { 202 - uri: "at://did:plc:123/app.bsky.feed.post/main", 203 - embed: { 204 - $type: "app.bsky.embed.record#view", 205 - record: { 206 - $type: "app.bsky.embed.record#viewBlocked", 207 - uri: "at://did:plc:456/app.bsky.feed.post/blocked", 208 - }, 209 - }, 210 - }; 211 - 212 - dataStore.setPosts([post]); 213 - 214 - assertEquals( 215 - dataStore.hasPost("at://did:plc:456/app.bsky.feed.post/blocked"), 216 - false, 217 - ); 218 - }); 219 - 220 - it("should not cache when post has no embed", () => { 221 - const dataStore = new DataStore(); 222 - const post = { 223 - uri: "at://did:plc:123/app.bsky.feed.post/main", 224 - record: { text: "No embed" }, 225 - }; 226 - 227 - dataStore.setPosts([post]); 228 - 229 - assertEquals(dataStore.hasPost(post.uri), true); 230 - assertEquals(dataStore.getAllPosts().length, 1); 231 - }); 232 - }); 233 - 234 - t.describe("PostThread Management", (it) => { 235 - const postURI = "at://did:test/app.bsky.feed.post/thread"; 236 - const testPostThread = { 237 - post: { uri: postURI }, 238 - replies: [], 239 - parent: null, 240 - }; 241 - 242 - it("should set and get a post thread", () => { 243 - const dataStore = new DataStore(); 244 - dataStore.setPostThread(postURI, testPostThread); 245 - assertEquals(dataStore.getPostThread(postURI), testPostThread); 246 - }); 247 - 248 - it("should check if post thread exists", () => { 249 - const dataStore = new DataStore(); 250 - assertEquals(dataStore.hasPostThread(postURI), false); 251 - dataStore.setPostThread(postURI, testPostThread); 252 - assertEquals(dataStore.hasPostThread(postURI), true); 253 - }); 254 - 255 - // Skipping async event test - requires callback support 256 - }); 257 - 258 - t.describe("PostThreadOther Management", (it) => { 259 - const postURI = "at://did:test/app.bsky.feed.post/thread"; 260 - const testPostThreadOther = [ 261 - { uri: "at://did:plc:reply1/app.bsky.feed.post/reply1" }, 262 - { uri: "at://did:plc:reply2/app.bsky.feed.post/reply2" }, 263 - ]; 264 - 265 - it("should set and get a post thread other", () => { 266 - const dataStore = new DataStore(); 267 - dataStore.setPostThreadOther(postURI, testPostThreadOther); 268 - assertEquals(dataStore.getPostThreadOther(postURI), testPostThreadOther); 269 - }); 270 - 271 - it("should check if post thread other exists", () => { 272 - const dataStore = new DataStore(); 273 - assertEquals(dataStore.hasPostThreadOther(postURI), false); 274 - dataStore.setPostThreadOther(postURI, testPostThreadOther); 275 - assertEquals(dataStore.hasPostThreadOther(postURI), true); 276 - }); 277 - 278 - it("should clear post thread other", () => { 279 - const dataStore = new DataStore(); 280 - dataStore.setPostThreadOther(postURI, testPostThreadOther); 281 - assertEquals(dataStore.hasPostThreadOther(postURI), true); 282 - 283 - dataStore.clearPostThreadOther(postURI); 284 - assertEquals(dataStore.hasPostThreadOther(postURI), false); 285 - assertEquals(dataStore.getPostThreadOther(postURI), undefined); 286 - }); 287 - 288 - it("should handle empty post thread other", () => { 289 - const dataStore = new DataStore(); 290 - dataStore.setPostThreadOther(postURI, []); 291 - assertEquals(dataStore.hasPostThreadOther(postURI), true); 292 - assertEquals(dataStore.getPostThreadOther(postURI), []); 293 - }); 294 - }); 295 - 296 - t.describe("Profile Management", (it) => { 297 - const profileDid = "did:test:profile"; 298 - const testProfile = { 299 - did: profileDid, 300 - handle: "test.profile", 301 - displayName: "Test Profile", 302 - }; 303 - 304 - it("should set and get a profile", () => { 305 - const dataStore = new DataStore(); 306 - dataStore.setProfile(profileDid, testProfile); 307 - assertEquals(dataStore.getProfile(profileDid), testProfile); 308 - }); 309 - 310 - it("should check if profile exists", () => { 311 - const dataStore = new DataStore(); 312 - assertEquals(dataStore.hasProfile(profileDid), false); 313 - dataStore.setProfile(profileDid, testProfile); 314 - assertEquals(dataStore.hasProfile(profileDid), true); 315 - }); 316 - }); 317 - 318 - t.describe("Muted Profiles Management", (it) => { 319 - const mutedList = { 320 - mutes: [{ did: "did:plc:a", handle: "a.bsky.social" }], 321 - cursor: "next", 322 - }; 323 - 324 - it("should return null before any muted profiles are set", () => { 325 - const dataStore = new DataStore(); 326 - assertEquals(dataStore.hasMutedProfiles(), false); 327 - assertEquals(dataStore.getMutedProfiles(), null); 328 - }); 329 - 330 - it("should set and get muted profiles", () => { 331 - const dataStore = new DataStore(); 332 - dataStore.setMutedProfiles(mutedList); 333 - assertEquals(dataStore.hasMutedProfiles(), true); 334 - assertEquals(dataStore.getMutedProfiles(), mutedList); 335 - }); 336 - 337 - it("should clear muted profiles", () => { 338 - const dataStore = new DataStore(); 339 - dataStore.setMutedProfiles(mutedList); 340 - dataStore.clearMutedProfiles(); 341 - assertEquals(dataStore.hasMutedProfiles(), false); 342 - assertEquals(dataStore.getMutedProfiles(), null); 343 - }); 344 - }); 345 - 346 - t.describe("Event Handling", (it) => { 347 - it("should handle multiple event listeners", () => { 348 - const dataStore = new DataStore(); 349 - let listener1Called = false; 350 - let listener2Called = false; 351 - 352 - dataStore.on("setPost", () => { 353 - listener1Called = true; 354 - }); 355 - dataStore.on("setPost", () => { 356 - listener2Called = true; 357 - }); 358 - 359 - dataStore.setPost("test", { uri: "test" }); 360 - 361 - assertEquals(listener1Called, true); 362 - assertEquals(listener2Called, true); 363 - }); 364 - }); 365 - 366 - t.describe("Labeler Info Management", (it) => { 367 - const labelerDid = "did:plc:testlabeler"; 368 - const testLabelerInfo = { 369 - uri: "at://did:plc:testlabeler/app.bsky.labeler.service/self", 370 - creator: { did: labelerDid, handle: "labeler.test" }, 371 - policies: { 372 - labelValueDefinitions: [ 373 - { identifier: "nsfw", locales: [{ lang: "en", name: "NSFW" }] }, 374 - ], 375 - }, 376 - }; 377 - 378 - it("should set and get labeler info", () => { 379 - const dataStore = new DataStore(); 380 - dataStore.setLabelerInfo(labelerDid, testLabelerInfo); 381 - assertEquals(dataStore.getLabelerInfo(labelerDid), testLabelerInfo); 382 - }); 383 - 384 - it("should check if labeler info exists", () => { 385 - const dataStore = new DataStore(); 386 - assertEquals(dataStore.hasLabelerInfo(labelerDid), false); 387 - dataStore.setLabelerInfo(labelerDid, testLabelerInfo); 388 - assertEquals(dataStore.hasLabelerInfo(labelerDid), true); 389 - }); 390 - 391 - it("should return undefined for non-existent labeler info", () => { 392 - const dataStore = new DataStore(); 393 - assertEquals(dataStore.getLabelerInfo(labelerDid), undefined); 394 - }); 395 - 396 - it("should clear labeler info", () => { 397 - const dataStore = new DataStore(); 398 - dataStore.setLabelerInfo(labelerDid, testLabelerInfo); 399 - assertEquals(dataStore.hasLabelerInfo(labelerDid), true); 400 - 401 - dataStore.clearLabelerInfo(labelerDid); 402 - assertEquals(dataStore.hasLabelerInfo(labelerDid), false); 403 - assertEquals(dataStore.getLabelerInfo(labelerDid), undefined); 404 - }); 405 - 406 - it("should handle multiple labelers independently", () => { 407 - const dataStore = new DataStore(); 408 - const labeler1Did = "did:plc:labeler1"; 409 - const labeler2Did = "did:plc:labeler2"; 410 - const labeler1Info = { ...testLabelerInfo, creator: { did: labeler1Did } }; 411 - const labeler2Info = { ...testLabelerInfo, creator: { did: labeler2Did } }; 412 - 413 - dataStore.setLabelerInfo(labeler1Did, labeler1Info); 414 - dataStore.setLabelerInfo(labeler2Did, labeler2Info); 415 - 416 - assertEquals(dataStore.getLabelerInfo(labeler1Did), labeler1Info); 417 - assertEquals(dataStore.getLabelerInfo(labeler2Did), labeler2Info); 418 - 419 - dataStore.clearLabelerInfo(labeler1Did); 420 - assertEquals(dataStore.hasLabelerInfo(labeler1Did), false); 421 - assertEquals(dataStore.hasLabelerInfo(labeler2Did), true); 422 - }); 423 - }); 424 - 425 - t.describe("Blocked Profiles Management", (it) => { 426 - const blockedList = { 427 - blocks: [{ did: "did:plc:b", handle: "b.bsky.social" }], 428 - cursor: "next", 429 - }; 430 - 431 - it("should return null before any blocked profiles are set", () => { 432 - const dataStore = new DataStore(); 433 - assertEquals(dataStore.hasBlockedProfiles(), false); 434 - assertEquals(dataStore.getBlockedProfiles(), null); 435 - }); 436 - 437 - it("should set and get blocked profiles", () => { 438 - const dataStore = new DataStore(); 439 - dataStore.setBlockedProfiles(blockedList); 440 - assertEquals(dataStore.hasBlockedProfiles(), true); 441 - assertEquals(dataStore.getBlockedProfiles(), blockedList); 442 - }); 443 - 444 - it("should clear blocked profiles", () => { 445 - const dataStore = new DataStore(); 446 - dataStore.setBlockedProfiles(blockedList); 447 - dataStore.clearBlockedProfiles(); 448 - assertEquals(dataStore.hasBlockedProfiles(), false); 449 - assertEquals(dataStore.getBlockedProfiles(), null); 450 - }); 451 - }); 452 - 453 - t.describe("Notifications Management", (it) => { 454 - const notifications = [{ uri: "at://did:test/notif/1", reason: "like" }]; 455 - 456 - it("should return null before notifications are set", () => { 457 - const dataStore = new DataStore(); 458 - assertEquals(dataStore.hasNotifications(), false); 459 - assertEquals(dataStore.getNotifications(), null); 460 - }); 461 - 462 - it("should set and get notifications", () => { 463 - const dataStore = new DataStore(); 464 - dataStore.setNotifications(notifications); 465 - assertEquals(dataStore.hasNotifications(), true); 466 - assertEquals(dataStore.getNotifications(), notifications); 467 - }); 468 - 469 - it("should clear notifications without clearing the cursor", () => { 470 - const dataStore = new DataStore(); 471 - dataStore.setNotifications(notifications); 472 - dataStore.setNotificationCursor("cursor-1"); 473 - dataStore.clearNotifications(); 474 - assertEquals(dataStore.hasNotifications(), false); 475 - assertEquals(dataStore.getNotifications(), null); 476 - assertEquals(dataStore.hasNotificationCursor(), true); 477 - assertEquals(dataStore.getNotificationCursor(), "cursor-1"); 478 - }); 479 - 480 - it("should set and clear the notification cursor independently", () => { 481 - const dataStore = new DataStore(); 482 - assertEquals(dataStore.hasNotificationCursor(), false); 483 - dataStore.setNotificationCursor("cursor-2"); 484 - assertEquals(dataStore.hasNotificationCursor(), true); 485 - assertEquals(dataStore.getNotificationCursor(), "cursor-2"); 486 - dataStore.clearNotificationCursor(); 487 - assertEquals(dataStore.hasNotificationCursor(), false); 488 - assertEquals(dataStore.getNotificationCursor(), null); 489 - }); 490 - 491 - it("should emit setNotifications event", () => { 492 - const dataStore = new DataStore(); 493 - let emitted = null; 494 - dataStore.on("setNotifications", (value) => { 495 - emitted = value; 496 - }); 497 - dataStore.setNotifications(notifications); 498 - assertEquals(emitted, notifications); 499 - }); 500 - }); 501 - 502 - t.describe("Mention Notifications Management", (it) => { 503 - const mentions = [{ uri: "at://did:test/notif/mention", reason: "mention" }]; 504 - 505 - it("should default to undefined before being set", () => { 506 - const dataStore = new DataStore(); 507 - assertEquals(dataStore.getMentionNotifications(), null); 508 - assertEquals(dataStore.getMentionNotificationCursor(), null); 509 - }); 510 - 511 - it("should set and get mention notifications", () => { 512 - const dataStore = new DataStore(); 513 - dataStore.setMentionNotifications(mentions); 514 - assertEquals(dataStore.getMentionNotifications(), mentions); 515 - }); 516 - 517 - it("should clear mention notifications without clearing cursor", () => { 518 - const dataStore = new DataStore(); 519 - dataStore.setMentionNotifications(mentions); 520 - dataStore.setMentionNotificationCursor("m-cursor"); 521 - dataStore.clearMentionNotifications(); 522 - assertEquals(dataStore.getMentionNotifications(), null); 523 - assertEquals(dataStore.getMentionNotificationCursor(), "m-cursor"); 524 - }); 525 - 526 - it("should set the mention notification cursor", () => { 527 - const dataStore = new DataStore(); 528 - dataStore.setMentionNotificationCursor("m-cursor-2"); 529 - assertEquals(dataStore.getMentionNotificationCursor(), "m-cursor-2"); 530 - }); 531 - }); 532 - 533 - t.describe("ConvoList Management", (it) => { 534 - const convos = [{ id: "convo-1" }, { id: "convo-2" }]; 535 - 536 - it("should return null before convo list is set", () => { 537 - const dataStore = new DataStore(); 538 - assertEquals(dataStore.hasConvoList(), false); 539 - assertEquals(dataStore.getConvoList(), null); 540 - }); 541 - 542 - it("should set and get the convo list", () => { 543 - const dataStore = new DataStore(); 544 - dataStore.setConvoList(convos); 545 - assertEquals(dataStore.hasConvoList(), true); 546 - assertEquals(dataStore.getConvoList(), convos); 547 - }); 548 - 549 - it("should clear the convo list without clearing the cursor", () => { 550 - const dataStore = new DataStore(); 551 - dataStore.setConvoList(convos); 552 - dataStore.setConvoListCursor("c-cursor"); 553 - dataStore.clearConvoList(); 554 - assertEquals(dataStore.hasConvoList(), false); 555 - assertEquals(dataStore.getConvoList(), null); 556 - assertEquals(dataStore.hasConvoListCursor(), true); 557 - assertEquals(dataStore.getConvoListCursor(), "c-cursor"); 558 - }); 559 - 560 - it("should set and clear the convo list cursor independently", () => { 561 - const dataStore = new DataStore(); 562 - assertEquals(dataStore.hasConvoListCursor(), false); 563 - dataStore.setConvoListCursor("c-cursor-2"); 564 - assertEquals(dataStore.hasConvoListCursor(), true); 565 - assertEquals(dataStore.getConvoListCursor(), "c-cursor-2"); 566 - dataStore.clearConvoListCursor(); 567 - assertEquals(dataStore.hasConvoListCursor(), false); 568 - assertEquals(dataStore.getConvoListCursor(), null); 569 - }); 570 - }); 571 - 572 - t.describe("Convo Management", (it) => { 573 - const convoId = "convo-123"; 574 - const convo = { id: convoId, members: [{ did: "did:plc:a" }] }; 575 - 576 - it("should set, get, and check existence of a convo", () => { 577 - const dataStore = new DataStore(); 578 - assertEquals(dataStore.hasConvo(convoId), false); 579 - dataStore.setConvo(convoId, convo); 580 - assertEquals(dataStore.hasConvo(convoId), true); 581 - assertEquals(dataStore.getConvo(convoId), convo); 582 - }); 583 - 584 - it("should clear a convo", () => { 585 - const dataStore = new DataStore(); 586 - dataStore.setConvo(convoId, convo); 587 - dataStore.clearConvo(convoId); 588 - assertEquals(dataStore.hasConvo(convoId), false); 589 - assertEquals(dataStore.getConvo(convoId), undefined); 590 - }); 591 - 592 - it("should return all convos", () => { 593 - const dataStore = new DataStore(); 594 - const convoA = { id: "a" }; 595 - const convoB = { id: "b" }; 596 - dataStore.setConvo("a", convoA); 597 - dataStore.setConvo("b", convoB); 598 - assertEquals(dataStore.getAllConvos(), [convoA, convoB]); 599 - }); 600 - }); 601 - 602 - t.describe("ConvoMessages and Message Mapping", (it) => { 603 - const convoId = "convo-xyz"; 604 - const messageA = { id: "msg-1", text: "hello" }; 605 - const messageB = { id: "msg-2", text: "world" }; 606 - const payload = { messages: [messageA, messageB], cursor: "next" }; 607 - 608 - it("should set and get convo messages", () => { 609 - const dataStore = new DataStore(); 610 - assertEquals(dataStore.hasConvoMessages(convoId), false); 611 - assertEquals(dataStore.getConvoMessages(convoId), null); 612 - dataStore.setConvoMessages(convoId, payload); 613 - assertEquals(dataStore.hasConvoMessages(convoId), true); 614 - assertEquals(dataStore.getConvoMessages(convoId), payload); 615 - }); 616 - 617 - it("should clear convo messages", () => { 618 - const dataStore = new DataStore(); 619 - dataStore.setConvoMessages(convoId, payload); 620 - dataStore.clearConvoMessages(convoId); 621 - assertEquals(dataStore.hasConvoMessages(convoId), false); 622 - assertEquals(dataStore.getConvoMessages(convoId), null); 623 - }); 624 - 625 - it("should set, get, and clear individual messages by id", () => { 626 - const dataStore = new DataStore(); 627 - assertEquals(dataStore.hasMessage(messageA.id), false); 628 - dataStore.setMessage(messageA.id, messageA); 629 - assertEquals(dataStore.hasMessage(messageA.id), true); 630 - assertEquals(dataStore.getMessage(messageA.id), messageA); 631 - dataStore.clearMessage(messageA.id); 632 - assertEquals(dataStore.hasMessage(messageA.id), false); 633 - assertEquals(dataStore.getMessage(messageA.id), undefined); 634 - }); 635 - 636 - it("should keep message ids isolated from convo messages buckets", () => { 637 - const dataStore = new DataStore(); 638 - dataStore.setConvoMessages(convoId, payload); 639 - assertEquals(dataStore.hasMessage(messageA.id), false); 640 - dataStore.setMessage(messageA.id, messageA); 641 - assertEquals(dataStore.hasMessage(messageA.id), true); 642 - assertEquals(dataStore.hasConvoMessages(convoId), true); 643 - }); 644 - }); 645 - 646 - t.describe("ShowLess and ShowMore Interactions", (it) => { 647 - const interactionA = { item: "post-a", event: "less" }; 648 - const interactionB = { item: "post-b", event: "less" }; 649 - const interactionC = { item: "post-c", event: "more" }; 650 - 651 - it("should start with empty interaction lists", () => { 652 - const dataStore = new DataStore(); 653 - assertEquals(dataStore.getShowLessInteractions(), []); 654 - assertEquals(dataStore.getShowMoreInteractions(), []); 655 - }); 656 - 657 - it("should append show-less interactions in order", () => { 658 - const dataStore = new DataStore(); 659 - dataStore.addShowLessInteraction(interactionA); 660 - assertEquals(dataStore.getShowLessInteractions(), [interactionA]); 661 - dataStore.addShowLessInteraction(interactionB); 662 - assertEquals(dataStore.getShowLessInteractions(), [ 663 - interactionA, 664 - interactionB, 665 - ]); 666 - }); 667 - 668 - it("should append show-more interactions independently of show-less", () => { 669 - const dataStore = new DataStore(); 670 - dataStore.addShowLessInteraction(interactionA); 671 - dataStore.addShowMoreInteraction(interactionC); 672 - assertEquals(dataStore.getShowLessInteractions(), [interactionA]); 673 - assertEquals(dataStore.getShowMoreInteractions(), [interactionC]); 674 - }); 675 - }); 676 - 677 - t.describe("setPosts bulk insert", (it) => { 678 - it("should insert multiple posts and emit setPost for each", () => { 679 - const dataStore = new DataStore(); 680 - const emittedUris = []; 681 - dataStore.on("setPost", (post) => { 682 - emittedUris.push(post.uri); 683 - }); 684 10 const posts = [ 685 11 { uri: "at://did:test/app.bsky.feed.post/1", record: { text: "one" } }, 686 12 { uri: "at://did:test/app.bsky.feed.post/2", record: { text: "two" } }, 687 13 { uri: "at://did:test/app.bsky.feed.post/3", record: { text: "three" } }, 688 14 ]; 689 15 dataStore.setPosts(posts); 690 - posts.forEach((post) => { 691 - assertEquals(dataStore.hasPost(post.uri), true); 692 - assertEquals(dataStore.getPost(post.uri), post); 693 - }); 694 - assertEquals( 695 - emittedUris, 696 - posts.map((post) => post.uri), 697 - ); 16 + for (const post of posts) { 17 + assertEquals(dataStore.$posts.get(post.uri).get(), post); 18 + } 698 19 }); 699 20 700 - it("should match setPost behavior when given a single post", () => { 21 + it("should match $posts.set behavior when given a single post", () => { 701 22 const dataStoreA = new DataStore(); 702 23 const dataStoreB = new DataStore(); 703 24 const post = { 704 25 uri: "at://did:test/app.bsky.feed.post/solo", 705 26 record: { text: "solo" }, 706 27 }; 707 - dataStoreA.setPost(post.uri, post); 28 + dataStoreA.$posts.set(post.uri, post); 708 29 dataStoreB.setPosts([post]); 709 - assertEquals(dataStoreA.getPost(post.uri), dataStoreB.getPost(post.uri)); 710 - assertEquals(dataStoreA.getAllPosts(), dataStoreB.getAllPosts()); 711 - }); 712 - }); 713 - 714 - t.describe("Event emission for set* methods", (it) => { 715 - it("should emit setNotifications when notifications are set", () => { 716 - const dataStore = new DataStore(); 717 - let emitted = null; 718 - dataStore.on("setNotifications", (value) => { 719 - emitted = value; 720 - }); 721 - const notifications = [{ uri: "at://did:test/notif/1" }]; 722 - dataStore.setNotifications(notifications); 723 - assertEquals(emitted, notifications); 724 - }); 725 - 726 - it("should emit setProfileSearchResults when profile search results are set", () => { 727 - const dataStore = new DataStore(); 728 - let emitted = null; 729 - dataStore.on("setProfileSearchResults", (value) => { 730 - emitted = value; 731 - }); 732 - const results = { actors: [{ did: "did:plc:a" }], cursor: "next" }; 733 - dataStore.setProfileSearchResults(results); 734 - assertEquals(emitted, results); 735 - }); 736 - 737 - it("should emit setFeedGenerator when a feed generator is set", () => { 738 - const dataStore = new DataStore(); 739 - let emitted = null; 740 - dataStore.on("setFeedGenerator", (value) => { 741 - emitted = value; 742 - }); 743 - const feedGenerator = { 744 - uri: "at://did:test/app.bsky.feed.generator/test", 745 - displayName: "Test", 746 - }; 747 - dataStore.setFeedGenerator(feedGenerator.uri, feedGenerator); 748 - assertEquals(emitted, feedGenerator); 749 - }); 750 - }); 751 - 752 - t.describe("Trivial accessor pairs", (it) => { 753 - const accessors = [ 754 - { name: "ProfileFollowers", key: "did:plc:a", value: { followers: [] } }, 755 - { name: "ProfileFollows", key: "did:plc:b", value: { follows: [] } }, 756 - { name: "ProfileChatStatus", key: "did:plc:c", value: { status: "all" } }, 757 - { 758 - name: "PluginFilteredFeedItems", 759 - key: "at://did:test/app.bsky.feed.generator/x", 760 - value: { items: ["a", "b"] }, 761 - }, 762 - { 763 - name: "AuthorFeed", 764 - key: "at://did:test/app.bsky.feed.generator/author", 765 - value: { feed: [], cursor: null }, 766 - }, 767 - { 768 - name: "FeedGenerator", 769 - key: "at://did:test/app.bsky.feed.generator/fg", 770 - value: { uri: "at://did:test/app.bsky.feed.generator/fg" }, 771 - }, 772 - { name: "ActorFeeds", key: "did:plc:d", value: { feeds: [] } }, 773 - { name: "HashtagFeed", key: "#test", value: { posts: [] } }, 774 - ]; 775 - 776 - for (const accessor of accessors) { 777 - it(`should has/get/set/clear ${accessor.name}`, () => { 778 - const dataStore = new DataStore(); 779 - const hasFn = `has${accessor.name}`; 780 - const getFn = `get${accessor.name}`; 781 - const setFn = `set${accessor.name}`; 782 - const clearFn = `clear${accessor.name}`; 783 - assertEquals(dataStore[hasFn](accessor.key), false); 784 - assertEquals(dataStore[getFn](accessor.key), undefined); 785 - dataStore[setFn](accessor.key, accessor.value); 786 - assertEquals(dataStore[hasFn](accessor.key), true); 787 - assertEquals(dataStore[getFn](accessor.key), accessor.value); 788 - dataStore[clearFn](accessor.key); 789 - assertEquals(dataStore[hasFn](accessor.key), false); 790 - assertEquals(dataStore[getFn](accessor.key), undefined); 791 - }); 792 - } 793 - 794 - it("should has/get/set/clear PinnedFeedGenerators (singleton)", () => { 795 - const dataStore = new DataStore(); 796 - const value = [{ uri: "at://did:test/app.bsky.feed.generator/pinned" }]; 797 - assertEquals(dataStore.hasPinnedFeedGenerators(), false); 798 - assertEquals(dataStore.getPinnedFeedGenerators(), null); 799 - dataStore.setPinnedFeedGenerators(value); 800 - assertEquals(dataStore.hasPinnedFeedGenerators(), true); 801 - assertEquals(dataStore.getPinnedFeedGenerators(), value); 802 - dataStore.clearPinnedFeedGenerators(); 803 - assertEquals(dataStore.hasPinnedFeedGenerators(), false); 804 - assertEquals(dataStore.getPinnedFeedGenerators(), null); 30 + assertEquals( 31 + dataStoreA.$posts.get(post.uri).get(), 32 + dataStoreB.$posts.get(post.uri).get(), 33 + ); 805 34 }); 806 35 }); 807 36
+111 -118
tests/unit/specs/dataLayer/declarative.test.js
··· 4 4 5 5 const t = new TestSuite("Declarative"); 6 6 7 - function createMockSelectors(data = {}) { 8 - return { 9 - getCurrentUser: () => data.currentUser ?? null, 10 - getPreferences: () => data.preferences ?? null, 11 - getPostThread: (uri) => data.postThreads?.[uri] ?? null, 12 - getPost: (uri) => data.posts?.[uri] ?? null, 13 - getPosts: (uris) => uris.map((uri) => data.posts?.[uri] ?? null), 14 - getFeedGenerator: (uri) => data.feedGenerators?.[uri] ?? null, 15 - getPinnedFeedGenerators: () => data.pinnedFeedGenerators ?? null, 16 - getConvoList: () => data.convoList ?? null, 17 - getConvo: (id) => data.convos?.[id] ?? null, 18 - getConvoForProfile: (did) => data.convoForProfile?.[did] ?? null, 19 - }; 20 - } 7 + const sig = (getter) => ({ get: getter }); 8 + const mapSig = (getter) => ({ get: (key) => ({ get: () => getter(key) }) }); 21 9 22 - function createMockBase(data = {}) { 10 + function createMockSignals(data = {}) { 23 11 return { 24 - getProfile: (did) => data.profiles?.[did] ?? null, 12 + $currentUser: sig(() => data.currentUser ?? null), 13 + $hydratedProfiles: mapSig((did) => data.profiles?.[did] ?? null), 14 + $hydratedPostThreads: mapSig((uri) => data.postThreads?.[uri] ?? null), 15 + $hydratedPosts: mapSig((uri) => data.posts?.[uri] ?? null), 16 + $feedGenerators: mapSig((uri) => data.feedGenerators?.[uri] ?? null), 17 + $hydratedPinnedFeedGenerators: sig(() => data.pinnedFeedGenerators ?? null), 18 + $convoList: sig(() => data.convoList ?? null), 19 + $convos: mapSig((id) => data.convos?.[id] ?? null), 20 + $convoForProfile: mapSig((did) => data.convoForProfile?.[did] ?? null), 25 21 }; 26 22 } 27 23 ··· 29 25 return { 30 26 loadCurrentUser: async () => loadResults.currentUser, 31 27 loadProfile: async (did) => loadResults.profiles?.[did], 28 + loadProfiles: async () => {}, 32 29 loadPostThread: async (uri) => loadResults.postThreads?.[uri], 33 30 loadPost: async (uri) => loadResults.posts?.[uri], 31 + loadPosts: async () => {}, 34 32 loadFeedGenerator: async (uri) => loadResults.feedGenerators?.[uri], 35 33 loadPinnedFeedGenerators: async () => loadResults.pinnedFeedGenerators, 36 34 loadConvoList: async () => loadResults.convoList, ··· 44 42 const currentUser = { did: "did:test:user", handle: "test.user" }; 45 43 let loadCalled = false; 46 44 47 - const selectors = createMockSelectors({ currentUser }); 45 + const signals = createMockSignals({ currentUser }); 48 46 const requests = { 49 47 loadCurrentUser: async () => { 50 48 loadCalled = true; 51 49 }, 52 50 }; 53 51 54 - const declarative = new Declarative(selectors, requests); 52 + const declarative = new Declarative(signals, requests); 55 53 const result = await declarative.ensureCurrentUser(); 56 54 57 55 assertEquals(result, currentUser); ··· 62 60 const currentUser = { did: "did:test:user", handle: "test.user" }; 63 61 let callCount = 0; 64 62 65 - const selectors = { 66 - getCurrentUser: () => { 63 + const signals = { 64 + $currentUser: sig(() => { 67 65 callCount++; 68 66 return callCount > 1 ? currentUser : null; 69 - }, 67 + }), 70 68 }; 71 69 const requests = { 72 70 loadCurrentUser: async () => {}, 73 71 }; 74 72 75 - const declarative = new Declarative(selectors, requests); 73 + const declarative = new Declarative(signals, requests); 76 74 const result = await declarative.ensureCurrentUser(); 77 75 78 76 assertEquals(result, currentUser); ··· 80 78 }); 81 79 82 80 it("should throw when user not found after loading", async () => { 83 - const selectors = createMockSelectors({}); 81 + const signals = createMockSignals({}); 84 82 const requests = createMockRequests({}); 85 83 86 - const declarative = new Declarative(selectors, requests); 84 + const declarative = new Declarative(signals, requests); 87 85 88 86 let error = null; 89 87 try { ··· 103 101 const profile = { did: profileDid, handle: "test.profile" }; 104 102 let loadCalled = false; 105 103 106 - const selectors = createMockSelectors({}); 107 - const base = createMockBase({ profiles: { [profileDid]: profile } }); 104 + const signals = createMockSignals({ profiles: { [profileDid]: profile } }); 108 105 const requests = { 109 106 loadProfile: async () => { 110 107 loadCalled = true; 111 108 }, 112 109 }; 113 110 114 - const declarative = new Declarative(selectors, requests, base); 111 + const declarative = new Declarative(signals, requests); 115 112 const result = await declarative.ensureProfile(profileDid); 116 113 117 114 assertEquals(result, profile); ··· 123 120 const profile = { did: profileDid, handle: "test.profile" }; 124 121 let callCount = 0; 125 122 126 - const base = { 127 - getProfile: (did) => { 123 + const signals = { 124 + $hydratedProfiles: mapSig(() => { 128 125 callCount++; 129 126 return callCount > 1 ? profile : null; 130 - }, 127 + }), 131 128 }; 132 129 const requests = { 133 - loadProfile: async (did) => {}, 130 + loadProfile: async () => {}, 134 131 }; 135 132 136 - const declarative = new Declarative({}, requests, base); 133 + const declarative = new Declarative(signals, requests); 137 134 const result = await declarative.ensureProfile(profileDid); 138 135 139 136 assertEquals(result, profile); 140 137 }); 141 138 142 139 it("should throw when profile not found after loading", async () => { 143 - const selectors = createMockSelectors({}); 144 - const base = createMockBase({}); 140 + const signals = createMockSignals({}); 145 141 const requests = createMockRequests({}); 146 142 147 - const declarative = new Declarative(selectors, requests, base); 143 + const declarative = new Declarative(signals, requests); 148 144 149 145 let error = null; 150 146 try { ··· 164 160 const profileB = { did: "did:test:b", handle: "b.test" }; 165 161 let loadCalled = false; 166 162 167 - const selectors = createMockSelectors({}); 168 - const base = createMockBase({ 163 + const signals = createMockSignals({ 169 164 profiles: { [profileA.did]: profileA, [profileB.did]: profileB }, 170 165 }); 171 166 const requests = { ··· 174 169 }, 175 170 }; 176 171 177 - const declarative = new Declarative(selectors, requests, base); 172 + const declarative = new Declarative(signals, requests); 178 173 const result = await declarative.ensureProfiles([ 179 174 profileB.did, 180 175 profileA.did, ··· 190 185 const store = { [profileA.did]: profileA }; 191 186 let loadedWith = null; 192 187 193 - const base = { 194 - getProfile: (did) => store[did] ?? null, 188 + const signals = { 189 + $hydratedProfiles: mapSig((did) => store[did] ?? null), 195 190 }; 196 191 const requests = { 197 192 loadProfiles: async (dids) => { ··· 200 195 }, 201 196 }; 202 197 203 - const declarative = new Declarative({}, requests, base); 198 + const declarative = new Declarative(signals, requests); 204 199 const result = await declarative.ensureProfiles([ 205 200 profileA.did, 206 201 profileB.did, ··· 211 206 }); 212 207 213 208 it("returns null entries for profiles still missing after load", async () => { 214 - const base = { getProfile: () => null }; 209 + const signals = { $hydratedProfiles: mapSig(() => null) }; 215 210 const requests = { loadProfiles: async () => {} }; 216 211 217 - const declarative = new Declarative({}, requests, base); 212 + const declarative = new Declarative(signals, requests); 218 213 const result = await declarative.ensureProfiles(["did:test:missing"]); 219 214 220 215 assertEquals(result, [null]); ··· 227 222 const postThread = { post: { uri: postURI }, replies: [] }; 228 223 let loadCalled = false; 229 224 230 - const selectors = createMockSelectors({ 225 + const signals = createMockSignals({ 231 226 postThreads: { [postURI]: postThread }, 232 227 }); 233 228 const requests = { ··· 236 231 }, 237 232 }; 238 233 239 - const declarative = new Declarative(selectors, requests); 234 + const declarative = new Declarative(signals, requests); 240 235 const result = await declarative.ensurePostThread(postURI); 241 236 242 237 assertEquals(result, postThread); ··· 248 243 const postThread = { post: { uri: postURI }, replies: [] }; 249 244 let callCount = 0; 250 245 251 - const selectors = { 252 - getPostThread: (uri) => { 246 + const signals = { 247 + $hydratedPostThreads: mapSig(() => { 253 248 callCount++; 254 249 return callCount > 1 ? postThread : null; 255 - }, 250 + }), 256 251 }; 257 252 const requests = { 258 - loadPostThread: async (uri, options) => {}, 253 + loadPostThread: async () => {}, 259 254 }; 260 255 261 - const declarative = new Declarative(selectors, requests); 256 + const declarative = new Declarative(signals, requests); 262 257 const result = await declarative.ensurePostThread(postURI); 263 258 264 259 assertEquals(result, postThread); ··· 270 265 let passedLabelers = null; 271 266 let callCount = 0; 272 267 273 - const selectors = { 274 - getPostThread: (uri) => { 268 + const signals = { 269 + $hydratedPostThreads: mapSig(() => { 275 270 callCount++; 276 271 return callCount > 1 ? postThread : null; 277 - }, 272 + }), 278 273 }; 279 274 const requests = { 280 275 loadPostThread: async (uri, options) => { ··· 282 277 }, 283 278 }; 284 279 285 - const declarative = new Declarative(selectors, requests); 280 + const declarative = new Declarative(signals, requests); 286 281 await declarative.ensurePostThread(postURI, { labelers: ["labeler1"] }); 287 282 288 283 assertEquals(passedLabelers, ["labeler1"]); 289 284 }); 290 285 291 286 it("should throw when post thread not found after loading", async () => { 292 - const selectors = createMockSelectors({}); 287 + const signals = createMockSignals({}); 293 288 const requests = createMockRequests({}); 294 289 295 - const declarative = new Declarative(selectors, requests); 290 + const declarative = new Declarative(signals, requests); 296 291 297 292 let error = null; 298 293 try { ··· 312 307 const post = { uri: postURI, text: "Hello" }; 313 308 let loadCalled = false; 314 309 315 - const selectors = createMockSelectors({ posts: { [postURI]: post } }); 310 + const signals = createMockSignals({ posts: { [postURI]: post } }); 316 311 const requests = { 317 312 loadPost: async () => { 318 313 loadCalled = true; 319 314 }, 320 315 }; 321 316 322 - const declarative = new Declarative(selectors, requests); 317 + const declarative = new Declarative(signals, requests); 323 318 const result = await declarative.ensurePost(postURI); 324 319 325 320 assertEquals(result, post); ··· 331 326 const post = { uri: postURI, text: "Hello" }; 332 327 let callCount = 0; 333 328 334 - const selectors = { 335 - getPost: (uri) => { 329 + const signals = { 330 + $hydratedPosts: mapSig(() => { 336 331 callCount++; 337 332 return callCount > 1 ? post : null; 338 - }, 333 + }), 339 334 }; 340 335 const requests = { 341 - loadPost: async (uri) => {}, 336 + loadPost: async () => {}, 342 337 }; 343 338 344 - const declarative = new Declarative(selectors, requests); 339 + const declarative = new Declarative(signals, requests); 345 340 const result = await declarative.ensurePost(postURI); 346 341 347 342 assertEquals(result, post); 348 343 }); 349 344 350 345 it("should throw when post not found after loading", async () => { 351 - const selectors = createMockSelectors({}); 346 + const signals = createMockSignals({}); 352 347 const requests = createMockRequests({}); 353 348 354 - const declarative = new Declarative(selectors, requests); 349 + const declarative = new Declarative(signals, requests); 355 350 356 351 let error = null; 357 352 try { ··· 371 366 const postB = { uri: "at://b", text: "B" }; 372 367 let loadCalled = false; 373 368 374 - const selectors = createMockSelectors({ 369 + const signals = createMockSignals({ 375 370 posts: { [postA.uri]: postA, [postB.uri]: postB }, 376 371 }); 377 372 const requests = { ··· 380 375 }, 381 376 }; 382 377 383 - const declarative = new Declarative(selectors, requests); 378 + const declarative = new Declarative(signals, requests); 384 379 const result = await declarative.ensurePosts([postB.uri, postA.uri]); 385 380 386 381 assertEquals(result, [postB, postA]); ··· 393 388 const store = { [postA.uri]: postA }; 394 389 let loadedWith = null; 395 390 396 - const selectors = { 397 - getPost: (uri) => store[uri] ?? null, 398 - getPosts: (uris) => uris.map((uri) => store[uri] ?? null), 391 + const signals = { 392 + $hydratedPosts: mapSig((uri) => store[uri] ?? null), 399 393 }; 400 394 const requests = { 401 395 loadPosts: async (uris) => { ··· 404 398 }, 405 399 }; 406 400 407 - const declarative = new Declarative(selectors, requests); 401 + const declarative = new Declarative(signals, requests); 408 402 const result = await declarative.ensurePosts([postA.uri, postB.uri]); 409 403 410 404 assertEquals(loadedWith, [postB.uri]); ··· 412 406 }); 413 407 414 408 it("returns null entries for posts still missing after load", async () => { 415 - const selectors = { 416 - getPost: () => null, 417 - getPosts: (uris) => uris.map(() => null), 409 + const signals = { 410 + $hydratedPosts: mapSig(() => null), 418 411 }; 419 412 const requests = { loadPosts: async () => {} }; 420 413 421 - const declarative = new Declarative(selectors, requests); 414 + const declarative = new Declarative(signals, requests); 422 415 const result = await declarative.ensurePosts(["at://missing"]); 423 416 424 417 assertEquals(result, [null]); ··· 431 424 const feedGenerator = { uri: feedUri, displayName: "Test Feed" }; 432 425 let loadCalled = false; 433 426 434 - const selectors = createMockSelectors({ 427 + const signals = createMockSignals({ 435 428 feedGenerators: { [feedUri]: feedGenerator }, 436 429 }); 437 430 const requests = { ··· 440 433 }, 441 434 }; 442 435 443 - const declarative = new Declarative(selectors, requests); 436 + const declarative = new Declarative(signals, requests); 444 437 const result = await declarative.ensureFeedGenerator(feedUri); 445 438 446 439 assertEquals(result, feedGenerator); ··· 452 445 const feedGenerator = { uri: feedUri, displayName: "Test Feed" }; 453 446 let callCount = 0; 454 447 455 - const selectors = { 456 - getFeedGenerator: (uri) => { 448 + const signals = { 449 + $feedGenerators: mapSig(() => { 457 450 callCount++; 458 451 return callCount > 1 ? feedGenerator : null; 459 - }, 452 + }), 460 453 }; 461 454 const requests = { 462 - loadFeedGenerator: async (uri) => {}, 455 + loadFeedGenerator: async () => {}, 463 456 }; 464 457 465 - const declarative = new Declarative(selectors, requests); 458 + const declarative = new Declarative(signals, requests); 466 459 const result = await declarative.ensureFeedGenerator(feedUri); 467 460 468 461 assertEquals(result, feedGenerator); 469 462 }); 470 463 471 464 it("should throw when feed generator not found after loading", async () => { 472 - const selectors = createMockSelectors({}); 465 + const signals = createMockSignals({}); 473 466 const requests = createMockRequests({}); 474 467 475 - const declarative = new Declarative(selectors, requests); 468 + const declarative = new Declarative(signals, requests); 476 469 477 470 let error = null; 478 471 try { ··· 491 484 const pinnedFeedGenerators = [{ uri: "feed1" }, { uri: "feed2" }]; 492 485 let loadCalled = false; 493 486 494 - const selectors = createMockSelectors({ pinnedFeedGenerators }); 487 + const signals = createMockSignals({ pinnedFeedGenerators }); 495 488 const requests = { 496 489 loadPinnedFeedGenerators: async () => { 497 490 loadCalled = true; 498 491 }, 499 492 }; 500 493 501 - const declarative = new Declarative(selectors, requests); 494 + const declarative = new Declarative(signals, requests); 502 495 const result = await declarative.ensurePinnedFeedGenerators(); 503 496 504 497 assertEquals(result, pinnedFeedGenerators); ··· 509 502 const pinnedFeedGenerators = [{ uri: "feed1" }]; 510 503 let callCount = 0; 511 504 512 - const selectors = { 513 - getPinnedFeedGenerators: () => { 505 + const signals = { 506 + $hydratedPinnedFeedGenerators: sig(() => { 514 507 callCount++; 515 508 return callCount > 1 ? pinnedFeedGenerators : null; 516 - }, 509 + }), 517 510 }; 518 511 const requests = { 519 512 loadPinnedFeedGenerators: async () => {}, 520 513 }; 521 514 522 - const declarative = new Declarative(selectors, requests); 515 + const declarative = new Declarative(signals, requests); 523 516 const result = await declarative.ensurePinnedFeedGenerators(); 524 517 525 518 assertEquals(result, pinnedFeedGenerators); 526 519 }); 527 520 528 521 it("should throw when pinned feed generators not found after loading", async () => { 529 - const selectors = createMockSelectors({}); 522 + const signals = createMockSignals({}); 530 523 const requests = createMockRequests({}); 531 524 532 - const declarative = new Declarative(selectors, requests); 525 + const declarative = new Declarative(signals, requests); 533 526 534 527 let error = null; 535 528 try { ··· 548 541 const convoList = [{ id: "convo1" }, { id: "convo2" }]; 549 542 let loadCalled = false; 550 543 551 - const selectors = createMockSelectors({ convoList }); 544 + const signals = createMockSignals({ convoList }); 552 545 const requests = { 553 546 loadConvoList: async () => { 554 547 loadCalled = true; 555 548 }, 556 549 }; 557 550 558 - const declarative = new Declarative(selectors, requests); 551 + const declarative = new Declarative(signals, requests); 559 552 const result = await declarative.ensureConvoList(); 560 553 561 554 assertEquals(result, convoList); ··· 566 559 const convoList = [{ id: "convo1" }]; 567 560 let callCount = 0; 568 561 569 - const selectors = { 570 - getConvoList: () => { 562 + const signals = { 563 + $convoList: sig(() => { 571 564 callCount++; 572 565 return callCount > 1 ? convoList : null; 573 - }, 566 + }), 574 567 }; 575 568 const requests = { 576 569 loadConvoList: async () => {}, 577 570 }; 578 571 579 - const declarative = new Declarative(selectors, requests); 572 + const declarative = new Declarative(signals, requests); 580 573 const result = await declarative.ensureConvoList(); 581 574 582 575 assertEquals(result, convoList); 583 576 }); 584 577 585 578 it("should throw when convo list not found after loading", async () => { 586 - const selectors = createMockSelectors({}); 579 + const signals = createMockSignals({}); 587 580 const requests = createMockRequests({}); 588 581 589 - const declarative = new Declarative(selectors, requests); 582 + const declarative = new Declarative(signals, requests); 590 583 591 584 let error = null; 592 585 try { ··· 606 599 const convo = { id: convoId, messages: [] }; 607 600 let loadCalled = false; 608 601 609 - const selectors = createMockSelectors({ convos: { [convoId]: convo } }); 602 + const signals = createMockSignals({ convos: { [convoId]: convo } }); 610 603 const requests = { 611 604 loadConvo: async () => { 612 605 loadCalled = true; 613 606 }, 614 607 }; 615 608 616 - const declarative = new Declarative(selectors, requests); 609 + const declarative = new Declarative(signals, requests); 617 610 const result = await declarative.ensureConvo(convoId); 618 611 619 612 assertEquals(result, convo); ··· 625 618 const convo = { id: convoId, messages: [] }; 626 619 let callCount = 0; 627 620 628 - const selectors = { 629 - getConvo: (id) => { 621 + const signals = { 622 + $convos: mapSig(() => { 630 623 callCount++; 631 624 return callCount > 1 ? convo : null; 632 - }, 625 + }), 633 626 }; 634 627 const requests = { 635 - loadConvo: async (id) => {}, 628 + loadConvo: async () => {}, 636 629 }; 637 630 638 - const declarative = new Declarative(selectors, requests); 631 + const declarative = new Declarative(signals, requests); 639 632 const result = await declarative.ensureConvo(convoId); 640 633 641 634 assertEquals(result, convo); 642 635 }); 643 636 644 637 it("should throw when convo not found after loading", async () => { 645 - const selectors = createMockSelectors({}); 638 + const signals = createMockSignals({}); 646 639 const requests = createMockRequests({}); 647 640 648 - const declarative = new Declarative(selectors, requests); 641 + const declarative = new Declarative(signals, requests); 649 642 650 643 let error = null; 651 644 try { ··· 665 658 const convo = { id: "convo123", members: [profileDid] }; 666 659 let loadCalled = false; 667 660 668 - const selectors = createMockSelectors({ 661 + const signals = createMockSignals({ 669 662 convoForProfile: { [profileDid]: convo }, 670 663 }); 671 664 const requests = { ··· 674 667 }, 675 668 }; 676 669 677 - const declarative = new Declarative(selectors, requests); 670 + const declarative = new Declarative(signals, requests); 678 671 const result = await declarative.ensureConvoForProfile(profileDid); 679 672 680 673 assertEquals(result, convo); ··· 686 679 const convo = { id: "convo123", members: [profileDid] }; 687 680 let callCount = 0; 688 681 689 - const selectors = { 690 - getConvoForProfile: (did) => { 682 + const signals = { 683 + $convoForProfile: mapSig(() => { 691 684 callCount++; 692 685 return callCount > 1 ? convo : null; 693 - }, 686 + }), 694 687 }; 695 688 const requests = { 696 - loadConvoForProfile: async (did) => {}, 689 + loadConvoForProfile: async () => {}, 697 690 }; 698 691 699 - const declarative = new Declarative(selectors, requests); 692 + const declarative = new Declarative(signals, requests); 700 693 const result = await declarative.ensureConvoForProfile(profileDid); 701 694 702 695 assertEquals(result, convo); 703 696 }); 704 697 705 698 it("should throw when convo for profile not found after loading", async () => { 706 - const selectors = createMockSelectors({}); 699 + const signals = createMockSignals({}); 707 700 const requests = createMockRequests({}); 708 701 709 - const declarative = new Declarative(selectors, requests); 702 + const declarative = new Declarative(signals, requests); 710 703 711 704 let error = null; 712 705 try {
-116
tests/unit/specs/dataLayer/derived.test.js
··· 1 - import { TestSuite } from "../../testSuite.js"; 2 - import { assert, assertEquals } from "../../testHelpers.js"; 3 - import { 4 - hydratePostForView, 5 - markMutedWords, 6 - markIsHidden, 7 - addLabels, 8 - resolveBlockedQuote, 9 - } from "/js/dataLayer/derived.js"; 10 - 11 - const t = new TestSuite("Derived"); 12 - 13 - function emptyPrefs(overrides = {}) { 14 - return { 15 - postHasMutedWord: () => false, 16 - quotedPostHasMutedWord: () => false, 17 - isPostHidden: () => false, 18 - getBadgeLabels: () => [], 19 - getContentLabel: () => null, 20 - getMediaLabel: () => null, 21 - ...overrides, 22 - }; 23 - } 24 - 25 - function makePost(extra = {}) { 26 - return { 27 - uri: "at://did:test/app.bsky.feed.post/x", 28 - record: { text: "hello" }, 29 - ...extra, 30 - }; 31 - } 32 - 33 - t.describe("markMutedWords", (it) => { 34 - it("marks the post when it contains a muted word", () => { 35 - const post = makePost(); 36 - const result = markMutedWords( 37 - post, 38 - emptyPrefs({ postHasMutedWord: () => true }), 39 - ); 40 - assertEquals(result.viewer.hasMutedWord, true); 41 - }); 42 - 43 - it("does not touch the post when there is no match", () => { 44 - const post = makePost(); 45 - const result = markMutedWords(post, emptyPrefs()); 46 - assertEquals(result.viewer, undefined); 47 - }); 48 - }); 49 - 50 - t.describe("markIsHidden", (it) => { 51 - it("marks the post hidden when preferences say so", () => { 52 - const post = makePost(); 53 - const result = markIsHidden(post, emptyPrefs({ isPostHidden: () => true })); 54 - assertEquals(result.viewer.isHidden, true); 55 - }); 56 - }); 57 - 58 - t.describe("addLabels", (it) => { 59 - it("attaches badge, content, and media labels from preferences", () => { 60 - const post = makePost(); 61 - const result = addLabels( 62 - post, 63 - emptyPrefs({ 64 - getBadgeLabels: () => ["badge"], 65 - getContentLabel: () => "warn", 66 - getMediaLabel: () => "blur", 67 - }), 68 - ); 69 - assertEquals(result.badgeLabels, ["badge"]); 70 - assertEquals(result.contentLabel, "warn"); 71 - assertEquals(result.mediaLabel, "blur"); 72 - }); 73 - 74 - it("leaves the post untouched when no labels apply", () => { 75 - const post = makePost(); 76 - const result = addLabels(post, emptyPrefs()); 77 - assertEquals(result.badgeLabels, undefined); 78 - assertEquals(result.contentLabel, undefined); 79 - assertEquals(result.mediaLabel, undefined); 80 - }); 81 - }); 82 - 83 - t.describe("resolveBlockedQuote", (it) => { 84 - it("returns the post unchanged when there is no blocked quote", () => { 85 - const post = makePost(); 86 - const result = resolveBlockedQuote(post, { getPost: () => null }); 87 - assertEquals(result, post); 88 - }); 89 - }); 90 - 91 - t.describe("hydratePostForView", (it) => { 92 - it("returns null when given null", () => { 93 - const result = hydratePostForView(null, { 94 - preferences: emptyPrefs(), 95 - getPost: () => null, 96 - }); 97 - assertEquals(result, null); 98 - }); 99 - 100 - it("composes muted/hidden/label marks against the post", () => { 101 - const post = makePost(); 102 - const result = hydratePostForView(post, { 103 - preferences: emptyPrefs({ 104 - postHasMutedWord: () => true, 105 - isPostHidden: () => true, 106 - getBadgeLabels: () => ["b"], 107 - }), 108 - getPost: () => null, 109 - }); 110 - assertEquals(result.viewer.hasMutedWord, true); 111 - assertEquals(result.viewer.isHidden, true); 112 - assertEquals(result.badgeLabels, ["b"]); 113 - }); 114 - }); 115 - 116 - await t.run();
+272 -242
tests/unit/specs/dataLayer/mutations.test.js
··· 3 3 import { Mutations } from "/js/dataLayer/mutations.js"; 4 4 import { DataStore } from "/js/dataLayer/dataStore.js"; 5 5 import { PatchStore } from "/js/dataLayer/patchStore.js"; 6 - import { Selectors } from "/js/dataLayer/selectors.js"; 6 + import { Signals } from "/js/dataLayer/signals.js"; 7 7 import { Preferences } from "/js/preferences.js"; 8 + import { Signal, SignalMap } from "/js/utils.js"; 9 + 10 + // Minimal pluginService stub for Signals constructor. 11 + function makePluginService() { 12 + return { 13 + $pluginFilteredFeedItems: new SignalMap(), 14 + }; 15 + } 16 + 17 + // `applyPostPatches` now requires the patches array. Helper that fetches the 18 + // current patches for a post URI and applies them. 19 + function applyPostPatches(patchStore, post) { 20 + const patches = patchStore.$postPatches.get(post.uri).get() || []; 21 + return patchStore.applyPostPatches(post, patches); 22 + } 23 + 24 + function makeSignals( 25 + dataStore, 26 + patchStore, 27 + preferencesProvider, 28 + isAuthenticated = true, 29 + ) { 30 + // Signals' $preferences computed reads `preferencesProvider.$preferences.get()`. 31 + // If the provider doesn't supply that signal, give it a passthrough. 32 + const provider = preferencesProvider.$preferences 33 + ? preferencesProvider 34 + : { 35 + ...preferencesProvider, 36 + $preferences: new Signal.State( 37 + preferencesProvider.requirePreferences 38 + ? preferencesProvider.requirePreferences() 39 + : null, 40 + ), 41 + }; 42 + return new Signals( 43 + dataStore, 44 + patchStore, 45 + provider, 46 + makePluginService(), 47 + isAuthenticated, 48 + ); 49 + } 8 50 9 51 const t = new TestSuite("Mutations"); 10 52 ··· 20 62 createLikeRecord: async () => ({ uri: "like-uri" }), 21 63 }; 22 64 const dataStore = new DataStore(); 23 - const patchStore = new PatchStore(); 65 + const patchStore = new PatchStore(dataStore); 24 66 const mockPreferencesProvider = { 25 67 requirePreferences: () => Preferences.createLoggedOutPreferences(), 26 68 }; ··· 31 73 mockPreferencesProvider, 32 74 ); 33 75 34 - // Start the mutation 35 76 mutations.addLike(testPost); 36 77 37 - // Check that patch was applied immediately 38 - const patchedPost = patchStore.applyPostPatches(testPost); 78 + const patchedPost = applyPostPatches(patchStore, testPost); 39 79 assertEquals(patchedPost.viewer.like, "fake like"); 40 80 assertEquals(patchedPost.likeCount, 6); 41 81 }); ··· 46 86 createLikeRecord: async () => mockLike, 47 87 }; 48 88 const dataStore = new DataStore(); 49 - const patchStore = new PatchStore(); 89 + const patchStore = new PatchStore(dataStore); 50 90 const mockPreferencesProvider = { 51 91 requirePreferences: () => Preferences.createLoggedOutPreferences(), 52 92 }; ··· 59 99 60 100 await mutations.addLike(testPost); 61 101 62 - // Check that post was updated in store 63 - const storedPost = dataStore.getPost(testPost.uri); 102 + const storedPost = dataStore.$posts.get(testPost.uri).get(); 64 103 assertEquals(storedPost.viewer.like, "like-123"); 65 104 assertEquals(storedPost.likeCount, 6); 66 105 67 - // Check that patch was removed 68 - const patchedPost = patchStore.applyPostPatches(storedPost); 69 - assertEquals(patchedPost, storedPost); // No patches applied 106 + const patchedPost = applyPostPatches(patchStore, storedPost); 107 + assertEquals(patchedPost, storedPost); 70 108 }); 71 109 72 110 it("should handle concurrent like operations", async () => { ··· 77 115 ), 78 116 }; 79 117 const dataStore = new DataStore(); 80 - const patchStore = new PatchStore(); 118 + const patchStore = new PatchStore(dataStore); 81 119 const mockPreferencesProvider = { 82 120 requirePreferences: () => Preferences.createLoggedOutPreferences(), 83 121 }; ··· 88 126 mockPreferencesProvider, 89 127 ); 90 128 91 - // Start two concurrent operations 92 129 const promise1 = mutations.addLike(testPost); 93 130 const promise2 = mutations.addLike(testPost); 94 131 95 - // Both should apply patches 96 - const patchedPost = patchStore.applyPostPatches(testPost); 97 - assertEquals(patchedPost.likeCount, 7); // +2 likes 132 + const patchedPost = applyPostPatches(patchStore, testPost); 133 + assertEquals(patchedPost.likeCount, 7); 98 134 99 135 await Promise.all([promise1, promise2]); 100 136 }); ··· 115 151 }), 116 152 }; 117 153 const dataStore = new DataStore(); 118 - const patchStore = new PatchStore(); 154 + const patchStore = new PatchStore(dataStore); 119 155 const mockPreferencesProvider = { 120 156 requirePreferences: () => Preferences.createLoggedOutPreferences(), 121 157 }; ··· 126 162 mockPreferencesProvider, 127 163 ); 128 164 129 - // Start the mutation 130 165 mutations.removeLike(testPost); 131 166 132 - // Check that patch was applied immediately 133 - const patchedPost = patchStore.applyPostPatches(testPost); 167 + const patchedPost = applyPostPatches(patchStore, testPost); 134 168 assertEquals(patchedPost.viewer.like, null); 135 169 assertEquals(patchedPost.likeCount, 5); 136 170 }); ··· 140 174 deleteLikeRecord: async () => {}, 141 175 }; 142 176 const dataStore = new DataStore(); 143 - const patchStore = new PatchStore(); 177 + const patchStore = new PatchStore(dataStore); 144 178 const mockPreferencesProvider = { 145 179 requirePreferences: () => Preferences.createLoggedOutPreferences(), 146 180 }; ··· 153 187 154 188 await mutations.removeLike(testPost); 155 189 156 - // Check that post was updated in store 157 - const storedPost = dataStore.getPost(testPost.uri); 190 + const storedPost = dataStore.$posts.get(testPost.uri).get(); 158 191 assertEquals(storedPost.viewer.like, null); 159 192 assertEquals(storedPost.likeCount, 5); 160 193 161 - // Check that patch was removed 162 - const patchedPost = patchStore.applyPostPatches(storedPost); 194 + const patchedPost = applyPostPatches(patchStore, storedPost); 163 195 assertEquals(patchedPost, storedPost); 164 196 }); 165 197 }); ··· 181 213 }), 182 214 }; 183 215 const dataStore = new DataStore(); 184 - const patchStore = new PatchStore(); 216 + const patchStore = new PatchStore(dataStore); 185 217 const mockPreferencesProvider = { 186 218 requirePreferences: () => Preferences.createLoggedOutPreferences(), 187 219 }; ··· 192 224 mockPreferencesProvider, 193 225 ); 194 226 195 - // Start the mutation 196 227 mutations.followProfile(testProfile); 197 228 198 - // Check that patch was applied immediately 199 229 const patchedProfile = patchStore.applyProfilePatches(testProfile); 200 230 assertEquals(patchedProfile.viewer.following, "fake following"); 201 231 assertEquals(patchedProfile.followersCount, 11); ··· 207 237 createFollowRecord: async () => mockFollow, 208 238 }; 209 239 const dataStore = new DataStore(); 210 - const patchStore = new PatchStore(); 240 + const patchStore = new PatchStore(dataStore); 211 241 const mockPreferencesProvider = { 212 242 requirePreferences: () => Preferences.createLoggedOutPreferences(), 213 243 }; ··· 220 250 221 251 await mutations.followProfile(testProfile); 222 252 223 - // Check that profile was updated in store 224 - const storedProfile = dataStore.getProfile(testProfile.did); 253 + const storedProfile = dataStore.$profiles.get(testProfile.did).get(); 225 254 assertEquals(storedProfile.viewer.following, "follow-123"); 226 255 assertEquals(storedProfile.followersCount, 11); 227 256 228 - // Check that patch was removed 229 257 const patchedProfile = patchStore.applyProfilePatches(storedProfile); 230 258 assertEquals(patchedProfile, storedProfile); 231 259 }); ··· 248 276 }), 249 277 }; 250 278 const dataStore = new DataStore(); 251 - const patchStore = new PatchStore(); 279 + const patchStore = new PatchStore(dataStore); 252 280 const mockPreferencesProvider = { 253 281 requirePreferences: () => Preferences.createLoggedOutPreferences(), 254 282 }; ··· 259 287 mockPreferencesProvider, 260 288 ); 261 289 262 - // Start the mutation 263 290 mutations.unfollowProfile(testProfile); 264 291 265 - // Check that patch was applied immediately 266 292 const patchedProfile = patchStore.applyProfilePatches(testProfile); 267 293 assertEquals(patchedProfile.viewer.following, null); 268 294 assertEquals(patchedProfile.followersCount, 9); ··· 273 299 deleteFollowRecord: async () => {}, 274 300 }; 275 301 const dataStore = new DataStore(); 276 - const patchStore = new PatchStore(); 302 + const patchStore = new PatchStore(dataStore); 277 303 const mockPreferencesProvider = { 278 304 requirePreferences: () => Preferences.createLoggedOutPreferences(), 279 305 }; ··· 286 312 287 313 await mutations.unfollowProfile(testProfile); 288 314 289 - // Check that profile was updated in store 290 - const storedProfile = dataStore.getProfile(testProfile.did); 315 + const storedProfile = dataStore.$profiles.get(testProfile.did).get(); 291 316 assertEquals(storedProfile.viewer.following, null); 292 317 assertEquals(storedProfile.followersCount, 9); 293 318 294 - // Check that patch was removed 295 319 const patchedProfile = patchStore.applyProfilePatches(storedProfile); 296 320 assertEquals(patchedProfile, storedProfile); 297 321 }); ··· 308 332 }; 309 333 310 334 it("should add optimistic preference patch immediately", () => { 311 - let updateCalled = false; 312 335 const mockPreferencesProvider = { 313 336 requirePreferences: () => ({ 314 337 subscribeLabeler: () => Preferences.createLoggedOutPreferences(), 315 338 }), 316 339 updatePreferences: async () => { 317 340 await new Promise((resolve) => setTimeout(resolve, 100)); 318 - updateCalled = true; 319 341 }, 320 342 }; 321 343 const dataStore = new DataStore(); 322 - const patchStore = new PatchStore(); 344 + const patchStore = new PatchStore(dataStore); 323 345 const mutations = new Mutations( 324 346 {}, 325 347 dataStore, ··· 327 349 mockPreferencesProvider, 328 350 ); 329 351 330 - // Start the mutation 331 352 mutations.subscribeLabeler(testProfile, testLabelerInfo); 332 353 333 - // Check that patch was applied immediately (before API call completes) 334 - const patches = patchStore._getPreferencePatches(); 354 + const patches = patchStore.$preferencePatches.get(); 335 355 assertEquals(patches.length, 1); 336 356 assertEquals(patches[0].body.type, "subscribeLabeler"); 337 357 assertEquals(patches[0].body.did, testProfile.did); ··· 345 365 updatePreferences: async () => {}, 346 366 }; 347 367 const dataStore = new DataStore(); 348 - const patchStore = new PatchStore(); 368 + const patchStore = new PatchStore(dataStore); 349 369 const mutations = new Mutations( 350 370 {}, 351 371 dataStore, ··· 355 375 356 376 await mutations.subscribeLabeler(testProfile, testLabelerInfo); 357 377 358 - // Check that patch was removed 359 - const patches = patchStore._getPreferencePatches(); 378 + const patches = patchStore.$preferencePatches.get(); 360 379 assertEquals(patches.length, 0); 361 380 }); 362 381 ··· 370 389 }, 371 390 }; 372 391 const dataStore = new DataStore(); 373 - const patchStore = new PatchStore(); 392 + const patchStore = new PatchStore(dataStore); 374 393 const mutations = new Mutations( 375 394 {}, 376 395 dataStore, ··· 386 405 } 387 406 388 407 assertEquals(errorThrown, true); 389 - // Patch should still be removed 390 - const patches = patchStore._getPreferencePatches(); 408 + const patches = patchStore.$preferencePatches.get(); 391 409 assertEquals(patches.length, 0); 392 410 }); 393 411 }); ··· 408 426 }, 409 427 }; 410 428 const dataStore = new DataStore(); 411 - const patchStore = new PatchStore(); 429 + const patchStore = new PatchStore(dataStore); 412 430 const mutations = new Mutations( 413 431 {}, 414 432 dataStore, ··· 416 434 mockPreferencesProvider, 417 435 ); 418 436 419 - // Start the mutation 420 437 mutations.unsubscribeLabeler(testProfile); 421 438 422 - // Check that patch was applied immediately 423 - const patches = patchStore._getPreferencePatches(); 439 + const patches = patchStore.$preferencePatches.get(); 424 440 assertEquals(patches.length, 1); 425 441 assertEquals(patches[0].body.type, "unsubscribeLabeler"); 426 442 assertEquals(patches[0].body.did, testProfile.did); ··· 434 450 updatePreferences: async () => {}, 435 451 }; 436 452 const dataStore = new DataStore(); 437 - const patchStore = new PatchStore(); 453 + const patchStore = new PatchStore(dataStore); 438 454 const mutations = new Mutations( 439 455 {}, 440 456 dataStore, ··· 444 460 445 461 await mutations.unsubscribeLabeler(testProfile); 446 462 447 - // Check that patch was removed 448 - const patches = patchStore._getPreferencePatches(); 463 + const patches = patchStore.$preferencePatches.get(); 449 464 assertEquals(patches.length, 0); 450 465 }); 451 466 ··· 459 474 }, 460 475 }; 461 476 const dataStore = new DataStore(); 462 - const patchStore = new PatchStore(); 477 + const patchStore = new PatchStore(dataStore); 463 478 const mutations = new Mutations( 464 479 {}, 465 480 dataStore, ··· 475 490 } 476 491 477 492 assertEquals(errorThrown, true); 478 - // Patch should still be removed 479 - const patches = patchStore._getPreferencePatches(); 493 + const patches = patchStore.$preferencePatches.get(); 480 494 assertEquals(patches.length, 0); 481 495 }); 482 496 }); ··· 496 510 }, 497 511 }; 498 512 const dataStore = new DataStore(); 499 - const patchStore = new PatchStore(); 513 + const patchStore = new PatchStore(dataStore); 500 514 const mutations = new Mutations( 501 515 {}, 502 516 dataStore, ··· 504 518 mockPreferencesProvider, 505 519 ); 506 520 507 - // Start the mutation 508 521 mutations.updateLabelerSetting({ labelerDid, label, visibility }); 509 522 510 - // Check that patch was applied immediately 511 - const patches = patchStore._getPreferencePatches(); 523 + const patches = patchStore.$preferencePatches.get(); 512 524 assertEquals(patches.length, 1); 513 525 assertEquals(patches[0].body.type, "setContentLabelPref"); 514 526 assertEquals(patches[0].body.label, label); ··· 524 536 updatePreferences: async () => {}, 525 537 }; 526 538 const dataStore = new DataStore(); 527 - const patchStore = new PatchStore(); 539 + const patchStore = new PatchStore(dataStore); 528 540 const mutations = new Mutations( 529 541 {}, 530 542 dataStore, ··· 534 546 535 547 await mutations.updateLabelerSetting({ labelerDid, label, visibility }); 536 548 537 - // Check that patch was removed 538 - const patches = patchStore._getPreferencePatches(); 549 + const patches = patchStore.$preferencePatches.get(); 539 550 assertEquals(patches.length, 0); 540 551 }); 541 552 ··· 549 560 }, 550 561 }; 551 562 const dataStore = new DataStore(); 552 - const patchStore = new PatchStore(); 563 + const patchStore = new PatchStore(dataStore); 553 564 const mutations = new Mutations( 554 565 {}, 555 566 dataStore, ··· 565 576 } 566 577 567 578 assertEquals(errorThrown, true); 568 - // Patch should still be removed 569 - const patches = patchStore._getPreferencePatches(); 579 + const patches = patchStore.$preferencePatches.get(); 570 580 assertEquals(patches.length, 0); 571 581 }); 572 582 ··· 582 592 updatePreferences: async () => {}, 583 593 }; 584 594 const dataStore = new DataStore(); 585 - const patchStore = new PatchStore(); 595 + const patchStore = new PatchStore(dataStore); 586 596 const mutations = new Mutations( 587 597 {}, 588 598 dataStore, ··· 615 625 new Promise((resolve) => setTimeout(resolve, 75)), 616 626 }; 617 627 const dataStore = new DataStore(); 618 - const patchStore = new PatchStore(); 628 + const patchStore = new PatchStore(dataStore); 619 629 const mockPreferencesProvider = { 620 630 requirePreferences: () => Preferences.createLoggedOutPreferences(), 621 631 }; ··· 626 636 mockPreferencesProvider, 627 637 ); 628 638 629 - // Start like, then unlike before like completes 630 639 const likePromise = mutations.addLike(post); 631 - 632 - // Add a small delay to ensure the like patch is added first 633 640 await new Promise((resolve) => setTimeout(resolve, 10)); 634 641 635 642 const unlikePromise = mutations.removeLike({ ··· 638 645 viewer: { like: "like1" }, 639 646 }); 640 647 641 - // Both patches should be active 642 - const patchedPost = patchStore.applyPostPatches(post); 643 - assertEquals(patchedPost.likeCount, 5); // +1 -1 = 0, so 5 648 + const patchedPost = applyPostPatches(patchStore, post); 649 + assertEquals(patchedPost.likeCount, 5); 644 650 645 651 await Promise.all([likePromise, unlikePromise]); 646 652 }); ··· 652 658 deleteLikeRecord: async () => undefined, 653 659 }; 654 660 const dataStore = new DataStore(); 655 - const patchStore = new PatchStore(); 661 + const patchStore = new PatchStore(dataStore); 656 662 const mockPreferencesProvider = { 657 663 requirePreferences: () => Preferences.createLoggedOutPreferences(), 658 664 }; ··· 665 671 666 672 await mutations.removeLike(post); 667 673 668 - const storedPost = dataStore.getPost(post.uri); 674 + const storedPost = dataStore.$posts.get(post.uri).get(); 669 675 assertEquals(storedPost.viewer.like, null); 670 676 }); 671 677 }); ··· 680 686 }, 681 687 }; 682 688 const dataStore = new DataStore(); 683 - const patchStore = new PatchStore(); 689 + const patchStore = new PatchStore(dataStore); 684 690 const mutations = new Mutations( 685 691 {}, 686 692 dataStore, ··· 710 716 }, 711 717 }; 712 718 const dataStore = new DataStore(); 713 - const patchStore = new PatchStore(); 719 + const patchStore = new PatchStore(dataStore); 714 720 const mutations = new Mutations( 715 721 {}, 716 722 dataStore, ··· 764 770 }, 765 771 }; 766 772 const dataStore = new DataStore(); 767 - const patchStore = new PatchStore(); 773 + const patchStore = new PatchStore(dataStore); 768 774 const mutations = new Mutations( 769 775 {}, 770 776 dataStore, ··· 792 798 793 799 function createMutationsWithMockApi(mockApi) { 794 800 const dataStore = new DataStore(); 795 - const patchStore = new PatchStore(); 801 + const patchStore = new PatchStore(dataStore); 796 802 const mockPreferencesProvider = { 797 803 requirePreferences: () => Preferences.createLoggedOutPreferences(), 798 804 }; 799 - dataStore.setProfile(testProfile.did, testProfile); 800 - dataStore.setCurrentUser(testProfile); 805 + dataStore.$profiles.set(testProfile.did, testProfile); 806 + dataStore.$currentUser.set(testProfile); 801 807 return { 802 808 mutations: new Mutations( 803 809 mockApi, ··· 926 932 description: "Updated bio", 927 933 }); 928 934 929 - const updatedProfile = dataStore.getProfile(testProfile.did); 935 + const updatedProfile = dataStore.$profiles.get(testProfile.did).get(); 930 936 assertEquals(updatedProfile.displayName, "Updated Name"); 931 937 assertEquals(updatedProfile.description, "Updated bio"); 932 938 assertEquals(updatedProfile.avatar, "https://example.com/new-avatar.jpg"); ··· 991 997 description: "Updated bio", 992 998 }); 993 999 994 - const currentUser = dataStore.getCurrentUser(); 1000 + const currentUser = dataStore.$currentUser.get(); 995 1001 assertEquals(currentUser.displayName, "Updated User"); 996 1002 }); 997 1003 }); ··· 1011 1017 1012 1018 function setup(mockApi, { pinnedPost = null, authorFeed = null } = {}) { 1013 1019 const dataStore = new DataStore(); 1014 - const patchStore = new PatchStore(); 1020 + const patchStore = new PatchStore(dataStore); 1015 1021 const mockPreferencesProvider = { 1016 1022 requirePreferences: () => Preferences.createLoggedOutPreferences(), 1017 1023 }; 1018 - dataStore.setCurrentUser({ ...testUser, pinnedPost }); 1024 + dataStore.$currentUser.set({ ...testUser, pinnedPost }); 1019 1025 if (authorFeed) { 1020 - dataStore.setAuthorFeed(`${testUser.did}-posts`, authorFeed); 1026 + dataStore.$authorFeeds.set(`${testUser.did}-posts`, authorFeed); 1021 1027 } 1022 - const selectors = new Selectors( 1023 - dataStore, 1024 - patchStore, 1025 - mockPreferencesProvider, 1026 - true, 1027 - ); 1028 + const signals = makeSignals(dataStore, patchStore, mockPreferencesProvider); 1028 1029 return { 1029 1030 mutations: new Mutations( 1030 1031 mockApi, ··· 1034 1035 ), 1035 1036 dataStore, 1036 1037 patchStore, 1037 - selectors, 1038 + signals, 1038 1039 }; 1039 1040 } 1040 1041 ··· 1054 1055 1055 1056 await mutations.pinPost(testPost); 1056 1057 1057 - assertEquals(dataStore.getCurrentUser().pinnedPost.uri, testPost.uri); 1058 - assertEquals(dataStore.getCurrentUser().pinnedPost.cid, testPost.cid); 1058 + assertEquals(dataStore.$currentUser.get().pinnedPost.uri, testPost.uri); 1059 + assertEquals(dataStore.$currentUser.get().pinnedPost.cid, testPost.cid); 1059 1060 assertEquals(putRecordArgs.record.pinnedPost.uri, testPost.uri); 1060 1061 assertEquals(putRecordArgs.record.pinnedPost.cid, testPost.cid); 1061 1062 assertEquals(putRecordArgs.record.displayName, "Me"); ··· 1077 1078 1078 1079 await mutations.pinPost(testPost); 1079 1080 1080 - const feed = dataStore.getAuthorFeed(`${testUser.did}-posts`).feed; 1081 + const feed = dataStore.$authorFeeds.get(`${testUser.did}-posts`).get().feed; 1081 1082 assertEquals(feed[0].post.uri, testPost.uri); 1082 1083 assertEquals(feed[0].reason.$type, "app.bsky.feed.defs#reasonPin"); 1083 1084 assertEquals(feed.length, 2); ··· 1100 1101 getProfileRecord: async () => ({ value: {}, cid: "cid-profile" }), 1101 1102 putProfileRecord: () => putPromise, 1102 1103 }; 1103 - const { mutations, selectors, dataStore } = setup(mockApi, { 1104 + const { mutations, signals, dataStore } = setup(mockApi, { 1104 1105 authorFeed: { feed: [otherItem, targetItem], cursor: "" }, 1105 1106 }); 1106 - dataStore.setPost(otherPost.uri, otherPost); 1107 - dataStore.setPost(testPost.uri, testPost); 1107 + dataStore.$posts.set(otherPost.uri, otherPost); 1108 + dataStore.$posts.set(testPost.uri, testPost); 1108 1109 1109 1110 const promise = mutations.pinPost(testPost); 1110 1111 // Yield so the patches apply before we inspect them. 1111 1112 await new Promise((resolve) => setTimeout(resolve, 0)); 1112 1113 1113 - assertEquals(selectors.getCurrentUser().pinnedPost.uri, testPost.uri); 1114 - const inFlightFeed = selectors.getAuthorFeed(testUser.did, "posts").feed; 1114 + assertEquals(signals.$currentUser.get().pinnedPost.uri, testPost.uri); 1115 + const inFlightFeed = signals.$hydratedAuthorFeeds 1116 + .get(`${testUser.did}-posts`) 1117 + .get().feed; 1115 1118 assertEquals(inFlightFeed[0].post.uri, testPost.uri); 1116 1119 assertEquals(inFlightFeed[0].reason.$type, "app.bsky.feed.defs#reasonPin"); 1117 1120 ··· 1119 1122 await promise; 1120 1123 1121 1124 // After success, dataStore matches the previously-patched view. 1122 - assertEquals(selectors.getCurrentUser().pinnedPost.uri, testPost.uri); 1125 + assertEquals(signals.$currentUser.get().pinnedPost.uri, testPost.uri); 1123 1126 }); 1124 1127 1125 1128 it("should revert to original state on failure", async () => { ··· 1141 1144 uri: "at://did:plc:user/app.bsky.feed.post/old", 1142 1145 cid: "cid-old", 1143 1146 }; 1144 - const { mutations, dataStore, selectors } = setup(mockApi, { 1147 + const { mutations, dataStore, signals } = setup(mockApi, { 1145 1148 pinnedPost: previousPinned, 1146 1149 authorFeed: { feed: [otherItem, targetItem], cursor: "" }, 1147 1150 }); 1148 - dataStore.setPost(otherPost.uri, otherPost); 1149 - dataStore.setPost(testPost.uri, testPost); 1151 + dataStore.$posts.set(otherPost.uri, otherPost); 1152 + dataStore.$posts.set(testPost.uri, testPost); 1150 1153 1151 1154 let threw = false; 1152 1155 try { ··· 1155 1158 threw = true; 1156 1159 } 1157 1160 assertEquals(threw, true); 1158 - // Patches removed; selectors reflect original dataStore. 1159 - assertEquals(selectors.getCurrentUser().pinnedPost.uri, previousPinned.uri); 1160 - const feed = selectors.getAuthorFeed(testUser.did, "posts").feed; 1161 + // Patches removed; signals reflect original dataStore. 1162 + assertEquals(signals.$currentUser.get().pinnedPost.uri, previousPinned.uri); 1163 + const feed = signals.$hydratedAuthorFeeds 1164 + .get(`${testUser.did}-posts`) 1165 + .get().feed; 1161 1166 assertEquals(feed[0].post.uri, otherItem.post.uri); 1162 1167 // dataStore unchanged. 1163 - assertEquals(dataStore.getCurrentUser().pinnedPost.uri, previousPinned.uri); 1168 + assertEquals( 1169 + dataStore.$currentUser.get().pinnedPost.uri, 1170 + previousPinned.uri, 1171 + ); 1164 1172 }); 1165 1173 }); 1166 1174 ··· 1179 1187 1180 1188 function setup(mockApi, { pinnedPost, authorFeed = null } = {}) { 1181 1189 const dataStore = new DataStore(); 1182 - const patchStore = new PatchStore(); 1190 + const patchStore = new PatchStore(dataStore); 1183 1191 const mockPreferencesProvider = { 1184 1192 requirePreferences: () => Preferences.createLoggedOutPreferences(), 1185 1193 }; 1186 - dataStore.setCurrentUser({ ...testUser, pinnedPost }); 1194 + dataStore.$currentUser.set({ ...testUser, pinnedPost }); 1187 1195 if (authorFeed) { 1188 - dataStore.setAuthorFeed(`${testUser.did}-posts`, authorFeed); 1196 + dataStore.$authorFeeds.set(`${testUser.did}-posts`, authorFeed); 1189 1197 } 1190 1198 return { 1191 1199 mutations: new Mutations( ··· 1219 1227 1220 1228 await mutations.unpinPost(testPost); 1221 1229 1222 - assertEquals(dataStore.getCurrentUser().pinnedPost, undefined); 1230 + assertEquals(dataStore.$currentUser.get().pinnedPost, undefined); 1223 1231 assertEquals("pinnedPost" in putRecordArgs.record, false); 1224 1232 assertEquals(putRecordArgs.record.displayName, "Me"); 1225 1233 }); ··· 1244 1252 await mutations.unpinPost(testPost); 1245 1253 1246 1254 assertEquals(putCalled, false); 1247 - assertEquals(dataStore.getCurrentUser().pinnedPost.uri, otherPinned.uri); 1255 + assertEquals(dataStore.$currentUser.get().pinnedPost.uri, otherPinned.uri); 1248 1256 }); 1249 1257 }); 1250 1258 ··· 1257 1265 1258 1266 function setup(mockApi = {}) { 1259 1267 const dataStore = new DataStore(); 1260 - const patchStore = new PatchStore(); 1268 + const patchStore = new PatchStore(dataStore); 1261 1269 const mockPreferencesProvider = { 1262 1270 requirePreferences: () => Preferences.createLoggedOutPreferences(), 1263 1271 }; ··· 1273 1281 it("should set viewer.muted on the profile", async () => { 1274 1282 const { mutations, dataStore } = setup(); 1275 1283 await mutations.muteProfile(profile); 1276 - assertEquals(dataStore.getProfile(profile.did).viewer.muted, true); 1284 + assertEquals(dataStore.$profiles.get(profile.did).get().viewer.muted, true); 1277 1285 }); 1278 1286 1279 1287 it("should prepend muted profile to the cached list", async () => { 1280 1288 const { mutations, dataStore } = setup(); 1281 1289 const existing = { did: "did:plc:other", viewer: { muted: true } }; 1282 - dataStore.setMutedProfiles({ mutes: [existing], cursor: "abc" }); 1290 + dataStore.$mutedProfiles.set({ mutes: [existing], cursor: "abc" }); 1283 1291 1284 1292 await mutations.muteProfile(profile); 1285 1293 1286 - const stored = dataStore.getMutedProfiles(); 1294 + const stored = dataStore.$mutedProfiles.get(); 1287 1295 assertEquals(stored.mutes.length, 2); 1288 1296 assertEquals(stored.mutes[0].did, profile.did); 1289 1297 assertEquals(stored.mutes[0].viewer.muted, true); ··· 1293 1301 1294 1302 it("should not duplicate when already present in the cached list", async () => { 1295 1303 const { mutations, dataStore } = setup(); 1296 - dataStore.setMutedProfiles({ 1304 + dataStore.$mutedProfiles.set({ 1297 1305 mutes: [{ ...profile, viewer: { muted: true } }], 1298 1306 cursor: null, 1299 1307 }); 1300 1308 1301 1309 await mutations.muteProfile(profile); 1302 1310 1303 - assertEquals(dataStore.getMutedProfiles().mutes.length, 1); 1311 + assertEquals(dataStore.$mutedProfiles.get().mutes.length, 1); 1304 1312 }); 1305 1313 1306 1314 it("should not initialize the cached list if it was not loaded", async () => { 1307 1315 const { mutations, dataStore } = setup(); 1308 1316 await mutations.muteProfile(profile); 1309 - assertEquals(dataStore.getMutedProfiles(), null); 1317 + assertEquals(dataStore.$mutedProfiles.get(), null); 1310 1318 }); 1311 1319 }); 1312 1320 ··· 1319 1327 1320 1328 function setup(mockApi = {}) { 1321 1329 const dataStore = new DataStore(); 1322 - const patchStore = new PatchStore(); 1330 + const patchStore = new PatchStore(dataStore); 1323 1331 const mockPreferencesProvider = { 1324 1332 requirePreferences: () => Preferences.createLoggedOutPreferences(), 1325 1333 }; ··· 1335 1343 it("should clear viewer.muted on the profile", async () => { 1336 1344 const { mutations, dataStore } = setup(); 1337 1345 await mutations.unmuteProfile(profile); 1338 - assertEquals(dataStore.getProfile(profile.did).viewer.muted, false); 1346 + assertEquals( 1347 + dataStore.$profiles.get(profile.did).get().viewer.muted, 1348 + false, 1349 + ); 1339 1350 }); 1340 1351 1341 1352 it("should remove profile from the cached list", async () => { 1342 1353 const { mutations, dataStore } = setup(); 1343 1354 const other = { did: "did:plc:other", viewer: { muted: true } }; 1344 - dataStore.setMutedProfiles({ 1355 + dataStore.$mutedProfiles.set({ 1345 1356 mutes: [profile, other], 1346 1357 cursor: "abc", 1347 1358 }); 1348 1359 1349 1360 await mutations.unmuteProfile(profile); 1350 1361 1351 - const stored = dataStore.getMutedProfiles(); 1362 + const stored = dataStore.$mutedProfiles.get(); 1352 1363 assertEquals(stored.mutes.length, 1); 1353 1364 assertEquals(stored.mutes[0].did, other.did); 1354 1365 assertEquals(stored.cursor, "abc"); ··· 1357 1368 it("should be a no-op on the cached list when not present", async () => { 1358 1369 const { mutations, dataStore } = setup(); 1359 1370 const other = { did: "did:plc:other", viewer: { muted: true } }; 1360 - dataStore.setMutedProfiles({ mutes: [other], cursor: null }); 1371 + dataStore.$mutedProfiles.set({ mutes: [other], cursor: null }); 1361 1372 1362 1373 await mutations.unmuteProfile(profile); 1363 1374 1364 - assertEquals(dataStore.getMutedProfiles().mutes.length, 1); 1375 + assertEquals(dataStore.$mutedProfiles.get().mutes.length, 1); 1365 1376 }); 1366 1377 }); 1367 1378 ··· 1375 1386 1376 1387 function setup(mockApi = {}) { 1377 1388 const dataStore = new DataStore(); 1378 - const patchStore = new PatchStore(); 1389 + const patchStore = new PatchStore(dataStore); 1379 1390 const mockPreferencesProvider = { 1380 1391 requirePreferences: () => Preferences.createLoggedOutPreferences(), 1381 1392 }; ··· 1391 1402 it("should set viewer.blocking on the profile", async () => { 1392 1403 const { mutations, dataStore } = setup(); 1393 1404 await mutations.blockProfile(profile); 1394 - assertEquals(dataStore.getProfile(profile.did).viewer.blocking, blockUri); 1405 + assertEquals( 1406 + dataStore.$profiles.get(profile.did).get().viewer.blocking, 1407 + blockUri, 1408 + ); 1395 1409 }); 1396 1410 1397 1411 it("should prepend blocked profile to the cached list", async () => { ··· 1400 1414 did: "did:plc:other", 1401 1415 viewer: { blocking: "at://existing-block" }, 1402 1416 }; 1403 - dataStore.setBlockedProfiles({ blocks: [existing], cursor: "abc" }); 1417 + dataStore.$blockedProfiles.set({ blocks: [existing], cursor: "abc" }); 1404 1418 1405 1419 await mutations.blockProfile(profile); 1406 1420 1407 - const stored = dataStore.getBlockedProfiles(); 1421 + const stored = dataStore.$blockedProfiles.get(); 1408 1422 assertEquals(stored.blocks.length, 2); 1409 1423 assertEquals(stored.blocks[0].did, profile.did); 1410 1424 assertEquals(stored.blocks[0].viewer.blocking, blockUri); ··· 1414 1428 1415 1429 it("should not duplicate when already present in the cached list", async () => { 1416 1430 const { mutations, dataStore } = setup(); 1417 - dataStore.setBlockedProfiles({ 1431 + dataStore.$blockedProfiles.set({ 1418 1432 blocks: [{ ...profile, viewer: { blocking: blockUri } }], 1419 1433 cursor: null, 1420 1434 }); 1421 1435 1422 1436 await mutations.blockProfile(profile); 1423 1437 1424 - assertEquals(dataStore.getBlockedProfiles().blocks.length, 1); 1438 + assertEquals(dataStore.$blockedProfiles.get().blocks.length, 1); 1425 1439 }); 1426 1440 1427 1441 it("should not initialize the cached list if it was not loaded", async () => { 1428 1442 const { mutations, dataStore } = setup(); 1429 1443 await mutations.blockProfile(profile); 1430 - assertEquals(dataStore.getBlockedProfiles(), null); 1444 + assertEquals(dataStore.$blockedProfiles.get(), null); 1431 1445 }); 1432 1446 1433 1447 it("should update author viewer.blocking on cached posts by that author", async () => { ··· 1440 1454 uri: "at://did:plc:someone/app.bsky.feed.post/1", 1441 1455 author: { did: "did:plc:someone", viewer: {} }, 1442 1456 }; 1443 - dataStore.setPost(post.uri, post); 1444 - dataStore.setPost(otherPost.uri, otherPost); 1457 + dataStore.$posts.set(post.uri, post); 1458 + dataStore.$posts.set(otherPost.uri, otherPost); 1445 1459 1446 1460 await mutations.blockProfile(profile); 1447 1461 1448 - assertEquals(dataStore.getPost(post.uri).author.viewer.blocking, blockUri); 1449 1462 assertEquals( 1450 - dataStore.getPost(otherPost.uri).author.viewer.blocking, 1463 + dataStore.$posts.get(post.uri).get().author.viewer.blocking, 1464 + blockUri, 1465 + ); 1466 + assertEquals( 1467 + dataStore.$posts.get(otherPost.uri).get().author.viewer.blocking, 1451 1468 undefined, 1452 1469 ); 1453 1470 }); ··· 1462 1479 1463 1480 function setup(mockApi = {}) { 1464 1481 const dataStore = new DataStore(); 1465 - const patchStore = new PatchStore(); 1482 + const patchStore = new PatchStore(dataStore); 1466 1483 const mockPreferencesProvider = { 1467 1484 requirePreferences: () => Preferences.createLoggedOutPreferences(), 1468 1485 }; ··· 1478 1495 it("should clear viewer.blocking on the profile", async () => { 1479 1496 const { mutations, dataStore } = setup(); 1480 1497 await mutations.unblockProfile(profile); 1481 - assertEquals(dataStore.getProfile(profile.did).viewer.blocking, null); 1498 + assertEquals( 1499 + dataStore.$profiles.get(profile.did).get().viewer.blocking, 1500 + null, 1501 + ); 1482 1502 }); 1483 1503 1484 1504 it("should remove profile from the cached list", async () => { ··· 1487 1507 did: "did:plc:other", 1488 1508 viewer: { blocking: "at://other-block" }, 1489 1509 }; 1490 - dataStore.setBlockedProfiles({ 1510 + dataStore.$blockedProfiles.set({ 1491 1511 blocks: [profile, other], 1492 1512 cursor: "abc", 1493 1513 }); 1494 1514 1495 1515 await mutations.unblockProfile(profile); 1496 1516 1497 - const stored = dataStore.getBlockedProfiles(); 1517 + const stored = dataStore.$blockedProfiles.get(); 1498 1518 assertEquals(stored.blocks.length, 1); 1499 1519 assertEquals(stored.blocks[0].did, other.did); 1500 1520 assertEquals(stored.cursor, "abc"); ··· 1506 1526 did: "did:plc:other", 1507 1527 viewer: { blocking: "at://other-block" }, 1508 1528 }; 1509 - dataStore.setBlockedProfiles({ blocks: [other], cursor: null }); 1529 + dataStore.$blockedProfiles.set({ blocks: [other], cursor: null }); 1510 1530 1511 1531 await mutations.unblockProfile(profile); 1512 1532 1513 - assertEquals(dataStore.getBlockedProfiles().blocks.length, 1); 1533 + assertEquals(dataStore.$blockedProfiles.get().blocks.length, 1); 1514 1534 }); 1515 1535 1516 1536 it("should clear author viewer.blocking on cached posts by that author", async () => { ··· 1519 1539 uri: "at://did:plc:target/app.bsky.feed.post/1", 1520 1540 author: { did: profile.did, viewer: { blocking: "at://old" } }, 1521 1541 }; 1522 - dataStore.setPost(post.uri, post); 1542 + dataStore.$posts.set(post.uri, post); 1523 1543 1524 1544 await mutations.unblockProfile(profile); 1525 1545 1526 - assertEquals(dataStore.getPost(post.uri).author.viewer.blocking, null); 1546 + assertEquals( 1547 + dataStore.$posts.get(post.uri).get().author.viewer.blocking, 1548 + null, 1549 + ); 1527 1550 }); 1528 1551 }); 1529 1552 ··· 1536 1559 1537 1560 function setup(mockApi = {}) { 1538 1561 const dataStore = new DataStore(); 1539 - const patchStore = new PatchStore(); 1562 + const patchStore = new PatchStore(dataStore); 1540 1563 const mockPreferencesProvider = { 1541 1564 requirePreferences: () => Preferences.createLoggedOutPreferences(), 1542 1565 }; ··· 1554 1577 createBookmark: () => new Promise((resolve) => setTimeout(resolve, 100)), 1555 1578 }); 1556 1579 mutations.addBookmark(testPost); 1557 - const patched = patchStore.applyPostPatches(testPost); 1580 + const patched = applyPostPatches(patchStore, testPost); 1558 1581 assertEquals(patched.viewer.bookmarked, true); 1559 1582 assertEquals(patched.bookmarkCount, 3); 1560 1583 }); ··· 1562 1585 it("should update dataStore and remove patch on success", async () => { 1563 1586 const { mutations, dataStore, patchStore } = setup(); 1564 1587 await mutations.addBookmark(testPost); 1565 - const stored = dataStore.getPost(testPost.uri); 1588 + const stored = dataStore.$posts.get(testPost.uri).get(); 1566 1589 assertEquals(stored.viewer.bookmarked, true); 1567 1590 assertEquals(stored.bookmarkCount, 3); 1568 - assertEquals(patchStore.applyPostPatches(stored), stored); 1591 + assertEquals(applyPostPatches(patchStore, stored), stored); 1569 1592 }); 1570 1593 1571 1594 it("should prepend post to the cached bookmarks feed", async () => { ··· 1573 1596 const existingItem = { 1574 1597 post: { uri: "at://did:test/app.bsky.feed.post/other" }, 1575 1598 }; 1576 - dataStore.setBookmarks({ feed: [existingItem], cursor: "abc" }); 1599 + dataStore.$bookmarks.set({ feed: [existingItem], cursor: "abc" }); 1577 1600 1578 1601 await mutations.addBookmark(testPost); 1579 1602 1580 - const bookmarks = dataStore.getBookmarks(); 1603 + const bookmarks = dataStore.$bookmarks.get(); 1581 1604 assertEquals(bookmarks.feed.length, 2); 1582 1605 assertEquals(bookmarks.feed[0].post.uri, testPost.uri); 1583 1606 assertEquals(bookmarks.feed[1].post.uri, existingItem.post.uri); ··· 1587 1610 it("should not initialize the bookmarks feed if it was not loaded", async () => { 1588 1611 const { mutations, dataStore } = setup(); 1589 1612 await mutations.addBookmark(testPost); 1590 - assertEquals(dataStore.getBookmarks(), null); 1613 + assertEquals(dataStore.$bookmarks.get(), null); 1591 1614 }); 1592 1615 }); 1593 1616 ··· 1600 1623 1601 1624 function setup(mockApi = {}) { 1602 1625 const dataStore = new DataStore(); 1603 - const patchStore = new PatchStore(); 1626 + const patchStore = new PatchStore(dataStore); 1604 1627 const mockPreferencesProvider = { 1605 1628 requirePreferences: () => Preferences.createLoggedOutPreferences(), 1606 1629 }; ··· 1618 1641 deleteBookmark: () => new Promise((resolve) => setTimeout(resolve, 100)), 1619 1642 }); 1620 1643 mutations.removeBookmark(testPost); 1621 - const patched = patchStore.applyPostPatches(testPost); 1644 + const patched = applyPostPatches(patchStore, testPost); 1622 1645 assertEquals(patched.viewer.bookmarked, false); 1623 1646 assertEquals(patched.bookmarkCount, 2); 1624 1647 }); ··· 1626 1649 it("should update dataStore and remove patch on success", async () => { 1627 1650 const { mutations, dataStore, patchStore } = setup(); 1628 1651 await mutations.removeBookmark(testPost); 1629 - const stored = dataStore.getPost(testPost.uri); 1652 + const stored = dataStore.$posts.get(testPost.uri).get(); 1630 1653 assertEquals(stored.viewer.bookmarked, false); 1631 1654 assertEquals(stored.bookmarkCount, 2); 1632 - assertEquals(patchStore.applyPostPatches(stored), stored); 1655 + assertEquals(applyPostPatches(patchStore, stored), stored); 1633 1656 }); 1634 1657 1635 1658 it("should remove post from the cached bookmarks feed", async () => { ··· 1637 1660 const otherItem = { 1638 1661 post: { uri: "at://did:test/app.bsky.feed.post/other" }, 1639 1662 }; 1640 - dataStore.setBookmarks({ 1663 + dataStore.$bookmarks.set({ 1641 1664 feed: [{ post: testPost }, otherItem], 1642 1665 cursor: "abc", 1643 1666 }); 1644 1667 1645 1668 await mutations.removeBookmark(testPost); 1646 1669 1647 - const bookmarks = dataStore.getBookmarks(); 1670 + const bookmarks = dataStore.$bookmarks.get(); 1648 1671 assertEquals(bookmarks.feed.length, 1); 1649 1672 assertEquals(bookmarks.feed[0].post.uri, otherItem.post.uri); 1650 1673 assertEquals(bookmarks.cursor, "abc"); ··· 1667 1690 1668 1691 function setup(mockApi = {}, { authorFeed } = {}) { 1669 1692 const dataStore = new DataStore(); 1670 - const patchStore = new PatchStore(); 1693 + const patchStore = new PatchStore(dataStore); 1671 1694 const mockPreferencesProvider = { 1672 1695 requirePreferences: () => Preferences.createLoggedOutPreferences(), 1673 1696 }; 1674 - dataStore.setCurrentUser(currentUser); 1697 + dataStore.$currentUser.set(currentUser); 1675 1698 if (authorFeed) { 1676 - dataStore.setAuthorFeed(`${currentUser.did}-posts`, authorFeed); 1699 + dataStore.$authorFeeds.set(`${currentUser.did}-posts`, authorFeed); 1677 1700 } 1678 1701 const mutations = new Mutations( 1679 1702 { ··· 1698 1721 ), 1699 1722 }); 1700 1723 mutations.createRepost(testPost); 1701 - const patched = patchStore.applyPostPatches(testPost); 1724 + const patched = applyPostPatches(patchStore, testPost); 1702 1725 assertEquals(patched.repostCount, 5); 1703 1726 assertEquals(patched.viewer.repost, "fake repost"); 1704 1727 }); ··· 1706 1729 it("should update dataStore with repost uri and incremented count", async () => { 1707 1730 const { mutations, dataStore } = setup(); 1708 1731 await mutations.createRepost(testPost); 1709 - const stored = dataStore.getPost(testPost.uri); 1732 + const stored = dataStore.$posts.get(testPost.uri).get(); 1710 1733 assertEquals( 1711 1734 stored.viewer.repost, 1712 1735 "at://did:plc:me/app.bsky.feed.repost/abc", ··· 1720 1743 { authorFeed: { feed: [], cursor: "c1" } }, 1721 1744 ); 1722 1745 await mutations.createRepost(testPost); 1723 - const feed = dataStore.getAuthorFeed(`${currentUser.did}-posts`); 1746 + const feed = dataStore.$authorFeeds.get(`${currentUser.did}-posts`).get(); 1724 1747 assertEquals(feed.feed.length, 1); 1725 1748 assertEquals(feed.feed[0].post.uri, testPost.uri); 1726 1749 assertEquals(feed.feed[0].reason.$type, "app.bsky.feed.defs#reasonRepost"); ··· 1750 1773 1751 1774 function setup(mockApi = {}, { authorFeed } = {}) { 1752 1775 const dataStore = new DataStore(); 1753 - const patchStore = new PatchStore(); 1776 + const patchStore = new PatchStore(dataStore); 1754 1777 const mockPreferencesProvider = { 1755 1778 requirePreferences: () => Preferences.createLoggedOutPreferences(), 1756 1779 }; 1757 - dataStore.setCurrentUser(currentUser); 1780 + dataStore.$currentUser.set(currentUser); 1758 1781 if (authorFeed) { 1759 - dataStore.setAuthorFeed(`${currentUser.did}-posts`, authorFeed); 1782 + dataStore.$authorFeeds.set(`${currentUser.did}-posts`, authorFeed); 1760 1783 } 1761 1784 const mutations = new Mutations( 1762 1785 { deleteRepostRecord: async () => ({}), ...mockApi }, ··· 1773 1796 new Promise((resolve) => setTimeout(resolve, 100)), 1774 1797 }); 1775 1798 mutations.deleteRepost(testPost); 1776 - const patched = patchStore.applyPostPatches(testPost); 1799 + const patched = applyPostPatches(patchStore, testPost); 1777 1800 assertEquals(patched.repostCount, 4); 1778 1801 assertEquals(patched.viewer.repost, null); 1779 1802 }); ··· 1781 1804 it("should update dataStore clearing repost uri and decrementing count", async () => { 1782 1805 const { mutations, dataStore } = setup(); 1783 1806 await mutations.deleteRepost(testPost); 1784 - const stored = dataStore.getPost(testPost.uri); 1807 + const stored = dataStore.$posts.get(testPost.uri).get(); 1785 1808 assertEquals(stored.viewer.repost, null); 1786 1809 assertEquals(stored.repostCount, 4); 1787 1810 }); ··· 1806 1829 1807 1830 await mutations.deleteRepost(testPost); 1808 1831 1809 - const feed = dataStore.getAuthorFeed(`${currentUser.did}-posts`); 1832 + const feed = dataStore.$authorFeeds.get(`${currentUser.did}-posts`).get(); 1810 1833 assertEquals(feed.feed.length, 1); 1811 1834 assertEquals(feed.feed[0].post.uri, otherItem.post.uri); 1812 1835 assertEquals(feed.cursor, "c1"); ··· 1826 1849 }, 1827 1850 }; 1828 1851 const dataStore = new DataStore(); 1829 - const patchStore = new PatchStore(); 1852 + const patchStore = new PatchStore(dataStore); 1830 1853 const mutations = new Mutations( 1831 1854 {}, 1832 1855 dataStore, ··· 1890 1913 updatePreferences: () => updatePromise, 1891 1914 }; 1892 1915 const dataStore = new DataStore(); 1893 - const patchStore = new PatchStore(); 1916 + const patchStore = new PatchStore(dataStore); 1894 1917 const mutations = new Mutations( 1895 1918 {}, 1896 1919 dataStore, ··· 1899 1922 ); 1900 1923 1901 1924 const promise = mutations.pinFeed(feedUri); 1902 - const patches = patchStore._getPreferencePatches(); 1925 + const patches = patchStore.$preferencePatches.get(); 1903 1926 assertEquals(patches.length, 1); 1904 1927 assertEquals(patches[0].body.type, "pinFeed"); 1905 1928 assertEquals(patches[0].body.feedUri, feedUri); 1906 1929 1907 1930 updateResolve(); 1908 1931 await promise; 1909 - assertEquals(patchStore._getPreferencePatches().length, 0); 1932 + assertEquals(patchStore.$preferencePatches.get().length, 0); 1910 1933 }); 1911 1934 }); 1912 1935 ··· 1931 1954 }, 1932 1955 }; 1933 1956 const dataStore = new DataStore(); 1934 - const patchStore = new PatchStore(); 1957 + const patchStore = new PatchStore(dataStore); 1935 1958 const mutations = new Mutations( 1936 1959 {}, 1937 1960 dataStore, ··· 1963 1986 updatePreferences: () => updatePromise, 1964 1987 }; 1965 1988 const dataStore = new DataStore(); 1966 - const patchStore = new PatchStore(); 1989 + const patchStore = new PatchStore(dataStore); 1967 1990 const mutations = new Mutations( 1968 1991 {}, 1969 1992 dataStore, ··· 1972 1995 ); 1973 1996 1974 1997 const promise = mutations.unpinFeed(feedUri); 1975 - const patches = patchStore._getPreferencePatches(); 1998 + const patches = patchStore.$preferencePatches.get(); 1976 1999 assertEquals(patches.length, 1); 1977 2000 assertEquals(patches[0].body.type, "unpinFeed"); 1978 2001 assertEquals(patches[0].body.feedUri, feedUri); 1979 2002 1980 2003 updateResolve(); 1981 2004 await promise; 1982 - assertEquals(patchStore._getPreferencePatches().length, 0); 2005 + assertEquals(patchStore.$preferencePatches.get().length, 0); 1983 2006 }); 1984 2007 }); 1985 2008 ··· 1996 2019 }, 1997 2020 }; 1998 2021 const dataStore = new DataStore(); 1999 - const patchStore = new PatchStore(); 2022 + const patchStore = new PatchStore(dataStore); 2000 2023 const mutations = new Mutations( 2001 2024 {}, 2002 2025 dataStore, ··· 2020 2043 updatePreferences: () => updatePromise, 2021 2044 }; 2022 2045 const dataStore = new DataStore(); 2023 - const patchStore = new PatchStore(); 2046 + const patchStore = new PatchStore(dataStore); 2024 2047 const mutations = new Mutations( 2025 2048 {}, 2026 2049 dataStore, ··· 2029 2052 ); 2030 2053 2031 2054 const promise = mutations.hidePost(testPost); 2032 - const patches = patchStore._getPostPatches(testPost.uri); 2055 + const patches = patchStore.$postPatches.get(testPost.uri).get() || []; 2033 2056 assertEquals(patches.length, 1); 2034 2057 assertEquals(patches[0].body.type, "hidePost"); 2035 2058 2036 2059 updateResolve(); 2037 2060 await promise; 2038 - assertEquals(patchStore._getPostPatches(testPost.uri).length, 0); 2061 + assertEquals( 2062 + (patchStore.$postPatches.get(testPost.uri).get() || []).length, 2063 + 0, 2064 + ); 2039 2065 }); 2040 2066 }); 2041 2067 ··· 2065 2091 }, 2066 2092 }; 2067 2093 const dataStore = new DataStore(); 2068 - const patchStore = new PatchStore(); 2094 + const patchStore = new PatchStore(dataStore); 2069 2095 const mutations = new Mutations( 2070 2096 {}, 2071 2097 dataStore, ··· 2096 2122 it("should set viewer.activitySubscription on the profile", async () => { 2097 2123 const subscription = { post: true, reply: false }; 2098 2124 const dataStore = new DataStore(); 2099 - const patchStore = new PatchStore(); 2125 + const patchStore = new PatchStore(dataStore); 2100 2126 const mockPreferencesProvider = { 2101 2127 requirePreferences: () => Preferences.createLoggedOutPreferences(), 2102 2128 }; ··· 2117 2143 assertEquals(calledWith.did, profile.did); 2118 2144 assertEquals(calledWith.sub, subscription); 2119 2145 assertEquals( 2120 - dataStore.getProfile(profile.did).viewer.activitySubscription, 2146 + dataStore.$profiles.get(profile.did).get().viewer.activitySubscription, 2121 2147 subscription, 2122 2148 ); 2123 2149 }); 2124 2150 2125 2151 it("should remove the patch on failure and rethrow", async () => { 2126 2152 const dataStore = new DataStore(); 2127 - const patchStore = new PatchStore(); 2153 + const patchStore = new PatchStore(dataStore); 2128 2154 const mockPreferencesProvider = { 2129 2155 requirePreferences: () => Preferences.createLoggedOutPreferences(), 2130 2156 }; ··· 2158 2184 2159 2185 function setup({ replyPostThread, authorFeed, replyAuthorFeed } = {}) { 2160 2186 const dataStore = new DataStore(); 2161 - const patchStore = new PatchStore(); 2187 + const patchStore = new PatchStore(dataStore); 2162 2188 const mockPreferencesProvider = { 2163 2189 requirePreferences: () => Preferences.createLoggedOutPreferences(), 2164 2190 }; ··· 2179 2205 createPost: async () => fullPost, 2180 2206 }; 2181 2207 if (replyPostThread) { 2182 - dataStore.setPostThread(replyPostThread.post.uri, replyPostThread); 2208 + dataStore.$postThreads.set(replyPostThread.post.uri, replyPostThread); 2183 2209 } 2184 2210 if (authorFeed) { 2185 - dataStore.setAuthorFeed(`${currentUserDid}-posts`, authorFeed); 2211 + dataStore.$authorFeeds.set(`${currentUserDid}-posts`, authorFeed); 2186 2212 } 2187 2213 if (replyAuthorFeed) { 2188 - dataStore.setAuthorFeed(`${currentUserDid}-replies`, replyAuthorFeed); 2214 + dataStore.$authorFeeds.set(`${currentUserDid}-replies`, replyAuthorFeed); 2189 2215 } 2190 2216 return { mutations, dataStore, fullPost }; 2191 2217 } ··· 2194 2220 const { mutations, dataStore } = setup(); 2195 2221 const result = await mutations.createPost({ postText: "hello" }); 2196 2222 assertEquals(result.uri, newPostUri); 2197 - const stored = dataStore.getPost(newPostUri); 2223 + const stored = dataStore.$posts.get(newPostUri).get(); 2198 2224 assertEquals(stored.uri, newPostUri); 2199 2225 assertEquals(stored.viewer.priorityReply, true); 2200 2226 }); ··· 2204 2230 authorFeed: { feed: [], cursor: "c1" }, 2205 2231 }); 2206 2232 await mutations.createPost({ postText: "hello" }); 2207 - const feed = dataStore.getAuthorFeed(`${currentUserDid}-posts`); 2233 + const feed = dataStore.$authorFeeds.get(`${currentUserDid}-posts`).get(); 2208 2234 assertEquals(feed.feed.length, 1); 2209 2235 assertEquals(feed.feed[0].post.uri, newPostUri); 2210 2236 assertEquals(feed.cursor, "c1"); ··· 2233 2259 2234 2260 await mutations.createPost({ postText: "hi", replyTo, replyRoot }); 2235 2261 2236 - const updatedThread = dataStore.getPostThread(replyTo.uri); 2262 + const updatedThread = dataStore.$postThreads.get(replyTo.uri).get(); 2237 2263 assertEquals(updatedThread.replies.length, 2); 2238 2264 assertEquals(updatedThread.replies[0].post.uri, newPostUri); 2239 - const repliesFeed = dataStore.getAuthorFeed(`${currentUserDid}-replies`); 2265 + const repliesFeed = dataStore.$authorFeeds 2266 + .get(`${currentUserDid}-replies`) 2267 + .get(); 2240 2268 assertEquals(repliesFeed.feed.length, 1); 2241 2269 assertEquals(repliesFeed.feed[0].post.uri, newPostUri); 2242 2270 }); ··· 2250 2278 }; 2251 2279 let apiCalledWith = null; 2252 2280 const dataStore = new DataStore(); 2253 - const patchStore = new PatchStore(); 2281 + const patchStore = new PatchStore(dataStore); 2254 2282 const mockPreferencesProvider = { 2255 2283 requirePreferences: () => Preferences.createLoggedOutPreferences(), 2256 2284 }; 2257 - dataStore.setPost(post.uri, { ...post, record: { text: "hi" } }); 2285 + dataStore.$posts.set(post.uri, { ...post, record: { text: "hi" } }); 2258 2286 const mutations = new Mutations( 2259 2287 { 2260 2288 deletePost: async (passed) => { ··· 2269 2297 await mutations.deletePost(post); 2270 2298 2271 2299 assertEquals(apiCalledWith, post); 2272 - const stored = dataStore.getPost(post.uri); 2300 + const stored = dataStore.$posts.get(post.uri).get(); 2273 2301 assertEquals(stored.uri, post.uri); 2274 2302 assertEquals(stored.$type, "app.bsky.feed.defs#notFoundPost"); 2275 2303 }); ··· 2285 2313 2286 2314 function setup({ convoMessages, convo } = {}) { 2287 2315 const dataStore = new DataStore(); 2288 - const patchStore = new PatchStore(); 2316 + const patchStore = new PatchStore(dataStore); 2289 2317 const mockPreferencesProvider = { 2290 2318 requirePreferences: () => Preferences.createLoggedOutPreferences(), 2291 2319 }; 2292 2320 if (convoMessages) { 2293 - dataStore.setConvoMessages(convoId, convoMessages); 2321 + dataStore.$convoMessages.set(convoId, convoMessages); 2294 2322 } 2295 2323 if (convo) { 2296 - dataStore.setConvo(convoId, convo); 2324 + dataStore.$convos.set(convoId, convo); 2297 2325 } 2298 2326 const mutations = new Mutations( 2299 2327 { sendMessage: async () => sentMessage }, ··· 2308 2336 const { mutations, dataStore } = setup(); 2309 2337 const result = await mutations.createMessage(convoId, { text: "hello" }); 2310 2338 assertEquals(result, sentMessage); 2311 - assertEquals(dataStore.getMessage(sentMessage.id), sentMessage); 2339 + assertEquals(dataStore.$messages.get(sentMessage.id).get(), sentMessage); 2312 2340 }); 2313 2341 2314 2342 it("should prepend the message to the cached convo messages", async () => { ··· 2317 2345 convoMessages: { messages: [existingMessage], cursor: "c1" }, 2318 2346 }); 2319 2347 await mutations.createMessage(convoId, { text: "hello" }); 2320 - const stored = dataStore.getConvoMessages(convoId); 2348 + const stored = dataStore.$convoMessages.get(convoId).get(); 2321 2349 assertEquals(stored.messages.length, 2); 2322 2350 assertEquals(stored.messages[0].id, sentMessage.id); 2323 2351 assertEquals(stored.messages[1].id, existingMessage.id); ··· 2328 2356 const convo = { id: convoId, unreadCount: 0 }; 2329 2357 const { mutations, dataStore } = setup({ convo }); 2330 2358 await mutations.createMessage(convoId, { text: "hello" }); 2331 - const stored = dataStore.getConvo(convoId); 2359 + const stored = dataStore.$convos.get(convoId).get(); 2332 2360 assertEquals(stored.lastMessage.id, sentMessage.id); 2333 2361 assertEquals(stored.lastMessage.$type, "chat.bsky.convo.defs#messageView"); 2334 2362 }); ··· 2339 2367 2340 2368 function setup({ convoList } = {}) { 2341 2369 const dataStore = new DataStore(); 2342 - const patchStore = new PatchStore(); 2370 + const patchStore = new PatchStore(dataStore); 2343 2371 const mockPreferencesProvider = { 2344 2372 requirePreferences: () => Preferences.createLoggedOutPreferences(), 2345 2373 }; 2346 2374 if (convoList) { 2347 - dataStore.setConvoList(convoList); 2375 + dataStore.$convoList.set(convoList); 2348 2376 } 2349 2377 let acceptCalledWith = null; 2350 2378 const mutations = new Mutations( ··· 2365 2393 const result = await mutations.acceptConvo(convo); 2366 2394 assertEquals(getAcceptArg(), convo.id); 2367 2395 assertEquals(result.status, "accepted"); 2368 - assertEquals(dataStore.getConvo(convo.id).status, "accepted"); 2396 + assertEquals(dataStore.$convos.get(convo.id).get().status, "accepted"); 2369 2397 }); 2370 2398 2371 2399 it("should update the matching convo in the convo list", async () => { ··· 2374 2402 convoList: [convo, otherConvo], 2375 2403 }); 2376 2404 await mutations.acceptConvo(convo); 2377 - const list = dataStore.getConvoList(); 2405 + const list = dataStore.$convoList.get(); 2378 2406 assertEquals(list.length, 2); 2379 2407 assertEquals(list.find((c) => c.id === convo.id).status, "accepted"); 2380 2408 assertEquals(list.find((c) => c.id === otherConvo.id).status, "accepted"); ··· 2387 2415 it("should clear the convo and remove it from the convo list", async () => { 2388 2416 const otherConvo = { id: "convo-2", status: "accepted" }; 2389 2417 const dataStore = new DataStore(); 2390 - const patchStore = new PatchStore(); 2418 + const patchStore = new PatchStore(dataStore); 2391 2419 const mockPreferencesProvider = { 2392 2420 requirePreferences: () => Preferences.createLoggedOutPreferences(), 2393 2421 }; 2394 - dataStore.setConvo(convo.id, convo); 2395 - dataStore.setConvoList([convo, otherConvo]); 2422 + dataStore.$convos.set(convo.id, convo); 2423 + dataStore.$convoList.set([convo, otherConvo]); 2396 2424 let leaveCalledWith = null; 2397 2425 const mutations = new Mutations( 2398 2426 { ··· 2408 2436 await mutations.rejectConvo(convo); 2409 2437 2410 2438 assertEquals(leaveCalledWith, convo.id); 2411 - assertEquals(dataStore.getConvo(convo.id), undefined); 2412 - const list = dataStore.getConvoList(); 2439 + // Mutations sets the convo signal to null on reject (was `undefined` pre-refactor). 2440 + assertEquals(dataStore.$convos.get(convo.id).get(), null); 2441 + const list = dataStore.$convoList.get(); 2413 2442 assertEquals(list.length, 1); 2414 2443 assertEquals(list[0].id, otherConvo.id); 2415 2444 }); ··· 2419 2448 it("should call api.markConvoAsRead and zero the unread count", async () => { 2420 2449 const convoId = "convo-1"; 2421 2450 const dataStore = new DataStore(); 2422 - const patchStore = new PatchStore(); 2451 + const patchStore = new PatchStore(dataStore); 2423 2452 const mockPreferencesProvider = { 2424 2453 requirePreferences: () => Preferences.createLoggedOutPreferences(), 2425 2454 }; 2426 - dataStore.setConvo(convoId, { id: convoId, unreadCount: 4 }); 2455 + dataStore.$convos.set(convoId, { id: convoId, unreadCount: 4 }); 2427 2456 let calledWith = null; 2428 2457 const mutations = new Mutations( 2429 2458 { ··· 2439 2468 await mutations.markConvoAsRead(convoId); 2440 2469 2441 2470 assertEquals(calledWith, convoId); 2442 - assertEquals(dataStore.getConvo(convoId).unreadCount, 0); 2471 + assertEquals(dataStore.$convos.get(convoId).get().unreadCount, 0); 2443 2472 }); 2444 2473 2445 2474 it("should not throw when the convo is not cached", async () => { 2446 2475 const dataStore = new DataStore(); 2447 - const patchStore = new PatchStore(); 2476 + const patchStore = new PatchStore(dataStore); 2448 2477 const mockPreferencesProvider = { 2449 2478 requirePreferences: () => Preferences.createLoggedOutPreferences(), 2450 2479 }; ··· 2455 2484 mockPreferencesProvider, 2456 2485 ); 2457 2486 await mutations.markConvoAsRead("missing"); 2458 - assertEquals(dataStore.getConvo("missing"), undefined); 2487 + // SignalMap returns null for uninitialized keys (was `undefined` pre-refactor). 2488 + assertEquals(dataStore.$convos.get("missing").get(), null); 2459 2489 }); 2460 2490 }); 2461 2491 ··· 2471 2501 2472 2502 function setup({ convo } = {}) { 2473 2503 const dataStore = new DataStore(); 2474 - const patchStore = new PatchStore(); 2504 + const patchStore = new PatchStore(dataStore); 2475 2505 const mockPreferencesProvider = { 2476 2506 requirePreferences: () => Preferences.createLoggedOutPreferences(), 2477 2507 }; 2478 2508 if (convo) { 2479 - dataStore.setConvo(convoId, convo); 2509 + dataStore.$convos.set(convoId, convo); 2480 2510 } 2481 2511 const mutations = new Mutations( 2482 2512 { addMessageReaction: async () => updatedMessage }, ··· 2505 2535 emoji, 2506 2536 currentUserDid, 2507 2537 ); 2508 - assertEquals(dataStore.getMessage(messageId), updatedMessage); 2538 + assertEquals(dataStore.$messages.get(messageId).get(), updatedMessage); 2509 2539 assertEquals(patchStore._getMessagePatches(messageId).length, 0); 2510 2540 }); 2511 2541 ··· 2519 2549 emoji, 2520 2550 currentUserDid, 2521 2551 ); 2522 - const convo = dataStore.getConvo(convoId); 2552 + const convo = dataStore.$convos.get(convoId).get(); 2523 2553 assertEquals( 2524 2554 convo.lastReaction.$type, 2525 2555 "chat.bsky.convo.defs#messageAndReactionView", ··· 2538 2568 2539 2569 function setup({ convo } = {}) { 2540 2570 const dataStore = new DataStore(); 2541 - const patchStore = new PatchStore(); 2571 + const patchStore = new PatchStore(dataStore); 2542 2572 const mockPreferencesProvider = { 2543 2573 requirePreferences: () => Preferences.createLoggedOutPreferences(), 2544 2574 }; 2545 2575 if (convo) { 2546 - dataStore.setConvo(convoId, convo); 2576 + dataStore.$convos.set(convoId, convo); 2547 2577 } 2548 2578 const mutations = new Mutations( 2549 2579 { removeMessageReaction: async () => updatedMessage }, ··· 2572 2602 emoji, 2573 2603 currentUserDid, 2574 2604 ); 2575 - assertEquals(dataStore.getMessage(messageId), updatedMessage); 2605 + assertEquals(dataStore.$messages.get(messageId).get(), updatedMessage); 2576 2606 assertEquals(patchStore._getMessagePatches(messageId).length, 0); 2577 2607 }); 2578 2608 ··· 2589 2619 emoji, 2590 2620 currentUserDid, 2591 2621 ); 2592 - assertEquals(dataStore.getConvo(convoId).lastReaction, null); 2622 + assertEquals(dataStore.$convos.get(convoId).get().lastReaction, null); 2593 2623 }); 2594 2624 }); 2595 2625 ··· 2600 2630 2601 2631 it("should append the interaction to the dataStore (empty list branch)", async () => { 2602 2632 const dataStore = new DataStore(); 2603 - const patchStore = new PatchStore(); 2633 + const patchStore = new PatchStore(dataStore); 2604 2634 const mockPreferencesProvider = { 2605 2635 requirePreferences: () => Preferences.createLoggedOutPreferences(), 2606 2636 }; ··· 2618 2648 2619 2649 await mutations.sendShowLessInteraction(postURI, feedContext, feedProxyUrl); 2620 2650 2621 - const stored = dataStore.getShowLessInteractions(); 2651 + const stored = dataStore.$showLessInteractions.get(); 2622 2652 assertEquals(stored.length, 1); 2623 2653 assertEquals(stored[0].item, postURI); 2624 2654 assertEquals(stored[0].event, "app.bsky.feed.defs#requestLess"); ··· 2630 2660 2631 2661 it("should append to an existing list (non-empty branch)", async () => { 2632 2662 const dataStore = new DataStore(); 2633 - const patchStore = new PatchStore(); 2663 + const patchStore = new PatchStore(dataStore); 2634 2664 const mockPreferencesProvider = { 2635 2665 requirePreferences: () => Preferences.createLoggedOutPreferences(), 2636 2666 }; 2637 - dataStore.addShowLessInteraction({ item: "existing", event: "x" }); 2667 + dataStore.$showLessInteractions.set([{ item: "existing", event: "x" }]); 2638 2668 const mutations = new Mutations( 2639 2669 { sendInteractions: async () => {} }, 2640 2670 dataStore, ··· 2644 2674 2645 2675 await mutations.sendShowLessInteraction(postURI, feedContext, feedProxyUrl); 2646 2676 2647 - const stored = dataStore.getShowLessInteractions(); 2677 + const stored = dataStore.$showLessInteractions.get(); 2648 2678 assertEquals(stored.length, 2); 2649 2679 assertEquals(stored[1].item, postURI); 2650 2680 }); ··· 2657 2687 2658 2688 it("should append the interaction to the dataStore (empty list branch)", async () => { 2659 2689 const dataStore = new DataStore(); 2660 - const patchStore = new PatchStore(); 2690 + const patchStore = new PatchStore(dataStore); 2661 2691 const mockPreferencesProvider = { 2662 2692 requirePreferences: () => Preferences.createLoggedOutPreferences(), 2663 2693 }; ··· 2675 2705 2676 2706 await mutations.sendShowMoreInteraction(postURI, feedContext, feedProxyUrl); 2677 2707 2678 - const stored = dataStore.getShowMoreInteractions(); 2708 + const stored = dataStore.$showMoreInteractions.get(); 2679 2709 assertEquals(stored.length, 1); 2680 2710 assertEquals(stored[0].item, postURI); 2681 2711 assertEquals(stored[0].event, "app.bsky.feed.defs#requestMore"); ··· 2686 2716 2687 2717 it("should append to an existing list (non-empty branch)", async () => { 2688 2718 const dataStore = new DataStore(); 2689 - const patchStore = new PatchStore(); 2719 + const patchStore = new PatchStore(dataStore); 2690 2720 const mockPreferencesProvider = { 2691 2721 requirePreferences: () => Preferences.createLoggedOutPreferences(), 2692 2722 }; 2693 - dataStore.addShowMoreInteraction({ item: "existing", event: "x" }); 2723 + dataStore.$showMoreInteractions.set([{ item: "existing", event: "x" }]); 2694 2724 const mutations = new Mutations( 2695 2725 { sendInteractions: async () => {} }, 2696 2726 dataStore, ··· 2700 2730 2701 2731 await mutations.sendShowMoreInteraction(postURI, feedContext, feedProxyUrl); 2702 2732 2703 - const stored = dataStore.getShowMoreInteractions(); 2733 + const stored = dataStore.$showMoreInteractions.get(); 2704 2734 assertEquals(stored.length, 2); 2705 2735 assertEquals(stored[1].item, postURI); 2706 2736 });
+19 -94
tests/unit/specs/dataLayer/patchStore.test.js
··· 1 1 import { TestSuite } from "../../testSuite.js"; 2 2 import { assert, assertEquals } from "../../testHelpers.js"; 3 3 import { PatchStore } from "/js/dataLayer/patchStore.js"; 4 - import { EventEmitter } from "/js/eventEmitter.js"; 5 4 6 5 const t = new TestSuite("PatchStore"); 7 6 8 - t.describe("Event bus", (it) => { 9 - const postURI = "at://did:test/app.bsky.feed.post/test"; 10 - 11 - it("emits post:${uri} on addPostPatch", () => { 12 - const events = new EventEmitter(); 13 - const patchStore = new PatchStore(events); 14 - let count = 0; 15 - events.on(`post:${postURI}`, () => { 16 - count += 1; 17 - }); 18 - patchStore.addPostPatch(postURI, { type: "addLike" }); 19 - assertEquals(count, 1); 20 - }); 21 - 22 - it("emits post:${uri} on removePostPatch", () => { 23 - const events = new EventEmitter(); 24 - const patchStore = new PatchStore(events); 25 - const patchId = patchStore.addPostPatch(postURI, { type: "addLike" }); 26 - let count = 0; 27 - events.on(`post:${postURI}`, () => { 28 - count += 1; 29 - }); 30 - patchStore.removePostPatch(postURI, patchId); 31 - assertEquals(count, 1); 32 - }); 33 - 34 - it("emits profile:${did} on addProfilePatch", () => { 35 - const events = new EventEmitter(); 36 - const patchStore = new PatchStore(events); 37 - const profileDID = "did:test:abc"; 38 - let count = 0; 39 - events.on(`profile:${profileDID}`, () => { 40 - count += 1; 41 - }); 42 - patchStore.addProfilePatch(profileDID, { type: "followProfile" }); 43 - assertEquals(count, 1); 44 - }); 45 - 46 - it("emits profile:${did} on removeProfilePatch", () => { 47 - const events = new EventEmitter(); 48 - const patchStore = new PatchStore(events); 49 - const profileDID = "did:test:abc"; 50 - const patchId = patchStore.addProfilePatch(profileDID, { 51 - type: "followProfile", 52 - }); 53 - let count = 0; 54 - events.on(`profile:${profileDID}`, () => { 55 - count += 1; 56 - }); 57 - patchStore.removeProfilePatch(profileDID, patchId); 58 - assertEquals(count, 1); 59 - }); 60 - 61 - it("emits preferences:changed on add/removePreferencePatch", () => { 62 - const events = new EventEmitter(); 63 - const patchStore = new PatchStore(events); 64 - let count = 0; 65 - events.on("preferences:changed", () => { 66 - count += 1; 67 - }); 68 - const patchId = patchStore.addPreferencePatch({ 69 - type: "pinFeed", 70 - feedUri: "at://feed", 71 - }); 72 - patchStore.removePreferencePatch(patchId); 73 - assertEquals(count, 2); 74 - }); 75 - 76 - it("works without an event bus (backward compatible)", () => { 77 - const patchStore = new PatchStore(); 78 - let threw = false; 79 - try { 80 - patchStore.addPostPatch("at://x", { type: "addLike" }); 81 - patchStore.addPreferencePatch({ type: "pinFeed", feedUri: "at://f" }); 82 - } catch (e) { 83 - threw = true; 84 - } 85 - assertEquals(threw, false); 86 - }); 87 - }); 7 + // applyPostPatches now requires the patches array explicitly. This helper 8 + // fetches the current patches for a post URI and applies them. 9 + function applyPostPatches(patchStore, post) { 10 + const patches = patchStore.$postPatches.get(post.uri).get() || []; 11 + return patchStore.applyPostPatches(post, patches); 12 + } 88 13 89 14 t.describe("Post Patches - Patch Management", (it) => { 90 15 const postURI = "at://did:test/app.bsky.feed.post/test"; ··· 113 38 const patchId = patchStore.addPostPatch(postURI, { type: "addLike" }); 114 39 115 40 // Verify patch exists 116 - const patchedPost = patchStore.applyPostPatches(basePost); 41 + const patchedPost = applyPostPatches(patchStore, basePost); 117 42 assertEquals(patchedPost.viewer.like, "fake like"); 118 43 119 44 // Remove patch 120 45 patchStore.removePostPatch(postURI, patchId); 121 46 122 47 // Verify patch is removed 123 - const unpatchedPost = patchStore.applyPostPatches(basePost); 48 + const unpatchedPost = applyPostPatches(patchStore, basePost); 124 49 assertEquals(unpatchedPost.viewer.like, null); 125 50 }); 126 51 ··· 148 73 it("should apply addLike patch correctly", () => { 149 74 const patchStore = new PatchStore(); 150 75 patchStore.addPostPatch(postURI, { type: "addLike" }); 151 - const result = patchStore.applyPostPatches(basePost); 76 + const result = applyPostPatches(patchStore, basePost); 152 77 153 78 assertEquals(result.viewer.like, "fake like"); 154 79 assertEquals(result.likeCount, 6); ··· 164 89 }; 165 90 166 91 patchStore.addPostPatch(postURI, { type: "removeLike" }); 167 - const result = patchStore.applyPostPatches(likedPost); 92 + const result = applyPostPatches(patchStore, likedPost); 168 93 169 94 assertEquals(result.viewer.like, null); 170 95 assertEquals(result.likeCount, 5); ··· 176 101 patchStore.addPostPatch(postURI, { type: "addLike" }); 177 102 patchStore.addPostPatch(postURI, { type: "removeLike" }); 178 103 179 - const result = patchStore.applyPostPatches(basePost); 104 + const result = applyPostPatches(patchStore, basePost); 180 105 181 106 assertEquals(result.viewer.like, null); 182 107 assertEquals(result.likeCount, 5); // +1 -1 = 0, so 5 + 0 = 5 ··· 184 109 185 110 it("should preserve original post when no patches exist", () => { 186 111 const patchStore = new PatchStore(); 187 - const result = patchStore.applyPostPatches(basePost); 112 + const result = applyPostPatches(patchStore, basePost); 188 113 assertEquals(result, basePost); 189 114 assert(result !== basePost); // Should be a copy 190 115 }); ··· 205 130 let errorThrown = false; 206 131 let errorMessage = ""; 207 132 try { 208 - patchStore.applyPostPatches(basePost); 133 + applyPostPatches(patchStore, basePost); 209 134 } catch (e) { 210 135 errorThrown = true; 211 136 errorMessage = e.message; ··· 338 263 339 264 patchStore.addPostPatch(post1URI, { type: "addLike" }); 340 265 341 - const result1 = patchStore.applyPostPatches(basePost1); 342 - const result2 = patchStore.applyPostPatches(basePost2); 266 + const result1 = applyPostPatches(patchStore, basePost1); 267 + const result2 = applyPostPatches(patchStore, basePost2); 343 268 344 269 assertEquals(result1.likeCount, 6); 345 270 assertEquals(result2.likeCount, 10); // Unchanged ··· 480 405 did: "did:test2", 481 406 }); 482 407 483 - assertEquals(patchStore._getPreferencePatches().length, 2); 408 + assertEquals(patchStore.$preferencePatches.get().length, 2); 484 409 485 410 patchStore.removePreferencePatch(patchId1); 486 - assertEquals(patchStore._getPreferencePatches().length, 1); 411 + assertEquals(patchStore.$preferencePatches.get().length, 1); 487 412 assertEquals( 488 - patchStore._getPreferencePatches()[0].body.type, 413 + patchStore.$preferencePatches.get()[0].body.type, 489 414 "unsubscribeLabeler", 490 415 ); 491 416 492 417 patchStore.removePreferencePatch(patchId2); 493 - assertEquals(patchStore._getPreferencePatches().length, 0); 418 + assertEquals(patchStore.$preferencePatches.get().length, 0); 494 419 }); 495 420 496 421 it("should generate unique IDs for preference patches", () => {
+188 -150
tests/unit/specs/dataLayer/requests.test.js
··· 4 4 import { DataStore } from "/js/dataLayer/dataStore.js"; 5 5 import { Preferences } from "/js/preferences.js"; 6 6 import { ApiError } from "/js/api.js"; 7 + import { SignalMap } from "/js/utils.js"; 7 8 8 9 const t = new TestSuite("Requests"); 9 10 10 11 const stubConstellation = { getLinks: async () => [] }; 11 - const stubPluginService = { getFilteredFeedItems: async () => ({}) }; 12 + const stubPluginService = { 13 + $pluginFilteredFeedItems: new SignalMap(), 14 + refreshFiltersForFeed: async () => {}, 15 + }; 12 16 13 17 function createRequests(api, dataStore, preferencesProvider) { 14 18 return new Requests(api, dataStore, preferencesProvider, stubPluginService, { ··· 55 59 await requests.loadPostThread(postURI); 56 60 57 61 // Check thread was stored 58 - assertEquals(dataStore.getPostThread(postURI), mockPostThread); 62 + assertEquals(dataStore.$postThreads.get(postURI).get(), mockPostThread); 59 63 60 64 // Check postThreadOther was stored 61 - assertEquals(dataStore.getPostThreadOther(postURI), mockPostThreadOther); 65 + assertEquals( 66 + dataStore.$postThreadOthers.get(postURI).get(), 67 + mockPostThreadOther, 68 + ); 62 69 63 70 // Check posts were stored 64 - assertEquals(dataStore.getPost(postURI), normalizedPosts[0]); 65 - assertEquals(dataStore.getPost("reply1"), normalizedPosts[1]); 71 + assertEquals(dataStore.$posts.get(postURI).get(), normalizedPosts[0]); 72 + assertEquals(dataStore.$posts.get("reply1").get(), normalizedPosts[1]); 66 73 }); 67 74 68 75 it("should handle empty post thread", async () => { ··· 90 97 91 98 await requests.loadPostThread(postURI); 92 99 93 - assertEquals(dataStore.getPostThread(postURI), emptyPostThread); 94 - assertEquals(dataStore.getPostThreadOther(postURI), []); 95 - assertEquals(dataStore.getPost(postURI), normalizedPosts[0]); 100 + assertEquals(dataStore.$postThreads.get(postURI).get(), emptyPostThread); 101 + assertEquals(dataStore.$postThreadOthers.get(postURI).get(), []); 102 + assertEquals(dataStore.$posts.get(postURI).get(), normalizedPosts[0]); 96 103 }); 97 104 }); 98 105 ··· 127 134 await requests.loadNextFeedPage(feedURI); 128 135 129 136 // Check feed was stored 130 - assertEquals(dataStore.getFeed(feedURI), mockFeed); 137 + assertEquals(dataStore.$feeds.get(feedURI).get(), mockFeed); 131 138 132 139 // Check posts were stored 133 - assertEquals(dataStore.getPost("post1"), normalizedPosts[0]); 134 - assertEquals(dataStore.getPost("post2"), normalizedPosts[1]); 140 + assertEquals(dataStore.$posts.get("post1").get(), normalizedPosts[0]); 141 + assertEquals(dataStore.$posts.get("post2").get(), normalizedPosts[1]); 135 142 }); 136 143 137 144 it("should append to existing feed", async () => { ··· 142 149 feed: [{ post: { uri: "post1" } }], 143 150 cursor: "cursor1", 144 151 }; 145 - dataStore.setFeed(feedURI, existingFeed); 152 + dataStore.$feeds.set(feedURI, existingFeed); 146 153 147 154 // New page 148 155 const newPage = { ··· 171 178 await requests.loadNextFeedPage(feedURI); 172 179 173 180 // Check feed was appended 174 - const storedFeed = dataStore.getFeed(feedURI); 181 + const storedFeed = dataStore.$feeds.get(feedURI).get(); 175 182 assertEquals(storedFeed.feed.length, 3); 176 183 assertEquals(storedFeed.feed[0], { post: { uri: "post1" } }); 177 184 assertEquals(storedFeed.feed[1], { post: { uri: "post2" } }); ··· 179 186 assertEquals(storedFeed.cursor, "cursor2"); 180 187 181 188 // Check new posts were stored 182 - assertEquals(dataStore.getPost("post2"), normalizedPosts[0]); 183 - assertEquals(dataStore.getPost("post3"), normalizedPosts[1]); 189 + assertEquals(dataStore.$posts.get("post2").get(), normalizedPosts[0]); 190 + assertEquals(dataStore.$posts.get("post3").get(), normalizedPosts[1]); 184 191 }); 185 192 186 193 it("should handle empty feed", async () => { ··· 205 212 206 213 await requests.loadNextFeedPage(feedURI); 207 214 208 - assertEquals(dataStore.getFeed(feedURI), emptyFeed); 215 + assertEquals(dataStore.$feeds.get(feedURI).get(), emptyFeed); 209 216 }); 210 217 211 218 it("should handle feed with reply context", async () => { ··· 244 251 245 252 await requests.loadNextFeedPage(feedURI); 246 253 247 - assertEquals(dataStore.getFeed(feedURI), feedWithReplies); 248 - assertEquals(dataStore.getPost("post1").uri, normalizedPosts[0].uri); 249 - assertEquals(dataStore.getPost("root1").uri, normalizedPosts[1].uri); 250 - assertEquals(dataStore.getPost("parent1").uri, normalizedPosts[2].uri); 254 + assertEquals(dataStore.$feeds.get(feedURI).get(), feedWithReplies); 255 + assertEquals( 256 + dataStore.$posts.get("post1").get().uri, 257 + normalizedPosts[0].uri, 258 + ); 259 + assertEquals( 260 + dataStore.$posts.get("root1").get().uri, 261 + normalizedPosts[1].uri, 262 + ); 263 + assertEquals( 264 + dataStore.$posts.get("parent1").get().uri, 265 + normalizedPosts[2].uri, 266 + ); 251 267 }); 252 268 }); 253 269 254 270 t.describe("loadPluginFilteredFeedItems", (it) => { 255 271 const feedURI = "at://did:test/app.bsky.feed.generator/test"; 256 272 273 + // Stub pluginService that mimics PluginService.refreshFiltersForFeed, 274 + // storing results in $pluginFilteredFeedItems so tests can verify them. 257 275 function makePluginService(getFilteredFeedItems) { 258 - return { getFilteredFeedItems }; 276 + const $pluginFilteredFeedItems = new SignalMap(); 277 + return { 278 + $pluginFilteredFeedItems, 279 + refreshFiltersForFeed: async (uri, feed, { reload = false } = {}) => { 280 + const filtered = await getFilteredFeedItems(uri, feed); 281 + const existing = reload 282 + ? {} 283 + : ($pluginFilteredFeedItems.get(uri).get() ?? {}); 284 + $pluginFilteredFeedItems.set(uri, { ...existing, ...filtered }); 285 + }, 286 + }; 259 287 } 260 288 261 289 function createRequestsWithPluginService(dataStore, pluginService) { ··· 280 308 await requests.loadPluginFilteredFeedItems(feedURI); 281 309 282 310 assertEquals(invoked, false); 283 - assertEquals(dataStore.getPluginFilteredFeedItems(feedURI), undefined); 311 + assertEquals( 312 + pluginService.$pluginFilteredFeedItems.get(feedURI).get(), 313 + null, 314 + ); 284 315 }); 285 316 286 317 it("should pass the feed to the plugin service and store results", async () => { ··· 289 320 feed: [{ post: { uri: "p1" } }], 290 321 cursor: "c1", 291 322 }; 292 - dataStore.setFeed(feedURI, storedFeed); 323 + dataStore.$feeds.set(feedURI, storedFeed); 293 324 294 325 let capturedUri = null; 295 326 let capturedFeed = null; ··· 304 335 305 336 assertEquals(capturedUri, feedURI); 306 337 assertEquals(capturedFeed, storedFeed); 307 - assertEquals(dataStore.getPluginFilteredFeedItems(feedURI), { 338 + assertEquals(pluginService.$pluginFilteredFeedItems.get(feedURI).get(), { 308 339 p1: { hidden: true }, 309 340 }); 310 341 }); 311 342 312 343 it("should merge with existing filtered items by default", async () => { 313 344 const dataStore = new DataStore(); 314 - dataStore.setFeed(feedURI, { feed: [], cursor: null }); 315 - dataStore.setPluginFilteredFeedItems(feedURI, { 316 - p1: { hidden: true }, 317 - p2: { hidden: true }, 318 - }); 319 - 345 + dataStore.$feeds.set(feedURI, { feed: [], cursor: null }); 320 346 const pluginService = makePluginService(async () => ({ 321 347 p2: { hidden: false }, 322 348 p3: { hidden: true }, 323 349 })); 350 + pluginService.$pluginFilteredFeedItems.set(feedURI, { 351 + p1: { hidden: true }, 352 + p2: { hidden: true }, 353 + }); 324 354 const requests = createRequestsWithPluginService(dataStore, pluginService); 325 355 326 356 await requests.loadPluginFilteredFeedItems(feedURI); 327 357 328 - assertEquals(dataStore.getPluginFilteredFeedItems(feedURI), { 358 + assertEquals(pluginService.$pluginFilteredFeedItems.get(feedURI).get(), { 329 359 p1: { hidden: true }, 330 360 p2: { hidden: false }, 331 361 p3: { hidden: true }, ··· 334 364 335 365 it("should replace existing filtered items when reload is true", async () => { 336 366 const dataStore = new DataStore(); 337 - dataStore.setFeed(feedURI, { feed: [], cursor: null }); 338 - dataStore.setPluginFilteredFeedItems(feedURI, { 339 - p1: { hidden: true }, 340 - p2: { hidden: true }, 341 - }); 342 - 367 + dataStore.$feeds.set(feedURI, { feed: [], cursor: null }); 343 368 const pluginService = makePluginService(async () => ({ 344 369 p3: { hidden: true }, 345 370 })); 371 + pluginService.$pluginFilteredFeedItems.set(feedURI, { 372 + p1: { hidden: true }, 373 + p2: { hidden: true }, 374 + }); 346 375 const requests = createRequestsWithPluginService(dataStore, pluginService); 347 376 348 377 await requests.loadPluginFilteredFeedItems(feedURI, { reload: true }); 349 378 350 - assertEquals(dataStore.getPluginFilteredFeedItems(feedURI), { 379 + assertEquals(pluginService.$pluginFilteredFeedItems.get(feedURI).get(), { 351 380 p3: { hidden: true }, 352 381 }); 353 382 }); ··· 382 411 await requests.loadProfile(profileDID); 383 412 384 413 // Check profile was stored 385 - assertEquals(dataStore.getProfile(profileDID), mockProfile); 414 + assertEquals(dataStore.$profiles.get(profileDID).get(), mockProfile); 386 415 }); 387 416 388 417 it("should handle profile updates", async () => { ··· 410 439 411 440 await requests.loadProfile(profileDID); 412 441 413 - assertEquals(dataStore.getProfile(profileDID), initialProfile); 442 + assertEquals(dataStore.$profiles.get(profileDID).get(), initialProfile); 414 443 415 444 // Load updated profile 416 445 const updatedProfile = { ··· 423 452 424 453 await requests.loadProfile(profileDID); 425 454 426 - assertEquals(dataStore.getProfile(profileDID), updatedProfile); 455 + assertEquals(dataStore.$profiles.get(profileDID).get(), updatedProfile); 427 456 }); 428 457 }); 429 458 ··· 453 482 await requests.loadPosts(["at://a", "at://b"]); 454 483 455 484 assertEquals(calledWith, ["at://a", "at://b"]); 456 - assertEquals(dataStore.getPost("at://a"), postA); 457 - assertEquals(dataStore.getPost("at://b"), postB); 485 + assertEquals(dataStore.$posts.get("at://a").get(), postA); 486 + assertEquals(dataStore.$posts.get("at://b").get(), postB); 458 487 }); 459 488 460 489 it("does not call api when uris is empty", async () => { ··· 512 541 513 542 await requests.loadLabelerInfo(labelerDid); 514 543 515 - assertEquals(dataStore.getLabelerInfo(labelerDid), mockLabelerInfo); 544 + assertEquals(dataStore.$labelerInfo.get(labelerDid).get(), mockLabelerInfo); 516 545 }); 517 546 518 547 it("should call api.getLabeler with correct DID", async () => { ··· 568 597 ); 569 598 570 599 await requests.loadLabelerInfo(labelerDid); 571 - assertEquals(dataStore.getLabelerInfo(labelerDid), initialInfo); 600 + assertEquals(dataStore.$labelerInfo.get(labelerDid).get(), initialInfo); 572 601 573 602 currentInfo = updatedInfo; 574 603 await requests.loadLabelerInfo(labelerDid); 575 - assertEquals(dataStore.getLabelerInfo(labelerDid), updatedInfo); 604 + assertEquals(dataStore.$labelerInfo.get(labelerDid).get(), updatedInfo); 576 605 }); 577 606 }); 578 607 ··· 595 624 596 625 await requests.loadMutedProfiles(); 597 626 598 - assertEquals(dataStore.getMutedProfiles(), res); 627 + assertEquals(dataStore.$mutedProfiles.get(), res); 599 628 }); 600 629 601 630 it("should append paginated muted profiles when cursor is provided", async () => { 602 631 const dataStore = new DataStore(); 603 - dataStore.setMutedProfiles({ 632 + dataStore.$mutedProfiles.set({ 604 633 mutes: [{ did: "did:plc:a" }], 605 634 cursor: "page2", 606 635 }); ··· 622 651 623 652 await requests.loadMutedProfiles({ cursor: "page2" }); 624 653 625 - const stored = dataStore.getMutedProfiles(); 654 + const stored = dataStore.$mutedProfiles.get(); 626 655 assertEquals(stored.mutes.length, 2); 627 656 assertEquals(stored.mutes[0].did, "did:plc:a"); 628 657 assertEquals(stored.mutes[1].did, "did:plc:b"); ··· 637 666 }, 638 667 }; 639 668 const dataStore = new DataStore(); 640 - dataStore.setMutedProfiles({ mutes: [], cursor: "abc" }); 669 + dataStore.$mutedProfiles.set({ mutes: [], cursor: "abc" }); 641 670 const mockPreferencesProvider = { 642 671 requirePreferences: () => Preferences.createLoggedOutPreferences(), 643 672 }; ··· 672 701 673 702 await requests.loadBlockedProfiles(); 674 703 675 - assertEquals(dataStore.getBlockedProfiles(), res); 704 + assertEquals(dataStore.$blockedProfiles.get(), res); 676 705 }); 677 706 678 707 it("should append paginated blocked profiles when cursor is provided", async () => { 679 708 const dataStore = new DataStore(); 680 - dataStore.setBlockedProfiles({ 709 + dataStore.$blockedProfiles.set({ 681 710 blocks: [{ did: "did:plc:a" }], 682 711 cursor: "page2", 683 712 }); ··· 692 721 693 722 await requests.loadBlockedProfiles({ cursor: "page2" }); 694 723 695 - const stored = dataStore.getBlockedProfiles(); 724 + const stored = dataStore.$blockedProfiles.get(); 696 725 assertEquals(stored.blocks.length, 2); 697 726 assertEquals(stored.blocks[0].did, "did:plc:a"); 698 727 assertEquals(stored.blocks[1].did, "did:plc:b"); ··· 707 736 }, 708 737 }; 709 738 const dataStore = new DataStore(); 710 - dataStore.setBlockedProfiles({ blocks: [], cursor: "abc" }); 739 + dataStore.$blockedProfiles.set({ blocks: [], cursor: "abc" }); 711 740 const requests = makeRequests(mockApi, dataStore); 712 741 713 742 await requests.loadBlockedProfiles({ cursor: "abc" }); ··· 735 764 assertEquals(capturedParams.filter, "posts_and_author_threads"); 736 765 assertEquals(capturedParams.includePins, true); 737 766 assertEquals(capturedParams.cursor, ""); 738 - assertEquals(dataStore.getAuthorFeed(`${did}-posts`).feed.length, 1); 767 + assertEquals( 768 + dataStore.$authorFeeds.get(`${did}-posts`).get().feed.length, 769 + 1, 770 + ); 739 771 }); 740 772 741 773 it("should use posts_with_replies filter for replies feedType", async () => { ··· 794 826 it("should append to existing feed", async () => { 795 827 const feedURI = `${did}-posts`; 796 828 const dataStore = new DataStore(); 797 - dataStore.setAuthorFeed(feedURI, { 829 + dataStore.$authorFeeds.set(feedURI, { 798 830 feed: [{ post: { uri: "old1" } }], 799 831 cursor: "c1", 800 832 }); ··· 809 841 810 842 await requests.loadNextAuthorFeedPage(did, "posts"); 811 843 812 - const stored = dataStore.getAuthorFeed(feedURI); 844 + const stored = dataStore.$authorFeeds.get(feedURI).get(); 813 845 assertEquals(stored.feed.length, 2); 814 846 assertEquals(stored.feed[0].post.uri, "old1"); 815 847 assertEquals(stored.feed[1].post.uri, "new1"); ··· 819 851 it("should reset cursor and replace feed on reload", async () => { 820 852 const feedURI = `${did}-posts`; 821 853 const dataStore = new DataStore(); 822 - dataStore.setAuthorFeed(feedURI, { 854 + dataStore.$authorFeeds.set(feedURI, { 823 855 feed: [{ post: { uri: "old1" } }], 824 856 cursor: "c1", 825 857 }); ··· 836 868 await requests.loadNextAuthorFeedPage(did, "posts", { reload: true }); 837 869 838 870 assertEquals(capturedCursor, ""); 839 - const stored = dataStore.getAuthorFeed(feedURI); 871 + const stored = dataStore.$authorFeeds.get(feedURI).get(); 840 872 assertEquals(stored.feed.length, 1); 841 873 assertEquals(stored.feed[0].post.uri, "new1"); 842 874 }); ··· 858 890 t.describe("loadPostSearch", (it) => { 859 891 it("should clear results when query is empty", async () => { 860 892 const dataStore = new DataStore(); 861 - dataStore.setPostSearchResults({ posts: [{ uri: "p1" }], cursor: "c1" }); 893 + dataStore.$postSearchResults.set({ posts: [{ uri: "p1" }], cursor: "c1" }); 862 894 const mockApi = { searchPosts: async () => ({ posts: [], cursor: null }) }; 863 895 const requests = makeRequests(mockApi, dataStore); 864 896 865 897 await requests.loadPostSearch(""); 866 898 867 - assertEquals(dataStore.getPostSearchResults(), null); 899 + assertEquals(dataStore.$postSearchResults.get(), null); 868 900 }); 869 901 870 902 it("should store results from a fresh search", async () => { ··· 879 911 880 912 await requests.loadPostSearch("hello"); 881 913 882 - const stored = dataStore.getPostSearchResults(); 914 + const stored = dataStore.$postSearchResults.get(); 883 915 assertEquals(stored.posts.length, 1); 884 916 assertEquals(stored.cursor, "next"); 885 917 }); ··· 909 941 resolveFirst(); 910 942 await firstCall; 911 943 912 - const stored = dataStore.getPostSearchResults(); 944 + const stored = dataStore.$postSearchResults.get(); 913 945 assertEquals(stored.posts[0].uri, "fresh"); 914 946 assertEquals(stored.cursor, "fresh"); 915 947 }); 916 948 917 949 it("should append when cursor is provided and existing results present", async () => { 918 950 const dataStore = new DataStore(); 919 - dataStore.setPostSearchResults({ 951 + dataStore.$postSearchResults.set({ 920 952 posts: [{ uri: "p1", record: {} }], 921 953 cursor: "c1", 922 954 }); ··· 930 962 931 963 await requests.loadPostSearch("hello", { cursor: "c1" }); 932 964 933 - const stored = dataStore.getPostSearchResults(); 965 + const stored = dataStore.$postSearchResults.get(); 934 966 assertEquals(stored.posts.length, 2); 935 967 assertEquals(stored.posts[1].uri, "p2"); 936 968 assertEquals(stored.cursor, "c2"); ··· 940 972 t.describe("loadProfileSearch", (it) => { 941 973 it("should clear results when query is empty", async () => { 942 974 const dataStore = new DataStore(); 943 - dataStore.setProfileSearchResults({ actors: [{ did: "x" }], cursor: "c" }); 975 + dataStore.$profileSearchResults.set({ 976 + actors: [{ did: "x" }], 977 + cursor: "c", 978 + }); 944 979 const mockApi = { 945 980 searchProfiles: async () => ({ actors: [], cursor: null }), 946 981 }; ··· 948 983 949 984 await requests.loadProfileSearch(""); 950 985 951 - assertEquals(dataStore.getProfileSearchResults(), null); 986 + assertEquals(dataStore.$profileSearchResults.get(), null); 952 987 }); 953 988 954 989 it("should store actors from a fresh search", async () => { ··· 963 998 964 999 await requests.loadProfileSearch("alice"); 965 1000 966 - const stored = dataStore.getProfileSearchResults(); 1001 + const stored = dataStore.$profileSearchResults.get(); 967 1002 assertEquals(stored.actors.length, 1); 968 1003 assertEquals(stored.cursor, "next"); 969 1004 }); ··· 993 1028 resolveFirst(); 994 1029 await firstCall; 995 1030 996 - const stored = dataStore.getProfileSearchResults(); 1031 + const stored = dataStore.$profileSearchResults.get(); 997 1032 assertEquals(stored.actors[0].did, "fresh"); 998 1033 }); 999 1034 1000 1035 it("should append when cursor is provided", async () => { 1001 1036 const dataStore = new DataStore(); 1002 - dataStore.setProfileSearchResults({ 1037 + dataStore.$profileSearchResults.set({ 1003 1038 actors: [{ did: "did:plc:a" }], 1004 1039 cursor: "c1", 1005 1040 }); ··· 1013 1048 1014 1049 await requests.loadProfileSearch("query", { cursor: "c1" }); 1015 1050 1016 - const stored = dataStore.getProfileSearchResults(); 1051 + const stored = dataStore.$profileSearchResults.get(); 1017 1052 assertEquals(stored.actors.length, 2); 1018 1053 assertEquals(stored.cursor, "c2"); 1019 1054 }); ··· 1022 1057 t.describe("loadFeedSearch", (it) => { 1023 1058 it("should clear results when query is empty", async () => { 1024 1059 const dataStore = new DataStore(); 1025 - dataStore.setFeedSearchResults({ feeds: [{ uri: "f1" }], cursor: "c" }); 1060 + dataStore.$feedSearchResults.set({ feeds: [{ uri: "f1" }], cursor: "c" }); 1026 1061 const mockApi = { 1027 1062 searchFeedGenerators: async () => ({ feeds: [], cursor: null }), 1028 1063 }; ··· 1030 1065 1031 1066 await requests.loadFeedSearch(""); 1032 1067 1033 - assertEquals(dataStore.getFeedSearchResults(), null); 1068 + assertEquals(dataStore.$feedSearchResults.get(), null); 1034 1069 }); 1035 1070 1036 1071 it("should store feeds and cache feed generators", async () => { ··· 1045 1080 1046 1081 await requests.loadFeedSearch("news"); 1047 1082 1048 - const stored = dataStore.getFeedSearchResults(); 1083 + const stored = dataStore.$feedSearchResults.get(); 1049 1084 assertEquals(stored.feeds.length, 1); 1050 - assertEquals(dataStore.getFeedGenerator("f1").displayName, "Feed One"); 1085 + assertEquals( 1086 + dataStore.$feedGenerators.get("f1").get().displayName, 1087 + "Feed One", 1088 + ); 1051 1089 }); 1052 1090 1053 1091 it("should discard stale responses", async () => { ··· 1075 1113 resolveFirst(); 1076 1114 await firstCall; 1077 1115 1078 - const stored = dataStore.getFeedSearchResults(); 1116 + const stored = dataStore.$feedSearchResults.get(); 1079 1117 assertEquals(stored.feeds[0].uri, "fresh"); 1080 1118 }); 1081 1119 1082 1120 it("should append when cursor is provided", async () => { 1083 1121 const dataStore = new DataStore(); 1084 - dataStore.setFeedSearchResults({ 1122 + dataStore.$feedSearchResults.set({ 1085 1123 feeds: [{ uri: "f1" }], 1086 1124 cursor: "c1", 1087 1125 }); ··· 1095 1133 1096 1134 await requests.loadFeedSearch("query", { cursor: "c1" }); 1097 1135 1098 - const stored = dataStore.getFeedSearchResults(); 1136 + const stored = dataStore.$feedSearchResults.get(); 1099 1137 assertEquals(stored.feeds.length, 2); 1100 1138 assertEquals(stored.cursor, "c2"); 1101 1139 }); ··· 1115 1153 1116 1154 await requests.loadNotifications(); 1117 1155 1118 - assertEquals(dataStore.getNotifications().length, 1); 1119 - assertEquals(dataStore.getNotificationCursor(), "next"); 1156 + assertEquals(dataStore.$notifications.get().length, 1); 1157 + assertEquals(dataStore.$notificationCursor.get(), "next"); 1120 1158 }); 1121 1159 1122 1160 it("should append when cursor matches previous", async () => { 1123 1161 const dataStore = new DataStore(); 1124 - dataStore.setNotifications([{ reason: "like", uri: "n1" }]); 1125 - dataStore.setNotificationCursor("page2"); 1162 + dataStore.$notifications.set([{ reason: "like", uri: "n1" }]); 1163 + dataStore.$notificationCursor.set("page2"); 1126 1164 1127 1165 let capturedCursor; 1128 1166 const mockApi = { ··· 1140 1178 await requests.loadNotifications(); 1141 1179 1142 1180 assertEquals(capturedCursor, "page2"); 1143 - assertEquals(dataStore.getNotifications().length, 2); 1144 - assertEquals(dataStore.getNotificationCursor(), "page3"); 1181 + assertEquals(dataStore.$notifications.get().length, 2); 1182 + assertEquals(dataStore.$notificationCursor.get(), "page3"); 1145 1183 }); 1146 1184 1147 1185 it("should reset on reload", async () => { 1148 1186 const dataStore = new DataStore(); 1149 - dataStore.setNotifications([{ reason: "like", uri: "n1" }]); 1150 - dataStore.setNotificationCursor("page2"); 1187 + dataStore.$notifications.set([{ reason: "like", uri: "n1" }]); 1188 + dataStore.$notificationCursor.set("page2"); 1151 1189 1152 1190 let capturedCursor; 1153 1191 const mockApi = { ··· 1165 1203 await requests.loadNotifications({ reload: true }); 1166 1204 1167 1205 assertEquals(capturedCursor, ""); 1168 - const stored = dataStore.getNotifications(); 1206 + const stored = dataStore.$notifications.get(); 1169 1207 assertEquals(stored.length, 1); 1170 1208 assertEquals(stored[0].uri, "n2"); 1171 - assertEquals(dataStore.getNotificationCursor(), "fresh"); 1209 + assertEquals(dataStore.$notificationCursor.get(), "fresh"); 1172 1210 }); 1173 1211 }); 1174 1212 ··· 1191 1229 await requests.loadMentionNotifications(); 1192 1230 1193 1231 assertEquals(capturedReasons, ["mention", "reply", "quote"]); 1194 - assertEquals(dataStore.getMentionNotifications().length, 1); 1195 - assertEquals(dataStore.getMentionNotificationCursor(), "next"); 1232 + assertEquals(dataStore.$mentionNotifications.get().length, 1); 1233 + assertEquals(dataStore.$mentionNotificationCursor.get(), "next"); 1196 1234 }); 1197 1235 1198 1236 it("should append when cursor matches previous", async () => { 1199 1237 const dataStore = new DataStore(); 1200 - dataStore.setMentionNotifications([{ reason: "mention", uri: "n1" }]); 1201 - dataStore.setMentionNotificationCursor("page2"); 1238 + dataStore.$mentionNotifications.set([{ reason: "mention", uri: "n1" }]); 1239 + dataStore.$mentionNotificationCursor.set("page2"); 1202 1240 1203 1241 const mockApi = { 1204 1242 getNotifications: async () => ({ ··· 1211 1249 1212 1250 await requests.loadMentionNotifications(); 1213 1251 1214 - assertEquals(dataStore.getMentionNotifications().length, 2); 1215 - assertEquals(dataStore.getMentionNotificationCursor(), "page3"); 1252 + assertEquals(dataStore.$mentionNotifications.get().length, 2); 1253 + assertEquals(dataStore.$mentionNotificationCursor.get(), "page3"); 1216 1254 }); 1217 1255 1218 1256 it("should reset on reload", async () => { 1219 1257 const dataStore = new DataStore(); 1220 - dataStore.setMentionNotifications([{ reason: "mention", uri: "n1" }]); 1221 - dataStore.setMentionNotificationCursor("page2"); 1258 + dataStore.$mentionNotifications.set([{ reason: "mention", uri: "n1" }]); 1259 + dataStore.$mentionNotificationCursor.set("page2"); 1222 1260 1223 1261 const mockApi = { 1224 1262 getNotifications: async () => ({ ··· 1231 1269 1232 1270 await requests.loadMentionNotifications({ reload: true }); 1233 1271 1234 - const stored = dataStore.getMentionNotifications(); 1272 + const stored = dataStore.$mentionNotifications.get(); 1235 1273 assertEquals(stored.length, 1); 1236 1274 assertEquals(stored[0].uri, "n2"); 1237 1275 }); ··· 1251 1289 1252 1290 await requests.loadBookmarks(); 1253 1291 1254 - const stored = dataStore.getBookmarks(); 1292 + const stored = dataStore.$bookmarks.get(); 1255 1293 assertEquals(stored.feed.length, 1); 1256 1294 assertEquals(stored.feed[0].post.uri, "post1"); 1257 1295 assertEquals(stored.cursor, "next"); ··· 1259 1297 1260 1298 it("should append on subsequent loads", async () => { 1261 1299 const dataStore = new DataStore(); 1262 - dataStore.setBookmarks({ 1300 + dataStore.$bookmarks.set({ 1263 1301 feed: [{ post: { uri: "post1" } }], 1264 1302 cursor: "c1", 1265 1303 }); ··· 1274 1312 1275 1313 await requests.loadBookmarks(); 1276 1314 1277 - const stored = dataStore.getBookmarks(); 1315 + const stored = dataStore.$bookmarks.get(); 1278 1316 assertEquals(stored.feed.length, 2); 1279 1317 assertEquals(stored.feed[1].post.uri, "post2"); 1280 1318 assertEquals(stored.cursor, "c2"); ··· 1282 1320 1283 1321 it("should reset on reload", async () => { 1284 1322 const dataStore = new DataStore(); 1285 - dataStore.setBookmarks({ 1323 + dataStore.$bookmarks.set({ 1286 1324 feed: [{ post: { uri: "post1" } }], 1287 1325 cursor: "c1", 1288 1326 }); ··· 1303 1341 await requests.loadBookmarks({ reload: true }); 1304 1342 1305 1343 assertEquals(capturedCursor, ""); 1306 - const stored = dataStore.getBookmarks(); 1344 + const stored = dataStore.$bookmarks.get(); 1307 1345 assertEquals(stored.feed.length, 1); 1308 1346 assertEquals(stored.feed[0].post.uri, "post2"); 1309 1347 }); ··· 1323 1361 1324 1362 await requests.loadProfileFollowers(profileDid); 1325 1363 1326 - assertEquals(dataStore.getProfileFollowers(profileDid), res); 1364 + assertEquals(dataStore.$profileFollowers.get(profileDid).get(), res); 1327 1365 }); 1328 1366 1329 1367 it("should append followers when cursor is provided", async () => { 1330 1368 const dataStore = new DataStore(); 1331 - dataStore.setProfileFollowers(profileDid, { 1369 + dataStore.$profileFollowers.set(profileDid, { 1332 1370 followers: [{ did: "did:plc:a" }], 1333 1371 cursor: "c1", 1334 1372 }); ··· 1342 1380 1343 1381 await requests.loadProfileFollowers(profileDid, { cursor: "c1" }); 1344 1382 1345 - const stored = dataStore.getProfileFollowers(profileDid); 1383 + const stored = dataStore.$profileFollowers.get(profileDid).get(); 1346 1384 assertEquals(stored.followers.length, 2); 1347 1385 assertEquals(stored.cursor, "c2"); 1348 1386 }); ··· 1359 1397 1360 1398 await requests.loadProfileFollows(profileDid); 1361 1399 1362 - assertEquals(dataStore.getProfileFollows(profileDid), res); 1400 + assertEquals(dataStore.$profileFollows.get(profileDid).get(), res); 1363 1401 }); 1364 1402 1365 1403 it("should append follows when cursor is provided", async () => { 1366 1404 const dataStore = new DataStore(); 1367 - dataStore.setProfileFollows(profileDid, { 1405 + dataStore.$profileFollows.set(profileDid, { 1368 1406 follows: [{ did: "did:plc:a" }], 1369 1407 cursor: "c1", 1370 1408 }); ··· 1378 1416 1379 1417 await requests.loadProfileFollows(profileDid, { cursor: "c1" }); 1380 1418 1381 - const stored = dataStore.getProfileFollows(profileDid); 1419 + const stored = dataStore.$profileFollows.get(profileDid).get(); 1382 1420 assertEquals(stored.follows.length, 2); 1383 1421 assertEquals(stored.cursor, "c2"); 1384 1422 }); ··· 1400 1438 1401 1439 await requests.loadConvoList(); 1402 1440 1403 - assertEquals(dataStore.getConvoList().length, 2); 1404 - assertEquals(dataStore.getConvo("c1").id, "c1"); 1405 - assertEquals(dataStore.getConvo("c2").id, "c2"); 1406 - assertEquals(dataStore.getConvoListCursor(), "next"); 1441 + assertEquals(dataStore.$convoList.get().length, 2); 1442 + assertEquals(dataStore.$convos.get("c1").get().id, "c1"); 1443 + assertEquals(dataStore.$convos.get("c2").get().id, "c2"); 1444 + assertEquals(dataStore.$convoListCursor.get(), "next"); 1407 1445 }); 1408 1446 1409 1447 it("should append when previous cursor matches", async () => { 1410 1448 const dataStore = new DataStore(); 1411 - dataStore.setConvoList([{ id: "c1" }]); 1412 - dataStore.setConvoListCursor("page2"); 1449 + dataStore.$convoList.set([{ id: "c1" }]); 1450 + dataStore.$convoListCursor.set("page2"); 1413 1451 1414 1452 const mockApi = { 1415 1453 listConvos: async () => ({ ··· 1421 1459 1422 1460 await requests.loadConvoList(); 1423 1461 1424 - assertEquals(dataStore.getConvoList().length, 2); 1425 - assertEquals(dataStore.getConvoListCursor(), "page3"); 1462 + assertEquals(dataStore.$convoList.get().length, 2); 1463 + assertEquals(dataStore.$convoListCursor.get(), "page3"); 1426 1464 }); 1427 1465 1428 1466 it("should reset cursor and replace on reload", async () => { 1429 1467 const dataStore = new DataStore(); 1430 - dataStore.setConvoList([{ id: "c1" }]); 1431 - dataStore.setConvoListCursor("page2"); 1468 + dataStore.$convoList.set([{ id: "c1" }]); 1469 + dataStore.$convoListCursor.set("page2"); 1432 1470 1433 1471 let capturedCursor; 1434 1472 const mockApi = { ··· 1442 1480 await requests.loadConvoList({ reload: true }); 1443 1481 1444 1482 assertEquals(capturedCursor, ""); 1445 - const stored = dataStore.getConvoList(); 1483 + const stored = dataStore.$convoList.get(); 1446 1484 assertEquals(stored.length, 1); 1447 1485 assertEquals(stored[0].id, "c2"); 1448 1486 }); ··· 1463 1501 1464 1502 await requests.loadConvoMessages(convoId); 1465 1503 1466 - const stored = dataStore.getConvoMessages(convoId); 1504 + const stored = dataStore.$convoMessages.get(convoId).get(); 1467 1505 assertEquals(stored.messages.length, 2); 1468 - assertEquals(dataStore.getMessage("m1").id, "m1"); 1506 + assertEquals(dataStore.$messages.get("m1").get().id, "m1"); 1469 1507 }); 1470 1508 1471 1509 it("should append messages when prior cursor exists", async () => { 1472 1510 const dataStore = new DataStore(); 1473 - dataStore.setConvoMessages(convoId, { 1511 + dataStore.$convoMessages.set(convoId, { 1474 1512 messages: [{ id: "m1" }], 1475 1513 cursor: "page2", 1476 1514 }); ··· 1489 1527 1490 1528 await requests.loadConvoMessages(convoId); 1491 1529 1492 - const stored = dataStore.getConvoMessages(convoId); 1530 + const stored = dataStore.$convoMessages.get(convoId).get(); 1493 1531 assertEquals(stored.messages.length, 2); 1494 1532 assertEquals(stored.messages[0].id, "m1"); 1495 1533 assertEquals(stored.messages[1].id, "m2"); ··· 1511 1549 1512 1550 await requests.loadConvoMessages(convoId); 1513 1551 1514 - assertEquals(dataStore.getConvoMessages(convoId).cursor, null); 1552 + assertEquals(dataStore.$convoMessages.get(convoId).get().cursor, null); 1515 1553 }); 1516 1554 1517 1555 it("should reset on reload", async () => { 1518 1556 const dataStore = new DataStore(); 1519 - dataStore.setConvoMessages(convoId, { 1557 + dataStore.$convoMessages.set(convoId, { 1520 1558 messages: [{ id: "old" }], 1521 1559 cursor: "page2", 1522 1560 }); ··· 1533 1571 await requests.loadConvoMessages(convoId, { reload: true }); 1534 1572 1535 1573 assertEquals(capturedCursor, ""); 1536 - const stored = dataStore.getConvoMessages(convoId); 1574 + const stored = dataStore.$convoMessages.get(convoId).get(); 1537 1575 assertEquals(stored.messages.length, 1); 1538 1576 assertEquals(stored.messages[0].id, "fresh"); 1539 1577 }); ··· 1550 1588 1551 1589 await requests.loadPostLikes(postUri); 1552 1590 1553 - assertEquals(dataStore.getPostLikes(postUri), res); 1591 + assertEquals(dataStore.$postLikes.get(postUri).get(), res); 1554 1592 }); 1555 1593 1556 1594 it("should append likes when cursor is provided", async () => { 1557 1595 const dataStore = new DataStore(); 1558 - dataStore.setPostLikes(postUri, { 1596 + dataStore.$postLikes.set(postUri, { 1559 1597 likes: [{ actor: { did: "did:plc:a" } }], 1560 1598 cursor: "c1", 1561 1599 }); ··· 1569 1607 1570 1608 await requests.loadPostLikes(postUri, { cursor: "c1" }); 1571 1609 1572 - const stored = dataStore.getPostLikes(postUri); 1610 + const stored = dataStore.$postLikes.get(postUri).get(); 1573 1611 assertEquals(stored.likes.length, 2); 1574 1612 assertEquals(stored.cursor, "c2"); 1575 1613 }); ··· 1591 1629 1592 1630 await requests.loadPostQuotes(postUri); 1593 1631 1594 - const stored = dataStore.getPostQuotes(postUri); 1632 + const stored = dataStore.$postQuotes.get(postUri).get(); 1595 1633 assertEquals(stored.posts.length, 1); 1596 1634 assertEquals(stored.cursor, "next"); 1597 1635 }); 1598 1636 1599 1637 it("should append quotes when cursor is provided", async () => { 1600 1638 const dataStore = new DataStore(); 1601 - dataStore.setPostQuotes(postUri, { 1639 + dataStore.$postQuotes.set(postUri, { 1602 1640 posts: [{ uri: "q1", record: {} }], 1603 1641 cursor: "c1", 1604 1642 }); ··· 1613 1651 1614 1652 await requests.loadPostQuotes(postUri, { cursor: "c1" }); 1615 1653 1616 - const stored = dataStore.getPostQuotes(postUri); 1654 + const stored = dataStore.$postQuotes.get(postUri).get(); 1617 1655 assertEquals(stored.posts.length, 2); 1618 1656 assertEquals(stored.cursor, "c2"); 1619 1657 }); ··· 1634 1672 1635 1673 await requests.loadPostReposts(postUri); 1636 1674 1637 - const stored = dataStore.getPostReposts(postUri); 1675 + const stored = dataStore.$postReposts.get(postUri).get(); 1638 1676 assertEquals(stored.reposts.length, 1); 1639 1677 assertEquals(stored.cursor, "next"); 1640 1678 }); 1641 1679 1642 1680 it("should append reposts when cursor is provided", async () => { 1643 1681 const dataStore = new DataStore(); 1644 - dataStore.setPostReposts(postUri, { 1682 + dataStore.$postReposts.set(postUri, { 1645 1683 reposts: [{ did: "did:plc:a" }], 1646 1684 cursor: "c1", 1647 1685 }); ··· 1655 1693 1656 1694 await requests.loadPostReposts(postUri, { cursor: "c1" }); 1657 1695 1658 - const stored = dataStore.getPostReposts(postUri); 1696 + const stored = dataStore.$postReposts.get(postUri).get(); 1659 1697 assertEquals(stored.reposts.length, 2); 1660 1698 assertEquals(stored.cursor, "c2"); 1661 1699 }); ··· 1676 1714 1677 1715 await requests.loadActorFeeds(did); 1678 1716 1679 - const stored = dataStore.getActorFeeds(did); 1717 + const stored = dataStore.$actorFeeds.get(did).get(); 1680 1718 assertEquals(stored.feeds.length, 1); 1681 1719 assertEquals(stored.cursor, "next"); 1682 - assertEquals(dataStore.getFeedGenerator("f1").displayName, "F1"); 1720 + assertEquals(dataStore.$feedGenerators.get("f1").get().displayName, "F1"); 1683 1721 }); 1684 1722 1685 1723 it("should append on subsequent calls when cursor remains", async () => { 1686 1724 const dataStore = new DataStore(); 1687 - dataStore.setActorFeeds(did, { 1725 + dataStore.$actorFeeds.set(did, { 1688 1726 feeds: [{ uri: "f1" }], 1689 1727 cursor: "c1", 1690 1728 }); ··· 1698 1736 1699 1737 await requests.loadActorFeeds(did); 1700 1738 1701 - const stored = dataStore.getActorFeeds(did); 1739 + const stored = dataStore.$actorFeeds.get(did).get(); 1702 1740 assertEquals(stored.feeds.length, 2); 1703 1741 assertEquals(stored.cursor, null); 1704 1742 }); 1705 1743 1706 1744 it("should short-circuit when there is no remaining cursor", async () => { 1707 1745 const dataStore = new DataStore(); 1708 - dataStore.setActorFeeds(did, { 1746 + dataStore.$actorFeeds.set(did, { 1709 1747 feeds: [{ uri: "f1" }], 1710 1748 cursor: null, 1711 1749 }); ··· 1725 1763 1726 1764 it("should reset on reload", async () => { 1727 1765 const dataStore = new DataStore(); 1728 - dataStore.setActorFeeds(did, { 1766 + dataStore.$actorFeeds.set(did, { 1729 1767 feeds: [{ uri: "f1" }], 1730 1768 cursor: null, 1731 1769 }); ··· 1742 1780 await requests.loadActorFeeds(did, { reload: true }); 1743 1781 1744 1782 assertEquals(capturedCursor, ""); 1745 - const stored = dataStore.getActorFeeds(did); 1783 + const stored = dataStore.$actorFeeds.get(did).get(); 1746 1784 assertEquals(stored.feeds.length, 1); 1747 1785 assertEquals(stored.feeds[0].uri, "f2"); 1748 1786 }); ··· 1762 1800 1763 1801 await requests.loadHashtagFeed("foo", "top"); 1764 1802 1765 - const stored = dataStore.getHashtagFeed("foo-top"); 1803 + const stored = dataStore.$hashtagFeeds.get("foo-top").get(); 1766 1804 assertEquals(stored.feed.length, 1); 1767 1805 assertEquals(stored.feed[0].post.uri, "p1"); 1768 1806 assertEquals(stored.cursor, "next"); ··· 1770 1808 1771 1809 it("should append on subsequent loads", async () => { 1772 1810 const dataStore = new DataStore(); 1773 - dataStore.setHashtagFeed("foo-top", { 1811 + dataStore.$hashtagFeeds.set("foo-top", { 1774 1812 feed: [{ post: { uri: "p1" } }], 1775 1813 cursor: "c1", 1776 1814 }); ··· 1785 1823 1786 1824 await requests.loadHashtagFeed("foo", "top"); 1787 1825 1788 - const stored = dataStore.getHashtagFeed("foo-top"); 1826 + const stored = dataStore.$hashtagFeeds.get("foo-top").get(); 1789 1827 assertEquals(stored.feed.length, 2); 1790 1828 assertEquals(stored.feed[1].post.uri, "p2"); 1791 1829 }); 1792 1830 1793 1831 it("should reset on reload", async () => { 1794 1832 const dataStore = new DataStore(); 1795 - dataStore.setHashtagFeed("foo-top", { 1833 + dataStore.$hashtagFeeds.set("foo-top", { 1796 1834 feed: [{ post: { uri: "p1" } }], 1797 1835 cursor: "c1", 1798 1836 }); ··· 1810 1848 await requests.loadHashtagFeed("foo", "top", { reload: true }); 1811 1849 1812 1850 assertEquals(capturedCursor, ""); 1813 - const stored = dataStore.getHashtagFeed("foo-top"); 1851 + const stored = dataStore.$hashtagFeeds.get("foo-top").get(); 1814 1852 assertEquals(stored.feed.length, 1); 1815 1853 assertEquals(stored.feed[0].post.uri, "p2"); 1816 1854 }); ··· 1841 1879 await requests.loadPinnedFeedGenerators(); 1842 1880 1843 1881 assertEquals(capturedUris, ["at://did/feed/one", "at://did/feed/two"]); 1844 - const pinned = dataStore.getPinnedFeedGenerators(); 1882 + const pinned = dataStore.$pinnedFeedGenerators.get(); 1845 1883 assertEquals(pinned.length, 2); 1846 1884 assertEquals( 1847 - dataStore.getFeedGenerator("at://did/feed/one").displayName, 1885 + dataStore.$feedGenerators.get("at://did/feed/one").get().displayName, 1848 1886 "name-at://did/feed/one", 1849 1887 ); 1850 1888 }); ··· 1865 1903 await requests.loadPinnedFeedGenerators(); 1866 1904 1867 1905 assertEquals(called, false); 1868 - assertEquals(dataStore.getPinnedFeedGenerators(), []); 1906 + assertEquals(dataStore.$pinnedFeedGenerators.get(), []); 1869 1907 }); 1870 1908 }); 1871 1909
-1737
tests/unit/specs/dataLayer/selectors.test.js
··· 1 - import { TestSuite } from "../../testSuite.js"; 2 - import { assert, assertEquals } from "../../testHelpers.js"; 3 - import { Selectors } from "/js/dataLayer/selectors.js"; 4 - import { DataStore } from "/js/dataLayer/dataStore.js"; 5 - import { PatchStore } from "/js/dataLayer/patchStore.js"; 6 - import * as base from "/js/dataLayer/base.js"; 7 - import { Preferences } from "/js/preferences.js"; 8 - 9 - const t = new TestSuite("Selectors"); 10 - 11 - t.describe("getFeed", (it) => { 12 - const feedURI = "at://did:test/app.bsky.feed.generator/test"; 13 - 14 - it("should return null when feed does not exist", () => { 15 - const dataStore = new DataStore(); 16 - const patchStore = new PatchStore(); 17 - const mockPreferencesProvider = { 18 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 19 - }; 20 - const selectors = new Selectors( 21 - dataStore, 22 - patchStore, 23 - mockPreferencesProvider, 24 - false, 25 - ); 26 - 27 - const result = selectors.getFeed(feedURI); 28 - assertEquals(result, null); 29 - }); 30 - 31 - it("should hydrate and return a feed with posts", () => { 32 - const dataStore = new DataStore(); 33 - const patchStore = new PatchStore(); 34 - const mockPreferencesProvider = { 35 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 36 - }; 37 - const selectors = new Selectors( 38 - dataStore, 39 - patchStore, 40 - mockPreferencesProvider, 41 - false, 42 - ); 43 - 44 - // Set up test data 45 - const rawFeed = { 46 - feed: [{ post: { uri: "post1" } }, { post: { uri: "post2" } }], 47 - cursor: "cursor123", 48 - }; 49 - 50 - const post1 = { uri: "post1", content: "First post", likeCount: 5 }; 51 - const post2 = { uri: "post2", content: "Second post", likeCount: 10 }; 52 - 53 - dataStore.setFeed(feedURI, rawFeed); 54 - dataStore.setPost("post1", post1); 55 - dataStore.setPost("post2", post2); 56 - 57 - const result = selectors.getFeed(feedURI); 58 - 59 - assertEquals(result, { 60 - feed: [{ post: post1 }, { post: post2 }], 61 - cursor: "cursor123", 62 - }); 63 - }); 64 - 65 - it("should apply patches to posts in feed", () => { 66 - const dataStore = new DataStore(); 67 - const patchStore = new PatchStore(); 68 - const mockPreferencesProvider = { 69 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 70 - }; 71 - const selectors = new Selectors( 72 - dataStore, 73 - patchStore, 74 - mockPreferencesProvider, 75 - false, 76 - ); 77 - 78 - const rawFeed = { 79 - feed: [{ post: { uri: "post1" } }], 80 - cursor: "cursor123", 81 - }; 82 - 83 - const post1 = { 84 - uri: "post1", 85 - content: "Test post", 86 - likeCount: 5, 87 - viewer: { like: null }, 88 - }; 89 - 90 - dataStore.setFeed(feedURI, rawFeed); 91 - dataStore.setPost("post1", post1); 92 - patchStore.addPostPatch("post1", { type: "addLike" }); 93 - 94 - const result = selectors.getFeed(feedURI); 95 - 96 - assertEquals(result.feed[0].post.likeCount, 6); 97 - assertEquals(result.feed[0].post.viewer.like, "fake like"); 98 - }); 99 - }); 100 - 101 - t.describe("getPostThread", (it) => { 102 - const postURI = "at://did:test/app.bsky.feed.post/thread"; 103 - 104 - it("should return null when post thread does not exist", () => { 105 - const dataStore = new DataStore(); 106 - const patchStore = new PatchStore(); 107 - const mockPreferencesProvider = { 108 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 109 - }; 110 - const selectors = new Selectors( 111 - dataStore, 112 - patchStore, 113 - mockPreferencesProvider, 114 - false, 115 - ); 116 - 117 - const result = selectors.getPostThread(postURI); 118 - assertEquals(result, null); 119 - }); 120 - 121 - it("should return null when postThreadOther does not exist", () => { 122 - const dataStore = new DataStore(); 123 - const patchStore = new PatchStore(); 124 - const mockPreferencesProvider = { 125 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 126 - }; 127 - const selectors = new Selectors( 128 - dataStore, 129 - patchStore, 130 - mockPreferencesProvider, 131 - false, 132 - ); 133 - 134 - const rawThread = { 135 - post: { uri: postURI }, 136 - replies: [], 137 - }; 138 - 139 - dataStore.setPostThread(postURI, rawThread); 140 - dataStore.setPost(postURI, { uri: postURI }); 141 - 142 - const result = selectors.getPostThread(postURI); 143 - assertEquals(result, null); 144 - }); 145 - 146 - it("should hydrate and return a simple post thread", () => { 147 - const dataStore = new DataStore(); 148 - const patchStore = new PatchStore(); 149 - const mockPreferencesProvider = { 150 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 151 - }; 152 - const selectors = new Selectors( 153 - dataStore, 154 - patchStore, 155 - mockPreferencesProvider, 156 - false, 157 - ); 158 - 159 - const rawThread = { 160 - post: { uri: postURI }, 161 - replies: [], 162 - }; 163 - 164 - const mainPost = { uri: postURI, content: "Main thread post" }; 165 - 166 - dataStore.setPostThread(postURI, rawThread); 167 - dataStore.setPostThreadOther(postURI, []); 168 - dataStore.setPost(postURI, mainPost); 169 - 170 - const result = selectors.getPostThread(postURI); 171 - 172 - assertEquals(result, { 173 - post: mainPost, 174 - replies: [], 175 - }); 176 - }); 177 - 178 - it("should hydrate post thread with replies", () => { 179 - const dataStore = new DataStore(); 180 - const patchStore = new PatchStore(); 181 - const mockPreferencesProvider = { 182 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 183 - }; 184 - const selectors = new Selectors( 185 - dataStore, 186 - patchStore, 187 - mockPreferencesProvider, 188 - false, 189 - ); 190 - 191 - const rawThread = { 192 - post: { uri: postURI }, 193 - replies: [ 194 - { 195 - $type: "app.bsky.feed.defs#threadViewPost", 196 - post: { uri: "reply1" }, 197 - otherData: "preserved", 198 - }, 199 - ], 200 - }; 201 - 202 - const mainPost = { uri: postURI, content: "Main post" }; 203 - const replyPost = { uri: "reply1", content: "Reply post" }; 204 - 205 - dataStore.setPostThread(postURI, rawThread); 206 - dataStore.setPostThreadOther(postURI, []); 207 - dataStore.setPost(postURI, mainPost); 208 - dataStore.setPost("reply1", replyPost); 209 - 210 - const result = selectors.getPostThread(postURI); 211 - 212 - assertEquals(result.post, mainPost); 213 - assertEquals(result.replies[0], { 214 - $type: "app.bsky.feed.defs#threadViewPost", 215 - post: replyPost, 216 - otherData: "preserved", 217 - }); 218 - }); 219 - 220 - it("should hydrate post thread with parent", () => { 221 - const dataStore = new DataStore(); 222 - const patchStore = new PatchStore(); 223 - const mockPreferencesProvider = { 224 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 225 - }; 226 - const selectors = new Selectors( 227 - dataStore, 228 - patchStore, 229 - mockPreferencesProvider, 230 - false, 231 - ); 232 - 233 - const rawThread = { 234 - post: { uri: postURI }, 235 - replies: [], 236 - parent: { 237 - post: { uri: "parent1" }, 238 - parentData: "preserved", 239 - }, 240 - }; 241 - 242 - const mainPost = { uri: postURI, content: "Reply post" }; 243 - const parentPost = { uri: "parent1", content: "Parent post" }; 244 - 245 - dataStore.setPostThread(postURI, rawThread); 246 - dataStore.setPostThreadOther(postURI, []); 247 - dataStore.setPost(postURI, mainPost); 248 - dataStore.setPost("parent1", parentPost); 249 - 250 - const result = selectors.getPostThread(postURI); 251 - 252 - assertEquals(result.post, mainPost); 253 - assertEquals(result.parent, { 254 - post: parentPost, 255 - parentData: "preserved", 256 - }); 257 - }); 258 - 259 - it("should apply patches to thread posts", () => { 260 - const dataStore = new DataStore(); 261 - const patchStore = new PatchStore(); 262 - const mockPreferencesProvider = { 263 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 264 - }; 265 - const selectors = new Selectors( 266 - dataStore, 267 - patchStore, 268 - mockPreferencesProvider, 269 - false, 270 - ); 271 - 272 - const rawThread = { 273 - post: { uri: postURI }, 274 - replies: [], 275 - }; 276 - 277 - const mainPost = { 278 - uri: postURI, 279 - content: "Main post", 280 - likeCount: 5, 281 - viewer: { like: null }, 282 - }; 283 - 284 - dataStore.setPostThread(postURI, rawThread); 285 - dataStore.setPostThreadOther(postURI, []); 286 - dataStore.setPost(postURI, mainPost); 287 - patchStore.addPostPatch(postURI, { type: "addLike" }); 288 - 289 - const result = selectors.getPostThread(postURI); 290 - 291 - assertEquals(result.post.likeCount, 6); 292 - assertEquals(result.post.viewer.like, "fake like"); 293 - }); 294 - 295 - it("should mark replies as isHidden when their URI is in postThreadOther", () => { 296 - const dataStore = new DataStore(); 297 - const patchStore = new PatchStore(); 298 - const mockPreferencesProvider = { 299 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 300 - }; 301 - const selectors = new Selectors( 302 - dataStore, 303 - patchStore, 304 - mockPreferencesProvider, 305 - false, 306 - ); 307 - 308 - const rawThread = { 309 - post: { uri: postURI }, 310 - replies: [ 311 - { 312 - $type: "app.bsky.feed.defs#threadViewPost", 313 - post: { uri: "reply1" }, 314 - replies: [], 315 - }, 316 - { 317 - $type: "app.bsky.feed.defs#threadViewPost", 318 - post: { uri: "reply2" }, 319 - replies: [], 320 - }, 321 - ], 322 - }; 323 - 324 - const mainPost = { uri: postURI, content: "Main post" }; 325 - const replyPost1 = { uri: "reply1", content: "Hidden reply" }; 326 - const replyPost2 = { uri: "reply2", content: "Normal reply" }; 327 - 328 - dataStore.setPostThread(postURI, rawThread); 329 - dataStore.setPostThreadOther(postURI, [{ uri: "reply1" }]); 330 - dataStore.setPost(postURI, mainPost); 331 - dataStore.setPost("reply1", replyPost1); 332 - dataStore.setPost("reply2", replyPost2); 333 - 334 - const result = selectors.getPostThread(postURI); 335 - 336 - assertEquals(result.replies[0].post.isHidden, true); 337 - assertEquals(result.replies[1].post.isHidden, undefined); 338 - }); 339 - 340 - it("should mark nested replies as isHidden when their URI is in postThreadOther", () => { 341 - const dataStore = new DataStore(); 342 - const patchStore = new PatchStore(); 343 - const mockPreferencesProvider = { 344 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 345 - }; 346 - const selectors = new Selectors( 347 - dataStore, 348 - patchStore, 349 - mockPreferencesProvider, 350 - false, 351 - ); 352 - 353 - const rawThread = { 354 - post: { uri: postURI }, 355 - replies: [ 356 - { 357 - $type: "app.bsky.feed.defs#threadViewPost", 358 - post: { uri: "reply1" }, 359 - replies: [ 360 - { 361 - $type: "app.bsky.feed.defs#threadViewPost", 362 - post: { uri: "nested1" }, 363 - replies: [], 364 - }, 365 - ], 366 - }, 367 - ], 368 - }; 369 - 370 - dataStore.setPostThread(postURI, rawThread); 371 - dataStore.setPostThreadOther(postURI, [{ uri: "nested1" }]); 372 - dataStore.setPost(postURI, { uri: postURI }); 373 - dataStore.setPost("reply1", { uri: "reply1" }); 374 - dataStore.setPost("nested1", { uri: "nested1" }); 375 - 376 - const result = selectors.getPostThread(postURI); 377 - 378 - assertEquals(result.replies[0].post.isHidden, undefined); 379 - assertEquals(result.replies[0].replies[0].post.isHidden, true); 380 - }); 381 - 382 - it("should preserve non-threadViewPost replies unchanged", () => { 383 - const dataStore = new DataStore(); 384 - const patchStore = new PatchStore(); 385 - const mockPreferencesProvider = { 386 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 387 - }; 388 - const selectors = new Selectors( 389 - dataStore, 390 - patchStore, 391 - mockPreferencesProvider, 392 - false, 393 - ); 394 - 395 - const rawThread = { 396 - post: { uri: postURI }, 397 - replies: [ 398 - { 399 - $type: "app.bsky.feed.defs#notThreadViewPost", 400 - someOtherData: "should be preserved", 401 - }, 402 - ], 403 - }; 404 - 405 - const mainPost = { uri: postURI, content: "Main post" }; 406 - 407 - dataStore.setPostThread(postURI, rawThread); 408 - dataStore.setPostThreadOther(postURI, []); 409 - dataStore.setPost(postURI, mainPost); 410 - 411 - const result = selectors.getPostThread(postURI); 412 - 413 - assertEquals(result.replies[0], { 414 - $type: "app.bsky.feed.defs#notThreadViewPost", 415 - someOtherData: "should be preserved", 416 - }); 417 - }); 418 - }); 419 - 420 - t.describe("getPost", (it) => { 421 - const postURI = "at://did:test/app.bsky.feed.post/test"; 422 - const testPost = { 423 - uri: postURI, 424 - content: "Test post", 425 - likeCount: 5, 426 - viewer: { like: null }, 427 - }; 428 - 429 - it("should return null when post does not exist", () => { 430 - const dataStore = new DataStore(); 431 - const patchStore = new PatchStore(); 432 - const selectors = new Selectors(dataStore, patchStore); 433 - 434 - const result = selectors.getPost(postURI); 435 - assertEquals(result, null); 436 - }); 437 - 438 - it("should return post with patches applied", () => { 439 - const dataStore = new DataStore(); 440 - const patchStore = new PatchStore(); 441 - const mockPreferencesProvider = { 442 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 443 - }; 444 - const selectors = new Selectors( 445 - dataStore, 446 - patchStore, 447 - mockPreferencesProvider, 448 - false, 449 - ); 450 - 451 - dataStore.setPost(postURI, testPost); 452 - patchStore.addPostPatch(postURI, { type: "addLike" }); 453 - 454 - const result = selectors.getPost(postURI); 455 - 456 - assertEquals(result.likeCount, 6); 457 - assertEquals(result.viewer.like, "fake like"); 458 - assertEquals(result.uri, postURI); 459 - }); 460 - 461 - it("should return post without patches when no patches exist", () => { 462 - const dataStore = new DataStore(); 463 - const patchStore = new PatchStore(); 464 - const mockPreferencesProvider = { 465 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 466 - }; 467 - const selectors = new Selectors( 468 - dataStore, 469 - patchStore, 470 - mockPreferencesProvider, 471 - false, 472 - ); 473 - 474 - dataStore.setPost(postURI, testPost); 475 - 476 - const result = selectors.getPost(postURI); 477 - 478 - assertEquals(result, testPost); 479 - assert(result !== testPost); // Should be a copy due to patch application 480 - }); 481 - 482 - it("should return null when optional post missing", () => { 483 - const dataStore = new DataStore(); 484 - const patchStore = new PatchStore(); 485 - const mockPreferencesProvider = { 486 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 487 - }; 488 - const selectors = new Selectors( 489 - dataStore, 490 - patchStore, 491 - mockPreferencesProvider, 492 - false, 493 - ); 494 - 495 - const result = selectors.getPost("nonExistentPost", { require: false }); 496 - assertEquals(result, null); 497 - }); 498 - 499 - it("should return null when post missing and require not specified", () => { 500 - const dataStore = new DataStore(); 501 - const patchStore = new PatchStore(); 502 - const mockPreferencesProvider = { 503 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 504 - }; 505 - const selectors = new Selectors( 506 - dataStore, 507 - patchStore, 508 - mockPreferencesProvider, 509 - false, 510 - ); 511 - 512 - const result = selectors.getPost("nonExistentPost"); 513 - assertEquals(result, null); 514 - }); 515 - }); 516 - 517 - t.describe("getPosts", (it) => { 518 - it("returns posts in input order", () => { 519 - const dataStore = new DataStore(); 520 - const patchStore = new PatchStore(); 521 - const mockPreferencesProvider = { 522 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 523 - }; 524 - const selectors = new Selectors( 525 - dataStore, 526 - patchStore, 527 - mockPreferencesProvider, 528 - false, 529 - ); 530 - 531 - const postA = { uri: "at://a", content: "A" }; 532 - const postB = { uri: "at://b", content: "B" }; 533 - dataStore.setPost("at://a", postA); 534 - dataStore.setPost("at://b", postB); 535 - 536 - const result = selectors.getPosts(["at://b", "at://a"]); 537 - 538 - assertEquals(result, [postB, postA]); 539 - }); 540 - 541 - it("returns null entries for missing posts", () => { 542 - const dataStore = new DataStore(); 543 - const patchStore = new PatchStore(); 544 - const mockPreferencesProvider = { 545 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 546 - }; 547 - const selectors = new Selectors( 548 - dataStore, 549 - patchStore, 550 - mockPreferencesProvider, 551 - false, 552 - ); 553 - 554 - const postA = { uri: "at://a", content: "A" }; 555 - dataStore.setPost("at://a", postA); 556 - 557 - const result = selectors.getPosts(["at://a", "at://missing"]); 558 - 559 - assertEquals(result, [postA, null]); 560 - }); 561 - 562 - it("applies patches to each post", () => { 563 - const dataStore = new DataStore(); 564 - const patchStore = new PatchStore(); 565 - const mockPreferencesProvider = { 566 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 567 - }; 568 - const selectors = new Selectors( 569 - dataStore, 570 - patchStore, 571 - mockPreferencesProvider, 572 - false, 573 - ); 574 - 575 - const postA = { 576 - uri: "at://a", 577 - likeCount: 5, 578 - viewer: { like: null }, 579 - }; 580 - dataStore.setPost("at://a", postA); 581 - patchStore.addPostPatch("at://a", { type: "addLike" }); 582 - 583 - const [result] = selectors.getPosts(["at://a"]); 584 - 585 - assertEquals(result.likeCount, 6); 586 - assertEquals(result.viewer.like, "fake like"); 587 - }); 588 - }); 589 - 590 - t.describe("Integration with DataStore and PatchStore", (it) => { 591 - it("should work with multiple data types and patches", () => { 592 - const dataStore = new DataStore(); 593 - const patchStore = new PatchStore(); 594 - const mockPreferencesProvider = { 595 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 596 - }; 597 - const selectors = new Selectors( 598 - dataStore, 599 - patchStore, 600 - mockPreferencesProvider, 601 - false, 602 - ); 603 - 604 - // Set up data 605 - const feedURI = "feed1"; 606 - const postURI = "post1"; 607 - const profileDID = "profile1"; 608 - 609 - const rawFeed = { feed: [{ post: { uri: postURI } }], cursor: "123" }; 610 - const post = { uri: postURI, likeCount: 5, viewer: { like: null } }; 611 - const profile = { did: profileDID, viewer: { following: null } }; 612 - 613 - dataStore.setFeed(feedURI, rawFeed); 614 - dataStore.setPost(postURI, post); 615 - dataStore.setProfile(profileDID, profile); 616 - 617 - // Add patches 618 - patchStore.addPostPatch(postURI, { type: "addLike" }); 619 - patchStore.addProfilePatch(profileDID, { type: "followProfile" }); 620 - 621 - // Test all selectors 622 - const feedResult = selectors.getFeed(feedURI); 623 - const postResult = selectors.getPost(postURI); 624 - const profileResult = base.getProfile(dataStore, patchStore, profileDID); 625 - 626 - assertEquals(feedResult.feed[0].post.likeCount, 6); 627 - assertEquals(postResult.likeCount, 6); 628 - assertEquals(profileResult.viewer.following, "fake following"); 629 - }); 630 - }); 631 - 632 - t.describe("getLabelerInfo", (it) => { 633 - const labelerDid = "did:plc:testlabeler"; 634 - const testLabelerInfo = { 635 - uri: `at://${labelerDid}/app.bsky.labeler.service/self`, 636 - creator: { did: labelerDid, handle: "labeler.test" }, 637 - policies: { 638 - labelValueDefinitions: [ 639 - { identifier: "nsfw", locales: [{ lang: "en", name: "NSFW" }] }, 640 - ], 641 - }, 642 - }; 643 - 644 - it("should return labeler info from dataStore", () => { 645 - const dataStore = new DataStore(); 646 - const patchStore = new PatchStore(); 647 - const mockPreferencesProvider = { 648 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 649 - }; 650 - const selectors = new Selectors( 651 - dataStore, 652 - patchStore, 653 - mockPreferencesProvider, 654 - false, 655 - ); 656 - 657 - dataStore.setLabelerInfo(labelerDid, testLabelerInfo); 658 - 659 - const result = selectors.getLabelerInfo(labelerDid); 660 - assertEquals(result, testLabelerInfo); 661 - }); 662 - 663 - it("should return undefined when labeler info does not exist", () => { 664 - const dataStore = new DataStore(); 665 - const patchStore = new PatchStore(); 666 - const mockPreferencesProvider = { 667 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 668 - }; 669 - const selectors = new Selectors( 670 - dataStore, 671 - patchStore, 672 - mockPreferencesProvider, 673 - false, 674 - ); 675 - 676 - const result = selectors.getLabelerInfo(labelerDid); 677 - assertEquals(result, undefined); 678 - }); 679 - }); 680 - 681 - t.describe("getLabelerSettings", (it) => { 682 - const labelerDid = "did:plc:testlabeler"; 683 - 684 - it("should return labeler settings from preferences", () => { 685 - const dataStore = new DataStore(); 686 - const patchStore = new PatchStore(); 687 - 688 - const contentLabelPrefs = [ 689 - { 690 - $type: "app.bsky.actor.defs#contentLabelPref", 691 - label: "nsfw", 692 - labelerDid: labelerDid, 693 - visibility: "warn", 694 - }, 695 - { 696 - $type: "app.bsky.actor.defs#contentLabelPref", 697 - label: "gore", 698 - labelerDid: labelerDid, 699 - visibility: "hide", 700 - }, 701 - ]; 702 - 703 - const mockPreferences = new Preferences(contentLabelPrefs, []); 704 - const mockPreferencesProvider = { 705 - requirePreferences: () => mockPreferences, 706 - }; 707 - const selectors = new Selectors( 708 - dataStore, 709 - patchStore, 710 - mockPreferencesProvider, 711 - false, 712 - ); 713 - 714 - const result = selectors.getLabelerSettings(labelerDid); 715 - 716 - assertEquals(result.length, 2); 717 - assertEquals(result[0].label, "nsfw"); 718 - assertEquals(result[0].visibility, "warn"); 719 - assertEquals(result[1].label, "gore"); 720 - assertEquals(result[1].visibility, "hide"); 721 - }); 722 - 723 - it("should return empty array when no labeler settings exist", () => { 724 - const dataStore = new DataStore(); 725 - const patchStore = new PatchStore(); 726 - 727 - const mockPreferences = new Preferences([], []); 728 - const mockPreferencesProvider = { 729 - requirePreferences: () => mockPreferences, 730 - }; 731 - const selectors = new Selectors( 732 - dataStore, 733 - patchStore, 734 - mockPreferencesProvider, 735 - false, 736 - ); 737 - 738 - const result = selectors.getLabelerSettings(labelerDid); 739 - assertEquals(result.length, 0); 740 - }); 741 - 742 - it("should only return settings for the specified labeler", () => { 743 - const dataStore = new DataStore(); 744 - const patchStore = new PatchStore(); 745 - 746 - const otherLabelerDid = "did:plc:otherlabeler"; 747 - const contentLabelPrefs = [ 748 - { 749 - $type: "app.bsky.actor.defs#contentLabelPref", 750 - label: "nsfw", 751 - labelerDid: labelerDid, 752 - visibility: "warn", 753 - }, 754 - { 755 - $type: "app.bsky.actor.defs#contentLabelPref", 756 - label: "spam", 757 - labelerDid: otherLabelerDid, 758 - visibility: "hide", 759 - }, 760 - ]; 761 - 762 - const mockPreferences = new Preferences(contentLabelPrefs, []); 763 - const mockPreferencesProvider = { 764 - requirePreferences: () => mockPreferences, 765 - }; 766 - const selectors = new Selectors( 767 - dataStore, 768 - patchStore, 769 - mockPreferencesProvider, 770 - false, 771 - ); 772 - 773 - const result = selectors.getLabelerSettings(labelerDid); 774 - 775 - assertEquals(result.length, 1); 776 - assertEquals(result[0].label, "nsfw"); 777 - assertEquals(result[0].labelerDid, labelerDid); 778 - }); 779 - }); 780 - 781 - t.describe("getAuthorFeed (replies)", (it) => { 782 - const did = "did:test:alice"; 783 - const feedURI = `${did}-replies`; 784 - 785 - function makeSelectors(dataStore) { 786 - const patchStore = new PatchStore(); 787 - const mockPreferencesProvider = { 788 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 789 - }; 790 - return new Selectors(dataStore, patchStore, mockPreferencesProvider, false); 791 - } 792 - 793 - it("should keep replies authored by the user", () => { 794 - const dataStore = new DataStore(); 795 - const replyPost = { uri: "post1", author: { did } }; 796 - const parentPost = { uri: "parent1", author: { did } }; 797 - const rootPost = { uri: "root1", author: { did } }; 798 - 799 - dataStore.setAuthorFeed(feedURI, { 800 - feed: [ 801 - { 802 - post: { uri: "post1" }, 803 - reply: { 804 - root: { uri: "root1" }, 805 - parent: { uri: "parent1" }, 806 - }, 807 - }, 808 - ], 809 - cursor: "c", 810 - }); 811 - dataStore.setPost("post1", replyPost); 812 - dataStore.setPost("parent1", parentPost); 813 - dataStore.setPost("root1", rootPost); 814 - 815 - const result = makeSelectors(dataStore).getAuthorFeed(did, "replies"); 816 - 817 - assertEquals(result.feed.length, 1); 818 - assertEquals(result.feed[0].post.uri, "post1"); 819 - }); 820 - 821 - it("should drop top-level posts (no reply)", () => { 822 - const dataStore = new DataStore(); 823 - dataStore.setAuthorFeed(feedURI, { 824 - feed: [{ post: { uri: "post1" } }], 825 - cursor: "c", 826 - }); 827 - dataStore.setPost("post1", { uri: "post1", author: { did } }); 828 - 829 - const result = makeSelectors(dataStore).getAuthorFeed(did, "replies"); 830 - 831 - assertEquals(result.feed.length, 0); 832 - }); 833 - 834 - it("should drop reposts even if the reposted post is itself a reply", () => { 835 - const dataStore = new DataStore(); 836 - dataStore.setAuthorFeed(feedURI, { 837 - feed: [ 838 - { 839 - post: { uri: "post1" }, 840 - reason: { $type: "app.bsky.feed.defs#reasonRepost" }, 841 - reply: { 842 - root: { uri: "root1" }, 843 - parent: { uri: "parent1" }, 844 - }, 845 - }, 846 - ], 847 - cursor: "c", 848 - }); 849 - dataStore.setPost("post1", { uri: "post1", author: { did: "did:other" } }); 850 - dataStore.setPost("parent1", { 851 - uri: "parent1", 852 - author: { did: "did:other" }, 853 - }); 854 - dataStore.setPost("root1", { 855 - uri: "root1", 856 - author: { did: "did:other" }, 857 - }); 858 - 859 - const result = makeSelectors(dataStore).getAuthorFeed(did, "replies"); 860 - 861 - assertEquals(result.feed.length, 0); 862 - }); 863 - }); 864 - 865 - t.describe("getNotifications", (it) => { 866 - let dataStore; 867 - let selectors; 868 - 869 - function makeSelectors(store) { 870 - const patchStore = new PatchStore(); 871 - const mockPreferencesProvider = { 872 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 873 - }; 874 - return new Selectors(store, patchStore, mockPreferencesProvider, false); 875 - } 876 - 877 - it("should return null when no notifications exist", () => { 878 - dataStore = new DataStore(); 879 - selectors = makeSelectors(dataStore); 880 - assertEquals(selectors.getNotifications(), null); 881 - }); 882 - 883 - it("should attach subject post for like notifications", () => { 884 - dataStore = new DataStore(); 885 - const subjectPost = { uri: "subjectPost", content: "liked post" }; 886 - dataStore.setPost("subjectPost", subjectPost); 887 - dataStore.setNotifications([ 888 - { 889 - reason: "like", 890 - reasonSubject: "subjectPost", 891 - uri: "notif1", 892 - }, 893 - ]); 894 - selectors = makeSelectors(dataStore); 895 - 896 - const result = selectors.getNotifications(); 897 - assertEquals(result.length, 1); 898 - assertEquals(result[0].subject, subjectPost); 899 - }); 900 - 901 - it("should fall back to unavailable post for like when subject missing", () => { 902 - dataStore = new DataStore(); 903 - dataStore.setNotifications([ 904 - { 905 - reason: "like", 906 - reasonSubject: "missingSubject", 907 - uri: "notif1", 908 - }, 909 - ]); 910 - selectors = makeSelectors(dataStore); 911 - 912 - const result = selectors.getNotifications(); 913 - assertEquals( 914 - result[0].subject.$type, 915 - "social.impro.feed.defs#unavailablePost", 916 - ); 917 - assertEquals(result[0].subject.uri, "missingSubject"); 918 - }); 919 - 920 - it("should attach subject post for repost notifications", () => { 921 - dataStore = new DataStore(); 922 - const subjectPost = { uri: "rpSubject", content: "reposted" }; 923 - dataStore.setPost("rpSubject", subjectPost); 924 - dataStore.setNotifications([ 925 - { 926 - reason: "repost", 927 - reasonSubject: "rpSubject", 928 - uri: "notif2", 929 - }, 930 - ]); 931 - selectors = makeSelectors(dataStore); 932 - 933 - const result = selectors.getNotifications(); 934 - assertEquals(result[0].subject, subjectPost); 935 - }); 936 - 937 - it("should attach subject post for like-via-repost notifications", () => { 938 - dataStore = new DataStore(); 939 - const subjectPost = { uri: "viaSubject", content: "via repost" }; 940 - dataStore.setPost("viaSubject", subjectPost); 941 - dataStore.setNotifications([ 942 - { 943 - reason: "like-via-repost", 944 - record: { subject: { uri: "viaSubject" } }, 945 - uri: "notif3", 946 - }, 947 - ]); 948 - selectors = makeSelectors(dataStore); 949 - 950 - const result = selectors.getNotifications(); 951 - assertEquals(result[0].subject, subjectPost); 952 - }); 953 - 954 - it("should fall back to unavailable post for like-via-repost when missing", () => { 955 - dataStore = new DataStore(); 956 - dataStore.setNotifications([ 957 - { 958 - reason: "repost-via-repost", 959 - record: { subject: { uri: "missingVia" } }, 960 - uri: "notif4", 961 - }, 962 - ]); 963 - selectors = makeSelectors(dataStore); 964 - 965 - const result = selectors.getNotifications(); 966 - assertEquals( 967 - result[0].subject.$type, 968 - "social.impro.feed.defs#unavailablePost", 969 - ); 970 - assertEquals(result[0].subject.uri, "missingVia"); 971 - }); 972 - 973 - it("should attach post and parentPost for reply notifications", () => { 974 - dataStore = new DataStore(); 975 - const replyPost = { uri: "replyUri", content: "the reply" }; 976 - const parentPost = { uri: "parentUri", content: "the parent" }; 977 - dataStore.setPost("replyUri", replyPost); 978 - dataStore.setPost("parentUri", parentPost); 979 - dataStore.setNotifications([ 980 - { 981 - reason: "reply", 982 - uri: "replyUri", 983 - record: { reply: { parent: { uri: "parentUri" } } }, 984 - }, 985 - ]); 986 - selectors = makeSelectors(dataStore); 987 - 988 - const result = selectors.getNotifications(); 989 - assertEquals(result[0].post, replyPost); 990 - assertEquals(result[0].parentPost, parentPost); 991 - }); 992 - 993 - it("should attach post for mention notifications without parent", () => { 994 - dataStore = new DataStore(); 995 - const mentionPost = { uri: "mentionUri", content: "mention" }; 996 - dataStore.setPost("mentionUri", mentionPost); 997 - dataStore.setNotifications([ 998 - { 999 - reason: "mention", 1000 - uri: "mentionUri", 1001 - }, 1002 - ]); 1003 - selectors = makeSelectors(dataStore); 1004 - 1005 - const result = selectors.getNotifications(); 1006 - assertEquals(result[0].post, mentionPost); 1007 - assertEquals(result[0].parentPost, null); 1008 - }); 1009 - 1010 - it("should attach post and parentPost for quote notifications", () => { 1011 - dataStore = new DataStore(); 1012 - const quotePost = { uri: "quoteUri", content: "quote" }; 1013 - const parentPost = { uri: "parentUri", content: "parent" }; 1014 - dataStore.setPost("quoteUri", quotePost); 1015 - dataStore.setPost("parentUri", parentPost); 1016 - dataStore.setNotifications([ 1017 - { 1018 - reason: "quote", 1019 - uri: "quoteUri", 1020 - record: { reply: { parent: { uri: "parentUri" } } }, 1021 - }, 1022 - ]); 1023 - selectors = makeSelectors(dataStore); 1024 - 1025 - const result = selectors.getNotifications(); 1026 - assertEquals(result[0].post, quotePost); 1027 - assertEquals(result[0].parentPost, parentPost); 1028 - }); 1029 - 1030 - it("should attach reasonSubject post for subscribed-post notifications", () => { 1031 - dataStore = new DataStore(); 1032 - const subPost = { uri: "subUri", content: "subscribed" }; 1033 - dataStore.setPost("subUri", subPost); 1034 - dataStore.setNotifications([ 1035 - { 1036 - reason: "subscribed-post", 1037 - uri: "subUri", 1038 - }, 1039 - ]); 1040 - selectors = makeSelectors(dataStore); 1041 - 1042 - const result = selectors.getNotifications(); 1043 - assertEquals(result[0].reasonSubject, subPost); 1044 - }); 1045 - 1046 - it("should pass follow notifications through unchanged", () => { 1047 - dataStore = new DataStore(); 1048 - const followNotification = { 1049 - reason: "follow", 1050 - uri: "followUri", 1051 - author: { did: "did:test:follower" }, 1052 - }; 1053 - dataStore.setNotifications([followNotification]); 1054 - selectors = makeSelectors(dataStore); 1055 - 1056 - const result = selectors.getNotifications(); 1057 - assertEquals(result[0], followNotification); 1058 - }); 1059 - }); 1060 - 1061 - t.describe("getMentionNotifications", (it) => { 1062 - function makeSelectors(dataStore) { 1063 - const patchStore = new PatchStore(); 1064 - const mockPreferencesProvider = { 1065 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 1066 - }; 1067 - return new Selectors(dataStore, patchStore, mockPreferencesProvider, false); 1068 - } 1069 - 1070 - it("should return null when none exist", () => { 1071 - const dataStore = new DataStore(); 1072 - assertEquals(makeSelectors(dataStore).getMentionNotifications(), null); 1073 - }); 1074 - 1075 - it("should attach post and parentPost for reply mentions", () => { 1076 - const dataStore = new DataStore(); 1077 - const replyPost = { uri: "rUri", content: "reply" }; 1078 - const parentPost = { uri: "pUri", content: "parent" }; 1079 - dataStore.setPost("rUri", replyPost); 1080 - dataStore.setPost("pUri", parentPost); 1081 - dataStore.setMentionNotifications([ 1082 - { 1083 - reason: "reply", 1084 - uri: "rUri", 1085 - record: { reply: { parent: { uri: "pUri" } } }, 1086 - }, 1087 - ]); 1088 - 1089 - const result = makeSelectors(dataStore).getMentionNotifications(); 1090 - assertEquals(result[0].post, replyPost); 1091 - assertEquals(result[0].parentPost, parentPost); 1092 - }); 1093 - 1094 - it("should attach post for mention without parent", () => { 1095 - const dataStore = new DataStore(); 1096 - const post = { uri: "mUri", content: "m" }; 1097 - dataStore.setPost("mUri", post); 1098 - dataStore.setMentionNotifications([{ reason: "mention", uri: "mUri" }]); 1099 - 1100 - const result = makeSelectors(dataStore).getMentionNotifications(); 1101 - assertEquals(result[0].post, post); 1102 - assertEquals(result[0].parentPost, null); 1103 - }); 1104 - 1105 - it("should pass non-reply/mention/quote notifications through unchanged", () => { 1106 - const dataStore = new DataStore(); 1107 - const followNotif = { reason: "follow", uri: "fUri" }; 1108 - dataStore.setMentionNotifications([followNotif]); 1109 - 1110 - const result = makeSelectors(dataStore).getMentionNotifications(); 1111 - assertEquals(result[0], followNotif); 1112 - }); 1113 - }); 1114 - 1115 - t.describe("getConvoList", (it) => { 1116 - function makeSelectors(dataStore) { 1117 - const patchStore = new PatchStore(); 1118 - const mockPreferencesProvider = { 1119 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 1120 - }; 1121 - return new Selectors(dataStore, patchStore, mockPreferencesProvider, false); 1122 - } 1123 - 1124 - it("should return null when convo list is missing", () => { 1125 - const dataStore = new DataStore(); 1126 - assertEquals(makeSelectors(dataStore).getConvoList(), null); 1127 - }); 1128 - 1129 - it("should sort convos by last interaction descending", () => { 1130 - const dataStore = new DataStore(); 1131 - const olderConvo = { 1132 - id: "convoOld", 1133 - members: [], 1134 - lastMessage: { 1135 - $type: "chat.bsky.convo.defs#messageView", 1136 - sentAt: "2024-01-01T00:00:00Z", 1137 - }, 1138 - }; 1139 - const newerConvo = { 1140 - id: "convoNew", 1141 - members: [], 1142 - lastMessage: { 1143 - $type: "chat.bsky.convo.defs#messageView", 1144 - sentAt: "2024-06-01T00:00:00Z", 1145 - }, 1146 - }; 1147 - dataStore.setConvo("convoOld", olderConvo); 1148 - dataStore.setConvo("convoNew", newerConvo); 1149 - dataStore.setConvoList([{ id: "convoOld" }, { id: "convoNew" }]); 1150 - 1151 - const result = makeSelectors(dataStore).getConvoList(); 1152 - assertEquals(result.length, 2); 1153 - assertEquals(result[0].id, "convoNew"); 1154 - assertEquals(result[1].id, "convoOld"); 1155 - }); 1156 - 1157 - it("should hydrate convos via getConvo", () => { 1158 - const dataStore = new DataStore(); 1159 - const fullConvo = { 1160 - id: "convo1", 1161 - members: [{ did: "did:test:a" }, { did: "did:test:b" }], 1162 - lastMessage: { 1163 - $type: "chat.bsky.convo.defs#messageView", 1164 - sentAt: "2024-01-01T00:00:00Z", 1165 - }, 1166 - }; 1167 - dataStore.setConvo("convo1", fullConvo); 1168 - dataStore.setConvoList([{ id: "convo1" }]); 1169 - 1170 - const result = makeSelectors(dataStore).getConvoList(); 1171 - assertEquals(result[0], fullConvo); 1172 - }); 1173 - }); 1174 - 1175 - t.describe("getConvoForProfile", (it) => { 1176 - function makeSelectors(dataStore) { 1177 - const patchStore = new PatchStore(); 1178 - const mockPreferencesProvider = { 1179 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 1180 - }; 1181 - return new Selectors(dataStore, patchStore, mockPreferencesProvider, false); 1182 - } 1183 - 1184 - it("should return null when no convo with the profile exists", () => { 1185 - const dataStore = new DataStore(); 1186 - dataStore.setConvo("convo1", { 1187 - id: "convo1", 1188 - members: [{ did: "did:test:a" }, { did: "did:test:b" }], 1189 - }); 1190 - const result = 1191 - makeSelectors(dataStore).getConvoForProfile("did:test:other"); 1192 - assertEquals(result, null); 1193 - }); 1194 - 1195 - it("should return the matching two-member convo", () => { 1196 - const dataStore = new DataStore(); 1197 - const convo = { 1198 - id: "convo1", 1199 - members: [{ did: "did:test:self" }, { did: "did:test:friend" }], 1200 - }; 1201 - dataStore.setConvo("convo1", convo); 1202 - const result = 1203 - makeSelectors(dataStore).getConvoForProfile("did:test:friend"); 1204 - assertEquals(result, convo); 1205 - }); 1206 - 1207 - it("should ignore group convos with more than two members", () => { 1208 - const dataStore = new DataStore(); 1209 - dataStore.setConvo("groupConvo", { 1210 - id: "groupConvo", 1211 - members: [ 1212 - { did: "did:test:a" }, 1213 - { did: "did:test:b" }, 1214 - { did: "did:test:c" }, 1215 - ], 1216 - }); 1217 - const result = makeSelectors(dataStore).getConvoForProfile("did:test:b"); 1218 - assertEquals(result, null); 1219 - }); 1220 - 1221 - it("should return null when there are no convos at all", () => { 1222 - const dataStore = new DataStore(); 1223 - const result = makeSelectors(dataStore).getConvoForProfile("did:test:any"); 1224 - assertEquals(result, null); 1225 - }); 1226 - }); 1227 - 1228 - t.describe("getBookmarks", (it) => { 1229 - function makeSelectors(dataStore) { 1230 - const patchStore = new PatchStore(); 1231 - const mockPreferencesProvider = { 1232 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 1233 - }; 1234 - return new Selectors(dataStore, patchStore, mockPreferencesProvider, false); 1235 - } 1236 - 1237 - it("should return null when bookmarks are missing", () => { 1238 - const dataStore = new DataStore(); 1239 - assertEquals(makeSelectors(dataStore).getBookmarks(), null); 1240 - }); 1241 - 1242 - it("should hydrate bookmarked posts via getPost", () => { 1243 - const dataStore = new DataStore(); 1244 - const post = { uri: "bm1", content: "bookmarked" }; 1245 - dataStore.setPost("bm1", post); 1246 - dataStore.setBookmarks({ 1247 - feed: [{ post: { uri: "bm1" } }], 1248 - cursor: "c1", 1249 - }); 1250 - 1251 - const result = makeSelectors(dataStore).getBookmarks(); 1252 - assertEquals(result.feed.length, 1); 1253 - assertEquals(result.feed[0].post, post); 1254 - assertEquals(result.cursor, "c1"); 1255 - }); 1256 - 1257 - it("should attach parentAuthor when bookmarked post is a reply", () => { 1258 - const dataStore = new DataStore(); 1259 - const parentPost = { 1260 - uri: "parentUri", 1261 - author: { did: "did:test:parent", handle: "parent.test" }, 1262 - }; 1263 - const replyPost = { 1264 - uri: "replyUri", 1265 - record: { reply: { parent: { uri: "parentUri" } } }, 1266 - }; 1267 - dataStore.setPost("parentUri", parentPost); 1268 - dataStore.setPost("replyUri", replyPost); 1269 - dataStore.setBookmarks({ 1270 - feed: [{ post: { uri: "replyUri" } }], 1271 - cursor: null, 1272 - }); 1273 - 1274 - const result = makeSelectors(dataStore).getBookmarks(); 1275 - assertEquals( 1276 - result.feed[0].post.record.reply.parentAuthor, 1277 - parentPost.author, 1278 - ); 1279 - }); 1280 - 1281 - it("should filter out bookmarks for blocked posts", () => { 1282 - const dataStore = new DataStore(); 1283 - const blockedPost = { 1284 - $type: "app.bsky.feed.defs#blockedPost", 1285 - uri: "blockedUri", 1286 - author: { did: "did:test:blocked", viewer: { blockedBy: true } }, 1287 - }; 1288 - const okPost = { uri: "okUri", content: "ok" }; 1289 - dataStore.setPost("blockedUri", blockedPost); 1290 - dataStore.setPost("okUri", okPost); 1291 - dataStore.setBookmarks({ 1292 - feed: [{ post: { uri: "blockedUri" } }, { post: { uri: "okUri" } }], 1293 - cursor: null, 1294 - }); 1295 - 1296 - const result = makeSelectors(dataStore).getBookmarks(); 1297 - assertEquals(result.feed.length, 1); 1298 - assertEquals(result.feed[0].post, okPost); 1299 - }); 1300 - }); 1301 - 1302 - t.describe("getAuthorFeed (non-replies)", (it) => { 1303 - const did = "did:test:alice"; 1304 - 1305 - function makeSelectors(dataStore) { 1306 - const patchStore = new PatchStore(); 1307 - const mockPreferencesProvider = { 1308 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 1309 - }; 1310 - return new Selectors(dataStore, patchStore, mockPreferencesProvider, false); 1311 - } 1312 - 1313 - it("should return null when feed is missing", () => { 1314 - const dataStore = new DataStore(); 1315 - const result = makeSelectors(dataStore).getAuthorFeed(did, "posts"); 1316 - assertEquals(result, null); 1317 - }); 1318 - 1319 - it("should keep top-level posts in posts feed", () => { 1320 - const dataStore = new DataStore(); 1321 - const post = { uri: "post1", author: { did } }; 1322 - dataStore.setAuthorFeed(`${did}-posts`, { 1323 - feed: [{ post: { uri: "post1" } }], 1324 - cursor: "c", 1325 - }); 1326 - dataStore.setPost("post1", post); 1327 - 1328 - const result = makeSelectors(dataStore).getAuthorFeed(did, "posts"); 1329 - assertEquals(result.feed.length, 1); 1330 - assertEquals(result.feed[0].post, post); 1331 - }); 1332 - 1333 - it("should keep replies in posts feed (no replies-only filter)", () => { 1334 - const dataStore = new DataStore(); 1335 - const replyPost = { uri: "post1", author: { did } }; 1336 - const parentPost = { uri: "parent1", author: { did } }; 1337 - const rootPost = { uri: "root1", author: { did } }; 1338 - dataStore.setAuthorFeed(`${did}-posts`, { 1339 - feed: [ 1340 - { 1341 - post: { uri: "post1" }, 1342 - reply: { 1343 - root: { uri: "root1" }, 1344 - parent: { uri: "parent1" }, 1345 - }, 1346 - }, 1347 - ], 1348 - cursor: "c", 1349 - }); 1350 - dataStore.setPost("post1", replyPost); 1351 - dataStore.setPost("parent1", parentPost); 1352 - dataStore.setPost("root1", rootPost); 1353 - 1354 - const result = makeSelectors(dataStore).getAuthorFeed(did, "posts"); 1355 - assertEquals(result.feed.length, 1); 1356 - assertEquals(result.feed[0].reply.root, rootPost); 1357 - assertEquals(result.feed[0].reply.parent, parentPost); 1358 - }); 1359 - 1360 - it("should preserve reason on reposts", () => { 1361 - const dataStore = new DataStore(); 1362 - const repostedPost = { uri: "post1", author: { did: "did:other" } }; 1363 - dataStore.setAuthorFeed(`${did}-posts`, { 1364 - feed: [ 1365 - { 1366 - post: { uri: "post1" }, 1367 - reason: { $type: "app.bsky.feed.defs#reasonRepost" }, 1368 - }, 1369 - ], 1370 - cursor: "c", 1371 - }); 1372 - dataStore.setPost("post1", repostedPost); 1373 - 1374 - const result = makeSelectors(dataStore).getAuthorFeed(did, "posts"); 1375 - assertEquals(result.feed.length, 1); 1376 - assertEquals( 1377 - result.feed[0].reason.$type, 1378 - "app.bsky.feed.defs#reasonRepost", 1379 - ); 1380 - }); 1381 - 1382 - it("should pass through cursor", () => { 1383 - const dataStore = new DataStore(); 1384 - dataStore.setAuthorFeed(`${did}-posts`, { 1385 - feed: [], 1386 - cursor: "next-page-cursor", 1387 - }); 1388 - const result = makeSelectors(dataStore).getAuthorFeed(did, "posts"); 1389 - assertEquals(result.cursor, "next-page-cursor"); 1390 - }); 1391 - }); 1392 - 1393 - t.describe("filterAuthorRepliesFeed", (it) => { 1394 - function makeSelectors() { 1395 - const dataStore = new DataStore(); 1396 - const patchStore = new PatchStore(); 1397 - const mockPreferencesProvider = { 1398 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 1399 - }; 1400 - return new Selectors(dataStore, patchStore, mockPreferencesProvider, false); 1401 - } 1402 - 1403 - it("should keep items that have a reply and no reason", () => { 1404 - const selectors = makeSelectors(); 1405 - const reply = { post: { uri: "p1" }, reply: { root: {}, parent: {} } }; 1406 - const result = selectors.filterAuthorRepliesFeed({ 1407 - feed: [reply], 1408 - cursor: "c", 1409 - }); 1410 - assertEquals(result.feed, [reply]); 1411 - assertEquals(result.cursor, "c"); 1412 - }); 1413 - 1414 - it("should drop items without a reply", () => { 1415 - const selectors = makeSelectors(); 1416 - const result = selectors.filterAuthorRepliesFeed({ 1417 - feed: [{ post: { uri: "p1" } }], 1418 - cursor: null, 1419 - }); 1420 - assertEquals(result.feed.length, 0); 1421 - }); 1422 - 1423 - it("should drop items that have a reason (e.g. reposts) even if reply present", () => { 1424 - const selectors = makeSelectors(); 1425 - const item = { 1426 - post: { uri: "p1" }, 1427 - reply: { root: {}, parent: {} }, 1428 - reason: { $type: "app.bsky.feed.defs#reasonRepost" }, 1429 - }; 1430 - const result = selectors.filterAuthorRepliesFeed({ 1431 - feed: [item], 1432 - cursor: null, 1433 - }); 1434 - assertEquals(result.feed.length, 0); 1435 - }); 1436 - 1437 - it("should preserve cursor when filtering", () => { 1438 - const selectors = makeSelectors(); 1439 - const result = selectors.filterAuthorRepliesFeed({ 1440 - feed: [], 1441 - cursor: "abc", 1442 - }); 1443 - assertEquals(result.cursor, "abc"); 1444 - }); 1445 - }); 1446 - 1447 - t.describe("hydratePostThread (direct)", (it) => { 1448 - function makeSelectors(dataStore) { 1449 - const patchStore = new PatchStore(); 1450 - const mockPreferencesProvider = { 1451 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 1452 - }; 1453 - return new Selectors(dataStore, patchStore, mockPreferencesProvider, false); 1454 - } 1455 - 1456 - it("should hydrate the root post via getPost", () => { 1457 - const dataStore = new DataStore(); 1458 - const mainPost = { uri: "mainUri", content: "main" }; 1459 - dataStore.setPost("mainUri", mainPost); 1460 - const selectors = makeSelectors(dataStore); 1461 - 1462 - const result = selectors.hydratePostThread( 1463 - { post: { uri: "mainUri" }, replies: [] }, 1464 - [], 1465 - ); 1466 - assertEquals(result.post, mainPost); 1467 - assertEquals(result.replies, []); 1468 - }); 1469 - 1470 - it("should mark the root post hidden when its uri is in hiddenReplyUris", () => { 1471 - const dataStore = new DataStore(); 1472 - dataStore.setPost("mainUri", { uri: "mainUri", content: "main" }); 1473 - const selectors = makeSelectors(dataStore); 1474 - 1475 - const result = selectors.hydratePostThread( 1476 - { post: { uri: "mainUri" }, replies: [] }, 1477 - ["mainUri"], 1478 - ); 1479 - assertEquals(result.post.isHidden, true); 1480 - }); 1481 - 1482 - it("should recursively hydrate nested threadViewPost replies", () => { 1483 - const dataStore = new DataStore(); 1484 - dataStore.setPost("mainUri", { uri: "mainUri" }); 1485 - dataStore.setPost("childUri", { uri: "childUri", content: "child" }); 1486 - const selectors = makeSelectors(dataStore); 1487 - 1488 - const result = selectors.hydratePostThread( 1489 - { 1490 - post: { uri: "mainUri" }, 1491 - replies: [ 1492 - { 1493 - $type: "app.bsky.feed.defs#threadViewPost", 1494 - post: { uri: "childUri" }, 1495 - replies: [], 1496 - }, 1497 - ], 1498 - }, 1499 - [], 1500 - ); 1501 - assertEquals(result.replies[0].post.uri, "childUri"); 1502 - assertEquals(result.replies[0].post.content, "child"); 1503 - }); 1504 - 1505 - it("should pass non-threadViewPost replies through unchanged", () => { 1506 - const dataStore = new DataStore(); 1507 - dataStore.setPost("mainUri", { uri: "mainUri" }); 1508 - const selectors = makeSelectors(dataStore); 1509 - const otherReply = { 1510 - $type: "app.bsky.feed.defs#notFoundPost", 1511 - uri: "missingUri", 1512 - }; 1513 - const result = selectors.hydratePostThread( 1514 - { post: { uri: "mainUri" }, replies: [otherReply] }, 1515 - [], 1516 - ); 1517 - assertEquals(result.replies[0], otherReply); 1518 - }); 1519 - 1520 - it("should return blocked post unchanged when blocking user", () => { 1521 - const dataStore = new DataStore(); 1522 - const blockedThread = { 1523 - $type: "app.bsky.feed.defs#blockedPost", 1524 - uri: "blockedUri", 1525 - author: { did: "did:test:b", viewer: { blockedBy: true } }, 1526 - }; 1527 - const selectors = makeSelectors(dataStore); 1528 - const result = selectors.hydratePostThread(blockedThread, []); 1529 - assertEquals(result, blockedThread); 1530 - }); 1531 - }); 1532 - 1533 - t.describe("hydratePostThreadParent (direct)", (it) => { 1534 - function makeSelectors(dataStore) { 1535 - const patchStore = new PatchStore(); 1536 - const mockPreferencesProvider = { 1537 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 1538 - }; 1539 - return new Selectors(dataStore, patchStore, mockPreferencesProvider, false); 1540 - } 1541 - 1542 - it("should hydrate a simple parent via getPost", () => { 1543 - const dataStore = new DataStore(); 1544 - const parentPost = { uri: "parentUri", content: "parent" }; 1545 - dataStore.setPost("parentUri", parentPost); 1546 - const selectors = makeSelectors(dataStore); 1547 - 1548 - const result = selectors.hydratePostThreadParent({ 1549 - $type: "app.bsky.feed.defs#threadViewPost", 1550 - post: { uri: "parentUri" }, 1551 - }); 1552 - assertEquals(result.post, parentPost); 1553 - assertEquals(result.$type, "app.bsky.feed.defs#threadViewPost"); 1554 - }); 1555 - 1556 - it("should walk up the parent chain recursively", () => { 1557 - const dataStore = new DataStore(); 1558 - dataStore.setPost("parentUri", { uri: "parentUri", content: "parent" }); 1559 - dataStore.setPost("grandUri", { uri: "grandUri", content: "grand" }); 1560 - const selectors = makeSelectors(dataStore); 1561 - 1562 - const result = selectors.hydratePostThreadParent({ 1563 - $type: "app.bsky.feed.defs#threadViewPost", 1564 - post: { uri: "parentUri" }, 1565 - parent: { 1566 - $type: "app.bsky.feed.defs#threadViewPost", 1567 - post: { uri: "grandUri" }, 1568 - }, 1569 - }); 1570 - assertEquals(result.post.uri, "parentUri"); 1571 - assertEquals(result.parent.post.uri, "grandUri"); 1572 - }); 1573 - 1574 - it("should return an unavailable post when parent uri is marked unavailable", () => { 1575 - const dataStore = new DataStore(); 1576 - dataStore.setUnavailablePost("missingParent", { uri: "missingParent" }); 1577 - const selectors = makeSelectors(dataStore); 1578 - 1579 - const result = selectors.hydratePostThreadParent({ 1580 - $type: "app.bsky.feed.defs#threadViewPost", 1581 - post: { uri: "missingParent" }, 1582 - uri: "missingParent", 1583 - }); 1584 - assertEquals(result.$type, "social.impro.feed.defs#unavailablePost"); 1585 - assertEquals(result.uri, "missingParent"); 1586 - }); 1587 - 1588 - it("should return blocked parent unchanged when blocking user", () => { 1589 - const dataStore = new DataStore(); 1590 - const blockedParent = { 1591 - $type: "app.bsky.feed.defs#blockedPost", 1592 - uri: "blockedParent", 1593 - author: { did: "did:test:b", viewer: { blockedBy: true } }, 1594 - }; 1595 - const selectors = makeSelectors(dataStore); 1596 - const result = selectors.hydratePostThreadParent(blockedParent); 1597 - assertEquals(result, blockedParent); 1598 - }); 1599 - 1600 - it("should return non-threadViewPost parent unchanged", () => { 1601 - const dataStore = new DataStore(); 1602 - const selectors = makeSelectors(dataStore); 1603 - const notFoundParent = { 1604 - $type: "app.bsky.feed.defs#notFoundPost", 1605 - uri: "missingUri", 1606 - }; 1607 - const result = selectors.hydratePostThreadParent(notFoundParent); 1608 - assertEquals(result, notFoundParent); 1609 - }); 1610 - }); 1611 - 1612 - t.describe("getPost muted-word marking", (it) => { 1613 - it("should mark post viewer.hasMutedWord when text contains a muted word", () => { 1614 - const dataStore = new DataStore(); 1615 - const patchStore = new PatchStore(); 1616 - let preferences = Preferences.createLoggedOutPreferences(); 1617 - preferences = preferences.addMutedWord({ 1618 - value: "spoiler", 1619 - targets: ["content"], 1620 - actorTarget: "all", 1621 - expiresAt: null, 1622 - }); 1623 - const mockPreferencesProvider = { requirePreferences: () => preferences }; 1624 - const selectors = new Selectors( 1625 - dataStore, 1626 - patchStore, 1627 - mockPreferencesProvider, 1628 - false, 1629 - ); 1630 - 1631 - const post = { 1632 - uri: "mutedPost", 1633 - record: { text: "this is a spoiler about the show" }, 1634 - viewer: {}, 1635 - }; 1636 - dataStore.setPost("mutedPost", post); 1637 - 1638 - const result = selectors.getPost("mutedPost"); 1639 - assertEquals(result.viewer.hasMutedWord, true); 1640 - }); 1641 - 1642 - it("should not mark posts that don't match a muted word", () => { 1643 - const dataStore = new DataStore(); 1644 - const patchStore = new PatchStore(); 1645 - let preferences = Preferences.createLoggedOutPreferences(); 1646 - preferences = preferences.addMutedWord({ 1647 - value: "spoiler", 1648 - targets: ["content"], 1649 - actorTarget: "all", 1650 - expiresAt: null, 1651 - }); 1652 - const mockPreferencesProvider = { requirePreferences: () => preferences }; 1653 - const selectors = new Selectors( 1654 - dataStore, 1655 - patchStore, 1656 - mockPreferencesProvider, 1657 - false, 1658 - ); 1659 - 1660 - const post = { 1661 - uri: "okPost", 1662 - record: { text: "this is fine" }, 1663 - viewer: {}, 1664 - }; 1665 - dataStore.setPost("okPost", post); 1666 - 1667 - const result = selectors.getPost("okPost"); 1668 - assertEquals(result.viewer.hasMutedWord, undefined); 1669 - }); 1670 - }); 1671 - 1672 - t.describe("getPost hidden-post marking", (it) => { 1673 - it("should mark post viewer.isHidden when uri is in hidden posts preference", () => { 1674 - const dataStore = new DataStore(); 1675 - const patchStore = new PatchStore(); 1676 - let preferences = Preferences.createLoggedOutPreferences(); 1677 - preferences = preferences.hidePost("hiddenUri"); 1678 - const mockPreferencesProvider = { requirePreferences: () => preferences }; 1679 - const selectors = new Selectors( 1680 - dataStore, 1681 - patchStore, 1682 - mockPreferencesProvider, 1683 - false, 1684 - ); 1685 - 1686 - const post = { uri: "hiddenUri", viewer: {} }; 1687 - dataStore.setPost("hiddenUri", post); 1688 - 1689 - const result = selectors.getPost("hiddenUri"); 1690 - assertEquals(result.viewer.isHidden, true); 1691 - }); 1692 - 1693 - it("should leave viewer.isHidden unset for non-hidden posts", () => { 1694 - const dataStore = new DataStore(); 1695 - const patchStore = new PatchStore(); 1696 - const preferences = Preferences.createLoggedOutPreferences(); 1697 - const mockPreferencesProvider = { requirePreferences: () => preferences }; 1698 - const selectors = new Selectors( 1699 - dataStore, 1700 - patchStore, 1701 - mockPreferencesProvider, 1702 - false, 1703 - ); 1704 - 1705 - const post = { uri: "visibleUri", viewer: {} }; 1706 - dataStore.setPost("visibleUri", post); 1707 - 1708 - const result = selectors.getPost("visibleUri"); 1709 - assertEquals(result.viewer.isHidden, undefined); 1710 - }); 1711 - }); 1712 - 1713 - t.describe("getPost label handling", (it) => { 1714 - it("should not attach badgeLabels/contentLabel/mediaLabel for unlabeled posts", () => { 1715 - const dataStore = new DataStore(); 1716 - const patchStore = new PatchStore(); 1717 - const mockPreferencesProvider = { 1718 - requirePreferences: () => Preferences.createLoggedOutPreferences(), 1719 - }; 1720 - const selectors = new Selectors( 1721 - dataStore, 1722 - patchStore, 1723 - mockPreferencesProvider, 1724 - false, 1725 - ); 1726 - 1727 - const post = { uri: "plainUri", viewer: {} }; 1728 - dataStore.setPost("plainUri", post); 1729 - 1730 - const result = selectors.getPost("plainUri"); 1731 - assertEquals(result.badgeLabels, undefined); 1732 - assertEquals(result.contentLabel, undefined); 1733 - assertEquals(result.mediaLabel, undefined); 1734 - }); 1735 - }); 1736 - 1737 - await t.run();
+527
tests/unit/specs/dataLayer/signals.test.js
··· 1 + import { TestSuite } from "../../testSuite.js"; 2 + import { assertEquals } from "../../testHelpers.js"; 3 + import { Signals } from "/js/dataLayer/signals.js"; 4 + import { DataStore } from "/js/dataLayer/dataStore.js"; 5 + import { PatchStore } from "/js/dataLayer/patchStore.js"; 6 + import { Preferences } from "/js/preferences.js"; 7 + import { Signal, SignalMap } from "/js/utils.js"; 8 + 9 + function makeSignals(dataStore, { preferences } = {}) { 10 + const patchStore = new PatchStore(dataStore); 11 + const prefs = preferences ?? Preferences.createLoggedOutPreferences(); 12 + const preferencesProvider = { 13 + requirePreferences: () => prefs, 14 + $preferences: new Signal.State(prefs), 15 + }; 16 + const pluginService = { 17 + $pluginFilteredFeedItems: new SignalMap(), 18 + }; 19 + const signals = new Signals( 20 + dataStore, 21 + patchStore, 22 + preferencesProvider, 23 + pluginService, 24 + false, 25 + ); 26 + return { signals, patchStore }; 27 + } 28 + 29 + function fakePreferences(overrides = {}) { 30 + return { 31 + postHasMutedWord: () => false, 32 + quotedPostHasMutedWord: () => false, 33 + isPostHidden: () => false, 34 + getBadgeLabels: () => [], 35 + getContentLabel: () => null, 36 + getMediaLabel: () => null, 37 + clone() { 38 + return this; 39 + }, 40 + ...overrides, 41 + }; 42 + } 43 + 44 + const t = new TestSuite("Signals"); 45 + 46 + t.describe("$hydratedFeeds", (it) => { 47 + const feedURI = "at://did:test/app.bsky.feed.generator/test"; 48 + 49 + it("should return null when feed does not exist", () => { 50 + const dataStore = new DataStore(); 51 + const { signals } = makeSignals(dataStore); 52 + assertEquals(signals.$hydratedFeeds.get(feedURI).get(), null); 53 + }); 54 + 55 + it("should hydrate and return a feed with posts", () => { 56 + const dataStore = new DataStore(); 57 + const { signals } = makeSignals(dataStore); 58 + 59 + const rawFeed = { 60 + feed: [{ post: { uri: "post1" } }, { post: { uri: "post2" } }], 61 + cursor: "cursor123", 62 + }; 63 + const post1 = { uri: "post1", content: "First post", likeCount: 5 }; 64 + const post2 = { uri: "post2", content: "Second post", likeCount: 10 }; 65 + 66 + dataStore.$posts.set("post1", post1); 67 + dataStore.$posts.set("post2", post2); 68 + dataStore.$feeds.set(feedURI, rawFeed); 69 + 70 + const result = signals.$hydratedFeeds.get(feedURI).get(); 71 + assertEquals(result, { 72 + feed: [{ post: post1 }, { post: post2 }], 73 + cursor: "cursor123", 74 + }); 75 + }); 76 + 77 + it("should apply patches to posts in feed", () => { 78 + const dataStore = new DataStore(); 79 + const { signals, patchStore } = makeSignals(dataStore); 80 + 81 + const rawFeed = { 82 + feed: [{ post: { uri: "post1" } }], 83 + cursor: "cursor123", 84 + }; 85 + const post1 = { 86 + uri: "post1", 87 + content: "Test post", 88 + likeCount: 5, 89 + viewer: { like: null }, 90 + }; 91 + 92 + dataStore.$posts.set("post1", post1); 93 + dataStore.$feeds.set(feedURI, rawFeed); 94 + patchStore.addPostPatch("post1", { type: "addLike" }); 95 + 96 + const result = signals.$hydratedFeeds.get(feedURI).get(); 97 + assertEquals(result.feed[0].post.likeCount, 6); 98 + assertEquals(result.feed[0].post.viewer.like, "fake like"); 99 + }); 100 + }); 101 + 102 + t.describe("$hydratedHashtagFeeds", (it) => { 103 + const hashtagKey = "javascript-top"; 104 + 105 + it("should return null when feed does not exist", () => { 106 + const dataStore = new DataStore(); 107 + const { signals } = makeSignals(dataStore); 108 + assertEquals(signals.$hydratedHashtagFeeds.get(hashtagKey).get(), null); 109 + }); 110 + 111 + it("should hydrate and return a feed with posts", () => { 112 + const dataStore = new DataStore(); 113 + const { signals } = makeSignals(dataStore); 114 + 115 + const rawFeed = { 116 + feed: [{ post: { uri: "post1" } }, { post: { uri: "post2" } }], 117 + cursor: "cursor123", 118 + }; 119 + const post1 = { uri: "post1", content: "First post", likeCount: 5 }; 120 + const post2 = { uri: "post2", content: "Second post", likeCount: 10 }; 121 + 122 + dataStore.$posts.set("post1", post1); 123 + dataStore.$posts.set("post2", post2); 124 + dataStore.$hashtagFeeds.set(hashtagKey, rawFeed); 125 + 126 + const result = signals.$hydratedHashtagFeeds.get(hashtagKey).get(); 127 + assertEquals(result, { 128 + feed: [{ post: post1 }, { post: post2 }], 129 + cursor: "cursor123", 130 + }); 131 + }); 132 + 133 + it("should attach parentAuthor when post is a reply and parent is loaded", () => { 134 + const dataStore = new DataStore(); 135 + const { signals } = makeSignals(dataStore); 136 + 137 + const parentAuthor = { did: "did:parent", handle: "parent.test" }; 138 + const parent = { 139 + uri: "post-parent", 140 + author: parentAuthor, 141 + }; 142 + const reply = { 143 + uri: "post-reply", 144 + record: { 145 + text: "reply text", 146 + reply: { parent: { uri: "post-parent" }, root: { uri: "post-parent" } }, 147 + }, 148 + }; 149 + const rawFeed = { 150 + feed: [{ post: { uri: "post-reply" } }], 151 + cursor: "c", 152 + }; 153 + 154 + dataStore.$posts.set("post-parent", parent); 155 + dataStore.$posts.set("post-reply", reply); 156 + dataStore.$hashtagFeeds.set(hashtagKey, rawFeed); 157 + 158 + const result = signals.$hydratedHashtagFeeds.get(hashtagKey).get(); 159 + assertEquals(result.feed[0].post.record.reply.parentAuthor, parentAuthor); 160 + }); 161 + 162 + it("should apply patches to posts in feed", () => { 163 + const dataStore = new DataStore(); 164 + const { signals, patchStore } = makeSignals(dataStore); 165 + 166 + const rawFeed = { 167 + feed: [{ post: { uri: "post1" } }], 168 + cursor: "c", 169 + }; 170 + const post1 = { 171 + uri: "post1", 172 + likeCount: 5, 173 + viewer: { like: null }, 174 + }; 175 + 176 + dataStore.$posts.set("post1", post1); 177 + dataStore.$hashtagFeeds.set(hashtagKey, rawFeed); 178 + patchStore.addPostPatch("post1", { type: "addLike" }); 179 + 180 + const result = signals.$hydratedHashtagFeeds.get(hashtagKey).get(); 181 + assertEquals(result.feed[0].post.likeCount, 6); 182 + assertEquals(result.feed[0].post.viewer.like, "fake like"); 183 + }); 184 + }); 185 + 186 + t.describe("$hydratedProfiles", (it) => { 187 + const did = "did:plc:user"; 188 + 189 + it("should return null when profile does not exist", () => { 190 + const dataStore = new DataStore(); 191 + const { signals } = makeSignals(dataStore); 192 + assertEquals(signals.$hydratedProfiles.get(did).get(), null); 193 + }); 194 + 195 + it("should return the profile when it exists", () => { 196 + const dataStore = new DataStore(); 197 + const { signals } = makeSignals(dataStore); 198 + const profile = { did, handle: "user.test", followersCount: 10 }; 199 + dataStore.$profiles.set(did, profile); 200 + const result = signals.$hydratedProfiles.get(did).get(); 201 + assertEquals(result.did, did); 202 + assertEquals(result.handle, "user.test"); 203 + assertEquals(result.followersCount, 10); 204 + }); 205 + 206 + it("should apply profile patches", () => { 207 + const dataStore = new DataStore(); 208 + const { signals, patchStore } = makeSignals(dataStore); 209 + const profile = { 210 + did, 211 + handle: "user.test", 212 + followersCount: 10, 213 + viewer: { following: null }, 214 + }; 215 + dataStore.$profiles.set(did, profile); 216 + patchStore.addProfilePatch(did, { type: "followProfile" }); 217 + const result = signals.$hydratedProfiles.get(did).get(); 218 + assertEquals(result.followersCount, 11); 219 + assertEquals(result.viewer.following, "fake following"); 220 + }); 221 + }); 222 + 223 + t.describe("$hydratedAuthorFeeds", (it) => { 224 + const did = "did:plc:author"; 225 + const feedURI = `${did}-posts`; 226 + 227 + it("should return null when author feed does not exist", () => { 228 + const dataStore = new DataStore(); 229 + const { signals } = makeSignals(dataStore); 230 + assertEquals(signals.$hydratedAuthorFeeds.get(feedURI).get(), null); 231 + }); 232 + 233 + it("should hydrate and return an author feed", () => { 234 + const dataStore = new DataStore(); 235 + const { signals } = makeSignals(dataStore); 236 + const post1 = { uri: "post1", likeCount: 1 }; 237 + const post2 = { uri: "post2", likeCount: 2 }; 238 + dataStore.$posts.set("post1", post1); 239 + dataStore.$posts.set("post2", post2); 240 + dataStore.$authorFeeds.set(feedURI, { 241 + feed: [{ post: { uri: "post1" } }, { post: { uri: "post2" } }], 242 + cursor: "c", 243 + }); 244 + const result = signals.$hydratedAuthorFeeds.get(feedURI).get(); 245 + assertEquals(result.feed.length, 2); 246 + assertEquals(result.feed[0].post.uri, "post1"); 247 + assertEquals(result.feed[1].post.uri, "post2"); 248 + assertEquals(result.cursor, "c"); 249 + }); 250 + 251 + it("should filter to replies-only for replies feed type", () => { 252 + const dataStore = new DataStore(); 253 + const { signals } = makeSignals(dataStore); 254 + const repliesFeedURI = `${did}-replies`; 255 + const post1 = { uri: "post1" }; 256 + const post2 = { uri: "post2" }; 257 + dataStore.$posts.set("post1", post1); 258 + dataStore.$posts.set("post2", post2); 259 + dataStore.$posts.set("parent", { uri: "parent" }); 260 + dataStore.$authorFeeds.set(repliesFeedURI, { 261 + feed: [ 262 + { post: { uri: "post1" } }, // top-level, should be filtered out 263 + { 264 + post: { uri: "post2" }, 265 + reply: { root: { uri: "parent" }, parent: { uri: "parent" } }, 266 + }, 267 + ], 268 + cursor: "c", 269 + }); 270 + const result = signals.$hydratedAuthorFeeds.get(repliesFeedURI).get(); 271 + assertEquals(result.feed.length, 1); 272 + assertEquals(result.feed[0].post.uri, "post2"); 273 + }); 274 + 275 + it("should apply author feed patches", () => { 276 + const dataStore = new DataStore(); 277 + const { signals, patchStore } = makeSignals(dataStore); 278 + const pinnedPost = { uri: "pinned", likeCount: 0 }; 279 + const otherPost = { uri: "other", likeCount: 0 }; 280 + dataStore.$posts.set("pinned", pinnedPost); 281 + dataStore.$posts.set("other", otherPost); 282 + dataStore.$authorFeeds.set(feedURI, { 283 + feed: [{ post: { uri: "other" } }, { post: { uri: "pinned" } }], 284 + cursor: null, 285 + }); 286 + patchStore.addAuthorFeedPatch(feedURI, { 287 + type: "pinPost", 288 + post: { uri: "pinned" }, 289 + }); 290 + const result = signals.$hydratedAuthorFeeds.get(feedURI).get(); 291 + assertEquals(result.feed[0].post.uri, "pinned"); 292 + }); 293 + }); 294 + 295 + t.describe("$actorFeeds", (it) => { 296 + const did = "did:plc:author"; 297 + 298 + it("should return null when actor feeds do not exist", () => { 299 + const dataStore = new DataStore(); 300 + const { signals } = makeSignals(dataStore); 301 + assertEquals(signals.$actorFeeds.get(did).get(), null); 302 + }); 303 + 304 + it("should return the stored actor feeds", () => { 305 + const dataStore = new DataStore(); 306 + const { signals } = makeSignals(dataStore); 307 + const actorFeeds = { feeds: [{ uri: "feed-1" }], cursor: "c" }; 308 + dataStore.$actorFeeds.set(did, actorFeeds); 309 + assertEquals(signals.$actorFeeds.get(did).get(), actorFeeds); 310 + }); 311 + }); 312 + 313 + t.describe("$profileChatStatus", (it) => { 314 + const did = "did:plc:user"; 315 + 316 + it("should return null when chat status does not exist", () => { 317 + const dataStore = new DataStore(); 318 + const { signals } = makeSignals(dataStore); 319 + assertEquals(signals.$profileChatStatus.get(did).get(), null); 320 + }); 321 + 322 + it("should return the stored chat status", () => { 323 + const dataStore = new DataStore(); 324 + const { signals } = makeSignals(dataStore); 325 + const status = { canChat: true, convo: null }; 326 + dataStore.$profileChatStatus.set(did, status); 327 + assertEquals(signals.$profileChatStatus.get(did).get(), status); 328 + }); 329 + }); 330 + 331 + t.describe("$labelerInfo", (it) => { 332 + const did = "did:plc:labeler"; 333 + 334 + it("should return null when labeler info does not exist", () => { 335 + const dataStore = new DataStore(); 336 + const { signals } = makeSignals(dataStore); 337 + assertEquals(signals.$labelerInfo.get(did).get(), null); 338 + }); 339 + 340 + it("should return the stored labeler info", () => { 341 + const dataStore = new DataStore(); 342 + const { signals } = makeSignals(dataStore); 343 + const info = { policies: { labelValues: ["spam"] } }; 344 + dataStore.$labelerInfo.set(did, info); 345 + assertEquals(signals.$labelerInfo.get(did).get(), info); 346 + }); 347 + }); 348 + 349 + t.describe("$labelerSettings", (it) => { 350 + it("should return labeler settings from preferences", () => { 351 + const dataStore = new DataStore(); 352 + const { signals } = makeSignals(dataStore); 353 + const labelerDid = "did:plc:labeler"; 354 + const result = signals.$labelerSettings.get(labelerDid).get(); 355 + // Logged-out preferences should still return a settings object 356 + assertEquals(typeof result, "object"); 357 + }); 358 + }); 359 + 360 + t.describe("$hydratedBookmarks", (it) => { 361 + it("should return null when bookmarks do not exist", () => { 362 + const dataStore = new DataStore(); 363 + const { signals } = makeSignals(dataStore); 364 + assertEquals(signals.$hydratedBookmarks.get(), null); 365 + }); 366 + 367 + it("should hydrate and return bookmarks", () => { 368 + const dataStore = new DataStore(); 369 + const { signals } = makeSignals(dataStore); 370 + const post1 = { uri: "post1", likeCount: 5 }; 371 + const post2 = { uri: "post2", likeCount: 10 }; 372 + dataStore.$posts.set("post1", post1); 373 + dataStore.$posts.set("post2", post2); 374 + dataStore.$bookmarks.set({ 375 + feed: [{ post: { uri: "post1" } }, { post: { uri: "post2" } }], 376 + cursor: "c", 377 + }); 378 + const result = signals.$hydratedBookmarks.get(); 379 + assertEquals(result.feed.length, 2); 380 + assertEquals(result.feed[0].post.uri, "post1"); 381 + assertEquals(result.feed[1].post.uri, "post2"); 382 + assertEquals(result.cursor, "c"); 383 + }); 384 + 385 + it("should attach parentAuthor when bookmarked post is a reply", () => { 386 + const dataStore = new DataStore(); 387 + const { signals } = makeSignals(dataStore); 388 + const parentAuthor = { did: "did:parent", handle: "parent.test" }; 389 + dataStore.$posts.set("post-parent", { 390 + uri: "post-parent", 391 + author: parentAuthor, 392 + }); 393 + dataStore.$posts.set("post-reply", { 394 + uri: "post-reply", 395 + record: { 396 + text: "reply", 397 + reply: { parent: { uri: "post-parent" }, root: { uri: "post-parent" } }, 398 + }, 399 + }); 400 + dataStore.$bookmarks.set({ 401 + feed: [{ post: { uri: "post-reply" } }], 402 + cursor: null, 403 + }); 404 + const result = signals.$hydratedBookmarks.get(); 405 + assertEquals(result.feed[0].post.record.reply.parentAuthor, parentAuthor); 406 + }); 407 + }); 408 + 409 + t.describe("$hydratedPinnedFeedGenerators", (it) => { 410 + it("should return null when pinned feed generators are not set", () => { 411 + const dataStore = new DataStore(); 412 + const { signals } = makeSignals(dataStore); 413 + assertEquals(signals.$hydratedPinnedFeedGenerators.get(), null); 414 + }); 415 + 416 + it("should hydrate pinned feed generators from the store", () => { 417 + const dataStore = new DataStore(); 418 + const { signals } = makeSignals(dataStore); 419 + const fg1 = { uri: "feed-1", displayName: "Feed One" }; 420 + const fg2 = { uri: "feed-2", displayName: "Feed Two" }; 421 + dataStore.$feedGenerators.set("feed-1", fg1); 422 + dataStore.$feedGenerators.set("feed-2", fg2); 423 + dataStore.$pinnedFeedGenerators.set([{ uri: "feed-1" }, { uri: "feed-2" }]); 424 + const result = signals.$hydratedPinnedFeedGenerators.get(); 425 + // isAuthenticated is false in test setup, so no "Following" entry 426 + assertEquals(result, [fg1, fg2]); 427 + }); 428 + }); 429 + 430 + t.describe("$hydratedPosts (post hydration)", (it) => { 431 + const postURI = "at://did:test/app.bsky.feed.post/x"; 432 + 433 + it("should return null when the post does not exist", () => { 434 + const dataStore = new DataStore(); 435 + const { signals } = makeSignals(dataStore); 436 + assertEquals(signals.$hydratedPosts.get(postURI).get(), null); 437 + }); 438 + 439 + it("should mark the post when it contains a muted word", () => { 440 + const dataStore = new DataStore(); 441 + const { signals } = makeSignals(dataStore, { 442 + preferences: fakePreferences({ postHasMutedWord: () => true }), 443 + }); 444 + dataStore.$posts.set(postURI, { uri: postURI, record: { text: "hello" } }); 445 + const result = signals.$hydratedPosts.get(postURI).get(); 446 + assertEquals(result.viewer.hasMutedWord, true); 447 + }); 448 + 449 + it("should not mark the post when there is no muted word match", () => { 450 + const dataStore = new DataStore(); 451 + const { signals } = makeSignals(dataStore, { 452 + preferences: fakePreferences(), 453 + }); 454 + dataStore.$posts.set(postURI, { uri: postURI, record: { text: "hello" } }); 455 + const result = signals.$hydratedPosts.get(postURI).get(); 456 + assertEquals(result.viewer, undefined); 457 + }); 458 + 459 + it("should mark the post hidden when preferences say so", () => { 460 + const dataStore = new DataStore(); 461 + const { signals } = makeSignals(dataStore, { 462 + preferences: fakePreferences({ isPostHidden: () => true }), 463 + }); 464 + dataStore.$posts.set(postURI, { uri: postURI, record: { text: "hello" } }); 465 + const result = signals.$hydratedPosts.get(postURI).get(); 466 + assertEquals(result.viewer.isHidden, true); 467 + }); 468 + 469 + it("should attach badge, content, and media labels from preferences", () => { 470 + const dataStore = new DataStore(); 471 + const { signals } = makeSignals(dataStore, { 472 + preferences: fakePreferences({ 473 + getBadgeLabels: () => ["badge"], 474 + getContentLabel: () => "warn", 475 + getMediaLabel: () => "blur", 476 + }), 477 + }); 478 + dataStore.$posts.set(postURI, { uri: postURI, record: { text: "hello" } }); 479 + const result = signals.$hydratedPosts.get(postURI).get(); 480 + assertEquals(result.badgeLabels, ["badge"]); 481 + assertEquals(result.contentLabel, "warn"); 482 + assertEquals(result.mediaLabel, "blur"); 483 + }); 484 + 485 + it("should leave the post untouched when no labels apply", () => { 486 + const dataStore = new DataStore(); 487 + const { signals } = makeSignals(dataStore, { 488 + preferences: fakePreferences(), 489 + }); 490 + dataStore.$posts.set(postURI, { uri: postURI, record: { text: "hello" } }); 491 + const result = signals.$hydratedPosts.get(postURI).get(); 492 + assertEquals(result.badgeLabels, undefined); 493 + assertEquals(result.contentLabel, undefined); 494 + assertEquals(result.mediaLabel, undefined); 495 + }); 496 + 497 + it("should compose muted/hidden/label marks on a single post", () => { 498 + const dataStore = new DataStore(); 499 + const { signals } = makeSignals(dataStore, { 500 + preferences: fakePreferences({ 501 + postHasMutedWord: () => true, 502 + isPostHidden: () => true, 503 + getBadgeLabels: () => ["b"], 504 + }), 505 + }); 506 + dataStore.$posts.set(postURI, { uri: postURI, record: { text: "hello" } }); 507 + const result = signals.$hydratedPosts.get(postURI).get(); 508 + assertEquals(result.viewer.hasMutedWord, true); 509 + assertEquals(result.viewer.isHidden, true); 510 + assertEquals(result.badgeLabels, ["b"]); 511 + }); 512 + 513 + it("should return the post unchanged when there is no blocked quote to resolve", () => { 514 + const dataStore = new DataStore(); 515 + const { signals } = makeSignals(dataStore, { 516 + preferences: fakePreferences(), 517 + }); 518 + const post = { uri: postURI, record: { text: "hello" } }; 519 + dataStore.$posts.set(postURI, post); 520 + const result = signals.$hydratedPosts.get(postURI).get(); 521 + // hydratePostForView always returns a fresh clone 522 + assertEquals(result.uri, post.uri); 523 + assertEquals(result.record.text, "hello"); 524 + }); 525 + }); 526 + 527 + await t.run();
+42 -41
tests/unit/specs/plugins/pluginService.test.js
··· 1112 1112 assertEquals(service.getSlotEntries("x").length, 1); 1113 1113 dispose(); 1114 1114 assertEquals(service.getSlotEntries("x"), []); 1115 - assert(!service.registries.slots.has("x")); 1115 + assertEquals(service.$slots.get("x").get(), null); 1116 1116 }); 1117 1117 1118 - it("emits slotRegistered / slotUnregistered events", () => { 1118 + it("updates the $slots signal on register and unregister", () => { 1119 1119 const service = makeServiceWithRealBridge(); 1120 - const events = []; 1121 - service.on("slotRegistered", (data) => 1122 - events.push({ type: "reg", ...data }), 1123 - ); 1124 - service.on("slotUnregistered", (data) => 1125 - events.push({ type: "unreg", ...data }), 1126 - ); 1120 + const updates = []; 1121 + const slotSignal = service.$slots.get("x"); 1122 + const initial = slotSignal.get(); 1127 1123 const dispose = register(service, makePlugin("alpha"), { 1128 1124 target: "slot", 1129 1125 name: "x", 1130 1126 handlerId: 1, 1131 1127 }); 1128 + updates.push(slotSignal.get()?.map((entry) => entry.pluginId) ?? null); 1132 1129 dispose(); 1133 - assertEquals(events, [ 1134 - { type: "reg", name: "x" }, 1135 - { type: "unreg", name: "x" }, 1136 - ]); 1130 + updates.push(slotSignal.get()?.map((entry) => entry.pluginId) ?? null); 1131 + assertEquals(initial, null); 1132 + assertEquals(updates, [["alpha"], null]); 1137 1133 }); 1138 1134 }); 1139 1135 ··· 1143 1139 return new PluginService(provider, null); 1144 1140 } 1145 1141 1146 - it("getPost host method returns selectors.getPost result", async () => { 1147 - const service = makeServiceWithRealBridge(); 1142 + function makeStubSignalMap(lookup) { 1148 1143 const calls = []; 1149 - service.setDataLayer({ 1150 - selectors: { 1151 - getPost: (uri) => { 1152 - calls.push(uri); 1153 - return { uri, record: { text: "cached" } }; 1154 - }, 1144 + const map = { 1145 + get: (key) => { 1146 + calls.push(key); 1147 + return { get: () => lookup(key) }; 1155 1148 }, 1156 - base: { 1157 - getProfile: () => null, 1149 + }; 1150 + return { map, calls }; 1151 + } 1152 + 1153 + it("getPost host method returns the hydrated post from signals", async () => { 1154 + const service = makeServiceWithRealBridge(); 1155 + const posts = makeStubSignalMap((uri) => ({ 1156 + uri, 1157 + record: { text: "cached" }, 1158 + })); 1159 + service.setDataLayer({ 1160 + signals: { 1161 + $hydratedPosts: posts.map, 1162 + $hydratedProfiles: makeStubSignalMap(() => null).map, 1158 1163 }, 1159 1164 }); 1160 1165 const handler = service.pluginBridge._hostCallHandlers.get("getPost"); 1161 1166 const result = await handler(null, { uri: "at://example/post/1" }); 1162 - assertEquals(calls, ["at://example/post/1"]); 1167 + assertEquals(posts.calls, ["at://example/post/1"]); 1163 1168 assertEquals(result, { 1164 1169 uri: "at://example/post/1", 1165 1170 record: { text: "cached" }, 1166 1171 }); 1167 1172 }); 1168 1173 1169 - it("getProfile host method returns base.getProfile result", async () => { 1174 + it("getProfile host method returns the hydrated profile from signals", async () => { 1170 1175 const service = makeServiceWithRealBridge(); 1171 - const calls = []; 1176 + const profiles = makeStubSignalMap((did) => ({ 1177 + did, 1178 + handle: "alice.test", 1179 + })); 1172 1180 service.setDataLayer({ 1173 - selectors: { 1174 - getPost: () => null, 1175 - }, 1176 - base: { 1177 - getProfile: (did) => { 1178 - calls.push(did); 1179 - return { did, handle: "alice.test" }; 1180 - }, 1181 + signals: { 1182 + $hydratedPosts: makeStubSignalMap(() => null).map, 1183 + $hydratedProfiles: profiles.map, 1181 1184 }, 1182 1185 }); 1183 1186 const handler = service.pluginBridge._hostCallHandlers.get("getProfile"); 1184 1187 const result = await handler(null, { did: "did:plc:abc" }); 1185 - assertEquals(calls, ["did:plc:abc"]); 1188 + assertEquals(profiles.calls, ["did:plc:abc"]); 1186 1189 assertEquals(result, { did: "did:plc:abc", handle: "alice.test" }); 1187 1190 }); 1188 1191 ··· 1193 1196 assertEquals(result, null); 1194 1197 }); 1195 1198 1196 - it("getProfile returns null when base returns null (uncached)", async () => { 1199 + it("getProfile returns null when the hydrated profile signal is empty", async () => { 1197 1200 const service = makeServiceWithRealBridge(); 1198 1201 service.setDataLayer({ 1199 - selectors: { 1200 - getPost: () => null, 1201 - }, 1202 - base: { 1203 - getProfile: () => null, 1202 + signals: { 1203 + $hydratedPosts: makeStubSignalMap(() => null).map, 1204 + $hydratedProfiles: makeStubSignalMap(() => null).map, 1204 1205 }, 1205 1206 }); 1206 1207 const handler = service.pluginBridge._hostCallHandlers.get("getProfile");