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: shared hooks & controllers

+294 -250
+14 -43
src/components/LoginPanel.tsx
··· 1 1 import { ActorSuggestionList, useActorSuggestions } from "$/components/actors/ActorSearch"; 2 - import type { LoginSuggestion } from "$/lib/types"; 2 + import { ActorTypeaheadLoading } from "$/components/actors/ActorTypeaheadLoading"; 3 + import { useActorTypeaheadCombobox } from "$/components/actors/hooks/useActorTypeaheadCombobox"; 4 + import type { ActorSuggestion } from "$/lib/types"; 3 5 import { createEffect, Show } from "solid-js"; 4 6 import { Motion } from "solid-motionone"; 5 7 import { Icon } from "./shared/Icon"; ··· 40 42 input: () => input, 41 43 value: () => props.value, 42 44 }); 45 + const combobox = useActorTypeaheadCombobox({ 46 + ariaControls: "login-suggestions", 47 + onSelect: applySuggestion, 48 + typeahead, 49 + }); 43 50 44 51 createEffect(() => { 45 52 if (props.shakeCount > 0) { ··· 48 55 } 49 56 }); 50 57 51 - function applySuggestion(suggestion: LoginSuggestion) { 58 + function applySuggestion(suggestion: ActorSuggestion) { 52 59 props.onInput(suggestion.handle); 53 60 typeahead.close(); 54 61 input?.focus(); 55 62 } 56 63 57 - function handleKeyDown(event: KeyboardEvent) { 58 - if (event.key === "ArrowDown") { 59 - event.preventDefault(); 60 - typeahead.moveActiveIndex(1); 61 - return; 62 - } 63 - 64 - if (event.key === "ArrowUp") { 65 - event.preventDefault(); 66 - typeahead.moveActiveIndex(-1); 67 - return; 68 - } 69 - 70 - if (event.key === "Escape") { 71 - typeahead.close(); 72 - return; 73 - } 74 - 75 - if (event.key === "Enter" && typeahead.open() && typeahead.activeSuggestion()) { 76 - event.preventDefault(); 77 - applySuggestion(typeahead.activeSuggestion() as LoginSuggestion); 78 - } 79 - } 80 - 81 64 return ( 82 65 <article 83 66 class="panel-surface grid gap-5 p-5" ··· 119 102 type="text" 120 103 role="combobox" 121 104 aria-autocomplete="list" 122 - aria-controls="login-suggestions" 123 - aria-activedescendant={typeahead.activeIndex() >= 0 124 - ? `login-suggestions-option-${typeahead.activeIndex()}` 125 - : undefined} 126 - aria-expanded={typeahead.open()} 105 + aria-controls={combobox.a11y.controls} 106 + aria-activedescendant={combobox.a11y.activeDescendant()} 107 + aria-expanded={combobox.a11y.expanded()} 127 108 autocomplete="username" 128 109 spellcheck={false} 129 110 value={props.value} 130 111 placeholder="alice.bsky.social" 131 112 onFocus={() => typeahead.focus()} 132 113 onInput={(event) => props.onInput(event.currentTarget.value)} 133 - onKeyDown={(event) => handleKeyDown(event)} /> 134 - <LoginLoadingIndicator visible={typeahead.loading()} /> 114 + onKeyDown={(event) => combobox.handleKeyDown(event)} /> 115 + <ActorTypeaheadLoading visible={typeahead.loading()} class="right-4" /> 135 116 <ActorSuggestionList 136 117 activeIndex={typeahead.activeIndex()} 137 118 id="login-suggestions" ··· 146 127 </article> 147 128 ); 148 129 } 149 - 150 - function LoginLoadingIndicator(props: { visible: boolean }) { 151 - return ( 152 - <Show when={props.visible}> 153 - <span class="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 text-on-surface-variant"> 154 - <Icon kind="loader" aria-hidden="true" /> 155 - </span> 156 - </Show> 157 - ); 158 - }
+3 -12
src/components/actors/ActorSearch.tsx
··· 1 1 import { AvatarBadge } from "$/components/AvatarBadge"; 2 - import { searchActorSuggestions } from "$/lib/api/actors"; 2 + import { TypeaheadController } from "$/lib/api/typeahead"; 3 3 import type { ActorSuggestion } from "$/lib/types"; 4 4 import { type Accessor, createEffect, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 5 5 ··· 13 13 value: Accessor<string>; 14 14 }; 15 15 16 - function normalizeActorSuggestionQuery(value: string) { 17 - const trimmed = value.trim(); 18 - if (trimmed.length < 2 || trimmed.startsWith("did:") || /^https?:\/\//i.test(trimmed)) { 19 - return ""; 20 - } 21 - 22 - return trimmed.replace(/^@/, ""); 23 - } 24 - 25 16 export function getActorSuggestionHeadline(suggestion: ActorSuggestion) { 26 17 const displayName = suggestion.displayName?.trim(); 27 18 return displayName && displayName !== suggestion.handle ? displayName : suggestion.handle.replace(/^@/, ""); ··· 36 27 const [suggestions, setSuggestions] = createSignal<ActorSuggestion[]>([]); 37 28 38 29 createEffect(() => { 39 - const query = normalizeActorSuggestionQuery(options.value()); 30 + const query = TypeaheadController.normalizeQuery(options.value()); 40 31 const disabled = options.disabled?.() ?? false; 41 32 const nextRequestId = requestId + 1; 42 33 requestId = nextRequestId; ··· 52 43 setLoading(true); 53 44 54 45 const timeout = globalThis.setTimeout(() => { 55 - void searchActorSuggestions(query).then((results) => { 46 + void TypeaheadController.searchActor(query).then((results) => { 56 47 if (requestId !== nextRequestId) { 57 48 return; 58 49 }
+19
src/components/actors/ActorTypeaheadLoading.tsx
··· 1 + import { Icon } from "$/components/shared/Icon"; 2 + import { Show } from "solid-js"; 3 + 4 + type ActorTypeaheadLoadingProps = { class?: string; iconClass?: string; inline?: boolean; visible: boolean }; 5 + 6 + export function ActorTypeaheadLoading(props: ActorTypeaheadLoadingProps) { 7 + const defaultClass = () => 8 + props.inline 9 + ? "flex items-center text-on-surface-variant" 10 + : "pointer-events-none absolute top-1/2 -translate-y-1/2 text-on-surface-variant right-3"; 11 + 12 + return ( 13 + <Show when={props.visible}> 14 + <span class={`${defaultClass()} ${props.class ?? ""}`}> 15 + <Icon kind="loader" aria-hidden="true" class={props.iconClass} /> 16 + </span> 17 + </Show> 18 + ); 19 + }
+69
src/components/actors/hooks/useActorTypeaheadCombobox.ts
··· 1 + import type { ActorSuggestion } from "$/lib/types"; 2 + import { type Accessor, createMemo } from "solid-js"; 3 + 4 + type ActorTypeaheadController = { 5 + activeIndex: Accessor<number>; 6 + activeSuggestion: () => ActorSuggestion | null; 7 + close: () => void; 8 + moveActiveIndex: (direction: 1 | -1) => void; 9 + open: Accessor<boolean>; 10 + }; 11 + 12 + type HandleActorTypeaheadKeyDownOptions = { 13 + onEscape?: () => void; 14 + onSelect: (suggestion: ActorSuggestion) => void; 15 + typeahead: ActorTypeaheadController; 16 + }; 17 + 18 + type UseActorTypeaheadComboboxOptions = HandleActorTypeaheadKeyDownOptions & { ariaControls: string }; 19 + 20 + export function handleActorTypeaheadKeyDown(event: KeyboardEvent, options: HandleActorTypeaheadKeyDownOptions) { 21 + if (event.key === "ArrowDown") { 22 + event.preventDefault(); 23 + options.typeahead.moveActiveIndex(1); 24 + return true; 25 + } 26 + 27 + if (event.key === "ArrowUp") { 28 + event.preventDefault(); 29 + options.typeahead.moveActiveIndex(-1); 30 + return true; 31 + } 32 + 33 + if (event.key === "Escape") { 34 + if (options.onEscape) { 35 + options.onEscape(); 36 + } else { 37 + options.typeahead.close(); 38 + } 39 + return true; 40 + } 41 + 42 + if (event.key !== "Enter" || !options.typeahead.open()) { 43 + return false; 44 + } 45 + 46 + const suggestion = options.typeahead.activeSuggestion(); 47 + if (!suggestion) { 48 + return false; 49 + } 50 + 51 + event.preventDefault(); 52 + options.onSelect(suggestion); 53 + return true; 54 + } 55 + 56 + export function useActorTypeaheadCombobox(options: UseActorTypeaheadComboboxOptions) { 57 + const activeDescendant = createMemo(() => 58 + options.typeahead.activeIndex() >= 0 59 + ? `${options.ariaControls}-option-${options.typeahead.activeIndex()}` 60 + : undefined 61 + ); 62 + const expanded = createMemo(() => options.typeahead.open()); 63 + 64 + function handleKeyDown(event: KeyboardEvent) { 65 + handleActorTypeaheadKeyDown(event, options); 66 + } 67 + 68 + return { a11y: { activeDescendant, controls: options.ariaControls, expanded }, handleKeyDown }; 69 + }
+15 -45
src/components/deck/ColumnPicker/ProfileColumnPicker.tsx
··· 1 1 import { ActorSuggestionList, useActorSuggestions } from "$/components/actors/ActorSearch"; 2 - import { Icon } from "$/components/shared/Icon"; 3 - import type { LoginSuggestion } from "$/lib/types"; 2 + import { ActorTypeaheadLoading } from "$/components/actors/ActorTypeaheadLoading"; 3 + import { useActorTypeaheadCombobox } from "$/components/actors/hooks/useActorTypeaheadCombobox"; 4 + import type { ActorSuggestion } from "$/lib/types"; 4 5 import * as logger from "@tauri-apps/plugin-log"; 5 - import { createSignal, Show } from "solid-js"; 6 + import { createSignal } from "solid-js"; 6 7 import type { ProfileSelection } from "../types"; 7 8 8 - function TypeaheadLoading(props: { visible: boolean }) { 9 - return ( 10 - <Show when={props.visible}> 11 - <span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-on-surface-variant"> 12 - <Icon kind="loader" class="animate-spin text-sm" /> 13 - </span> 14 - </Show> 15 - ); 16 - } 17 - 18 9 export function ProfilePicker(props: { onSubmit: (selection: ProfileSelection) => void }) { 19 10 let container: HTMLDivElement | undefined; 20 11 let input: HTMLInputElement | undefined; ··· 25 16 onError: (error) => logger.warn(`Failed to load profile suggestions: ${String(error)}`), 26 17 value, 27 18 }); 19 + const combobox = useActorTypeaheadCombobox({ 20 + ariaControls: "profile-suggestions", 21 + onSelect: submitSuggestion, 22 + typeahead, 23 + }); 28 24 29 25 function submitManualActor() { 30 26 const actor = value().trim(); ··· 36 32 props.onSubmit({ actor }); 37 33 } 38 34 39 - function submitSuggestion(suggestion: LoginSuggestion) { 35 + function submitSuggestion(suggestion: ActorSuggestion) { 40 36 typeahead.close(); 41 37 props.onSubmit({ 42 38 actor: suggestion.handle, ··· 46 42 }); 47 43 } 48 44 49 - function handleKeyDown(event: KeyboardEvent) { 50 - if (event.key === "ArrowDown") { 51 - event.preventDefault(); 52 - typeahead.moveActiveIndex(1); 53 - return; 54 - } 55 - 56 - if (event.key === "ArrowUp") { 57 - event.preventDefault(); 58 - typeahead.moveActiveIndex(-1); 59 - return; 60 - } 61 - 62 - if (event.key === "Escape") { 63 - typeahead.close(); 64 - return; 65 - } 66 - 67 - if (event.key === "Enter" && typeahead.open() && typeahead.activeSuggestion()) { 68 - event.preventDefault(); 69 - submitSuggestion(typeahead.activeSuggestion() as LoginSuggestion); 70 - } 71 - } 72 - 73 45 return ( 74 46 <form 75 47 class="grid gap-3" ··· 91 63 type="text" 92 64 role="combobox" 93 65 aria-autocomplete="list" 94 - aria-controls="profile-suggestions" 95 - aria-activedescendant={typeahead.activeIndex() >= 0 96 - ? `profile-suggestions-option-${typeahead.activeIndex()}` 97 - : undefined} 98 - aria-expanded={typeahead.open()} 66 + aria-controls={combobox.a11y.controls} 67 + aria-activedescendant={combobox.a11y.activeDescendant()} 68 + aria-expanded={combobox.a11y.expanded()} 99 69 class="ui-input ui-input-strong w-full rounded-xl px-4 py-2.5 pr-10" 100 70 placeholder="alice.bsky.social" 101 71 spellcheck={false} 102 72 value={value()} 103 73 onFocus={() => typeahead.focus()} 104 74 onInput={(event) => setValue(event.currentTarget.value)} 105 - onKeyDown={(event) => handleKeyDown(event)} /> 75 + onKeyDown={(event) => combobox.handleKeyDown(event)} /> 106 76 107 - <TypeaheadLoading visible={typeahead.loading()} /> 77 + <ActorTypeaheadLoading visible={typeahead.loading()} iconClass="animate-spin text-sm" /> 108 78 <ActorSuggestionList 109 79 activeIndex={typeahead.activeIndex()} 110 80 id="profile-suggestions"
+19 -23
src/components/explorer/ExplorerPanel.tsx
··· 1 - import { 2 - clearLexiconFaviconCache, 3 - describeRepo, 4 - describeServer, 5 - exportRepoCar, 6 - getLexiconFavicons, 7 - getRecord, 8 - listRecords, 9 - queryLabels, 10 - resolveInput, 11 - } from "$/lib/api/explorer"; 12 - import { getProfile } from "$/lib/api/profile"; 1 + import { ExplorerController } from "$/lib/api/explorer"; 2 + import { ProfileController } from "$/lib/api/profile"; 13 3 import type { ExplorerNavigation, ExplorerTargetKind } from "$/lib/api/types/explorer"; 14 4 import { NAVIGATION_EVENT } from "$/lib/constants/events"; 15 5 import { consumeQueuedExplorerTarget } from "$/lib/explorer-navigation"; ··· 112 102 } 113 103 114 104 try { 115 - const icons = await getLexiconFavicons(pendingCollections); 105 + const icons = await ExplorerController.getLexiconFavicons(pendingCollections); 116 106 explorer.mergeLexiconIcons(icons); 117 107 } catch (error) { 118 108 logger.warn("Failed to load lexicon favicons for explorer", { ··· 152 142 setCurrentView({ level: "repo", input: submittedInput, resolved: null, loading: true, error: null, data: null }); 153 143 154 144 try { 155 - const resolved = await resolveInput(submittedInput); 145 + const resolved = await ExplorerController.resolveInput(submittedInput); 156 146 if (requestId !== resolveRequestId) return; 157 147 158 148 const level = resolveTargetLevel(resolved.targetKind); ··· 166 156 switch (resolved.targetKind) { 167 157 case "pds": { 168 158 if (resolved.pdsUrl) { 169 - const serverView = await describeServer(resolved.pdsUrl); 159 + const serverView = await ExplorerController.describeServer(resolved.pdsUrl); 170 160 finalViewState = { 171 161 ...viewState, 172 162 loading: false, ··· 178 168 case "repo": { 179 169 if (resolved.did) { 180 170 const [repoData, profile] = await Promise.all([ 181 - describeRepo(resolved.did), 182 - getProfile(resolved.did).catch(() => null), 171 + ExplorerController.describeRepo(resolved.did), 172 + ProfileController.getProfile(resolved.did).catch(() => null), 183 173 ]); 184 174 const profileData = profile?.status === "available" ? profile.profile : null; 185 175 const collections = extractCollections(repoData); ··· 204 194 } 205 195 case "collection": { 206 196 if (resolved.did && resolved.collection) { 207 - const listData = await listRecords(resolved.did, resolved.collection); 197 + const listData = await ExplorerController.listRecords(resolved.did, resolved.collection); 208 198 finalViewState = { 209 199 ...viewState, 210 200 loading: false, ··· 222 212 case "record": { 223 213 if (resolved.did && resolved.collection && resolved.rkey) { 224 214 const [recordData, labels] = await Promise.all([ 225 - getRecord(resolved.did, resolved.collection, resolved.rkey), 226 - resolved.uri ? queryLabels(resolved.uri).catch(() => ({ labels: [] })) : Promise.resolve({ labels: [] }), 215 + ExplorerController.getRecord(resolved.did, resolved.collection, resolved.rkey), 216 + resolved.uri 217 + ? ExplorerController.queryLabels(resolved.uri).catch(() => ({ labels: [] })) 218 + : Promise.resolve({ labels: [] }), 227 219 ]); 228 220 finalViewState = { 229 221 ...viewState, ··· 335 327 }); 336 328 337 329 try { 338 - const nextPage = await listRecords(collectionData.did, collectionData.collection, collectionData.cursor); 330 + const nextPage = await ExplorerController.listRecords( 331 + collectionData.did, 332 + collectionData.collection, 333 + collectionData.cursor, 334 + ); 339 335 const nextRecords = (nextPage.records as Array<Record<string, unknown>>) || []; 340 336 const nextCursor = (nextPage.cursor as string) || null; 341 337 ··· 360 356 if (!did) return; 361 357 362 358 try { 363 - const result = await exportRepoCar(did); 359 + const result = await ExplorerController.exportRepoCar(did); 364 360 setStatusMessage({ kind: "success", text: `Saved CAR export to ${result.path}` }); 365 361 } catch (error) { 366 362 setStatusMessage({ kind: "error", text: String(error) }); ··· 376 372 setStatusMessage(null); 377 373 378 374 try { 379 - await clearLexiconFaviconCache(); 375 + await ExplorerController.clearLexiconFaviconCache(); 380 376 explorer.resetLexiconIcons(); 381 377 setStatusMessage({ kind: "success", text: "Cleared explorer icon cache." }); 382 378
+15 -38
src/components/explorer/ExplorerUrlBar.tsx
··· 1 1 import { ActorSuggestionList, useActorSuggestions } from "$/components/actors/ActorSearch"; 2 + import { ActorTypeaheadLoading } from "$/components/actors/ActorTypeaheadLoading"; 3 + import { useActorTypeaheadCombobox } from "$/components/actors/hooks/useActorTypeaheadCombobox"; 2 4 import { ArrowIcon, Icon } from "$/components/shared/Icon"; 3 - import type { LoginSuggestion } from "$/lib/types"; 4 - import { createEffect, createSignal, Show } from "solid-js"; 5 + import type { ActorSuggestion } from "$/lib/types"; 6 + import { createEffect, createSignal } from "solid-js"; 5 7 6 8 type ExplorerUrlBarProps = { 7 9 value: string; ··· 40 42 input: () => input, 41 43 value: () => props.value, 42 44 }); 45 + const combobox = useActorTypeaheadCombobox({ 46 + ariaControls: "explorer-suggestions", 47 + onSelect: applySuggestion, 48 + typeahead, 49 + }); 43 50 44 51 createEffect(() => { 45 52 if (focused() && typeahead.suggestions().length > 0 && props.value.trim().startsWith("@")) { ··· 52 59 props.onSubmit(props.value); 53 60 } 54 61 55 - function applySuggestion(suggestion: LoginSuggestion) { 62 + function applySuggestion(suggestion: ActorSuggestion) { 56 63 const nextValue = suggestion.handle.startsWith("@") ? suggestion.handle : `@${suggestion.handle}`; 57 64 props.onInput(nextValue); 58 65 typeahead.close(); ··· 60 67 input?.focus(); 61 68 } 62 69 63 - function handleKeyDown(event: KeyboardEvent) { 64 - if (event.key === "ArrowDown") { 65 - event.preventDefault(); 66 - typeahead.moveActiveIndex(1); 67 - return; 68 - } 69 - 70 - if (event.key === "ArrowUp") { 71 - event.preventDefault(); 72 - typeahead.moveActiveIndex(-1); 73 - return; 74 - } 75 - 76 - if (event.key === "Escape") { 77 - typeahead.close(); 78 - return; 79 - } 80 - 81 - if (event.key === "Enter" && typeahead.open() && typeahead.activeSuggestion()) { 82 - event.preventDefault(); 83 - applySuggestion(typeahead.activeSuggestion() as LoginSuggestion); 84 - } 85 - } 86 - 87 70 return ( 88 71 <form 89 72 ref={(element) => { ··· 106 89 type="text" 107 90 role="combobox" 108 91 aria-autocomplete="list" 109 - aria-controls="explorer-suggestions" 110 - aria-activedescendant={typeahead.activeIndex() >= 0 111 - ? `explorer-suggestions-option-${typeahead.activeIndex()}` 112 - : undefined} 113 - aria-expanded={typeahead.open()} 92 + aria-controls={combobox.a11y.controls} 93 + aria-activedescendant={combobox.a11y.activeDescendant()} 94 + aria-expanded={combobox.a11y.expanded()} 114 95 value={props.value} 115 96 spellcheck={false} 116 97 onInput={(event) => props.onInput(event.currentTarget.value)} ··· 122 103 setFocused(false); 123 104 typeahead.close(); 124 105 }} 125 - onKeyDown={(event) => handleKeyDown(event)} 106 + onKeyDown={(event) => combobox.handleKeyDown(event)} 126 107 class="flex-1 bg-transparent text-sm font-mono outline-none text-on-surface placeholder:text-on-surface-variant/50" 127 108 placeholder="at://did:... or @handle or https://pds..." /> 128 - <Show when={typeahead.loading()}> 129 - <span class="flex items-center text-on-surface-variant"> 130 - <Icon kind="loader" aria-hidden="true" /> 131 - </span> 132 - </Show> 109 + <ActorTypeaheadLoading visible={typeahead.loading()} inline /> 133 110 <button 134 111 type="submit" 135 112 class="rounded-lg p-1.5 text-on-surface-variant transition-all hover:bg-surface-bright hover:text-on-surface">
+12 -10
src/components/explorer/tests/ExplorerPanel.test.tsx
··· 18 18 vi.mock( 19 19 "$/lib/api/explorer", 20 20 () => ({ 21 - describeRepo: describeRepoMock, 22 - describeServer: describeServerMock, 23 - exportRepoCar: exportRepoCarMock, 24 - clearLexiconFaviconCache: clearLexiconFaviconCacheMock, 25 - getLexiconFavicons: getLexiconFaviconsMock, 26 - getRecord: getRecordMock, 27 - listRecords: listRecordsMock, 28 - queryLabels: queryLabelsMock, 29 - resolveInput: resolveInputMock, 21 + ExplorerController: { 22 + describeRepo: describeRepoMock, 23 + describeServer: describeServerMock, 24 + exportRepoCar: exportRepoCarMock, 25 + clearLexiconFaviconCache: clearLexiconFaviconCacheMock, 26 + getLexiconFavicons: getLexiconFaviconsMock, 27 + getRecord: getRecordMock, 28 + listRecords: listRecordsMock, 29 + queryLabels: queryLabelsMock, 30 + resolveInput: resolveInputMock, 31 + }, 30 32 }), 31 33 ); 32 34 33 - vi.mock("$/lib/api/profile", () => ({ getProfile: getProfileMock })); 35 + vi.mock("$/lib/api/profile", () => ({ ProfileController: { getProfile: getProfileMock } })); 34 36 vi.mock("$/lib/api/diagnostics", () => ({ getRecordBacklinks: getRecordBacklinksMock })); 35 37 vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); 36 38
+14 -6
src/components/explorer/tests/ExplorerUrlBar.test.tsx
··· 2 2 import { beforeEach, describe, expect, it, vi } from "vitest"; 3 3 import { ExplorerUrlBar } from "../ExplorerUrlBar"; 4 4 5 - const searchActorSuggestionsMock = vi.hoisted(() => vi.fn()); 5 + const searchActorTypeaheadMock = vi.hoisted(() => vi.fn()); 6 6 7 - vi.mock("$/lib/api/actors", () => ({ searchActorSuggestions: searchActorSuggestionsMock })); 7 + vi.mock( 8 + "$/lib/api/typeahead", 9 + () => ({ 10 + TypeaheadController: { 11 + normalizeQuery: (value: string) => value.trim().replace(/^@/, ""), 12 + searchActor: searchActorTypeaheadMock, 13 + }, 14 + }), 15 + ); 8 16 9 17 describe("ExplorerUrlBar", () => { 10 18 beforeEach(() => { 11 - searchActorSuggestionsMock.mockReset(); 19 + searchActorTypeaheadMock.mockReset(); 12 20 }); 13 21 14 22 it("opens typeahead for @ input and not for other input kinds", async () => { 15 23 const onInput = vi.fn(); 16 24 const onSubmit = vi.fn(); 17 - searchActorSuggestionsMock.mockResolvedValue([{ did: "did:plc:alice", handle: "alice.test" }]); 25 + searchActorTypeaheadMock.mockResolvedValue([{ did: "did:plc:alice", handle: "alice.test" }]); 18 26 19 27 render(() => ( 20 28 <ExplorerUrlBar ··· 35 43 input.focus(); 36 44 fireEvent.focus(input); 37 45 38 - await waitFor(() => expect(searchActorSuggestionsMock).toHaveBeenCalledWith("ali")); 46 + await waitFor(() => expect(searchActorTypeaheadMock).toHaveBeenCalledWith("ali")); 39 47 await waitFor(() => expect(input).toHaveAttribute("aria-expanded", "true")); 40 48 expect(await screen.findByRole("option", { name: /alice\.test/u })).toBeInTheDocument(); 41 49 ··· 61 69 it("submits the highlighted suggestion on enter and on click", async () => { 62 70 const onInput = vi.fn(); 63 71 const onSubmit = vi.fn(); 64 - searchActorSuggestionsMock.mockResolvedValue([{ did: "did:plc:alice", handle: "alice.test" }]); 72 + searchActorTypeaheadMock.mockResolvedValue([{ did: "did:plc:alice", handle: "alice.test" }]); 65 73 66 74 render(() => ( 67 75 <ExplorerUrlBar
+10 -18
src/components/profile/ProfilePanel.tsx
··· 3 3 import { usePostNavigation } from "$/components/posts/hooks/usePostNavigation"; 4 4 import { ProfileSkeleton } from "$/components/ProfileSkeleton"; 5 5 import { useAppSession } from "$/contexts/app-session"; 6 - import { 7 - followActor, 8 - getActorLikes, 9 - getAuthorFeed, 10 - getFollowers, 11 - getFollows, 12 - getProfile, 13 - unfollowActor, 14 - } from "$/lib/api/profile"; 6 + import { ProfileController } from "$/lib/api/profile"; 15 7 import { buildMessagesRoute } from "$/lib/conversations"; 16 8 import { queueExplorerTarget } from "$/lib/explorer-navigation"; 17 9 import { patchFeedItems } from "$/lib/feeds"; ··· 135 127 136 128 async function loadProfile(sequence: number, actor: string) { 137 129 try { 138 - const result = await getProfile(actor); 130 + const result = await ProfileController.getProfile(actor); 139 131 if (sequence !== requestSequence || actor !== activeActor()) { 140 132 return; 141 133 } ··· 164 156 setState("authorFeed", loadMore ? "loadingMore" : "loading", true); 165 157 166 158 try { 167 - const response = await getAuthorFeed(actor, loadMore ? feed.cursor : null, FEED_PAGE_SIZE); 159 + const response = await ProfileController.getAuthorFeed(actor, loadMore ? feed.cursor : null, FEED_PAGE_SIZE); 168 160 if (sequence !== requestSequence || actor !== activeActor()) { 169 161 return; 170 162 } ··· 194 186 setState("likesFeed", loadMore ? "loadingMore" : "loading", true); 195 187 196 188 try { 197 - const response = await getActorLikes(actor, loadMore ? feed.cursor : null, FEED_PAGE_SIZE); 189 + const response = await ProfileController.getActorLikes(actor, loadMore ? feed.cursor : null, FEED_PAGE_SIZE); 198 190 if (sequence !== requestSequence || actor !== activeActor()) { 199 191 return; 200 192 } ··· 296 288 setState("profile", "followersCount", prevFollowersCount + 1); 297 289 298 290 try { 299 - const result = await followActor(profile.did); 291 + const result = await ProfileController.followActor(profile.did); 300 292 setState("profile", "viewer", { ...state.profile?.viewer, following: result.uri }); 301 293 } catch { 302 294 setState("profile", "viewer", prevViewer ?? null); ··· 320 312 setState("profile", "followersCount", Math.max(0, prevFollowersCount - 1)); 321 313 322 314 try { 323 - await unfollowActor(followUri); 315 + await ProfileController.unfollowActor(followUri); 324 316 } catch { 325 317 setState("profile", "viewer", prevViewer ?? null); 326 318 setState("profile", "followersCount", prevFollowersCount); ··· 360 352 361 353 try { 362 354 const response: ActorListResponse = kind === "followers" 363 - ? await getFollowers(actor, cursor) 364 - : await getFollows(actor, cursor); 355 + ? await ProfileController.getFollowers(actor, cursor) 356 + : await ProfileController.getFollows(actor, cursor); 365 357 366 358 const nextActors = loadMore ? [...current.actors, ...response.actors] : response.actors; 367 359 setState("actorList", { ··· 408 400 updateActorListActor(actor.did, (current) => withActorFollowing(current, "optimistic")); 409 401 410 402 try { 411 - const result = await followActor(actor.did); 403 + const result = await ProfileController.followActor(actor.did); 412 404 updateActorListActor(actor.did, (current) => withActorFollowing(current, result.uri)); 413 405 } catch { 414 406 updateActorListActor(actor.did, (current) => ({ ...current, viewer: previousViewer })); ··· 433 425 updateActorListActor(actor.did, (current) => withActorFollowing(current, null)); 434 426 435 427 try { 436 - await unfollowActor(followUri); 428 + await ProfileController.unfollowActor(followUri); 437 429 } catch { 438 430 updateActorListActor(actor.did, (current) => ({ ...current, viewer: previousViewer })); 439 431 } finally {
+9 -7
src/components/profile/tests/ProfilePanel.test.tsx
··· 26 26 vi.mock( 27 27 "$/lib/api/profile", 28 28 () => ({ 29 - followActor: followActorMock, 30 - getActorLikes: getActorLikesMock, 31 - getAuthorFeed: getAuthorFeedMock, 32 - getFollowers: getFollowersMock, 33 - getFollows: getFollowsMock, 34 - getProfile: getProfileMock, 35 - unfollowActor: unfollowActorMock, 29 + ProfileController: { 30 + followActor: followActorMock, 31 + getActorLikes: getActorLikesMock, 32 + getAuthorFeed: getAuthorFeedMock, 33 + getFollowers: getFollowersMock, 34 + getFollows: getFollowsMock, 35 + getProfile: getProfileMock, 36 + unfollowActor: unfollowActorMock, 37 + }, 36 38 }), 37 39 ); 38 40
+19 -19
src/components/search/hooks/useSearchController.ts
··· 1 1 import { useActorSuggestions } from "$/components/actors/ActorSearch"; 2 + import { handleActorTypeaheadKeyDown } from "$/components/actors/hooks/useActorTypeaheadCombobox"; 2 3 import { usePostNavigation } from "$/components/posts/hooks/usePostNavigation"; 3 4 import { useAppPreferences } from "$/contexts/app-preferences"; 4 5 import { useAppSession } from "$/contexts/app-session"; ··· 108 109 const semanticEnabled = createMemo(() => 109 110 !!preferences.embeddingsConfig?.enabled && !!preferences.embeddingsConfig?.downloaded 110 111 ); 112 + 111 113 const totalIndexedPosts = createMemo(() => 112 114 search.syncStatus.reduce((sum, status) => sum + (status.postCount ?? 0), 0) 113 115 ); 116 + 114 117 const hasLocalPosts = createMemo(() => totalIndexedPosts() > 0); 115 118 const lastSync = createMemo(() => { 116 119 const timestamps = search.syncStatus.map((status) => status.lastSyncedAt).filter(Boolean) as string[]; ··· 120 123 121 124 return formatRelativeTime(timestamps.toSorted((left, right) => right.localeCompare(left))[0]); 122 125 }); 126 + 123 127 const cycleModes = createMemo(() => 124 128 MODES.filter((candidate) => semanticEnabled() || (candidate !== "semantic" && candidate !== "hybrid")) 125 129 ); ··· 281 285 282 286 function handleKeyDown(event: KeyboardEvent) { 283 287 if (routeState().tab === "profiles") { 284 - if (event.key === "ArrowDown") { 285 - event.preventDefault(); 286 - typeahead.moveActiveIndex(1); 287 - return; 288 - } 288 + const handled = handleActorTypeaheadKeyDown(event, { 289 + onEscape: () => { 290 + if (routeState().q) { 291 + clearSearch(); 292 + return; 293 + } 289 294 290 - if (event.key === "ArrowUp") { 291 - event.preventDefault(); 292 - typeahead.moveActiveIndex(-1); 293 - return; 294 - } 295 + typeahead.close(); 296 + }, 297 + onSelect: (suggestion) => { 298 + openActor(suggestion); 299 + typeahead.close(); 300 + }, 301 + typeahead, 302 + }); 295 303 296 - if (event.key === "Enter" && typeahead.open() && typeahead.activeSuggestion()) { 297 - event.preventDefault(); 298 - openActor(typeahead.activeSuggestion() as ProfileViewBasic); 299 - typeahead.close(); 304 + if (handled) { 300 305 return; 301 306 } 302 307 } ··· 312 317 313 318 if (event.key === "Escape" && routeState().q) { 314 319 clearSearch(); 315 - return; 316 - } 317 - 318 - if (event.key === "Escape" && routeState().tab === "profiles") { 319 - typeahead.close(); 320 320 } 321 321 } 322 322
+13 -5
src/components/search/tests/SearchPanel.test.tsx
··· 5 5 import { beforeEach, describe, expect, it, vi } from "vitest"; 6 6 import { SearchPanel } from "../SearchPanel"; 7 7 8 - const searchActorSuggestionsMock = vi.hoisted(() => vi.fn()); 8 + const searchActorTypeaheadMock = vi.hoisted(() => vi.fn()); 9 9 const searchActorsMock = vi.hoisted(() => vi.fn()); 10 10 const searchPostsMock = vi.hoisted(() => vi.fn()); 11 11 const searchPostsNetworkMock = vi.hoisted(() => vi.fn()); ··· 25 25 }, 26 26 }), 27 27 ); 28 - vi.mock("$/lib/api/actors", () => ({ searchActorSuggestions: searchActorSuggestionsMock })); 28 + vi.mock( 29 + "$/lib/api/typeahead", 30 + () => ({ 31 + TypeaheadController: { 32 + normalizeQuery: (value: string) => value.trim().replace(/^@/, ""), 33 + searchActor: searchActorTypeaheadMock, 34 + }, 35 + }), 36 + ); 29 37 vi.mock("$/components/posts/usePostNavigation", () => ({ usePostNavigation: () => postNavigationMock })); 30 38 31 39 vi.mock("@tauri-apps/plugin-log", () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); ··· 51 59 describe("SearchPanel", () => { 52 60 beforeEach(() => { 53 61 vi.useFakeTimers(); 54 - searchActorSuggestionsMock.mockReset(); 62 + searchActorTypeaheadMock.mockReset(); 55 63 searchActorsMock.mockReset(); 56 64 searchPostsMock.mockReset(); 57 65 searchPostsNetworkMock.mockReset(); ··· 60 68 postNavigationMock.openPost.mockReset(); 61 69 62 70 getSyncStatusMock.mockResolvedValue([]); 63 - searchActorSuggestionsMock.mockResolvedValue([]); 71 + searchActorTypeaheadMock.mockResolvedValue([]); 64 72 searchActorsMock.mockResolvedValue({ actors: [], cursor: null }); 65 73 syncPostsMock.mockResolvedValue({ 66 74 did: "did:plc:test", ··· 207 215 }); 208 216 209 217 it("searches profiles and opens a selected actor", async () => { 210 - searchActorSuggestionsMock.mockResolvedValue([{ 218 + searchActorTypeaheadMock.mockResolvedValue([{ 211 219 avatar: null, 212 220 did: "did:plc:bob", 213 221 displayName: "Bob Example",
-6
src/lib/api/actors.ts
··· 1 - import type { ActorSuggestion } from "$/lib/types"; 2 - import { invoke } from "@tauri-apps/api/core"; 3 - 4 - export function searchActorSuggestions(query: string): Promise<ActorSuggestion[]> { 5 - return invoke("search_login_suggestions", { query }); 6 - }
+25 -11
src/lib/api/explorer.ts
··· 1 1 import { invoke } from "@tauri-apps/api/core"; 2 2 import type { ExplorerServerView, RepoCarExport, ResolvedExplorerInput, TempBlobFile } from "./types/explorer"; 3 3 4 - export async function resolveInput(input: string): Promise<ResolvedExplorerInput> { 4 + async function resolveInput(input: string): Promise<ResolvedExplorerInput> { 5 5 return invoke("resolve_input", { input }); 6 6 } 7 7 8 - export async function describeServer(pdsUrl: string): Promise<ExplorerServerView> { 8 + async function describeServer(pdsUrl: string): Promise<ExplorerServerView> { 9 9 return invoke("describe_server", { pdsUrl }); 10 10 } 11 11 12 - export async function describeRepo(did: string): Promise<Record<string, unknown>> { 12 + async function describeRepo(did: string): Promise<Record<string, unknown>> { 13 13 return invoke("describe_repo", { did }); 14 14 } 15 15 16 - export async function listRecords(did: string, collection: string, cursor?: string): Promise<Record<string, unknown>> { 16 + async function listRecords(did: string, collection: string, cursor?: string): Promise<Record<string, unknown>> { 17 17 return invoke("list_records", { did, collection, cursor }); 18 18 } 19 19 20 - export async function getRecord(did: string, collection: string, rkey: string): Promise<Record<string, unknown>> { 20 + async function getRecord(did: string, collection: string, rkey: string): Promise<Record<string, unknown>> { 21 21 return invoke("get_record", { did, collection, rkey }); 22 22 } 23 23 24 - export async function exportRepoCar(did: string): Promise<RepoCarExport> { 24 + async function exportRepoCar(did: string): Promise<RepoCarExport> { 25 25 return invoke("export_repo_car", { did }); 26 26 } 27 27 28 - export async function fetchBlobToTempFile(did: string, cid: string, extension?: string | null): Promise<TempBlobFile> { 28 + async function fetchBlobToTempFile(did: string, cid: string, extension?: string | null): Promise<TempBlobFile> { 29 29 return invoke("fetch_blob_to_temp_file", { cid, did, extension: extension ?? null }); 30 30 } 31 31 32 - export async function deleteBlobTempFile(path: string): Promise<void> { 32 + async function deleteBlobTempFile(path: string): Promise<void> { 33 33 return invoke("delete_blob_temp_file", { path }); 34 34 } 35 35 36 - export async function queryLabels(uri: string): Promise<Record<string, unknown>> { 36 + async function queryLabels(uri: string): Promise<Record<string, unknown>> { 37 37 return invoke("query_labels", { uri }); 38 38 } 39 39 40 - export async function getLexiconFavicons(collections: string[]): Promise<Record<string, string | null>> { 40 + async function getLexiconFavicons(collections: string[]): Promise<Record<string, string | null>> { 41 41 return invoke("get_lexicon_favicons", { collections }); 42 42 } 43 43 44 - export async function clearLexiconFaviconCache(): Promise<void> { 44 + async function clearLexiconFaviconCache(): Promise<void> { 45 45 return invoke("clear_lexicon_favicon_cache"); 46 46 } 47 + 48 + export const ExplorerController = { 49 + resolveInput, 50 + describeServer, 51 + describeRepo, 52 + listRecords, 53 + getRecord, 54 + exportRepoCar, 55 + fetchBlobToTempFile, 56 + deleteBlobTempFile, 57 + queryLabels, 58 + getLexiconFavicons, 59 + clearLexiconFaviconCache, 60 + };
+17 -7
src/lib/api/profile.ts
··· 2 2 import type { CreateRecordResult, ProfileLookupResult } from "$/lib/types"; 3 3 import { invoke } from "@tauri-apps/api/core"; 4 4 5 - export async function getProfile(actor: string): Promise<ProfileLookupResult> { 5 + async function getProfile(actor: string): Promise<ProfileLookupResult> { 6 6 return parseProfileResult(await invoke("get_profile", { actor })); 7 7 } 8 8 9 - export async function getAuthorFeed(actor: string, cursor?: string | null, limit?: number) { 9 + async function getAuthorFeed(actor: string, cursor?: string | null, limit?: number) { 10 10 return parseProfileFeed(await invoke("get_author_feed", { actor, cursor: cursor ?? null, limit: limit ?? null })); 11 11 } 12 12 13 - export async function getActorLikes(actor: string, cursor?: string | null, limit?: number) { 13 + async function getActorLikes(actor: string, cursor?: string | null, limit?: number) { 14 14 return parseProfileFeed(await invoke("get_actor_likes", { actor, cursor: cursor ?? null, limit: limit ?? null })); 15 15 } 16 16 17 - export async function followActor(did: string): Promise<CreateRecordResult> { 17 + async function followActor(did: string): Promise<CreateRecordResult> { 18 18 return invoke("follow_actor", { did }); 19 19 } 20 20 21 - export async function unfollowActor(followUri: string): Promise<void> { 21 + async function unfollowActor(followUri: string): Promise<void> { 22 22 return invoke("unfollow_actor", { followUri }); 23 23 } 24 24 25 - export async function getFollowers(actor: string, cursor?: string | null, limit?: number) { 25 + async function getFollowers(actor: string, cursor?: string | null, limit?: number) { 26 26 return parseActorList( 27 27 await invoke("get_followers", { actor, cursor: cursor ?? null, limit: limit ?? null }), 28 28 "followers", 29 29 ); 30 30 } 31 31 32 - export async function getFollows(actor: string, cursor?: string | null, limit?: number) { 32 + async function getFollows(actor: string, cursor?: string | null, limit?: number) { 33 33 return parseActorList( 34 34 await invoke("get_follows", { actor, cursor: cursor ?? null, limit: limit ?? null }), 35 35 "follows", 36 36 ); 37 37 } 38 + 39 + export const ProfileController = { 40 + getProfile, 41 + getAuthorFeed, 42 + getActorLikes, 43 + followActor, 44 + unfollowActor, 45 + getFollowers, 46 + getFollows, 47 + };
+21
src/lib/api/typeahead.ts
··· 1 + import type { ActorSuggestion } from "$/lib/types"; 2 + import { invoke } from "@tauri-apps/api/core"; 3 + 4 + const ACTOR_TYPEAHEAD_MIN_QUERY_LENGTH = 2; 5 + 6 + function normalizeQuery(value: string) { 7 + const trimmed = value.trim(); 8 + if ( 9 + trimmed.length < ACTOR_TYPEAHEAD_MIN_QUERY_LENGTH || trimmed.startsWith("did:") || /^https?:\/\//i.test(trimmed) 10 + ) { 11 + return ""; 12 + } 13 + 14 + return trimmed.replace(/^@/, ""); 15 + } 16 + 17 + function searchActor(query: string): Promise<ActorSuggestion[]> { 18 + return invoke("search_login_suggestions", { query }); 19 + } 20 + 21 + export const TypeaheadController = { normalizeQuery, searchActor };