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.

Update search flow

Grace Kind (Jul 18, 2026, 9:01 PM -0500) 38d58302 dc9c158a

+907 -344
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.18.12", 3 + "version": "0.18.13", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+70 -2
src/css/style.css
··· 5594 5594 gap: 16px; 5595 5595 } 5596 5596 5597 - .search-tab-panel[hidden] { 5598 - display: none; 5597 + .search-typeahead { 5598 + display: flex; 5599 + flex-direction: column; 5600 + } 5601 + 5602 + .search-typeahead-row { 5603 + display: flex; 5604 + align-items: center; 5605 + gap: 12px; 5606 + padding: 12px 16px; 5607 + border: none; 5608 + background: none; 5609 + text-align: left; 5610 + font-size: 15px; 5611 + color: var(--text-color); 5612 + cursor: pointer; 5613 + border-bottom: var(--hair) solid var(--post-border-color); 5614 + } 5615 + 5616 + @media (hover: hover) { 5617 + .search-typeahead-row:hover { 5618 + background-color: color-mix( 5619 + in srgb, 5620 + var(--highlight-color) 6%, 5621 + transparent 5622 + ); 5623 + } 5624 + } 5625 + 5626 + .search-typeahead-loading { 5627 + display: flex; 5628 + justify-content: center; 5629 + padding: 16px; 5630 + } 5631 + 5632 + .search-typeahead-icon { 5633 + width: var(--avatar-size-post); 5634 + height: var(--avatar-size-post); 5635 + display: flex; 5636 + align-items: center; 5637 + justify-content: center; 5638 + flex-shrink: 0; 5639 + color: var(--text-color-muted); 5640 + } 5641 + 5642 + .search-typeahead-icon .search-icon { 5643 + width: 24px; 5644 + height: 24px; 5645 + } 5646 + 5647 + .search-typeahead-text { 5648 + min-width: 0; 5649 + overflow: hidden; 5650 + text-overflow: ellipsis; 5651 + white-space: nowrap; 5652 + } 5653 + 5654 + .search-typeahead-name { 5655 + font-weight: 600; 5656 + overflow: hidden; 5657 + text-overflow: ellipsis; 5658 + white-space: nowrap; 5659 + } 5660 + 5661 + .search-typeahead-handle { 5662 + color: var(--text-color-muted); 5663 + font-size: 14px; 5664 + overflow: hidden; 5665 + text-overflow: ellipsis; 5666 + white-space: nowrap; 5599 5667 } 5600 5668 5601 5669 .search-results-panel {
+2 -6
src/js/components/new-chat-dialog.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { Component } from "/js/components/component.js"; 3 3 import { ScrollLock } from "/js/scrollLock.js"; 4 - import { enableDragToDismiss, debounce } from "/js/utils.js"; 4 + import { enableDragToDismiss } from "/js/utils.js"; 5 5 import { Signal, ReactiveStore, effect } from "/js/signals.js"; 6 6 import { getDisplayName, MISSING_HANDLE } from "/js/dataHelpers.js"; 7 7 import { avatarTemplate } from "/js/templates/avatar.template.js"; ··· 147 147 this.scrollLock = new ScrollLock(this); 148 148 this.state = new ReactiveStore("new-chat-dialog"); 149 149 this.state.$query = new Signal.State(""); 150 - this._debouncedSearch = debounce((query) => { 151 - this.dataLayer.requests.loadChatRecipientSearch(query, { limit: 12 }); 152 - }); 153 150 this.innerHTML = ""; 154 151 this._disposeEffect = effect(() => { 155 152 this.render(); ··· 176 173 this.state.$query.set(value); 177 174 const query = value.trim(); 178 175 if (!query) { 179 - this._debouncedSearch.cancel(); 180 176 this.dataLayer.requests.loadChatRecipientSearch(""); 181 177 } else { 182 - this._debouncedSearch(query); 178 + this.dataLayer.requests.loadChatRecipientSearch(query, { limit: 12 }); 183 179 } 184 180 } 185 181
+2
src/js/dataLayer/dataStore.js
··· 9 9 this.$currentUser = new Signal.State(null); 10 10 this.$profileSearchResults = new Signal.State(null); 11 11 this.$chatRecipientSearchResults = new Signal.State(null); 12 + this.$searchTypeaheadResults = new Signal.State(null); 12 13 this.$feedSearchResults = new Signal.State(null); 13 14 this.$showLessInteractions = new Signal.State([]); 14 15 this.$showMoreInteractions = new Signal.State([]); ··· 23 24 this.$mutedProfiles = new Signal.State(null); 24 25 this.$latestProfileSearchRequestTime = new Signal.State(null); 25 26 this.$latestChatRecipientSearchRequestTime = new Signal.State(null); 27 + this.$latestSearchTypeaheadRequestTime = new Signal.State(null); 26 28 this.$latestFeedSearchRequestTime = new Signal.State(null); 27 29 this.$postSearchResultsTop = new Signal.State(null); 28 30 this.$postSearchResultsLatest = new Signal.State(null);
+5
src/js/dataLayer/derived.js
··· 281 281 if (!data) return null; 282 282 return data.actors.map((actor) => this.$hydratedProfiles.get(actor.did)); 283 283 }); 284 + this.$searchTypeaheadResults = new Signal.Computed(() => { 285 + const data = this.dataStore.$searchTypeaheadResults.get(); 286 + if (!data) return null; 287 + return data.actors.map((actor) => this.$hydratedProfiles.get(actor.did)); 288 + }); 284 289 this.$feedSearchResults = new Signal.Computed(() => { 285 290 const data = this.dataStore.$feedSearchResults.get(); 286 291 if (!data) return null;
+24
src/js/dataLayer/requests.js
··· 196 196 (query) => "loadProfileSearch-" + query, 197 197 ); 198 198 this.enableStatus(this.loadChatRecipientSearch, "loadChatRecipientSearch"); 199 + this.enableStatus(this.loadSearchTypeahead, "loadSearchTypeahead"); 199 200 this.enableStatus( 200 201 this.loadPostSearchTop, 201 202 (query) => "loadPostSearchTop-" + query, ··· 641 642 } 642 643 this.dataStore.setProfiles(searchData.actors); 643 644 this.dataStore.$chatRecipientSearchResults.set(searchData); 645 + } 646 + 647 + async loadSearchTypeahead(query, { limit = 8 } = {}) { 648 + if (!query) { 649 + // Invalidate in-flight searches so they can't repopulate cleared results 650 + this.dataStore.$latestSearchTypeaheadRequestTime.set(null); 651 + this.dataStore.$searchTypeaheadResults.set(null); 652 + return; 653 + } 654 + const labelers = this.requireLabelers(); 655 + const requestTime = Date.now(); 656 + this.dataStore.$latestSearchTypeaheadRequestTime.set(requestTime); 657 + const searchData = await this.api.searchProfilesTypeahead(query, { 658 + limit, 659 + labelers, 660 + }); 661 + if ( 662 + requestTime !== this.dataStore.$latestSearchTypeaheadRequestTime.get() 663 + ) { 664 + return; 665 + } 666 + this.dataStore.setProfiles(searchData.actors); 667 + this.dataStore.$searchTypeaheadResults.set(searchData); 644 668 } 645 669 646 670 async loadPostSearchTop(query, { limit = 25, cursor = "" } = {}) {
-12
src/js/utils.js
··· 243 243 return value; 244 244 } 245 245 246 - export function debounce(fn, delay = 250) { 247 - let timeoutId = null; 248 - const debounced = (...args) => { 249 - clearTimeout(timeoutId); 250 - timeoutId = setTimeout(() => fn(...args), delay); 251 - }; 252 - debounced.cancel = () => { 253 - clearTimeout(timeoutId); 254 - }; 255 - return debounced; 256 - } 257 - 258 246 export function throttle(fn, delay = 250) { 259 247 let lastCall = 0; 260 248 return (...args) => {
+320 -242
src/js/views/search.view.js
··· 3 3 import { searchIconTemplate } from "/js/templates/icons/searchIcon.template.js"; 4 4 import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; 5 5 import { headerTemplate } from "/js/templates/header.template.js"; 6 - import { classnames, debounce } from "/js/utils.js"; 6 + import { avatarTemplate } from "/js/templates/avatar.template.js"; 7 + import { classnames } from "/js/utils.js"; 7 8 import { Signal, ReactiveStore } from "/js/signals.js"; 8 - import { linkToFeed } from "/js/navigation.js"; 9 + import { linkToFeed, linkToProfile } from "/js/navigation.js"; 9 10 import { smallPostTemplate } from "/js/templates/smallPost.template.js"; 10 11 import { pageEffect } from "/js/router.js"; 11 12 import { pinIconTemplate } from "/js/templates/icons/pinIcon.template.js"; ··· 20 21 context: { dataLayer, isAuthenticated, pluginService, interactionHandlers }, 21 22 }) { 22 23 const state = new ReactiveStore("searchView"); 23 - state.$activeTab = new Signal.State("profiles"); 24 - state.$searchQuery = new Signal.State(""); 24 + state.$activeTab = new Signal.State("top"); 25 + state.$inputValue = new Signal.State(""); 26 + state.$committedQuery = new Signal.State(""); 27 + state.$showTypeahead = new Signal.State(false); 25 28 26 29 const tabScrollState = new Map(); 30 + const loadedTabs = new Set(); 27 31 28 - async function loadSearchResults() { 29 - const searchQuery = state.$searchQuery.get(); 30 - const normalizedQuery = searchQuery.trim(); 32 + const TAB_LOADERS = { 33 + profiles: (query) => 34 + dataLayer.requests.loadProfileSearch(query, { limit: 25 }), 35 + top: (query) => 36 + dataLayer.requests.loadPostSearchTop(query, { limit: 25 }), 37 + latest: (query) => 38 + dataLayer.requests.loadPostSearchLatest(query, { limit: 25 }), 39 + feeds: (query) => dataLayer.requests.loadFeedSearch(query, { limit: 15 }), 40 + }; 31 41 32 - // Update URL query parameter 33 - const url = new URL(window.location); 34 - if (searchQuery) { 35 - url.searchParams.set("q", searchQuery); 36 - } else { 37 - url.searchParams.delete("q"); 42 + const TAB_STATUS_PREFIXES = { 43 + profiles: "loadProfileSearch-", 44 + top: "loadPostSearchTop-", 45 + latest: "loadPostSearchLatest-", 46 + feeds: "loadFeedSearch-", 47 + }; 48 + 49 + function loadTabIfNeeded(tab) { 50 + const query = state.$committedQuery.get(); 51 + if (!query) return; 52 + if (!isAuthenticated) { 53 + tab = "profiles"; 38 54 } 39 - window.history.replaceState({}, "", url); 55 + if (loadedTabs.has(tab)) return; 56 + loadedTabs.add(tab); 57 + TAB_LOADERS[tab](query) 58 + .then(() => { 59 + if ( 60 + dataLayer.requests.statusStore.getError( 61 + TAB_STATUS_PREFIXES[tab] + query, 62 + ) 63 + ) { 64 + loadedTabs.delete(tab); 65 + } 66 + }) 67 + .catch((error) => { 68 + loadedTabs.delete(tab); 69 + console.error("Failed to load search results", error); 70 + }); 71 + } 72 + 73 + function loadTypeahead(query) { 74 + dataLayer.requests 75 + .loadSearchTypeahead(query, { limit: 8 }) 76 + .catch((error) => console.warn("Typeahead search failed", error)); 77 + } 40 78 41 - const requests = []; 79 + function handleInput(value) { 80 + state.$inputValue.set(value); 81 + const trimmed = value.trim(); 82 + if (!trimmed) { 83 + state.$showTypeahead.set(false); 84 + dataLayer.requests.loadSearchTypeahead(""); 85 + return; 86 + } 87 + state.$showTypeahead.set(true); 88 + loadTypeahead(trimmed); 89 + } 42 90 43 - requests.push( 44 - dataLayer.requests.loadProfileSearch(normalizedQuery, { 45 - limit: 25, 46 - }), 47 - ); 91 + function commitSearch() { 92 + const query = state.$inputValue.get().trim(); 93 + if (!query) return; 94 + state.$showTypeahead.set(false); 95 + const queryChanged = query !== state.$committedQuery.get(); 96 + state.$committedQuery.set(query); 97 + const url = new URL(window.location); 98 + url.searchParams.set("q", query); 99 + window.history.replaceState({}, "", url); 100 + if (queryChanged) { 101 + loadedTabs.clear(); 102 + tabScrollState.clear(); 103 + } 104 + loadTabIfNeeded(state.$activeTab.get()); 105 + root.querySelector(".search-input")?.blur(); 106 + } 48 107 108 + function handleClearSearch() { 109 + state.$inputValue.set(""); 110 + state.$showTypeahead.set(false); 111 + state.$committedQuery.set(""); 112 + loadedTabs.clear(); 113 + tabScrollState.clear(); 114 + const url = new URL(window.location); 115 + url.searchParams.delete("q"); 116 + window.history.replaceState({}, "", url); 117 + dataLayer.requests.loadSearchTypeahead(""); 118 + dataLayer.requests.loadProfileSearch(""); 49 119 if (isAuthenticated) { 50 - requests.push( 51 - dataLayer.requests.loadPostSearchTop(normalizedQuery, { 52 - limit: 25, 53 - }), 54 - ); 55 - requests.push( 56 - dataLayer.requests.loadPostSearchLatest(normalizedQuery, { 57 - limit: 25, 58 - }), 59 - ); 60 - requests.push( 61 - dataLayer.requests.loadFeedSearch(normalizedQuery, { 62 - limit: 15, 63 - }), 64 - ); 120 + dataLayer.requests.loadPostSearchTop(""); 121 + dataLayer.requests.loadPostSearchLatest(""); 122 + dataLayer.requests.loadFeedSearch(""); 65 123 } 124 + root.querySelector(".search-input")?.focus(); 125 + } 66 126 67 - try { 68 - await Promise.all(requests); 69 - } catch (error) { 70 - console.error("Failed to load search results", error); 127 + function handleTabChange(tab) { 128 + if (tab === state.$activeTab.get()) { 129 + if (window.scrollY > 0) { 130 + window.scrollTo({ top: -1, behavior: "smooth" }); 131 + } 132 + return; 71 133 } 134 + tabScrollState.set(state.$activeTab.get(), window.scrollY); 135 + state.$activeTab.set(tab); 136 + loadTabIfNeeded(tab); 137 + requestAnimationFrame(() => { 138 + window.scrollTo(0, tabScrollState.get(tab) ?? 0); 139 + }); 72 140 } 73 141 74 142 async function loadMoreProfiles() { 75 143 const cursor = dataLayer.derived.$profileSearchCursor.get(); 76 144 if (!cursor) return; 77 - await dataLayer.requests.loadProfileSearch( 78 - state.$searchQuery.get().trim(), 79 - { 80 - limit: 25, 81 - cursor, 82 - }, 83 - ); 145 + await dataLayer.requests.loadProfileSearch(state.$committedQuery.get(), { 146 + limit: 25, 147 + cursor, 148 + }); 84 149 } 85 150 86 151 async function loadMoreTopPosts() { 87 152 const cursor = dataLayer.derived.$postSearchCursorTop.get(); 88 153 if (!cursor) return; 89 - await dataLayer.requests.loadPostSearchTop( 90 - state.$searchQuery.get().trim(), 91 - { 92 - limit: 25, 93 - cursor, 94 - }, 95 - ); 154 + await dataLayer.requests.loadPostSearchTop(state.$committedQuery.get(), { 155 + limit: 25, 156 + cursor, 157 + }); 96 158 } 97 159 98 160 async function loadMoreLatestPosts() { 99 161 const cursor = dataLayer.derived.$postSearchCursorLatest.get(); 100 162 if (!cursor) return; 101 163 await dataLayer.requests.loadPostSearchLatest( 102 - state.$searchQuery.get().trim(), 164 + state.$committedQuery.get(), 103 165 { 104 166 limit: 25, 105 167 cursor, ··· 110 172 async function loadMoreFeeds() { 111 173 const cursor = dataLayer.derived.$feedSearchCursor.get(); 112 174 if (!cursor) return; 113 - await dataLayer.requests.loadFeedSearch(state.$searchQuery.get().trim(), { 175 + await dataLayer.requests.loadFeedSearch(state.$committedQuery.get(), { 114 176 limit: 15, 115 177 cursor, 116 178 }); ··· 122 184 profileInteractionHandler, 123 185 } = interactionHandlers; 124 186 125 - const handleSearchInput = debounce((value) => { 126 - state.$searchQuery.set(value); 127 - loadSearchResults(); 128 - }); 129 - 130 - function handleClearSearch() { 131 - handleSearchInput.cancel(); 132 - state.$searchQuery.set(""); 133 - loadSearchResults(); 134 - } 135 - 136 - function handleTabChange(tab) { 137 - tabScrollState.set(state.$activeTab.get(), window.scrollY); 138 - state.$activeTab.set(tab); 139 - if (tabScrollState.has(tab)) { 140 - window.scrollTo(0, tabScrollState.get(tab)); 141 - } else { 142 - window.scrollTo(0, 0); 143 - } 187 + function typeaheadTemplate({ query, profiles, onCommit }) { 188 + return html`<div class="search-typeahead"> 189 + <button 190 + class="search-typeahead-row search-typeahead-search-row" 191 + data-testid="search-typeahead-search-row" 192 + @click=${() => onCommit()} 193 + > 194 + <div class="search-typeahead-icon">${searchIconTemplate()}</div> 195 + <div class="search-typeahead-text">Search for "${query}"</div> 196 + </button> 197 + ${profiles === null 198 + ? html`<div class="search-typeahead-loading"> 199 + <div class="loading-spinner"></div> 200 + </div>` 201 + : profiles.map( 202 + (profile) => html` 203 + <container-link 204 + class="search-typeahead-row clickable" 205 + data-testid="search-typeahead-result" 206 + href=${linkToProfile(profile)} 207 + > 208 + ${avatarTemplate({ author: profile, clickAction: "none" })} 209 + <div class="search-typeahead-text"> 210 + <div class="search-typeahead-name"> 211 + ${profile.displayName || profile.handle} 212 + </div> 213 + <div class="search-typeahead-handle"> 214 + @${profile.handle} 215 + </div> 216 + </div> 217 + </container-link> 218 + `, 219 + )} 220 + </div>`; 144 221 } 145 222 146 223 function postSearchResultsTemplate({ ··· 335 412 </infinite-scroll-container>`; 336 413 } 337 414 338 - pageEffect(root, () => { 339 - const currentUser = dataLayer.derived.$currentUser.get(); 340 - const searchQuery = state.$searchQuery.get(); 341 - const activeTab = state.$activeTab.get(); 342 - const normalizedQuery = searchQuery.trim(); 343 - const showResults = normalizedQuery.length > 0; 344 - const topPostStatus = dataLayer.requests.statusStore.$statuses.get( 345 - "loadPostSearchTop-" + normalizedQuery, 346 - ); 347 - const latestPostStatus = dataLayer.requests.statusStore.$statuses.get( 348 - "loadPostSearchLatest-" + normalizedQuery, 349 - ); 350 - const topPostSearchResults = 351 - dataLayer.derived.$postSearchResultsTop.get(); 352 - const latestPostSearchResults = 353 - dataLayer.derived.$postSearchResultsLatest.get(); 354 - const topPostSearchHasMore = 355 - !!dataLayer.derived.$postSearchCursorTop.get(); 356 - const latestPostSearchHasMore = 357 - !!dataLayer.derived.$postSearchCursorLatest.get(); 358 - const profileStatus = dataLayer.requests.statusStore.$statuses.get( 359 - "loadProfileSearch-" + normalizedQuery, 360 - ); 361 - const feedStatus = dataLayer.requests.statusStore.$statuses.get( 362 - "loadFeedSearch-" + normalizedQuery, 415 + function getActivePanelTemplate(activeTab, committedQuery, currentUser) { 416 + const status = dataLayer.requests.statusStore.$statuses.get( 417 + TAB_STATUS_PREFIXES[activeTab] + committedQuery, 363 418 ); 364 - const profileSearchResults = 365 - dataLayer.derived.$profileSearchResults.get(); 366 - const feedSearchResults = dataLayer.derived.$feedSearchResults.get(); 367 - const profileSearchHasMore = 368 - !!dataLayer.derived.$profileSearchCursor.get(); 369 - const feedSearchHasMore = !!dataLayer.derived.$feedSearchCursor.get(); 370 - const preferences = dataLayer.derived.$preferences.get(); 419 + switch (activeTab) { 420 + case "top": 421 + return html`<div 422 + class="search-results-panel search-post-results search-post-results-top" 423 + > 424 + ${postSearchResultsTemplate({ 425 + status, 426 + postSearchResults: dataLayer.derived.$postSearchResultsTop.get(), 427 + postSearchHasMore: !!dataLayer.derived.$postSearchCursorTop.get(), 428 + onLoadMore: loadMoreTopPosts, 429 + currentUser, 430 + })} 431 + </div>`; 432 + case "latest": 433 + return html`<div 434 + class="search-results-panel search-post-results search-post-results-latest" 435 + > 436 + ${postSearchResultsTemplate({ 437 + status, 438 + postSearchResults: 439 + dataLayer.derived.$postSearchResultsLatest.get(), 440 + postSearchHasMore: 441 + !!dataLayer.derived.$postSearchCursorLatest.get(), 442 + onLoadMore: loadMoreLatestPosts, 443 + currentUser, 444 + })} 445 + </div>`; 446 + case "feeds": 447 + return html`<div class="search-results-panel"> 448 + ${feedSearchResultsTemplate({ 449 + status, 450 + feedSearchResults: dataLayer.derived.$feedSearchResults.get(), 451 + feedSearchHasMore: !!dataLayer.derived.$feedSearchCursor.get(), 452 + preferences: dataLayer.derived.$preferences.get(), 453 + })} 454 + </div>`; 455 + default: 456 + return html`<div class="search-results-panel"> 457 + ${profileSearchResultsTemplate({ 458 + status, 459 + profileSearchResults: 460 + dataLayer.derived.$profileSearchResults.get(), 461 + profileSearchHasMore: 462 + !!dataLayer.derived.$profileSearchCursor.get(), 463 + currentUser, 464 + })} 465 + </div>`; 466 + } 467 + } 371 468 372 - render( 373 - html`<div id="search-view"> 374 - ${headerTemplate({ 375 - title: "Search", 376 - leftButton: "menu", 377 - onClickMenuButton: () => layout.openSidebar(), 378 - bottomItemTemplate: () => html` 379 - <div class="search-input-container"> 380 - ${searchIconTemplate()} 381 - <input 382 - class="search-input" 383 - type="search" 384 - autocapitalize="none" 385 - autocomplete="off" 386 - autocorrect="off" 387 - placeholder=${isAuthenticated 388 - ? "Search for users, posts, and feeds" 389 - : "Search for users"} 390 - .value=${searchQuery} 391 - @input=${(event) => handleSearchInput(event.target.value)} 392 - /> 393 - ${searchQuery.length > 0 394 - ? html` 395 - <button 396 - class="search-clear-button" 397 - @click=${() => handleClearSearch()} 398 - > 399 - ${closeIconTemplate()} 400 - </button> 401 - ` 402 - : ""} 403 - ${showResults && isAuthenticated 404 - ? html` 405 - <tab-bar 406 - .tabs=${[ 407 - { value: "profiles", label: "Profiles" }, 408 - { value: "top", label: "Top" }, 409 - { value: "latest", label: "Latest" }, 410 - { value: "feeds", label: "Feeds" }, 411 - ]} 412 - active-tab=${activeTab} 413 - full-width 414 - @tab-click=${(event) => handleTabChange(event.detail)} 415 - ></tab-bar> 416 - ` 417 - : ""} 418 - </div> 419 - `, 420 - })} 421 - <main> 422 - <div class="search-results-container"> 423 - ${showResults 424 - ? html` 425 - <div class="search-tab-panels"> 426 - <div 427 - class="search-tab-panel" 428 - ?hidden=${activeTab !== "top"} 429 - > 430 - <div 431 - class="search-results-panel search-post-results search-post-results-top" 469 + pageEffect( 470 + root, 471 + () => { 472 + const currentUser = dataLayer.derived.$currentUser.get(); 473 + const inputValue = state.$inputValue.get(); 474 + const showTypeahead = state.$showTypeahead.get(); 475 + const committedQuery = state.$committedQuery.get(); 476 + const activeTab = state.$activeTab.get(); 477 + const trimmedInput = inputValue.trim(); 478 + const mode = !trimmedInput 479 + ? "placeholder" 480 + : showTypeahead 481 + ? "typeahead" 482 + : "results"; 483 + 484 + let bodyTemplate; 485 + if (mode === "typeahead") { 486 + bodyTemplate = typeaheadTemplate({ 487 + query: trimmedInput, 488 + profiles: dataLayer.derived.$searchTypeaheadResults.get(), 489 + onCommit: commitSearch, 490 + }); 491 + } else if (mode === "results") { 492 + bodyTemplate = html`<div class="search-tab-panels"> 493 + <div class="search-tab-panel"> 494 + ${getActivePanelTemplate( 495 + isAuthenticated ? activeTab : "profiles", 496 + committedQuery, 497 + currentUser, 498 + )} 499 + </div> 500 + </div>`; 501 + } else { 502 + bodyTemplate = html`<div class="search-placeholder"> 503 + <div class="search-placeholder-icon">${searchIconTemplate()}</div> 504 + <div class="search-placeholder-text"> 505 + ${isAuthenticated 506 + ? "Start typing to search for users, posts, and feeds." 507 + : html`Start typing to search for users.<br />Sign in to search 508 + for posts.`} 509 + </div> 510 + </div>`; 511 + } 512 + 513 + render( 514 + html`<div id="search-view"> 515 + ${headerTemplate({ 516 + title: "Search", 517 + leftButton: "menu", 518 + onClickMenuButton: () => layout.openSidebar(), 519 + bottomItemTemplate: () => html` 520 + <div class="search-input-container"> 521 + ${searchIconTemplate()} 522 + <input 523 + class="search-input" 524 + type="search" 525 + autocapitalize="none" 526 + autocomplete="off" 527 + autocorrect="off" 528 + enterkeyhint="search" 529 + placeholder=${isAuthenticated 530 + ? "Search for users, posts, and feeds" 531 + : "Search for users"} 532 + .value=${inputValue} 533 + @input=${(event) => handleInput(event.target.value)} 534 + @keydown=${(event) => { 535 + if (event.key === "Enter") { 536 + event.preventDefault(); 537 + commitSearch(); 538 + } 539 + }} 540 + /> 541 + ${inputValue.length > 0 542 + ? html` 543 + <button 544 + class="search-clear-button" 545 + @click=${() => handleClearSearch()} 432 546 > 433 - ${postSearchResultsTemplate({ 434 - status: topPostStatus, 435 - postSearchResults: topPostSearchResults, 436 - postSearchHasMore: topPostSearchHasMore, 437 - onLoadMore: loadMoreTopPosts, 438 - currentUser, 439 - })} 440 - </div> 441 - </div> 442 - <div 443 - class="search-tab-panel" 444 - ?hidden=${activeTab !== "latest"} 445 - > 446 - <div 447 - class="search-results-panel search-post-results search-post-results-latest" 448 - > 449 - ${postSearchResultsTemplate({ 450 - status: latestPostStatus, 451 - postSearchResults: latestPostSearchResults, 452 - postSearchHasMore: latestPostSearchHasMore, 453 - onLoadMore: loadMoreLatestPosts, 454 - currentUser, 455 - })} 456 - </div> 457 - </div> 458 - <div 459 - class="search-tab-panel" 460 - ?hidden=${activeTab !== "profiles"} 461 - > 462 - <div class="search-results-panel"> 463 - ${profileSearchResultsTemplate({ 464 - status: profileStatus, 465 - profileSearchResults, 466 - profileSearchHasMore, 467 - currentUser, 468 - })} 469 - </div> 470 - </div> 471 - <div 472 - class="search-tab-panel" 473 - ?hidden=${activeTab !== "feeds"} 474 - > 475 - <div class="search-results-panel"> 476 - ${feedSearchResultsTemplate({ 477 - status: feedStatus, 478 - feedSearchResults, 479 - feedSearchHasMore, 480 - preferences, 481 - })} 482 - </div> 483 - </div> 484 - </div> 485 - ` 486 - : html`<div class="search-placeholder"> 487 - <div class="search-placeholder-icon"> 488 - ${searchIconTemplate()} 489 - </div> 490 - <div class="search-placeholder-text"> 491 - ${isAuthenticated 492 - ? "Start typing to search for users, posts, and feeds." 493 - : html`Start typing to search for users.<br />Sign in to 494 - search for posts.`} 495 - </div> 496 - </div>`} 497 - </div> 498 - </main> 499 - </div>`, 500 - root, 501 - ); 502 - }); 547 + ${closeIconTemplate()} 548 + </button> 549 + ` 550 + : ""} 551 + ${mode === "results" && isAuthenticated 552 + ? html` 553 + <tab-bar 554 + .tabs=${[ 555 + { value: "top", label: "Top" }, 556 + { value: "latest", label: "Latest" }, 557 + { value: "profiles", label: "People" }, 558 + { value: "feeds", label: "Feeds" }, 559 + ]} 560 + active-tab=${activeTab} 561 + full-width 562 + @tab-click=${(event) => handleTabChange(event.detail)} 563 + ></tab-bar> 564 + ` 565 + : ""} 566 + </div> 567 + `, 568 + })} 569 + <main> 570 + <div class="search-results-container">${bodyTemplate}</div> 571 + </main> 572 + </div>`, 573 + root, 574 + ); 575 + }, 576 + { debugName: "searchView" }, 577 + ); 503 578 504 - root.addEventListener("page-enter", async () => { 579 + root.addEventListener("page-enter", () => { 505 580 const query = new URLSearchParams(window.location.search); 506 - if (query.get("q")) { 507 - state.$searchQuery.set(query.get("q")); 508 - } 509 581 if (query.get("tab")) { 510 582 const tab = query.get("tab"); 511 583 state.$activeTab.set(tab === "posts" ? "top" : tab); 512 584 } 513 - if (state.$searchQuery.get()) { 514 - loadSearchResults(); 585 + const q = query.get("q"); 586 + if (q) { 587 + state.$inputValue.set(q); 588 + state.$showTypeahead.set(false); 589 + state.$committedQuery.set(q.trim()); 590 + loadedTabs.clear(); 591 + tabScrollState.clear(); 592 + loadTabIfNeeded(state.$activeTab.get()); 515 593 } 516 594 }); 517 595
+18 -1
tests/e2e/mockServer.js
··· 31 31 this.sendMessageFailure = null; 32 32 this.convoForMembersError = null; 33 33 this.typeaheadProfiles = []; 34 + this.typeaheadDelayMs = 0; 34 35 this.externalLinkCards = new Map(); 35 36 this.feedGenerators = []; 36 37 this.feeds = new Map(); ··· 70 71 this.searchPosts = []; 71 72 this.searchPostsBySort = { top: [], latest: [] }; 72 73 this.searchProfiles = []; 74 + this.searchRequestCounts = { 75 + profiles: 0, 76 + top: 0, 77 + latest: 0, 78 + feeds: 0, 79 + typeahead: 0, 80 + }; 73 81 this.timelinePosts = []; 74 82 this.timelineDelayMs = 0; 75 83 this.pluginSettings = new Map(); ··· 1334 1342 }); 1335 1343 1336 1344 await page.route("**/xrpc/app.bsky.actor.searchActors*", (route) => { 1345 + this.searchRequestCounts.profiles += 1; 1337 1346 const url = new URL(route.request().url()); 1338 1347 const cursor = url.searchParams.get("cursor") || ""; 1339 1348 const limit = parseInt(url.searchParams.get("limit") || "0", 10); ··· 1361 1370 await page.route( 1362 1371 "**/xrpc/app.bsky.unspecced.getPopularFeedGenerators*", 1363 1372 (route) => { 1373 + this.searchRequestCounts.feeds += 1; 1364 1374 const url = new URL(route.request().url()); 1365 1375 const cursor = url.searchParams.get("cursor") || ""; 1366 1376 const limit = parseInt(url.searchParams.get("limit") || "0", 10); ··· 1417 1427 const limit = parseInt(url.searchParams.get("limit") || "0", 10); 1418 1428 const offset = cursor ? parseInt(cursor, 10) : 0; 1419 1429 const sort = url.searchParams.get("sort") || "top"; 1430 + this.searchRequestCounts[sort] += 1; 1420 1431 const sortedPosts = this.searchPostsBySort[sort] ?? this.searchPosts; 1421 1432 1422 1433 let posts, nextCursor; ··· 2514 2525 2515 2526 await page.route( 2516 2527 "**/xrpc/app.bsky.actor.searchActorsTypeahead*", 2517 - (route) => { 2528 + async (route) => { 2529 + this.searchRequestCounts.typeahead += 1; 2530 + if (this.typeaheadDelayMs > 0) { 2531 + await new Promise((resolve) => 2532 + setTimeout(resolve, this.typeaheadDelayMs), 2533 + ); 2534 + } 2518 2535 return route.fulfill({ 2519 2536 status: 200, 2520 2537 contentType: "application/json",
+402 -32
tests/e2e/specs/views/search.view.test.js
··· 41 41 await mockServer.setup(page); 42 42 43 43 await login(page); 44 - await page.goto("/search?q=ali"); 44 + await page.goto("/search?q=ali&tab=profiles"); 45 45 46 46 const view = page.locator("#search-view"); 47 47 await expect(view.locator(".profile-list-item")).toHaveCount(2, { ··· 86 86 await expect(view).toContainText("Another search result"); 87 87 }); 88 88 89 - test("should show Profiles tab as active by default", async ({ page }) => { 89 + test("should show Top tab as active by default", async ({ page }) => { 90 90 const mockServer = new MockServer(); 91 - mockServer.addSearchProfiles([ 92 - createProfile({ 93 - did: "did:plc:profile1", 94 - handle: "alice.bsky.social", 95 - displayName: "Alice", 91 + mockServer.addSearchPosts([ 92 + createPost({ 93 + uri: "at://did:plc:author1/app.bsky.feed.post/post1", 94 + text: "A matching post", 95 + authorHandle: "author1.bsky.social", 96 + authorDisplayName: "Author One", 96 97 }), 97 98 ]); 98 99 await mockServer.setup(page); ··· 101 102 await page.goto("/search?q=alice"); 102 103 103 104 const view = page.locator("#search-view"); 105 + await expect(view.locator('[data-testid="tab-top"].active')).toBeVisible({ 106 + timeout: 10000, 107 + }); 104 108 await expect( 105 - view.locator('[data-testid="tab-profiles"].active'), 106 - ).toBeVisible({ timeout: 10000 }); 109 + view.locator(".search-post-results-top [data-post-uri]"), 110 + ).toHaveCount(1, { timeout: 10000 }); 107 111 }); 108 112 109 113 test("should show empty state when no profiles match", async ({ page }) => { ··· 111 115 await mockServer.setup(page); 112 116 113 117 await login(page); 114 - await page.goto("/search?q=nonexistentuser"); 118 + await page.goto("/search?q=nonexistentuser&tab=profiles"); 115 119 116 120 const view = page.locator("#search-view"); 117 121 const profilesPanel = view.locator( ··· 135 139 ).toBeVisible({ timeout: 10000 }); 136 140 }); 137 141 138 - test("should switch between Profiles, Top, Latest, and Feeds tabs", async ({ 142 + test("should switch between Top, Latest, People, and Feeds tabs", async ({ 139 143 page, 140 144 }) => { 141 145 const mockServer = new MockServer(); ··· 168 172 169 173 const view = page.locator("#search-view"); 170 174 171 - // Profiles tab is active by default 172 - await expect(view.locator(".profile-list-item")).toHaveCount(1, { 175 + // Top tab is active by default 176 + await expect(view.locator('[data-testid="tab-top"].active')).toBeVisible({ 173 177 timeout: 10000, 174 178 }); 175 - 176 - // Switch to Top tab 177 - await view.locator('[data-testid="tab-top"]').click(); 178 - await expect(view.locator('[data-testid="tab-top"].active')).toBeVisible(); 179 179 await expect( 180 180 view.locator(".search-post-results-top [data-post-uri]"), 181 181 ).toHaveCount(1, { timeout: 10000 }); ··· 190 190 view.locator(".search-post-results-latest [data-post-uri]"), 191 191 ).toHaveCount(1, { timeout: 10000 }); 192 192 193 + // Switch to People tab 194 + await view.locator('[data-testid="tab-profiles"]').click(); 195 + await expect( 196 + view.locator('[data-testid="tab-profiles"].active'), 197 + ).toBeVisible(); 198 + await expect(view.locator(".profile-list-item")).toHaveCount(1, { 199 + timeout: 10000, 200 + }); 201 + 193 202 // Switch to Feeds tab 194 203 await view.locator('[data-testid="tab-feeds"]').click(); 195 204 await expect( ··· 200 209 }); 201 210 await expect(view).toContainText("My Custom Feed"); 202 211 203 - // Switch back to Profiles tab 204 - await view.locator('[data-testid="tab-profiles"]').click(); 212 + // Switch back to Top tab 213 + await view.locator('[data-testid="tab-top"]').click(); 214 + await expect(view.locator('[data-testid="tab-top"].active')).toBeVisible(); 205 215 await expect( 206 - view.locator('[data-testid="tab-profiles"].active'), 207 - ).toBeVisible(); 208 - await expect(view.locator(".profile-list-item")).toHaveCount(1); 216 + view.locator(".search-post-results-top [data-post-uri]"), 217 + ).toHaveCount(1); 209 218 }); 210 219 211 220 test("should display clear button when search has text and clear on click", async ({ ··· 246 255 await mockServer.setup(page); 247 256 248 257 await login(page); 249 - await page.goto("/search?q=bob"); 258 + await page.goto("/search?q=bob&tab=profiles"); 250 259 251 260 const view = page.locator("#search-view"); 252 261 await expect(view.locator(".profile-list-item")).toHaveCount(1, { ··· 270 279 await mockServer.setup(page); 271 280 272 281 await login(page); 273 - await page.goto("/search?q=alice"); 282 + await page.goto("/search?q=alice&tab=profiles"); 274 283 275 284 const view = page.locator("#search-view"); 276 285 await expect(view.locator(".profile-list-item")).toHaveCount(1, { ··· 389 398 await expect(topPanel).toContainText("A top ranked post"); 390 399 }); 391 400 392 - test("should render tabs in order: Profiles, Top, Latest, Feeds", async ({ 401 + test("should render tabs in order: Top, Latest, People, Feeds", async ({ 393 402 page, 394 403 }) => { 395 404 const mockServer = new MockServer(); ··· 401 410 const view = page.locator("#search-view"); 402 411 const tabs = view.locator("tab-bar [data-testid^='tab-']"); 403 412 await expect(tabs).toHaveCount(4, { timeout: 10000 }); 404 - await expect(tabs.nth(0)).toHaveAttribute("data-testid", "tab-profiles"); 405 - await expect(tabs.nth(1)).toHaveAttribute("data-testid", "tab-top"); 406 - await expect(tabs.nth(2)).toHaveAttribute("data-testid", "tab-latest"); 413 + await expect(tabs.nth(0)).toHaveAttribute("data-testid", "tab-top"); 414 + await expect(tabs.nth(1)).toHaveAttribute("data-testid", "tab-latest"); 415 + await expect(tabs.nth(2)).toHaveAttribute("data-testid", "tab-profiles"); 407 416 await expect(tabs.nth(3)).toHaveAttribute("data-testid", "tab-feeds"); 408 417 }); 409 418 ··· 642 651 await mockServer.setup(page); 643 652 644 653 await login(page); 645 - await page.goto("/search?q=test"); 654 + await page.goto("/search?q=test&tab=profiles"); 646 655 647 656 const view = page.locator("#search-view"); 648 657 const followsBackRow = view ··· 697 706 await mockServer.setup(page); 698 707 699 708 await login(page); 700 - await page.goto("/search?q=target"); 709 + await page.goto("/search?q=target&tab=profiles"); 701 710 702 711 const view = page.locator("#search-view"); 703 712 const targetRow = view ··· 714 723 }); 715 724 }); 716 725 726 + test.describe("Typeahead", () => { 727 + test("should show typeahead results while typing without searching", async ({ 728 + page, 729 + }) => { 730 + const mockServer = new MockServer(); 731 + mockServer.addTypeaheadProfiles([ 732 + createProfile({ 733 + did: "did:plc:profile1", 734 + handle: "alice.bsky.social", 735 + displayName: "Alice", 736 + }), 737 + createProfile({ 738 + did: "did:plc:profile2", 739 + handle: "alicia.bsky.social", 740 + displayName: "Alicia", 741 + }), 742 + ]); 743 + await mockServer.setup(page); 744 + 745 + await login(page); 746 + await page.goto("/search"); 747 + 748 + const view = page.locator("#search-view"); 749 + await view.locator(".search-input").fill("ali"); 750 + 751 + await expect( 752 + view.locator('[data-testid="search-typeahead-search-row"]'), 753 + ).toBeVisible({ timeout: 10000 }); 754 + await expect( 755 + view.locator('[data-testid="search-typeahead-result"]'), 756 + ).toHaveCount(2, { timeout: 10000 }); 757 + await expect(view).toContainText("@alice.bsky.social"); 758 + 759 + // No full search fired and no query committed to the URL 760 + expect(mockServer.searchRequestCounts.profiles).toBe(0); 761 + await expect(page).not.toHaveURL(/[?&]q=/); 762 + await expect(view.locator("tab-bar")).toBeHidden(); 763 + }); 764 + 765 + test("should show a loading spinner until typeahead results arrive", async ({ 766 + page, 767 + }) => { 768 + const mockServer = new MockServer(); 769 + mockServer.addTypeaheadProfiles([ 770 + createProfile({ 771 + did: "did:plc:profile1", 772 + handle: "alice.bsky.social", 773 + displayName: "Alice", 774 + }), 775 + ]); 776 + mockServer.typeaheadDelayMs = 1000; 777 + await mockServer.setup(page); 778 + 779 + await login(page); 780 + await page.goto("/search"); 781 + 782 + const view = page.locator("#search-view"); 783 + await view.locator(".search-input").fill("ali"); 784 + 785 + await expect( 786 + view.locator(".search-typeahead-loading .loading-spinner"), 787 + ).toBeVisible({ timeout: 10000 }); 788 + 789 + await expect( 790 + view.locator('[data-testid="search-typeahead-result"]'), 791 + ).toHaveCount(1, { timeout: 10000 }); 792 + await expect(view.locator(".search-typeahead-loading")).not.toBeVisible(); 793 + }); 794 + 795 + test("should commit the search on Enter", async ({ page }) => { 796 + const mockServer = new MockServer(); 797 + mockServer.addTypeaheadProfiles([ 798 + createProfile({ 799 + did: "did:plc:profile1", 800 + handle: "alice.bsky.social", 801 + displayName: "Alice", 802 + }), 803 + ]); 804 + mockServer.addSearchPosts([ 805 + createPost({ 806 + uri: "at://did:plc:author1/app.bsky.feed.post/post1", 807 + text: "A post about ali", 808 + authorHandle: "author1.bsky.social", 809 + authorDisplayName: "Author One", 810 + }), 811 + ]); 812 + await mockServer.setup(page); 813 + 814 + await login(page); 815 + await page.goto("/search"); 816 + 817 + const view = page.locator("#search-view"); 818 + const input = view.locator(".search-input"); 819 + await input.fill("ali"); 820 + await expect( 821 + view.locator('[data-testid="search-typeahead-search-row"]'), 822 + ).toBeVisible({ timeout: 10000 }); 823 + await input.press("Enter"); 824 + 825 + await expect( 826 + view.locator(".search-post-results-top [data-post-uri]"), 827 + ).toHaveCount(1, { timeout: 10000 }); 828 + await expect(view.locator("tab-bar")).toBeVisible(); 829 + await expect(page).toHaveURL(/[?&]q=ali/); 830 + await expect( 831 + view.locator('[data-testid="search-typeahead-search-row"]'), 832 + ).toHaveCount(0); 833 + }); 834 + 835 + test("should commit the search when clicking the search row", async ({ 836 + page, 837 + }) => { 838 + const mockServer = new MockServer(); 839 + mockServer.addSearchPosts([ 840 + createPost({ 841 + uri: "at://did:plc:author1/app.bsky.feed.post/post1", 842 + text: "A post about ali", 843 + authorHandle: "author1.bsky.social", 844 + authorDisplayName: "Author One", 845 + }), 846 + ]); 847 + await mockServer.setup(page); 848 + 849 + await login(page); 850 + await page.goto("/search"); 851 + 852 + const view = page.locator("#search-view"); 853 + await view.locator(".search-input").fill("ali"); 854 + await view.locator('[data-testid="search-typeahead-search-row"]').click(); 855 + 856 + await expect( 857 + view.locator(".search-post-results-top [data-post-uri]"), 858 + ).toHaveCount(1, { timeout: 10000 }); 859 + await expect(page).toHaveURL(/[?&]q=ali/); 860 + }); 861 + 862 + test("should navigate to the profile when clicking a typeahead result", async ({ 863 + page, 864 + }) => { 865 + const mockServer = new MockServer(); 866 + mockServer.addTypeaheadProfiles([ 867 + createProfile({ 868 + did: "did:plc:profile1", 869 + handle: "alice.bsky.social", 870 + displayName: "Alice", 871 + }), 872 + ]); 873 + await mockServer.setup(page); 874 + 875 + await login(page); 876 + await page.goto("/search"); 877 + 878 + const view = page.locator("#search-view"); 879 + await view.locator(".search-input").fill("ali"); 880 + await expect( 881 + view.locator('[data-testid="search-typeahead-result"]'), 882 + ).toHaveCount(1, { timeout: 10000 }); 883 + 884 + await view.locator('[data-testid="search-typeahead-result"]').click(); 885 + 886 + await expect(page).toHaveURL(/\/profile\/alice\.bsky\.social/, { 887 + timeout: 10000, 888 + }); 889 + }); 890 + 891 + test("should return to typeahead mode when editing a committed search", async ({ 892 + page, 893 + }) => { 894 + const mockServer = new MockServer(); 895 + mockServer.addTypeaheadProfiles([ 896 + createProfile({ 897 + did: "did:plc:profile1", 898 + handle: "alicia.bsky.social", 899 + displayName: "Alicia", 900 + }), 901 + ]); 902 + mockServer.addSearchPosts([ 903 + createPost({ 904 + uri: "at://did:plc:author1/app.bsky.feed.post/post1", 905 + text: "A post about alicia", 906 + authorHandle: "author1.bsky.social", 907 + authorDisplayName: "Author One", 908 + }), 909 + ]); 910 + await mockServer.setup(page); 911 + 912 + await login(page); 913 + await page.goto("/search?q=alice"); 914 + 915 + const view = page.locator("#search-view"); 916 + await expect(view.locator("tab-bar")).toBeVisible({ timeout: 10000 }); 917 + 918 + const input = view.locator(".search-input"); 919 + await input.fill("alicia"); 920 + 921 + await expect( 922 + view.locator('[data-testid="search-typeahead-search-row"]'), 923 + ).toBeVisible({ timeout: 10000 }); 924 + await expect(view.locator("tab-bar")).toBeHidden(); 925 + // The committed query only changes on the next commit 926 + await expect(page).toHaveURL(/[?&]q=alice/); 927 + 928 + await input.press("Enter"); 929 + await expect(page).toHaveURL(/[?&]q=alicia/); 930 + await expect( 931 + view.locator(".search-post-results-top [data-post-uri]"), 932 + ).toHaveCount(1, { timeout: 10000 }); 933 + }); 934 + 935 + test("should show the placeholder when the input is cleared", async ({ 936 + page, 937 + }) => { 938 + const mockServer = new MockServer(); 939 + await mockServer.setup(page); 940 + 941 + await login(page); 942 + await page.goto("/search?q=alice"); 943 + 944 + const view = page.locator("#search-view"); 945 + const input = view.locator(".search-input"); 946 + await expect(input).toHaveValue("alice", { timeout: 10000 }); 947 + 948 + await input.fill(""); 949 + 950 + await expect(view.locator(".search-placeholder")).toBeVisible({ 951 + timeout: 10000, 952 + }); 953 + }); 954 + }); 955 + 956 + test.describe("Lazy tab loading", () => { 957 + test("should only load a tab's results when it is first activated", async ({ 958 + page, 959 + }) => { 960 + const mockServer = new MockServer(); 961 + mockServer.addSearchProfiles([ 962 + createProfile({ 963 + did: "did:plc:profile1", 964 + handle: "alice.bsky.social", 965 + displayName: "Alice", 966 + }), 967 + ]); 968 + mockServer.addSearchPosts([ 969 + createPost({ 970 + uri: "at://did:plc:author1/app.bsky.feed.post/post1", 971 + text: "A matching post", 972 + authorHandle: "author1.bsky.social", 973 + authorDisplayName: "Author One", 974 + }), 975 + ]); 976 + await mockServer.setup(page); 977 + 978 + await login(page); 979 + await page.goto("/search?q=test"); 980 + 981 + const view = page.locator("#search-view"); 982 + await expect( 983 + view.locator(".search-post-results-top [data-post-uri]"), 984 + ).toHaveCount(1, { timeout: 10000 }); 985 + 986 + // Only the active (Top) tab has loaded 987 + expect(mockServer.searchRequestCounts.top).toBe(1); 988 + expect(mockServer.searchRequestCounts.profiles).toBe(0); 989 + expect(mockServer.searchRequestCounts.latest).toBe(0); 990 + expect(mockServer.searchRequestCounts.feeds).toBe(0); 991 + 992 + await view.locator('[data-testid="tab-profiles"]').click(); 993 + await expect(view.locator(".profile-list-item")).toHaveCount(1, { 994 + timeout: 10000, 995 + }); 996 + expect(mockServer.searchRequestCounts.profiles).toBe(1); 997 + 998 + // Switching back and forth doesn't refetch 999 + await view.locator('[data-testid="tab-top"]').click(); 1000 + await expect( 1001 + view.locator(".search-post-results-top [data-post-uri]"), 1002 + ).toHaveCount(1, { timeout: 10000 }); 1003 + await view.locator('[data-testid="tab-profiles"]').click(); 1004 + await expect(view.locator(".profile-list-item")).toHaveCount(1, { 1005 + timeout: 10000, 1006 + }); 1007 + expect(mockServer.searchRequestCounts.profiles).toBe(1); 1008 + expect(mockServer.searchRequestCounts.top).toBe(1); 1009 + }); 1010 + }); 1011 + 1012 + test("should scroll to top when clicking the active tab", async ({ 1013 + page, 1014 + }) => { 1015 + const mockServer = new MockServer(); 1016 + const posts = []; 1017 + for (let i = 0; i < 30; i++) { 1018 + posts.push( 1019 + createPost({ 1020 + uri: `at://did:plc:author${i}/app.bsky.feed.post/post${i}`, 1021 + text: `Search result post ${i}`, 1022 + authorHandle: `author${i}.bsky.social`, 1023 + authorDisplayName: `Author ${i}`, 1024 + }), 1025 + ); 1026 + } 1027 + mockServer.addSearchPosts(posts); 1028 + await mockServer.setup(page); 1029 + 1030 + await login(page); 1031 + await page.goto("/search?q=result"); 1032 + 1033 + const view = page.locator("#search-view"); 1034 + await expect( 1035 + view.locator(".search-post-results-top [data-post-uri]"), 1036 + ).toHaveCount(30, { timeout: 10000 }); 1037 + 1038 + await page.evaluate(() => window.scrollTo(0, 1000)); 1039 + await expect 1040 + .poll(() => page.evaluate(() => window.scrollY)) 1041 + .toBeGreaterThan(0); 1042 + 1043 + await view.locator('[data-testid="tab-top"]').click(); 1044 + 1045 + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0); 1046 + }); 1047 + 717 1048 test.describe("Pagination", () => { 718 1049 test("should paginate profile results", async ({ page }) => { 719 1050 const mockServer = new MockServer(); ··· 731 1062 await mockServer.setup(page); 732 1063 733 1064 await login(page); 734 - await page.goto("/search?q=user"); 1065 + await page.goto("/search?q=user&tab=profiles"); 735 1066 736 1067 const view = page.locator("#search-view"); 737 1068 // All 30 profiles should load across multiple pages ··· 815 1146 await mockServer.setup(page); 816 1147 817 1148 await login(page); 818 - await page.goto("/search?q=user"); 1149 + await page.goto("/search?q=user&tab=profiles"); 819 1150 820 1151 const view = page.locator("#search-view"); 821 1152 await expect(view.locator(".profile-list-item")).toHaveCount(3, { ··· 864 1195 await expect(view.locator('[data-testid="follow-button"]')).toHaveCount( 865 1196 0, 866 1197 ); 1198 + }); 1199 + 1200 + test("should show typeahead and commit profile search when logged out", async ({ 1201 + page, 1202 + }) => { 1203 + const mockServer = new MockServer(); 1204 + mockServer.addTypeaheadProfiles([ 1205 + createProfile({ 1206 + did: "did:plc:profile1", 1207 + handle: "alice.bsky.social", 1208 + displayName: "Alice", 1209 + }), 1210 + ]); 1211 + mockServer.addSearchProfiles([ 1212 + createProfile({ 1213 + did: "did:plc:profile1", 1214 + handle: "alice.bsky.social", 1215 + displayName: "Alice", 1216 + }), 1217 + ]); 1218 + await mockServer.setup(page); 1219 + 1220 + await page.goto("/search"); 1221 + 1222 + const view = page.locator("#search-view"); 1223 + const input = view.locator(".search-input"); 1224 + await input.fill("ali"); 1225 + 1226 + await expect( 1227 + view.locator('[data-testid="search-typeahead-result"]'), 1228 + ).toHaveCount(1, { timeout: 10000 }); 1229 + 1230 + await input.press("Enter"); 1231 + 1232 + await expect(view.locator(".profile-list-item")).toHaveCount(1, { 1233 + timeout: 10000, 1234 + }); 1235 + await expect(view.locator("tab-bar")).toBeHidden(); 1236 + expect(mockServer.searchRequestCounts.top).toBe(0); 867 1237 }); 868 1238 869 1239 test("should hide the tab bar entirely when logged out", async ({
+7 -6
tests/unit/specs/components/new-chat-dialog.test.js
··· 64 64 }, 65 65 // Mirrors enableStatus: record every failure in the status store, 66 66 // clear it on success, swallow ApiErrors, reject with everything else. 67 - // The dialog's debounced search is fire-and-forget, so the rejection is 67 + // The dialog's search is fire-and-forget, so the rejection is 68 68 // pre-handled here — node:test fails the running test on any unhandled 69 69 // rejection, even when a process-level listener is attached. 70 70 loadChatRecipientSearch: (query, options) => { ··· 171 171 ); 172 172 input.value = value; 173 173 input.dispatchEvent(new window.InputEvent("input", { bubbles: true })); 174 - // One tick for the debounce, one for the re-render. 174 + // One tick for the search promise, one for the re-render. 175 175 await nextFrame(); 176 176 await nextFrame(); 177 177 } ··· 390 390 }); 391 391 392 392 describe("NewChatDialog - searching", () => { 393 - it("should call the debounced loader with the trimmed query", async () => { 393 + it("should call the loader with the trimmed query", async () => { 394 394 const { dataLayer, searchCalls } = createFakeDataLayer(); 395 395 const element = createDialog(dataLayer); 396 396 await typeQuery(element, " alice "); ··· 462 462 ); 463 463 }); 464 464 465 - it("should not fire a pending search after the query is cleared", async () => { 465 + it("should search immediately and end with a clearing call when the query is emptied", async () => { 466 466 const { dataLayer, searchCalls } = createFakeDataLayer(); 467 467 const element = createDialog(dataLayer); 468 468 const input = element.querySelector( ··· 474 474 input.dispatchEvent(new window.InputEvent("input", { bubbles: true })); 475 475 await nextFrame(); 476 476 await nextFrame(); 477 - assert.deepEqual(searchCalls.length, 1); 478 - assert.deepEqual(searchCalls[0].query, ""); 477 + assert.deepEqual(searchCalls.length, 2); 478 + assert.deepEqual(searchCalls[0].query, "al"); 479 + assert.deepEqual(searchCalls[1].query, ""); 479 480 }); 480 481 }); 481 482
+56
tests/unit/specs/dataLayer/requests.test.js
··· 1321 1321 }); 1322 1322 }); 1323 1323 1324 + describe("loadSearchTypeahead", () => { 1325 + it("should store the search results and hydrate profiles", async () => { 1326 + const dataStore = new DataStore(); 1327 + const mockApi = { 1328 + searchProfilesTypeahead: async () => ({ 1329 + actors: [{ did: "did:plc:a" }], 1330 + }), 1331 + }; 1332 + const requests = makeRequests(mockApi, dataStore); 1333 + 1334 + await requests.loadSearchTypeahead("alice"); 1335 + 1336 + const stored = dataStore.$searchTypeaheadResults.get(); 1337 + assert.deepEqual(stored.actors.length, 1); 1338 + assert.deepEqual(stored.actors[0].did, "did:plc:a"); 1339 + assert.deepEqual(dataStore.$profiles.get("did:plc:a"), { 1340 + did: "did:plc:a", 1341 + }); 1342 + }); 1343 + 1344 + it("should clear results when query is empty", async () => { 1345 + const dataStore = new DataStore(); 1346 + dataStore.$searchTypeaheadResults.set({ actors: [{ did: "x" }] }); 1347 + const mockApi = { 1348 + searchProfilesTypeahead: async () => ({ actors: [] }), 1349 + }; 1350 + const requests = makeRequests(mockApi, dataStore); 1351 + 1352 + await requests.loadSearchTypeahead(""); 1353 + 1354 + assert.deepEqual(dataStore.$searchTypeaheadResults.get(), null); 1355 + }); 1356 + 1357 + it("should discard in-flight responses after the query is cleared", async () => { 1358 + const dataStore = new DataStore(); 1359 + let resolveSearch; 1360 + const searchPromise = new Promise((resolve) => { 1361 + resolveSearch = resolve; 1362 + }); 1363 + const mockApi = { 1364 + searchProfilesTypeahead: async () => { 1365 + await searchPromise; 1366 + return { actors: [{ did: "stale" }] }; 1367 + }, 1368 + }; 1369 + const requests = makeRequests(mockApi, dataStore); 1370 + 1371 + const inFlight = requests.loadSearchTypeahead("query"); 1372 + await requests.loadSearchTypeahead(""); 1373 + resolveSearch(); 1374 + await inFlight; 1375 + 1376 + assert.deepEqual(dataStore.$searchTypeaheadResults.get(), null); 1377 + }); 1378 + }); 1379 + 1324 1380 describe("loadFeedSearch", () => { 1325 1381 it("should clear results when query is empty", async () => { 1326 1382 const dataStore = new DataStore();
-42
tests/unit/specs/utils.test.js
··· 24 24 enableLongPress, 25 25 enableDragToDismiss, 26 26 TimeoutError, 27 - debounce, 28 27 resetScrollOnBlur, 29 28 pinScrollPosition, 30 29 } from "/js/utils.js"; ··· 1204 1203 assert.deepEqual(closeCount, 0); 1205 1204 assert.deepEqual(el.style.transform, ""); 1206 1205 }); 1207 - }); 1208 - }); 1209 - 1210 - describe("debounce", () => { 1211 - let originalSetTimeout; 1212 - 1213 - beforeEach(() => { 1214 - originalSetTimeout = globalThis.setTimeout; 1215 - globalThis.setTimeout = (fn) => originalSetTimeout(fn, 0); 1216 - }); 1217 - 1218 - afterEach(() => { 1219 - globalThis.setTimeout = originalSetTimeout; 1220 - }); 1221 - 1222 - it("fires once with the latest arguments", async () => { 1223 - const calls = []; 1224 - const debounced = debounce((value) => calls.push(value)); 1225 - debounced("first"); 1226 - debounced("second"); 1227 - await wait(10); 1228 - assert.deepEqual(calls, ["second"]); 1229 - }); 1230 - 1231 - it("does not fire a pending invocation after cancel", async () => { 1232 - const calls = []; 1233 - const debounced = debounce((value) => calls.push(value)); 1234 - debounced("pending"); 1235 - debounced.cancel(); 1236 - await wait(10); 1237 - assert.deepEqual(calls, []); 1238 - }); 1239 - 1240 - it("fires again when invoked after cancel", async () => { 1241 - const calls = []; 1242 - const debounced = debounce((value) => calls.push(value)); 1243 - debounced("cancelled"); 1244 - debounced.cancel(); 1245 - debounced("kept"); 1246 - await wait(10); 1247 - assert.deepEqual(calls, ["kept"]); 1248 1206 }); 1249 1207 }); 1250 1208