Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
5

Configure Feed

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

Refactor following feed data model

Grace Kind (Jul 16, 2026, 3:31 PM -0500) 399419ad caa7e56f

+76 -50
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.185", 3 + "version": "0.17.186", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+1
src/js/config.js
··· 7 7 export const AUTHOR_FEED_PAGE_SIZE = 40; 8 8 export const LOGGED_OUT_FEED_URI = 9 9 "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot"; 10 + export const FOLLOWING_FEED_URI = "following"; 10 11 export const CHAT_MESSAGES_PAGE_SIZE = 100; 11 12 12 13 // Plugins
+5 -1
src/js/dataHelpers.js
··· 1 1 import { unique } from "/js/utils.js"; 2 - import { IN_APP_LINK_DOMAINS } from "/js/config.js"; 2 + import { FOLLOWING_FEED_URI, IN_APP_LINK_DOMAINS } from "/js/config.js"; 3 3 4 4 export const INVALID_HANDLE = "handle.invalid"; 5 5 export const MISSING_HANDLE = "missing.invalid"; ··· 40 40 41 41 export function isListFeed(feedURI) { 42 42 return feedURI.includes("/app.bsky.graph.list/"); 43 + } 44 + 45 + export function isFollowingFeedUri(feedURI) { 46 + return feedURI === FOLLOWING_FEED_URI; 43 47 } 44 48 45 49 export function getRKey(record) {
+6 -4
src/js/dataLayer/derived.js
··· 23 23 transformNestedQuotes, 24 24 attachJoinLinkPreviewToEmbed, 25 25 getJoinLinkCodeFromEmbed, 26 + isFollowingFeedUri, 26 27 } from "/js/dataHelpers.js"; 27 28 import { sortBy } from "/js/utils.js"; 29 + import { FOLLOWING_FEED_URI } from "/js/config.js"; 28 30 import { 29 31 effect, 30 32 Signal, ··· 166 168 }; 167 169 const pluginFilteredFeedItems = 168 170 this.pluginService.$pluginFilteredFeedItems.get(feedURI) ?? {}; 169 - if (feedURI === "following") { 171 + if (isFollowingFeedUri(feedURI)) { 170 172 const currentUser = this.$currentUser.get(); 171 173 const preferences = this.$preferences.get(); 172 174 return filterFollowingFeed( ··· 319 321 const pinnedItems = this.dataStore.$pinnedItems.get(); 320 322 if (!pinnedItems) return null; 321 323 return pinnedItems.map((item) => { 322 - if (item.type === "following") { 324 + if (item.type === "timeline") { 323 325 return { 324 - type: "following", 326 + type: "timeline", 325 327 data: item.data, 326 - uri: "following", 328 + uri: FOLLOWING_FEED_URI, 327 329 displayName: "Following", 328 330 }; 329 331 }
+22 -16
src/js/dataLayer/requests.js
··· 9 9 getPostUrisFromNotifications, 10 10 buildUri, 11 11 parseUri, 12 - isListFeed, 13 12 isGroupConvo, 14 13 getGroupConvoDetails, 15 14 getJoinLinkCodesFromPosts, ··· 21 20 import { unique } from "/js/utils.js"; 22 21 import { SignalMap, ComputedMap, ReactiveStore } from "/js/signals.js"; 23 22 import { ApiError } from "/js/api.js"; 23 + import { FOLLOWING_FEED_URI } from "/js/config.js"; 24 24 25 25 const CONVO_LOG_SYSTEM_MESSAGE_TYPES = new Set([ 26 26 "chat.bsky.convo.defs#logAddMember", ··· 182 182 ); 183 183 this.enableStatus( 184 184 this.loadNextFeedPage, 185 - (feedURI) => "loadNextFeedPage-" + feedURI, 185 + ({ uri }) => "loadNextFeedPage-" + uri, 186 186 ); 187 187 this.enableStatus( 188 188 this.loadDetailedProfile, ··· 469 469 return loadedReplies; 470 470 } 471 471 472 - async loadNextFeedPage(feedURI, { reload = false, limit = 31 } = {}) { 472 + async loadNextFeedPage({ type, uri }, { reload = false, limit = 31 } = {}) { 473 473 const labelers = this.requireLabelers(); 474 474 const cursor = reload 475 475 ? "" 476 - : readCollectionCursor(this.dataStore.$feeds, { key: feedURI }); 477 - const feed = 478 - feedURI === "following" 479 - ? await this.api.getFollowingFeed({ limit, cursor, labelers }) 480 - : isListFeed(feedURI) 481 - ? await this.api.getListFeed(feedURI, { limit, cursor, labelers }) 482 - : await this.api.getFeed(feedURI, { limit, cursor, labelers }); 483 - // Save posts 476 + : readCollectionCursor(this.dataStore.$feeds, { key: uri }); 477 + let feed; 478 + switch (type) { 479 + case "timeline": 480 + feed = await this.api.getFollowingFeed({ limit, cursor, labelers }); 481 + break; 482 + case "list": 483 + feed = await this.api.getListFeed(uri, { limit, cursor, labelers }); 484 + break; 485 + case "feed": 486 + feed = await this.api.getFeed(uri, { limit, cursor, labelers }); 487 + break; 488 + default: 489 + throw new Error(`Unknown pinned item type: ${type}`); 490 + } 484 491 const postsToSave = getPostsFromFeed(feed); 485 492 await this._loadPostDependencies(postsToSave); 486 493 this.dataStore.setPosts(postsToSave); 487 - // Filter posts with plugins 488 - await this.pluginService.refreshFiltersForFeed(feedURI, feed, { reload }); 494 + await this.pluginService.refreshFiltersForFeed(uri, feed, { reload }); 489 495 writePageToCollection(this.dataStore.$feeds, "feed", feed, { 490 - key: feedURI, 496 + key: uri, 491 497 requestCursor: cursor, 492 498 overwrite: reload, 493 499 }); ··· 1141 1147 for (const item of pinnedFeeds) { 1142 1148 if (item.type === "timeline") { 1143 1149 orderedItems.push({ 1144 - type: "following", 1145 - data: { uri: "following", displayName: "Following" }, 1150 + type: "timeline", 1151 + data: { uri: FOLLOWING_FEED_URI, displayName: "Following" }, 1146 1152 }); 1147 1153 } else if (item.type === "feed") { 1148 1154 const fg = feedGeneratorMap.get(item.value);
+4 -4
src/js/views/feedDetail.view.js
··· 123 123 }); 124 124 125 125 async function loadFeed({ reload = false } = {}) { 126 - await dataLayer.requests.loadNextFeedPage(feedUri, { 127 - reload, 128 - limit: FEED_PAGE_SIZE + 1, 129 - }); 126 + await dataLayer.requests.loadNextFeedPage( 127 + { type: "feed", uri: feedUri }, 128 + { reload, limit: FEED_PAGE_SIZE + 1 }, 129 + ); 130 130 } 131 131 132 132 root.addEventListener("page-enter", async () => {
+1 -1
src/js/views/feeds.view.js
··· 27 27 <div class="feeds-list"> 28 28 ${pinnedItems 29 29 ? pinnedItems.map((item) => { 30 - if (item.type === "following") { 30 + if (item.type === "timeline") { 31 31 return html` 32 32 <div class="feeds-list-item"> 33 33 <div class="feeds-list-item-avatar">
+17 -7
src/js/views/home.view.js
··· 6 6 import { floatingComposeButtonTemplate } from "/js/templates/floatingComposeButton.template.js"; 7 7 import "/js/components/tab-bar.js"; 8 8 import { PostSeenObserver } from "/js/postSeenObserver.js"; 9 - import { FEED_PAGE_SIZE, LOGGED_OUT_FEED_URI } from "/js/config.js"; 9 + import { 10 + FEED_PAGE_SIZE, 11 + FOLLOWING_FEED_URI, 12 + LOGGED_OUT_FEED_URI, 13 + } from "/js/config.js"; 10 14 import { bindToPage, pageEffect } from "/js/router.js"; 11 15 import { showToast } from "/js/toasts.js"; 12 16 import { Signal, ReactiveStore } from "/js/signals.js"; ··· 40 44 41 45 function resetToDefaultFeed() { 42 46 state.$currentFeedUri.set( 43 - isAuthenticated ? "following" : LOGGED_OUT_FEED_URI, 47 + isAuthenticated ? FOLLOWING_FEED_URI : LOGGED_OUT_FEED_URI, 44 48 ); 45 49 } 46 50 ··· 286 290 }); 287 291 }); 288 292 293 + function getFeedRequestDescriptor(uri) { 294 + const pinnedItems = dataLayer.derived.$hydratedPinnedItems.get() ?? []; 295 + const item = pinnedItems.find((i) => i.uri === uri); 296 + return item ? { type: item.type, uri } : { type: "feed", uri }; 297 + } 298 + 289 299 async function loadCurrentFeed({ reload = false } = {}) { 290 300 const currentFeedUri = state.$currentFeedUri.get(); 291 - await dataLayer.requests.loadNextFeedPage(currentFeedUri, { 292 - reload, 293 - limit: FEED_PAGE_SIZE + 1, 294 - }); 301 + await dataLayer.requests.loadNextFeedPage( 302 + getFeedRequestDescriptor(currentFeedUri), 303 + { reload, limit: FEED_PAGE_SIZE + 1 }, 304 + ); 295 305 } 296 306 297 307 async function preloadHiddenFeeds(pinnedItems) { ··· 300 310 .filter((item) => item.uri !== currentFeedUri) 301 311 .slice(0, 3); // Up to 3 feeds 302 312 for (const item of itemsToPreload) { 303 - await dataLayer.requests.loadNextFeedPage(item.uri, { 313 + await dataLayer.requests.loadNextFeedPage(item, { 304 314 limit: FEED_PAGE_SIZE + 1, 305 315 }); 306 316 }
+4 -4
src/js/views/listDetail.view.js
··· 288 288 }); 289 289 290 290 async function loadFeed({ reload = false } = {}) { 291 - await dataLayer.requests.loadNextFeedPage(listUri, { 292 - reload, 293 - limit: FEED_PAGE_SIZE + 1, 294 - }); 291 + await dataLayer.requests.loadNextFeedPage( 292 + { type: "list", uri: listUri }, 293 + { reload, limit: FEED_PAGE_SIZE + 1 }, 294 + ); 295 295 } 296 296 297 297 async function loadMembers({ reload = false } = {}) {
+3 -3
tests/unit/specs/dataLayer/derived.test.js
··· 527 527 assert.deepEqual(result[1].displayName, "Feed Two"); 528 528 }); 529 529 530 - it("should hydrate list and following entries", () => { 530 + it("should hydrate list and timeline entries", () => { 531 531 const dataStore = new DataStore(); 532 532 const { derived } = makeDerived(dataStore); 533 533 const list = { uri: "list-1", name: "My List" }; 534 534 dataStore.$pinnedItems.set([ 535 - { type: "following", data: { uri: "following" } }, 535 + { type: "timeline", data: { uri: "following" } }, 536 536 { type: "list", data: list }, 537 537 ]); 538 538 const result = derived.$hydratedPinnedItems.get(); 539 - assert.deepEqual(result[0].type, "following"); 539 + assert.deepEqual(result[0].type, "timeline"); 540 540 assert.deepEqual(result[0].displayName, "Following"); 541 541 assert.deepEqual(result[1].type, "list"); 542 542 assert.deepEqual(result[1].uri, "list-1");
+12 -9
tests/unit/specs/dataLayer/requests.test.js
··· 126 126 mockPreferencesProvider, 127 127 ); 128 128 129 - await requests.loadNextFeedPage(feedURI); 129 + await requests.loadNextFeedPage({ type: "feed", uri: feedURI }); 130 130 131 131 // Check feed was stored 132 132 assert.deepEqual(dataStore.$feeds.get(feedURI), mockFeed); ··· 167 167 mockPreferencesProvider, 168 168 ); 169 169 170 - await requests.loadNextFeedPage(feedURI); 170 + await requests.loadNextFeedPage({ type: "feed", uri: feedURI }); 171 171 172 172 // Check feed was appended 173 173 const storedFeed = dataStore.$feeds.get(feedURI); ··· 210 210 mockPreferencesProvider, 211 211 ); 212 212 213 - await requests.loadNextFeedPage(feedURI); 213 + await requests.loadNextFeedPage({ type: "feed", uri: feedURI }); 214 214 215 215 assert.deepEqual(dataStore.$feeds.get(feedURI), reloadedFeed); 216 216 }); ··· 240 240 { constellation: stubConstellation }, 241 241 ); 242 242 243 - await requests.loadNextFeedPage(feedURI); 244 - await requests.loadNextFeedPage(feedURI, { reload: true }); 243 + await requests.loadNextFeedPage({ type: "feed", uri: feedURI }); 244 + await requests.loadNextFeedPage( 245 + { type: "feed", uri: feedURI }, 246 + { reload: true }, 247 + ); 245 248 246 249 assert.deepEqual(capturedReloads, [false, true]); 247 250 }); ··· 266 269 mockPreferencesProvider, 267 270 ); 268 271 269 - await requests.loadNextFeedPage(feedURI); 272 + await requests.loadNextFeedPage({ type: "feed", uri: feedURI }); 270 273 271 274 assert.deepEqual(dataStore.$feeds.get(feedURI), emptyFeed); 272 275 }); ··· 305 308 mockPreferencesProvider, 306 309 ); 307 310 308 - await requests.loadNextFeedPage(feedURI); 311 + await requests.loadNextFeedPage({ type: "feed", uri: feedURI }); 309 312 310 313 assert.deepEqual(dataStore.$feeds.get(feedURI), feedWithReplies); 311 314 assert.deepEqual(dataStore.$posts.get("post1").uri, normalizedPosts[0].uri); ··· 2545 2548 assert.deepEqual(pinned.length, 4); 2546 2549 assert.deepEqual(pinned[0].type, "feed"); 2547 2550 assert.deepEqual(pinned[2].type, "list"); 2548 - assert.deepEqual(pinned[3].type, "following"); 2551 + assert.deepEqual(pinned[3].type, "timeline"); 2549 2552 assert.deepEqual( 2550 2553 dataStore.$feedGenerators.get("at://did/feed/one").displayName, 2551 2554 "name-at://did/feed/one", ··· 2578 2581 assert.deepEqual(listCalled, false); 2579 2582 const pinned = dataStore.$pinnedItems.get(); 2580 2583 assert.deepEqual(pinned.length, 1); 2581 - assert.deepEqual(pinned[0].type, "following"); 2584 + assert.deepEqual(pinned[0].type, "timeline"); 2582 2585 }); 2583 2586 }); 2584 2587