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.

refactor: restructure, FeedComposer, SearchPanel & SearchQueryInput component

+947 -715
+10 -13
src/components/feeds/ComposerWindow.tsx
··· 36 36 return ( 37 37 <div class="min-h-screen bg-[radial-gradient(circle_at_top,rgba(125,175,255,0.12),transparent_32%),#000]"> 38 38 <ComposerSurface 39 - activeAvatar={session.activeAvatar} 40 - activeHandle={session.activeHandle} 39 + handlers={{ 40 + onApplySuggestion: () => {}, 41 + onClearQuote: () => {}, 42 + onClearReply: () => {}, 43 + onClose: () => void closeWindow(), 44 + onSubmit: () => void submitPost(), 45 + onTextChange: setText, 46 + }} 47 + identity={{ activeAvatar: session.activeAvatar, activeHandle: session.activeHandle }} 41 48 layout="window" 42 - pending={pending()} 43 - quoteTarget={null} 44 - replyTarget={null} 45 - suggestions={[]} 46 - text={text()} 47 - onApplySuggestion={() => {}} 48 - onClearQuote={() => {}} 49 - onClearReply={() => {}} 50 - onClose={() => void closeWindow()} 51 - onSubmit={() => void submitPost()} 52 - onTextChange={setText} /> 49 + state={{ pending: pending(), quoteTarget: null, replyTarget: null, suggestions: [], text: text() }} /> 53 50 </div> 54 51 ); 55 52 }
+62 -66
src/components/feeds/FeedComposer.tsx
··· 6 6 import { Motion, Presence } from "solid-motionone"; 7 7 import type { AutosaveStatus } from "./types"; 8 8 9 - type ComposerSuggestion = { label: string; type: "handle" | "hashtag" }; 9 + export type ComposerSuggestion = { label: string; type: "handle" | "hashtag" }; 10 + export type FeedComposerIdentity = { activeAvatar?: string | null; activeHandle: string | null }; 11 + export type FeedComposerState = { 12 + autosaveStatus?: AutosaveStatus; 13 + draftCount?: number; 14 + open: boolean; 15 + pending: boolean; 16 + quoteTarget: PostView | null; 17 + replyTarget: PostView | null; 18 + suggestions: ComposerSuggestion[]; 19 + text: string; 20 + }; 21 + export type FeedComposerHandlers = { 22 + onApplySuggestion: (value: string) => void; 23 + onClearQuote: () => void; 24 + onClearReply: () => void; 25 + onClose: () => void; 26 + onOpenDrafts?: () => void; 27 + onSaveDraft?: () => void; 28 + onSubmit: () => void; 29 + onTextChange: (value: string) => void; 30 + }; 10 31 11 32 export function ComposerLauncher(props: { activeAvatar?: string | null; activeHandle: string; onCompose: () => void }) { 12 33 return ( ··· 30 51 ); 31 52 } 32 53 33 - type FeedComposerProps = { 34 - activeAvatar?: string | null; 35 - activeHandle: string | null; 36 - autosaveStatus?: AutosaveStatus; 37 - draftCount?: number; 38 - open: boolean; 39 - pending: boolean; 40 - quoteTarget: PostView | null; 41 - replyTarget: PostView | null; 42 - suggestions: ComposerSuggestion[]; 43 - text: string; 44 - onApplySuggestion: (value: string) => void; 45 - onClearQuote: () => void; 46 - onClearReply: () => void; 47 - onClose: () => void; 48 - onOpenDrafts?: () => void; 49 - onSaveDraft?: () => void; 50 - onSubmit: () => void; 51 - onTextChange: (value: string) => void; 52 - }; 54 + type FeedComposerProps = { handlers: FeedComposerHandlers; identity: FeedComposerIdentity; state: FeedComposerState }; 55 + type ComposerSurfaceState = Omit<FeedComposerState, "open">; 53 56 54 - type ComposerSurfaceProps = Omit<FeedComposerProps, "open"> & { 57 + type ComposerSurfaceProps = { 58 + handlers: FeedComposerHandlers; 59 + identity: FeedComposerIdentity; 55 60 layout?: "dialog" | "window"; 56 - onOpenDrafts?: () => void; 57 - onSaveDraft?: () => void; 61 + state: ComposerSurfaceState; 58 62 }; 59 63 60 64 export function FeedComposer(props: FeedComposerProps) { 61 65 return ( 62 66 <Presence> 63 - <Show when={props.open}> 67 + <Show when={props.state.open}> 64 68 <div class="fixed inset-0 z-50"> 65 69 <Motion.button 66 70 class="absolute inset-0 h-full w-full border-0 bg-black/80 backdrop-blur-[20px]" ··· 69 73 exit={{ opacity: 0 }} 70 74 transition={{ duration: 0.18 }} 71 75 type="button" 72 - onClick={() => props.onClose()} /> 76 + onClick={() => props.handlers.onClose()} /> 73 77 74 78 <ComposerSurface 75 - activeAvatar={props.activeAvatar} 76 - activeHandle={props.activeHandle} 77 - autosaveStatus={props.autosaveStatus} 78 - draftCount={props.draftCount} 79 + handlers={props.handlers} 80 + identity={props.identity} 79 81 layout="dialog" 80 - pending={props.pending} 81 - quoteTarget={props.quoteTarget} 82 - replyTarget={props.replyTarget} 83 - suggestions={props.suggestions} 84 - text={props.text} 85 - onApplySuggestion={props.onApplySuggestion} 86 - onClearQuote={props.onClearQuote} 87 - onClearReply={props.onClearReply} 88 - onClose={props.onClose} 89 - onOpenDrafts={props.onOpenDrafts} 90 - onSaveDraft={props.onSaveDraft} 91 - onSubmit={props.onSubmit} 92 - onTextChange={props.onTextChange} /> 82 + state={{ 83 + autosaveStatus: props.state.autosaveStatus, 84 + draftCount: props.state.draftCount, 85 + pending: props.state.pending, 86 + quoteTarget: props.state.quoteTarget, 87 + replyTarget: props.state.replyTarget, 88 + suggestions: props.state.suggestions, 89 + text: props.state.text, 90 + }} /> 93 91 </div> 94 92 </Show> 95 93 </Presence> ··· 97 95 } 98 96 99 97 export function ComposerSurface(props: ComposerSurfaceProps) { 100 - const count = createMemo(() => [...props.text].length); 98 + const count = createMemo(() => [...props.state.text].length); 101 99 const progress = createMemo(() => Math.min(100, (count() / 300) * 100)); 102 100 103 101 return ( ··· 109 107 exit={{ opacity: 0, y: 30 }} 110 108 transition={{ duration: 0.24, easing: [0.22, 1, 0.36, 1] }}> 111 109 <ComposerHeader 112 - activeAvatar={props.activeAvatar} 113 - activeHandle={props.activeHandle} 114 - draftCount={props.draftCount} 115 - pending={props.pending} 116 - quoteTarget={props.quoteTarget} 117 - text={props.text} 118 - onClose={props.onClose} 119 - onOpenDrafts={props.onOpenDrafts} 120 - onSaveDraft={props.onSaveDraft} 121 - onSubmit={props.onSubmit} /> 110 + activeHandle={props.identity.activeHandle} 111 + draftCount={props.state.draftCount} 112 + pending={props.state.pending} 113 + quoteTarget={props.state.quoteTarget} 114 + text={props.state.text} 115 + onClose={props.handlers.onClose} 116 + onOpenDrafts={props.handlers.onOpenDrafts} 117 + onSaveDraft={props.handlers.onSaveDraft} 118 + onSubmit={props.handlers.onSubmit} /> 122 119 <ComposerBody 123 - activeAvatar={props.activeAvatar} 124 - activeHandle={props.activeHandle} 125 - quoteTarget={props.quoteTarget} 126 - replyTarget={props.replyTarget} 127 - suggestions={props.suggestions} 128 - text={props.text} 129 - onApplySuggestion={props.onApplySuggestion} 130 - onClearQuote={props.onClearQuote} 131 - onClearReply={props.onClearReply} 132 - onTextChange={props.onTextChange} /> 133 - <ComposerFooter autosaveStatus={props.autosaveStatus ?? "idle"} count={count()} progress={progress()} /> 120 + activeAvatar={props.identity.activeAvatar} 121 + activeHandle={props.identity.activeHandle} 122 + quoteTarget={props.state.quoteTarget} 123 + replyTarget={props.state.replyTarget} 124 + suggestions={props.state.suggestions} 125 + text={props.state.text} 126 + onApplySuggestion={props.handlers.onApplySuggestion} 127 + onClearQuote={props.handlers.onClearQuote} 128 + onClearReply={props.handlers.onClearReply} 129 + onTextChange={props.handlers.onTextChange} /> 130 + <ComposerFooter autosaveStatus={props.state.autosaveStatus ?? "idle"} count={count()} progress={progress()} /> 134 131 </Motion.section> 135 132 </div> 136 133 ); ··· 160 157 161 158 function ComposerHeader( 162 159 props: { 163 - activeAvatar?: string | null; 164 160 activeHandle: string | null; 165 161 draftCount?: number; 166 162 pending: boolean;
+21 -18
src/components/feeds/FeedWorkspace.tsx
··· 51 51 onUnpinFeed={controller.unpinFeed} /> 52 52 53 53 <FeedComposer 54 - activeAvatar={session.activeAvatar} 55 - activeHandle={session.activeHandle} 56 - autosaveStatus={controller.workspace.composer.autosaveStatus} 57 - draftCount={controller.workspace.draftCount} 58 - open={controller.workspace.composer.open} 59 - pending={controller.workspace.composer.pending} 60 - quoteTarget={controller.workspace.composer.quoteTarget} 61 - replyTarget={controller.workspace.composer.replyTarget} 62 - suggestions={controller.composerSuggestions()} 63 - text={controller.workspace.composer.text} 64 - onApplySuggestion={controller.applySuggestion} 65 - onClearQuote={controller.clearQuoteComposer} 66 - onClearReply={controller.clearReplyComposer} 67 - onClose={() => void controller.resetComposer()} 68 - onOpenDrafts={controller.openDraftsList} 69 - onSaveDraft={() => void controller.saveAndCloseComposer()} 70 - onSubmit={() => void controller.submitPost()} 71 - onTextChange={controller.setComposerText} /> 54 + handlers={{ 55 + onApplySuggestion: controller.applySuggestion, 56 + onClearQuote: controller.clearQuoteComposer, 57 + onClearReply: controller.clearReplyComposer, 58 + onClose: () => void controller.resetComposer(), 59 + onOpenDrafts: controller.openDraftsList, 60 + onSaveDraft: () => void controller.saveAndCloseComposer(), 61 + onSubmit: () => void controller.submitPost(), 62 + onTextChange: controller.setComposerText, 63 + }} 64 + identity={{ activeAvatar: session.activeAvatar, activeHandle: session.activeHandle }} 65 + state={{ 66 + autosaveStatus: controller.workspace.composer.autosaveStatus, 67 + draftCount: controller.workspace.draftCount, 68 + open: controller.workspace.composer.open, 69 + pending: controller.workspace.composer.pending, 70 + quoteTarget: controller.workspace.composer.quoteTarget, 71 + replyTarget: controller.workspace.composer.replyTarget, 72 + suggestions: controller.composerSuggestions(), 73 + text: controller.workspace.composer.text, 74 + }} /> 72 75 73 76 <DraftsList 74 77 accountDid={activeSession().did}
+34 -20
src/components/feeds/tests/FeedComposer.test.tsx
··· 8 8 ); 9 9 10 10 const BASE_PROPS = { 11 - activeHandle: "alice.test", 12 - open: true, 13 - pending: false, 14 - quoteTarget: null, 15 - replyTarget: null, 16 - suggestions: [], 17 - text: "", 18 - onApplySuggestion: () => {}, 19 - onClearQuote: () => {}, 20 - onClearReply: () => {}, 21 - onClose: () => {}, 22 - onSubmit: () => {}, 23 - onTextChange: () => {}, 11 + handlers: { 12 + onApplySuggestion: () => {}, 13 + onClearQuote: () => {}, 14 + onClearReply: () => {}, 15 + onClose: () => {}, 16 + onSubmit: () => {}, 17 + onTextChange: () => {}, 18 + }, 19 + identity: { activeHandle: "alice.test" }, 20 + state: { open: true, pending: false, quoteTarget: null, replyTarget: null, suggestions: [], text: "" }, 24 21 }; 25 22 26 23 describe("FeedComposer", () => { 27 24 it("renders a contained scroll region for typeahead suggestions", () => { 28 - render(() => <FeedComposer {...BASE_PROPS} suggestions={suggestions} text="@ha" />); 25 + render(() => <FeedComposer {...BASE_PROPS} state={{ ...BASE_PROPS.state, suggestions, text: "@ha" }} />); 29 26 30 27 expect(screen.getByText("@handle-12.test")).toBeInTheDocument(); 31 28 expect(screen.queryByText("@handle-13.test")).not.toBeInTheDocument(); ··· 36 33 }); 37 34 38 35 it("shows 'Saving...' autosave indicator when status is saving", () => { 39 - render(() => <FeedComposer {...BASE_PROPS} autosaveStatus="saving" text="hello" />); 36 + render(() => ( 37 + <FeedComposer {...BASE_PROPS} state={{ ...BASE_PROPS.state, autosaveStatus: "saving", text: "hello" }} /> 38 + )); 40 39 41 40 expect(screen.getByText("Saving...")).toBeInTheDocument(); 42 41 }); 43 42 44 43 it("shows 'Saved' autosave indicator when status is saved", () => { 45 - render(() => <FeedComposer {...BASE_PROPS} autosaveStatus="saved" text="hello" />); 44 + render(() => ( 45 + <FeedComposer {...BASE_PROPS} state={{ ...BASE_PROPS.state, autosaveStatus: "saved", text: "hello" }} /> 46 + )); 46 47 47 48 expect(screen.getByText("Saved")).toBeInTheDocument(); 48 49 }); 49 50 50 51 it("does not show autosave indicator when status is idle", () => { 51 - render(() => <FeedComposer {...BASE_PROPS} autosaveStatus="idle" text="hello" />); 52 + render(() => <FeedComposer 53 + {...BASE_PROPS} 54 + state={{ ...BASE_PROPS.state, autosaveStatus: "idle", text: "hello" }} /> 55 + ); 52 56 53 57 expect(screen.queryByText("Saving...")).not.toBeInTheDocument(); 54 58 expect(screen.queryByText("Saved")).not.toBeInTheDocument(); 55 59 }); 56 60 57 61 it("shows 'Save' button when onSaveDraft is provided", () => { 58 - render(() => <FeedComposer {...BASE_PROPS} onSaveDraft={() => {}} />); 62 + render(() => <FeedComposer {...BASE_PROPS} handlers={{ ...BASE_PROPS.handlers, onSaveDraft: () => {} }} />); 59 63 60 64 expect(screen.getByTitle("Save as draft (Ctrl+S)")).toBeInTheDocument(); 61 65 }); ··· 67 71 }); 68 72 69 73 it("shows draft count badge on drafts button when draftCount is positive", () => { 70 - render(() => <FeedComposer {...BASE_PROPS} draftCount={3} onOpenDrafts={() => {}} />); 74 + render(() => ( 75 + <FeedComposer 76 + {...BASE_PROPS} 77 + handlers={{ ...BASE_PROPS.handlers, onOpenDrafts: () => {} }} 78 + state={{ ...BASE_PROPS.state, draftCount: 3 }} /> 79 + )); 71 80 72 81 expect(screen.getByText("3")).toBeInTheDocument(); 73 82 }); 74 83 75 84 it("does not show draft count badge when draftCount is zero", () => { 76 - render(() => <FeedComposer {...BASE_PROPS} draftCount={0} onOpenDrafts={() => {}} />); 85 + render(() => ( 86 + <FeedComposer 87 + {...BASE_PROPS} 88 + handlers={{ ...BASE_PROPS.handlers, onOpenDrafts: () => {} }} 89 + state={{ ...BASE_PROPS.state, draftCount: 0 }} /> 90 + )); 77 91 78 92 const draftsButton = screen.getByTitle("Drafts (Ctrl+D)"); 79 93 expect(draftsButton).toBeInTheDocument();
+8 -8
src/components/saved/SavedPostsPanel.tsx
··· 474 474 </div> 475 475 476 476 <SearchQueryInput 477 - error={null} 478 - inputRef={props.queryRef} 479 - loading={props.searchLoading} 480 - placeholder={props.activeTab === "bookmark" ? "Search saved posts..." : "Search liked posts..."} 481 - query={props.query} 482 - onClear={props.onSearchClear} 483 - onKeyDown={props.onSearchKeyDown} 484 - onQueryChange={props.onQueryChange} /> 477 + actions={{ onClear: props.onSearchClear, onKeyDown: props.onSearchKeyDown, onQueryChange: props.onQueryChange }} 478 + refs={{ inputRef: props.queryRef }} 479 + state={{ 480 + error: null, 481 + loading: props.searchLoading, 482 + placeholder: props.activeTab === "bookmark" ? "Search saved posts..." : "Search liked posts...", 483 + query: props.query, 484 + }} /> 485 485 486 486 <div class="flex items-center justify-between gap-4"> 487 487 <nav class="flex flex-wrap gap-2" aria-label="Saved post tabs">
+342 -551
src/components/search/SearchPanel.tsx
··· 1 - import { ActorSuggestionList, getActorSuggestionHeadline, useActorSuggestions } from "$/components/actors/actor-search"; 1 + import { ActorSuggestionList, getActorSuggestionHeadline } from "$/components/actors/actor-search"; 2 2 import { AvatarBadge } from "$/components/AvatarBadge"; 3 3 import { PostCard } from "$/components/feeds/PostCard"; 4 - import { usePostNavigation } from "$/components/posts/usePostNavigation"; 5 4 import { Icon, SearchModeIcon } from "$/components/shared/Icon"; 6 - import { useAppPreferences } from "$/contexts/app-preferences"; 7 - import { useAppSession } from "$/contexts/app-session"; 8 - import { SearchController } from "$/lib/api/search"; 9 5 import type { 10 6 ActorResult, 11 7 ActorSearchResult, 12 8 LocalPostResult, 13 - NetworkSearchParams, 14 9 NetworkSearchResult, 15 10 SearchMode, 16 - SyncStatus, 17 11 } from "$/lib/api/types/search"; 18 - import { formatRelativeTime } from "$/lib/feeds"; 19 - import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 20 - import { buildSearchRoute, parseSearchRouteState, toLocalDayStartIso, toLocalDayUntilIso } from "$/lib/search-routes"; 21 12 import type { PostSearchFilters, SearchTab } from "$/lib/search-routes"; 22 13 import type { ProfileViewBasic } from "$/lib/types"; 23 - import { normalizeError } from "$/lib/utils/text"; 24 - import { useLocation, useNavigate } from "@solidjs/router"; 25 - import * as logger from "@tauri-apps/plugin-log"; 26 - import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; 27 - import { createStore } from "solid-js/store"; 14 + import { createContext, createEffect, createMemo, createSignal, For, Match, Show, Switch, useContext } from "solid-js"; 28 15 import { Motion, Presence } from "solid-motionone"; 29 16 import { PostCount } from "../shared/PostCount"; 30 17 import { EmbeddingsSettings } from "./EmbeddingsSettings"; ··· 34 21 import { SearchQueryInput } from "./SearchQueryInput"; 35 22 import { SyncStatusPanel } from "./SyncStatusPanel"; 36 23 import type { EmptyStateReason } from "./types"; 24 + import { useSearchController } from "./useSearchController"; 37 25 38 26 const MODES: SearchMode[] = ["network", "keyword", "semantic", "hybrid"]; 39 27 40 28 const SEARCH_TABS: SearchTab[] = ["posts", "profiles"]; 41 29 42 - type SearchPanelState = { 43 - actorResults: ActorSearchResult | null; 30 + type SearchPanelProps = { embedded?: boolean; initialMode?: SearchMode; initialQuery?: string }; 31 + 32 + type SearchPanelContextValue = ReturnType<typeof useSearchController>; 33 + 34 + const SearchPanelContext = createContext<SearchPanelContextValue>(); 35 + 36 + type SearchHeaderState = { 44 37 error: string | null; 38 + filters: PostSearchFilters; 39 + filtersEnabled: boolean; 45 40 hasSearched: boolean; 41 + lastSync: string | null; 46 42 loading: boolean; 47 - networkResults: NetworkSearchResult | null; 43 + mode: SearchMode; 44 + query: string; 48 45 resultCount: number; 49 - results: LocalPostResult[]; 50 - syncStatus: SyncStatus[]; 46 + semanticEnabled: boolean; 47 + tab: SearchTab; 48 + totalIndexedPosts: number; 51 49 }; 52 50 53 - type SearchPanelProps = { embedded?: boolean; initialMode?: SearchMode; initialQuery?: string }; 51 + type SearchHeaderSuggestions = { activeIndex: number; items: ProfileViewBasic[]; open: boolean }; 52 + 53 + type SearchHeaderActions = { 54 + onActorSuggestionFocus: () => void; 55 + onActorSuggestionSelect: (suggestion: ProfileViewBasic) => void; 56 + onClear: () => void; 57 + onFilterChange: (next: Partial<PostSearchFilters>) => void; 58 + onKeyDown: (event: KeyboardEvent) => void; 59 + onModeChange: (mode: SearchMode) => void; 60 + onQueryChange: (value: string) => void; 61 + onTabChange: (tab: SearchTab) => void; 62 + }; 63 + 64 + type SearchHeaderRefs = { actorContainerRef: (el: HTMLDivElement) => void; inputRef: (el: HTMLInputElement) => void }; 65 + 66 + type SearchViewState = { 67 + actorResults: ActorSearchResult | null; 68 + error: string | null; 69 + hasLocalPosts: boolean; 70 + hasSearched: boolean; 71 + isActorTab: boolean; 72 + isLocalMode: boolean; 73 + localResults: LocalPostResult[]; 74 + networkResults: NetworkSearchResult | null; 75 + query: string; 76 + }; 77 + 78 + type SearchViewActions = { 79 + onOpenActor: (actor: Pick<ProfileViewBasic, "did" | "handle">) => void; 80 + onOpenThread: (uri: string) => void; 81 + }; 54 82 55 83 function ModeLabel(props: { mode: SearchMode }) { 56 84 const text = createMemo(() => { ··· 79 107 } 80 108 81 109 export function SearchPanel(props: SearchPanelProps = {}) { 82 - const location = useLocation(); 83 - const navigate = useNavigate(); 84 - const preferences = useAppPreferences(); 85 - const session = useAppSession(); 86 - const postNavigation = usePostNavigation(); 87 - const [search, setSearch] = createStore<SearchPanelState>({ 88 - actorResults: null, 89 - error: null, 90 - hasSearched: false, 91 - loading: false, 92 - networkResults: null, 93 - resultCount: 0, 94 - results: [], 95 - syncStatus: [], 96 - }); 110 + const controller = useSearchController(props); 97 111 98 - let actorSearchContainerRef: HTMLDivElement | undefined; 99 - let searchInputRef: HTMLInputElement | undefined; 100 - let debounceTimer: ReturnType<typeof setTimeout> | undefined; 112 + return ( 113 + <SearchPanelContext.Provider value={controller}> 114 + <SearchPanelLayout embedded={!!props.embedded} /> 115 + </SearchPanelContext.Provider> 116 + ); 117 + } 101 118 102 - const routeState = createMemo(() => { 103 - const parsed = parseSearchRouteState(location.search); 119 + function useSearchPanelContext() { 120 + const context = useContext(SearchPanelContext); 121 + if (!context) { 122 + throw new Error("SearchPanel context is unavailable"); 123 + } 104 124 105 - if (!parsed.q && props.initialQuery) { 106 - parsed.q = props.initialQuery; 107 - } 125 + return context; 126 + } 108 127 109 - if (props.initialMode && !new URLSearchParams(location.search).has("mode")) { 110 - parsed.mode = props.initialMode; 111 - } 128 + function SearchPanelLayout(props: { embedded: boolean }) { 129 + return ( 130 + <div class="grid min-h-0 gap-6" classList={{ "xl:grid-cols-[minmax(0,1fr)_22rem]": !props.embedded }}> 131 + <SearchMainSurface embedded={props.embedded} /> 132 + <SearchSidebar embedded={props.embedded} /> 133 + </div> 134 + ); 135 + } 112 136 113 - return parsed; 114 - }); 137 + function SearchMainSurface(props: { embedded: boolean }) { 138 + return ( 139 + <section 140 + class="grid min-h-0 grid-rows-[auto_1fr] overflow-hidden" 141 + classList={{ 142 + "rounded-4xl bg-surface-container shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]": !props.embedded, 143 + }}> 144 + <SearchHeaderSection /> 145 + <SearchViewportSection /> 146 + </section> 147 + ); 148 + } 115 149 116 - const actorSuggestions = useActorSuggestions({ 117 - container: () => actorSearchContainerRef, 118 - disabled: () => routeState().tab !== "profiles", 119 - input: () => searchInputRef, 120 - onError: (error) => 121 - logger.warn("failed to load actor search suggestions", { keyValues: { error: normalizeError(error) } }), 122 - value: () => routeState().q, 150 + function SearchHeaderSection() { 151 + const controller = useSearchPanelContext(); 152 + const headerState = createMemo<SearchHeaderState>(() => { 153 + const routeState = controller.routeState(); 154 + return { 155 + error: controller.search.error, 156 + filters: routeState, 157 + filtersEnabled: controller.derived.networkFiltersEnabled(), 158 + hasSearched: controller.search.hasSearched, 159 + lastSync: controller.derived.lastSync(), 160 + loading: controller.search.loading, 161 + mode: routeState.mode, 162 + query: routeState.q, 163 + resultCount: controller.search.resultCount, 164 + semanticEnabled: controller.derived.semanticEnabled(), 165 + tab: routeState.tab, 166 + totalIndexedPosts: controller.derived.totalIndexedPosts(), 167 + }; 123 168 }); 124 169 125 - const isActorTab = createMemo(() => routeState().tab === "profiles"); 126 - const isLocalMode = createMemo(() => routeState().tab === "posts" && routeState().mode !== "network"); 127 - const networkFiltersEnabled = createMemo(() => routeState().tab === "posts" && routeState().mode === "network"); 128 - const semanticEnabled = createMemo(() => 129 - !!preferences.embeddingsConfig?.enabled && !!preferences.embeddingsConfig?.downloaded 130 - ); 170 + const headerSuggestions = createMemo<SearchHeaderSuggestions>(() => ({ 171 + activeIndex: controller.actorSuggestions.activeIndex(), 172 + items: controller.actorSuggestions.suggestions(), 173 + open: controller.actorSuggestions.open(), 174 + })); 131 175 132 - const totalIndexedPosts = createMemo(() => 133 - search.syncStatus.reduce((sum, status) => sum + (status.postCount ?? 0), 0) 176 + return ( 177 + <SearchHeader 178 + actions={{ 179 + onActorSuggestionFocus: controller.actorSuggestions.focus, 180 + onActorSuggestionSelect: (suggestion) => controller.actions.openActor(suggestion), 181 + onClear: controller.actions.clearSearch, 182 + onFilterChange: controller.actions.handleFilterChange, 183 + onKeyDown: controller.actions.handleKeyDown, 184 + onModeChange: controller.actions.handleModeChange, 185 + onQueryChange: controller.actions.handleInput, 186 + onTabChange: controller.actions.handleTabChange, 187 + }} 188 + refs={{ 189 + actorContainerRef: controller.refs.setActorSearchContainerRef, 190 + inputRef: controller.refs.setSearchInputRef, 191 + }} 192 + state={headerState()} 193 + suggestions={headerSuggestions()} /> 134 194 ); 135 - 136 - const hasLocalPosts = createMemo(() => totalIndexedPosts() > 0); 137 - const lastSync = createMemo(() => { 138 - const timestamps = search.syncStatus.map((status) => status.lastSyncedAt).filter(Boolean) as string[]; 139 - if (timestamps.length === 0) { 140 - return null; 141 - } 195 + } 142 196 143 - return formatRelativeTime(timestamps.toSorted((left, right) => right.localeCompare(left))[0]); 197 + function SearchViewportSection() { 198 + const controller = useSearchPanelContext(); 199 + const view = createMemo<SearchViewState>(() => { 200 + const routeState = controller.routeState(); 201 + return { 202 + actorResults: controller.search.actorResults, 203 + error: controller.search.error, 204 + hasLocalPosts: controller.derived.hasLocalPosts(), 205 + hasSearched: controller.search.hasSearched, 206 + isActorTab: controller.derived.isActorTab(), 207 + isLocalMode: controller.derived.isLocalMode(), 208 + localResults: controller.search.results, 209 + networkResults: controller.search.networkResults, 210 + query: routeState.q, 211 + }; 144 212 }); 145 213 146 - const cycleModes = createMemo(() => 147 - MODES.filter((candidate) => semanticEnabled() || (candidate !== "semantic" && candidate !== "hybrid")) 214 + return ( 215 + <SearchViewport 216 + actions={{ onOpenActor: controller.actions.openActor, onOpenThread: controller.actions.openThread }} 217 + loading={controller.search.loading} 218 + view={view()} /> 148 219 ); 220 + } 149 221 150 - async function performSearch() { 151 - const state = routeState(); 152 - const searchQuery = state.q.trim(); 222 + function SearchSidebar(props: { embedded: boolean }) { 223 + const controller = useSearchPanelContext(); 153 224 154 - if (!searchQuery) { 155 - clearResults(); 156 - return; 157 - } 158 - 159 - if (state.tab === "profiles") { 160 - setSearch({ error: null, loading: true }); 161 - 162 - try { 163 - const response = await SearchController.searchActors(searchQuery, 25); 164 - setSearch({ 165 - actorResults: response, 166 - error: null, 167 - hasSearched: true, 168 - networkResults: null, 169 - resultCount: response.actors.length, 170 - results: [], 171 - }); 172 - } catch (error) { 173 - const errorMessage = normalizeError(error); 174 - setSearch({ 175 - actorResults: null, 176 - error: errorMessage, 177 - hasSearched: true, 178 - networkResults: null, 179 - resultCount: 0, 180 - results: [], 181 - }); 182 - logger.error("actor search failed", { keyValues: { error: errorMessage, query: searchQuery } }); 183 - } finally { 184 - setSearch("loading", false); 185 - } 225 + return ( 226 + <Show when={!props.embedded}> 227 + <aside class="grid content-start gap-3 overflow-y-auto xl:sticky xl:top-0 xl:max-h-[calc(100vh-2rem)] xl:pr-1"> 228 + <Show when={controller.session.activeDid()}> 229 + {(did) => ( 230 + <SyncStatusPanel 231 + did={did()} 232 + onStatusChange={(status) => controller.actions.setSyncStatus(status)} /> 233 + )} 234 + </Show> 235 + <EmbeddingsSettings /> 236 + <SearchTipsCard /> 237 + </aside> 238 + </Show> 239 + ); 240 + } 186 241 187 - return; 188 - } 242 + function SearchHeader( 243 + props: { 244 + actions: SearchHeaderActions; 245 + refs: SearchHeaderRefs; 246 + state: SearchHeaderState; 247 + suggestions: SearchHeaderSuggestions; 248 + }, 249 + ) { 250 + return ( 251 + <header class="grid gap-4 px-6 pb-5 pt-6"> 252 + <SearchTabSelector activeTab={props.state.tab} onTabChange={props.actions.onTabChange} /> 253 + <SearchQuerySection 254 + actions={props.actions} 255 + refs={props.refs} 256 + state={props.state} 257 + suggestions={props.suggestions} /> 258 + <SearchModeRow 259 + mode={props.state.mode} 260 + semanticEnabled={props.state.semanticEnabled} 261 + tab={props.state.tab} 262 + onModeChange={props.actions.onModeChange} /> 263 + <SearchFiltersSection 264 + filters={props.state.filters} 265 + filtersEnabled={props.state.filtersEnabled} 266 + tab={props.state.tab} 267 + onFilterChange={props.actions.onFilterChange} /> 268 + <ResultMeta 269 + hasSearched={props.state.hasSearched} 270 + isActorTab={props.state.tab === "profiles"} 271 + lastSync={props.state.lastSync} 272 + mode={props.state.mode} 273 + resultCount={props.state.resultCount} 274 + totalIndexedPosts={props.state.totalIndexedPosts} /> 275 + </header> 276 + ); 277 + } 189 278 190 - if ((state.mode === "semantic" || state.mode === "hybrid") && !semanticEnabled()) { 191 - setSearch({ 192 - actorResults: null, 193 - error: "Semantic search is optional and currently off. Use Search setup or Settings to enable embeddings.", 194 - hasSearched: true, 195 - networkResults: null, 196 - resultCount: 0, 197 - results: [], 198 - }); 199 - return; 279 + function SearchQuerySection( 280 + props: { 281 + actions: SearchHeaderActions; 282 + refs: SearchHeaderRefs; 283 + state: SearchHeaderState; 284 + suggestions: SearchHeaderSuggestions; 285 + }, 286 + ) { 287 + const placeholder = createMemo(() => { 288 + if (props.state.tab === "profiles") { 289 + return "Search profiles by handle or display name..."; 200 290 } 201 291 202 - setSearch({ error: null, loading: true }); 203 - 204 - try { 205 - if (state.mode === "network") { 206 - const response = await SearchController.searchPostsNetwork(buildNetworkSearchParams(state)); 207 - setSearch({ 208 - actorResults: null, 209 - hasSearched: true, 210 - networkResults: response, 211 - resultCount: response.posts.length, 212 - results: [], 213 - }); 214 - } else { 215 - const response = await SearchController.searchPosts(searchQuery, state.mode, 50); 216 - setSearch({ 217 - actorResults: null, 218 - hasSearched: true, 219 - networkResults: null, 220 - resultCount: response.length, 221 - results: response, 222 - }); 223 - } 224 - } catch (error) { 225 - const errorMessage = normalizeError(error); 226 - setSearch({ 227 - actorResults: null, 228 - error: errorMessage, 229 - hasSearched: true, 230 - networkResults: null, 231 - resultCount: 0, 232 - results: [], 233 - }); 234 - logger.error("search failed", { 235 - keyValues: { error: errorMessage, mode: state.mode, query: searchQuery, tab: state.tab }, 236 - }); 237 - } finally { 238 - setSearch("loading", false); 239 - } 240 - } 241 - 242 - function clearResults() { 243 - setSearch({ 244 - actorResults: null, 245 - error: null, 246 - hasSearched: false, 247 - networkResults: null, 248 - resultCount: 0, 249 - results: [], 250 - }); 251 - } 252 - 253 - function replaceRoute(next: Partial<ReturnType<typeof routeState>>) { 254 - const state = routeState(); 255 - void navigate(buildSearchRoute(location.pathname, location.search, { ...state, ...next })); 256 - } 257 - 258 - function handleInput(value: string) { 259 - replaceRoute({ q: value }); 260 - } 261 - 262 - function handleModeChange(newMode: SearchMode) { 263 - if ((newMode === "semantic" || newMode === "hybrid") && !semanticEnabled()) { 264 - return; 265 - } 266 - 267 - replaceRoute({ mode: newMode, tab: "posts" }); 268 - } 269 - 270 - function handleFilterChange(next: Partial<PostSearchFilters>) { 271 - replaceRoute(next); 272 - } 273 - 274 - function handleTabChange(nextTab: SearchTab) { 275 - if (nextTab === routeState().tab) { 276 - return; 277 - } 278 - 279 - setSearch({ error: null, hasSearched: false, resultCount: 0 }); 280 - replaceRoute({ tab: nextTab }); 281 - } 282 - 283 - function cycleMode() { 284 - const availableModes = cycleModes(); 285 - const currentIndex = availableModes.indexOf(routeState().mode); 286 - const nextIndex = (currentIndex + 1) % availableModes.length; 287 - handleModeChange(availableModes[nextIndex] ?? availableModes[0] ?? "network"); 288 - } 289 - 290 - function clearSearch() { 291 - actorSuggestions.close(); 292 - replaceRoute({ q: "" }); 293 - clearResults(); 294 - searchInputRef?.focus(); 295 - } 296 - 297 - function handleKeyDown(event: KeyboardEvent) { 298 - if (routeState().tab === "profiles") { 299 - if (event.key === "ArrowDown") { 300 - event.preventDefault(); 301 - actorSuggestions.moveActiveIndex(1); 302 - return; 303 - } 304 - 305 - if (event.key === "ArrowUp") { 306 - event.preventDefault(); 307 - actorSuggestions.moveActiveIndex(-1); 308 - return; 309 - } 310 - 311 - if (event.key === "Enter" && actorSuggestions.open() && actorSuggestions.activeSuggestion()) { 312 - event.preventDefault(); 313 - openActor(actorSuggestions.activeSuggestion() as ProfileViewBasic); 314 - actorSuggestions.close(); 315 - return; 316 - } 317 - } 318 - 319 - if ( 320 - routeState().tab === "posts" && event.key === "Tab" && !event.shiftKey 321 - && document.activeElement === searchInputRef 322 - ) { 323 - event.preventDefault(); 324 - cycleMode(); 325 - return; 326 - } 327 - 328 - if (event.key === "Escape" && routeState().q) { 329 - clearSearch(); 330 - return; 331 - } 332 - 333 - if (event.key === "Escape" && routeState().tab === "profiles") { 334 - actorSuggestions.close(); 335 - } 336 - } 337 - 338 - function handleGlobalKeyDown(event: KeyboardEvent) { 339 - if (event.key === "/" || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f")) { 340 - const target = event.target as HTMLElement; 341 - if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") { 342 - event.preventDefault(); 343 - searchInputRef?.focus(); 344 - } 345 - } 346 - } 347 - 348 - onMount(() => { 349 - if (!props.embedded) { 350 - document.addEventListener("keydown", handleGlobalKeyDown); 351 - } 352 - 353 - if (props.embedded && session.activeDid) { 354 - void SearchController.getSyncStatus(session.activeDid).then((status) => { 355 - setSearch("syncStatus", status); 356 - }).catch((error) => { 357 - logger.warn("failed to load embedded search sync status", { keyValues: { error: normalizeError(error) } }); 358 - }); 359 - } 360 - 361 - onCleanup(() => { 362 - if (!props.embedded) { 363 - document.removeEventListener("keydown", handleGlobalKeyDown); 364 - } 365 - 366 - clearTimeout(debounceTimer); 367 - }); 292 + return props.state.mode === "network" 293 + ? "Search public posts across Bluesky..." 294 + : "Search your saved & liked posts..."; 368 295 }); 369 296 370 - createEffect(() => { 371 - if ((routeState().mode === "semantic" || routeState().mode === "hybrid") && !semanticEnabled()) { 372 - replaceRoute({ mode: "keyword" }); 373 - } 374 - }); 375 - 376 - createEffect(() => { 377 - routeState(); 378 - clearTimeout(debounceTimer); 379 - debounceTimer = setTimeout(() => { 380 - void performSearch(); 381 - }, 300); 382 - }); 383 - 384 - function openActor(actor: Pick<ProfileViewBasic, "did" | "handle">) { 385 - void navigate(buildProfileRoute(getProfileRouteActor(actor))); 386 - } 387 - 388 297 return ( 389 - <div class="grid min-h-0 gap-6" classList={{ "xl:grid-cols-[minmax(0,1fr)_22rem]": !props.embedded }}> 390 - <section 391 - class="grid min-h-0 grid-rows-[auto_1fr] overflow-hidden" 392 - classList={{ 393 - "rounded-4xl bg-surface-container shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]": !props.embedded, 298 + <div ref={props.refs.actorContainerRef} class="relative"> 299 + <SearchQueryInput 300 + a11y={{ 301 + ariaActivedescendant: props.state.tab === "profiles" && props.suggestions.activeIndex >= 0 302 + ? `search-actor-suggestions-option-${props.suggestions.activeIndex}` 303 + : undefined, 304 + ariaAutocomplete: props.state.tab === "profiles" ? "list" : undefined, 305 + ariaControls: props.state.tab === "profiles" ? "search-actor-suggestions" : undefined, 306 + ariaExpanded: props.state.tab === "profiles" ? props.suggestions.open : undefined, 307 + autocomplete: props.state.tab === "profiles" ? "off" : undefined, 308 + role: props.state.tab === "profiles" ? "combobox" : undefined, 309 + spellcheck: false, 310 + }} 311 + actions={{ 312 + onClear: props.actions.onClear, 313 + onFocus: props.state.tab === "profiles" ? props.actions.onActorSuggestionFocus : undefined, 314 + onKeyDown: props.actions.onKeyDown, 315 + onQueryChange: props.actions.onQueryChange, 316 + }} 317 + refs={{ inputRef: props.refs.inputRef }} 318 + state={{ 319 + error: props.state.error, 320 + loading: props.state.loading, 321 + placeholder: placeholder(), 322 + query: props.state.query, 394 323 }}> 395 - <SearchHeader 396 - actorSearchContainerRef={(element) => { 397 - actorSearchContainerRef = element; 398 - }} 399 - actorSuggestions={actorSuggestions.suggestions()} 400 - error={search.error} 401 - filters={routeState()} 402 - filtersEnabled={networkFiltersEnabled()} 403 - hasSearched={search.hasSearched} 404 - inputRef={(element) => { 405 - searchInputRef = element; 406 - }} 407 - lastSync={lastSync()} 408 - loading={search.loading} 409 - mode={routeState().mode} 410 - onActorSuggestionFocus={actorSuggestions.focus} 411 - onActorSuggestionSelect={(suggestion) => openActor(suggestion)} 412 - onClear={clearSearch} 413 - onFilterChange={handleFilterChange} 414 - onKeyDown={handleKeyDown} 415 - onModeChange={handleModeChange} 416 - onQueryChange={handleInput} 417 - onTabChange={handleTabChange} 418 - query={routeState().q} 419 - resultCount={search.resultCount} 420 - semanticEnabled={semanticEnabled()} 421 - suggestionsActiveIndex={actorSuggestions.activeIndex()} 422 - suggestionsOpen={actorSuggestions.open()} 423 - tab={routeState().tab} 424 - totalIndexedPosts={totalIndexedPosts()} /> 324 + <Show when={props.state.tab === "profiles"}> 325 + <ActorSuggestionList 326 + activeIndex={props.suggestions.activeIndex} 327 + id="search-actor-suggestions" 328 + open={props.suggestions.open} 329 + suggestions={props.suggestions.items} 330 + title="Suggested profiles" 331 + onSelect={props.actions.onActorSuggestionSelect} /> 332 + </Show> 333 + </SearchQueryInput> 334 + </div> 335 + ); 336 + } 425 337 426 - <SearchViewport 427 - actorResults={search.actorResults} 428 - error={search.error} 429 - hasLocalPosts={hasLocalPosts()} 430 - hasSearched={search.hasSearched} 431 - isActorTab={isActorTab()} 432 - isLocalMode={isLocalMode()} 433 - loading={search.loading} 434 - localResults={search.results} 435 - networkResults={search.networkResults} 436 - onOpenActor={openActor} 437 - onOpenThread={(uri) => void postNavigation.openPost(uri)} 438 - query={routeState().q} /> 439 - </section> 440 - 441 - <Show when={!props.embedded}> 442 - <aside class="grid content-start gap-3 overflow-y-auto xl:sticky xl:top-0 xl:max-h-[calc(100vh-2rem)] xl:pr-1"> 443 - <Show when={session.activeDid}> 444 - {(did) => <SyncStatusPanel did={did()} onStatusChange={(status) => setSearch("syncStatus", status)} />} 445 - </Show> 446 - <EmbeddingsSettings /> 447 - <SearchTipsCard /> 448 - </aside> 338 + function SearchModeRow( 339 + props: { mode: SearchMode; semanticEnabled: boolean; tab: SearchTab; onModeChange: (mode: SearchMode) => void }, 340 + ) { 341 + return ( 342 + <div class="flex items-center justify-between gap-4"> 343 + <Show 344 + when={props.tab === "posts"} 345 + fallback={ 346 + <span class="inline-flex items-center gap-2 rounded-full bg-black/30 px-3 py-2 text-xs text-on-surface-variant"> 347 + <Icon kind="profile" class="text-sm text-primary" /> 348 + Profiles are always searched across Bluesky. 349 + </span> 350 + }> 351 + <ModeSelector 352 + activeMode={props.mode} 353 + semanticEnabled={props.semanticEnabled} 354 + onModeChange={props.onModeChange} /> 449 355 </Show> 356 + <SearchHint tab={props.tab} /> 450 357 </div> 451 358 ); 452 359 } 453 360 454 - function SearchHeader( 361 + function SearchFiltersSection( 455 362 props: { 456 - actorSearchContainerRef: (el: HTMLDivElement) => void; 457 - actorSuggestions: ProfileViewBasic[]; 458 - error: string | null; 459 - filters: ReturnType<typeof parseSearchRouteState>; 363 + filters: PostSearchFilters; 460 364 filtersEnabled: boolean; 461 - hasSearched: boolean; 462 - inputRef: (el: HTMLInputElement) => void; 463 - lastSync: string | null; 464 - loading: boolean; 465 - mode: SearchMode; 466 - onActorSuggestionFocus: () => void; 467 - onActorSuggestionSelect: (suggestion: ProfileViewBasic) => void; 468 - onClear: () => void; 365 + tab: SearchTab; 469 366 onFilterChange: (next: Partial<PostSearchFilters>) => void; 470 - onKeyDown: (event: KeyboardEvent) => void; 471 - onModeChange: (mode: SearchMode) => void; 472 - onQueryChange: (value: string) => void; 473 - onTabChange: (tab: SearchTab) => void; 474 - query: string; 475 - resultCount: number; 476 - semanticEnabled: boolean; 477 - suggestionsActiveIndex: number; 478 - suggestionsOpen: boolean; 479 - tab: SearchTab; 480 - totalIndexedPosts: number; 481 367 }, 482 368 ) { 483 369 return ( 484 - <header class="grid gap-4 px-6 pb-5 pt-6"> 485 - <SearchTabSelector activeTab={props.tab} onTabChange={props.onTabChange} /> 486 - 487 - <div ref={props.actorSearchContainerRef} class="relative"> 488 - <SearchQueryInput 489 - ariaActivedescendant={props.tab === "profiles" && props.suggestionsActiveIndex >= 0 490 - ? `search-actor-suggestions-option-${props.suggestionsActiveIndex}` 491 - : undefined} 492 - ariaAutocomplete={props.tab === "profiles" ? "list" : undefined} 493 - ariaControls={props.tab === "profiles" ? "search-actor-suggestions" : undefined} 494 - ariaExpanded={props.tab === "profiles" ? props.suggestionsOpen : undefined} 495 - autocomplete={props.tab === "profiles" ? "off" : undefined} 496 - error={props.error} 497 - inputRef={props.inputRef} 498 - loading={props.loading} 499 - onFocus={props.tab === "profiles" ? props.onActorSuggestionFocus : undefined} 500 - placeholder={props.tab === "profiles" 501 - ? "Search profiles by handle or display name..." 502 - : (props.mode === "network" 503 - ? "Search public posts across Bluesky..." 504 - : "Search your saved & liked posts...")} 505 - query={props.query} 506 - role={props.tab === "profiles" ? "combobox" : undefined} 507 - spellcheck={false} 508 - onClear={props.onClear} 509 - onKeyDown={props.onKeyDown} 510 - onQueryChange={props.onQueryChange}> 511 - <Show when={props.tab === "profiles"}> 512 - <ActorSuggestionList 513 - activeIndex={props.suggestionsActiveIndex} 514 - id="search-actor-suggestions" 515 - open={props.suggestionsOpen} 516 - suggestions={props.actorSuggestions} 517 - title="Suggested profiles" 518 - onSelect={props.onActorSuggestionSelect} /> 519 - </Show> 520 - </SearchQueryInput> 521 - </div> 522 - 523 - <div class="flex items-center justify-between gap-4"> 524 - <Show 525 - when={props.tab === "posts"} 526 - fallback={ 527 - <span class="inline-flex items-center gap-2 rounded-full bg-black/30 px-3 py-2 text-xs text-on-surface-variant"> 528 - <Icon kind="profile" class="text-sm text-primary" /> 529 - Profiles are always searched across Bluesky. 530 - </span> 531 - }> 532 - <ModeSelector 533 - activeMode={props.mode} 534 - semanticEnabled={props.semanticEnabled} 535 - onModeChange={props.onModeChange} /> 536 - </Show> 537 - <SearchHint tab={props.tab} /> 538 - </div> 539 - 540 - <Show when={props.filtersEnabled} fallback={<NetworkFiltersNotice tab={props.tab} />}> 541 - <PostSearchFiltersRow 542 - collapsible 543 - defaultExpanded={hasAdvancedNetworkFilters(props.filters)} 544 - filters={props.filters} 545 - helperText="Filters update the URL and apply to network post search." 546 - onChange={props.onFilterChange} /> 547 - </Show> 548 - 549 - <ResultMeta 550 - hasSearched={props.hasSearched} 551 - isActorTab={props.tab === "profiles"} 552 - lastSync={props.lastSync} 553 - mode={props.mode} 554 - resultCount={props.resultCount} 555 - totalIndexedPosts={props.totalIndexedPosts} /> 556 - </header> 370 + <Show when={props.filtersEnabled} fallback={<NetworkFiltersNotice tab={props.tab} />}> 371 + <PostSearchFiltersRow 372 + collapsible 373 + defaultExpanded={hasAdvancedNetworkFilters(props.filters)} 374 + filters={props.filters} 375 + helperText="Filters update the URL and apply to network post search." 376 + onChange={props.onFilterChange} /> 377 + </Show> 557 378 ); 558 379 } 559 380 ··· 578 399 579 400 function SearchHint(props: { tab: SearchTab }) { 580 401 return ( 581 - <span class="text-xs text-on-surface-variant"> 582 - <Show 583 - when={props.tab === "posts"} 584 - fallback={ 585 - <> 586 - <kbd class="rounded bg-white/10 px-1.5 py-0.5">↑↓</kbd> to navigate suggestions 587 - </> 588 - }> 589 - <> 590 - <kbd class="rounded bg-white/10 px-1.5 py-0.5">Tab</kbd> to switch modes 591 - </> 592 - </Show> 593 - </span> 402 + <Show 403 + when={props.tab === "posts"} 404 + fallback={ 405 + <span class="text-xs text-on-surface-variant"> 406 + <kbd class="rounded bg-white/10 px-1.5 py-0.5">↑↓</kbd> to navigate suggestions 407 + </span> 408 + }> 409 + <span class="text-xs text-on-surface-variant"> 410 + <kbd class="rounded bg-white/10 px-1.5 py-0.5">Tab</kbd> to switch modes 411 + </span> 412 + </Show> 594 413 ); 595 414 } 596 415 ··· 608 427 "bg-white/4 text-on-surface-variant hover:bg-white/8 hover:text-on-surface": props.activeTab !== tab, 609 428 }} 610 429 onClick={() => props.onTabChange(tab)}> 611 - <Icon kind={tab === "posts" ? "search" : "profile"} class="text-sm" /> 612 - <span>{tab === "posts" ? "Posts" : "Profiles"}</span> 430 + <Show 431 + when={tab === "posts"} 432 + fallback={ 433 + <> 434 + <Icon kind="profile" class="text-sm" /> 435 + <span>Profiles</span> 436 + </> 437 + }> 438 + <Icon kind="search" class="text-sm" /> 439 + <span>Posts</span> 440 + </Show> 613 441 </button> 614 442 )} 615 443 </For> ··· 632 460 <span class="text-sm text-on-surface-variant"> 633 461 <Show 634 462 when={props.hasSearched} 635 - fallback={props.isActorTab 636 - ? "Search people across Bluesky by handle or display name." 637 - : (props.mode === "network" 638 - ? "Search public posts across Bluesky or switch to your synced archive." 639 - : "Search your liked and bookmarked posts locally, or search the network.")}> 463 + fallback={ 464 + <Switch fallback={"Search your liked and bookmarked posts locally, or search the network."}> 465 + <Match when={props.isActorTab}>Search people across Bluesky by handle or display name.</Match> 466 + <Match when={props.mode === "network"}> 467 + Search public posts across Bluesky or switch to your synced archive. 468 + </Match> 469 + </Switch> 470 + }> 640 471 <span> 641 472 Found <span class="font-medium text-on-surface">{props.resultCount}</span>{" "} 642 - {props.isActorTab ? "profiles" : "results"} 473 + <Show when={props.isActorTab} fallback={"results"}>profiles</Show> 643 474 </span> 644 475 </Show> 645 476 </span> ··· 711 542 ); 712 543 } 713 544 714 - type SearchViewportProps = { 715 - actorResults: ActorSearchResult | null; 716 - error: string | null; 717 - hasLocalPosts: boolean; 718 - hasSearched: boolean; 719 - isActorTab: boolean; 720 - isLocalMode: boolean; 721 - loading: boolean; 722 - localResults: LocalPostResult[]; 723 - networkResults: NetworkSearchResult | null; 724 - onOpenActor: (actor: Pick<ProfileViewBasic, "did" | "handle">) => void; 725 - onOpenThread: (uri: string) => void; 726 - query: string; 727 - }; 728 - 729 - function SearchViewport(props: SearchViewportProps) { 545 + function SearchViewport(props: { actions: SearchViewActions; loading: boolean; view: SearchViewState }) { 730 546 return ( 731 547 <div class="min-h-0 overflow-y-auto px-3 pb-3"> 732 - <Show when={props.loading} fallback={<SearchState {...props} />}> 548 + <Show when={props.loading} fallback={<SearchState actions={props.actions} view={props.view} />}> 733 549 <LocalPostResultsSkeletons /> 734 550 </Show> 735 551 </div> 736 552 ); 737 553 } 738 554 739 - type SearchStateProps = { 740 - actorResults: ActorSearchResult | null; 741 - error: string | null; 742 - hasLocalPosts: boolean; 743 - hasSearched: boolean; 744 - isActorTab: boolean; 745 - isLocalMode: boolean; 746 - localResults: LocalPostResult[]; 747 - networkResults: NetworkSearchResult | null; 748 - onOpenActor: (actor: Pick<ProfileViewBasic, "did" | "handle">) => void; 749 - onOpenThread: (uri: string) => void; 750 - query: string; 751 - }; 555 + function SearchState(props: { actions: SearchViewActions; view: SearchViewState }) { 556 + const scope = () => { 557 + if (props.view.isActorTab) { 558 + return "profiles"; 559 + } else if (props.view.isLocalMode) { 560 + return "local"; 561 + } else { 562 + return "network"; 563 + } 564 + }; 752 565 753 - function SearchState(props: SearchStateProps) { 754 566 return ( 755 567 <Presence> 756 568 <Switch> 757 - <Match when={props.error && props.query}> 758 - <EmptyStateView 759 - reason="error" 760 - scope={props.isActorTab ? "profiles" : (props.isLocalMode ? "local" : "network")} /> 569 + <Match when={props.view.error && props.view.query}> 570 + <EmptyStateView reason="error" scope={scope()} /> 761 571 </Match> 762 - 763 - <Match when={!props.isActorTab && props.isLocalMode && !props.hasLocalPosts}> 572 + <Match when={!props.view.isActorTab && props.view.isLocalMode && !props.view.hasLocalPosts}> 764 573 <EmptyStateView reason="no-sync" scope="local" /> 765 574 </Match> 766 - 767 - <Match when={!props.hasSearched && !props.query}> 768 - <EmptyStateView 769 - reason="initial" 770 - scope={props.isActorTab ? "profiles" : (props.isLocalMode ? "local" : "network")} /> 575 + <Match when={!props.view.hasSearched && !props.view.query}> 576 + <EmptyStateView reason="initial" scope={scope()} /> 771 577 </Match> 772 - 773 - <Match when={props.isActorTab && props.actorResults?.actors.length === 0}> 578 + <Match when={props.view.isActorTab && props.view.actorResults?.actors.length === 0}> 774 579 <EmptyStateView reason="no-results" scope="profiles" /> 775 580 </Match> 776 - 777 - <Match when={!props.isActorTab && props.isLocalMode && props.localResults.length === 0}> 581 + <Match when={!props.view.isActorTab && props.view.isLocalMode && props.view.localResults.length === 0}> 778 582 <EmptyStateView reason="no-results" scope="local" /> 779 583 </Match> 780 - 781 - <Match when={!props.isActorTab && !props.isLocalMode && props.networkResults?.posts.length === 0}> 584 + <Match 585 + when={!props.view.isActorTab && !props.view.isLocalMode && props.view.networkResults?.posts.length === 0}> 782 586 <EmptyStateView reason="no-results" scope="network" /> 783 587 </Match> 784 - 785 - <Match when={props.isActorTab && props.actorResults}> 786 - <ActorResultsList onOpenActor={props.onOpenActor} results={props.actorResults} /> 588 + <Match when={props.view.isActorTab && props.view.actorResults}> 589 + <ActorResultsList onOpenActor={props.actions.onOpenActor} results={props.view.actorResults} /> 787 590 </Match> 788 - 789 - <Match when={props.isLocalMode}> 790 - <LocalPostResultsList onOpenThread={props.onOpenThread} query={props.query} results={props.localResults} /> 591 + <Match when={props.view.isLocalMode}> 592 + <LocalPostResultsList 593 + onOpenThread={props.actions.onOpenThread} 594 + query={props.view.query} 595 + results={props.view.localResults} /> 791 596 </Match> 792 - 793 - <Match when={!props.isLocalMode && props.networkResults}> 794 - <NetworkResultsList onOpenThread={props.onOpenThread} results={props.networkResults} /> 597 + <Match when={!props.view.isLocalMode && props.view.networkResults}> 598 + <NetworkResultsList onOpenThread={props.actions.onOpenThread} results={props.view.networkResults} /> 795 599 </Match> 796 600 </Switch> 797 601 </Presence> ··· 953 757 function hasAdvancedNetworkFilters(filters: PostSearchFilters) { 954 758 return !!(filters.author || filters.mentions || filters.since || filters.until || filters.tags.length > 0); 955 759 } 956 - 957 - function buildNetworkSearchParams(state: ReturnType<typeof parseSearchRouteState>): NetworkSearchParams { 958 - return { 959 - author: state.author || null, 960 - limit: 25, 961 - mentions: state.mentions || null, 962 - query: state.q, 963 - since: state.since ? toLocalDayStartIso(state.since) : null, 964 - sort: state.sort, 965 - tags: state.tags, 966 - until: state.until ? toLocalDayUntilIso(state.until) : null, 967 - }; 968 - }
+61 -39
src/components/search/SearchQueryInput.tsx
··· 1 - import type { JSX } from "solid-js"; 1 + import type { JSX, ParentProps } from "solid-js"; 2 2 import { Show } from "solid-js"; 3 3 import { Icon } from "../shared/Icon"; 4 4 5 - type SearchQueryInputProps = { 5 + type SearchQueryInputA11y = { 6 6 ariaActivedescendant?: string; 7 7 ariaAutocomplete?: "both" | "inline" | "list" | "none"; 8 8 ariaControls?: string; 9 9 ariaExpanded?: boolean; 10 10 autocomplete?: string; 11 - children?: JSX.Element; 12 - error: string | null; 13 - inputRef?: (el: HTMLInputElement) => void; 14 - loading: boolean; 15 - onFocus?: () => void; 16 - placeholder: string; 17 - query: string; 18 11 role?: JSX.InputHTMLAttributes<HTMLInputElement>["role"]; 19 12 spellcheck?: boolean; 13 + }; 14 + 15 + type SearchQueryInputState = { error: string | null; loading: boolean; placeholder: string; query: string }; 16 + 17 + type SearchQueryInputHandlers = { 20 18 onClear: () => void; 19 + onFocus?: () => void; 21 20 onKeyDown?: (event: KeyboardEvent) => void; 22 21 onQueryChange: (value: string) => void; 23 22 }; 24 23 24 + type SearchQueryInputRefs = { inputRef?: (el: HTMLInputElement) => void }; 25 + 26 + type SearchQueryInputProps = ParentProps & { 27 + a11y?: SearchQueryInputA11y; 28 + actions: SearchQueryInputHandlers; 29 + refs?: SearchQueryInputRefs; 30 + state: SearchQueryInputState; 31 + }; 32 + 25 33 export function SearchQueryInput(props: SearchQueryInputProps) { 26 34 return ( 27 35 <div class="grid gap-2"> 28 - <div class="relative"> 29 - <div class="absolute left-4 top-1/2 -translate-y-1/2 text-on-surface-variant"> 30 - <Icon kind="search" class="text-lg" /> 31 - </div> 32 - 33 - <input 34 - ref={props.inputRef} 35 - type="text" 36 - role={props.role} 37 - aria-activedescendant={props.ariaActivedescendant} 38 - aria-autocomplete={props.ariaAutocomplete} 39 - aria-controls={props.ariaControls} 40 - aria-expanded={props.ariaExpanded} 41 - autocomplete={props.autocomplete} 42 - spellcheck={props.spellcheck} 43 - value={props.query} 44 - placeholder={props.placeholder} 45 - class="w-full rounded-3xl border-0 bg-black/40 py-3.5 pl-12 pr-20 text-base text-on-surface placeholder:text-on-surface-variant/50 outline-none ring-1 ring-white/5 transition-all focus:ring-primary/50" 46 - onInput={(event) => props.onQueryChange(event.currentTarget.value)} 47 - onFocus={() => props.onFocus?.()} 48 - onKeyDown={(event) => props.onKeyDown?.(event)} /> 49 - 50 - <div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-2"> 51 - <LoadingIndicator loading={props.loading} /> 52 - <ClearButton query={props.query} loading={props.loading} onClear={props.onClear} /> 53 - </div> 54 - 36 + <SearchInputField a11y={props.a11y} actions={props.actions} refs={props.refs} state={props.state}> 55 37 {props.children} 56 - </div> 57 - 58 - <Show when={props.error}> 38 + </SearchInputField> 39 + <Show when={props.state.error}> 59 40 {(message) => ( 60 41 <div class="rounded-2xl bg-red-500/10 px-3 py-2 text-sm text-red-200 shadow-[inset_0_0_0_1px_rgba(239,68,68,0.15)]"> 61 42 {message()} 62 43 </div> 63 44 )} 64 45 </Show> 46 + </div> 47 + ); 48 + } 49 + 50 + function SearchInputField( 51 + props: ParentProps & { 52 + a11y?: SearchQueryInputA11y; 53 + actions: SearchQueryInputHandlers; 54 + refs?: SearchQueryInputRefs; 55 + state: SearchQueryInputState; 56 + }, 57 + ) { 58 + return ( 59 + <div class="relative"> 60 + <div class="absolute left-4 top-1/2 -translate-y-1/2 text-on-surface-variant"> 61 + <Icon kind="search" class="text-lg" /> 62 + </div> 63 + 64 + <input 65 + ref={props.refs?.inputRef} 66 + type="text" 67 + role={props.a11y?.role} 68 + aria-activedescendant={props.a11y?.ariaActivedescendant} 69 + aria-autocomplete={props.a11y?.ariaAutocomplete} 70 + aria-controls={props.a11y?.ariaControls} 71 + aria-expanded={props.a11y?.ariaExpanded} 72 + autocomplete={props.a11y?.autocomplete} 73 + spellcheck={props.a11y?.spellcheck} 74 + value={props.state.query} 75 + placeholder={props.state.placeholder} 76 + class="w-full rounded-3xl border-0 bg-black/40 py-3.5 pl-12 pr-20 text-base text-on-surface placeholder:text-on-surface-variant/50 outline-none ring-1 ring-white/5 transition-all focus:ring-primary/50" 77 + onInput={(event) => props.actions.onQueryChange(event.currentTarget.value)} 78 + onFocus={() => props.actions.onFocus?.()} 79 + onKeyDown={(event) => props.actions.onKeyDown?.(event)} /> 80 + 81 + <div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-2"> 82 + <LoadingIndicator loading={props.state.loading} /> 83 + <ClearButton query={props.state.query} loading={props.state.loading} onClear={props.actions.onClear} /> 84 + </div> 85 + 86 + {props.children} 65 87 </div> 66 88 ); 67 89 }
+409
src/components/search/useSearchController.ts
··· 1 + import { useActorSuggestions } from "$/components/actors/actor-search"; 2 + import { usePostNavigation } from "$/components/posts/usePostNavigation"; 3 + import { useAppPreferences } from "$/contexts/app-preferences"; 4 + import { useAppSession } from "$/contexts/app-session"; 5 + import { SearchController } from "$/lib/api/search"; 6 + import type { 7 + ActorSearchResult, 8 + LocalPostResult, 9 + NetworkSearchParams, 10 + NetworkSearchResult, 11 + SearchMode, 12 + SyncStatus, 13 + } from "$/lib/api/types/search"; 14 + import { formatRelativeTime } from "$/lib/feeds"; 15 + import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 16 + import { buildSearchRoute, parseSearchRouteState, toLocalDayStartIso, toLocalDayUntilIso } from "$/lib/search-routes"; 17 + import type { PostSearchFilters, SearchTab } from "$/lib/search-routes"; 18 + import type { ProfileViewBasic } from "$/lib/types"; 19 + import { normalizeError } from "$/lib/utils/text"; 20 + import { useLocation, useNavigate } from "@solidjs/router"; 21 + import * as logger from "@tauri-apps/plugin-log"; 22 + import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"; 23 + import { createStore } from "solid-js/store"; 24 + 25 + const MODES: SearchMode[] = ["network", "keyword", "semantic", "hybrid"]; 26 + const SEARCH_DEBOUNCE_MS = 300; 27 + const PROFILE_SEARCH_LIMIT = 25; 28 + const LOCAL_SEARCH_LIMIT = 50; 29 + 30 + type SearchControllerState = { 31 + actorResults: ActorSearchResult | null; 32 + error: string | null; 33 + hasSearched: boolean; 34 + loading: boolean; 35 + networkResults: NetworkSearchResult | null; 36 + resultCount: number; 37 + results: LocalPostResult[]; 38 + syncStatus: SyncStatus[]; 39 + }; 40 + 41 + type SearchControllerOptions = { embedded?: boolean; initialMode?: SearchMode; initialQuery?: string }; 42 + 43 + type SearchRouteState = ReturnType<typeof parseSearchRouteState>; 44 + 45 + function createSearchControllerState(): SearchControllerState { 46 + return { 47 + actorResults: null, 48 + error: null, 49 + hasSearched: false, 50 + loading: false, 51 + networkResults: null, 52 + resultCount: 0, 53 + results: [], 54 + syncStatus: [], 55 + }; 56 + } 57 + 58 + function buildNetworkSearchParams(state: SearchRouteState): NetworkSearchParams { 59 + return { 60 + author: state.author || null, 61 + limit: PROFILE_SEARCH_LIMIT, 62 + mentions: state.mentions || null, 63 + query: state.q, 64 + since: state.since ? toLocalDayStartIso(state.since) : null, 65 + sort: state.sort, 66 + tags: state.tags, 67 + until: state.until ? toLocalDayUntilIso(state.until) : null, 68 + }; 69 + } 70 + 71 + export function useSearchController(options: SearchControllerOptions = {}) { 72 + const location = useLocation(); 73 + const navigate = useNavigate(); 74 + const preferences = useAppPreferences(); 75 + const session = useAppSession(); 76 + const postNavigation = usePostNavigation(); 77 + const [search, setSearch] = createStore<SearchControllerState>(createSearchControllerState()); 78 + const [actorSearchContainerRef, setActorSearchContainerRef] = createSignal<HTMLDivElement>(); 79 + const [searchInputRef, setSearchInputRef] = createSignal<HTMLInputElement>(); 80 + let debounceTimer: ReturnType<typeof setTimeout> | undefined; 81 + 82 + const routeState = createMemo(() => { 83 + const parsed = parseSearchRouteState(location.search); 84 + 85 + if (!parsed.q && options.initialQuery) { 86 + parsed.q = options.initialQuery; 87 + } 88 + 89 + if (options.initialMode && !new URLSearchParams(location.search).has("mode")) { 90 + parsed.mode = options.initialMode; 91 + } 92 + 93 + return parsed; 94 + }); 95 + 96 + const actorSuggestions = useActorSuggestions({ 97 + container: actorSearchContainerRef, 98 + disabled: () => routeState().tab !== "profiles", 99 + input: searchInputRef, 100 + onError: (error) => 101 + logger.warn("failed to load actor search suggestions", { keyValues: { error: normalizeError(error) } }), 102 + value: () => routeState().q, 103 + }); 104 + 105 + const isActorTab = createMemo(() => routeState().tab === "profiles"); 106 + const isLocalMode = createMemo(() => routeState().tab === "posts" && routeState().mode !== "network"); 107 + const networkFiltersEnabled = createMemo(() => routeState().tab === "posts" && routeState().mode === "network"); 108 + const semanticEnabled = createMemo(() => 109 + !!preferences.embeddingsConfig?.enabled && !!preferences.embeddingsConfig?.downloaded 110 + ); 111 + const totalIndexedPosts = createMemo(() => 112 + search.syncStatus.reduce((sum, status) => sum + (status.postCount ?? 0), 0) 113 + ); 114 + const hasLocalPosts = createMemo(() => totalIndexedPosts() > 0); 115 + const lastSync = createMemo(() => { 116 + const timestamps = search.syncStatus.map((status) => status.lastSyncedAt).filter(Boolean) as string[]; 117 + if (timestamps.length === 0) { 118 + return null; 119 + } 120 + 121 + return formatRelativeTime(timestamps.toSorted((left, right) => right.localeCompare(left))[0]); 122 + }); 123 + const cycleModes = createMemo(() => 124 + MODES.filter((candidate) => semanticEnabled() || (candidate !== "semantic" && candidate !== "hybrid")) 125 + ); 126 + 127 + async function performSearch() { 128 + const state = routeState(); 129 + const searchQuery = state.q.trim(); 130 + 131 + if (!searchQuery) { 132 + clearResults(); 133 + return; 134 + } 135 + 136 + if (state.tab === "profiles") { 137 + setSearch({ error: null, loading: true }); 138 + 139 + try { 140 + const response = await SearchController.searchActors(searchQuery, PROFILE_SEARCH_LIMIT); 141 + setSearch({ 142 + actorResults: response, 143 + error: null, 144 + hasSearched: true, 145 + networkResults: null, 146 + resultCount: response.actors.length, 147 + results: [], 148 + }); 149 + } catch (error) { 150 + const errorMessage = normalizeError(error); 151 + setSearch({ 152 + actorResults: null, 153 + error: errorMessage, 154 + hasSearched: true, 155 + networkResults: null, 156 + resultCount: 0, 157 + results: [], 158 + }); 159 + logger.error("actor search failed", { keyValues: { error: errorMessage, query: searchQuery } }); 160 + } finally { 161 + setSearch("loading", false); 162 + } 163 + 164 + return; 165 + } 166 + 167 + if ((state.mode === "semantic" || state.mode === "hybrid") && !semanticEnabled()) { 168 + setSearch({ 169 + actorResults: null, 170 + error: "Semantic search is optional and currently off. Use Search setup or Settings to enable embeddings.", 171 + hasSearched: true, 172 + networkResults: null, 173 + resultCount: 0, 174 + results: [], 175 + }); 176 + return; 177 + } 178 + 179 + setSearch({ error: null, loading: true }); 180 + 181 + try { 182 + if (state.mode === "network") { 183 + const response = await SearchController.searchPostsNetwork(buildNetworkSearchParams(state)); 184 + setSearch({ 185 + actorResults: null, 186 + hasSearched: true, 187 + networkResults: response, 188 + resultCount: response.posts.length, 189 + results: [], 190 + }); 191 + } else { 192 + const response = await SearchController.searchPosts(searchQuery, state.mode, LOCAL_SEARCH_LIMIT); 193 + setSearch({ 194 + actorResults: null, 195 + hasSearched: true, 196 + networkResults: null, 197 + resultCount: response.length, 198 + results: response, 199 + }); 200 + } 201 + } catch (error) { 202 + const errorMessage = normalizeError(error); 203 + setSearch({ 204 + actorResults: null, 205 + error: errorMessage, 206 + hasSearched: true, 207 + networkResults: null, 208 + resultCount: 0, 209 + results: [], 210 + }); 211 + logger.error("search failed", { 212 + keyValues: { error: errorMessage, mode: state.mode, query: searchQuery, tab: state.tab }, 213 + }); 214 + } finally { 215 + setSearch("loading", false); 216 + } 217 + } 218 + 219 + function clearResults() { 220 + setSearch({ 221 + actorResults: null, 222 + error: null, 223 + hasSearched: false, 224 + networkResults: null, 225 + resultCount: 0, 226 + results: [], 227 + }); 228 + } 229 + 230 + function replaceRoute(next: Partial<SearchRouteState>) { 231 + const state = routeState(); 232 + void navigate(buildSearchRoute(location.pathname, location.search, { ...state, ...next })); 233 + } 234 + 235 + function handleInput(value: string) { 236 + replaceRoute({ q: value }); 237 + } 238 + 239 + function handleModeChange(newMode: SearchMode) { 240 + if ((newMode === "semantic" || newMode === "hybrid") && !semanticEnabled()) { 241 + return; 242 + } 243 + 244 + replaceRoute({ mode: newMode, tab: "posts" }); 245 + } 246 + 247 + function handleFilterChange(next: Partial<PostSearchFilters>) { 248 + replaceRoute(next); 249 + } 250 + 251 + function handleTabChange(nextTab: SearchTab) { 252 + if (nextTab === routeState().tab) { 253 + return; 254 + } 255 + 256 + setSearch({ error: null, hasSearched: false, resultCount: 0 }); 257 + replaceRoute({ tab: nextTab }); 258 + } 259 + 260 + function cycleMode() { 261 + const availableModes = cycleModes(); 262 + const currentIndex = availableModes.indexOf(routeState().mode); 263 + const nextIndex = (currentIndex + 1) % availableModes.length; 264 + handleModeChange(availableModes[nextIndex] ?? availableModes[0] ?? "network"); 265 + } 266 + 267 + function clearSearch() { 268 + actorSuggestions.close(); 269 + replaceRoute({ q: "" }); 270 + clearResults(); 271 + searchInputRef()?.focus(); 272 + } 273 + 274 + function openActor(actor: Pick<ProfileViewBasic, "did" | "handle">) { 275 + void navigate(buildProfileRoute(getProfileRouteActor(actor))); 276 + } 277 + 278 + function openThread(uri: string) { 279 + void postNavigation.openPost(uri); 280 + } 281 + 282 + function handleKeyDown(event: KeyboardEvent) { 283 + if (routeState().tab === "profiles") { 284 + if (event.key === "ArrowDown") { 285 + event.preventDefault(); 286 + actorSuggestions.moveActiveIndex(1); 287 + return; 288 + } 289 + 290 + if (event.key === "ArrowUp") { 291 + event.preventDefault(); 292 + actorSuggestions.moveActiveIndex(-1); 293 + return; 294 + } 295 + 296 + if (event.key === "Enter" && actorSuggestions.open() && actorSuggestions.activeSuggestion()) { 297 + event.preventDefault(); 298 + openActor(actorSuggestions.activeSuggestion() as ProfileViewBasic); 299 + actorSuggestions.close(); 300 + return; 301 + } 302 + } 303 + 304 + if ( 305 + routeState().tab === "posts" && event.key === "Tab" && !event.shiftKey 306 + && document.activeElement === searchInputRef() 307 + ) { 308 + event.preventDefault(); 309 + cycleMode(); 310 + return; 311 + } 312 + 313 + if (event.key === "Escape" && routeState().q) { 314 + clearSearch(); 315 + return; 316 + } 317 + 318 + if (event.key === "Escape" && routeState().tab === "profiles") { 319 + actorSuggestions.close(); 320 + } 321 + } 322 + 323 + function handleGlobalKeyDown(event: KeyboardEvent) { 324 + if (event.key !== "/" && !((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f")) { 325 + return; 326 + } 327 + 328 + const target = event.target as HTMLElement; 329 + if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") { 330 + return; 331 + } 332 + 333 + event.preventDefault(); 334 + searchInputRef()?.focus(); 335 + } 336 + 337 + function setSyncStatus(status: SyncStatus[]) { 338 + setSearch("syncStatus", status); 339 + } 340 + 341 + onMount(() => { 342 + if (!options.embedded) { 343 + document.addEventListener("keydown", handleGlobalKeyDown); 344 + } 345 + 346 + if (options.embedded && session.activeDid) { 347 + void SearchController.getSyncStatus(session.activeDid).then((status) => { 348 + setSearch("syncStatus", status); 349 + }).catch((error) => { 350 + logger.warn("failed to load embedded search sync status", { keyValues: { error: normalizeError(error) } }); 351 + }); 352 + } 353 + 354 + onCleanup(() => { 355 + if (!options.embedded) { 356 + document.removeEventListener("keydown", handleGlobalKeyDown); 357 + } 358 + 359 + clearTimeout(debounceTimer); 360 + }); 361 + }); 362 + 363 + createEffect(() => { 364 + if ((routeState().mode === "semantic" || routeState().mode === "hybrid") && !semanticEnabled()) { 365 + replaceRoute({ mode: "keyword" }); 366 + } 367 + }); 368 + 369 + createEffect(() => { 370 + routeState(); 371 + clearTimeout(debounceTimer); 372 + debounceTimer = setTimeout(() => { 373 + void performSearch(); 374 + }, SEARCH_DEBOUNCE_MS); 375 + }); 376 + 377 + return { 378 + actions: { 379 + clearSearch, 380 + handleFilterChange, 381 + handleInput, 382 + handleKeyDown, 383 + handleModeChange, 384 + handleTabChange, 385 + openActor, 386 + openThread, 387 + setSyncStatus, 388 + }, 389 + actorSuggestions: { 390 + activeIndex: actorSuggestions.activeIndex, 391 + focus: actorSuggestions.focus, 392 + open: actorSuggestions.open, 393 + suggestions: actorSuggestions.suggestions, 394 + }, 395 + derived: { 396 + hasLocalPosts, 397 + isActorTab, 398 + isLocalMode, 399 + lastSync, 400 + networkFiltersEnabled, 401 + semanticEnabled, 402 + totalIndexedPosts, 403 + }, 404 + refs: { setActorSearchContainerRef, setSearchInputRef }, 405 + routeState, 406 + search, 407 + session: { activeDid: () => session.activeDid }, 408 + }; 409 + }