1import { html, render } from "/js/lib/lit-html.js"; 2import { resolveDidFromHandleOrDid } from "/js/atproto.js"; 3import { Signal, ReactiveStore } from "/js/signals.js"; 4import { classnames } from "/js/utils.js"; 5import { cdnImageUrl, isModerationList } from "/js/dataHelpers.js"; 6import { postFeedTemplate } from "/js/templates/postFeed.template.js"; 7import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; 8import { headerTemplate } from "/js/templates/header.template.js"; 9import "/js/components/tab-bar.js"; 10import { fillableIconTemplate } from "/js/templates/fillableIcon.template.js"; 11import { richTextTemplate } from "/js/templates/richText.template.js"; 12import { 13 bindToPage, 14 pageEffect, 15 bindPageTitle, 16 onPageShow, 17} from "/js/router.js"; 18import { FEED_PAGE_SIZE } from "/js/config.js"; 19import { showToast } from "/js/toasts.js"; 20import "/js/components/infinite-scroll-container.js"; 21import "/js/components/context-menu.js"; 22import "/js/components/context-menu-item.js"; 23import "/js/components/context-menu-item-group.js"; 24import "/js/components/edit-list-details-dialog.js"; 25import "/js/components/manage-list-members-dialog.js"; 26 27export default async function listDetailView({ 28 root, 29 params, 30 context: { 31 auth, 32 dataLayer, 33 identityResolver, 34 isAuthenticated, 35 pluginService, 36 interactionHandlers, 37 }, 38}) { 39 await auth.requireAuth(); 40 41 const { handleOrDid, rkey } = params; 42 43 const profileDid = await resolveDidFromHandleOrDid( 44 handleOrDid, 45 identityResolver, 46 ); 47 const listUri = `at://${profileDid}/app.bsky.graph.list/${rkey}`; 48 49 const { 50 postInteractionHandler, 51 listInteractionHandler, 52 profileInteractionHandler, 53 } = interactionHandlers; 54 55 const state = new ReactiveStore("listDetailView"); 56 state.$activeTab = new Signal.State("posts"); 57 58 function listSubscriptionButtonTemplate({ list, listInteractionHandler }) { 59 const isMuted = !!list.viewer?.muted; 60 const isBlocked = !!list.viewer?.blocked; 61 const isSubscribed = isMuted || isBlocked; 62 const teststate = isMuted 63 ? "muted" 64 : isBlocked 65 ? "blocked" 66 : "not-subscribed"; 67 return html` 68 <button 69 class=${classnames("rounded-button", "subscribe-list-button", { 70 "rounded-button-primary": !isSubscribed, 71 subscribed: isSubscribed, 72 })} 73 data-testid="subscribe-list-button" 74 data-teststate=${teststate} 75 @click=${function (e) { 76 if (isMuted) { 77 listInteractionHandler.handleUnmuteList(list); 78 return; 79 } 80 if (isBlocked) { 81 listInteractionHandler.handleUnblockList(list); 82 return; 83 } 84 const menu = this.nextElementSibling; 85 menu.open(e.clientX, e.clientY); 86 }} 87 > 88 ${isMuted ? "Unmute list" : isBlocked ? "Unblock list" : "Subscribe"} 89 </button> 90 <context-menu> 91 <context-menu-item 92 data-testid="menu-action-list-mute" 93 icon="speaker-slash-line" 94 @click=${() => listInteractionHandler.handleMuteList(list)} 95 > 96 Mute accounts 97 </context-menu-item> 98 <context-menu-item 99 data-testid="menu-action-list-block" 100 icon="user-x-line" 101 @click=${() => listInteractionHandler.handleBlockList(list)} 102 > 103 Block accounts 104 </context-menu-item> 105 </context-menu> 106 `; 107 } 108 109 bindPageTitle(root, () => { 110 return dataLayer.derived.$lists.get(listUri)?.name ?? null; 111 }); 112 113 pageEffect(root, () => { 114 const showLessInteractions = 115 dataLayer.derived.$showLessInteractions.get(listUri); 116 const hiddenPostUris = showLessInteractions.map( 117 (interaction) => interaction.item, 118 ); 119 const currentUser = dataLayer.derived.$currentUser.get(); 120 const list = dataLayer.derived.$lists.get(listUri); 121 const listCreator = list?.creator; 122 const listCreatorHandle = listCreator?.handle; 123 const preferences = dataLayer.derived.$preferences.get(); 124 const isPinned = preferences?.isFeedPinned(listUri) ?? false; 125 const feed = dataLayer.derived.$hydratedFeeds.get(listUri); 126 const membersEntry = dataLayer.derived.$listMembers.get(listUri); 127 const members = membersEntry?.members ?? null; 128 const hasMoreMembers = membersEntry?.cursor != null; 129 const activeTab = state.$activeTab.get(); 130 const isCurateList = !isModerationList(list); 131 const isCurrentUserList = listCreator?.did === currentUser?.did; 132 const listPermalink = `https://bsky.app/profile/${listCreatorHandle || handleOrDid}/lists/${rkey}`; 133 render( 134 html`<div id="list-detail-view"> 135 ${headerTemplate({ 136 rightItemTemplate: list 137 ? () => html` 138 ${isCurateList 139 ? html`<button 140 class=${classnames("pin-feed-button", { 141 pinned: isPinned, 142 })} 143 data-testid="pin-list-button" 144 data-teststate=${isPinned ? "pinned" : "not-pinned"} 145 @click=${() => 146 listInteractionHandler.handlePinList( 147 listUri, 148 !isPinned, 149 )} 150 > 151 ${fillableIconTemplate({ icon: "pin", filled: isPinned })} 152 </button>` 153 : listSubscriptionButtonTemplate({ 154 list, 155 listInteractionHandler, 156 })} 157 <button 158 class="context-menu-button" 159 @click=${function (e) { 160 const contextMenu = this.nextElementSibling; 161 contextMenu.open(e.clientX, e.clientY); 162 }} 163 >
164 <span>...</span> 165 </button> 166 <context-menu> 167 <context-menu-item-group> 168 <context-menu-item 169 data-testid="menu-action-list-open-in-bsky" 170 icon="open-line" 171 @click=${() => { 172 window.open(listPermalink, "_blank"); 173 }} 174 > 175 Open in bsky.app 176 </context-menu-item> 177 <context-menu-item 178 data-testid="menu-action-list-copy-link" 179 icon="link-line" 180 @click=${() => { 181 navigator.clipboard.writeText(listPermalink); 182 showToast("Link copied to clipboard", { 183 style: "success", 184 }); 185 }} 186 > 187 Copy link to list 188 </context-menu-item> 189 </context-menu-item-group> 190 ${isCurrentUserList 191 ? html`<context-menu-item 192 data-testid="menu-action-list-add-people" 193 icon="user-plus-line" 194 @click=${() => handleAddPeople(list)} 195 > 196 Add people to list 197 </context-menu-item> 198 <context-menu-item-group> 199 <context-menu-item 200 data-testid="menu-action-list-edit" 201 icon="edit-pen-2-line" 202 @click=${() => handleEditList(list)} 203 > 204 Edit list details 205 </context-menu-item> 206 <context-menu-item 207 data-testid="menu-action-list-delete" 208 icon="delete-bin-line" 209 @click=${() => handleDeleteList(list)} 210 > 211 Delete list 212 </context-menu-item> 213 </context-menu-item-group>` 214 : ""} 215 </context-menu> 216 ` 217 : null, 218 })} 219 ${!list 220 ? html`<main> 221 <div 222 class="list-detail-loading" 223 data-testid="list-detail-loading" 224 > 225 <div class="loading-spinner"></div> 226 </div> 227 </main>` 228 : html`<main> 229 <div class="list-detail-header" data-testid="list-detail-header"> 230 ${list.avatar 231 ? html`<img 232 class="list-detail-avatar" 233 src=${cdnImageUrl(list.avatar)} 234 alt=${list.name} 235 />` 236 : html`<img 237 class="list-detail-avatar" 238 src="/img/list-avatar-fallback.svg" 239 alt=${list.name} 240 />`} 241 <div class="list-detail-header-text"> 242 <div class="list-detail-name" data-testid="list-detail-name"> 243 ${list.name} 244 </div> 245 ${listCreator 246 ? html`<div 247 class="list-detail-creator" 248 data-testid="list-detail-creator" 249 > 250 ${isModerationList(list) ? "Moderation list" : "List"} 251 by 252 ${isCurrentUserList ? "you" : `@${listCreator.handle}`} 253 </div>` 254 : ""} 255 </div> 256 </div> 257 ${list.description 258 ? html`<div 259 class="list-detail-description" 260 data-testid="list-detail-description" 261 > 262 ${richTextTemplate({ 263 text: list.description, 264 facets: list.descriptionFacets ?? [], 265 })} 266 </div>` 267 : ""} 268 ${isCurateList 269 ? html`<div class="list-detail-tab-bar" data-scroll-lock-sticky> 270 <tab-bar 271 .tabs=${[ 272 { value: "posts", label: "Posts" }, 273 { value: "people", label: "People" }, 274 ]} 275 active-tab=${activeTab} 276 full-width 277 @tab-click=${(event) => 278 state.$activeTab.set(event.detail)} 279 ></tab-bar> 280 </div>` 281 : html`<hr />`} 282 <div 283 class="list-tab-content" 284 data-testid="list-tab-content" 285 data-teststate=${activeTab} 286 > 287 ${activeTab === "posts" && isCurateList 288 ? html`<div class="feed-container"> 289 ${postFeedTemplate({ 290 feed, 291 currentUser, 292 isAuthenticated, 293 hiddenPostUris, 294 onLoadMore: () => loadFeed(), 295 postInteractionHandler, 296 pluginService, 297 showEndMessage: true, 298 })} 299 ${feed?.feed?.length === 0 && isCurrentUserList 300 ? html`<button 301 class="rounded-button rounded-button-primary list-empty-add-people-button" 302 data-testid="list-empty-add-people-button" 303 @click=${() => handleAddPeople(list)} 304 > 305 <app-icon icon="user-plus-line"></app-icon> Add 306 people to list 307 </button>` 308 : ""} 309 </div>` 310 : html`<div class="feed-container"> 311 ${profileFeedTemplate({ 312 profiles: members, 313 hasMore: hasMoreMembers, 314 onLoadMore: () => loadMembers(), 315 emptyMessage: "This list has no members.", 316 showEndMessage: true, 317 isAuthenticated, 318 currentUserDid: currentUser?.did ?? null, 319 profileInteractionHandler, 320 pluginService, 321 ...(isCurateList ? {} : { rightItemTemplate: null }), 322 })} 323 ${members?.length === 0 && isCurrentUserList 324 ? html`<button 325 class="rounded-button rounded-button-primary list-empty-add-people-button" 326 data-testid="list-empty-add-people-button" 327 @click=${() => handleAddPeople(list)} 328 > 329 <app-icon icon="user-plus-line"></app-icon> Add 330 people to list 331 </button>` 332 : ""} 333 </div>`} 334 </div> 335 </main>`} 336 </div>`, 337 root, 338 ); 339 }); 340 341 async function handleDeleteList(list) { 342 const deleted = await listInteractionHandler.handleDeleteList(list); 343 if (!deleted) return; 344 const fallbackRoute = list.creator?.handle 345 ? `/profile/${list.creator.handle}` 346 : "/"; 347 window.router.back({ fallbackRoute }); 348 } 349 350 function handleAddPeople(list) { 351 const dialog = document.createElement("manage-list-members-dialog"); 352 dialog.dataLayer = dataLayer; 353 dialog.list = list; 354 let reloadTimeout = null; 355 dialog.addEventListener("dialog-closed", () => { 356 clearTimeout(reloadTimeout); 357 dialog.remove(); 358 }); 359 dialog.addEventListener("members-changed", () => { 360 if (isModerationList(list) || userHasScrolled) return; 361 clearTimeout(reloadTimeout); 362 reloadTimeout = setTimeout(() => { 363 if (userHasScrolled) return; 364 loadFeed({ reload: true }); 365 }, 1000); // 1 sec delay for appview update 366 }); 367 root.querySelector("main").appendChild(dialog); 368 dialog.open(); 369 } 370 371 async function handleEditList(list) { 372 const dialog = document.createElement("edit-list-details-dialog"); 373 dialog.addEventListener("list-save", async (event) => { 374 const { listUpdates, successCallback, errorCallback } = event.detail;
375 try { 376 await dataLayer.mutations.updateList(list, listUpdates); 377 showToast("List updated"); 378 successCallback(); 379 } catch (error) { 380 errorCallback(error); 381 } 382 }); 383 dialog.addEventListener("edit-list-details-closed", () => { 384 dialog.remove(); 385 }); 386 root.querySelector("main").appendChild(dialog); 387 dialog.setList(list); 388 dialog.open(); 389 } 390 391 async function loadFeed({ reload = false } = {}) { 392 await dataLayer.requests.loadNextFeedPage( 393 { type: "list", uri: listUri }, 394 { reload, limit: FEED_PAGE_SIZE + 1 }, 395 ); 396 } 397 398 async function loadMembers({ reload = false } = {}) { 399 await dataLayer.requests.loadListMembers(listUri, { reload }); 400 } 401 402 async function loadPageData() { 403 await dataLayer.requests.loadList(listUri); 404 const list = dataLayer.derived.$lists.get(listUri); 405 const requests = [loadMembers({ reload: true })]; 406 if (!isModerationList(list)) { 407 requests.push(loadFeed({ reload: true })); 408 } 409 await Promise.all(requests); 410 } 411 412 let userHasScrolled = false; 413 const markUserScrolled = () => { 414 userHasScrolled = true; 415 }; 416 bindToPage(root, window, "touchmove", markUserScrolled); 417 bindToPage(root, window, "wheel", markUserScrolled); 418 bindToPage(root, window, "keydown", markUserScrolled); 419 420 const resetUserScrolled = () => { 421 userHasScrolled = false; 422 }; 423 onPageShow(root, resetUserScrolled); 424 425 onPageShow(root, ({ action }) => { 426 if (action === "restore") return; 427 loadPageData(); 428 }); 429}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.