PageSourceSearch

https://impro.social/js/views/listDetail.view.534340ae67.js

js impro.social collected 2026-09-25 22:28:20 UTC 16,040 bytes, 429 lines download raw bytes

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.