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