PageSourceSearch

https://impro.social/js/views/search.view.cad1a11838.js

js impro.social collected 2026-09-25 22:28:36 UTC 25,974 bytes, 790 lines download raw bytes

1import { html, keyed, render } from "/js/lib/lit-html.js";
2import "/js/components/app-icon.js";
3import { headerTemplate } from "/js/templates/header.template.js";
4import { avatarTemplate } from "/js/templates/avatar.template.js";
5import { classnames } from "/js/utils.js";
6import {
7  cdnImageUrl,
8  getDisplayName,
9  MISSING_HANDLE,
10} from "/js/dataHelpers.js";
11import { Signal, ReactiveStore } from "/js/signals.js";
12import {
13  linkToFeed,
14  linkToProfile,
15  linkToProfileByDid,
16} from "/js/navigation.js";
17import { smallPostTemplate } from "/js/templates/smallPost.template.js";
18import { bindToPage, pageEffect, bindPageTitle } from "/js/router.js";
19import { fillableIconTemplate } from "/js/templates/fillableIcon.template.js";
20import "/js/components/container-link.js";
21import "/js/components/tab-bar.js";
22import { profileFeedTemplate } from "/js/templates/profileFeed.template.js";
23
24export default async function searchView({
25  root,
26  layout,
27  context: { dataLayer, isAuthenticated, pluginService, interactionHandlers },
28}) {
29  function getUrlQuery() {
30    return (new URLSearchParams(window.location.search).get("q") ?? "").trim();
31  }
32
33  const initialQuery = getUrlQuery();
34  const state = new ReactiveStore("searchView");
35  state.$activeTab = new Signal.State("top");
36  state.$inputValue = new Signal.State(initialQuery);
37  state.$committedQuery = new Signal.State(initialQuery);
38  state.$showTypeahead = new Signal.State(false);
39  state.$recentProfilesLoading = new Signal.State(true);
40
41  const tabScrollState = new Map();
42  const loadedTabs = new Set();
43
44  const TAB_LOADERS = {
45    profiles: (query) =>
46      dataLayer.requests.loadProfileSearch(query, { limit: 25 }),
47    top: (query) => dataLayer.requests.loadPostSearchTop(query, { limit: 25 }),
48    latest: (query) =>
49      dataLayer.requests.loadPostSearchLatest(query, { limit: 25 }),
50    feeds: (query) => dataLayer.requests.loadFeedSearch(query, { limit: 15 }),
51  };
52
53  const TAB_STATUS_PREFIXES = {
54    profiles: "loadProfileSearch-",
55    top: "loadPostSearchTop-",
56    latest: "loadPostSearchLatest-",
57    feeds: "loadFeedSearch-",
58  };
59
60  function loadTabIfNeeded(tab) {
61    const query = state.$committedQuery.get();
62    if (!query) return;
63    if (!isAuthenticated) {
64      tab = "profiles";
65    }
66    if (loadedTabs.has(tab)) return;
67    loadedTabs.add(tab);
68    TAB_LOADERS[tab](query)
69      .then(() => {
70        if (
71          dataLayer.requests.statusStore.getError(
72            TAB_STATUS_PREFIXES[tab] + query,
73          )
74        ) {
75          loadedTabs.delete(tab);
76        }
77      })
78      .catch((error) => {
79        loadedTabs.delete(tab);
80        console.error("Failed to load search results", error);
81      });
82  }
83
84  function loadTypeahead(query) {
85    dataLayer.requests
86      .loadSearchTypeahead(query, { limit: 8 })
87      .catch((error) => console.warn("Typeahead search failed", error));
88  }
89
90  function resetSearchState() {
91    state.$inputValue.set("");
92    state.$showTypeahead.set(false);
93    state.$committedQuery.set("");
94    loadedTabs.clear();
95    tabScrollState.clear();
96    const url = new URL(window.location);
97    url.searchParams.delete("q");
98    window.history.replaceState({}, "", url);
99    dataLayer.requests.loadSearchTypeahead("");
100    dataLayer.requests.loadProfileSearch("");
101    if (isAuthenticated) {
102      dataLayer.requests.loadPostSearchTop("");
103      dataLayer.requests.loadPostSearchLatest("");
104      dataLayer.requests.loadFeedSearch("");
105    }
106  }
107
108  function handleInput(value) {
109    const trimmed = value.trim();
110    if (!trimmed) {
111      resetSearchState();
112      state.$inputValue.set(value);
113      return;
114    }
115    state.$inputValue.set(value);
116    state.$showTypeahead.set(true);
117    loadTypeahead(trimmed);
118  }
119
120  function commitSearch() {
121    const query = state.$inputValue.get().trim();
122    if (!query) return;
123    if (isAuthenticated) {
124      dataLayer.mutations.addRecentSearch(query).catch(console.warn);
125    }
126    state.$showTypeahead.set(false);
127    const queryChanged = query !== state.$committedQuery.get();
128    state.$committedQuery.set(query);
129    const url = new URL(window.location);
130    url.searchParams.set("q", query);
131    window.history.replaceState({}, "", url);
132    if (queryChanged) {
133      loadedTabs.clear();
134      tabScrollState.clear();
135    }
136    loadTabIfNeeded(state.$activeTab.get());
137    root.querySelector(".search-input")?.blur();
138  }
139
140  function handleClearSearch() {
141    resetSearchState();
142    root.querySelector(".search-input")?.focus();
143  }
144
145  function handleRecentSearchSelect(q) {
146    state.$inputValue.set(q);
147    state.$showTypeahead.set(false);
148    commitSearch();
149  }
150
151  function handleRecentSearchRemove(q) {
152    dataLayer.mutations.removeRecentSearch(q).catch(console.warn);
153  }
154
155  function handleRecentProfileRecord(did) {
156    if (!isAuthenticated) return;
157    dataLayer.mutations.addRecentSearchProfile(did).catch(console.warn);
158  }
159
160  function handleRecentProfileRemove(did) {
161    dataLayer.mutations.removeRecentSearchProfile(did).catch(console.warn);
162  }
163
164  function isRecentProfileVisible(profile) {
165    if (!profile) return false;
166    if (profile.viewer?.blocking || profile.viewer?.blockedBy) return false;
167    if (profile.handle === MISSING_HANDLE) return false;
168    if ((profile.labels ?? []).some((label) => label.val === "!takendown")) {
169      return false;
170    }
171    return true;
172  }
173
174  async function hydrateAndPruneRecentProfiles() {
175    try {
176      if (!isAuthenticated) return;
177      try {
178        await dataLayer.preferencesProvider.requirePreferences();
179      } catch (error) {
180        console.warn("Failed to load recent search profiles", error);
181        return;
182      }
183      const entries = dataLayer.derived.$recentSearchProfiles.get() ?? [];
184      if (entries.length === 0) return;
185      const dids = entries.map((entry) => entry.did);
186      try {
187        await dataLayer.declarative.ensureProfiles(dids);
188      } catch (error) {
189        console.warn("Failed to load recent search profiles", error);
190        return;
191      }
192      const hydrated = dataLayer.derived.$recentSearchProfiles.get() ?? [];
193      const fetchedDids = new Set(dids);
194      const prunedDids = hydrated
195        .filter(
196          (entry) =>
197            fetchedDids.has(entry.did) &&
198            !isRecentProfileVisible(entry.profile),
199        )
200        .map((entry) => entry.did);
201      if (prunedDids.length > 0) {
202        dataLayer.mutations
203          .removeRecentSearchProfiles(prunedDids)
204          .catch(console.warn);
205      }
206    } finally {
207      state.$recentProfilesLoading.set(false);
208    }
209  }
210
211  function loadPageData() {
212    const query = new URLSearchParams(window.location.search);
213    if (query.get("tab")) {
214      const tab = query.get("tab");
215      state.$activeTab.set(tab === "posts" ? "top" : tab);
216    }
217    const q = getUrlQuery();
218    if (q) {
219      state.$inputValue.set(q);
220      state.$showTypeahead.set(false);
221      state.$committedQuery.set(q);
222      loadedTabs.clear();
223      tabScrollState.clear();
224      loadTabIfNeeded(state.$activeTab.get());
225    }
226    hydrateAndPruneRecentProfiles();
227  }
228
229  function handleTabChange(tab) {
230    if (tab === state.$activeTab.get()) {
231      if (window.scrollY > 0) {
232        window.scrollTo({ top: -1, behavior: "smooth" });
233      }
234      return;
235    }
236    tabScrollState.set(state.$activeTab.get(), window.scrollY);
237    state.$activeTab.set(tab);
238    loadTabIfNeeded(tab);
239    requestAnimationFrame(() => {
240      window.scrollTo(0, tabScrollState.get(tab) ?? 0);
241    });
242  }
243
244  async function loadMoreProfiles() {
245    const cursor = dataLayer.derived.$profileSearchCursor.get();
246    if (!cursor) return;
247    await dataLayer.requests.loadProfileSearch(state.$committedQuery.get(), {
248      limit: 25,
249      cursor,
250    });
251  }
252
253  async function loadMoreTopPosts() {
254    const cursor = dataLayer.derived.$postSearchCursorTop.get();
255    if (!cursor) return;
256    await dataLayer.requests.loadPostSearchTop(state.$committedQuery.get(), {
257      limit: 25,
258      cursor,
259    });
260  }
261
262  async function loadMoreLatestPosts() {
263    const cursor = dataLayer.derived.$postSearchCursorLatest.get();
264    if (!cursor) return;
265    await dataLayer.requests.loadPostSearchLatest(state.$committedQuery.get(), {
266      limit: 25,
267      cursor,
268    });
269  }
270
271  async function loadMoreFeeds() {
272    const cursor = dataLayer.derived.$feedSearchCursor.get();
273    if (!cursor) return;
274    await dataLayer.requests.loadFeedSearch(state.$committedQuery.get(), {
275      limit: 15,
276      cursor,
277    });
278  }
279
280  const {
281    postInteractionHandler,
282    feedInteractionHandler,
283    profileInteractionHandler,
284  } = interactionHandlers;
285
286  function typeaheadTemplate({ query, profiles, onCommit }) {
287    return html`<div class="search-typeahead">
288      <button
289        class="search-typeahead-row search-typeahead-search-row"
290        data-testid="search-typeahead-search-row"
291        @click=${() => onCommit()}
292      >
293        <div class="search-typeahead-icon">
294          <app-icon icon="search-line"></app-icon>
295        </div>
296        <div class="search-typeahead-text">${query}</div>
297      </button>
298      ${profiles === null
299        ? html`<div class="search-typeahead-loading">
300            <div class="loading-spinner"></div>
301          </div>`
302        : profiles.map(
303            (profile) => html`
304              <container-link
305                class="search-typeahead-row clickable"
306                data-testid="search-typeahead-result"
307                href=${linkToProfile(profile)}
307
308                @click=${() => handleRecentProfileRecord(profile.did)}
309              >
310                ${avatarTemplate({ author: profile, clickAction: "none" })}
311                <div class="search-typeahead-text">
312                  <div class="search-typeahead-name">
313                    ${getDisplayName(profile)}
314                  </div>
315                  <div class="search-typeahead-handle">@${profile.handle}</div>
316                </div>
317              </container-link>
318            `,
319          )}
320    </div>`;
321  }
322
323  function recentSearchRowTemplate(q) {
324    return html`<div class="search-recent-row" data-testid="search-recent-row">
325      <button
326        type="button"
327        class="search-typeahead-row search-recent-row-button"
328        data-testid="search-recent-row-button"
329        @click=${() => handleRecentSearchSelect(q)}
330      >
331        <div class="search-typeahead-icon">
332          <app-icon icon="search-line"></app-icon>
333        </div>
334        <div class="search-typeahead-text">${q}</div>
335      </button>
336      <button
337        type="button"
338        class="icon-button search-recent-remove-button"
339        data-testid="search-recent-remove-button"
340        aria-label="Remove ${q}"
341        @mousedown=${(event) => event.preventDefault()}
342        @click=${(event) => {
343          event.stopPropagation();
344          handleRecentSearchRemove(q);
345        }}
346      >
347        <app-icon icon="close-line"></app-icon>
348      </button>
349    </div>`;
350  }
351
352  function recentProfileTileTemplate(profile) {
353    return html`<container-link
354      class="search-recent-profile clickable"
355      data-testid="search-recent-profile"
356      href=${linkToProfileByDid(profile.did)}
357      @click=${() => handleRecentProfileRecord(profile.did)}
358    >
359      ${avatarTemplate({ author: profile, clickAction: "none" })}
360      <div class="search-recent-profile-name">${getDisplayName(profile)}</div>
361      <button
362        type="button"
363        class="embed-preview-close-button search-recent-profile-remove"
364        data-testid="search-recent-profile-remove"
365        aria-label="Remove ${getDisplayName(profile)}"
366        @click=${(event) => {
367          event.stopPropagation();
368          handleRecentProfileRemove(profile.did);
369        }}
370      >
371        <app-icon icon="close-line"></app-icon>
372      </button>
373    </container-link>`;
374  }
375
376  function recentProfileSkeletonTemplate() {
377    return html`<div
378      class="search-recent-profile search-recent-profile-skeleton"
379      data-testid="search-recent-profile-skeleton"
380    >
381      <div
382        class="skeleton-avatar skeleton-animate search-recent-profile-skeleton-avatar"
383      ></div>
384      <div class="search-recent-profile-name">
385        &#8203;<span
386          class="skeleton-line-shorter skeleton-animate search-recent-profile-skeleton-name-line"
387        ></span>
388      </div>
389    </div>`;
390  }
391
392  function recentSearchesTemplate({ terms, profileItems }) {
393    return html`<div class="search-landing" data-testid="search-recent">
394      <div class="search-recent-heading">Recent searches</div>
395      ${profileItems.length > 0
396        ? html`<div class="search-recent-profiles">
397            ${profileItems.map((item) =>
398              keyed(
399                item.did,
400                item.profile
401                  ? recentProfileTileTemplate(item.profile)
402                  : recentProfileSkeletonTemplate(),
403              ),
404            )}
405          </div>`
406        : ""}
407      ${terms.map((entry) => recentSearchRowTemplate(entry.q))}
408    </div>`;
409  }
410
411  function postSearchResultsTemplate({
412    status,
413    postSearchResults,
414    postSearchHasMore,
415    onLoadMore,
416    currentUser,
417  }) {
418    if (!postSearchResults && status.loading) {
419      return html`<div class="search-status-message">Searching posts…</div>`;
420    }
421    if (status.error) {
422      return html`<div class="search-status-message error">
423        Failed to search posts
424        ${status.error.message ? html`(${status.error.message})` : ""}.
425      </div>`;
426    }
427    if (!postSearchResults || postSearchResults.length === 0) {
428      return html`<div class="search-status-message" data-testid="empty-state">
429        No posts found.
430      </div>`;
431    }
432    return html`<infinite-scroll-container
433      lookahead="2500px"
434      @load-more=${async (event) => {
435        if (postSearchHasMore) {
436          await onLoadMore();
437          event.detail.resume();
438        }
439      }}
440      ?disabled=${!postSearchHasMore}
441    >
442      <div>
443        ${postSearchResults.map(
444          (post) =>
445            html`<div class="feed-item" data-post-uri="${post.uri}">
446              ${smallPostTemplate({
447                post,
448                currentUser,
449                isAuthenticated,
450                showReplyToLabel: !!post.record?.reply,
451                replyToAuthor: post.record?.reply?.parentAuthor ?? null,
452                isUserPost: currentUser?.did === post.author?.did,
453                postInteractionHandler,
454                pluginService,
455              })}
456            </div>`,
457        )}
458        ${postSearchHasMore
459          ? html`<div class="feed-loading-indicator">
460              <div class="loading-spinner"></div>
461            </div>`
462          : ""}
463      </div>
464    </infinite-scroll-container>`;
465  }
466
467  function profileSearchResultsTemplate({
468    status,
469    profileSearchResults,
470    profileSearchHasMore,
471    currentUser,
472  }) {
473    if (!profileSearchResults && status.loading) {
474      return html`<div class="search-status-message">Searching profiles…</div>`;
475    }
476    if (status.error) {
477      return html`<div class="search-status-message error">
478        Failed to search profiles
479        ${status.error.message ? html`(${status.error.message})` : ""}.
480      </div>`;
481    }
482    if (!profileSearchResults || profileSearchResults.length === 0) {
483      return html`<div class="search-status-message" data-testid="empty-state">
484        No profiles found.
485      </div>`;
486    }
487    return profileFeedTemplate({
488      profiles: profileSearchResults,
489      hasMore: profileSearchHasMore,
490      onLoadMore: loadMoreProfiles,
491      isAuthenticated,
492      currentUserDid: currentUser?.did ?? null,
493      profileInteractionHandler,
494      pluginService,
495    });
496  }
497
498  function feedSearchResultsTemplate({
499    status,
500    feedSearchResults,
501    feedSearchHasMore,
502    preferences,
503  }) {
504    if (!feedSearchResults && status.loading) {
505      return html`<div class="search-status-message">Searching feeds…</div>`;
506    }
507    if (status.error) {
508      return html`<div class="search-status-message error">
509        Failed to search feeds
510        ${status.error.message ? html`(${status.error.message})` : ""}.
511      </div>`;
512    }
513    if (!feedSearchResults || feedSearchResults.length === 0) {
514      return html`<div class="search-status-message" data-testid="empty-state">
515        No feeds found.
516      </div>`;
517    }
518    return html`<infinite-scroll-container
519      lookahead="2500px"
520      @load-more=${async (event) => {
521        if (feedSearchHasMore) {
522          await loadMoreFeeds();
523          event.detail.resume();
524        }
525      }}
526      ?disabled=${!feedSearchHasMore}
527    >
528      <div class="feeds-list">
529        ${feedSearchResults.map((feedGenerator) => {
530          const isPinned = preferences.isFeedPinned(feedGenerator.uri);
531          return html`
532            <container-link
533              class="feeds-list-item clickable"
534              href=${linkToFeed(feedGenerator)}
535            >
536              <div class="feeds-list-item-avatar">
537                ${feedGenerator.avatar
538                  ? html`<img
539                      src=${cdnImageUrl(feedGenerator.avatar)}
540                      alt=${feedGenerator.displayName}
541                      class="feed-avatar"
542                    />`
543                  : html`<img
544                      src="/img/feed-avatar-fallback.svg"
545                      alt=${feedGenerator.displayName}
546                      class="feed-avatar"
547                    />`}
548              </div>
549              <div class="feeds-list-item-content">
550                <div class="feeds-list-item-title">
551                  ${feedGenerator.displayName}
552                </div>
553                ${feedGenerator.creator
554                  ? html`<div class="feeds-list-item-creator">
555                      by @${feedGenerator.creator.handle}
556                    </div>`
557                  : ""}
558                ${feedGenerator.description
559                  ? // prettier-ignore
560                    html`<div class="feeds-list-item-description">${feedGenerator.description}</div>`
561                  : ""}
562              </div>
563              <div class="feeds-list-item-actions">
564                <button
565                  class=${classnames("rounded-button pin-feed-button", {
566                    "rounded-button-primary": !isPinned,
567                    pinned: isPinned,
568                  })}
569                  @click=${(e) => {
570                    e.stopPropagation();
571                    feedInteractionHandler.handlePinFeed(
572                      feedGenerator.uri,
573                      !isPinned,
574                    );
575                  }}
576                >
577                  ${isPinned ? "" : fillableIconTemplate({ icon: "pin" })}
578                  ${isPinned ? "Unpin feed" : "Pin feed"}
579                </button>
580              </div>
581            </container-link>
582          `;
583        })}
584        ${feedSearchHasMore
585          ? html`<div class="feed-loading-indicator">
586              <div class="loading-spinner"></div>
587            </div>`
588          : ""}
589      </div>
590    </infinite-scroll-container>`;
591  }
592
593  function getActivePanelTemplate(activeTab, committedQuery, currentUser) {
594    const status = dataLayer.requests.statusStore.$statuses.get(
595      TAB_STATUS_PREFIXES[activeTab] + committedQuery,
596    );
597    switch (activeTab) {
598      case "top":
599        return html`<div
600          class="search-results-panel search-post-results search-post-results-top"
601        >
602          ${postSearchResultsTemplate({
603            status,
604            postSearchResults: dataLayer.derived.$postSearchResultsTop.get(),
605            postSearchHasMore: !!dataLayer.derived.$postSearchCursorTop.get(),
606            onLoadMore: loadMoreTopPosts,
607            currentUser,
608          })}
609        </div>`;
610      case "latest":
611        return html`<div
612          class="search-results-panel search-post-results search-post-results-latest"
613        >
614          ${postSearchResultsTemplate({
615            status,
616            postSearchResults: dataLayer.derived.$postSearchResultsLatest.get(),
617            postSearchHasMore:
618              !!dataLayer.derived.$postSearchCursorLatest.get(),
619            onLoadMore: loadMoreLatestPosts,
620            currentUser,
621          })}
622        </div>`;
623      case "feeds":
624        return html`<div class="search-results-panel">
625          ${feedSearchResultsTemplate({
626            status,
627            feedSearchResults: dataLayer.derived.$feedSearchResults.get(),
628            feedSearchHasMore: !!dataLayer.derived.$feedSearchCursor.get(),
629            preferences: dataLayer.derived.$preferences.get(),
630          })}
631        </div>`;
632      default:
633        return html`<div class="search-results-panel">
634          ${profileSearchResultsTemplate({
635            status,
636            profileSearchResults: dataLayer.derived.$profileSearchResults.get(),
637            profileSearchHasMore:
638              !!dataLayer.derived.$profileSearchCursor.get(),
639            currentUser,
640          })}
641        </div>`;
642    }
643  }
644
645  loadPageData();
646
647  bindToPage(root, layout, "active-nav-click", () => {
648    loadPageData();
649  });
650
651  bindPageTitle(root, () => "Search");
652
653  pageEffect(root, () => {
654    const currentUser = dataLayer.derived.$currentUser.get();
655    const inputValue = state.$inputValue.get();
656    const showTypeahead = state.$showTypeahead.get();
657    const committedQuery = state.$committedQuery.get();
658    const activeTab = state.$activeTab.get();
659    const trimmedInput = inputValue.trim();
660    const mode = !trimmedInput
661      ? "placeholder"
662      : showTypeahead
663        ? "typeahead"
664        : "results";
665
666    let bodyTemplate;
667    if (mode === "typeahead") {
668      bodyTemplate = typeaheadTemplate({
669        query: trimmedInput,
670        profiles: dataLayer.derived.$searchTypeaheadResults.get(),
671        onCommit: commitSearch,
672      });
673    } else if (mode === "results") {
674      bodyTemplate = html`<div class="search-tab-panels">
675        <div class="search-tab-panel">
676          ${getActivePanelTemplate(
677            isAuthenticated ? activeTab : "profiles",
678            committedQuery,
679            currentUser,
680          )}
681        </div>
682      </div>`;
683    } else {
684      const recentTerms = isAuthenticated
685        ? dataLayer.derived.$recentSearchTerms.get()
686        : [];
687      const recentProfilesLoading = state.$recentProfilesLoading.get();
688      const recentProfileItems = (
689        (isAuthenticated
690          ? dataLayer.derived.$recentSearchProfiles.get()
691          : null) ?? []
692      )
693        .map((entry) => ({
694          did: entry.did,
695          profile: isRecentProfileVisible(entry.profile) ? entry.profile : null,
696          pending: !entry.profile && recentProfilesLoading,
697        }))
698        .filter((item) => item.profile || item.pending);
699      if (recentTerms.length > 0 || recentProfileItems.length > 0) {
700        bodyTemplate = recentSearchesTemplate({
701          terms: recentTerms,
702          profileItems: recentProfileItems,
703        });
704      } else {
705        bodyTemplate = html`<div class="search-placeholder">
706          <div class="search-placeholder-icon">
707            <app-icon icon="search-line"></app-icon>
708          </div>
709          <div class="search-placeholder-text">
710            ${isAuthenticated
711              ? "Start typing to search for users, posts, and feeds."
712              : html`Start typing to search for users.<br />Sign in to search
713                  for posts.`}
714          </div>
715        </div>`;
716      }
717    }
718
719    render(
720      html`<div id="search-view">
721        ${headerTemplate({
722          title: "Search",
723          leftButton: "menu",
724          onClickMenuButton: () => layout.openSidebar(),
725          bottomItemTemplate: () => html`
726            <div class="search-input-container">
727              <app-icon icon="search-line"></app-icon>
728              <input
729                class="search-input"
730                type="search"
731                name="search"
732                aria-label="Search"
733                autocapitalize="none"
734                autocomplete="off"
735                autocorrect="off"
736                enterkeyhint="search"
737                spellcheck="false"
738                placeholder=${isAuthenticated
739                  ? "Search for users, posts, and feeds"
740                  : "Search for users"}
741                .value=${inputValue}
742                @input=${(event) => {
743                  // Prevent events from being picked up by password manager extensions
744                  event.stopPropagation();
745                  handleInput(event.target.value);
746                }}
747                @keydown=${(event) => {
748                  event.stopPropagation();
749                  if (event.key === "Enter") {
750                    event.preventDefault();
751                    commitSearch();
752                  }
753                }}
754              />
755              ${inputValue.length > 0
756                ? html`
757                    <button
758                      class="search-clear-button"
759                      @click=${() => handleClearSearch()}
760                    >
761                      <app-icon icon="close-line"></app-icon>
762                    </button>
763                  `
764                : ""}
765              ${mode === "results" && isAuthenticated
766                ? html`
767                    <tab-bar
768                      .tabs=${[
769                        { value: "top", label: "Top" },
770                        { value: "latest", label: "Latest" },
771                        { value: "profiles", label: "People" },
772                        { value: "feeds", label: "Feeds" },
773                      ]}
774                      active-tab=${activeTab}
775                      full-width
776                      @tab-click=${(event) => handleTabChange(event.detail)}
777                    ></tab-bar>
778                  `
779                : ""}
780            </div>
781          `,
782        })}
783        <main>
784          <div class="search-results-container">${bodyTemplate}</div>
785        </main>
786      </div>`,
787      root,
788    );
789  });
790}

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.