[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.

Add list detail view

Grace Kind (May 31, 2026, 5:12 PM -0500) b3211314 ffea4af3

+1005 -28
+65 -8
src/css/style.css
··· 891 891 892 892 .tab-bar { 893 893 display: flex; 894 - height: 45px; 894 + height: 46px; 895 895 } 896 896 897 897 .tab-bar .tab-bar-button { ··· 941 941 margin-bottom: 8px; 942 942 } 943 943 944 - .profile-tab-bar { 944 + .profile-tab-bar, 945 + .list-detail-tab-bar { 945 946 position: sticky; 946 947 top: 0; 947 948 background: var(--background-color); ··· 4275 4276 display: flex; 4276 4277 flex-direction: column; 4277 4278 gap: 0; 4279 + padding-bottom: calc(var(--footer-height) + var(--safe-area-inset-bottom)); 4278 4280 } 4279 4281 4280 4282 .feeds-list-header { ··· 4357 4359 align-items: center; 4358 4360 } 4359 4361 4360 - .feed-menu-button { 4362 + .feed-menu-button, 4363 + .list-menu-button { 4361 4364 border: none; 4362 4365 background: none; 4363 4366 cursor: pointer; ··· 4370 4373 margin-right: 12px; 4371 4374 } 4372 4375 4373 - .feed-menu-button span { 4376 + .feed-menu-button span, 4377 + .list-menu-button span { 4374 4378 position: relative; 4375 4379 top: -2px; 4376 4380 } 4377 4381 4378 - #feed-detail-view .pin-feed-button { 4382 + #feed-detail-view .pin-feed-button, 4383 + #list-detail-view .pin-feed-button { 4379 4384 width: 24px; 4380 4385 height: 24px; 4381 4386 border: none; ··· 4385 4390 font-size: 24px; 4386 4391 } 4387 4392 4388 - #feed-detail-view .pin-feed-button.pinned { 4393 + #feed-detail-view .pin-feed-button.pinned, 4394 + #list-detail-view .pin-feed-button.pinned { 4389 4395 color: var(--highlight-color); 4396 + } 4397 + 4398 + .list-detail-header { 4399 + display: flex; 4400 + flex-direction: row; 4401 + align-items: flex-start; 4402 + gap: 10px; 4403 + padding: 14px 12px; 4404 + } 4405 + 4406 + .list-detail-avatar { 4407 + width: 48px; 4408 + height: 48px; 4409 + border-radius: 8px; 4410 + object-fit: cover; 4411 + flex-shrink: 0; 4412 + } 4413 + 4414 + .list-detail-header-text { 4415 + flex: 1; 4416 + min-width: 0; 4417 + display: flex; 4418 + flex-direction: column; 4419 + gap: 4px; 4420 + padding-top: 2px; 4421 + } 4422 + 4423 + .list-detail-name { 4424 + font-size: 22px; 4425 + font-weight: 600; 4426 + line-height: 1.2; 4427 + word-wrap: break-word; 4428 + } 4429 + 4430 + .list-detail-creator { 4431 + color: var(--text-color-muted); 4432 + font-size: 14px; 4433 + line-height: 1.3; 4434 + } 4435 + 4436 + .list-detail-description { 4437 + padding: 0 12px 14px; 4438 + font-size: 15px; 4439 + line-height: 1.4; 4440 + word-wrap: break-word; 4441 + } 4442 + 4443 + .list-detail-loading { 4444 + display: flex; 4445 + justify-content: center; 4446 + padding: 32px 0; 4390 4447 } 4391 4448 4392 4449 .feeds-list-item .pin-feed-button svg { ··· 7003 7060 cursor: not-allowed; 7004 7061 } 7005 7062 7006 - #notifications-view header .tab-bar { 7063 + .tab-bar.tab-bar-full-width { 7007 7064 width: 100%; 7008 7065 } 7009 7066 7010 - #notifications-view header .tab-bar-button { 7067 + .tab-bar.tab-bar-full-width .tab-bar-button { 7011 7068 flex: 1; 7012 7069 } 7013 7070
+5
src/index.html
··· 88 88 import settingsCommunityPluginsView from "/js/views/settings/communityPlugins.view.js"; 89 89 import settingsCommunityPluginListingView from "/js/views/settings/communityPluginListing.view.js"; 90 90 import feedDetailView from "/js/views/feedDetail.view.js"; 91 + import listDetailView from "/js/views/listDetail.view.js"; 91 92 import bookmarksView from "/js/views/bookmarks.view.js"; 92 93 import { DataLayer } from "/js/dataLayer/dataLayer.js"; 93 94 import { PreferencesProvider } from "/js/dataLayer/preferencesProvider.js"; ··· 255 256 router.addRoute( 256 257 "/profile/:handleOrDid/feed/:rkey", 257 258 () => feedDetailView, 259 + ); 260 + router.addRoute( 261 + "/profile/:handleOrDid/lists/:rkey", 262 + () => listDetailView, 258 263 ); 259 264 router.addRoute( 260 265 "/profile/:handleOrDid/post/:rkey/likes",
+7 -3
src/js/api.js
··· 284 284 return res.data.feeds; 285 285 } 286 286 287 - async getList(listURI) { 287 + async getList(listURI, { limit = 1, cursor = "" } = {}) { 288 + const query = { list: listURI, limit }; 289 + if (cursor) { 290 + query.cursor = cursor; 291 + } 288 292 const res = await this.request(`app.bsky.graph.getList`, { 289 - query: { list: listURI, limit: 1 }, 293 + query, 290 294 headers: { 291 295 "atproto-proxy": this.bskyAppViewServiceDid, 292 296 }, 293 297 }); 294 - return res.data.list; 298 + return res.data; 295 299 } 296 300 297 301 async getListFeed(listURI, { limit = 31, cursor = "", labelers = [] } = {}) {
+2
src/js/dataLayer/dataStore.js
··· 41 41 this.$postQuotes = new SignalMap(); 42 42 this.$postReposts = new SignalMap(); 43 43 this.$feedGenerators = new SignalMap(); 44 + this.$lists = new SignalMap(); 45 + this.$listMembers = new SignalMap(); 44 46 this.$actorFeeds = new SignalMap(); 45 47 this.$hashtagFeeds = new SignalMap(); 46 48 this.$profileFollowers = new SignalMap();
+12
src/js/dataLayer/declarative.js
··· 82 82 return feedGenerator; 83 83 } 84 84 85 + async ensureList(listUri) { 86 + let list = this.derived.$lists.get(listUri); 87 + if (!list) { 88 + await this.requests.loadList(listUri); 89 + list = this.derived.$lists.get(listUri); 90 + } 91 + if (!list) { 92 + throw new Error("List not found"); 93 + } 94 + return list; 95 + } 96 + 85 97 async ensurePinnedItems() { 86 98 let pinnedItems = this.derived.$hydratedPinnedItems.get(); 87 99 if (!pinnedItems) {
+6
src/js/dataLayer/derived.js
··· 379 379 this.$feedGenerators = new ComputedMap((feedUri) => 380 380 this.dataStore.$feedGenerators.get(feedUri), 381 381 ); 382 + this.$lists = new ComputedMap((listUri) => 383 + this.dataStore.$lists.get(listUri), 384 + ); 385 + this.$listMembers = new ComputedMap((listUri) => 386 + this.dataStore.$listMembers.get(listUri), 387 + ); 382 388 this.$profileSearchResults = new Signal.Computed(() => { 383 389 const data = this.dataStore.$profileSearchResults.get(); 384 390 if (!data) return null;
+37 -3
src/js/dataLayer/mutations.js
··· 299 299 const patchId = this.patchStore.addPreferencePatch({ 300 300 type: "pinFeed", 301 301 feedUri, 302 + entryType: "feed", 302 303 }); 303 304 const preferences = this.preferencesProvider.requirePreferences(); 304 - const newPreferences = preferences.pinFeed(feedUri); 305 + const newPreferences = preferences.pinFeed(feedUri, "feed"); 305 306 try { 306 307 await this.preferencesProvider.updatePreferences(newPreferences); 307 308 } catch (error) { 308 309 console.error(error); 309 310 throw error; 310 311 } finally { 311 - // clear patch 312 + this.patchStore.removePreferencePatch(patchId); 313 + } 314 + } 315 + 316 + async pinList(listUri) { 317 + const patchId = this.patchStore.addPreferencePatch({ 318 + type: "pinFeed", 319 + feedUri: listUri, 320 + entryType: "list", 321 + }); 322 + const preferences = this.preferencesProvider.requirePreferences(); 323 + const newPreferences = preferences.pinFeed(listUri, "list"); 324 + try { 325 + await this.preferencesProvider.updatePreferences(newPreferences); 326 + } catch (error) { 327 + console.error(error); 328 + throw error; 329 + } finally { 312 330 this.patchStore.removePreferencePatch(patchId); 313 331 } 314 332 } ··· 326 344 console.error(error); 327 345 throw error; 328 346 } finally { 329 - // clear patch 347 + this.patchStore.removePreferencePatch(patchId); 348 + } 349 + } 350 + 351 + async unpinList(listUri) { 352 + const patchId = this.patchStore.addPreferencePatch({ 353 + type: "unpinFeed", 354 + feedUri: listUri, 355 + }); 356 + const preferences = this.preferencesProvider.requirePreferences(); 357 + const newPreferences = preferences.unpinFeed(listUri); 358 + try { 359 + await this.preferencesProvider.updatePreferences(newPreferences); 360 + } catch (error) { 361 + console.error(error); 362 + throw error; 363 + } finally { 330 364 this.patchStore.removePreferencePatch(patchId); 331 365 } 332 366 }
+1 -1
src/js/dataLayer/patchStore.js
··· 324 324 applyPreferencePatch(preferences, patchBody) { 325 325 switch (patchBody.type) { 326 326 case "pinFeed": 327 - return preferences.pinFeed(patchBody.feedUri); 327 + return preferences.pinFeed(patchBody.feedUri, patchBody.entryType); 328 328 case "unpinFeed": 329 329 return preferences.unpinFeed(patchBody.feedUri); 330 330 case "subscribeLabeler":
+32 -3
src/js/dataLayer/requests.js
··· 913 913 this.dataStore.$feedGenerators.set(feedUri, feedGeneratorData); 914 914 } 915 915 916 + async loadList(listUri) { 917 + const data = await this.api.getList(listUri, { limit: 1 }); 918 + this.dataStore.$lists.set(listUri, data.list); 919 + } 920 + 921 + async loadListMembers(listUri, { reload = false, limit = 50 } = {}) { 922 + const existing = this.dataStore.$listMembers.get(listUri); 923 + let cursor = existing ? existing.cursor : ""; 924 + if (reload) { 925 + cursor = ""; 926 + } 927 + if (existing && !existing.cursor && !reload) { 928 + return; 929 + } 930 + const data = await this.api.getList(listUri, { limit, cursor }); 931 + const newMembers = (data.items ?? []).map((item) => item.subject); 932 + if (reload || !existing) { 933 + this.dataStore.$listMembers.set(listUri, { 934 + members: newMembers, 935 + cursor: data.cursor || null, 936 + }); 937 + } else { 938 + this.dataStore.$listMembers.set(listUri, { 939 + members: [...existing.members, ...newMembers], 940 + cursor: data.cursor || null, 941 + }); 942 + } 943 + } 944 + 916 945 async loadPinnedItems() { 917 946 const preferences = this.preferencesProvider.requirePreferences(); 918 947 const pinnedFeeds = preferences.getPinnedFeeds(); ··· 932 961 ]); 933 962 const listViews = listResults 934 963 .filter((result) => result.status === "fulfilled") 935 - .map((result) => result.value); 964 + .map((result) => result.value.list); 936 965 937 966 for (const feedGenerator of feedGenerators) { 938 967 this.dataStore.$feedGenerators.set(feedGenerator.uri, feedGenerator); ··· 975 1004 if (reload || !existing) { 976 1005 this.dataStore.$actorFeeds.set(did, { 977 1006 feeds: data.feeds, 978 - cursor: data.cursor ?? null, 1007 + cursor: data.cursor || null, 979 1008 }); 980 1009 } else { 981 1010 this.dataStore.$actorFeeds.set(did, { 982 1011 feeds: [...existing.feeds, ...data.feeds], 983 - cursor: data.cursor ?? null, 1012 + cursor: data.cursor || null, 984 1013 }); 985 1014 } 986 1015 }
+4
src/js/interactionHandlers.js
··· 1 1 import { PostInteractionHandler } from "/js/postInteractionHandler.js"; 2 2 import { ProfileInteractionHandler } from "/js/profileInteractionHandler.js"; 3 3 import { FeedInteractionHandler } from "/js/feedInteractionHandler.js"; 4 + import { ListInteractionHandler } from "/js/listInteractionHandler.js"; 4 5 5 6 function loggedOutHandler(name) { 6 7 return new Proxy( ··· 28 29 this.feedInteractionHandler = session 29 30 ? new FeedInteractionHandler(dataLayer) 30 31 : loggedOutHandler("feedInteractionHandler"); 32 + this.listInteractionHandler = session 33 + ? new ListInteractionHandler(dataLayer) 34 + : loggedOutHandler("listInteractionHandler"); 31 35 } 32 36 }
+29
src/js/listInteractionHandler.js
··· 1 + import { hapticsImpactMedium } from "/js/haptics.js"; 2 + import { showToast } from "/js/toasts.js"; 3 + 4 + export class ListInteractionHandler { 5 + constructor(dataLayer) { 6 + this.dataLayer = dataLayer; 7 + } 8 + 9 + async handlePinList(listUri, doPin) { 10 + if (doPin) { 11 + try { 12 + hapticsImpactMedium(); 13 + await this.dataLayer.mutations.pinList(listUri); 14 + showToast("List pinned"); 15 + } catch (error) { 16 + console.error(error); 17 + showToast("Failed to pin list", { style: "error" }); 18 + } 19 + } else { 20 + try { 21 + await this.dataLayer.mutations.unpinList(listUri); 22 + showToast("List unpinned"); 23 + } catch (error) { 24 + console.error(error); 25 + showToast("Failed to unpin list", { style: "error" }); 26 + } 27 + } 28 + } 29 + }
+6
src/js/navigation.js
··· 59 59 )}`; 60 60 } 61 61 62 + export function linkToList(list) { 63 + return `/profile/${encodePathSegment(profileIdentifier(list.creator))}/lists/${encodePathSegment( 64 + getRKey(list), 65 + )}`; 66 + } 67 + 62 68 export function linkToSearchPostsByProfile(profile) { 63 69 const searchString = `from:@${profile.handle} `; 64 70 const query = new URLSearchParams();
+7 -2
src/js/templates/tabBar.template.js
··· 1 1 import { html } from "/js/lib/lit-html.js"; 2 2 import { classnames } from "/js/utils.js"; 3 3 4 - export function tabBarTemplate({ tabs, activeTab, onTabClick }) { 4 + export function tabBarTemplate({ 5 + tabs, 6 + activeTab, 7 + onTabClick, 8 + fullWidth = false, 9 + }) { 5 10 return html` 6 - <div class="tab-bar"> 11 + <div class=${classnames("tab-bar", { "tab-bar-full-width": fullWidth })}> 7 12 ${tabs.map( 8 13 (tab) => 9 14 html`<button
-4
src/js/views/feedDetail.view.js
··· 66 66 render( 67 67 html`<div id="feed-detail-view"> 68 68 ${mainLayoutTemplate({ 69 - onClickActiveNavItem: () => { 70 - window.router.back(); 71 - }, 72 69 onClickComposeButton: () => 73 70 postComposerService.composePost({ currentUser }), 74 71 numNotifications, 75 72 numChatNotifications, 76 73 currentUser, 77 74 showSidebarOverlay: false, 78 - activeNavItem: null, 79 75 pluginService, 80 76 children: html`${headerTemplate({ 81 77 title: feedName,
+7 -1
src/js/views/feeds.view.js
··· 6 6 import { headerTemplate } from "/js/templates/header.template.js"; 7 7 import { feedGeneratorListItemTemplate } from "/js/templates/feedGeneratorListItem.template.js"; 8 8 import { feedGeneratorListItemSkeletonTemplate } from "/js/templates/feedGeneratorListItemSkeleton.template.js"; 9 + import { linkToList } from "/js/navigation.js"; 9 10 10 11 class FeedsView extends View { 11 12 async render({ ··· 70 71 } 71 72 if (item.type === "list") { 72 73 return html` 73 - <div class="feeds-list-item"> 74 + <div 75 + class="feeds-list-item clickable" 76 + data-testid="feeds-list-item-list" 77 + @click=${() => 78 + window.router.go(linkToList(item.data))} 79 + > 74 80 <div class="feeds-list-item-avatar"> 75 81 ${item.data.avatar 76 82 ? html`<img
+1
src/js/views/hashtag.view.js
··· 98 98 tabs: sortOptions, 99 99 activeTab: currentSort, 100 100 onTabClick: handleTabClick, 101 + fullWidth: true, 101 102 }), 102 103 })} 103 104 ${sortOptions.map((sort) => {
+271
src/js/views/listDetail.view.js
··· 1 + import { View } from "/js/views/view.js"; 2 + import { html, render } from "/js/lib/lit-html.js"; 3 + import { Signal } from "/js/signals.js"; 4 + import { classnames } from "/js/utils.js"; 5 + import { postFeedTemplate } from "/js/templates/postFeed.template.js"; 6 + import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; 7 + import { auth } from "/js/auth.js"; 8 + import { mainLayoutTemplate } from "/js/templates/mainLayout.template.js"; 9 + import { headerTemplate } from "/js/templates/header.template.js"; 10 + import { tabBarTemplate } from "/js/templates/tabBar.template.js"; 11 + import { pinIconTemplate } from "/js/templates/icons/pinIcon.template.js"; 12 + import { richTextTemplate } from "/js/templates/richText.template.js"; 13 + import { pageEffect } from "/js/router.js"; 14 + import { FEED_PAGE_SIZE } from "/js/config.js"; 15 + import { showToast } from "/js/toasts.js"; 16 + import "/js/components/infinite-scroll-container.js"; 17 + import "/js/components/context-menu.js"; 18 + import "/js/components/context-menu-item.js"; 19 + 20 + const CURATELIST_PURPOSE = "app.bsky.graph.defs#curatelist"; 21 + 22 + class ListDetailView extends View { 23 + async render({ 24 + root, 25 + params, 26 + context: { 27 + dataLayer, 28 + identityResolver, 29 + notificationService, 30 + chatNotificationService, 31 + postComposerService, 32 + isAuthenticated, 33 + pluginService, 34 + interactionHandlers, 35 + }, 36 + }) { 37 + await auth.requireAuth(); 38 + 39 + const { handleOrDid, rkey } = params; 40 + 41 + let profileDid = null; 42 + if (handleOrDid.startsWith("did:")) { 43 + profileDid = handleOrDid; 44 + } else { 45 + profileDid = await identityResolver.resolveHandle(handleOrDid); 46 + } 47 + const listUri = `at://${profileDid}/app.bsky.graph.list/${rkey}`; 48 + 49 + const { postInteractionHandler, listInteractionHandler } = 50 + interactionHandlers; 51 + 52 + const $activeTab = new Signal.State("posts"); 53 + 54 + pageEffect(root, () => { 55 + const showLessInteractions = 56 + dataLayer.derived.$showLessInteractions.get() ?? []; 57 + const hiddenPostUris = showLessInteractions.map( 58 + (interaction) => interaction.item, 59 + ); 60 + const numNotifications = 61 + notificationService?.$numNotifications.get() ?? null; 62 + const numChatNotifications = 63 + chatNotificationService?.$numNotifications.get() ?? null; 64 + const currentUser = dataLayer.derived.$currentUser.get(); 65 + const list = dataLayer.derived.$lists.get(listUri); 66 + const listCreator = list?.creator; 67 + const listCreatorHandle = listCreator?.handle; 68 + const preferences = dataLayer.derived.$preferences.get(); 69 + const isPinned = preferences?.isFeedPinned(listUri) ?? false; 70 + const feed = dataLayer.derived.$hydratedFeeds.get(listUri); 71 + const membersEntry = dataLayer.derived.$listMembers.get(listUri); 72 + const members = membersEntry?.members ?? null; 73 + const hasMoreMembers = membersEntry?.cursor != null; 74 + const activeTab = $activeTab.get(); 75 + const isCurateList = !list || list.purpose === CURATELIST_PURPOSE; 76 + 77 + const listPermalink = `https://bsky.app/profile/${listCreatorHandle || handleOrDid}/lists/${rkey}`; 78 + 79 + render( 80 + html`<div id="list-detail-view"> 81 + ${mainLayoutTemplate({ 82 + onClickComposeButton: () => 83 + postComposerService.composePost({ currentUser }), 84 + numNotifications, 85 + numChatNotifications, 86 + currentUser, 87 + pluginService, 88 + children: html`${headerTemplate({ 89 + rightItemTemplate: list 90 + ? () => html` 91 + <button 92 + class="list-menu-button" 93 + @click=${function (e) { 94 + const contextMenu = this.nextElementSibling; 95 + contextMenu.open(e.clientX, e.clientY); 96 + }} 97 + > 98 + <span>...</span> 99 + </button> 100 + <context-menu> 101 + <context-menu-item 102 + data-testid="menu-action-list-open-in-bsky" 103 + @click=${() => { 104 + window.open(listPermalink, "_blank"); 105 + }} 106 + > 107 + Open in bsky.app 108 + </context-menu-item> 109 + <context-menu-item 110 + data-testid="menu-action-list-copy-link" 111 + @click=${() => { 112 + navigator.clipboard.writeText(listPermalink); 113 + showToast("Link copied to clipboard", { 114 + style: "success", 115 + }); 116 + }} 117 + > 118 + Copy link to list 119 + </context-menu-item> 120 + </context-menu> 121 + ${isCurateList 122 + ? html`<button 123 + class=${classnames("pin-feed-button", { 124 + pinned: isPinned, 125 + })} 126 + data-testid="pin-list-button" 127 + data-teststate=${isPinned ? "pinned" : "not-pinned"} 128 + @click=${() => 129 + listInteractionHandler.handlePinList( 130 + listUri, 131 + !isPinned, 132 + )} 133 + > 134 + ${pinIconTemplate({ filled: isPinned })} 135 + </button>` 136 + : ""} 137 + ` 138 + : null, 139 + })} 140 + ${!list 141 + ? html`<main> 142 + <div 143 + class="list-detail-loading" 144 + data-testid="list-detail-loading" 145 + > 146 + <div class="loading-spinner"></div> 147 + </div> 148 + </main>` 149 + : html`<main> 150 + <div 151 + class="list-detail-header" 152 + data-testid="list-detail-header" 153 + > 154 + ${list.avatar 155 + ? html`<img 156 + class="list-detail-avatar" 157 + src=${list.avatar} 158 + alt=${list.name} 159 + />` 160 + : html`<img 161 + class="list-detail-avatar" 162 + src="/img/list-avatar-fallback.svg" 163 + alt=${list.name} 164 + />`} 165 + <div class="list-detail-header-text"> 166 + <div 167 + class="list-detail-name" 168 + data-testid="list-detail-name" 169 + > 170 + ${list.name} 171 + </div> 172 + ${listCreator 173 + ? html`<div 174 + class="list-detail-creator" 175 + data-testid="list-detail-creator" 176 + > 177 + List by @${listCreator.handle} 178 + </div>` 179 + : ""} 180 + </div> 181 + </div> 182 + ${list.description 183 + ? html`<div 184 + class="list-detail-description" 185 + data-testid="list-detail-description" 186 + > 187 + ${richTextTemplate({ 188 + text: list.description, 189 + facets: list.descriptionFacets ?? [], 190 + })} 191 + </div>` 192 + : ""} 193 + ${isCurateList 194 + ? html`<div 195 + class="list-detail-tab-bar" 196 + data-scroll-lock-sticky 197 + > 198 + ${tabBarTemplate({ 199 + tabs: [ 200 + { value: "posts", label: "Posts" }, 201 + { value: "people", label: "People" }, 202 + ], 203 + activeTab, 204 + onTabClick: (value) => $activeTab.set(value), 205 + fullWidth: true, 206 + })} 207 + </div>` 208 + : ""} 209 + <div 210 + class="list-tab-content" 211 + data-testid="list-tab-content" 212 + data-teststate=${activeTab} 213 + > 214 + ${activeTab === "posts" && isCurateList 215 + ? html`<div class="feed-container"> 216 + ${postFeedTemplate({ 217 + feed, 218 + currentUser, 219 + isAuthenticated, 220 + hiddenPostUris, 221 + onLoadMore: () => loadFeed(), 222 + postInteractionHandler, 223 + pluginService, 224 + showEndMessage: true, 225 + })} 226 + </div>` 227 + : html`<div class="list-members-container"> 228 + ${profileFeedTemplate({ 229 + profiles: members, 230 + hasMore: hasMoreMembers, 231 + onLoadMore: () => loadMembers(), 232 + emptyMessage: "This list has no members.", 233 + showEndMessage: true, 234 + })} 235 + </div>`} 236 + </div> 237 + </main>`}`, 238 + })} 239 + </div>`, 240 + root, 241 + ); 242 + }); 243 + 244 + async function loadFeed({ reload = false } = {}) { 245 + await dataLayer.requests.loadNextFeedPage(listUri, { 246 + reload, 247 + limit: FEED_PAGE_SIZE + 1, 248 + }); 249 + } 250 + 251 + async function loadMembers({ reload = false } = {}) { 252 + await dataLayer.requests.loadListMembers(listUri, { reload }); 253 + } 254 + 255 + root.addEventListener("page-enter", async () => { 256 + dataLayer.declarative.ensureCurrentUser(); 257 + await Promise.all([ 258 + dataLayer.declarative.ensureList(listUri), 259 + loadFeed(), 260 + loadMembers(), 261 + ]); 262 + }); 263 + 264 + root.addEventListener("page-restore", (e) => { 265 + const scrollY = e.detail?.scrollY ?? 0; 266 + window.scrollTo(0, scrollY); 267 + }); 268 + } 269 + } 270 + 271 + export default new ListDetailView();
+1
src/js/views/notifications.view.js
··· 770 770 ], 771 771 activeTab, 772 772 onTabClick: handleTabClick, 773 + fullWidth: true, 773 774 }), 774 775 })} 775 776 <main>
+1
src/js/views/search.view.js
··· 390 390 ], 391 391 activeTab, 392 392 onTabClick: handleTabChange, 393 + fullWidth: true, 393 394 }) 394 395 : ""} 395 396 </div>
+20 -2
tests/e2e/mockServer.js
··· 33 33 this.pinnedFeedUris = []; 34 34 this.pinnedListUris = []; 35 35 this.lists = []; 36 + this.listMembers = new Map(); 36 37 this.posts = []; 37 38 this.postLikes = new Map(); 38 39 this.reportPayloads = []; ··· 96 97 97 98 addLists(lists) { 98 99 this.lists.push(...lists); 100 + } 101 + 102 + addListMembers(listUri, profiles) { 103 + this.listMembers.set(listUri, profiles); 104 + } 105 + 106 + addListFeedItems(listUri, posts) { 107 + this.feeds.set( 108 + listUri, 109 + posts.map((post) => ({ post })), 110 + ); 99 111 } 100 112 101 113 setPinnedFeeds(feedUris) { ··· 1028 1040 const url = new URL(route.request().url()); 1029 1041 const listUri = url.searchParams.get("list"); 1030 1042 const list = this.lists.find((l) => l.uri === listUri) || {}; 1043 + const members = this.listMembers.get(listUri) || []; 1044 + const items = members.map((profile) => ({ subject: profile })); 1031 1045 return route.fulfill({ 1032 1046 status: 200, 1033 1047 contentType: "application/json", 1034 - body: JSON.stringify({ list, items: [] }), 1048 + body: JSON.stringify({ list, items, cursor: "" }), 1035 1049 }); 1036 1050 }); 1037 1051 ··· 1232 1246 const generator = this.feedGenerators.find( 1233 1247 (g) => g.creator.handle === handle, 1234 1248 ); 1249 + const list = this.lists.find((l) => l.creator?.handle === handle); 1235 1250 const profileEntry = [...this.profiles.values()].find( 1236 1251 (p) => p.handle === handle, 1237 1252 ); 1238 1253 const did = 1239 - postAuthor?.did || generator?.creator?.did || profileEntry?.did; 1254 + postAuthor?.did || 1255 + generator?.creator?.did || 1256 + list?.creator?.did || 1257 + profileEntry?.did; 1240 1258 if (!did) { 1241 1259 return route.fulfill({ 1242 1260 status: 404,
+37
tests/e2e/specs/flows/feedsToListDetail.test.js
··· 1 + import { test, expect } from "../../base.js"; 2 + import { login } from "../../helpers.js"; 3 + import { MockServer } from "../../mockServer.js"; 4 + import { createList } from "../../factories.js"; 5 + 6 + test.describe("Feeds → List Detail flow", () => { 7 + test("clicking a pinned list opens its detail view", async ({ page }) => { 8 + const mockServer = new MockServer(); 9 + const list = createList({ 10 + uri: "at://did:plc:creator1/app.bsky.graph.list/mylist", 11 + name: "My Curated List", 12 + creatorHandle: "creator1.bsky.social", 13 + }); 14 + mockServer.addLists([list]); 15 + mockServer.setPinnedLists([list.uri]); 16 + await mockServer.setup(page); 17 + 18 + await login(page); 19 + await page.goto("/feeds"); 20 + 21 + const feedsView = page.locator("#feeds-view"); 22 + await expect( 23 + feedsView.locator('[data-testid="feeds-list-item-list"]'), 24 + ).toBeVisible({ timeout: 10000 }); 25 + 26 + await feedsView.locator('[data-testid="feeds-list-item-list"]').click(); 27 + 28 + await expect(page).toHaveURL("/profile/creator1.bsky.social/lists/mylist", { 29 + timeout: 10000, 30 + }); 31 + 32 + const view = page.locator("#list-detail-view"); 33 + await expect( 34 + view.locator('[data-testid="list-detail-name"]'), 35 + ).toContainText("My Curated List", { timeout: 10000 }); 36 + }); 37 + });
+244
tests/e2e/specs/views/listDetail.view.test.js
··· 1 + import { test, expect } from "../../base.js"; 2 + import { login } from "../../helpers.js"; 3 + import { MockServer } from "../../mockServer.js"; 4 + import { createList, createPost, createProfile } from "../../factories.js"; 5 + 6 + const LIST_URI = "at://did:plc:creator1/app.bsky.graph.list/mylist"; 7 + 8 + function setupList(mockServer, { description } = {}) { 9 + const list = createList({ 10 + uri: LIST_URI, 11 + name: "My Curated List", 12 + creatorHandle: "creator1.bsky.social", 13 + }); 14 + if (description !== undefined) { 15 + list.description = description; 16 + } 17 + mockServer.addLists([list]); 18 + return list; 19 + } 20 + 21 + test.describe("List Detail view", () => { 22 + test("should display list name, creator, and description", async ({ 23 + page, 24 + }) => { 25 + const mockServer = new MockServer(); 26 + setupList(mockServer, { description: "A list of cool people" }); 27 + await mockServer.setup(page); 28 + 29 + await login(page); 30 + await page.goto("/profile/creator1.bsky.social/lists/mylist"); 31 + 32 + const view = page.locator("#list-detail-view"); 33 + await expect( 34 + view.locator('[data-testid="list-detail-name"]'), 35 + ).toContainText("My Curated List", { timeout: 10000 }); 36 + await expect( 37 + view.locator('[data-testid="list-detail-creator"]'), 38 + ).toContainText("by @creator1.bsky.social"); 39 + await expect( 40 + view.locator('[data-testid="list-detail-description"]'), 41 + ).toContainText("A list of cool people"); 42 + }); 43 + 44 + test("should show posts on the Posts tab by default", async ({ page }) => { 45 + const mockServer = new MockServer(); 46 + setupList(mockServer); 47 + const post1 = createPost({ 48 + uri: "at://did:plc:author1/app.bsky.feed.post/p1", 49 + text: "List post one", 50 + authorHandle: "author1.bsky.social", 51 + authorDisplayName: "Author One", 52 + }); 53 + const post2 = createPost({ 54 + uri: "at://did:plc:author2/app.bsky.feed.post/p2", 55 + text: "List post two", 56 + authorHandle: "author2.bsky.social", 57 + authorDisplayName: "Author Two", 58 + }); 59 + mockServer.addListFeedItems(LIST_URI, [post1, post2]); 60 + await mockServer.setup(page); 61 + 62 + await login(page); 63 + await page.goto("/profile/creator1.bsky.social/lists/mylist"); 64 + 65 + const view = page.locator("#list-detail-view"); 66 + await expect( 67 + view.locator('[data-testid="list-tab-content"]'), 68 + ).toHaveAttribute("data-teststate", "posts", { timeout: 10000 }); 69 + await expect(view.locator('[data-testid="feed-item"]')).toHaveCount(2, { 70 + timeout: 10000, 71 + }); 72 + await expect(view).toContainText("List post one"); 73 + await expect(view).toContainText("List post two"); 74 + }); 75 + 76 + test("should show members on the People tab", async ({ page }) => { 77 + const mockServer = new MockServer(); 78 + setupList(mockServer); 79 + const member1 = createProfile({ 80 + did: "did:plc:member1", 81 + handle: "member1.bsky.social", 82 + displayName: "Member One", 83 + }); 84 + const member2 = createProfile({ 85 + did: "did:plc:member2", 86 + handle: "member2.bsky.social", 87 + displayName: "Member Two", 88 + }); 89 + mockServer.addListMembers(LIST_URI, [member1, member2]); 90 + await mockServer.setup(page); 91 + 92 + await login(page); 93 + await page.goto("/profile/creator1.bsky.social/lists/mylist"); 94 + 95 + const view = page.locator("#list-detail-view"); 96 + await expect(view.locator('[data-testid="tab-people"]')).toBeVisible({ 97 + timeout: 10000, 98 + }); 99 + 100 + await view.locator('[data-testid="tab-people"]').click(); 101 + 102 + await expect( 103 + view.locator('[data-testid="list-tab-content"]'), 104 + ).toHaveAttribute("data-teststate", "people"); 105 + await expect(view.locator(".profile-list-item")).toHaveCount(2, { 106 + timeout: 10000, 107 + }); 108 + await expect(view).toContainText("Member One"); 109 + await expect(view).toContainText("Member Two"); 110 + }); 111 + 112 + test("should show feed empty state when list has no posts", async ({ 113 + page, 114 + }) => { 115 + const mockServer = new MockServer(); 116 + setupList(mockServer); 117 + await mockServer.setup(page); 118 + 119 + await login(page); 120 + await page.goto("/profile/creator1.bsky.social/lists/mylist"); 121 + 122 + const view = page.locator("#list-detail-view"); 123 + await expect(view.locator('[data-testid="feed-end-message"]')).toBeVisible({ 124 + timeout: 10000, 125 + }); 126 + }); 127 + 128 + test("should show pin button as unpinned by default", async ({ page }) => { 129 + const mockServer = new MockServer(); 130 + setupList(mockServer); 131 + await mockServer.setup(page); 132 + 133 + await login(page); 134 + await page.goto("/profile/creator1.bsky.social/lists/mylist"); 135 + 136 + const view = page.locator("#list-detail-view"); 137 + await expect(view.locator('[data-testid="pin-list-button"]')).toBeVisible({ 138 + timeout: 10000, 139 + }); 140 + await expect( 141 + view.locator('[data-testid="pin-list-button"]'), 142 + ).toHaveAttribute("data-teststate", "not-pinned"); 143 + }); 144 + 145 + test("should show pin button as pinned when list is pinned", async ({ 146 + page, 147 + }) => { 148 + const mockServer = new MockServer(); 149 + setupList(mockServer); 150 + mockServer.setPinnedLists([LIST_URI]); 151 + await mockServer.setup(page); 152 + 153 + await login(page); 154 + await page.goto("/profile/creator1.bsky.social/lists/mylist"); 155 + 156 + const view = page.locator("#list-detail-view"); 157 + await expect( 158 + view.locator('[data-testid="pin-list-button"]'), 159 + ).toHaveAttribute("data-teststate", "pinned", { timeout: 10000 }); 160 + }); 161 + 162 + test("should pin an unpinned list when pin button is clicked", async ({ 163 + page, 164 + }) => { 165 + const mockServer = new MockServer(); 166 + setupList(mockServer); 167 + await mockServer.setup(page); 168 + 169 + await login(page); 170 + await page.goto("/profile/creator1.bsky.social/lists/mylist"); 171 + 172 + const view = page.locator("#list-detail-view"); 173 + const pinButton = view.locator('[data-testid="pin-list-button"]'); 174 + await expect(pinButton).toHaveAttribute("data-teststate", "not-pinned", { 175 + timeout: 10000, 176 + }); 177 + 178 + await pinButton.click(); 179 + 180 + await expect(pinButton).toHaveAttribute("data-teststate", "pinned", { 181 + timeout: 10000, 182 + }); 183 + await expect(page.locator('[data-testid="toast"]')).toBeVisible(); 184 + }); 185 + 186 + test("should open context menu with list actions", async ({ page }) => { 187 + const mockServer = new MockServer(); 188 + setupList(mockServer); 189 + await mockServer.setup(page); 190 + 191 + await login(page); 192 + await page.goto("/profile/creator1.bsky.social/lists/mylist"); 193 + 194 + const view = page.locator("#list-detail-view"); 195 + await expect(view.locator(".list-menu-button")).toBeVisible({ 196 + timeout: 10000, 197 + }); 198 + 199 + await view.locator(".list-menu-button").click(); 200 + 201 + const menu = view.locator("context-menu"); 202 + await expect( 203 + menu.locator('[data-testid="menu-action-list-open-in-bsky"]'), 204 + ).toBeVisible(); 205 + await expect( 206 + menu.locator('[data-testid="menu-action-list-copy-link"]'), 207 + ).toBeVisible(); 208 + }); 209 + 210 + test("should open bsky.app link when 'Open in bsky.app' is clicked", async ({ 211 + page, 212 + }) => { 213 + const mockServer = new MockServer(); 214 + setupList(mockServer); 215 + await mockServer.setup(page); 216 + 217 + await login(page); 218 + await page.goto("/profile/creator1.bsky.social/lists/mylist"); 219 + 220 + const view = page.locator("#list-detail-view"); 221 + await expect(view.locator(".list-menu-button")).toBeVisible({ 222 + timeout: 10000, 223 + }); 224 + 225 + const popupPromise = page.waitForEvent("popup"); 226 + await view.locator(".list-menu-button").click(); 227 + await view.locator('[data-testid="menu-action-list-open-in-bsky"]').click(); 228 + 229 + const popup = await popupPromise; 230 + expect(popup.url()).toBe( 231 + "https://bsky.app/profile/creator1.bsky.social/lists/mylist", 232 + ); 233 + }); 234 + 235 + test.describe("Logged-out behavior", () => { 236 + test("should redirect to /login when not authenticated", async ({ 237 + page, 238 + }) => { 239 + await page.goto("/profile/creator1.bsky.social/lists/mylist"); 240 + 241 + await expect(page).toHaveURL(/\/login(\?|$)/, { timeout: 10000 }); 242 + }); 243 + }); 244 + });
+163
tests/unit/specs/dataLayer/mutations.test.js
··· 2720 2720 }); 2721 2721 }); 2722 2722 2723 + t.describe("pinList", (it) => { 2724 + const listUri = "at://did:test/app.bsky.graph.list/abc"; 2725 + 2726 + function makeMockProvider({ updatePreferences } = {}) { 2727 + const pinFeedCalls = []; 2728 + return { 2729 + pinFeedCalls, 2730 + provider: { 2731 + requirePreferences: () => ({ 2732 + pinFeed: (feedUri, type) => { 2733 + pinFeedCalls.push({ feedUri, type }); 2734 + return Preferences.createLoggedOutPreferences(); 2735 + }, 2736 + }), 2737 + updatePreferences: updatePreferences ?? (async () => {}), 2738 + }, 2739 + }; 2740 + } 2741 + 2742 + it("should add optimistic patch with entryType 'list'", () => { 2743 + const { provider } = makeMockProvider({ 2744 + updatePreferences: async () => 2745 + new Promise((resolve) => setTimeout(resolve, 100)), 2746 + }); 2747 + const dataStore = new DataStore(); 2748 + const patchStore = new PatchStore(dataStore); 2749 + const mutations = new Mutations({}, dataStore, patchStore, provider); 2750 + 2751 + mutations.pinList(listUri); 2752 + 2753 + const patches = patchStore.$preferencePatches.get(); 2754 + assertEquals(patches.length, 1); 2755 + assertEquals(patches[0].body.type, "pinFeed"); 2756 + assertEquals(patches[0].body.feedUri, listUri); 2757 + assertEquals(patches[0].body.entryType, "list"); 2758 + }); 2759 + 2760 + it("should call preferences.pinFeed with type 'list'", async () => { 2761 + const { provider, pinFeedCalls } = makeMockProvider(); 2762 + const dataStore = new DataStore(); 2763 + const patchStore = new PatchStore(dataStore); 2764 + const mutations = new Mutations({}, dataStore, patchStore, provider); 2765 + 2766 + await mutations.pinList(listUri); 2767 + 2768 + assertEquals(pinFeedCalls.length, 1); 2769 + assertEquals(pinFeedCalls[0].feedUri, listUri); 2770 + assertEquals(pinFeedCalls[0].type, "list"); 2771 + }); 2772 + 2773 + it("should remove patch after successful update", async () => { 2774 + const { provider } = makeMockProvider(); 2775 + const dataStore = new DataStore(); 2776 + const patchStore = new PatchStore(dataStore); 2777 + const mutations = new Mutations({}, dataStore, patchStore, provider); 2778 + 2779 + await mutations.pinList(listUri); 2780 + 2781 + assertEquals(patchStore.$preferencePatches.get().length, 0); 2782 + }); 2783 + 2784 + it("should remove patch even on error", async () => { 2785 + const { provider } = makeMockProvider({ 2786 + updatePreferences: async () => { 2787 + throw new Error("API error"); 2788 + }, 2789 + }); 2790 + const dataStore = new DataStore(); 2791 + const patchStore = new PatchStore(dataStore); 2792 + const mutations = new Mutations({}, dataStore, patchStore, provider); 2793 + 2794 + let errorThrown = false; 2795 + try { 2796 + await mutations.pinList(listUri); 2797 + } catch (e) { 2798 + errorThrown = true; 2799 + } 2800 + 2801 + assertEquals(errorThrown, true); 2802 + assertEquals(patchStore.$preferencePatches.get().length, 0); 2803 + }); 2804 + }); 2805 + 2806 + t.describe("pinFeed entryType", (it) => { 2807 + it("should add optimistic patch with entryType 'feed'", () => { 2808 + const feedUri = "at://did:test/app.bsky.feed.generator/xyz"; 2809 + const preferences = new Preferences( 2810 + [{ $type: "app.bsky.actor.defs#savedFeedsPrefV2", items: [] }], 2811 + [], 2812 + ); 2813 + const mockPreferencesProvider = { 2814 + requirePreferences: () => preferences, 2815 + updatePreferences: () => 2816 + new Promise((resolve) => setTimeout(resolve, 100)), 2817 + }; 2818 + const dataStore = new DataStore(); 2819 + const patchStore = new PatchStore(dataStore); 2820 + const mutations = new Mutations( 2821 + {}, 2822 + dataStore, 2823 + patchStore, 2824 + mockPreferencesProvider, 2825 + ); 2826 + 2827 + mutations.pinFeed(feedUri); 2828 + 2829 + const patches = patchStore.$preferencePatches.get(); 2830 + assertEquals(patches.length, 1); 2831 + assertEquals(patches[0].body.entryType, "feed"); 2832 + }); 2833 + }); 2834 + 2835 + t.describe("unpinList", (it) => { 2836 + const listUri = "at://did:test/app.bsky.graph.list/abc"; 2837 + 2838 + it("should call preferences.unpinFeed with the list URI", async () => { 2839 + const unpinCalls = []; 2840 + const provider = { 2841 + requirePreferences: () => ({ 2842 + unpinFeed: (uri) => { 2843 + unpinCalls.push(uri); 2844 + return Preferences.createLoggedOutPreferences(); 2845 + }, 2846 + }), 2847 + updatePreferences: async () => {}, 2848 + }; 2849 + const dataStore = new DataStore(); 2850 + const patchStore = new PatchStore(dataStore); 2851 + const mutations = new Mutations({}, dataStore, patchStore, provider); 2852 + 2853 + await mutations.unpinList(listUri); 2854 + 2855 + assertEquals(unpinCalls.length, 1); 2856 + assertEquals(unpinCalls[0], listUri); 2857 + }); 2858 + 2859 + it("should add and remove an unpinFeed patch", async () => { 2860 + let updateResolve; 2861 + const updatePromise = new Promise((resolve) => { 2862 + updateResolve = resolve; 2863 + }); 2864 + const provider = { 2865 + requirePreferences: () => ({ 2866 + unpinFeed: () => Preferences.createLoggedOutPreferences(), 2867 + }), 2868 + updatePreferences: () => updatePromise, 2869 + }; 2870 + const dataStore = new DataStore(); 2871 + const patchStore = new PatchStore(dataStore); 2872 + const mutations = new Mutations({}, dataStore, patchStore, provider); 2873 + 2874 + const promise = mutations.unpinList(listUri); 2875 + const patches = patchStore.$preferencePatches.get(); 2876 + assertEquals(patches.length, 1); 2877 + assertEquals(patches[0].body.type, "unpinFeed"); 2878 + assertEquals(patches[0].body.feedUri, listUri); 2879 + 2880 + updateResolve(); 2881 + await promise; 2882 + assertEquals(patchStore.$preferencePatches.get().length, 0); 2883 + }); 2884 + }); 2885 + 2723 2886 await t.run();
+46
tests/unit/specs/dataLayer/patchStore.test.js
··· 392 392 }); 393 393 }); 394 394 395 + t.describe("Preference Patches - Pin Feed Patches", (it) => { 396 + it("should forward entryType to preferences.pinFeed", () => { 397 + const patchStore = new PatchStore(); 398 + const calls = []; 399 + const mockPreferences = { 400 + clone: () => mockPreferences, 401 + pinFeed: (feedUri, type) => { 402 + calls.push({ feedUri, type }); 403 + return mockPreferences; 404 + }, 405 + }; 406 + 407 + patchStore.addPreferencePatch({ 408 + type: "pinFeed", 409 + feedUri: "at://did:test/app.bsky.graph.list/abc", 410 + entryType: "list", 411 + }); 412 + patchStore.applyPreferencePatches(mockPreferences); 413 + 414 + assertEquals(calls.length, 1); 415 + assertEquals(calls[0].feedUri, "at://did:test/app.bsky.graph.list/abc"); 416 + assertEquals(calls[0].type, "list"); 417 + }); 418 + 419 + it("should pass entryType undefined when patch omits it (default 'feed' applies)", () => { 420 + const patchStore = new PatchStore(); 421 + const calls = []; 422 + const mockPreferences = { 423 + clone: () => mockPreferences, 424 + pinFeed: (feedUri, type) => { 425 + calls.push({ feedUri, type }); 426 + return mockPreferences; 427 + }, 428 + }; 429 + 430 + patchStore.addPreferencePatch({ 431 + type: "pinFeed", 432 + feedUri: "at://did:test/app.bsky.feed.generator/xyz", 433 + }); 434 + patchStore.applyPreferencePatches(mockPreferences); 435 + 436 + assertEquals(calls.length, 1); 437 + assertEquals(calls[0].type, undefined); 438 + }); 439 + }); 440 + 395 441 t.describe("Preference Patches - Patch Management", (it) => { 396 442 it("should add and remove preference patches", () => { 397 443 const patchStore = new PatchStore();
+1 -1
tests/unit/specs/dataLayer/requests.test.js
··· 1853 1853 }, 1854 1854 getList: async (uri) => { 1855 1855 capturedListUris.push(uri); 1856 - return { uri, name: `list-${uri}` }; 1856 + return { list: { uri, name: `list-${uri}` }, items: [], cursor: "" }; 1857 1857 }, 1858 1858 }; 1859 1859 const dataStore = new DataStore();