[READ-ONLY] 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.

Add latest posts search tab

Grace Kind (Jul 18, 2026, 5:35 PM -0500) d4f68da3 0a06afeb

+373 -103
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.18.9", 3 + "version": "0.18.10", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+4 -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.$postSearchResults = new Signal.State(null); 13 12 this.$feedSearchResults = new Signal.State(null); 14 13 this.$showLessInteractions = new Signal.State([]); 15 14 this.$showMoreInteractions = new Signal.State([]); ··· 24 23 this.$mutedProfiles = new Signal.State(null); 25 24 this.$latestProfileSearchRequestTime = new Signal.State(null); 26 25 this.$latestChatRecipientSearchRequestTime = new Signal.State(null); 27 - this.$latestPostSearchRequestTime = new Signal.State(null); 28 26 this.$latestFeedSearchRequestTime = new Signal.State(null); 27 + this.$postSearchResultsTop = new Signal.State(null); 28 + this.$postSearchResultsLatest = new Signal.State(null); 29 + this.$latestPostSearchRequestTimeTop = new Signal.State(null); 30 + this.$latestPostSearchRequestTimeLatest = new Signal.State(null); 29 31 // Keyed signals 30 32 this.$feeds = new SignalMap(); 31 33 this.$posts = new SignalMap();
+23 -13
src/js/dataLayer/derived.js
··· 289 289 this.$feedSearchCursor = new Signal.Computed( 290 290 () => this.dataStore.$feedSearchResults.get()?.cursor ?? null, 291 291 ); 292 - this.$postSearchResults = new Signal.Computed(() => { 293 - const data = this.dataStore.$postSearchResults.get(); 294 - if (!data) return null; 295 - const hydratedSearchResults = []; 296 - for (const result of data.posts) { 297 - const post = this.$hydratedPosts.get(result.uri); 298 - if (!post) continue; 299 - hydratedSearchResults.push(this.attachParentAuthor(post)); 300 - } 301 - return hydratedSearchResults; 302 - }); 303 - this.$postSearchCursor = new Signal.Computed( 304 - () => this.dataStore.$postSearchResults.get()?.cursor ?? null, 292 + this.$postSearchResultsTop = new Signal.Computed(() => 293 + this.hydratePostSearchResults(this.dataStore.$postSearchResultsTop), 294 + ); 295 + this.$postSearchResultsLatest = new Signal.Computed(() => 296 + this.hydratePostSearchResults(this.dataStore.$postSearchResultsLatest), 297 + ); 298 + this.$postSearchCursorTop = new Signal.Computed( 299 + () => this.dataStore.$postSearchResultsTop.get()?.cursor ?? null, 300 + ); 301 + this.$postSearchCursorLatest = new Signal.Computed( 302 + () => this.dataStore.$postSearchResultsLatest.get()?.cursor ?? null, 305 303 ); 306 304 this.$hydratedPostQuotes = new ComputedMap((postUri) => { 307 305 const quotes = this.dataStore.$postQuotes.get(postUri); ··· 725 723 }, 726 724 }, 727 725 }; 726 + } 727 + 728 + hydratePostSearchResults($storedResults) { 729 + const data = $storedResults.get(); 730 + if (!data) return null; 731 + const hydratedSearchResults = []; 732 + for (const result of data.posts) { 733 + const post = this.$hydratedPosts.get(result.uri); 734 + if (!post) continue; 735 + hydratedSearchResults.push(this.attachParentAuthor(post)); 736 + } 737 + return hydratedSearchResults; 728 738 } 729 739 730 740 hydrateNotification(notification) {
+39 -12
src/js/dataLayer/requests.js
··· 197 197 ); 198 198 this.enableStatus(this.loadChatRecipientSearch, "loadChatRecipientSearch"); 199 199 this.enableStatus( 200 - this.loadPostSearch, 201 - (query, { sort = "top" } = {}) => `loadPostSearch-${query}-${sort}`, 200 + this.loadPostSearchTop, 201 + (query) => "loadPostSearchTop-" + query, 202 + ); 203 + this.enableStatus( 204 + this.loadPostSearchLatest, 205 + (query) => "loadPostSearchLatest-" + query, 202 206 ); 203 207 this.enableStatus( 204 208 this.loadFeedSearch, ··· 639 643 this.dataStore.$chatRecipientSearchResults.set(searchData); 640 644 } 641 645 642 - async loadPostSearch(query, { limit = 25, sort = "top", cursor = "" } = {}) { 646 + async loadPostSearchTop(query, { limit = 25, cursor = "" } = {}) { 647 + await this._loadPostSearch(query, { 648 + limit, 649 + cursor, 650 + sort: "top", 651 + $results: this.dataStore.$postSearchResultsTop, 652 + $latestRequestTime: this.dataStore.$latestPostSearchRequestTimeTop, 653 + }); 654 + } 655 + 656 + async loadPostSearchLatest(query, { limit = 25, cursor = "" } = {}) { 657 + await this._loadPostSearch(query, { 658 + limit, 659 + cursor, 660 + sort: "latest", 661 + $results: this.dataStore.$postSearchResultsLatest, 662 + $latestRequestTime: this.dataStore.$latestPostSearchRequestTimeLatest, 663 + }); 664 + } 665 + 666 + async _loadPostSearch( 667 + query, 668 + { limit, cursor, sort, $results, $latestRequestTime }, 669 + ) { 643 670 if (!query) { 644 671 // Invalidate in-flight searches so they can't repopulate cleared results 645 - this.dataStore.$latestPostSearchRequestTime.set(null); 646 - this.dataStore.$postSearchResults.set(null); 672 + $latestRequestTime.set(null); 673 + $results.set(null); 647 674 return; 648 675 } 649 676 if (!cursor) { 650 - this.dataStore.$postSearchResults.set(null); 677 + $results.set(null); 651 678 } 652 679 const labelers = this.requireLabelers(); 653 680 const requestTime = Date.now(); 654 - this.dataStore.$latestPostSearchRequestTime.set(requestTime); 681 + $latestRequestTime.set(requestTime); 655 682 const searchData = await this.api.searchPosts(query, { 656 683 limit, 657 684 sort, 658 685 cursor, 659 686 labelers, 660 687 }); 661 - if (requestTime !== this.dataStore.$latestPostSearchRequestTime.get()) { 688 + if (requestTime !== $latestRequestTime.get()) { 662 689 return; 663 690 } 664 691 const searchResults = searchData.posts || []; ··· 676 703 this.dataStore.setPosts([...searchResults, ...parentPosts]); 677 704 } 678 705 // Re-check relevance after loading dependencies 679 - if (requestTime !== this.dataStore.$latestPostSearchRequestTime.get()) { 706 + if (requestTime !== $latestRequestTime.get()) { 680 707 return; 681 708 } 682 - const existingResults = this.dataStore.$postSearchResults.get(); 709 + const existingResults = $results.get(); 683 710 if (existingResults && cursor) { 684 - this.dataStore.$postSearchResults.set({ 711 + $results.set({ 685 712 posts: [...existingResults.posts, ...searchResults], 686 713 cursor: searchData.cursor, 687 714 }); 688 715 } else { 689 - this.dataStore.$postSearchResults.set({ 716 + $results.set({ 690 717 posts: searchResults, 691 718 cursor: searchData.cursor, 692 719 });
+1 -1
src/js/navigation.js
··· 73 73 const searchString = `from:@${profile.handle} `; 74 74 const query = new URLSearchParams(); 75 75 query.set("q", searchString); 76 - query.set("tab", "posts"); 76 + query.set("tab", "top"); 77 77 return `/search?${query.toString()}`; 78 78 } 79 79
+70 -20
src/js/views/search.view.js
··· 48 48 49 49 if (isAuthenticated) { 50 50 requests.push( 51 - dataLayer.requests.loadPostSearch(normalizedQuery, { 51 + dataLayer.requests.loadPostSearchTop(normalizedQuery, { 52 + limit: 25, 53 + }), 54 + ); 55 + requests.push( 56 + dataLayer.requests.loadPostSearchLatest(normalizedQuery, { 52 57 limit: 25, 53 58 }), 54 59 ); ··· 78 83 ); 79 84 } 80 85 81 - async function loadMorePosts() { 82 - const cursor = dataLayer.derived.$postSearchCursor.get(); 86 + async function loadMoreTopPosts() { 87 + const cursor = dataLayer.derived.$postSearchCursorTop.get(); 88 + if (!cursor) return; 89 + await dataLayer.requests.loadPostSearchTop( 90 + state.$searchQuery.get().trim(), 91 + { 92 + limit: 25, 93 + cursor, 94 + }, 95 + ); 96 + } 97 + 98 + async function loadMoreLatestPosts() { 99 + const cursor = dataLayer.derived.$postSearchCursorLatest.get(); 83 100 if (!cursor) return; 84 - await dataLayer.requests.loadPostSearch(state.$searchQuery.get().trim(), { 85 - limit: 25, 86 - cursor, 87 - }); 101 + await dataLayer.requests.loadPostSearchLatest( 102 + state.$searchQuery.get().trim(), 103 + { 104 + limit: 25, 105 + cursor, 106 + }, 107 + ); 88 108 } 89 109 90 110 async function loadMoreFeeds() { ··· 127 147 status, 128 148 postSearchResults, 129 149 postSearchHasMore, 150 + onLoadMore, 130 151 currentUser, 131 152 }) { 132 153 if (!postSearchResults && status.loading) { ··· 150 171 lookahead="2500px" 151 172 @load-more=${async (event) => { 152 173 if (postSearchHasMore) { 153 - await loadMorePosts(); 174 + await onLoadMore(); 154 175 event.detail.resume(); 155 176 } 156 177 }} ··· 320 341 const activeTab = state.$activeTab.get(); 321 342 const normalizedQuery = searchQuery.trim(); 322 343 const showResults = normalizedQuery.length > 0; 323 - const postStatus = dataLayer.requests.statusStore.$statuses.get( 324 - `loadPostSearch-${normalizedQuery}-top`, 344 + const topPostStatus = dataLayer.requests.statusStore.$statuses.get( 345 + "loadPostSearchTop-" + normalizedQuery, 325 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(); 326 358 const profileStatus = dataLayer.requests.statusStore.$statuses.get( 327 359 "loadProfileSearch-" + normalizedQuery, 328 360 ); 329 361 const feedStatus = dataLayer.requests.statusStore.$statuses.get( 330 362 "loadFeedSearch-" + normalizedQuery, 331 363 ); 332 - const postSearchResults = dataLayer.derived.$postSearchResults.get(); 333 364 const profileSearchResults = 334 365 dataLayer.derived.$profileSearchResults.get(); 335 366 const feedSearchResults = dataLayer.derived.$feedSearchResults.get(); 336 - const postSearchHasMore = !!dataLayer.derived.$postSearchCursor.get(); 337 367 const profileSearchHasMore = 338 368 !!dataLayer.derived.$profileSearchCursor.get(); 339 369 const feedSearchHasMore = !!dataLayer.derived.$feedSearchCursor.get(); ··· 375 405 <tab-bar 376 406 .tabs=${[ 377 407 { value: "profiles", label: "Profiles" }, 378 - 379 - { value: "posts", label: "Posts" }, 408 + { value: "top", label: "Top" }, 409 + { value: "latest", label: "Latest" }, 380 410 { value: "feeds", label: "Feeds" }, 381 411 ]} 382 412 active-tab=${activeTab} ··· 395 425 <div class="search-tab-panels"> 396 426 <div 397 427 class="search-tab-panel" 398 - ?hidden=${activeTab !== "posts"} 428 + ?hidden=${activeTab !== "top"} 429 + > 430 + <div 431 + class="search-results-panel search-post-results search-post-results-top" 432 + > 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"} 399 445 > 400 - <div class="search-results-panel search-post-results"> 446 + <div 447 + class="search-results-panel search-post-results search-post-results-latest" 448 + > 401 449 ${postSearchResultsTemplate({ 402 - status: postStatus, 403 - postSearchResults, 404 - postSearchHasMore, 450 + status: latestPostStatus, 451 + postSearchResults: latestPostSearchResults, 452 + postSearchHasMore: latestPostSearchHasMore, 453 + onLoadMore: loadMoreLatestPosts, 405 454 currentUser, 406 455 })} 407 456 </div> ··· 458 507 state.$searchQuery.set(query.get("q")); 459 508 } 460 509 if (query.get("tab")) { 461 - state.$activeTab.set(query.get("tab")); 510 + const tab = query.get("tab"); 511 + state.$activeTab.set(tab === "posts" ? "top" : tab); 462 512 } 463 513 if (state.$searchQuery.get()) { 464 514 loadSearchResults();
+13 -6
tests/e2e/mockServer.js
··· 68 68 this.actorLists = new Map(); 69 69 this.searchFeedGenerators = []; 70 70 this.searchPosts = []; 71 + this.searchPostsBySort = { top: [], latest: [] }; 71 72 this.searchProfiles = []; 72 73 this.timelinePosts = []; 73 74 this.timelineDelayMs = 0; ··· 176 177 this.notificationsDelayMs = delayMs; 177 178 } 178 179 179 - addSearchPosts(posts) { 180 + addSearchPosts(posts, { sort } = {}) { 180 181 this.searchPosts.push(...posts); 182 + if (sort) { 183 + this.searchPostsBySort[sort].push(...posts); 184 + } else { 185 + this.searchPostsBySort.top.push(...posts); 186 + this.searchPostsBySort.latest.push(...posts); 187 + } 181 188 } 182 189 183 190 addSearchProfiles(profiles) { ··· 1409 1416 const cursor = url.searchParams.get("cursor") || ""; 1410 1417 const limit = parseInt(url.searchParams.get("limit") || "0", 10); 1411 1418 const offset = cursor ? parseInt(cursor, 10) : 0; 1419 + const sort = url.searchParams.get("sort") || "top"; 1420 + const sortedPosts = this.searchPostsBySort[sort] ?? this.searchPosts; 1412 1421 1413 1422 let posts, nextCursor; 1414 1423 if (limit) { 1415 - posts = this.searchPosts.slice(offset, offset + limit); 1424 + posts = sortedPosts.slice(offset, offset + limit); 1416 1425 nextCursor = 1417 - offset + limit < this.searchPosts.length 1418 - ? String(offset + limit) 1419 - : ""; 1426 + offset + limit < sortedPosts.length ? String(offset + limit) : ""; 1420 1427 } else { 1421 - posts = this.searchPosts; 1428 + posts = sortedPosts; 1422 1429 nextCursor = ""; 1423 1430 } 1424 1431
+2 -2
tests/e2e/specs/views/profile.view.test.js
··· 1031 1031 .click(); 1032 1032 1033 1033 await expect(page).toHaveURL( 1034 - /\/search\?q=from%3A%40otheruser\.bsky\.social\+&tab=posts/, 1034 + /\/search\?q=from%3A%40otheruser\.bsky\.social\+&tab=top/, 1035 1035 { timeout: 10000 }, 1036 1036 ); 1037 1037 }); ··· 1066 1066 .click(); 1067 1067 1068 1068 await expect(page).toHaveURL( 1069 - /\/search\?q=from%3A%40testuser\.bsky\.social\+&tab=posts/, 1069 + /\/search\?q=from%3A%40testuser\.bsky\.social\+&tab=top/, 1070 1070 { timeout: 10000 }, 1071 1071 ); 1072 1072 });
+137 -28
tests/e2e/specs/views/search.view.test.js
··· 53 53 await expect(view).toContainText("@alicia.bsky.social"); 54 54 }); 55 55 56 - test("should display post search results when switching to Posts tab", async ({ 56 + test("should display post search results when switching to Top tab", async ({ 57 57 page, 58 58 }) => { 59 59 const mockServer = new MockServer(); ··· 76 76 await page.goto("/search?q=hello"); 77 77 78 78 const view = page.locator("#search-view"); 79 - // Click the Posts tab 80 - await view.locator('[data-testid="tab-posts"]').click(); 79 + // Click the Top tab 80 + await view.locator('[data-testid="tab-top"]').click(); 81 81 82 - await expect(view.locator("[data-post-uri]")).toHaveCount(2, { 83 - timeout: 10000, 84 - }); 82 + await expect( 83 + view.locator(".search-post-results-top [data-post-uri]"), 84 + ).toHaveCount(2, { timeout: 10000 }); 85 85 await expect(view).toContainText("Hello world from search"); 86 86 await expect(view).toContainText("Another search result"); 87 87 }); ··· 127 127 await mockServer.setup(page); 128 128 129 129 await login(page); 130 - await page.goto("/search?q=nonexistentpost&tab=posts"); 130 + await page.goto("/search?q=nonexistentpost&tab=top"); 131 131 132 132 const view = page.locator("#search-view"); 133 133 await expect( 134 - view.locator('.search-post-results [data-testid="empty-state"]'), 134 + view.locator('.search-post-results-top [data-testid="empty-state"]'), 135 135 ).toBeVisible({ timeout: 10000 }); 136 136 }); 137 137 138 - test("should switch between Profiles, Posts, and Feeds tabs", async ({ 138 + test("should switch between Profiles, Top, Latest, and Feeds tabs", async ({ 139 139 page, 140 140 }) => { 141 141 const mockServer = new MockServer(); ··· 173 173 timeout: 10000, 174 174 }); 175 175 176 - // Switch to Posts tab 177 - await view.locator('[data-testid="tab-posts"]').click(); 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(); 178 179 await expect( 179 - view.locator('[data-testid="tab-posts"].active'), 180 - ).toBeVisible(); 181 - await expect(view.locator("[data-post-uri]")).toHaveCount(1, { 182 - timeout: 10000, 183 - }); 180 + view.locator(".search-post-results-top [data-post-uri]"), 181 + ).toHaveCount(1, { timeout: 10000 }); 184 182 await expect(view).toContainText("A matching post"); 183 + 184 + // Switch to Latest tab 185 + await view.locator('[data-testid="tab-latest"]').click(); 186 + await expect( 187 + view.locator('[data-testid="tab-latest"].active'), 188 + ).toBeVisible(); 189 + await expect( 190 + view.locator(".search-post-results-latest [data-post-uri]"), 191 + ).toHaveCount(1, { timeout: 10000 }); 185 192 186 193 // Switch to Feeds tab 187 194 await view.locator('[data-testid="tab-feeds"]').click(); ··· 290 297 await mockServer.setup(page); 291 298 292 299 await login(page); 300 + await page.goto("/search?q=test&tab=top"); 301 + 302 + const view = page.locator("#search-view"); 303 + await expect(view.locator('[data-testid="tab-top"].active')).toBeVisible({ 304 + timeout: 10000, 305 + }); 306 + await expect( 307 + view.locator(".search-post-results-top [data-post-uri]"), 308 + ).toHaveCount(1, { timeout: 10000 }); 309 + await expect(view).toContainText("Post from tab param"); 310 + }); 311 + 312 + test("should map legacy tab=posts query parameter to the Top tab", async ({ 313 + page, 314 + }) => { 315 + const mockServer = new MockServer(); 316 + mockServer.addSearchPosts([ 317 + createPost({ 318 + uri: "at://did:plc:author1/app.bsky.feed.post/post1", 319 + text: "Post from legacy tab param", 320 + authorHandle: "author1.bsky.social", 321 + authorDisplayName: "Author One", 322 + }), 323 + ]); 324 + await mockServer.setup(page); 325 + 326 + await login(page); 293 327 await page.goto("/search?q=test&tab=posts"); 294 328 295 329 const view = page.locator("#search-view"); 296 - await expect(view.locator('[data-testid="tab-posts"].active')).toBeVisible({ 330 + await expect(view.locator('[data-testid="tab-top"].active')).toBeVisible({ 331 + timeout: 10000, 332 + }); 333 + await expect( 334 + view.locator(".search-post-results-top [data-post-uri]"), 335 + ).toHaveCount(1, { timeout: 10000 }); 336 + await expect(view).toContainText("Post from legacy tab param"); 337 + }); 338 + 339 + test("should show separate results for Top and Latest tabs", async ({ 340 + page, 341 + }) => { 342 + const mockServer = new MockServer(); 343 + mockServer.addSearchPosts( 344 + [ 345 + createPost({ 346 + uri: "at://did:plc:author1/app.bsky.feed.post/toppost", 347 + text: "A top ranked post", 348 + authorHandle: "author1.bsky.social", 349 + authorDisplayName: "Author One", 350 + }), 351 + ], 352 + { sort: "top" }, 353 + ); 354 + mockServer.addSearchPosts( 355 + [ 356 + createPost({ 357 + uri: "at://did:plc:author2/app.bsky.feed.post/latestpost", 358 + text: "A very recent post", 359 + authorHandle: "author2.bsky.social", 360 + authorDisplayName: "Author Two", 361 + }), 362 + ], 363 + { sort: "latest" }, 364 + ); 365 + await mockServer.setup(page); 366 + 367 + await login(page); 368 + await page.goto("/search?q=post&tab=top"); 369 + 370 + const view = page.locator("#search-view"); 371 + const topPanel = view.locator(".search-post-results-top"); 372 + const latestPanel = view.locator(".search-post-results-latest"); 373 + 374 + await expect(topPanel.locator("[data-post-uri]")).toHaveCount(1, { 297 375 timeout: 10000, 298 376 }); 299 - await expect(view.locator("[data-post-uri]")).toHaveCount(1, { 377 + await expect(topPanel).toContainText("A top ranked post"); 378 + 379 + await view.locator('[data-testid="tab-latest"]').click(); 380 + await expect(latestPanel.locator("[data-post-uri]")).toHaveCount(1, { 300 381 timeout: 10000, 301 382 }); 302 - await expect(view).toContainText("Post from tab param"); 383 + await expect(latestPanel).toContainText("A very recent post"); 384 + await expect(latestPanel).not.toContainText("A top ranked post"); 385 + 386 + // Returning to Top keeps its cached results 387 + await view.locator('[data-testid="tab-top"]').click(); 388 + await expect(topPanel.locator("[data-post-uri]")).toHaveCount(1); 389 + await expect(topPanel).toContainText("A top ranked post"); 390 + }); 391 + 392 + test("should render tabs in order: Profiles, Top, Latest, Feeds", async ({ 393 + page, 394 + }) => { 395 + const mockServer = new MockServer(); 396 + await mockServer.setup(page); 397 + 398 + await login(page); 399 + await page.goto("/search?q=test"); 400 + 401 + const view = page.locator("#search-view"); 402 + const tabs = view.locator("tab-bar [data-testid^='tab-']"); 403 + 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"); 407 + await expect(tabs.nth(3)).toHaveAttribute("data-testid", "tab-feeds"); 303 408 }); 304 409 305 410 test("should navigate to post thread view when clicking a post", async ({ ··· 316 421 await mockServer.setup(page); 317 422 318 423 await login(page); 319 - await page.goto("/search?q=click&tab=posts"); 424 + await page.goto("/search?q=click&tab=top"); 320 425 321 426 const view = page.locator("#search-view"); 322 - await expect(view.locator("[data-post-uri]")).toHaveCount(1, { 427 + const topPanel = view.locator(".search-post-results-top"); 428 + await expect(topPanel.locator("[data-post-uri]")).toHaveCount(1, { 323 429 timeout: 10000, 324 430 }); 325 431 326 - await view.locator("[data-post-uri]").click(); 432 + await topPanel.locator("[data-post-uri]").click(); 327 433 328 434 const threadView = page.locator("#post-detail-view"); 329 435 await expect(threadView).toBeVisible({ timeout: 10000 }); ··· 653 759 await mockServer.setup(page); 654 760 655 761 await login(page); 656 - await page.goto("/search?q=result&tab=posts"); 762 + await page.goto("/search?q=result&tab=top"); 657 763 658 764 const view = page.locator("#search-view"); 659 765 // All 30 posts should load across multiple pages 660 - await expect(view.locator("[data-post-uri]")).toHaveCount(30, { 661 - timeout: 10000, 662 - }); 766 + await expect( 767 + view.locator(".search-post-results-top [data-post-uri]"), 768 + ).toHaveCount(30, { timeout: 10000 }); 663 769 await expect(view).toContainText("Search result post 0"); 664 770 await expect(view).toContainText("Search result post 29"); 665 771 }); ··· 747 853 await expect(view).toContainText("Alice"); 748 854 await expect(view).toContainText("Alicia"); 749 855 750 - // Posts and Feeds tabs should be hidden for logged-out users 751 - await expect(view.locator('[data-testid="tab-posts"]')).not.toBeVisible(); 856 + // Post and Feeds tabs should be hidden for logged-out users 857 + await expect(view.locator('[data-testid="tab-top"]')).not.toBeVisible(); 858 + await expect( 859 + view.locator('[data-testid="tab-latest"]'), 860 + ).not.toBeVisible(); 752 861 await expect(view.locator('[data-testid="tab-feeds"]')).not.toBeVisible(); 753 862 754 863 // Follow buttons should be hidden for logged-out users
+83 -18
tests/unit/specs/dataLayer/requests.test.js
··· 970 970 }); 971 971 }); 972 972 973 - describe("loadPostSearch", () => { 974 - it("should clear results when query is empty", async () => { 973 + describe("loadPostSearchTop / loadPostSearchLatest", () => { 974 + it("should clear results for both sorts when query is empty", async () => { 975 975 const dataStore = new DataStore(); 976 - dataStore.$postSearchResults.set({ posts: [{ uri: "p1" }], cursor: "c1" }); 976 + dataStore.$postSearchResultsTop.set({ 977 + posts: [{ uri: "p1" }], 978 + cursor: "c1", 979 + }); 980 + dataStore.$postSearchResultsLatest.set({ 981 + posts: [{ uri: "p2" }], 982 + cursor: "c2", 983 + }); 977 984 const mockApi = { searchPosts: async () => ({ posts: [], cursor: null }) }; 978 985 const requests = makeRequests(mockApi, dataStore); 979 986 980 - await requests.loadPostSearch(""); 987 + await requests.loadPostSearchTop(""); 988 + await requests.loadPostSearchLatest(""); 981 989 982 - assert.deepEqual(dataStore.$postSearchResults.get(), null); 990 + assert.deepEqual(dataStore.$postSearchResultsTop.get(), null); 991 + assert.deepEqual(dataStore.$postSearchResultsLatest.get(), null); 983 992 }); 984 993 985 994 it("should store results from a fresh search", async () => { ··· 992 1001 const dataStore = new DataStore(); 993 1002 const requests = makeRequests(mockApi, dataStore); 994 1003 995 - await requests.loadPostSearch("hello"); 1004 + await requests.loadPostSearchTop("hello"); 996 1005 997 - const stored = dataStore.$postSearchResults.get(); 1006 + const stored = dataStore.$postSearchResultsTop.get(); 998 1007 assert.deepEqual(stored.posts.length, 1); 999 1008 assert.deepEqual(stored.cursor, "next"); 1000 1009 }); 1001 1010 1011 + it("should store each sort's results independently", async () => { 1012 + const mockApi = { 1013 + searchPosts: async (query, { sort }) => ({ 1014 + posts: [{ uri: `post-${sort}`, record: {} }], 1015 + cursor: null, 1016 + }), 1017 + }; 1018 + const dataStore = new DataStore(); 1019 + const requests = makeRequests(mockApi, dataStore); 1020 + 1021 + await requests.loadPostSearchTop("hello"); 1022 + await requests.loadPostSearchLatest("hello"); 1023 + 1024 + assert.deepEqual( 1025 + dataStore.$postSearchResultsTop.get().posts[0].uri, 1026 + "post-top", 1027 + ); 1028 + assert.deepEqual( 1029 + dataStore.$postSearchResultsLatest.get().posts[0].uri, 1030 + "post-latest", 1031 + ); 1032 + }); 1033 + 1034 + it("should not discard an in-flight sort when the other sort loads", async () => { 1035 + const dataStore = new DataStore(); 1036 + let resolveTop; 1037 + const topPromise = new Promise((resolve) => { 1038 + resolveTop = resolve; 1039 + }); 1040 + const mockApi = { 1041 + searchPosts: async (query, { sort }) => { 1042 + if (sort === "top") { 1043 + await topPromise; 1044 + return { posts: [{ uri: "top-post", record: {} }], cursor: "tc" }; 1045 + } 1046 + return { posts: [{ uri: "latest-post", record: {} }], cursor: "lc" }; 1047 + }, 1048 + }; 1049 + const requests = makeRequests(mockApi, dataStore); 1050 + 1051 + const topCall = requests.loadPostSearchTop("query"); 1052 + await new Promise((resolve) => setTimeout(resolve, 5)); 1053 + await requests.loadPostSearchLatest("query"); 1054 + resolveTop(); 1055 + await topCall; 1056 + 1057 + assert.deepEqual( 1058 + dataStore.$postSearchResultsTop.get().posts[0].uri, 1059 + "top-post", 1060 + ); 1061 + assert.deepEqual( 1062 + dataStore.$postSearchResultsLatest.get().posts[0].uri, 1063 + "latest-post", 1064 + ); 1065 + }); 1066 + 1002 1067 it("should discard stale responses based on requestTime guard", async () => { 1003 1068 const dataStore = new DataStore(); 1004 1069 let resolveFirst; ··· 1018 1083 }; 1019 1084 const requests = makeRequests(mockApi, dataStore); 1020 1085 1021 - const firstCall = requests.loadPostSearch("query"); 1086 + const firstCall = requests.loadPostSearchTop("query"); 1022 1087 await new Promise((resolve) => setTimeout(resolve, 5)); 1023 - await requests.loadPostSearch("query"); 1088 + await requests.loadPostSearchTop("query"); 1024 1089 resolveFirst(); 1025 1090 await firstCall; 1026 1091 1027 - const stored = dataStore.$postSearchResults.get(); 1092 + const stored = dataStore.$postSearchResultsTop.get(); 1028 1093 assert.deepEqual(stored.posts[0].uri, "fresh"); 1029 1094 assert.deepEqual(stored.cursor, "fresh"); 1030 1095 }); ··· 1051 1116 }; 1052 1117 const requests = makeRequests(mockApi, dataStore); 1053 1118 1054 - await requests.loadPostSearch("query"); 1119 + await requests.loadPostSearchTop("query"); 1055 1120 1056 1121 // A load-more passes the requestTime guard, then stalls loading 1057 1122 // dependencies while a re-search and a fresh load-more complete ··· 1059 1124 getPostsGate = new Promise((resolve) => { 1060 1125 releaseGate = resolve; 1061 1126 }); 1062 - const staleLoadMore = requests.loadPostSearch("query", { cursor: "c1" }); 1127 + const staleLoadMore = requests.loadPostSearchTop("query", { cursor: "c1" }); 1063 1128 await new Promise((resolve) => setTimeout(resolve, 5)); 1064 - await requests.loadPostSearch("query"); 1065 - await requests.loadPostSearch("query", { cursor: "c1" }); 1129 + await requests.loadPostSearchTop("query"); 1130 + await requests.loadPostSearchTop("query", { cursor: "c1" }); 1066 1131 releaseGate(); 1067 1132 await staleLoadMore; 1068 1133 1069 - const stored = dataStore.$postSearchResults.get(); 1134 + const stored = dataStore.$postSearchResultsTop.get(); 1070 1135 assert.deepEqual( 1071 1136 stored.posts.map((post) => post.uri), 1072 1137 ["p1", "p2"], ··· 1075 1140 1076 1141 it("should append when cursor is provided and existing results present", async () => { 1077 1142 const dataStore = new DataStore(); 1078 - dataStore.$postSearchResults.set({ 1143 + dataStore.$postSearchResultsTop.set({ 1079 1144 posts: [{ uri: "p1", record: {} }], 1080 1145 cursor: "c1", 1081 1146 }); ··· 1087 1152 }; 1088 1153 const requests = makeRequests(mockApi, dataStore); 1089 1154 1090 - await requests.loadPostSearch("hello", { cursor: "c1" }); 1155 + await requests.loadPostSearchTop("hello", { cursor: "c1" }); 1091 1156 1092 - const stored = dataStore.$postSearchResults.get(); 1157 + const stored = dataStore.$postSearchResultsTop.get(); 1093 1158 assert.deepEqual(stored.posts.length, 2); 1094 1159 assert.deepEqual(stored.posts[1].uri, "p2"); 1095 1160 assert.deepEqual(stored.cursor, "c2");