BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: search with URL-synced filters and hashtag support

+1158 -218
+20 -2
docs/specs/search.md
··· 21 21 | `sort` | string | no | `top` (default) or `latest` | 22 22 | `since` | string | no | ISO 8601 datetime, inclusive | 23 23 | `until` | string | no | ISO 8601 datetime, exclusive | 24 + | `mentions`| string | no | Filter to posts mentioning this DID/handle | 24 25 | `author` | string | no | Filter by DID or handle | 25 26 | `lang` | string | no | Language code (e.g., `en`) | 26 27 | `tag` | string[] | no | Hashtag filter (without `#`), repeatable | 27 28 | `limit` | integer | no | 1–100, default 25 | 28 29 | `cursor` | string | no | Pagination cursor from previous response | 29 30 30 - Returns `{ cursor?, hitsTotal?, posts: PostView[] }`. With auth the response includes `viewer` state. 31 + Returns `{ cursor?, hitsTotal?, posts: PostView[] }`. With auth the response includes `viewer` state and full post facets. 32 + 33 + ### Search Route Contract 34 + 35 + - `/search` owns `q`, `tab`, `mode`, `sort`, `since`, `until`, `mentions`, `author`, and repeatable `tags` 36 + - `/hashtag/:hashtag` uses the path segment as the primary hashtag query and reuses `sort`, `since`, `until`, `mentions`, `author`, and repeatable `tags` 37 + - `since` and `until` are URL-facing `YYYY-MM-DD` values in the frontend, converted to ISO datetimes before calling Tauri 38 + - `tags` are URL-facing repeatable params and are normalized to bare tag strings before `searchPosts` 31 39 32 40 ### `app.bsky.actor.searchActors` 33 41 ··· 127 135 128 136 ```rs 129 137 // Network search (not indexed - direct API calls) 130 - search_posts_network(query: String, sort: Option<String>, limit: Option<u32>, cursor: Option<String>) -> NetworkSearchResult 138 + search_posts_network( 139 + query: String, 140 + sort: Option<String>, 141 + since: Option<String>, 142 + until: Option<String>, 143 + mentions: Option<String>, 144 + author: Option<String>, 145 + tags: Option<Vec<String>>, 146 + limit: Option<u32>, 147 + cursor: Option<String> 148 + ) -> NetworkSearchResult 131 149 search_actors(query: String, limit: Option<u32>, cursor: Option<String>) -> ActorSearchResult 132 150 search_starter_packs(query: String, limit: Option<u32>, cursor: Option<String>) -> StarterPackSearchResult 133 151 // Note: searchActorsTypeahead already exists in auth module
+8 -1
docs/tasks/07-search.md
··· 12 12 - `src-tauri/src/search.rs` for business logic 13 13 - `src-tauri/src/commands/search.rs` 14 14 - [x] Implement network search commands (not indexed - direct API calls): 15 - - `search_posts_network(query, sort?, limit?, cursor?)` → `app.bsky.feed.searchPosts` 15 + - `search_posts_network(query, sort?, since?, until?, mentions?, author?, tags?, limit?, cursor?)` → `app.bsky.feed.searchPosts` 16 16 - `search_actors(query, limit?, cursor?)` → `app.bsky.actor.searchActors` 17 17 - `search_starter_packs(query, limit?, cursor?)` → `app.bsky.graph.searchStarterPacks` 18 18 - Note: `searchActorsTypeahead` already exists in auth module 19 19 - Always available - no local setup required 20 + 21 + #### Search Routing 22 + 23 + - [x] Add URL-synced network post search state on `/search` 24 + - `q`, `tab`, `mode`, `sort`, `since`, `until`, `mentions`, `author`, repeatable `tags` 25 + - [x] Add dedicated `/hashtag/:hashtag` route backed by `searchPosts` with `q=#tag` 26 + - [x] Render hashtag facets as internal links to the hashtag route 20 27 21 28 #### Local Data Pipeline (Base) 22 29
+3 -5
src-tauri/src/commands/search.rs
··· 1 1 #![allow(clippy::needless_pass_by_value)] 2 - use crate::search::{self, PostResult, SavedPostsPage, SyncStatus}; 2 + use crate::search::{self, NetworkSearchQueryParams, PostResult, SavedPostsPage, SyncStatus}; 3 3 use crate::{error::Result, state::AppState}; 4 4 use serde_json::Value; 5 5 use tauri::{AppHandle, State}; 6 6 7 7 #[tauri::command] 8 - pub async fn search_posts_network( 9 - query: String, sort: Option<String>, limit: Option<u32>, cursor: Option<String>, state: State<'_, AppState>, 10 - ) -> Result<Value> { 11 - search::search_posts_network(query, sort, limit, cursor, &state).await 8 + pub async fn search_posts_network(query_params: NetworkSearchQueryParams, state: State<'_, AppState>) -> Result<Value> { 9 + search::search_posts_network(query_params, &state).await 12 10 } 13 11 14 12 #[tauri::command]
+195 -17
src-tauri/src/search.rs
··· 13 13 use jacquard::types::ident::AtIdentifier; 14 14 use jacquard::xrpc::XrpcClient; 15 15 use rusqlite::{params, Connection, OptionalExtension}; 16 - use serde::Serialize; 16 + use serde::{Deserialize, Serialize}; 17 17 use std::collections::HashMap; 18 18 use std::fs; 19 19 use std::path::{Path, PathBuf}; ··· 101 101 total_files: usize, 102 102 } 103 103 104 + #[derive(Clone, Debug, Deserialize)] 105 + #[serde(rename_all = "camelCase")] 106 + pub struct NetworkSearchQueryParams { 107 + author: Option<String>, 108 + cursor: Option<String>, 109 + limit: Option<u32>, 110 + mentions: Option<String>, 111 + query: String, 112 + since: Option<String>, 113 + sort: Option<String>, 114 + tags: Option<Vec<String>>, 115 + until: Option<String>, 116 + } 117 + 104 118 impl ModelDownloadProgress { 105 119 fn new(file_index: usize, total_files: usize) -> Self { 106 120 Self { file_index, total_files } ··· 147 161 0 => Err(AppError::validation("search limit must be greater than zero")), 148 162 _ => Ok(limit as usize), 149 163 } 164 + } 165 + 166 + fn normalize_identifier_filter(value: Option<&str>, label: &str) -> Result<Option<AtIdentifier<'static>>> { 167 + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { 168 + return Ok(None); 169 + }; 170 + 171 + let normalized = value.trim_start_matches('@'); 172 + AtIdentifier::new_owned(normalized).map(Some).map_err(|error| { 173 + log::error!("invalid {label} filter: {error}"); 174 + AppError::validation(format!("{label} must be a valid handle or DID.")) 175 + }) 176 + } 177 + 178 + fn normalize_optional_filter(value: Option<&str>) -> Option<String> { 179 + value 180 + .map(str::trim) 181 + .filter(|value| !value.is_empty()) 182 + .map(str::to_owned) 183 + } 184 + 185 + fn normalize_search_sort(value: Option<&str>) -> Result<Option<String>> { 186 + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { 187 + return Ok(None); 188 + }; 189 + 190 + match value { 191 + "top" | "latest" => Ok(Some(value.to_owned())), 192 + _ => Err(AppError::validation("Search sort must be 'top' or 'latest'.")), 193 + } 194 + } 195 + 196 + fn normalize_tag_filter(value: &str) -> Result<String> { 197 + let normalized = value.trim().trim_start_matches('#').trim(); 198 + if normalized.is_empty() { 199 + return Err(AppError::validation("Tag filters must not be empty.")); 200 + } 201 + 202 + Ok(normalized.to_owned()) 203 + } 204 + 205 + fn normalize_tag_filters(tags: Option<Vec<String>>) -> Result<Option<Vec<String>>> { 206 + let Some(tags) = tags else { 207 + return Ok(None); 208 + }; 209 + 210 + let mut normalized = Vec::new(); 211 + for tag in tags { 212 + let tag = normalize_tag_filter(&tag)?; 213 + if !normalized.iter().any(|existing| existing == &tag) { 214 + normalized.push(tag); 215 + } 216 + } 217 + 218 + if normalized.is_empty() { 219 + return Ok(None); 220 + } 221 + 222 + Ok(Some(normalized)) 223 + } 224 + 225 + fn build_search_posts_request(params: &NetworkSearchQueryParams) -> Result<SearchPosts<'static>> { 226 + validate_query(&params.query)?; 227 + 228 + if let Some(limit) = params.limit { 229 + let _ = validate_limit(limit)?; 230 + } 231 + 232 + let query = params.query.trim().to_owned(); 233 + let sort = normalize_search_sort(params.sort.as_deref())?; 234 + let since = normalize_optional_filter(params.since.as_deref()); 235 + let until = normalize_optional_filter(params.until.as_deref()); 236 + let author = normalize_identifier_filter(params.author.as_deref(), "Author filter")?; 237 + let mentions = normalize_identifier_filter(params.mentions.as_deref(), "Mentions filter")?; 238 + let tags = 239 + normalize_tag_filters(params.tags.clone())?.map(|items| items.into_iter().map(Into::into).collect::<Vec<_>>()); 240 + let cursor = normalize_optional_filter(params.cursor.as_deref()).map(Into::into); 241 + let since = since.map(Into::into); 242 + let sort = sort.map(Into::into); 243 + let until = until.map(Into::into); 244 + 245 + Ok(SearchPosts::new() 246 + .author(author) 247 + .cursor(cursor) 248 + .limit(params.limit.map(i64::from)) 249 + .mentions(mentions) 250 + .q(query) 251 + .since(since) 252 + .sort(sort) 253 + .tag(tags) 254 + .until(until) 255 + .build()) 150 256 } 151 257 152 258 fn validate_search_mode(mode: &str) -> Result<SearchMode> { ··· 424 530 Ok(SyncStatus { did: did.to_owned(), source: source.to_owned(), post_count, cursor, last_synced_at }) 425 531 } 426 532 427 - pub async fn search_posts_network( 428 - query: String, sort: Option<String>, limit: Option<u32>, cursor: Option<String>, state: &AppState, 429 - ) -> Result<serde_json::Value> { 430 - validate_query(&query)?; 533 + pub async fn search_posts_network(params: NetworkSearchQueryParams, state: &AppState) -> Result<serde_json::Value> { 431 534 let session = get_session(state).await?; 535 + let request = build_search_posts_request(&params)?; 432 536 433 537 let output = session 434 - .send( 435 - SearchPosts::new() 436 - .sort(sort.as_deref().map(|s| s.into())) 437 - .limit(limit.map(|l| l as i64)) 438 - .cursor(cursor.as_deref().map(|c| c.into())) 439 - .q(query.as_str()) 440 - .build(), 441 - ) 538 + .send(request) 442 539 .await 443 540 .map_err(|error| { 444 541 log::error!("searchPosts error: {error}"); ··· 1300 1397 #[cfg(test)] 1301 1398 mod tests { 1302 1399 use super::{ 1303 - build_fts_match_query, db_get_embeddings_enabled, db_list_saved_posts, db_load_sync_cursor, db_post_count, 1304 - db_save_sync_state, db_semantic_search, db_set_embeddings_enabled, db_sync_status, db_upsert_embedding, 1305 - db_upsert_post, run_local_search, storage_key, sync_due, validate_limit, validate_query, validate_search_mode, 1306 - validate_source, SearchMode, 1400 + build_fts_match_query, build_search_posts_request, db_get_embeddings_enabled, db_list_saved_posts, 1401 + db_load_sync_cursor, db_post_count, db_save_sync_state, db_semantic_search, db_set_embeddings_enabled, 1402 + db_sync_status, db_upsert_embedding, db_upsert_post, normalize_identifier_filter, normalize_tag_filter, 1403 + run_local_search, storage_key, sync_due, validate_limit, validate_query, validate_search_mode, validate_source, 1404 + NetworkSearchQueryParams, SearchMode, 1307 1405 }; 1308 1406 use rusqlite::{ffi::sqlite3_auto_extension, Connection}; 1309 1407 use sqlite_vec::sqlite3_vec_init; ··· 1444 1542 fn unknown_source_is_rejected() { 1445 1543 assert!(validate_source("repost").is_err()); 1446 1544 assert!(validate_source("").is_err()); 1545 + } 1546 + 1547 + #[test] 1548 + fn normalize_identifier_filter_accepts_handle() { 1549 + let identifier = normalize_identifier_filter(Some("alice.test"), "Author filter").unwrap(); 1550 + assert_eq!(identifier.unwrap().as_str(), "alice.test"); 1551 + } 1552 + 1553 + #[test] 1554 + fn normalize_identifier_filter_strips_leading_at_sign() { 1555 + let identifier = normalize_identifier_filter(Some("@alice.test"), "Author filter").unwrap(); 1556 + assert_eq!(identifier.unwrap().as_str(), "alice.test"); 1557 + } 1558 + 1559 + #[test] 1560 + fn normalize_identifier_filter_rejects_invalid_values() { 1561 + assert!(normalize_identifier_filter(Some("not a valid handle"), "Author filter").is_err()); 1562 + } 1563 + 1564 + #[test] 1565 + fn normalize_tag_filter_strips_hash_prefix() { 1566 + assert_eq!(normalize_tag_filter("#rust").unwrap(), "rust"); 1567 + assert_eq!(normalize_tag_filter("##solid").unwrap(), "solid"); 1568 + } 1569 + 1570 + #[test] 1571 + fn normalize_tag_filter_rejects_blank_values() { 1572 + assert!(normalize_tag_filter(" ").is_err()); 1573 + assert!(normalize_tag_filter("###").is_err()); 1574 + } 1575 + 1576 + #[test] 1577 + fn build_search_posts_request_includes_filters() { 1578 + let request = build_search_posts_request(&NetworkSearchQueryParams { 1579 + author: Some("@alice.test".to_owned()), 1580 + cursor: Some("cursor-1".to_owned()), 1581 + limit: Some(25), 1582 + mentions: Some("did:plc:bob".to_owned()), 1583 + query: "search text".to_owned(), 1584 + since: Some("2026-04-01T05:00:00.000Z".to_owned()), 1585 + sort: Some("latest".to_owned()), 1586 + tags: Some(vec!["#rust".to_owned(), "solid".to_owned()]), 1587 + until: Some("2026-04-02T05:00:00.000Z".to_owned()), 1588 + }) 1589 + .unwrap(); 1590 + 1591 + assert_eq!(request.author.unwrap().as_str(), "alice.test"); 1592 + assert_eq!(request.mentions.unwrap().as_str(), "did:plc:bob"); 1593 + assert_eq!(request.cursor.unwrap().as_ref(), "cursor-1"); 1594 + assert_eq!(request.limit, Some(25)); 1595 + assert_eq!(request.q.as_ref(), "search text"); 1596 + assert_eq!(request.since.unwrap().as_ref(), "2026-04-01T05:00:00.000Z"); 1597 + assert_eq!(request.sort.unwrap().as_ref(), "latest"); 1598 + assert_eq!( 1599 + request 1600 + .tag 1601 + .unwrap() 1602 + .iter() 1603 + .map(|value| value.as_ref().to_owned()) 1604 + .collect::<Vec<_>>(), 1605 + vec!["rust".to_owned(), "solid".to_owned()] 1606 + ); 1607 + assert_eq!(request.until.unwrap().as_ref(), "2026-04-02T05:00:00.000Z"); 1608 + } 1609 + 1610 + #[test] 1611 + fn build_search_posts_request_rejects_invalid_sort() { 1612 + let result = build_search_posts_request(&NetworkSearchQueryParams { 1613 + author: None, 1614 + cursor: None, 1615 + limit: Some(25), 1616 + mentions: None, 1617 + query: "search text".to_owned(), 1618 + since: None, 1619 + sort: Some("oldest".to_owned()), 1620 + tags: None, 1621 + until: None, 1622 + }); 1623 + 1624 + assert!(result.is_err()); 1447 1625 } 1448 1626 1449 1627 #[test]
-1
src/App.tsx
··· 1 1 import { getCurrentWindow } from "@tauri-apps/api/window"; 2 - // @ts-expect-error - erroneous font types missing 3 2 import "@fontsource-variable/google-sans"; 4 3 import { useNavigate } from "@solidjs/router"; 5 4 import type { ParentProps } from "solid-js";
+2 -1
src/components/feeds/PostCard.test.tsx
··· 1 + import { buildHashtagRoute } from "$/lib/search-routes"; 1 2 import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 2 3 import { describe, expect, it, vi } from "vitest"; 3 4 import { PostCard } from "./PostCard"; ··· 32 33 33 34 expect(screen.getByRole("link", { name: "https://example.com" })).toHaveAttribute("href", "https://example.com"); 34 35 expect(screen.getByRole("link", { name: "@bob.test" })).toHaveAttribute("href", "#/profile/did%3Aplc%3Abob"); 35 - expect(screen.getByText("#solid")).toBeInTheDocument(); 36 + expect(screen.getByRole("link", { name: "#solid" })).toHaveAttribute("href", `#${buildHashtagRoute("solid")}`); 36 37 }); 37 38 38 39 it("opens the thread from the primary region on click and Enter", async () => {
+102
src/components/search/HashtagPanel.test.tsx
··· 1 + import { buildHashtagRoute, toLocalDayStartIso, toLocalDayUntilIso } from "$/lib/search-routes"; 2 + import { AppTestProviders } from "$/test/providers"; 3 + import { HashRouter, Route } from "@solidjs/router"; 4 + import { fireEvent, render, screen } from "@solidjs/testing-library"; 5 + import { beforeEach, describe, expect, it, vi } from "vitest"; 6 + import { HashtagPanel } from "./HashtagPanel"; 7 + 8 + const searchPostsNetworkMock = vi.hoisted(() => vi.fn()); 9 + const threadOverlayMock = vi.hoisted(() => ({ openThread: vi.fn() })); 10 + 11 + vi.mock("$/lib/api/search", () => ({ searchPostsNetwork: searchPostsNetworkMock })); 12 + vi.mock( 13 + "$/components/posts/useThreadOverlayNavigation", 14 + () => ({ useThreadOverlayNavigation: () => threadOverlayMock }), 15 + ); 16 + vi.mock("@tauri-apps/plugin-log", () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn() })); 17 + 18 + async function flushRouter() { 19 + await Promise.resolve(); 20 + await Promise.resolve(); 21 + } 22 + 23 + function renderHashtagPanel(hash = `#${buildHashtagRoute("solid")}`) { 24 + globalThis.location.hash = hash; 25 + 26 + render(() => ( 27 + <AppTestProviders> 28 + <HashRouter> 29 + <Route path="/hashtag/:hashtag" component={HashtagPanel} /> 30 + </HashRouter> 31 + </AppTestProviders> 32 + )); 33 + } 34 + 35 + describe("HashtagPanel", () => { 36 + beforeEach(() => { 37 + vi.useFakeTimers(); 38 + searchPostsNetworkMock.mockReset(); 39 + threadOverlayMock.openThread.mockReset(); 40 + searchPostsNetworkMock.mockResolvedValue({ posts: [] }); 41 + }); 42 + 43 + it("searches the route hashtag using top sort by default", async () => { 44 + renderHashtagPanel(); 45 + 46 + await vi.advanceTimersByTimeAsync(350); 47 + 48 + expect(searchPostsNetworkMock).toHaveBeenCalledWith({ 49 + author: null, 50 + limit: 25, 51 + mentions: null, 52 + query: "#solid", 53 + since: null, 54 + sort: "top", 55 + tags: [], 56 + until: null, 57 + }); 58 + }); 59 + 60 + it("supports encoded hashtag paths", async () => { 61 + renderHashtagPanel("#/hashtag/%23solid"); 62 + 63 + await vi.advanceTimersByTimeAsync(350); 64 + 65 + expect(searchPostsNetworkMock).toHaveBeenCalledWith(expect.objectContaining({ query: "#solid" })); 66 + }); 67 + 68 + it("updates sort and filters via the URL", async () => { 69 + renderHashtagPanel("#/hashtag/solid?since=2026-04-01&until=2026-04-03&tags=rust"); 70 + 71 + await vi.advanceTimersByTimeAsync(350); 72 + 73 + expect(searchPostsNetworkMock).toHaveBeenCalledWith({ 74 + author: null, 75 + limit: 25, 76 + mentions: null, 77 + query: "#solid", 78 + since: toLocalDayStartIso("2026-04-01"), 79 + sort: "top", 80 + tags: ["rust"], 81 + until: toLocalDayUntilIso("2026-04-03"), 82 + }); 83 + 84 + fireEvent.click(screen.getByRole("tab", { name: /latest/i })); 85 + await flushRouter(); 86 + 87 + expect(globalThis.location.hash).toContain("sort=latest"); 88 + 89 + await vi.advanceTimersByTimeAsync(350); 90 + 91 + expect(searchPostsNetworkMock).toHaveBeenLastCalledWith({ 92 + author: null, 93 + limit: 25, 94 + mentions: null, 95 + query: "#solid", 96 + since: toLocalDayStartIso("2026-04-01"), 97 + sort: "latest", 98 + tags: ["rust"], 99 + until: toLocalDayUntilIso("2026-04-03"), 100 + }); 101 + }); 102 + });
+187
src/components/search/HashtagPanel.tsx
··· 1 + import { PostCard } from "$/components/feeds/PostCard"; 2 + import { useThreadOverlayNavigation } from "$/components/posts/useThreadOverlayNavigation"; 3 + import { Icon } from "$/components/shared/Icon"; 4 + import { type NetworkSearchResult, searchPostsNetwork } from "$/lib/api/search"; 5 + import { 6 + buildHashtagQuery, 7 + buildPostSearchRoute, 8 + decodeHashtagRouteTag, 9 + formatHashtagLabel, 10 + parsePostSearchFilters, 11 + type PostSearchFilters, 12 + toLocalDayStartIso, 13 + toLocalDayUntilIso, 14 + } from "$/lib/search-routes"; 15 + import { normalizeError } from "$/lib/utils/text"; 16 + import { useLocation, useNavigate, useParams } from "@solidjs/router"; 17 + import * as logger from "@tauri-apps/plugin-log"; 18 + import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"; 19 + import { Motion, Presence } from "solid-motionone"; 20 + import { PostSearchFiltersRow } from "./PostSearchFilters"; 21 + import { SearchEmptyState } from "./SearchEmptyState"; 22 + 23 + type HashtagPanelState = { 24 + error: string | null; 25 + hasSearched: boolean; 26 + loading: boolean; 27 + results: NetworkSearchResult | null; 28 + }; 29 + 30 + export function HashtagPanel() { 31 + const location = useLocation(); 32 + const navigate = useNavigate(); 33 + const params = useParams<{ hashtag: string }>(); 34 + const threadOverlay = useThreadOverlayNavigation(); 35 + const [state, setState] = createSignal<HashtagPanelState>({ 36 + error: null, 37 + hasSearched: false, 38 + loading: false, 39 + results: null, 40 + }); 41 + let debounceTimer: ReturnType<typeof setTimeout> | undefined; 42 + 43 + const tag = createMemo(() => decodeHashtagRouteTag(params.hashtag)); 44 + const filters = createMemo(() => parsePostSearchFilters(location.search)); 45 + const hashtagLabel = createMemo(() => formatHashtagLabel(tag() ?? "")); 46 + 47 + function replaceRoute(next: Partial<PostSearchFilters>) { 48 + const currentTag = tag(); 49 + if (!currentTag) { 50 + return; 51 + } 52 + 53 + void navigate(buildPostSearchRoute(location.pathname, location.search, { ...filters(), ...next })); 54 + } 55 + 56 + createEffect(() => { 57 + const currentTag = tag(); 58 + const activeFilters = filters(); 59 + clearTimeout(debounceTimer); 60 + debounceTimer = setTimeout(() => { 61 + if (!currentTag) { 62 + setState({ error: "This hashtag could not be opened.", hasSearched: false, loading: false, results: null }); 63 + return; 64 + } 65 + 66 + setState((previous) => ({ ...previous, error: null, loading: true })); 67 + void searchPostsNetwork({ 68 + author: activeFilters.author || null, 69 + limit: 25, 70 + mentions: activeFilters.mentions || null, 71 + query: buildHashtagQuery(currentTag), 72 + since: activeFilters.since ? toLocalDayStartIso(activeFilters.since) : null, 73 + sort: activeFilters.sort, 74 + tags: activeFilters.tags, 75 + until: activeFilters.until ? toLocalDayUntilIso(activeFilters.until) : null, 76 + }).then((results) => { 77 + setState({ error: null, hasSearched: true, loading: false, results }); 78 + }).catch((error) => { 79 + const errorMessage = normalizeError(error); 80 + logger.error("hashtag search failed", { 81 + keyValues: { error: errorMessage, hashtag: currentTag, sort: activeFilters.sort }, 82 + }); 83 + setState({ error: errorMessage, hasSearched: true, loading: false, results: null }); 84 + }); 85 + }, 300); 86 + }); 87 + 88 + return ( 89 + <section class="grid min-h-0 grid-rows-[auto_1fr] overflow-hidden rounded-4xl bg-surface-container shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]"> 90 + <header class="grid gap-4 px-6 pb-5 pt-6"> 91 + <HashtagHero hashtagLabel={hashtagLabel()} /> 92 + 93 + <PostSearchFiltersRow 94 + filters={filters()} 95 + helperText="Filter this hashtag feed by date window, mentions, author, and additional tags." 96 + onChange={(next) => replaceRoute(next)} /> 97 + </header> 98 + 99 + <div class="min-h-0 overflow-y-auto px-3 pb-3"> 100 + <Show when={state().loading} fallback={<HashtagState {...state()} onOpenThread={threadOverlay.openThread} />}> 101 + <div class="grid gap-2 py-1"> 102 + <For each={Array.from({ length: 5 })}> 103 + {() => <div class="h-40 animate-pulse rounded-3xl bg-white/4" aria-hidden="true" />} 104 + </For> 105 + </div> 106 + </Show> 107 + </div> 108 + </section> 109 + ); 110 + } 111 + 112 + function HashtagState(props: HashtagPanelState & { onOpenThread: (uri: string) => void }) { 113 + return ( 114 + <Presence> 115 + <Switch> 116 + <Match when={props.error}> 117 + <EmptyState reason="error" /> 118 + </Match> 119 + <Match when={!props.hasSearched}> 120 + <EmptyState reason="initial" /> 121 + </Match> 122 + <Match when={props.results?.posts.length === 0}> 123 + <EmptyState reason="no-results" /> 124 + </Match> 125 + <Match when={props.results}> 126 + {(results) => ( 127 + <Motion.div 128 + class="grid gap-2" 129 + initial={{ opacity: 0 }} 130 + animate={{ opacity: 1 }} 131 + exit={{ opacity: 0 }} 132 + transition={{ duration: 0.15 }}> 133 + <div class="grid gap-2" role="list"> 134 + <For each={results().posts}> 135 + {(post, index) => ( 136 + <Motion.div 137 + role="listitem" 138 + initial={{ opacity: 0, y: -6 }} 139 + animate={{ opacity: 1, y: 0 }} 140 + transition={{ duration: 0.2, delay: Math.min(index() * 0.03, 0.18) }}> 141 + <PostCard 142 + post={post} 143 + showActions={false} 144 + onOpenThread={() => props.onOpenThread(post.uri)} /> 145 + </Motion.div> 146 + )} 147 + </For> 148 + </div> 149 + </Motion.div> 150 + )} 151 + </Match> 152 + </Switch> 153 + </Presence> 154 + ); 155 + } 156 + 157 + function EmptyState(props: { reason: "error" | "initial" | "no-results" }) { 158 + return ( 159 + <Motion.div 160 + class="grid place-items-center px-6 py-16" 161 + initial={{ opacity: 0 }} 162 + animate={{ opacity: 1 }} 163 + exit={{ opacity: 0 }} 164 + transition={{ duration: 0.15 }}> 165 + <SearchEmptyState reason={props.reason} scope="network" /> 166 + </Motion.div> 167 + ); 168 + } 169 + 170 + function HashtagHero(props: { hashtagLabel: string }) { 171 + return ( 172 + <div class="flex flex-wrap items-center justify-between gap-4"> 173 + <div class="grid gap-2"> 174 + <div class="inline-flex items-center gap-2 rounded-full bg-primary/12 px-3 py-1.5 text-xs font-medium uppercase tracking-[0.12em] text-primary"> 175 + <Icon kind="hashtag" class="text-sm" /> 176 + <span>Hashtag</span> 177 + </div> 178 + <div class="grid gap-1"> 179 + <h1 class="m-0 text-3xl font-semibold tracking-[-0.03em] text-on-surface">{props.hashtagLabel}</h1> 180 + <p class="m-0 text-sm text-on-surface-variant"> 181 + Search public Bluesky posts for this hashtag with URL-synced network filters. 182 + </p> 183 + </div> 184 + </div> 185 + </div> 186 + ); 187 + }
+184
src/components/search/PostSearchFilters.tsx
··· 1 + import type { NetworkSearchSort } from "$/lib/search-routes"; 2 + import { normalizeTagToken, type PostSearchFilters } from "$/lib/search-routes"; 3 + import { createSignal, For, Show } from "solid-js"; 4 + import { Icon } from "../shared/Icon"; 5 + 6 + type SearchSortTabsProps = { disabled?: boolean; sort: NetworkSearchSort; onChange: (sort: NetworkSearchSort) => void }; 7 + 8 + type PostSearchFiltersProps = { 9 + disabled?: boolean; 10 + filters: PostSearchFilters; 11 + helperText?: string; 12 + onChange: (next: Partial<PostSearchFilters>) => void; 13 + }; 14 + 15 + export function SearchSortTabs(props: SearchSortTabsProps) { 16 + return ( 17 + <div class="flex items-center gap-2" role="tablist" aria-label="Post search sort"> 18 + <For each={["top", "latest"] as const}> 19 + {(sort) => ( 20 + <button 21 + type="button" 22 + role="tab" 23 + aria-selected={props.sort === sort} 24 + disabled={props.disabled} 25 + class="inline-flex items-center gap-2 rounded-full border-0 px-3 py-1.5 text-sm font-medium transition duration-150 disabled:cursor-not-allowed" 26 + classList={{ 27 + "bg-primary/16 text-primary shadow-[inset_0_0_0_1px_rgba(125,175,255,0.18)]": props.sort === sort, 28 + "bg-white/4 text-on-surface-variant hover:bg-white/8 hover:text-on-surface": props.sort !== sort, 29 + "opacity-50": !!props.disabled, 30 + }} 31 + onClick={() => props.onChange(sort)}> 32 + <Icon kind={sort === "top" ? "timeline" : "rss"} class="text-sm" /> 33 + <span>{sort === "top" ? "Top" : "Latest"}</span> 34 + </button> 35 + )} 36 + </For> 37 + </div> 38 + ); 39 + } 40 + 41 + export function PostSearchFiltersRow(props: PostSearchFiltersProps) { 42 + const [pendingTag, setPendingTag] = createSignal(""); 43 + 44 + function commitTag(rawValue: string) { 45 + const nextTag = normalizeTagToken(rawValue); 46 + if (!nextTag) { 47 + setPendingTag(""); 48 + return; 49 + } 50 + 51 + if (!props.filters.tags.includes(nextTag)) { 52 + props.onChange({ tags: [...props.filters.tags, nextTag] }); 53 + } 54 + 55 + setPendingTag(""); 56 + } 57 + 58 + function removeTag(tag: string) { 59 + props.onChange({ tags: props.filters.tags.filter((candidate) => candidate !== tag) }); 60 + } 61 + 62 + return ( 63 + <section class="grid gap-3 rounded-3xl bg-black/25 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 64 + <div class="flex flex-wrap items-center justify-between gap-3"> 65 + <div class="grid gap-1"> 66 + <p class="m-0 text-xs font-medium uppercase tracking-[0.12em] text-on-surface-variant">Network Filters</p> 67 + <Show when={props.helperText}> 68 + {(text) => <p class="m-0 text-xs text-on-surface-variant/80">{text()}</p>} 69 + </Show> 70 + </div> 71 + <SearchSortTabs 72 + disabled={props.disabled} 73 + sort={props.filters.sort} 74 + onChange={(sort) => props.onChange({ sort })} /> 75 + </div> 76 + 77 + <div class="grid gap-3 xl:grid-cols-2"> 78 + <FilterField 79 + disabled={props.disabled} 80 + icon="user" 81 + label="Author" 82 + placeholder="alice.test or did:plc:..." 83 + type="text" 84 + value={props.filters.author} 85 + onInput={(value) => props.onChange({ author: value })} /> 86 + <FilterField 87 + disabled={props.disabled} 88 + icon="at" 89 + label="Mentions" 90 + placeholder="bob.test or did:plc:..." 91 + type="text" 92 + value={props.filters.mentions} 93 + onInput={(value) => props.onChange({ mentions: value })} /> 94 + <FilterField 95 + disabled={props.disabled} 96 + icon="timeline" 97 + label="Since" 98 + placeholder="" 99 + type="date" 100 + value={props.filters.since} 101 + onInput={(value) => props.onChange({ since: value })} /> 102 + <FilterField 103 + disabled={props.disabled} 104 + icon="timeline" 105 + label="Until" 106 + placeholder="" 107 + type="date" 108 + value={props.filters.until} 109 + onInput={(value) => props.onChange({ until: value })} /> 110 + </div> 111 + 112 + <div class="grid gap-2"> 113 + <div class="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.12em] text-on-surface-variant"> 114 + <Icon kind="hashtag" class="text-sm" /> 115 + <span>Tags</span> 116 + </div> 117 + <div class="flex flex-wrap items-center gap-2 rounded-2xl bg-white/3 px-3 py-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 118 + <For each={props.filters.tags}> 119 + {(tag) => ( 120 + <span class="inline-flex items-center gap-1.5 rounded-full bg-primary/14 px-3 py-1 text-sm text-primary shadow-[inset_0_0_0_1px_rgba(125,175,255,0.14)]"> 121 + <span>#{tag}</span> 122 + <button 123 + type="button" 124 + disabled={props.disabled} 125 + class="inline-flex border-0 bg-transparent p-0 text-primary/80 transition hover:text-primary disabled:cursor-not-allowed" 126 + aria-label={`Remove #${tag}`} 127 + onClick={() => removeTag(tag)}> 128 + <Icon kind="close" class="text-xs" /> 129 + </button> 130 + </span> 131 + )} 132 + </For> 133 + <input 134 + type="text" 135 + value={pendingTag()} 136 + disabled={props.disabled} 137 + placeholder={props.filters.tags.length > 0 ? "Add another tag" : "Add a tag and press Enter"} 138 + class="min-w-36 flex-1 border-0 bg-transparent text-sm text-on-surface placeholder:text-on-surface-variant/50 outline-none disabled:cursor-not-allowed disabled:text-on-surface-variant/50" 139 + onBlur={(event) => commitTag(event.currentTarget.value)} 140 + onInput={(event) => setPendingTag(event.currentTarget.value)} 141 + onKeyDown={(event) => { 142 + if (event.key === "Enter" || event.key === ",") { 143 + event.preventDefault(); 144 + commitTag(event.currentTarget.value); 145 + return; 146 + } 147 + 148 + if (event.key === "Backspace" && !event.currentTarget.value && props.filters.tags.length > 0) { 149 + removeTag(props.filters.tags.at(-1) ?? ""); 150 + } 151 + }} /> 152 + </div> 153 + </div> 154 + </section> 155 + ); 156 + } 157 + 158 + function FilterField( 159 + props: { 160 + disabled?: boolean; 161 + icon: "at" | "timeline" | "user"; 162 + label: string; 163 + placeholder: string; 164 + type: "date" | "text"; 165 + value: string; 166 + onInput: (value: string) => void; 167 + }, 168 + ) { 169 + return ( 170 + <label class="grid gap-2"> 171 + <span class="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.12em] text-on-surface-variant"> 172 + <Icon kind={props.icon} class="text-sm" /> 173 + <span>{props.label}</span> 174 + </span> 175 + <input 176 + type={props.type} 177 + value={props.value} 178 + disabled={props.disabled} 179 + placeholder={props.placeholder} 180 + class="w-full rounded-2xl border-0 bg-white/3 px-3 py-2.5 text-sm text-on-surface outline-none ring-1 ring-white/5 transition focus:ring-primary/40 disabled:cursor-not-allowed disabled:text-on-surface-variant/50" 181 + onInput={(event) => props.onInput(event.currentTarget.value)} /> 182 + </label> 183 + ); 184 + }
+76 -102
src/components/search/SearchPanel.test.tsx
··· 1 + import { toLocalDayStartIso, toLocalDayUntilIso } from "$/lib/search-routes"; 1 2 import { AppTestProviders } from "$/test/providers"; 2 - import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 3 + import { HashRouter, Route } from "@solidjs/router"; 4 + import { fireEvent, render, screen } from "@solidjs/testing-library"; 3 5 import { beforeEach, describe, expect, it, vi } from "vitest"; 4 6 import { SearchPanel } from "./SearchPanel"; 5 7 6 - const navigateMock = vi.hoisted(() => vi.fn()); 7 8 const searchActorSuggestionsMock = vi.hoisted(() => vi.fn()); 8 9 const searchActorsMock = vi.hoisted(() => vi.fn()); 9 10 const searchPostsMock = vi.hoisted(() => vi.fn()); ··· 15 16 vi.mock( 16 17 "$/lib/api/search", 17 18 () => ({ 19 + getSyncStatus: getSyncStatusMock, 18 20 searchActors: searchActorsMock, 19 21 searchPosts: searchPostsMock, 20 22 searchPostsNetwork: searchPostsNetworkMock, 21 - getSyncStatus: getSyncStatusMock, 22 23 syncPosts: syncPostsMock, 23 24 }), 24 25 ); ··· 27 28 "$/components/posts/useThreadOverlayNavigation", 28 29 () => ({ useThreadOverlayNavigation: () => threadOverlayMock }), 29 30 ); 30 - vi.mock("@solidjs/router", () => ({ useNavigate: () => navigateMock })); 31 31 32 32 vi.mock("@tauri-apps/plugin-log", () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); 33 33 34 - function renderSearchPanel() { 34 + async function flushRouter() { 35 + await Promise.resolve(); 36 + await Promise.resolve(); 37 + } 38 + 39 + function renderSearchPanel(hash = "#/search") { 40 + globalThis.location.hash = hash; 41 + 35 42 render(() => ( 36 43 <AppTestProviders> 37 - <SearchPanel /> 44 + <HashRouter> 45 + <Route path="/search" component={() => <SearchPanel />} /> 46 + <Route path="/profile/:actor" component={() => <div data-testid="profile-route">profile</div>} /> 47 + </HashRouter> 38 48 </AppTestProviders> 39 49 )); 40 50 } ··· 42 52 describe("SearchPanel", () => { 43 53 beforeEach(() => { 44 54 vi.useFakeTimers(); 45 - navigateMock.mockReset(); 46 55 searchActorSuggestionsMock.mockReset(); 47 56 searchActorsMock.mockReset(); 48 57 searchPostsMock.mockReset(); 49 58 searchPostsNetworkMock.mockReset(); 50 59 getSyncStatusMock.mockReset(); 51 60 syncPostsMock.mockReset(); 61 + threadOverlayMock.openThread.mockReset(); 52 62 53 63 getSyncStatusMock.mockResolvedValue([]); 54 64 searchActorSuggestionsMock.mockResolvedValue([]); ··· 61 71 }); 62 72 }); 63 73 64 - it("renders the search panel with initial state", async () => { 74 + it("renders the search panel with network filters", async () => { 65 75 renderSearchPanel(); 66 76 67 - expect(await screen.findByPlaceholderText("Search public posts across Bluesky...")).toBeInTheDocument(); 68 - expect(screen.getByText("Network")).toBeInTheDocument(); 69 - expect(screen.getByText("Keyword")).toBeInTheDocument(); 70 - expect(screen.getByText("Semantic")).toBeInTheDocument(); 71 - expect(screen.getByText("Hybrid")).toBeInTheDocument(); 77 + expect(screen.getByPlaceholderText("Search public posts across Bluesky...")).toBeInTheDocument(); 78 + expect(screen.getByText("Network Filters")).toBeInTheDocument(); 79 + expect(screen.getByRole("tab", { name: /top/i })).toHaveAttribute("aria-selected", "true"); 72 80 expect(screen.getByRole("link", { name: /open settings/i })).toHaveAttribute("href", "#/settings"); 73 81 }); 74 82 75 - it("switches search modes when clicking mode buttons", async () => { 76 - renderSearchPanel(); 83 + it("performs network search with URL-synced filters", async () => { 84 + searchPostsNetworkMock.mockResolvedValue({ posts: [] }); 85 + 86 + renderSearchPanel( 87 + "#/search?q=test%20query&sort=latest&since=2026-04-01&until=2026-04-03&author=alice.test&mentions=bob.test&tags=solid&tags=rust", 88 + ); 77 89 78 - const keywordButton = screen.getByRole("button", { name: /keyword/i }); 79 - fireEvent.click(keywordButton); 90 + await vi.advanceTimersByTimeAsync(350); 80 91 81 - await waitFor(() => { 82 - expect(keywordButton).toHaveAttribute("aria-pressed", "true"); 92 + expect(searchPostsNetworkMock).toHaveBeenCalledWith({ 93 + author: "alice.test", 94 + limit: 25, 95 + mentions: "bob.test", 96 + query: "test query", 97 + since: toLocalDayStartIso("2026-04-01"), 98 + sort: "latest", 99 + tags: ["solid", "rust"], 100 + until: toLocalDayUntilIso("2026-04-03"), 83 101 }); 84 102 }); 85 103 86 - it("performs network search when typing", async () => { 87 - searchPostsNetworkMock.mockResolvedValue({ 88 - posts: [{ 89 - uri: "at://test", 90 - cid: "cid-1", 91 - author: { did: "did:plc:test", handle: "test.bsky.social" }, 92 - indexedAt: "2026-03-29T12:00:00.000Z", 93 - record: { text: "Test post content" }, 94 - }], 95 - }); 96 - 104 + it("updates the URL and performs network search when typing", async () => { 105 + searchPostsNetworkMock.mockResolvedValue({ posts: [] }); 97 106 renderSearchPanel(); 98 107 99 - const input = await screen.findByRole("textbox"); 108 + const input = screen.getByPlaceholderText("Search public posts across Bluesky..."); 100 109 fireEvent.input(input, { target: { value: "test query" } }); 101 110 102 - vi.advanceTimersByTime(350); 111 + await flushRouter(); 112 + await vi.advanceTimersByTimeAsync(350); 103 113 104 - await waitFor(() => { 105 - expect(searchPostsNetworkMock).toHaveBeenCalledWith("test query", "top", 25); 114 + expect(globalThis.location.hash).toContain("q=test+query"); 115 + expect(searchPostsNetworkMock).toHaveBeenCalledWith({ 116 + author: null, 117 + limit: 25, 118 + mentions: null, 119 + query: "test query", 120 + since: null, 121 + sort: "top", 122 + tags: [], 123 + until: null, 106 124 }); 107 - 108 - expect(await screen.findByText(/post content/i)).toBeInTheDocument(); 109 125 }); 110 126 111 - it("performs local search in keyword mode", async () => { 127 + it("performs local search in keyword mode and preserves filters in the URL", async () => { 112 128 getSyncStatusMock.mockResolvedValue([{ did: "did:plc:test", source: "like", postCount: 12, lastSyncedAt: null }]); 113 129 searchPostsMock.mockResolvedValue([{ 114 130 uri: "at://test", ··· 123 139 semanticMatch: false, 124 140 }]); 125 141 126 - renderSearchPanel(); 142 + renderSearchPanel("#/search?author=alice.test"); 127 143 128 - const keywordButton = screen.getByRole("button", { name: /keyword/i }); 129 - fireEvent.click(keywordButton); 130 - expect(keywordButton).toHaveAttribute("aria-pressed", "true"); 144 + fireEvent.click(await screen.findByRole("button", { name: /keyword/i })); 145 + await flushRouter(); 131 146 132 - const input = screen.getByRole("textbox"); 147 + const input = screen.getByPlaceholderText("Search your saved & liked posts..."); 133 148 fireEvent.input(input, { target: { value: "test query" } }); 134 149 150 + await flushRouter(); 135 151 await vi.advanceTimersByTimeAsync(350); 136 - await Promise.resolve(); 137 - await Promise.resolve(); 138 152 153 + expect(globalThis.location.hash).toContain("author=alice.test"); 154 + expect(globalThis.location.hash).toContain("mode=keyword"); 139 155 expect(searchPostsMock).toHaveBeenCalledWith("test query", "keyword", 50); 140 156 expect(screen.getByText("Liked")).toBeInTheDocument(); 141 157 }); ··· 143 159 it("cycles through modes with Tab key", async () => { 144 160 renderSearchPanel(); 145 161 146 - const input = await screen.findByRole("textbox"); 162 + const input = screen.getByPlaceholderText("Search public posts across Bluesky..."); 147 163 input.focus(); 148 164 fireEvent.keyDown(input, { key: "Tab" }); 165 + await flushRouter(); 149 166 150 - expect(screen.getByRole("button", { name: /keyword/i })).toHaveAttribute("aria-pressed", "true"); 151 - }, 5000); 167 + expect(globalThis.location.hash).toContain("mode=keyword"); 168 + }); 152 169 153 170 it("clears search with Escape key", async () => { 154 - searchPostsNetworkMock.mockResolvedValue({ 155 - posts: [{ 156 - uri: "at://test", 157 - cid: "cid-1", 158 - author: { did: "did:plc:test", handle: "test.bsky.social" }, 159 - indexedAt: "2026-03-29T12:00:00.000Z", 160 - record: { text: "Test content" }, 161 - }], 162 - }); 163 - 171 + searchPostsNetworkMock.mockResolvedValue({ posts: [] }); 164 172 renderSearchPanel(); 165 173 166 - const input = await screen.findByRole("textbox"); 174 + const input = screen.getByPlaceholderText("Search public posts across Bluesky..."); 167 175 fireEvent.input(input, { target: { value: "test" } }); 168 - vi.advanceTimersByTime(350); 169 176 170 - await waitFor(() => expect(searchPostsNetworkMock).toHaveBeenCalled()); 171 - 177 + await flushRouter(); 178 + await vi.advanceTimersByTimeAsync(350); 172 179 fireEvent.keyDown(input, { key: "Escape" }); 180 + await flushRouter(); 173 181 174 - await waitFor(() => { 175 - expect(input).toHaveValue(""); 176 - }); 177 - }); 178 - 179 - it("displays error state when search fails", async () => { 180 - searchPostsNetworkMock.mockRejectedValue(new Error("Search failed")); 181 - 182 - renderSearchPanel(); 183 - 184 - const input = await screen.findByRole("textbox"); 185 - fireEvent.input(input, { target: { value: "test" } }); 186 - vi.advanceTimersByTime(350); 187 - 188 - await waitFor(() => { 189 - expect(searchPostsNetworkMock).toHaveBeenCalled(); 190 - }); 191 - }); 192 - 193 - it("shows empty state when no results found", async () => { 194 - getSyncStatusMock.mockResolvedValue([{ did: "did:plc:test", source: "like", postCount: 12, lastSyncedAt: null }]); 195 - searchPostsMock.mockResolvedValue([]); 196 - 197 - renderSearchPanel(); 198 - 199 - const keywordButton = screen.getByRole("button", { name: /keyword/i }); 200 - fireEvent.click(keywordButton); 201 - 202 - const input = await screen.findByRole("textbox"); 203 - fireEvent.input(input, { target: { value: "nonexistent" } }); 204 - vi.advanceTimersByTime(350); 205 - 206 - await waitFor(() => { 207 - expect(searchPostsMock).toHaveBeenCalled(); 208 - }); 209 - 210 - expect(await screen.findByText("No results found")).toBeInTheDocument(); 182 + expect(input).toHaveValue(""); 183 + expect(globalThis.location.hash).toBe("#/search"); 211 184 }); 212 185 213 186 it("searches profiles and opens a selected actor", async () => { ··· 231 204 renderSearchPanel(); 232 205 233 206 fireEvent.click(screen.getByRole("button", { name: /profiles/i })); 207 + await flushRouter(); 234 208 235 - const input = screen.getByRole("combobox"); 209 + const input = screen.getByPlaceholderText("Search profiles by handle or display name..."); 236 210 fireEvent.input(input, { target: { value: "bob" } }); 237 211 212 + await flushRouter(); 238 213 await vi.advanceTimersByTimeAsync(350); 239 - await Promise.resolve(); 240 - await Promise.resolve(); 241 214 242 215 expect(searchActorsMock).toHaveBeenCalledWith("bob", 25); 243 216 expect(await screen.findByText("Builds search systems.")).toBeInTheDocument(); 244 217 245 218 fireEvent.click(screen.getByRole("button", { name: /bob example/i })); 219 + await flushRouter(); 246 220 247 - expect(navigateMock).toHaveBeenCalledWith("/profile/bob.test"); 221 + expect(globalThis.location.hash).toBe("#/profile/bob.test"); 248 222 }); 249 223 });
+121 -77
src/components/search/SearchPanel.tsx
··· 1 1 import { ActorSuggestionList, getActorSuggestionHeadline, useActorSuggestions } from "$/components/actors/actor-search"; 2 2 import { AvatarBadge } from "$/components/AvatarBadge"; 3 + import { PostCard } from "$/components/feeds/PostCard"; 3 4 import { useThreadOverlayNavigation } from "$/components/posts/useThreadOverlayNavigation"; 4 5 import { Icon, SearchModeIcon } from "$/components/shared/Icon"; 5 6 import { useAppPreferences } from "$/contexts/app-preferences"; ··· 8 9 type ActorSearchResult, 9 10 getSyncStatus, 10 11 type LocalPostResult, 12 + type NetworkSearchParams, 11 13 type NetworkSearchResult, 12 14 searchActors, 13 15 type SearchMode, ··· 17 19 } from "$/lib/api/search"; 18 20 import { formatRelativeTime } from "$/lib/feeds"; 19 21 import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 22 + import { 23 + buildSearchRoute, 24 + parseSearchRouteState, 25 + type PostSearchFilters, 26 + type SearchTab, 27 + toLocalDayStartIso, 28 + toLocalDayUntilIso, 29 + } from "$/lib/search-routes"; 20 30 import type { ProfileViewBasic } from "$/lib/types"; 21 31 import { normalizeError } from "$/lib/utils/text"; 22 - import { useNavigate } from "@solidjs/router"; 32 + import { useLocation, useNavigate } from "@solidjs/router"; 23 33 import * as logger from "@tauri-apps/plugin-log"; 24 34 import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; 25 35 import { createStore } from "solid-js/store"; ··· 27 37 import { PostCount } from "../shared/PostCount"; 28 38 import { EmbeddingsSettings } from "./EmbeddingsSettings"; 29 39 import { LocalPostResultsList, LocalPostResultsSkeletons } from "./LocalPostResultsList"; 40 + import { PostSearchFiltersRow } from "./PostSearchFilters"; 30 41 import { SearchEmptyState } from "./SearchEmptyState"; 31 42 import { SearchQueryInput } from "./SearchQueryInput"; 32 - import { SearchResultCard } from "./SearchResultCard"; 33 43 import { SyncStatusPanel } from "./SyncStatusPanel"; 34 44 35 45 const MODES: SearchMode[] = ["network", "keyword", "semantic", "hybrid"]; 36 - const SEARCH_TABS = ["posts", "profiles"] as const; 37 - 38 - type SearchTab = typeof SEARCH_TABS[number]; 46 + const SEARCH_TABS: SearchTab[] = ["posts", "profiles"]; 39 47 40 48 type SearchPanelState = { 41 49 actorResults: ActorSearchResult | null; 42 50 error: string | null; 43 51 hasSearched: boolean; 44 52 loading: boolean; 45 - mode: SearchMode; 46 53 networkResults: NetworkSearchResult | null; 47 - query: string; 48 54 resultCount: number; 49 55 results: LocalPostResult[]; 50 - tab: SearchTab; 51 56 syncStatus: SyncStatus[]; 52 57 }; 53 58 ··· 68 73 } 69 74 70 75 export function SearchPanel(props: SearchPanelProps = {}) { 76 + const location = useLocation(); 71 77 const navigate = useNavigate(); 72 78 const preferences = useAppPreferences(); 73 79 const session = useAppSession(); ··· 77 83 error: null, 78 84 hasSearched: false, 79 85 loading: false, 80 - mode: props.initialMode ?? "network", 81 86 networkResults: null, 82 - query: props.initialQuery ?? "", 83 87 resultCount: 0, 84 88 results: [], 85 - tab: "posts", 86 89 syncStatus: [], 87 90 }); 88 91 ··· 90 93 let searchInputRef: HTMLInputElement | undefined; 91 94 let debounceTimer: ReturnType<typeof setTimeout> | undefined; 92 95 96 + const routeState = createMemo(() => { 97 + const parsed = parseSearchRouteState(location.search); 98 + 99 + if (!parsed.q && props.initialQuery) { 100 + parsed.q = props.initialQuery; 101 + } 102 + 103 + if (props.initialMode && !new URLSearchParams(location.search).has("mode")) { 104 + parsed.mode = props.initialMode; 105 + } 106 + 107 + return parsed; 108 + }); 93 109 const actorSuggestions = useActorSuggestions({ 94 110 container: () => actorSearchContainerRef, 95 - disabled: () => search.tab !== "profiles", 111 + disabled: () => routeState().tab !== "profiles", 96 112 input: () => searchInputRef, 97 113 onError: (error) => 98 114 logger.warn("failed to load actor search suggestions", { keyValues: { error: normalizeError(error) } }), 99 - value: () => search.query, 115 + value: () => routeState().q, 100 116 }); 101 - const isActorTab = createMemo(() => search.tab === "profiles"); 102 - const isLocalMode = createMemo(() => search.tab === "posts" && search.mode !== "network"); 117 + const isActorTab = createMemo(() => routeState().tab === "profiles"); 118 + const isLocalMode = createMemo(() => routeState().tab === "posts" && routeState().mode !== "network"); 119 + const networkFiltersEnabled = createMemo(() => routeState().tab === "posts" && routeState().mode === "network"); 103 120 const semanticEnabled = createMemo(() => preferences.embeddingsEnabled); 104 121 const totalIndexedPosts = createMemo(() => 105 122 search.syncStatus.reduce((sum, status) => sum + (status.postCount ?? 0), 0) ··· 115 132 }); 116 133 const cycleModes = createMemo(() => MODES.filter((candidate) => candidate !== "semantic" || semanticEnabled())); 117 134 118 - async function performSearch(searchQuery: string, searchTab: SearchTab, searchMode: SearchMode) { 119 - if (!searchQuery.trim()) { 135 + async function performSearch() { 136 + const state = routeState(); 137 + const searchQuery = state.q.trim(); 138 + 139 + if (!searchQuery) { 120 140 clearResults(); 121 141 return; 122 142 } 123 143 124 - if (searchTab === "profiles") { 144 + if (state.tab === "profiles") { 125 145 setSearch({ error: null, loading: true }); 126 146 127 147 try { ··· 144 164 resultCount: 0, 145 165 results: [], 146 166 }); 147 - logger.error("actor search failed", { keyValues: { query: searchQuery, error: errorMessage } }); 167 + logger.error("actor search failed", { keyValues: { error: errorMessage, query: searchQuery } }); 148 168 } finally { 149 169 setSearch("loading", false); 150 170 } ··· 152 172 return; 153 173 } 154 174 155 - if (searchMode === "semantic" && !semanticEnabled()) { 175 + if (state.mode === "semantic" && !semanticEnabled()) { 156 176 setSearch({ 157 177 actorResults: null, 158 178 error: "Semantic search is disabled. Re-enable embeddings to use this mode.", ··· 167 187 setSearch({ error: null, loading: true }); 168 188 169 189 try { 170 - if (searchMode === "network") { 171 - const response = await searchPostsNetwork(searchQuery, "top", 25); 190 + if (state.mode === "network") { 191 + const response = await searchPostsNetwork(buildNetworkSearchParams(state)); 172 192 setSearch({ 173 193 actorResults: null, 174 194 hasSearched: true, ··· 177 197 results: [], 178 198 }); 179 199 } else { 180 - const response = await searchPosts(searchQuery, searchMode, 50); 200 + const response = await searchPosts(searchQuery, state.mode, 50); 181 201 setSearch({ 182 202 actorResults: null, 183 203 hasSearched: true, ··· 196 216 resultCount: 0, 197 217 results: [], 198 218 }); 199 - logger.error("search failed", { keyValues: { query: searchQuery, mode: searchMode, error: errorMessage } }); 219 + logger.error("search failed", { 220 + keyValues: { error: errorMessage, mode: state.mode, query: searchQuery, tab: state.tab }, 221 + }); 200 222 } finally { 201 223 setSearch("loading", false); 202 224 } ··· 211 233 resultCount: 0, 212 234 results: [], 213 235 }); 236 + } 237 + 238 + function replaceRoute(next: Partial<ReturnType<typeof routeState>>) { 239 + const state = routeState(); 240 + void navigate(buildSearchRoute(location.pathname, location.search, { ...state, ...next })); 214 241 } 215 242 216 243 function handleInput(value: string) { 217 - setSearch("query", value); 218 - clearTimeout(debounceTimer); 219 - debounceTimer = setTimeout(() => { 220 - void performSearch(value, search.tab, search.mode); 221 - }, 300); 244 + replaceRoute({ q: value }); 222 245 } 223 246 224 247 function handleModeChange(newMode: SearchMode) { ··· 226 249 return; 227 250 } 228 251 229 - setSearch("mode", newMode); 230 - if (search.query.trim()) { 231 - void performSearch(search.query, "posts", newMode); 232 - return; 233 - } 252 + replaceRoute({ mode: newMode, tab: "posts" }); 253 + } 234 254 235 - setSearch("error", null); 255 + function handleFilterChange(next: Partial<PostSearchFilters>) { 256 + replaceRoute(next); 236 257 } 237 258 238 259 function handleTabChange(nextTab: SearchTab) { 239 - if (nextTab === search.tab) { 260 + if (nextTab === routeState().tab) { 240 261 return; 241 262 } 242 263 243 - setSearch({ error: null, hasSearched: false, resultCount: 0, tab: nextTab }); 244 - if (search.query.trim()) { 245 - void performSearch(search.query, nextTab, search.mode); 246 - } 264 + setSearch({ error: null, hasSearched: false, resultCount: 0 }); 265 + replaceRoute({ tab: nextTab }); 247 266 } 248 267 249 268 function cycleMode() { 250 269 const availableModes = cycleModes(); 251 - const currentIndex = availableModes.indexOf(search.mode); 270 + const currentIndex = availableModes.indexOf(routeState().mode); 252 271 const nextIndex = (currentIndex + 1) % availableModes.length; 253 272 handleModeChange(availableModes[nextIndex] ?? availableModes[0] ?? "network"); 254 273 } 255 274 256 275 function clearSearch() { 257 276 actorSuggestions.close(); 258 - setSearch("query", ""); 277 + replaceRoute({ q: "" }); 259 278 clearResults(); 260 279 searchInputRef?.focus(); 261 280 } 262 281 263 282 function handleKeyDown(event: KeyboardEvent) { 264 - if (search.tab === "profiles") { 283 + if (routeState().tab === "profiles") { 265 284 if (event.key === "ArrowDown") { 266 285 event.preventDefault(); 267 286 actorSuggestions.moveActiveIndex(1); ··· 282 301 } 283 302 } 284 303 285 - if (search.tab === "posts" && event.key === "Tab" && !event.shiftKey && document.activeElement === searchInputRef) { 304 + if ( 305 + routeState().tab === "posts" && event.key === "Tab" && !event.shiftKey 306 + && document.activeElement === searchInputRef 307 + ) { 286 308 event.preventDefault(); 287 309 cycleMode(); 288 310 return; 289 311 } 290 312 291 - if (event.key === "Escape" && search.query) { 313 + if (event.key === "Escape" && routeState().q) { 292 314 clearSearch(); 293 315 return; 294 316 } 295 317 296 - if (event.key === "Escape" && search.tab === "profiles") { 318 + if (event.key === "Escape" && routeState().tab === "profiles") { 297 319 actorSuggestions.close(); 298 320 } 299 321 } ··· 312 334 if (!props.embedded) { 313 335 document.addEventListener("keydown", handleGlobalKeyDown); 314 336 } 337 + 315 338 if (props.embedded && session.activeDid) { 316 339 void getSyncStatus(session.activeDid).then((status) => { 317 340 setSearch("syncStatus", status); 318 341 }).catch((error) => { 319 342 logger.warn("failed to load embedded search sync status", { keyValues: { error: normalizeError(error) } }); 320 343 }); 321 - } 322 - if (search.query.trim()) { 323 - void performSearch(search.query, search.tab, search.mode); 324 344 } 325 345 326 346 onCleanup(() => { 327 347 if (!props.embedded) { 328 348 document.removeEventListener("keydown", handleGlobalKeyDown); 329 349 } 350 + 330 351 clearTimeout(debounceTimer); 331 352 }); 332 353 }); 333 354 334 355 createEffect(() => { 335 - if (search.mode === "semantic" && !semanticEnabled()) { 336 - setSearch("mode", "keyword"); 337 - if (search.query.trim()) { 338 - void performSearch(search.query, search.tab, "keyword"); 339 - } 356 + if (routeState().mode === "semantic" && !semanticEnabled()) { 357 + replaceRoute({ mode: "keyword" }); 340 358 } 341 359 }); 342 360 361 + createEffect(() => { 362 + routeState(); 363 + clearTimeout(debounceTimer); 364 + debounceTimer = setTimeout(() => { 365 + void performSearch(); 366 + }, 300); 367 + }); 368 + 343 369 function openActor(actor: Pick<ProfileViewBasic, "did" | "handle">) { 344 370 void navigate(buildProfileRoute(getProfileRouteActor(actor))); 345 371 } ··· 357 383 }} 358 384 actorSuggestions={actorSuggestions.suggestions()} 359 385 error={search.error} 386 + filters={routeState()} 387 + filtersEnabled={networkFiltersEnabled()} 360 388 hasSearched={search.hasSearched} 361 389 inputRef={(element) => { 362 390 searchInputRef = element; 363 391 }} 364 392 lastSync={lastSync()} 365 393 loading={search.loading} 366 - mode={search.mode} 394 + mode={routeState().mode} 395 + onActorSuggestionFocus={actorSuggestions.focus} 367 396 onActorSuggestionSelect={(suggestion) => openActor(suggestion)} 368 - onActorSuggestionFocus={actorSuggestions.focus} 369 397 onClear={clearSearch} 398 + onFilterChange={handleFilterChange} 370 399 onKeyDown={handleKeyDown} 371 400 onModeChange={handleModeChange} 372 401 onQueryChange={handleInput} 373 402 onTabChange={handleTabChange} 374 - query={search.query} 403 + query={routeState().q} 375 404 resultCount={search.resultCount} 376 - tab={search.tab} 377 405 semanticEnabled={semanticEnabled()} 378 406 suggestionsActiveIndex={actorSuggestions.activeIndex()} 379 407 suggestionsOpen={actorSuggestions.open()} 408 + tab={routeState().tab} 380 409 totalIndexedPosts={totalIndexedPosts()} /> 381 410 382 411 <SearchViewport ··· 391 420 networkResults={search.networkResults} 392 421 onOpenActor={openActor} 393 422 onOpenThread={(uri) => void threadOverlay.openThread(uri)} 394 - query={search.query} /> 423 + query={routeState().q} /> 395 424 </section> 396 425 397 426 <Show when={!props.embedded}> ··· 412 441 actorSearchContainerRef: (el: HTMLDivElement) => void; 413 442 actorSuggestions: ProfileViewBasic[]; 414 443 error: string | null; 444 + filters: ReturnType<typeof parseSearchRouteState>; 445 + filtersEnabled: boolean; 415 446 hasSearched: boolean; 416 447 inputRef: (el: HTMLInputElement) => void; 417 448 lastSync: string | null; 418 449 loading: boolean; 419 450 mode: SearchMode; 420 - onActorSuggestionSelect: (suggestion: ProfileViewBasic) => void; 421 451 onActorSuggestionFocus: () => void; 452 + onActorSuggestionSelect: (suggestion: ProfileViewBasic) => void; 422 453 onClear: () => void; 454 + onFilterChange: (next: Partial<PostSearchFilters>) => void; 423 455 onKeyDown: (event: KeyboardEvent) => void; 424 456 onModeChange: (mode: SearchMode) => void; 425 457 onQueryChange: (value: string) => void; ··· 490 522 <SearchHint tab={props.tab} /> 491 523 </div> 492 524 525 + <PostSearchFiltersRow 526 + disabled={!props.filtersEnabled} 527 + filters={props.filters} 528 + helperText={props.filtersEnabled 529 + ? "Filters update the URL and apply to network post search." 530 + : "Filters stay in the URL, but only apply when Posts + Network search is active."} 531 + onChange={props.onFilterChange} /> 532 + 493 533 <ResultMeta 494 534 hasSearched={props.hasSearched} 495 535 isActorTab={props.tab === "profiles"} ··· 599 639 600 640 const rect = activeButton.getBoundingClientRect(); 601 641 const containerRect = ref.getBoundingClientRect(); 602 - 603 642 setIndicatorStyle({ left: `${rect.left - containerRect.left}px`, width: `${rect.width}px` }); 604 643 }); 605 644 ··· 715 754 </Match> 716 755 717 756 <Match when={!props.isLocalMode && props.networkResults}> 718 - <NetworkResultsList onOpenThread={props.onOpenThread} query={props.query} results={props.networkResults} /> 757 + <NetworkResultsList onOpenThread={props.onOpenThread} results={props.networkResults} /> 719 758 </Match> 720 759 </Switch> 721 760 </Presence> ··· 805 844 ); 806 845 } 807 846 808 - function NetworkResultsList( 809 - props: { onOpenThread: (uri: string) => void; query: string; results: NetworkSearchResult | null }, 810 - ) { 847 + function NetworkResultsList(props: { onOpenThread: (uri: string) => void; results: NetworkSearchResult | null }) { 811 848 return ( 812 849 <Motion.div 813 850 class="grid gap-2" ··· 823 860 animate={{ opacity: 1, y: 0 }} 824 861 transition={{ duration: 0.2, delay: Math.min(index() * 0.03, 0.18) }} 825 862 role="listitem"> 826 - <SearchResultCard 827 - authorDid={post.author.did} 828 - authorHandle={post.author.handle} 829 - source="network" 830 - text={typeof post.record.text === "string" ? post.record.text : ""} 831 - createdAt={post.indexedAt} 832 - likeCount={post.likeCount ?? 0} 833 - onOpenThread={() => props.onOpenThread(post.uri)} 834 - replyCount={post.replyCount ?? 0} 835 - query={props.query} /> 863 + <PostCard 864 + post={post} 865 + showActions={false} 866 + onOpenThread={() => props.onOpenThread(post.uri)} /> 836 867 </Motion.div> 837 868 )} 838 869 </For> ··· 857 888 <div class="col-span-2 flex flex-col items-start gap-1"> 858 889 <div class="m-0 flex items-start gap-2"> 859 890 <div>·</div> 860 - <div>Use keyword mode for exact terms and hybrid mode for broader recall.</div> 891 + <div>Network filters are URL-synced, so you can bookmark or share exact search states.</div> 861 892 </div> 862 893 <div class="m-0 flex items-start gap-2"> 863 894 <div>·</div> 864 - <div>Semantic mode follows the embeddings setting and model status shown above.</div> 895 + <div>Use keyword mode for exact terms and hybrid mode for broader recall.</div> 865 896 </div> 866 897 <div class="m-0 flex items-start gap-2"> 867 898 <div>·</div> ··· 878 909 </section> 879 910 ); 880 911 } 912 + 913 + function buildNetworkSearchParams(state: ReturnType<typeof parseSearchRouteState>): NetworkSearchParams { 914 + return { 915 + author: state.author || null, 916 + limit: 25, 917 + mentions: state.mentions || null, 918 + query: state.q, 919 + since: state.since ? toLocalDayStartIso(state.since) : null, 920 + sort: state.sort, 921 + tags: state.tags, 922 + until: state.until ? toLocalDayUntilIso(state.until) : null, 923 + }; 924 + }
+2 -1
src/components/shared/PostRichText.test.tsx
··· 1 + import { buildHashtagRoute } from "$/lib/search-routes"; 1 2 import { render, screen } from "@solidjs/testing-library"; 2 3 import { describe, expect, it } from "vitest"; 3 4 import { PostRichText } from "./PostRichText"; ··· 21 22 22 23 expect(screen.getByRole("link", { name: "https://example.com" })).toHaveAttribute("href", "https://example.com"); 23 24 expect(screen.getByRole("link", { name: "@bob.test" })).toHaveAttribute("href", "#/profile/did%3Aplc%3Abob"); 24 - expect(screen.getByText("#solid")).toBeInTheDocument(); 25 + expect(screen.getByRole("link", { name: "#solid" })).toHaveAttribute("href", `#${buildHashtagRoute("solid")}`); 25 26 }); 26 27 27 28 it("renders markdown blocks and does not linkify inside code", () => {
+9 -1
src/components/shared/PostRichText.tsx
··· 8 8 splitLegacyUrls, 9 9 } from "$/lib/post-rich-text"; 10 10 import { buildProfileRoute } from "$/lib/profile"; 11 + import { buildHashtagRoute } from "$/lib/search-routes"; 11 12 import type { RichTextFacet } from "$/lib/types"; 12 13 import { For, type JSX, Show } from "solid-js"; 13 14 ··· 167 168 ); 168 169 } 169 170 170 - return <span class="break-all text-primary">{label}</span>; 171 + return ( 172 + <a 173 + class="break-all text-primary no-underline hover:underline" 174 + href={`#${buildHashtagRoute(facet.feature.tag)}`} 175 + onClick={(event) => event.stopPropagation()}> 176 + {label} 177 + </a> 178 + ); 171 179 } 172 180 173 181 function LegacyText(props: { text: string; useFallback: boolean }) {
+1 -3
src/components/shared/QuotedPostPreview.tsx
··· 10 10 return ( 11 11 <div class={props.class ?? "rounded-2xl bg-black/30 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"}> 12 12 <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.title}</p> 13 - <Show 14 - when={props.href} 15 - fallback={<QuotedPreviewContent author={props.author} preview={preview()} />}> 13 + <Show when={props.href} fallback={<QuotedPreviewContent author={props.author} preview={preview()} />}> 16 14 {(href) => ( 17 15 <a 18 16 class="mt-2 block rounded-xl px-1 py-1 text-inherit no-underline transition duration-150 ease-out hover:bg-white/4"
+26 -7
src/lib/api/search.ts
··· 2 2 import { invoke } from "@tauri-apps/api/core"; 3 3 4 4 export type SearchMode = "network" | "keyword" | "semantic" | "hybrid"; 5 + export type NetworkSearchSort = "top" | "latest"; 5 6 6 7 export type NetworkSearchResult = { cursor?: string | null; hitsTotal?: number | null; posts: PostView[] }; 8 + export type NetworkSearchParams = { 9 + query: string; 10 + sort?: NetworkSearchSort; 11 + since?: string | null; 12 + until?: string | null; 13 + mentions?: string | null; 14 + author?: string | null; 15 + tags?: string[]; 16 + limit?: number; 17 + cursor?: string | null; 18 + }; 7 19 8 20 type TActor = { 9 21 did: string; ··· 65 77 lastError?: string | null; 66 78 }; 67 79 68 - export function searchPostsNetwork( 69 - query: string, 70 - sort?: "top" | "latest", 71 - limit?: number, 72 - cursor?: string | null, 73 - ): Promise<NetworkSearchResult> { 74 - return invoke("search_posts_network", { query, sort: sort ?? null, limit: limit ?? null, cursor: cursor ?? null }); 80 + export function searchPostsNetwork(params: NetworkSearchParams): Promise<NetworkSearchResult> { 81 + return invoke("search_posts_network", { 82 + queryParams: { 83 + author: params.author ?? null, 84 + cursor: params.cursor ?? null, 85 + limit: params.limit ?? null, 86 + mentions: params.mentions ?? null, 87 + query: params.query, 88 + since: params.since ?? null, 89 + sort: params.sort ?? null, 90 + tags: params.tags?.length ? params.tags : null, 91 + until: params.until ?? null, 92 + }, 93 + }); 75 94 } 76 95 77 96 export function searchPosts(query: string, mode: SearchMode, limit: number): Promise<LocalPostResult[]> {
+185
src/lib/search-routes.ts
··· 1 + import type { SearchMode } from "$/lib/api/search"; 2 + 3 + export type SearchTab = "posts" | "profiles"; 4 + export type NetworkSearchSort = "top" | "latest"; 5 + 6 + export type PostSearchFilters = { 7 + author: string; 8 + mentions: string; 9 + since: string; 10 + sort: NetworkSearchSort; 11 + tags: string[]; 12 + until: string; 13 + }; 14 + 15 + export type SearchRouteState = PostSearchFilters & { mode: SearchMode; q: string; tab: SearchTab }; 16 + 17 + const CONTROLLED_POST_SEARCH_PARAMS = ["author", "mentions", "since", "sort", "tags", "until"] as const; 18 + const SEARCH_ROUTE_DEFAULTS: SearchRouteState = { 19 + author: "", 20 + mentions: "", 21 + mode: "network", 22 + q: "", 23 + since: "", 24 + sort: "top", 25 + tab: "posts", 26 + tags: [], 27 + until: "", 28 + }; 29 + const SEARCH_TABS = new Set<SearchTab>(["posts", "profiles"]); 30 + const SEARCH_MODES = new Set<SearchMode>(["network", "keyword", "semantic", "hybrid"]); 31 + const SEARCH_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u; 32 + 33 + export function buildHashtagQuery(tag: string) { 34 + const normalized = normalizeTagToken(tag); 35 + return normalized ? `#${normalized}` : "#"; 36 + } 37 + 38 + export function buildHashtagRoute(tag: string) { 39 + const normalized = normalizeTagToken(tag); 40 + return normalized ? `/hashtag/${encodeURIComponent(normalized)}` : "/search"; 41 + } 42 + 43 + export function buildPostSearchRoute(pathname: string, search: string, filters: PostSearchFilters) { 44 + const params = new URLSearchParams(search); 45 + 46 + clearControlledParams(params, CONTROLLED_POST_SEARCH_PARAMS); 47 + setOptionalParam(params, "author", filters.author); 48 + setOptionalParam(params, "mentions", filters.mentions); 49 + setOptionalParam(params, "since", normalizeDateInput(filters.since)); 50 + setOptionalParam(params, "until", normalizeDateInput(filters.until)); 51 + 52 + if (filters.sort !== SEARCH_ROUTE_DEFAULTS.sort) { 53 + params.set("sort", filters.sort); 54 + } 55 + 56 + for (const tag of filters.tags.map((value) => normalizeTagToken(value)).filter(Boolean)) { 57 + params.append("tags", tag); 58 + } 59 + 60 + return buildRouteFromParams(pathname, params); 61 + } 62 + 63 + export function buildSearchRoute(pathname: string, search: string, state: SearchRouteState) { 64 + const params = new URLSearchParams(search); 65 + 66 + clearControlledParams(params, [...CONTROLLED_POST_SEARCH_PARAMS, "mode", "q", "tab"]); 67 + setOptionalParam(params, "q", state.q); 68 + 69 + if (state.tab !== SEARCH_ROUTE_DEFAULTS.tab) { 70 + params.set("tab", state.tab); 71 + } 72 + 73 + if (state.mode !== SEARCH_ROUTE_DEFAULTS.mode) { 74 + params.set("mode", state.mode); 75 + } 76 + 77 + return buildPostSearchRoute(pathname, params.toString(), state); 78 + } 79 + 80 + export function decodeHashtagRouteTag(value?: string | null) { 81 + if (!value) { 82 + return null; 83 + } 84 + 85 + try { 86 + return normalizeTagToken(decodeURIComponent(value)) || null; 87 + } catch { 88 + return normalizeTagToken(value) || null; 89 + } 90 + } 91 + 92 + export function formatHashtagLabel(tag: string) { 93 + const normalized = normalizeTagToken(tag); 94 + return normalized ? `#${normalized}` : "#"; 95 + } 96 + 97 + export function normalizeDateInput(value?: string | null) { 98 + const trimmed = value?.trim() ?? ""; 99 + if (!SEARCH_DATE_PATTERN.test(trimmed)) { 100 + return ""; 101 + } 102 + 103 + const parsed = new Date(`${trimmed}T00:00:00`); 104 + if (Number.isNaN(parsed.getTime())) { 105 + return ""; 106 + } 107 + 108 + const [year, month, day] = trimmed.split("-").map(Number); 109 + if (parsed.getFullYear() !== year || parsed.getMonth() + 1 !== month || parsed.getDate() !== day) { 110 + return ""; 111 + } 112 + 113 + return trimmed; 114 + } 115 + 116 + export function normalizeTagToken(value?: string | null) { 117 + return value?.trim().replace(/^#+/u, "").trim() ?? ""; 118 + } 119 + 120 + export function parsePostSearchFilters(search: string): PostSearchFilters { 121 + const params = new URLSearchParams(search); 122 + 123 + return { 124 + author: params.get("author")?.trim() ?? "", 125 + mentions: params.get("mentions")?.trim() ?? "", 126 + since: normalizeDateInput(params.get("since")), 127 + sort: params.get("sort") === "latest" ? "latest" : "top", 128 + tags: params.getAll("tags").map((value) => normalizeTagToken(value)).filter(Boolean), 129 + until: normalizeDateInput(params.get("until")), 130 + }; 131 + } 132 + 133 + export function parseSearchRouteState(search: string): SearchRouteState { 134 + const params = new URLSearchParams(search); 135 + const tab = params.get("tab"); 136 + const mode = params.get("mode"); 137 + 138 + return { 139 + ...parsePostSearchFilters(search), 140 + mode: SEARCH_MODES.has(mode as SearchMode) ? (mode as SearchMode) : SEARCH_ROUTE_DEFAULTS.mode, 141 + q: params.get("q")?.trim() ?? "", 142 + tab: SEARCH_TABS.has(tab as SearchTab) ? (tab as SearchTab) : SEARCH_ROUTE_DEFAULTS.tab, 143 + }; 144 + } 145 + 146 + export function toLocalDayStartIso(value: string) { 147 + const normalized = normalizeDateInput(value); 148 + if (!normalized) { 149 + return null; 150 + } 151 + 152 + const [year, month, day] = normalized.split("-").map(Number); 153 + return new Date(year, month - 1, day).toISOString(); 154 + } 155 + 156 + export function toLocalDayUntilIso(value: string) { 157 + const normalized = normalizeDateInput(value); 158 + if (!normalized) { 159 + return null; 160 + } 161 + 162 + const [year, month, day] = normalized.split("-").map(Number); 163 + return new Date(year, month - 1, day + 1).toISOString(); 164 + } 165 + 166 + function buildRouteFromParams(pathname: string, params: URLSearchParams) { 167 + const nextSearch = params.toString(); 168 + return nextSearch ? `${pathname}?${nextSearch}` : pathname; 169 + } 170 + 171 + function clearControlledParams(params: URLSearchParams, keys: readonly string[]) { 172 + for (const key of keys) { 173 + params.delete(key); 174 + } 175 + } 176 + 177 + function setOptionalParam(params: URLSearchParams, key: string, value: string) { 178 + const trimmed = value.trim(); 179 + if (trimmed) { 180 + params.set(key, trimmed); 181 + return; 182 + } 183 + 184 + params.delete(key); 185 + }
+21
src/router.test.tsx
··· 13 13 "$/components/saved/SavedPostsPanel", 14 14 () => ({ SavedPostsPanel: () => <div data-testid="saved-posts-view">saved</div> }), 15 15 ); 16 + vi.mock( 17 + "$/components/search/HashtagPanel", 18 + () => ({ HashtagPanel: () => <div data-testid="hashtag-view">hashtag</div> }), 19 + ); 16 20 17 21 const Shell: Component<ParentProps<{ fullWidth?: boolean }>> = (props) => ( 18 22 <div data-testid="shell" data-full-width={props.fullWidth ? "true" : "false"}>{props.children}</div> ··· 142 146 143 147 expect(renderProfile.mock.lastCall?.[0].actor).toBe(actor); 144 148 expect(screen.getByText(actor)).toBeInTheDocument(); 149 + }); 150 + 151 + it("renders hashtag routes inside the protected shell", async () => { 152 + renderRouter("#/hashtag/solid"); 153 + 154 + await screen.findByTestId("hashtag-view"); 155 + 156 + expect(screen.getByText("hashtag")).toBeInTheDocument(); 157 + expect(screen.getByTestId("shell")).toHaveAttribute("data-full-width", "false"); 158 + }); 159 + 160 + it("renders encoded hashtag routes", async () => { 161 + renderRouter("#/hashtag/%23solid"); 162 + 163 + await screen.findByTestId("hashtag-view"); 164 + 165 + expect(screen.getByText("hashtag")).toBeInTheDocument(); 145 166 }); 146 167 });
+16
src/router.tsx
··· 7 7 import { DeckWorkspace } from "./components/deck/DeckWorkspace"; 8 8 import { ExplorerPanel } from "./components/explorer/ExplorerPanel"; 9 9 import { SavedPostsPanel } from "./components/saved/SavedPostsPanel"; 10 + import { HashtagPanel } from "./components/search/HashtagPanel"; 10 11 import { SearchPanel } from "./components/search/SearchPanel"; 11 12 import { SettingsPanel } from "./components/settings/SettingsPanel"; 12 13 import { decodeMessagesRouteMemberDid } from "./lib/conversations"; 13 14 import { TIMELINE_ROUTE } from "./lib/feeds"; 14 15 import { decodeProfileRouteActor } from "./lib/profile"; 16 + import { decodeHashtagRouteTag } from "./lib/search-routes"; 15 17 16 18 type TMessagesRouteProps = { memberDid: string | null }; 17 19 type TProfileRouteProps = { actor: string | null }; ··· 90 92 91 93 const NotificationsRoute = () => <ProtectedRouteView>{props.renderNotifications()}</ProtectedRouteView>; 92 94 95 + const HashtagRoute = () => { 96 + const params = useParams<{ hashtag: string }>(); 97 + const tag = decodeHashtagRouteTag(params.hashtag); 98 + 99 + return ( 100 + <ProtectedRouteView> 101 + <Show when={tag} fallback={<Navigate href="/search" />}> 102 + <HashtagPanel /> 103 + </Show> 104 + </ProtectedRouteView> 105 + ); 106 + }; 107 + 93 108 const MessagesRoute = () => ( 94 109 <ProtectedRouteView> 95 110 <Dynamic component={props.renderMessages} memberDid={null} /> ··· 147 162 <Route path="/profile/:actor" component={ActorProfileRoute} /> 148 163 <Route path="/composer" component={ComposerRoute} /> 149 164 <Route path="/search" component={SearchRoute} /> 165 + <Route path="/hashtag/:hashtag" component={HashtagRoute} /> 150 166 <Route path="/saved" component={SavedPostsRoute} /> 151 167 <Route path="/notifications" component={NotificationsRoute} /> 152 168 <Route path="/messages" component={MessagesRoute} />