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: make filters collapsible

+363 -174
+67 -2
src-tauri/src/search.rs
··· 9 9 use jacquard::api::app_bsky::feed::get_actor_likes::GetActorLikes; 10 10 use jacquard::api::app_bsky::feed::search_posts::SearchPosts; 11 11 use jacquard::api::app_bsky::graph::search_starter_packs::SearchStarterPacks; 12 + use jacquard::types::datetime::Datetime; 12 13 use jacquard::types::did::Did; 13 14 use jacquard::types::ident::AtIdentifier; 14 15 use jacquard::xrpc::XrpcClient; ··· 17 18 use std::collections::HashMap; 18 19 use std::fs; 19 20 use std::path::{Path, PathBuf}; 21 + use std::str::FromStr; 20 22 use std::sync::{Arc, LazyLock, Mutex}; 21 23 use std::time::{Duration, Instant}; 22 24 use tauri::{AppHandle, Manager}; ··· 184 186 .map(str::to_owned) 185 187 } 186 188 189 + fn normalize_datetime_filter(value: Option<&str>, label: &str) -> Result<Option<String>> { 190 + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { 191 + return Ok(None); 192 + }; 193 + 194 + Datetime::from_str(value).map_err(|error| { 195 + log::error!("invalid {label}: {error}"); 196 + AppError::validation(format!("{label} must be a valid ISO 8601 datetime.")) 197 + })?; 198 + 199 + Ok(Some(value.to_owned())) 200 + } 201 + 187 202 fn normalize_search_sort(value: Option<&str>) -> Result<Option<String>> { 188 203 let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { 189 204 return Ok(None); ··· 233 248 234 249 let query = params.query.trim().to_owned(); 235 250 let sort = normalize_search_sort(params.sort.as_deref())?; 236 - let since = normalize_optional_filter(params.since.as_deref()); 237 - let until = normalize_optional_filter(params.until.as_deref()); 251 + let since = normalize_datetime_filter(params.since.as_deref(), "Since filter")?; 252 + let until = normalize_datetime_filter(params.until.as_deref(), "Until filter")?; 238 253 let author = normalize_identifier_filter(params.author.as_deref(), "Author filter")?; 239 254 let mentions = normalize_identifier_filter(params.mentions.as_deref(), "Mentions filter")?; 240 255 let tags = 241 256 normalize_tag_filters(params.tags.clone())?.map(|items| items.into_iter().map(Into::into).collect::<Vec<_>>()); 242 257 let cursor = normalize_optional_filter(params.cursor.as_deref()).map(Into::into); 258 + 259 + if let (Some(since), Some(until)) = (since.as_deref(), until.as_deref()) { 260 + let since = Datetime::from_str(since).map_err(|error| { 261 + log::error!("invalid Since filter during range validation: {error}"); 262 + AppError::validation("Since filter must be a valid ISO 8601 datetime.") 263 + })?; 264 + let until = Datetime::from_str(until).map_err(|error| { 265 + log::error!("invalid Until filter during range validation: {error}"); 266 + AppError::validation("Until filter must be a valid ISO 8601 datetime.") 267 + })?; 268 + 269 + if since >= until { 270 + return Err(AppError::validation("Since filter must be earlier than until.")); 271 + } 272 + } 273 + 243 274 let since = since.map(Into::into); 244 275 let sort = sort.map(Into::into); 245 276 let until = until.map(Into::into); ··· 1695 1726 sort: Some("oldest".to_owned()), 1696 1727 tags: None, 1697 1728 until: None, 1729 + }); 1730 + 1731 + assert!(result.is_err()); 1732 + } 1733 + 1734 + #[test] 1735 + fn build_search_posts_request_rejects_invalid_datetime() { 1736 + let result = build_search_posts_request(&NetworkSearchQueryParams { 1737 + author: None, 1738 + cursor: None, 1739 + limit: Some(25), 1740 + mentions: None, 1741 + query: "search text".to_owned(), 1742 + since: Some("2026-04-01".to_owned()), 1743 + sort: Some("latest".to_owned()), 1744 + tags: None, 1745 + until: None, 1746 + }); 1747 + 1748 + assert!(result.is_err()); 1749 + } 1750 + 1751 + #[test] 1752 + fn build_search_posts_request_rejects_inverted_datetime_range() { 1753 + let result = build_search_posts_request(&NetworkSearchQueryParams { 1754 + author: None, 1755 + cursor: None, 1756 + limit: Some(25), 1757 + mentions: None, 1758 + query: "search text".to_owned(), 1759 + since: Some("2026-04-02T05:00:00.000Z".to_owned()), 1760 + sort: Some("latest".to_owned()), 1761 + tags: None, 1762 + until: Some("2026-04-01T05:00:00.000Z".to_owned()), 1698 1763 }); 1699 1764 1700 1765 assert!(result.is_err());
+16 -18
src/components/search/EmbeddingsSettings.tsx
··· 22 22 23 23 function EmbedSettingsHeader(props: { config: EmbeddingsConfig | null; isLoading: boolean; handleToggle: () => void }) { 24 24 return ( 25 - <div class="flex items-start justify-between gap-4"> 26 - <div class="flex items-start gap-2"> 25 + <div class="flex flex-col gap-4"> 26 + <div class="flex items-center gap-2"> 27 27 <div> 28 28 <Icon 29 29 kind="search" 30 30 class="h-11 w-11 items-center justify-center rounded-2xl bg-primary/12 text-lg text-primary" /> 31 31 </div> 32 32 33 - <div class="grid gap-1"> 34 - <p class="m-0 text-base font-medium text-on-surface">Optional Semantic Search</p> 35 - <p class="m-0 text-sm leading-relaxed text-on-surface-variant"> 36 - Off by default. Turn this on to download a local model and unlock semantic plus hybrid search for synced 37 - posts. 38 - </p> 33 + <p class="m-0 text-base font-medium text-on-surface">Optional Semantic Search</p> 34 + <div> 35 + <Show when={props.config}> 36 + {(current) => ( 37 + <ToggleSwitch 38 + checked={current().enabled} 39 + disabled={props.isLoading || current().downloadActive} 40 + onChange={() => void props.handleToggle()} /> 41 + )} 42 + </Show> 39 43 </div> 40 44 </div> 41 - <div> 42 - <Show when={props.config}> 43 - {(current) => ( 44 - <ToggleSwitch 45 - checked={current().enabled} 46 - disabled={props.isLoading || current().downloadActive} 47 - onChange={() => void props.handleToggle()} /> 48 - )} 49 - </Show> 50 - </div> 45 + <p class="m-0 text-sm leading-relaxed text-on-surface-variant"> 46 + Off by default. Turn this on to download a local model and unlock more detailed search for your liked and saved 47 + posts. 48 + </p> 51 49 </div> 52 50 ); 53 51 }
+33 -22
src/components/search/HashtagPanel.tsx
··· 15 15 import { normalizeError } from "$/lib/utils/text"; 16 16 import { useLocation, useNavigate, useParams } from "@solidjs/router"; 17 17 import * as logger from "@tauri-apps/plugin-log"; 18 - import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"; 18 + import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js"; 19 + import { createStore } from "solid-js/store"; 19 20 import { Motion, Presence } from "solid-motionone"; 20 21 import { PostSearchFiltersRow } from "./PostSearchFilters"; 21 22 import { SearchEmptyState } from "./SearchEmptyState"; 23 + import type { EmptyStateReason } from "./types"; 22 24 23 25 type HashtagPanelState = { 24 26 error: string | null; ··· 32 34 const navigate = useNavigate(); 33 35 const params = useParams<{ hashtag: string }>(); 34 36 const threadOverlay = useThreadOverlayNavigation(); 35 - const [state, setState] = createSignal<HashtagPanelState>({ 37 + const [state, setState] = createStore<HashtagPanelState>({ 36 38 error: null, 37 39 hasSearched: false, 38 40 loading: false, ··· 53 55 void navigate(buildPostSearchRoute(location.pathname, location.search, { ...filters(), ...next })); 54 56 } 55 57 58 + async function performSearch(f: PostSearchFilters, t: string) { 59 + try { 60 + const results = await searchPostsNetwork({ 61 + author: f.author || null, 62 + limit: 25, 63 + mentions: f.mentions || null, 64 + query: buildHashtagQuery(t), 65 + since: f.since ? toLocalDayStartIso(f.since) : null, 66 + sort: f.sort, 67 + tags: f.tags, 68 + until: f.until ? toLocalDayUntilIso(f.until) : null, 69 + }); 70 + setState({ error: null, hasSearched: true, loading: false, results }); 71 + } catch (error) { 72 + const errorMessage = normalizeError(error); 73 + logger.error("hashtag search failed", { keyValues: { error: errorMessage, hashtag: t, sort: f.sort } }); 74 + setState({ error: errorMessage, hasSearched: true, loading: false, results: null }); 75 + } 76 + } 77 + 56 78 createEffect(() => { 57 79 const currentTag = tag(); 58 80 const activeFilters = filters(); ··· 64 86 } 65 87 66 88 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 - }); 89 + void performSearch(activeFilters, currentTag); 85 90 }, 300); 86 91 }); 87 92 ··· 91 96 <HashtagHero hashtagLabel={hashtagLabel()} /> 92 97 93 98 <PostSearchFiltersRow 99 + collapsible 100 + defaultExpanded={hasAdvancedNetworkFilters(filters())} 94 101 filters={filters()} 95 102 helperText="Filter this hashtag feed by date window, mentions, author, and additional tags." 96 103 onChange={(next) => replaceRoute(next)} /> 97 104 </header> 98 105 99 106 <div class="min-h-0 overflow-y-auto px-3 pb-3"> 100 - <Show when={state().loading} fallback={<HashtagState {...state()} onOpenThread={threadOverlay.openThread} />}> 107 + <Show when={state.loading} fallback={<HashtagState {...state} onOpenThread={threadOverlay.openThread} />}> 101 108 <div class="grid gap-2 py-1"> 102 109 <For each={Array.from({ length: 5 })}> 103 110 {() => <div class="h-40 animate-pulse rounded-3xl bg-white/4" aria-hidden="true" />} ··· 107 114 </div> 108 115 </section> 109 116 ); 117 + } 118 + 119 + function hasAdvancedNetworkFilters(filters: PostSearchFilters) { 120 + return !!(filters.author || filters.mentions || filters.since || filters.until || filters.tags.length > 0); 110 121 } 111 122 112 123 function HashtagState(props: HashtagPanelState & { onOpenThread: (uri: string) => void }) { ··· 154 165 ); 155 166 } 156 167 157 - function EmptyState(props: { reason: "error" | "initial" | "no-results" }) { 168 + function EmptyState(props: { reason: EmptyStateReason }) { 158 169 return ( 159 170 <Motion.div 160 171 class="grid place-items-center px-6 py-16"
+143 -78
src/components/search/PostSearchFilters.tsx
··· 1 1 import type { NetworkSearchSort } from "$/lib/search-routes"; 2 2 import { normalizeTagToken, type PostSearchFilters } from "$/lib/search-routes"; 3 - import { createSignal, For, Show } from "solid-js"; 4 - import { Icon } from "../shared/Icon"; 3 + import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; 4 + import { ArrowIcon, Icon } from "../shared/Icon"; 5 5 6 6 type SearchSortTabsProps = { disabled?: boolean; sort: NetworkSearchSort; onChange: (sort: NetworkSearchSort) => void }; 7 7 8 8 type PostSearchFiltersProps = { 9 + collapsible?: boolean; 10 + defaultExpanded?: boolean; 9 11 disabled?: boolean; 10 12 filters: PostSearchFilters; 11 13 helperText?: string; ··· 40 42 41 43 export function PostSearchFiltersRow(props: PostSearchFiltersProps) { 42 44 const [pendingTag, setPendingTag] = createSignal(""); 45 + const [expanded, setExpanded] = createSignal(false); 46 + const summary = createMemo(() => summarizeFilters(props.filters)); 47 + let initialized = false; 48 + 49 + createEffect(() => { 50 + if (initialized) { 51 + return; 52 + } 53 + 54 + setExpanded(props.defaultExpanded ?? (!props.collapsible || hasAdvancedFilters(props.filters))); 55 + initialized = true; 56 + }); 43 57 44 58 function commitTag(rawValue: string) { 45 59 const nextTag = normalizeTagToken(rawValue); ··· 68 82 {(text) => <p class="m-0 text-xs text-on-surface-variant/80">{text()}</p>} 69 83 </Show> 70 84 </div> 71 - <SearchSortTabs 72 - disabled={props.disabled} 73 - sort={props.filters.sort} 74 - onChange={(sort) => props.onChange({ sort })} /> 85 + <div class="flex flex-wrap items-center justify-end gap-2"> 86 + <SearchSortTabs 87 + disabled={props.disabled} 88 + sort={props.filters.sort} 89 + onChange={(sort) => props.onChange({ sort })} /> 90 + <Show when={props.collapsible}> 91 + <FiltersToggle expanded={expanded()} onToggle={() => setExpanded((current) => !current)} /> 92 + </Show> 93 + </div> 75 94 </div> 76 95 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> 96 + <Show when={props.collapsible && !expanded()}> 97 + <p class="m-0 rounded-2xl bg-white/[0.035] px-3 py-2 text-sm text-on-surface-variant">{summary()}</p> 98 + </Show> 111 99 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 100 + <Show when={!props.collapsible || expanded()}> 101 + <div class="grid gap-3 xl:grid-cols-2"> 102 + <FilterField 103 + disabled={props.disabled} 104 + icon="user" 105 + label="Author" 106 + placeholder="alice.test or did:plc:..." 107 + type="text" 108 + value={props.filters.author} 109 + onInput={(value) => props.onChange({ author: value })} /> 110 + <FilterField 111 + disabled={props.disabled} 112 + icon="at" 113 + label="Mentions" 114 + placeholder="bob.test or did:plc:..." 134 115 type="text" 135 - value={pendingTag()} 116 + value={props.filters.mentions} 117 + onInput={(value) => props.onChange({ mentions: value })} /> 118 + <FilterField 119 + disabled={props.disabled} 120 + icon="timeline" 121 + label="Since" 122 + placeholder="" 123 + type="date" 124 + value={props.filters.since} 125 + onInput={(value) => props.onChange({ since: value })} /> 126 + <FilterField 136 127 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 - } 128 + icon="timeline" 129 + label="Until" 130 + placeholder="" 131 + type="date" 132 + value={props.filters.until} 133 + onInput={(value) => props.onChange({ until: value })} /> 134 + </div> 135 + <div class="grid gap-2"> 136 + <div class="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.12em] text-on-surface-variant"> 137 + <Icon kind="hashtag" class="text-sm" /> 138 + <span>Tags</span> 139 + </div> 140 + <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)]"> 141 + <For each={props.filters.tags}> 142 + {(tag) => ( 143 + <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)]"> 144 + <span>#{tag}</span> 145 + <button 146 + type="button" 147 + disabled={props.disabled} 148 + class="inline-flex border-0 bg-transparent p-0 text-primary/80 transition hover:text-primary disabled:cursor-not-allowed" 149 + aria-label={`Remove #${tag}`} 150 + onClick={() => removeTag(tag)}> 151 + <Icon kind="close" class="text-xs" /> 152 + </button> 153 + </span> 154 + )} 155 + </For> 156 + <input 157 + type="text" 158 + value={pendingTag()} 159 + disabled={props.disabled} 160 + placeholder={props.filters.tags.length > 0 ? "Add another tag" : "Add a tag and press Enter"} 161 + 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" 162 + onBlur={(event) => commitTag(event.currentTarget.value)} 163 + onInput={(event) => setPendingTag(event.currentTarget.value)} 164 + onKeyDown={(event) => { 165 + if (event.key === "Enter" || event.key === ",") { 166 + event.preventDefault(); 167 + commitTag(event.currentTarget.value); 168 + return; 169 + } 147 170 148 - if (event.key === "Backspace" && !event.currentTarget.value && props.filters.tags.length > 0) { 149 - removeTag(props.filters.tags.at(-1) ?? ""); 150 - } 151 - }} /> 171 + if (event.key === "Backspace" && !event.currentTarget.value && props.filters.tags.length > 0) { 172 + removeTag(props.filters.tags.at(-1) ?? ""); 173 + } 174 + }} /> 175 + </div> 152 176 </div> 153 - </div> 177 + </Show> 154 178 </section> 155 179 ); 180 + } 181 + 182 + function FiltersToggle(props: { expanded: boolean; onToggle: () => void }) { 183 + return ( 184 + <button 185 + type="button" 186 + aria-expanded={props.expanded} 187 + class="inline-flex items-center gap-2 rounded-full border-0 bg-white/5 px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-white/9" 188 + onClick={() => props.onToggle()}> 189 + <span>{props.expanded ? "Hide filters" : "Show filters"}</span> 190 + <ArrowIcon direction={props.expanded ? "up" : "down"} class="text-base text-on-surface-variant" /> 191 + </button> 192 + ); 193 + } 194 + 195 + function hasAdvancedFilters(filters: PostSearchFilters) { 196 + return !!(filters.author || filters.mentions || filters.since || filters.until || filters.tags.length > 0); 197 + } 198 + 199 + function summarizeFilters(filters: PostSearchFilters) { 200 + const segments: string[] = []; 201 + 202 + if (filters.author) { 203 + segments.push(`Author ${filters.author}`); 204 + } 205 + 206 + if (filters.mentions) { 207 + segments.push(`Mentions ${filters.mentions}`); 208 + } 209 + 210 + if (filters.since || filters.until) { 211 + const start = filters.since || "any time"; 212 + const end = filters.until || "now"; 213 + segments.push(`${start} to ${end}`); 214 + } 215 + 216 + if (filters.tags.length > 0) { 217 + segments.push(filters.tags.length === 1 ? "1 tag" : `${filters.tags.length} tags`); 218 + } 219 + 220 + return segments.length > 0 ? segments.join(" • ") : "No author, mention, date, or tag filters applied."; 156 221 } 157 222 158 223 function FilterField(
+5 -4
src/components/search/SearchEmptyState.tsx
··· 1 1 import { Icon } from "$/components/shared/Icon"; 2 2 import { Match, Show, Switch } from "solid-js"; 3 + import type { EmptyStateReason } from "./types"; 3 4 4 5 type SearchEmptyStateScope = "local" | "network" | "profiles"; 5 6 6 - type SearchEmptyStateProps = { reason: "error" | "initial" | "no-results" | "no-sync"; scope?: SearchEmptyStateScope }; 7 + type SearchEmptyStateProps = { reason: EmptyStateReason | "no-sync"; scope?: SearchEmptyStateScope }; 7 8 8 9 export function SearchEmptyState(props: SearchEmptyStateProps) { 9 10 return ( ··· 14 15 ); 15 16 } 16 17 17 - function EmptyStateVisual(props: { reason: string }) { 18 + function EmptyStateVisual(props: { reason: EmptyStateReason | "no-sync" }) { 18 19 return ( 19 20 <Show when={props.reason === "no-sync"} fallback={<EmptyStateIcon />}> 20 21 <NoSyncIllustration /> ··· 34 35 return ( 35 36 <div 36 37 data-testid="no-sync-illustration" 37 - class="relative mx-auto mb-6 h-40 w-full max-w-xs overflow-hidden rounded-[2rem] bg-white/[0.025] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 38 + class="relative mx-auto mb-6 h-40 w-full max-w-xs overflow-hidden rounded-4xl bg-white/2.5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 38 39 <div class="absolute inset-x-6 top-5 h-16 rounded-[1.25rem] bg-primary/10 blur-2xl" /> 39 40 <div class="absolute left-5 top-7 w-26 rounded-[1.4rem] bg-surface-container p-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 40 41 <div class="mb-2 flex items-center gap-2"> ··· 73 74 ); 74 75 } 75 76 76 - function EmptyStateContent(props: { reason: string; scope: SearchEmptyStateScope }) { 77 + function EmptyStateContent(props: { reason: EmptyStateReason | "no-sync"; scope: SearchEmptyStateScope }) { 77 78 return ( 78 79 <Switch> 79 80 <Match when={props.reason === "initial"}>
+24
src/components/search/SearchPanel.test.tsx
··· 76 76 77 77 expect(screen.getByPlaceholderText("Search public posts across Bluesky...")).toBeInTheDocument(); 78 78 expect(screen.getByText("Network Filters")).toBeInTheDocument(); 79 + expect(screen.getByRole("button", { name: /show filters/i })).toBeInTheDocument(); 79 80 expect(screen.getByRole("tab", { name: /top/i })).toHaveAttribute("aria-selected", "true"); 81 + expect(screen.getByRole("button", { name: /hybrid/i })).toBeDisabled(); 80 82 expect(screen.getByRole("link", { name: /open settings/i })).toHaveAttribute("href", "#/settings"); 83 + }); 84 + 85 + it("expands and collapses the network filter details", async () => { 86 + renderSearchPanel(); 87 + 88 + expect(screen.queryByLabelText("Author")).not.toBeInTheDocument(); 89 + 90 + fireEvent.click(screen.getByRole("button", { name: /show filters/i })); 91 + expect(screen.getByLabelText("Author")).toBeInTheDocument(); 92 + 93 + fireEvent.click(screen.getByRole("button", { name: /hide filters/i })); 94 + expect(screen.queryByLabelText("Author")).not.toBeInTheDocument(); 81 95 }); 82 96 83 97 it("performs network search with URL-synced filters", async () => { ··· 154 168 expect(globalThis.location.hash).toContain("mode=keyword"); 155 169 expect(searchPostsMock).toHaveBeenCalledWith("test query", "keyword", 50); 156 170 expect(screen.getByText("Liked")).toBeInTheDocument(); 171 + }); 172 + 173 + it("shows a network-only notice outside network mode", async () => { 174 + renderSearchPanel("#/search?author=alice.test"); 175 + 176 + fireEvent.click(screen.getByRole("button", { name: /keyword/i })); 177 + await flushRouter(); 178 + 179 + expect(screen.queryByRole("button", { name: /show filters/i })).not.toBeInTheDocument(); 180 + expect(screen.getByText(/network filters only apply in posts when network mode is active/i)).toBeInTheDocument(); 157 181 }); 158 182 159 183 it("cycles through modes with Tab key", async () => {
+72 -48
src/components/search/SearchPanel.tsx
··· 6 6 import { useAppPreferences } from "$/contexts/app-preferences"; 7 7 import { useAppSession } from "$/contexts/app-session"; 8 8 import { 9 + type ActorResult, 9 10 type ActorSearchResult, 10 11 getSyncStatus, 11 12 type LocalPostResult, ··· 41 42 import { SearchEmptyState } from "./SearchEmptyState"; 42 43 import { SearchQueryInput } from "./SearchQueryInput"; 43 44 import { SyncStatusPanel } from "./SyncStatusPanel"; 45 + import type { EmptyStateReason } from "./types"; 44 46 45 47 const MODES: SearchMode[] = ["network", "keyword", "semantic", "hybrid"]; 46 48 const SEARCH_TABS: SearchTab[] = ["posts", "profiles"]; ··· 526 528 <SearchHint tab={props.tab} /> 527 529 </div> 528 530 529 - <PostSearchFiltersRow 530 - disabled={!props.filtersEnabled} 531 - filters={props.filters} 532 - helperText={props.filtersEnabled 533 - ? "Filters update the URL and apply to network post search." 534 - : "Filters stay in the URL, but only apply when Posts + Network search is active."} 535 - onChange={props.onFilterChange} /> 531 + <Show when={props.filtersEnabled} fallback={<NetworkFiltersNotice tab={props.tab} />}> 532 + <PostSearchFiltersRow 533 + collapsible 534 + defaultExpanded={hasAdvancedNetworkFilters(props.filters)} 535 + filters={props.filters} 536 + helperText="Filters update the URL and apply to network post search." 537 + onChange={props.onFilterChange} /> 538 + </Show> 536 539 537 540 <ResultMeta 538 541 hasSearched={props.hasSearched} ··· 542 545 resultCount={props.resultCount} 543 546 totalIndexedPosts={props.totalIndexedPosts} /> 544 547 </header> 548 + ); 549 + } 550 + 551 + function NetworkFiltersNotice(props: { tab: SearchTab }) { 552 + return ( 553 + <section class="grid gap-2 rounded-3xl bg-black/20 px-4 py-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]"> 554 + <div class="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.12em] text-on-surface-variant"> 555 + <Icon kind="search" class="text-sm text-primary" /> 556 + <span>Network Filters</span> 557 + </div> 558 + <p class="m-0 text-sm text-on-surface-variant"> 559 + <Show 560 + when={props.tab === "posts"} 561 + fallback="Network filters only apply to post search. Switch back to Posts and choose Network to use author, mention, date, or tag filters."> 562 + Network filters only apply in Posts when Network mode is active. Your current filter values stay in the URL 563 + and will reapply when you switch back. 564 + </Show> 565 + </p> 566 + </section> 545 567 ); 546 568 } 547 569 ··· 655 677 656 678 <For each={MODES}> 657 679 {(searchMode) => { 658 - const disabled = searchMode === "semantic" && !props.semanticEnabled; 680 + const disabled = (searchMode === "semantic" || searchMode === "hybrid") && !props.semanticEnabled; 659 681 return ( 660 682 <button 661 683 type="button" ··· 680 702 ); 681 703 } 682 704 683 - function SearchViewport( 684 - props: { 685 - actorResults: ActorSearchResult | null; 686 - error: string | null; 687 - hasLocalPosts: boolean; 688 - hasSearched: boolean; 689 - isActorTab: boolean; 690 - isLocalMode: boolean; 691 - loading: boolean; 692 - localResults: LocalPostResult[]; 693 - networkResults: NetworkSearchResult | null; 694 - onOpenActor: (actor: Pick<ProfileViewBasic, "did" | "handle">) => void; 695 - onOpenThread: (uri: string) => void; 696 - query: string; 697 - }, 698 - ) { 705 + type SearchViewportProps = { 706 + actorResults: ActorSearchResult | null; 707 + error: string | null; 708 + hasLocalPosts: boolean; 709 + hasSearched: boolean; 710 + isActorTab: boolean; 711 + isLocalMode: boolean; 712 + loading: boolean; 713 + localResults: LocalPostResult[]; 714 + networkResults: NetworkSearchResult | null; 715 + onOpenActor: (actor: Pick<ProfileViewBasic, "did" | "handle">) => void; 716 + onOpenThread: (uri: string) => void; 717 + query: string; 718 + }; 719 + 720 + function SearchViewport(props: SearchViewportProps) { 699 721 return ( 700 722 <div class="min-h-0 overflow-y-auto px-3 pb-3"> 701 723 <Show when={props.loading} fallback={<SearchState {...props} />}> ··· 705 727 ); 706 728 } 707 729 708 - function SearchState( 709 - props: { 710 - actorResults: ActorSearchResult | null; 711 - error: string | null; 712 - hasLocalPosts: boolean; 713 - hasSearched: boolean; 714 - isActorTab: boolean; 715 - isLocalMode: boolean; 716 - localResults: LocalPostResult[]; 717 - networkResults: NetworkSearchResult | null; 718 - onOpenActor: (actor: Pick<ProfileViewBasic, "did" | "handle">) => void; 719 - onOpenThread: (uri: string) => void; 720 - query: string; 721 - }, 722 - ) { 730 + type SearchStateProps = { 731 + actorResults: ActorSearchResult | null; 732 + error: string | null; 733 + hasLocalPosts: boolean; 734 + hasSearched: boolean; 735 + isActorTab: boolean; 736 + isLocalMode: boolean; 737 + localResults: LocalPostResult[]; 738 + networkResults: NetworkSearchResult | null; 739 + onOpenActor: (actor: Pick<ProfileViewBasic, "did" | "handle">) => void; 740 + onOpenThread: (uri: string) => void; 741 + query: string; 742 + }; 743 + 744 + function SearchState(props: SearchStateProps) { 723 745 return ( 724 746 <Presence> 725 747 <Switch> ··· 767 789 ); 768 790 } 769 791 770 - function EmptyStateView( 771 - props: { reason: "error" | "initial" | "no-results" | "no-sync"; scope: "local" | "network" | "profiles" }, 772 - ) { 792 + function EmptyStateView(props: { reason: EmptyStateReason | "no-sync"; scope: "local" | "network" | "profiles" }) { 773 793 return ( 774 794 <Motion.div 775 795 class="grid place-items-center px-6 py-16" ··· 809 829 ); 810 830 } 811 831 812 - function ActorResultCard( 813 - props: { 814 - actor: ActorSearchResult["actors"][number]; 815 - onOpenActor: (actor: Pick<ProfileViewBasic, "did" | "handle">) => void; 816 - }, 817 - ) { 832 + type ActorResultCardProps = { 833 + actor: ActorResult; 834 + onOpenActor: (actor: Pick<ProfileViewBasic, "did" | "handle">) => void; 835 + }; 836 + 837 + function ActorResultCard(props: ActorResultCardProps) { 818 838 return ( 819 839 <button 820 840 type="button" ··· 919 939 </a> 920 940 </section> 921 941 ); 942 + } 943 + 944 + function hasAdvancedNetworkFilters(filters: PostSearchFilters) { 945 + return !!(filters.author || filters.mentions || filters.since || filters.until || filters.tags.length > 0); 922 946 } 923 947 924 948 function buildNetworkSearchParams(state: ReturnType<typeof parseSearchRouteState>): NetworkSearchParams {
+1
src/components/search/types.ts
··· 1 + export type EmptyStateReason = "error" | "initial" | "no-results";
+2 -2
src/lib/api/search.ts
··· 17 17 cursor?: string | null; 18 18 }; 19 19 20 - type TActor = { 20 + export type ActorResult = { 21 21 did: string; 22 22 handle: string; 23 23 displayName?: string | null; ··· 25 25 description?: string | null; 26 26 }; 27 27 28 - export type ActorSearchResult = { cursor?: string | null; actors: TActor[] }; 28 + export type ActorSearchResult = { cursor?: string | null; actors: ActorResult[] }; 29 29 30 30 type TStarterPack = { 31 31 uri: string;