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

Configure Feed

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

feat: add profile search tab

+916 -470
+181 -51
src-tauri/src/diagnostics.rs
··· 142 142 pub async fn get_account_lists(did: String, state: &AppState) -> Result<AccountListsResult> { 143 143 let normalized_did = normalize_did(&did)?; 144 144 let client = constellation_client(state)?; 145 - let counts = client 145 + let counts = match client 146 146 .get_many_to_many_counts( 147 147 normalized_did.clone(), 148 148 LIST_MEMBERSHIP_SOURCE.to_string(), 149 149 LIST_MEMBERSHIP_PATH_TO_OTHER.to_string(), 150 150 ) 151 151 .await 152 - .map_err(|error| diagnostics_error("Couldn't load lists for this account.", error))?; 152 + { 153 + Ok(counts) => counts, 154 + Err(error) if should_skip_missing_resource(&error) => { 155 + log_missing_resource("account lists", &normalized_did, &error); 156 + return Ok(AccountListsResult { total: 0, lists: Vec::new(), truncated: false }); 157 + } 158 + Err(error) => return Err(AppError::diagnostics("Couldn't load lists for this account.", error)), 159 + }; 153 160 154 161 let mut list_uris = Vec::new(); 155 162 let mut cursor = None; 156 163 let mut truncated = false; 157 164 158 165 while list_uris.len() < ACCOUNT_LIST_MAX_ITEMS { 159 - let response = client 166 + let response = match client 160 167 .get_many_to_many( 161 168 normalized_did.clone(), 162 169 LIST_MEMBERSHIP_SOURCE.to_string(), ··· 165 172 cursor.clone(), 166 173 ) 167 174 .await 168 - .map_err(|error| diagnostics_error("Couldn't load lists for this account.", error))?; 175 + { 176 + Ok(response) => response, 177 + Err(error) if should_skip_missing_resource(&error) => { 178 + log_missing_resource("account lists", &normalized_did, &error); 179 + break; 180 + } 181 + Err(error) => return Err(AppError::diagnostics("Couldn't load lists for this account.", error)), 182 + }; 169 183 170 184 if response.items.is_empty() { 171 185 break; ··· 206 220 .build(), 207 221 ) 208 222 .await 209 - .map_err(|error| diagnostics_error("Couldn't load labels for this account.", error))? 223 + .map_err(|error| AppError::diagnostics("Couldn't load labels for this account.", error))? 210 224 .into_output() 211 - .map_err(|error| diagnostics_error("Couldn't read labels for this account.", error))? 225 + .map_err(|error| AppError::diagnostics("Couldn't read labels for this account.", error))? 212 226 .into_static(); 213 227 214 228 let labels = output ··· 232 246 ) -> Result<AccountBlockedByResult> { 233 247 let normalized_did = normalize_did(&did)?; 234 248 let client = constellation_client(state)?; 235 - let response = client 249 + let response = match client 236 250 .get_distinct_dids( 237 251 normalized_did, 238 252 BLOCK_SOURCE.to_string(), ··· 240 254 cursor, 241 255 ) 242 256 .await 243 - .map_err(|error| diagnostics_error("Couldn't load the accounts blocking this profile.", error))?; 257 + { 258 + Ok(response) => response, 259 + Err(error) if should_skip_missing_resource(&error) => { 260 + return Ok(AccountBlockedByResult { total: 0, items: Vec::new(), cursor: None }); 261 + } 262 + Err(error) => { 263 + return Err(AppError::diagnostics( 264 + "Couldn't load the accounts blocking this profile.", 265 + error, 266 + )) 267 + } 268 + }; 244 269 245 270 let profiles = fetch_profiles_map(&response.dids).await?; 246 271 let items = response ··· 254 279 255 280 pub async fn get_account_blocking(did: String, cursor: Option<String>) -> Result<AccountBlockingResult> { 256 281 let normalized_did = normalize_did(&did)?; 257 - let output = explorer::list_records(normalized_did.clone(), BLOCK_COLLECTION.to_string(), cursor) 258 - .await 259 - .map_err(|error| diagnostics_error("Couldn't load this account's block records.", error))?; 282 + let output = match explorer::list_records(normalized_did.clone(), BLOCK_COLLECTION.to_string(), cursor).await { 283 + Ok(output) => output, 284 + Err(error) if should_skip_missing_resource(&error) => { 285 + log_missing_resource("block records", &normalized_did, &error); 286 + return Ok(AccountBlockingResult { items: Vec::new(), cursor: None }); 287 + } 288 + Err(error) => { 289 + return Err(AppError::diagnostics( 290 + "Couldn't load this account's block records.", 291 + error, 292 + )) 293 + } 294 + }; 260 295 let parsed: RepoListRecordsOutput = serde_json::from_value(output).map_err(|error| { 261 296 log::error!("failed to decode block listRecords output: {error}"); 262 297 AppError::validation("Lazurite couldn't read this account's block records.") ··· 291 326 pub async fn get_account_starter_packs(did: String, state: &AppState) -> Result<AccountStarterPacksResult> { 292 327 let normalized_did = normalize_did(&did)?; 293 328 let client = constellation_client(state)?; 294 - let count = client 329 + let count = match client 295 330 .get_backlinks_count(normalized_did.clone(), STARTER_PACK_SOURCE.to_string()) 296 331 .await 297 - .map_err(|error| diagnostics_error("Couldn't load starter packs for this account.", error))?; 332 + { 333 + Ok(count) => count, 334 + Err(error) if should_skip_missing_resource(&error) => { 335 + log_missing_resource("starter packs", &normalized_did, &error); 336 + return Ok(AccountStarterPacksResult { total: 0, starter_packs: Vec::new(), truncated: false }); 337 + } 338 + Err(error) => { 339 + return Err(AppError::diagnostics( 340 + "Couldn't load starter packs for this account.", 341 + error, 342 + )) 343 + } 344 + }; 298 345 299 346 let mut pack_uris = Vec::new(); 300 347 let mut cursor = None; 301 348 let mut truncated = false; 302 349 303 350 while pack_uris.len() < STARTER_PACK_MAX_ITEMS { 304 - let response = client 351 + let response = match client 305 352 .get_backlinks( 306 353 normalized_did.clone(), 307 354 STARTER_PACK_SOURCE.to_string(), ··· 309 356 cursor.clone(), 310 357 ) 311 358 .await 312 - .map_err(|error| diagnostics_error("Couldn't load starter packs for this account.", error))?; 359 + { 360 + Ok(response) => response, 361 + Err(error) if should_skip_missing_resource(&error) => { 362 + log_missing_resource("starter packs", &normalized_did, &error); 363 + break; 364 + } 365 + Err(error) => { 366 + return Err(AppError::diagnostics( 367 + "Couldn't load starter packs for this account.", 368 + error, 369 + )) 370 + } 371 + }; 313 372 314 373 if response.records.is_empty() { 315 374 break; ··· 379 438 .map_err(|_| AppError::validation("Enter a valid AT-URI.")) 380 439 } 381 440 382 - fn diagnostics_error(message: &'static str, error: impl std::fmt::Display) -> AppError { 383 - log::error!("{message} {error}"); 384 - AppError::validation(message) 441 + fn log_missing_resource(kind: &str, identifier: &str, error: impl std::fmt::Display) { 442 + log::warn!("Skipping missing {kind} for {identifier}: {error}"); 443 + } 444 + 445 + fn should_skip_missing_resource(error: &impl std::fmt::Display) -> bool { 446 + let message = error.to_string().to_ascii_lowercase(); 447 + let mentions_missing = message.contains("not found") || message.contains("notfound"); 448 + let mentions_resource = message.contains("list") 449 + || message.contains("record") 450 + || message.contains("repo") 451 + || message.contains("profile") 452 + || message.contains("starter pack") 453 + || message.contains("starterpack"); 454 + 455 + mentions_missing && mentions_resource 385 456 } 386 457 387 458 fn link_record_uri(record: &ConstellationLinkRecord) -> String { ··· 417 488 for chunk in unique_dids.chunks(PUBLIC_BATCH_LIMIT) { 418 489 let actors = chunk 419 490 .iter() 420 - .map(|did| did_identifier(did)) 421 - .collect::<Result<Vec<_>>>()?; 422 - let output = client 423 - .send(GetProfiles::new().actors(actors).build()) 424 - .await 425 - .map_err(|error| diagnostics_error("Couldn't load account profiles.", error))? 426 - .into_output() 427 - .map_err(|error| diagnostics_error("Couldn't read account profiles.", error))? 428 - .into_static(); 491 + .filter_map(|did| match did_identifier(did) { 492 + Ok(actor) => Some(actor), 493 + Err(error) => { 494 + log_missing_resource("profile", did, error); 495 + None 496 + } 497 + }) 498 + .collect::<Vec<_>>(); 499 + if actors.is_empty() { 500 + continue; 501 + } 502 + 503 + let output = match client.send(GetProfiles::new().actors(actors).build()).await { 504 + Ok(output) => output, 505 + Err(error) if should_skip_missing_resource(&error) => { 506 + log_missing_resource("profiles", &chunk.join(","), error); 507 + continue; 508 + } 509 + Err(error) => return Err(AppError::diagnostics("Couldn't load account profiles.", error)), 510 + }; 511 + let output = match output.into_output() { 512 + Ok(output) => output.into_static(), 513 + Err(error) if should_skip_missing_resource(&error) => { 514 + log_missing_resource("profiles", &chunk.join(","), error); 515 + continue; 516 + } 517 + Err(error) => return Err(AppError::diagnostics("Couldn't read account profiles.", error)), 518 + }; 429 519 430 520 for profile in output.profiles { 431 521 profiles.insert(profile.did.to_string(), serde_json::to_value(profile)?); ··· 440 530 let mut lists = Vec::new(); 441 531 442 532 for list_uri in list_uris { 443 - let parsed_uri = AtUri::new(list_uri).map_err(|_| AppError::validation("A list URI was invalid."))?; 444 - let output = client 533 + let parsed_uri = match AtUri::new(list_uri) { 534 + Ok(uri) => uri, 535 + Err(error) => { 536 + log_missing_resource("list", list_uri, error); 537 + continue; 538 + } 539 + }; 540 + let output = match client 445 541 .send(GetList::new().list(parsed_uri.into_static()).limit(1).build()) 446 542 .await 447 - .map_err(|error| diagnostics_error("Couldn't load one of the matching lists.", error))? 448 - .into_output() 449 - .map_err(|error| diagnostics_error("Couldn't read one of the matching lists.", error))? 450 - .into_static(); 543 + { 544 + Ok(output) => output, 545 + Err(error) if should_skip_missing_resource(&error) => { 546 + log_missing_resource("list", list_uri, error); 547 + continue; 548 + } 549 + Err(error) => return Err(AppError::diagnostics("Couldn't load one of the matching lists.", error)), 550 + }; 551 + let output = match output.into_output() { 552 + Ok(output) => output.into_static(), 553 + Err(error) if should_skip_missing_resource(&error) => { 554 + log_missing_resource("list", list_uri, error); 555 + continue; 556 + } 557 + Err(error) => return Err(AppError::diagnostics("Couldn't read one of the matching lists.", error)), 558 + }; 451 559 lists.push(serde_json::to_value(output.list)?); 452 560 } 453 561 ··· 462 570 let client = public_client(); 463 571 let mut starter_packs = Vec::new(); 464 572 465 - for chunk in uris.chunks(PUBLIC_BATCH_LIMIT) { 466 - let parsed_uris = chunk 467 - .iter() 468 - .map(|uri| { 469 - AtUri::new(uri) 470 - .map(IntoStatic::into_static) 471 - .map_err(|_| AppError::validation("A starter pack URI was invalid.")) 472 - }) 473 - .collect::<Result<Vec<_>>>()?; 474 - let output = client 475 - .send(GetStarterPacks::new().uris(parsed_uris).build()) 476 - .await 477 - .map_err(|error| diagnostics_error("Couldn't load starter packs for this account.", error))? 478 - .into_output() 479 - .map_err(|error| diagnostics_error("Couldn't read starter pack details.", error))? 480 - .into_static(); 573 + for uri in uris { 574 + let parsed_uri = match AtUri::new(uri).map(IntoStatic::into_static) { 575 + Ok(parsed_uri) => parsed_uri, 576 + Err(error) => { 577 + log_missing_resource("starter pack", uri, error); 578 + continue; 579 + } 580 + }; 581 + let output = match client.send(GetStarterPacks::new().uris(vec![parsed_uri]).build()).await { 582 + Ok(output) => output, 583 + Err(error) if should_skip_missing_resource(&error) => { 584 + log_missing_resource("starter pack", uri, error); 585 + continue; 586 + } 587 + Err(error) => { 588 + return Err(AppError::diagnostics( 589 + "Couldn't load starter packs for this account.", 590 + error, 591 + )) 592 + } 593 + }; 594 + let output = match output.into_output() { 595 + Ok(output) => output.into_static(), 596 + Err(error) if should_skip_missing_resource(&error) => { 597 + log_missing_resource("starter pack", uri, error); 598 + continue; 599 + } 600 + Err(error) => return Err(AppError::diagnostics("Couldn't read starter pack details.", error)), 601 + }; 481 602 482 603 for starter_pack in output.starter_packs { 483 604 starter_packs.push(serde_json::to_value(starter_pack)?); ··· 496 617 None, 497 618 ) 498 619 .await 499 - .map_err(|error| diagnostics_error("Couldn't load record backlinks right now.", error))?; 620 + .map_err(|error| AppError::diagnostics("Couldn't load record backlinks right now.", error))?; 500 621 501 622 build_backlink_group(response).await 502 623 } ··· 538 659 539 660 #[cfg(test)] 540 661 mod tests { 541 - use super::{dedupe_preserve_order, extract_created_at, extract_subject_did}; 662 + use super::{dedupe_preserve_order, extract_created_at, extract_subject_did, should_skip_missing_resource}; 542 663 use serde_json::json; 543 664 544 665 #[test] ··· 560 681 561 682 assert_eq!(extract_subject_did(&value).as_deref(), Some("did:plc:blocked")); 562 683 assert_eq!(extract_created_at(&value).as_deref(), Some("2025-01-01T00:00:00Z")); 684 + } 685 + 686 + #[test] 687 + fn treats_missing_list_errors_as_skippable() { 688 + assert!(should_skip_missing_resource( 689 + &"XRPC error: Object(Object({\"error\":\"InvalidRequest\",\"message\":\"List not found\"}))" 690 + )); 691 + assert!(should_skip_missing_resource(&"repo not found")); 692 + assert!(!should_skip_missing_resource(&"rate limit exceeded")); 563 693 } 564 694 }
+5
src-tauri/src/error.rs
··· 105 105 log::error!("state lock poisoned: {}", msg); 106 106 AppError::StatePoisoned(Box::leak(msg.into_boxed_str())) 107 107 } 108 + 109 + pub fn diagnostics(message: &'static str, error: impl std::fmt::Display) -> Self { 110 + log::error!("{message} {error}"); 111 + AppError::validation(message) 112 + } 108 113 }
+1
src/App.tsx
··· 1 1 import { getCurrentWindow } from "@tauri-apps/api/window"; 2 + // @ts-expect-error - erroneous font types missing 2 3 import "@fontsource-variable/google-sans"; 3 4 import { useNavigate } from "@solidjs/router"; 4 5 import type { ParentProps } from "solid-js";
+26 -184
src/components/LoginPanel.tsx
··· 1 + import { ActorSuggestionList, useActorSuggestions } from "$/components/actors/actor-search"; 1 2 import type { LoginSuggestion } from "$/lib/types"; 2 - import { invoke } from "@tauri-apps/api/core"; 3 - import { createEffect, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 3 + import { createEffect, Show } from "solid-js"; 4 4 import { Motion } from "solid-motionone"; 5 - import { AvatarBadge } from "./AvatarBadge"; 6 5 import { Icon } from "./shared/Icon"; 7 6 import { LazuriteLogo } from "./Wordmark"; 8 - 9 - const LOGIN_TYPEAHEAD_DEBOUNCE_MS = 180; 10 7 11 8 function LoginSubmitButton(props: { pending: boolean }) { 12 9 return ( ··· 39 36 export function LoginPanel(props: LoginPanelProps) { 40 37 let container: HTMLDivElement | undefined; 41 38 let input: HTMLInputElement | undefined; 42 - let requestId = 0; 43 - const [activeIndex, setActiveIndex] = createSignal(-1); 44 - const [loading, setLoading] = createSignal(false); 45 - const [open, setOpen] = createSignal(false); 46 - const [suggestions, setSuggestions] = createSignal<LoginSuggestion[]>([]); 39 + const typeahead = useActorSuggestions({ 40 + container: () => container, 41 + disabled: () => props.pending, 42 + input: () => input, 43 + value: () => props.value, 44 + }); 47 45 48 46 createEffect(() => { 49 47 if (props.shakeCount > 0) { ··· 52 50 } 53 51 }); 54 52 55 - createEffect(() => { 56 - const query = normalizeSuggestionQuery(props.value); 57 - const nextRequestId = requestId + 1; 58 - requestId = nextRequestId; 59 - 60 - if (!query || props.pending) { 61 - setLoading(false); 62 - setOpen(false); 63 - setActiveIndex(-1); 64 - setSuggestions([]); 65 - return; 66 - } 67 - 68 - setLoading(true); 69 - 70 - const timeout = globalThis.setTimeout(() => { 71 - void invoke<LoginSuggestion[]>("search_login_suggestions", { query }).then((results) => { 72 - if (requestId !== nextRequestId) { 73 - return; 74 - } 75 - 76 - setSuggestions(results); 77 - setActiveIndex(results.length > 0 ? 0 : -1); 78 - setOpen(results.length > 0 && document.activeElement === input); 79 - }).catch(() => { 80 - if (requestId !== nextRequestId) { 81 - return; 82 - } 83 - 84 - setSuggestions([]); 85 - setActiveIndex(-1); 86 - setOpen(false); 87 - }).finally(() => { 88 - if (requestId === nextRequestId) { 89 - setLoading(false); 90 - } 91 - }); 92 - }, LOGIN_TYPEAHEAD_DEBOUNCE_MS); 93 - 94 - onCleanup(() => globalThis.clearTimeout(timeout)); 95 - }); 96 - 97 - onMount(() => { 98 - const pointerListener = { 99 - handleEvent(event: Event) { 100 - if (!open()) { 101 - return; 102 - } 103 - 104 - if (container?.contains(event.target as Node)) { 105 - return; 106 - } 107 - 108 - setOpen(false); 109 - }, 110 - }; 111 - 112 - globalThis.addEventListener("pointerdown", pointerListener); 113 - onCleanup(() => globalThis.removeEventListener("pointerdown", pointerListener)); 114 - }); 115 - 116 53 function applySuggestion(suggestion: LoginSuggestion) { 117 54 props.onInput(suggestion.handle); 118 - setOpen(false); 119 - setActiveIndex(-1); 55 + typeahead.close(); 120 56 input?.focus(); 121 57 } 122 58 123 - function moveActiveIndex(direction: 1 | -1) { 124 - const items = suggestions(); 125 - if (items.length === 0) { 126 - return; 127 - } 128 - 129 - setOpen(true); 130 - setActiveIndex((current) => { 131 - if (current < 0) { 132 - return direction > 0 ? 0 : items.length - 1; 133 - } 134 - 135 - return (current + direction + items.length) % items.length; 136 - }); 137 - } 138 - 139 59 function handleKeyDown(event: KeyboardEvent) { 140 60 if (event.key === "ArrowDown") { 141 61 event.preventDefault(); 142 - moveActiveIndex(1); 62 + typeahead.moveActiveIndex(1); 143 63 return; 144 64 } 145 65 146 66 if (event.key === "ArrowUp") { 147 67 event.preventDefault(); 148 - moveActiveIndex(-1); 68 + typeahead.moveActiveIndex(-1); 149 69 return; 150 70 } 151 71 152 72 if (event.key === "Escape") { 153 - setOpen(false); 154 - setActiveIndex(-1); 73 + typeahead.close(); 155 74 return; 156 75 } 157 76 158 - if (event.key === "Enter" && open() && activeIndex() >= 0) { 77 + if (event.key === "Enter" && typeahead.open() && typeahead.activeSuggestion()) { 159 78 event.preventDefault(); 160 - applySuggestion(suggestions()[activeIndex()]); 79 + applySuggestion(typeahead.activeSuggestion() as LoginSuggestion); 161 80 } 162 81 } 163 82 ··· 203 122 role="combobox" 204 123 aria-autocomplete="list" 205 124 aria-controls="login-suggestions" 206 - aria-activedescendant={activeIndex() >= 0 ? `login-suggestion-${activeIndex()}` : undefined} 207 - aria-expanded={open()} 125 + aria-activedescendant={typeahead.activeIndex() >= 0 126 + ? `login-suggestions-option-${typeahead.activeIndex()}` 127 + : undefined} 128 + aria-expanded={typeahead.open()} 208 129 autocomplete="username" 209 130 spellcheck={false} 210 131 value={props.value} 211 132 placeholder="alice.bsky.social" 212 - onFocus={() => setOpen(suggestions().length > 0)} 133 + onFocus={() => typeahead.focus()} 213 134 onInput={(event) => props.onInput(event.currentTarget.value)} 214 135 onKeyDown={(event) => handleKeyDown(event)} /> 215 - <LoginLoadingIndicator visible={loading()} /> 216 - <LoginTypeaheadPanel 217 - activeIndex={activeIndex()} 218 - open={open()} 219 - suggestions={suggestions()} 136 + <LoginLoadingIndicator visible={typeahead.loading()} /> 137 + <ActorSuggestionList 138 + activeIndex={typeahead.activeIndex()} 139 + id="login-suggestions" 140 + open={typeahead.open()} 141 + suggestions={typeahead.suggestions()} 142 + title="Suggested handles" 220 143 onSelect={applySuggestion} /> 221 144 </div> 222 145 </label> ··· 235 158 </Show> 236 159 ); 237 160 } 238 - 239 - function LoginTypeaheadPanel( 240 - props: { 241 - activeIndex: number; 242 - open: boolean; 243 - suggestions: LoginSuggestion[]; 244 - onSelect: (suggestion: LoginSuggestion) => void; 245 - }, 246 - ) { 247 - return ( 248 - <Show when={props.open && props.suggestions.length > 0}> 249 - <div 250 - class="absolute inset-x-0 top-[calc(100%+0.7rem)] z-10 rounded-3xl bg-(--surface-container-highest) p-2.5 shadow-[0_24px_40px_rgba(0,0,0,0.28)] backdrop-blur-[20px]" 251 - id="login-suggestions" 252 - role="listbox"> 253 - <p class="px-2 pb-2 text-[0.68rem] uppercase tracking-[0.12em] text-on-surface-variant">Suggested handles</p> 254 - <div class="grid gap-1.5"> 255 - <For each={props.suggestions}> 256 - {(suggestion, index) => ( 257 - <LoginTypeaheadOption 258 - active={props.activeIndex === index()} 259 - id={`login-suggestion-${index()}`} 260 - suggestion={suggestion} 261 - onSelect={props.onSelect} /> 262 - )} 263 - </For> 264 - </div> 265 - </div> 266 - </Show> 267 - ); 268 - } 269 - 270 - function LoginTypeaheadOption( 271 - props: { active: boolean; id: string; suggestion: LoginSuggestion; onSelect: (suggestion: LoginSuggestion) => void }, 272 - ) { 273 - return ( 274 - <button 275 - class="grid w-full grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-1xl border-0 bg-transparent px-3 py-2.5 text-left transition duration-150 ease-out hover:bg-white/6" 276 - classList={{ "bg-white/7 shadow-[inset_0_0_0_1px_rgba(125,175,255,0.12)]": props.active }} 277 - id={props.id} 278 - type="button" 279 - role="option" 280 - aria-selected={props.active} 281 - onPointerDown={(event) => event.preventDefault()} 282 - onClick={() => props.onSelect(props.suggestion)}> 283 - <LoginTypeaheadAvatar suggestion={props.suggestion} /> 284 - <div class="min-w-0"> 285 - <p class="m-0 truncate text-sm font-medium text-on-surface">{getSuggestionHeadline(props.suggestion)}</p> 286 - <p class="mt-0.5 truncate text-xs text-on-surface-variant">@{props.suggestion.handle.replace(/^@/, "")}</p> 287 - </div> 288 - </button> 289 - ); 290 - } 291 - 292 - function LoginTypeaheadAvatar(props: { suggestion: LoginSuggestion }) { 293 - return ( 294 - <Show when={props.suggestion.avatar} fallback={<AvatarBadge label={props.suggestion.handle} tone="muted" />}> 295 - {(avatar) => ( 296 - <img 297 - class="h-10 w-10 rounded-full object-cover shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)]" 298 - src={avatar()} 299 - alt="" 300 - loading="lazy" /> 301 - )} 302 - </Show> 303 - ); 304 - } 305 - 306 - function getSuggestionHeadline(suggestion: LoginSuggestion) { 307 - const displayName = suggestion.displayName?.trim(); 308 - return displayName && displayName !== suggestion.handle ? displayName : suggestion.handle.replace(/^@/, ""); 309 - } 310 - 311 - function normalizeSuggestionQuery(value: string) { 312 - const trimmed = value.trim(); 313 - if (trimmed.length < 2 || trimmed.startsWith("did:") || /^https?:\/\//i.test(trimmed)) { 314 - return ""; 315 - } 316 - 317 - return trimmed.replace(/^@/, ""); 318 - }
+200
src/components/actors/actor-search.tsx
··· 1 + import { AvatarBadge } from "$/components/AvatarBadge"; 2 + import { searchActorSuggestions } from "$/lib/api/actors"; 3 + import type { ActorSuggestion } from "$/lib/types"; 4 + import { type Accessor, createEffect, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 5 + 6 + export const ACTOR_TYPEAHEAD_DEBOUNCE_MS = 180; 7 + 8 + type UseActorSuggestionsOptions = { 9 + container: Accessor<HTMLElement | undefined>; 10 + disabled?: Accessor<boolean>; 11 + input: Accessor<HTMLInputElement | undefined>; 12 + onError?: (error: unknown) => void; 13 + value: Accessor<string>; 14 + }; 15 + 16 + export 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 + export function getActorSuggestionHeadline(suggestion: ActorSuggestion) { 26 + const displayName = suggestion.displayName?.trim(); 27 + return displayName && displayName !== suggestion.handle ? displayName : suggestion.handle.replace(/^@/, ""); 28 + } 29 + 30 + export function useActorSuggestions(options: UseActorSuggestionsOptions) { 31 + let requestId = 0; 32 + 33 + const [activeIndex, setActiveIndex] = createSignal(-1); 34 + const [loading, setLoading] = createSignal(false); 35 + const [open, setOpen] = createSignal(false); 36 + const [suggestions, setSuggestions] = createSignal<ActorSuggestion[]>([]); 37 + 38 + createEffect(() => { 39 + const query = normalizeActorSuggestionQuery(options.value()); 40 + const disabled = options.disabled?.() ?? false; 41 + const nextRequestId = requestId + 1; 42 + requestId = nextRequestId; 43 + 44 + if (!query || disabled) { 45 + setLoading(false); 46 + setOpen(false); 47 + setActiveIndex(-1); 48 + setSuggestions([]); 49 + return; 50 + } 51 + 52 + setLoading(true); 53 + 54 + const timeout = globalThis.setTimeout(() => { 55 + void searchActorSuggestions(query).then((results) => { 56 + if (requestId !== nextRequestId) { 57 + return; 58 + } 59 + 60 + setSuggestions(results); 61 + setActiveIndex(results.length > 0 ? 0 : -1); 62 + setOpen(results.length > 0 && document.activeElement === options.input()); 63 + }).catch((error) => { 64 + if (requestId !== nextRequestId) { 65 + return; 66 + } 67 + 68 + options.onError?.(error); 69 + setSuggestions([]); 70 + setActiveIndex(-1); 71 + setOpen(false); 72 + }).finally(() => { 73 + if (requestId === nextRequestId) { 74 + setLoading(false); 75 + } 76 + }); 77 + }, ACTOR_TYPEAHEAD_DEBOUNCE_MS); 78 + 79 + onCleanup(() => globalThis.clearTimeout(timeout)); 80 + }); 81 + 82 + onMount(() => { 83 + const pointerListener = { 84 + handleEvent(event: Event) { 85 + if (!open()) { 86 + return; 87 + } 88 + 89 + if (options.container()?.contains(event.target as Node)) { 90 + return; 91 + } 92 + 93 + close(); 94 + }, 95 + }; 96 + 97 + globalThis.addEventListener("pointerdown", pointerListener); 98 + onCleanup(() => globalThis.removeEventListener("pointerdown", pointerListener)); 99 + }); 100 + 101 + function close() { 102 + setOpen(false); 103 + setActiveIndex(-1); 104 + } 105 + 106 + function focus() { 107 + setOpen(suggestions().length > 0); 108 + } 109 + 110 + function moveActiveIndex(direction: 1 | -1) { 111 + const items = suggestions(); 112 + if (items.length === 0) { 113 + return; 114 + } 115 + 116 + setOpen(true); 117 + setActiveIndex((current) => { 118 + if (current < 0) { 119 + return direction > 0 ? 0 : items.length - 1; 120 + } 121 + 122 + return (current + direction + items.length) % items.length; 123 + }); 124 + } 125 + 126 + function activeSuggestion() { 127 + return activeIndex() >= 0 ? suggestions()[activeIndex()] : null; 128 + } 129 + 130 + return { activeIndex, activeSuggestion, close, focus, loading, moveActiveIndex, open, suggestions }; 131 + } 132 + 133 + export function ActorSuggestionList( 134 + props: { 135 + activeIndex: number; 136 + id: string; 137 + open: boolean; 138 + suggestions: ActorSuggestion[]; 139 + title: string; 140 + onSelect: (suggestion: ActorSuggestion) => void; 141 + }, 142 + ) { 143 + return ( 144 + <Show when={props.open && props.suggestions.length > 0}> 145 + <div 146 + id={props.id} 147 + role="listbox" 148 + class="absolute inset-x-0 top-[calc(100%+0.7rem)] z-10 rounded-3xl bg-(--surface-container-highest) p-2.5 shadow-[0_24px_40px_rgba(0,0,0,0.28)] backdrop-blur-[20px]"> 149 + <p class="px-2 pb-2 text-[0.68rem] uppercase tracking-[0.12em] text-on-surface-variant">{props.title}</p> 150 + <div class="grid gap-1.5"> 151 + <For each={props.suggestions}> 152 + {(suggestion, index) => ( 153 + <ActorSuggestionOption 154 + active={props.activeIndex === index()} 155 + id={`${props.id}-option-${index()}`} 156 + suggestion={suggestion} 157 + onSelect={props.onSelect} /> 158 + )} 159 + </For> 160 + </div> 161 + </div> 162 + </Show> 163 + ); 164 + } 165 + 166 + function ActorSuggestionOption( 167 + props: { active: boolean; id: string; suggestion: ActorSuggestion; onSelect: (suggestion: ActorSuggestion) => void }, 168 + ) { 169 + return ( 170 + <button 171 + id={props.id} 172 + type="button" 173 + role="option" 174 + aria-selected={props.active} 175 + class="grid w-full grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-xl border-0 bg-transparent px-3 py-2.5 text-left transition duration-150 ease-out hover:bg-white/6" 176 + classList={{ "bg-white/7 shadow-[inset_0_0_0_1px_rgba(125,175,255,0.12)]": props.active }} 177 + onPointerDown={(event) => event.preventDefault()} 178 + onClick={() => props.onSelect(props.suggestion)}> 179 + <ActorSuggestionAvatar suggestion={props.suggestion} /> 180 + <div class="min-w-0"> 181 + <p class="m-0 truncate text-sm font-medium text-on-surface">{getActorSuggestionHeadline(props.suggestion)}</p> 182 + <p class="mt-0.5 truncate text-xs text-on-surface-variant">@{props.suggestion.handle.replace(/^@/, "")}</p> 183 + </div> 184 + </button> 185 + ); 186 + } 187 + 188 + function ActorSuggestionAvatar(props: { suggestion: ActorSuggestion }) { 189 + return ( 190 + <Show when={props.suggestion.avatar} fallback={<AvatarBadge label={props.suggestion.handle} tone="muted" />}> 191 + {(avatar) => ( 192 + <img 193 + class="h-10 w-10 rounded-full object-cover shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)]" 194 + src={avatar()} 195 + alt={`avatar for ${props.suggestion.handle}`} 196 + loading="lazy" /> 197 + )} 198 + </Show> 199 + ); 200 + }
+26 -182
src/components/deck/AddColumnPanel.tsx
··· 1 + import { ActorSuggestionList, useActorSuggestions } from "$/components/actors/actor-search"; 1 2 import { getFeedGenerators, getPreferences } from "$/lib/api/feeds"; 2 3 import type { SearchMode } from "$/lib/api/search"; 3 4 import type { ColumnKind } from "$/lib/api/types/columns"; 4 5 import { getFeedName } from "$/lib/feeds"; 5 6 import type { FeedGeneratorView, LoginSuggestion, SavedFeedItem } from "$/lib/types"; 6 - import { invoke } from "@tauri-apps/api/core"; 7 7 import * as logger from "@tauri-apps/plugin-log"; 8 8 import { createEffect, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; 9 9 import { Portal } from "solid-js/web"; 10 10 import { Motion, Presence } from "solid-motionone"; 11 - import { AvatarBadge } from "../AvatarBadge"; 12 11 import { FeedChipAvatar } from "../feeds/FeedChipAvatar"; 13 12 import { Icon, SearchModeIcon } from "../shared/Icon"; 14 13 15 14 type AddColumnPanelProps = { onAdd: (kind: ColumnKind, config: string) => void; onClose: () => void; open: boolean }; 16 15 17 16 type PanelTab = ColumnKind; 18 - type FeedPickerSelection = { feed: SavedFeedItem; title: string }; 19 17 20 - const ACTOR_TYPEAHEAD_DEBOUNCE_MS = 180; 18 + type FeedPickerSelection = { feed: SavedFeedItem; title: string }; 21 19 22 20 function feedKindLabel(feed: SavedFeedItem) { 23 21 switch (feed.type) { ··· 274 272 ) { 275 273 let container: HTMLDivElement | undefined; 276 274 let input: HTMLInputElement | undefined; 277 - let requestId = 0; 278 - 279 - const [activeIndex, setActiveIndex] = createSignal(-1); 280 - const [loading, setLoading] = createSignal(false); 281 - const [open, setOpen] = createSignal(false); 282 - const [suggestions, setSuggestions] = createSignal<LoginSuggestion[]>([]); 283 275 const [value, setValue] = createSignal(""); 284 - 285 - createEffect(() => { 286 - const query = normalizeActorSuggestionQuery(value()); 287 - const nextRequestId = requestId + 1; 288 - requestId = nextRequestId; 289 - 290 - if (!query) { 291 - setLoading(false); 292 - setOpen(false); 293 - setActiveIndex(-1); 294 - setSuggestions([]); 295 - return; 296 - } 297 - 298 - setLoading(true); 299 - 300 - const timeout = globalThis.setTimeout(() => { 301 - void invoke<LoginSuggestion[]>("search_login_suggestions", { query }).then((results) => { 302 - if (requestId !== nextRequestId) { 303 - return; 304 - } 305 - 306 - setSuggestions(results); 307 - setActiveIndex(results.length > 0 ? 0 : -1); 308 - setOpen(results.length > 0 && document.activeElement === input); 309 - }).catch((error) => { 310 - if (requestId !== nextRequestId) { 311 - return; 312 - } 313 - 314 - logger.warn(`Failed to load profile suggestions: ${String(error)}`); 315 - setSuggestions([]); 316 - setActiveIndex(-1); 317 - setOpen(false); 318 - }).finally(() => { 319 - if (requestId === nextRequestId) { 320 - setLoading(false); 321 - } 322 - }); 323 - }, ACTOR_TYPEAHEAD_DEBOUNCE_MS); 324 - 325 - onCleanup(() => globalThis.clearTimeout(timeout)); 326 - }); 327 - 328 - onMount(() => { 329 - const pointerListener = { 330 - handleEvent(event: Event) { 331 - if (!open()) { 332 - return; 333 - } 334 - 335 - if (container?.contains(event.target as Node)) { 336 - return; 337 - } 338 - 339 - setOpen(false); 340 - }, 341 - }; 342 - 343 - globalThis.addEventListener("pointerdown", pointerListener); 344 - onCleanup(() => globalThis.removeEventListener("pointerdown", pointerListener)); 276 + const typeahead = useActorSuggestions({ 277 + container: () => container, 278 + input: () => input, 279 + onError: (error) => logger.warn(`Failed to load profile suggestions: ${String(error)}`), 280 + value, 345 281 }); 346 282 347 - function moveActiveIndex(direction: 1 | -1) { 348 - const items = suggestions(); 349 - if (items.length === 0) { 350 - return; 351 - } 352 - 353 - setOpen(true); 354 - setActiveIndex((current) => { 355 - if (current < 0) { 356 - return direction > 0 ? 0 : items.length - 1; 357 - } 358 - 359 - return (current + direction + items.length) % items.length; 360 - }); 361 - } 362 - 363 283 function submitManualActor() { 364 284 const actor = value().trim(); 365 285 if (!actor) { 366 286 return; 367 287 } 368 288 289 + typeahead.close(); 369 290 props.onSubmit({ actor }); 370 291 } 371 292 372 293 function submitSuggestion(suggestion: LoginSuggestion) { 294 + typeahead.close(); 373 295 props.onSubmit({ 374 296 actor: suggestion.handle, 375 297 did: suggestion.did, ··· 381 303 function handleKeyDown(event: KeyboardEvent) { 382 304 if (event.key === "ArrowDown") { 383 305 event.preventDefault(); 384 - moveActiveIndex(1); 306 + typeahead.moveActiveIndex(1); 385 307 return; 386 308 } 387 309 388 310 if (event.key === "ArrowUp") { 389 311 event.preventDefault(); 390 - moveActiveIndex(-1); 312 + typeahead.moveActiveIndex(-1); 391 313 return; 392 314 } 393 315 394 316 if (event.key === "Escape") { 395 - setOpen(false); 396 - setActiveIndex(-1); 317 + typeahead.close(); 397 318 return; 398 319 } 399 320 400 - if (event.key === "Enter" && open() && activeIndex() >= 0) { 321 + if (event.key === "Enter" && typeahead.open() && typeahead.activeSuggestion()) { 401 322 event.preventDefault(); 402 - submitSuggestion(suggestions()[activeIndex()]); 323 + submitSuggestion(typeahead.activeSuggestion() as LoginSuggestion); 403 324 } 404 325 } 405 326 ··· 425 346 role="combobox" 426 347 aria-autocomplete="list" 427 348 aria-controls="profile-suggestions" 428 - aria-activedescendant={activeIndex() >= 0 ? `profile-suggestion-${activeIndex()}` : undefined} 429 - aria-expanded={open()} 349 + aria-activedescendant={typeahead.activeIndex() >= 0 350 + ? `profile-suggestions-option-${typeahead.activeIndex()}` 351 + : undefined} 352 + aria-expanded={typeahead.open()} 430 353 class="w-full rounded-xl border-0 bg-white/6 px-4 py-2.5 pr-10 text-sm text-on-surface placeholder:text-on-surface-variant/50 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)] outline-none focus:shadow-[inset_0_0_0_1px_rgba(125,175,255,0.4)]" 431 354 placeholder="alice.bsky.social" 432 355 spellcheck={false} 433 356 value={value()} 434 - onFocus={() => setOpen(suggestions().length > 0)} 357 + onFocus={() => typeahead.focus()} 435 358 onInput={(event) => setValue(event.currentTarget.value)} 436 359 onKeyDown={(event) => handleKeyDown(event)} /> 437 360 438 - <TypeaheadLoading visible={loading()} /> 439 - <ProfileSuggestionList 440 - activeIndex={activeIndex()} 441 - open={open()} 442 - suggestions={suggestions()} 361 + <TypeaheadLoading visible={typeahead.loading()} /> 362 + <ActorSuggestionList 363 + activeIndex={typeahead.activeIndex()} 364 + id="profile-suggestions" 365 + open={typeahead.open()} 366 + suggestions={typeahead.suggestions()} 367 + title="Suggested profiles" 443 368 onSelect={submitSuggestion} /> 444 369 </div> 445 370 </label> ··· 463 388 <span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-on-surface-variant"> 464 389 <Icon kind="loader" class="animate-spin text-sm" /> 465 390 </span> 466 - </Show> 467 - ); 468 - } 469 - 470 - function ProfileSuggestionList( 471 - props: { 472 - activeIndex: number; 473 - open: boolean; 474 - suggestions: LoginSuggestion[]; 475 - onSelect: (suggestion: LoginSuggestion) => void; 476 - }, 477 - ) { 478 - return ( 479 - <Show when={props.open && props.suggestions.length > 0}> 480 - <div 481 - id="profile-suggestions" 482 - role="listbox" 483 - class="absolute inset-x-0 top-[calc(100%+0.65rem)] z-10 rounded-3xl bg-(--surface-container-highest) p-2.5 shadow-[0_24px_40px_rgba(0,0,0,0.28)] backdrop-blur-[20px]"> 484 - <p class="px-2 pb-2 text-[0.68rem] uppercase tracking-[0.12em] text-on-surface-variant">Suggested profiles</p> 485 - <div class="grid gap-1.5"> 486 - <For each={props.suggestions}> 487 - {(suggestion, index) => ( 488 - <ProfileSuggestionOption 489 - active={props.activeIndex === index()} 490 - id={`profile-suggestion-${index()}`} 491 - suggestion={suggestion} 492 - onSelect={props.onSelect} /> 493 - )} 494 - </For> 495 - </div> 496 - </div> 497 - </Show> 498 - ); 499 - } 500 - 501 - function ProfileSuggestionOption( 502 - props: { active: boolean; id: string; suggestion: LoginSuggestion; onSelect: (suggestion: LoginSuggestion) => void }, 503 - ) { 504 - return ( 505 - <button 506 - id={props.id} 507 - type="button" 508 - role="option" 509 - aria-selected={props.active} 510 - class="grid w-full grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-xl border-0 bg-transparent px-3 py-2.5 text-left transition duration-150 ease-out hover:bg-white/6" 511 - classList={{ "bg-white/7 shadow-[inset_0_0_0_1px_rgba(125,175,255,0.12)]": props.active }} 512 - onPointerDown={(event) => event.preventDefault()} 513 - onClick={() => props.onSelect(props.suggestion)}> 514 - <ProfileSuggestionAvatar suggestion={props.suggestion} /> 515 - <div class="min-w-0"> 516 - <p class="m-0 truncate text-sm font-medium text-on-surface">{getSuggestionHeadline(props.suggestion)}</p> 517 - <p class="mt-0.5 truncate text-xs text-on-surface-variant">@{props.suggestion.handle.replace(/^@/, "")}</p> 518 - </div> 519 - </button> 520 - ); 521 - } 522 - 523 - function ProfileSuggestionAvatar(props: { suggestion: LoginSuggestion }) { 524 - return ( 525 - <Show when={props.suggestion.avatar} fallback={<AvatarBadge label={props.suggestion.handle} tone="muted" />}> 526 - {(avatar) => ( 527 - <img 528 - class="h-10 w-10 rounded-full object-cover shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)]" 529 - src={avatar()} 530 - alt="" 531 - loading="lazy" /> 532 - )} 533 391 </Show> 534 392 ); 535 393 } ··· 758 616 </Presence> 759 617 ); 760 618 } 761 - 762 - function getSuggestionHeadline(suggestion: LoginSuggestion) { 763 - const displayName = suggestion.displayName?.trim(); 764 - return displayName && displayName !== suggestion.handle ? displayName : suggestion.handle.replace(/^@/, ""); 765 - } 766 - 767 - function normalizeActorSuggestionQuery(value: string) { 768 - const trimmed = value.trim(); 769 - if (trimmed.length < 2 || trimmed.startsWith("did:") || /^https?:\/\//i.test(trimmed)) { 770 - return ""; 771 - } 772 - 773 - return trimmed.replace(/^@/, ""); 774 - }
+2 -2
src/components/feeds/PostCard.tsx
··· 18 18 import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 19 19 import type { EmbedView, FeedViewPost, ImagesEmbedView, PostView, ProfileViewBasic, RichTextFacet } from "$/lib/types"; 20 20 import { formatCount } from "$/lib/utils/text"; 21 - import { createMemo, createSignal, For, type JSX, Match, Show, Switch } from "solid-js"; 21 + import { createMemo, createSignal, For, Match, type ParentProps, Show, Switch } from "solid-js"; 22 22 import { Motion } from "solid-motionone"; 23 23 24 24 type PostCardProps = { ··· 247 247 ); 248 248 } 249 249 250 - function PostPrimaryRegion(props: { children: JSX.Element; onFocus?: () => void; onOpenThread?: () => void }) { 250 + function PostPrimaryRegion(props: ParentProps<{ onFocus?: () => void; onOpenThread?: () => void }>) { 251 251 const interactive = () => !!props.onOpenThread; 252 252 253 253 return (
+33 -7
src/components/search/SearchEmptyState.tsx
··· 1 1 import { Icon } from "$/components/shared/Icon"; 2 2 import { Match, Show, Switch } from "solid-js"; 3 3 4 - type SearchEmptyStateProps = { reason: "error" | "initial" | "no-results" | "no-sync"; scope?: "local" | "network" }; 4 + type SearchEmptyStateScope = "local" | "network" | "profiles"; 5 + 6 + type SearchEmptyStateProps = { reason: "error" | "initial" | "no-results" | "no-sync"; scope?: SearchEmptyStateScope }; 5 7 6 8 export function SearchEmptyState(props: SearchEmptyStateProps) { 7 9 return ( ··· 24 26 ); 25 27 } 26 28 27 - function EmptyStateContent(props: { reason: string; scope: "local" | "network" }) { 29 + function EmptyStateContent(props: { reason: string; scope: SearchEmptyStateScope }) { 28 30 return ( 29 31 <Switch> 30 32 <Match when={props.reason === "initial"}> ··· 46 48 ); 47 49 } 48 50 49 - function InitialContent(props: { scope: "local" | "network" }) { 51 + function InitialContent(props: { scope: SearchEmptyStateScope }) { 50 52 return ( 51 53 <> 52 54 <Switch> 55 + <Match when={props.scope === "profiles"}> 56 + <h3 class="mb-1 text-base font-medium text-on-surface">Search people across Bluesky</h3> 57 + <p class="m-0 text-sm text-on-surface-variant"> 58 + Type a handle or display name above to find profiles and jump directly into their profile view. 59 + </p> 60 + </Match> 53 61 <Match when={props.scope === "network"}> 54 62 <h3 class="mb-1 text-base font-medium text-on-surface">Search public posts across the network</h3> 55 63 <p class="m-0 text-sm text-on-surface-variant"> ··· 79 87 <kbd class="rounded bg-white/10 px-1.5 py-0.5">Tab</kbd> 80 88 Cycle search modes 81 89 </div> 90 + <div class="flex items-center gap-2"> 91 + <kbd class="rounded bg-white/10 px-1.5 py-0.5">↑↓</kbd> 92 + Navigate profile suggestions 93 + </div> 82 94 </div> 83 95 ); 84 96 } 85 97 86 - function NoResultsContent(props: { scope: "local" | "network" }) { 98 + function NoResultsContent(props: { scope: SearchEmptyStateScope }) { 87 99 return ( 88 100 <> 89 - <h3 class="mb-1 text-base font-medium text-on-surface">No results found</h3> 101 + <h3 class="mb-1 text-base font-medium text-on-surface"> 102 + {props.scope === "profiles" ? "No profiles found" : "No results found"} 103 + </h3> 90 104 <Switch> 105 + <Match when={props.scope === "profiles"}> 106 + <p class="m-0 text-sm text-on-surface-variant"> 107 + Try a broader handle fragment, a display name, or select one of the suggested profiles as you type. 108 + </p> 109 + </Match> 91 110 <Match when={props.scope === "network"}> 92 111 <p class="m-0 text-sm text-on-surface-variant"> 93 112 Try a broader query or switch to local search if you want to search your synced posts instead. ··· 114 133 ); 115 134 } 116 135 117 - function ErrorContent(props: { scope: "local" | "network" }) { 136 + function ErrorContent(props: { scope: SearchEmptyStateScope }) { 118 137 return ( 119 138 <> 120 - <h3 class="mb-1 text-base font-medium text-on-surface">Search failed</h3> 139 + <h3 class="mb-1 text-base font-medium text-on-surface"> 140 + {props.scope === "profiles" ? "Profile search failed" : "Search failed"} 141 + </h3> 121 142 <Switch> 143 + <Match when={props.scope === "profiles"}> 144 + <p class="m-0 text-sm text-on-surface-variant"> 145 + The profile lookup did not complete. Retry the query or open a suggested profile if it appears. 146 + </p> 147 + </Match> 122 148 <Match when={props.scope === "network"}> 123 149 <p class="m-0 text-sm text-on-surface-variant"> 124 150 The network request did not complete. Retry the query or switch to local search while the network recovers.
+48
src/components/search/SearchPanel.test.tsx
··· 3 3 import { beforeEach, describe, expect, it, vi } from "vitest"; 4 4 import { SearchPanel } from "./SearchPanel"; 5 5 6 + const navigateMock = vi.hoisted(() => vi.fn()); 7 + const searchActorSuggestionsMock = vi.hoisted(() => vi.fn()); 8 + const searchActorsMock = vi.hoisted(() => vi.fn()); 6 9 const searchPostsMock = vi.hoisted(() => vi.fn()); 7 10 const searchPostsNetworkMock = vi.hoisted(() => vi.fn()); 8 11 const getSyncStatusMock = vi.hoisted(() => vi.fn()); ··· 12 15 vi.mock( 13 16 "$/lib/api/search", 14 17 () => ({ 18 + searchActors: searchActorsMock, 15 19 searchPosts: searchPostsMock, 16 20 searchPostsNetwork: searchPostsNetworkMock, 17 21 getSyncStatus: getSyncStatusMock, 18 22 syncPosts: syncPostsMock, 19 23 }), 20 24 ); 25 + vi.mock("$/lib/api/actors", () => ({ searchActorSuggestions: searchActorSuggestionsMock })); 21 26 vi.mock( 22 27 "$/components/posts/useThreadOverlayNavigation", 23 28 () => ({ useThreadOverlayNavigation: () => threadOverlayMock }), 24 29 ); 30 + vi.mock("@solidjs/router", () => ({ useNavigate: () => navigateMock })); 25 31 26 32 vi.mock("@tauri-apps/plugin-log", () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); 27 33 ··· 36 42 describe("SearchPanel", () => { 37 43 beforeEach(() => { 38 44 vi.useFakeTimers(); 45 + navigateMock.mockReset(); 46 + searchActorSuggestionsMock.mockReset(); 47 + searchActorsMock.mockReset(); 39 48 searchPostsMock.mockReset(); 40 49 searchPostsNetworkMock.mockReset(); 41 50 getSyncStatusMock.mockReset(); 42 51 syncPostsMock.mockReset(); 43 52 44 53 getSyncStatusMock.mockResolvedValue([]); 54 + searchActorSuggestionsMock.mockResolvedValue([]); 55 + searchActorsMock.mockResolvedValue({ actors: [], cursor: null }); 45 56 syncPostsMock.mockResolvedValue({ 46 57 did: "did:plc:test", 47 58 source: "like", ··· 197 208 }); 198 209 199 210 expect(await screen.findByText("No results found")).toBeInTheDocument(); 211 + }); 212 + 213 + it("searches profiles and opens a selected actor", async () => { 214 + searchActorSuggestionsMock.mockResolvedValue([{ 215 + avatar: null, 216 + did: "did:plc:bob", 217 + displayName: "Bob Example", 218 + handle: "bob.test", 219 + }]); 220 + searchActorsMock.mockResolvedValue({ 221 + actors: [{ 222 + avatar: null, 223 + description: "Builds search systems.", 224 + did: "did:plc:bob", 225 + displayName: "Bob Example", 226 + handle: "bob.test", 227 + }], 228 + cursor: null, 229 + }); 230 + 231 + renderSearchPanel(); 232 + 233 + fireEvent.click(screen.getByRole("button", { name: /profiles/i })); 234 + 235 + const input = screen.getByRole("combobox"); 236 + fireEvent.input(input, { target: { value: "bob" } }); 237 + 238 + await vi.advanceTimersByTimeAsync(350); 239 + await Promise.resolve(); 240 + await Promise.resolve(); 241 + 242 + expect(searchActorsMock).toHaveBeenCalledWith("bob", 25); 243 + expect(await screen.findByText("Builds search systems.")).toBeInTheDocument(); 244 + 245 + fireEvent.click(screen.getByRole("button", { name: /bob example/i })); 246 + 247 + expect(navigateMock).toHaveBeenCalledWith("/profile/bob.test"); 200 248 }); 201 249 });
+357 -40
src/components/search/SearchPanel.tsx
··· 1 + import { ActorSuggestionList, getActorSuggestionHeadline, useActorSuggestions } from "$/components/actors/actor-search"; 2 + import { AvatarBadge } from "$/components/AvatarBadge"; 1 3 import { useThreadOverlayNavigation } from "$/components/posts/useThreadOverlayNavigation"; 2 4 import { Icon, SearchModeIcon } from "$/components/shared/Icon"; 3 5 import { useAppPreferences } from "$/contexts/app-preferences"; 4 6 import { useAppSession } from "$/contexts/app-session"; 5 7 import { 8 + type ActorSearchResult, 6 9 getSyncStatus, 7 10 type LocalPostResult, 8 11 type NetworkSearchResult, 12 + searchActors, 9 13 type SearchMode, 10 14 searchPosts, 11 15 searchPostsNetwork, 12 16 type SyncStatus, 13 17 } from "$/lib/api/search"; 14 18 import { formatRelativeTime } from "$/lib/feeds"; 19 + import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 20 + import type { ProfileViewBasic } from "$/lib/types"; 15 21 import { normalizeError } from "$/lib/utils/text"; 22 + import { useNavigate } from "@solidjs/router"; 16 23 import * as logger from "@tauri-apps/plugin-log"; 17 24 import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; 18 25 import { createStore } from "solid-js/store"; ··· 26 33 import { SyncStatusPanel } from "./SyncStatusPanel"; 27 34 28 35 const MODES: SearchMode[] = ["network", "keyword", "semantic", "hybrid"]; 36 + const SEARCH_TABS = ["posts", "profiles"] as const; 37 + 38 + type SearchTab = typeof SEARCH_TABS[number]; 29 39 30 40 type SearchPanelState = { 41 + actorResults: ActorSearchResult | null; 31 42 error: string | null; 32 43 hasSearched: boolean; 33 44 loading: boolean; ··· 36 47 query: string; 37 48 resultCount: number; 38 49 results: LocalPostResult[]; 50 + tab: SearchTab; 39 51 syncStatus: SyncStatus[]; 40 52 }; 41 53 ··· 56 68 } 57 69 58 70 export function SearchPanel(props: SearchPanelProps = {}) { 71 + const navigate = useNavigate(); 59 72 const preferences = useAppPreferences(); 60 73 const session = useAppSession(); 61 74 const threadOverlay = useThreadOverlayNavigation(); 62 75 const [search, setSearch] = createStore<SearchPanelState>({ 76 + actorResults: null, 63 77 error: null, 64 78 hasSearched: false, 65 79 loading: false, ··· 68 82 query: props.initialQuery ?? "", 69 83 resultCount: 0, 70 84 results: [], 85 + tab: "posts", 71 86 syncStatus: [], 72 87 }); 73 88 89 + let actorSearchContainerRef: HTMLDivElement | undefined; 74 90 let searchInputRef: HTMLInputElement | undefined; 75 91 let debounceTimer: ReturnType<typeof setTimeout> | undefined; 76 92 77 - const isLocalMode = createMemo(() => search.mode !== "network"); 93 + const actorSuggestions = useActorSuggestions({ 94 + container: () => actorSearchContainerRef, 95 + disabled: () => search.tab !== "profiles", 96 + input: () => searchInputRef, 97 + onError: (error) => 98 + logger.warn("failed to load actor search suggestions", { keyValues: { error: normalizeError(error) } }), 99 + value: () => search.query, 100 + }); 101 + const isActorTab = createMemo(() => search.tab === "profiles"); 102 + const isLocalMode = createMemo(() => search.tab === "posts" && search.mode !== "network"); 78 103 const semanticEnabled = createMemo(() => preferences.embeddingsEnabled); 79 104 const totalIndexedPosts = createMemo(() => 80 105 search.syncStatus.reduce((sum, status) => sum + (status.postCount ?? 0), 0) ··· 90 115 }); 91 116 const cycleModes = createMemo(() => MODES.filter((candidate) => candidate !== "semantic" || semanticEnabled())); 92 117 93 - async function performSearch(searchQuery: string, searchMode: SearchMode) { 118 + async function performSearch(searchQuery: string, searchTab: SearchTab, searchMode: SearchMode) { 94 119 if (!searchQuery.trim()) { 95 120 clearResults(); 96 121 return; 97 122 } 98 123 124 + if (searchTab === "profiles") { 125 + setSearch({ error: null, loading: true }); 126 + 127 + try { 128 + const response = await searchActors(searchQuery, 25); 129 + setSearch({ 130 + actorResults: response, 131 + error: null, 132 + hasSearched: true, 133 + networkResults: null, 134 + resultCount: response.actors.length, 135 + results: [], 136 + }); 137 + } catch (error) { 138 + const errorMessage = normalizeError(error); 139 + setSearch({ 140 + actorResults: null, 141 + error: errorMessage, 142 + hasSearched: true, 143 + networkResults: null, 144 + resultCount: 0, 145 + results: [], 146 + }); 147 + logger.error("actor search failed", { keyValues: { query: searchQuery, error: errorMessage } }); 148 + } finally { 149 + setSearch("loading", false); 150 + } 151 + 152 + return; 153 + } 154 + 99 155 if (searchMode === "semantic" && !semanticEnabled()) { 100 156 setSearch({ 157 + actorResults: null, 101 158 error: "Semantic search is disabled. Re-enable embeddings to use this mode.", 102 159 hasSearched: true, 103 160 networkResults: null, ··· 112 169 try { 113 170 if (searchMode === "network") { 114 171 const response = await searchPostsNetwork(searchQuery, "top", 25); 115 - setSearch({ hasSearched: true, networkResults: response, resultCount: response.posts.length, results: [] }); 172 + setSearch({ 173 + actorResults: null, 174 + hasSearched: true, 175 + networkResults: response, 176 + resultCount: response.posts.length, 177 + results: [], 178 + }); 116 179 } else { 117 180 const response = await searchPosts(searchQuery, searchMode, 50); 118 - setSearch({ hasSearched: true, networkResults: null, resultCount: response.length, results: response }); 181 + setSearch({ 182 + actorResults: null, 183 + hasSearched: true, 184 + networkResults: null, 185 + resultCount: response.length, 186 + results: response, 187 + }); 119 188 } 120 189 } catch (error) { 121 190 const errorMessage = normalizeError(error); 122 - setSearch({ error: errorMessage, hasSearched: true, networkResults: null, resultCount: 0, results: [] }); 191 + setSearch({ 192 + actorResults: null, 193 + error: errorMessage, 194 + hasSearched: true, 195 + networkResults: null, 196 + resultCount: 0, 197 + results: [], 198 + }); 123 199 logger.error("search failed", { keyValues: { query: searchQuery, mode: searchMode, error: errorMessage } }); 124 200 } finally { 125 201 setSearch("loading", false); ··· 127 203 } 128 204 129 205 function clearResults() { 130 - setSearch({ error: null, hasSearched: false, networkResults: null, resultCount: 0, results: [] }); 206 + setSearch({ 207 + actorResults: null, 208 + error: null, 209 + hasSearched: false, 210 + networkResults: null, 211 + resultCount: 0, 212 + results: [], 213 + }); 131 214 } 132 215 133 216 function handleInput(value: string) { 134 217 setSearch("query", value); 135 218 clearTimeout(debounceTimer); 136 219 debounceTimer = setTimeout(() => { 137 - void performSearch(value, search.mode); 220 + void performSearch(value, search.tab, search.mode); 138 221 }, 300); 139 222 } 140 223 ··· 145 228 146 229 setSearch("mode", newMode); 147 230 if (search.query.trim()) { 148 - void performSearch(search.query, newMode); 231 + void performSearch(search.query, "posts", newMode); 149 232 return; 150 233 } 151 234 152 235 setSearch("error", null); 153 236 } 154 237 238 + function handleTabChange(nextTab: SearchTab) { 239 + if (nextTab === search.tab) { 240 + return; 241 + } 242 + 243 + setSearch({ error: null, hasSearched: false, resultCount: 0, tab: nextTab }); 244 + if (search.query.trim()) { 245 + void performSearch(search.query, nextTab, search.mode); 246 + } 247 + } 248 + 155 249 function cycleMode() { 156 250 const availableModes = cycleModes(); 157 251 const currentIndex = availableModes.indexOf(search.mode); ··· 160 254 } 161 255 162 256 function clearSearch() { 257 + actorSuggestions.close(); 163 258 setSearch("query", ""); 164 259 clearResults(); 165 260 searchInputRef?.focus(); 166 261 } 167 262 168 263 function handleKeyDown(event: KeyboardEvent) { 169 - if (event.key === "Tab" && !event.shiftKey && document.activeElement === searchInputRef) { 264 + if (search.tab === "profiles") { 265 + if (event.key === "ArrowDown") { 266 + event.preventDefault(); 267 + actorSuggestions.moveActiveIndex(1); 268 + return; 269 + } 270 + 271 + if (event.key === "ArrowUp") { 272 + event.preventDefault(); 273 + actorSuggestions.moveActiveIndex(-1); 274 + return; 275 + } 276 + 277 + if (event.key === "Enter" && actorSuggestions.open() && actorSuggestions.activeSuggestion()) { 278 + event.preventDefault(); 279 + openActor(actorSuggestions.activeSuggestion() as ProfileViewBasic); 280 + actorSuggestions.close(); 281 + return; 282 + } 283 + } 284 + 285 + if (search.tab === "posts" && event.key === "Tab" && !event.shiftKey && document.activeElement === searchInputRef) { 170 286 event.preventDefault(); 171 287 cycleMode(); 172 288 return; ··· 174 290 175 291 if (event.key === "Escape" && search.query) { 176 292 clearSearch(); 293 + return; 294 + } 295 + 296 + if (event.key === "Escape" && search.tab === "profiles") { 297 + actorSuggestions.close(); 177 298 } 178 299 } 179 300 ··· 199 320 }); 200 321 } 201 322 if (search.query.trim()) { 202 - void performSearch(search.query, search.mode); 323 + void performSearch(search.query, search.tab, search.mode); 203 324 } 204 325 205 326 onCleanup(() => { ··· 214 335 if (search.mode === "semantic" && !semanticEnabled()) { 215 336 setSearch("mode", "keyword"); 216 337 if (search.query.trim()) { 217 - void performSearch(search.query, "keyword"); 338 + void performSearch(search.query, search.tab, "keyword"); 218 339 } 219 340 } 220 341 }); 221 342 343 + function openActor(actor: Pick<ProfileViewBasic, "did" | "handle">) { 344 + void navigate(buildProfileRoute(getProfileRouteActor(actor))); 345 + } 346 + 222 347 return ( 223 348 <div class="grid min-h-0 gap-6" classList={{ "xl:grid-cols-[minmax(0,1fr)_20rem]": !props.embedded }}> 224 349 <section ··· 227 352 "rounded-4xl bg-surface-container shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]": !props.embedded, 228 353 }}> 229 354 <SearchHeader 355 + actorSearchContainerRef={(element) => { 356 + actorSearchContainerRef = element; 357 + }} 358 + actorSuggestions={actorSuggestions.suggestions()} 230 359 error={search.error} 231 360 hasSearched={search.hasSearched} 232 361 inputRef={(element) => { ··· 235 364 lastSync={lastSync()} 236 365 loading={search.loading} 237 366 mode={search.mode} 367 + onActorSuggestionSelect={(suggestion) => openActor(suggestion)} 368 + onActorSuggestionFocus={actorSuggestions.focus} 238 369 onClear={clearSearch} 239 370 onKeyDown={handleKeyDown} 240 371 onModeChange={handleModeChange} 241 372 onQueryChange={handleInput} 373 + onTabChange={handleTabChange} 242 374 query={search.query} 243 375 resultCount={search.resultCount} 376 + tab={search.tab} 244 377 semanticEnabled={semanticEnabled()} 378 + suggestionsActiveIndex={actorSuggestions.activeIndex()} 379 + suggestionsOpen={actorSuggestions.open()} 245 380 totalIndexedPosts={totalIndexedPosts()} /> 246 381 247 382 <SearchViewport 383 + actorResults={search.actorResults} 248 384 error={search.error} 249 385 hasLocalPosts={hasLocalPosts()} 250 386 hasSearched={search.hasSearched} 387 + isActorTab={isActorTab()} 251 388 isLocalMode={isLocalMode()} 252 389 loading={search.loading} 253 390 localResults={search.results} 254 391 networkResults={search.networkResults} 392 + onOpenActor={openActor} 255 393 onOpenThread={(uri) => void threadOverlay.openThread(uri)} 256 394 query={search.query} /> 257 395 </section> ··· 271 409 272 410 function SearchHeader( 273 411 props: { 412 + actorSearchContainerRef: (el: HTMLDivElement) => void; 413 + actorSuggestions: ProfileViewBasic[]; 274 414 error: string | null; 275 415 hasSearched: boolean; 276 416 inputRef: (el: HTMLInputElement) => void; 277 417 lastSync: string | null; 278 418 loading: boolean; 279 419 mode: SearchMode; 420 + onActorSuggestionSelect: (suggestion: ProfileViewBasic) => void; 421 + onActorSuggestionFocus: () => void; 280 422 onClear: () => void; 281 423 onKeyDown: (event: KeyboardEvent) => void; 282 424 onModeChange: (mode: SearchMode) => void; 283 425 onQueryChange: (value: string) => void; 426 + onTabChange: (tab: SearchTab) => void; 284 427 query: string; 285 428 resultCount: number; 286 429 semanticEnabled: boolean; 430 + suggestionsActiveIndex: number; 431 + suggestionsOpen: boolean; 432 + tab: SearchTab; 287 433 totalIndexedPosts: number; 288 434 }, 289 435 ) { 290 436 return ( 291 437 <header class="grid gap-4 px-6 pb-5 pt-6"> 292 - <SearchQueryInput 293 - error={props.error} 294 - inputRef={props.inputRef} 295 - loading={props.loading} 296 - placeholder={props.mode === "network" 297 - ? "Search public posts across Bluesky..." 298 - : "Search your saved & liked posts..."} 299 - query={props.query} 300 - onClear={props.onClear} 301 - onKeyDown={props.onKeyDown} 302 - onQueryChange={props.onQueryChange} /> 438 + <SearchTabSelector activeTab={props.tab} onTabChange={props.onTabChange} /> 439 + 440 + <div ref={props.actorSearchContainerRef} class="relative"> 441 + <SearchQueryInput 442 + ariaActivedescendant={props.tab === "profiles" && props.suggestionsActiveIndex >= 0 443 + ? `search-actor-suggestions-option-${props.suggestionsActiveIndex}` 444 + : undefined} 445 + ariaAutocomplete={props.tab === "profiles" ? "list" : undefined} 446 + ariaControls={props.tab === "profiles" ? "search-actor-suggestions" : undefined} 447 + ariaExpanded={props.tab === "profiles" ? props.suggestionsOpen : undefined} 448 + autocomplete={props.tab === "profiles" ? "off" : undefined} 449 + error={props.error} 450 + inputRef={props.inputRef} 451 + loading={props.loading} 452 + onFocus={props.tab === "profiles" ? props.onActorSuggestionFocus : undefined} 453 + placeholder={props.tab === "profiles" 454 + ? "Search profiles by handle or display name..." 455 + : (props.mode === "network" 456 + ? "Search public posts across Bluesky..." 457 + : "Search your saved & liked posts...")} 458 + query={props.query} 459 + role={props.tab === "profiles" ? "combobox" : undefined} 460 + spellcheck={false} 461 + onClear={props.onClear} 462 + onKeyDown={props.onKeyDown} 463 + onQueryChange={props.onQueryChange}> 464 + <Show when={props.tab === "profiles"}> 465 + <ActorSuggestionList 466 + activeIndex={props.suggestionsActiveIndex} 467 + id="search-actor-suggestions" 468 + open={props.suggestionsOpen} 469 + suggestions={props.actorSuggestions} 470 + title="Suggested profiles" 471 + onSelect={props.onActorSuggestionSelect} /> 472 + </Show> 473 + </SearchQueryInput> 474 + </div> 303 475 304 476 <div class="flex items-center justify-between gap-4"> 305 - <ModeSelector 306 - activeMode={props.mode} 307 - semanticEnabled={props.semanticEnabled} 308 - onModeChange={props.onModeChange} /> 309 - <span class="text-xs text-on-surface-variant"> 310 - <kbd class="rounded bg-white/10 px-1.5 py-0.5">Tab</kbd> to switch modes 311 - </span> 477 + <Show 478 + when={props.tab === "posts"} 479 + fallback={ 480 + <span class="inline-flex items-center gap-2 rounded-full bg-black/30 px-3 py-2 text-xs text-on-surface-variant"> 481 + <Icon kind="profile" class="text-sm text-primary" /> 482 + Profiles are always searched across Bluesky. 483 + </span> 484 + }> 485 + <ModeSelector 486 + activeMode={props.mode} 487 + semanticEnabled={props.semanticEnabled} 488 + onModeChange={props.onModeChange} /> 489 + </Show> 490 + <SearchHint tab={props.tab} /> 312 491 </div> 313 492 314 493 <ResultMeta 315 494 hasSearched={props.hasSearched} 495 + isActorTab={props.tab === "profiles"} 316 496 lastSync={props.lastSync} 317 497 mode={props.mode} 318 498 resultCount={props.resultCount} ··· 321 501 ); 322 502 } 323 503 504 + function SearchHint(props: { tab: SearchTab }) { 505 + return ( 506 + <span class="text-xs text-on-surface-variant"> 507 + <Show 508 + when={props.tab === "posts"} 509 + fallback={ 510 + <> 511 + <kbd class="rounded bg-white/10 px-1.5 py-0.5">↑↓</kbd> to navigate suggestions 512 + </> 513 + }> 514 + <> 515 + <kbd class="rounded bg-white/10 px-1.5 py-0.5">Tab</kbd> to switch modes 516 + </> 517 + </Show> 518 + </span> 519 + ); 520 + } 521 + 522 + function SearchTabSelector(props: { activeTab: SearchTab; onTabChange: (tab: SearchTab) => void }) { 523 + return ( 524 + <nav class="flex items-center gap-2" aria-label="Search tabs"> 525 + <For each={SEARCH_TABS}> 526 + {(tab) => ( 527 + <button 528 + type="button" 529 + aria-pressed={props.activeTab === tab} 530 + class="inline-flex items-center gap-2 rounded-full border-0 px-4 py-2 text-sm font-medium transition duration-150" 531 + classList={{ 532 + "bg-primary/16 text-primary shadow-[inset_0_0_0_1px_rgba(125,175,255,0.18)]": props.activeTab === tab, 533 + "bg-white/4 text-on-surface-variant hover:bg-white/8 hover:text-on-surface": props.activeTab !== tab, 534 + }} 535 + onClick={() => props.onTabChange(tab)}> 536 + <Icon kind={tab === "posts" ? "search" : "profile"} class="text-sm" /> 537 + <span>{tab === "posts" ? "Posts" : "Profiles"}</span> 538 + </button> 539 + )} 540 + </For> 541 + </nav> 542 + ); 543 + } 544 + 324 545 function ResultMeta( 325 546 props: { 326 547 hasSearched: boolean; 548 + isActorTab: boolean; 327 549 lastSync: string | null; 328 550 mode: SearchMode; 329 551 resultCount: number; ··· 335 557 <span class="text-sm text-on-surface-variant"> 336 558 <Show 337 559 when={props.hasSearched} 338 - fallback={props.mode === "network" 339 - ? "Search public posts across Bluesky or switch to your synced archive." 340 - : "Search your liked and bookmarked posts locally, or search the network."}> 560 + fallback={props.isActorTab 561 + ? "Search people across Bluesky by handle or display name." 562 + : (props.mode === "network" 563 + ? "Search public posts across Bluesky or switch to your synced archive." 564 + : "Search your liked and bookmarked posts locally, or search the network.")}> 341 565 <span> 342 - Found <span class="font-medium text-on-surface">{props.resultCount}</span> results 566 + Found <span class="font-medium text-on-surface">{props.resultCount}</span>{" "} 567 + {props.isActorTab ? "profiles" : "results"} 343 568 </span> 344 569 </Show> 345 570 </span> 346 571 347 572 <span class="text-xs text-on-surface-variant"> 348 - <Show when={props.totalIndexedPosts > 0}> 573 + <Show when={!props.isActorTab && props.totalIndexedPosts > 0}> 349 574 <PostCount totalPosts={props.totalIndexedPosts} lastSync={props.lastSync} inline /> 350 575 </Show> 351 576 </span> ··· 412 637 413 638 function SearchViewport( 414 639 props: { 640 + actorResults: ActorSearchResult | null; 415 641 error: string | null; 416 642 hasLocalPosts: boolean; 417 643 hasSearched: boolean; 644 + isActorTab: boolean; 418 645 isLocalMode: boolean; 419 646 loading: boolean; 420 647 localResults: LocalPostResult[]; 421 648 networkResults: NetworkSearchResult | null; 649 + onOpenActor: (actor: Pick<ProfileViewBasic, "did" | "handle">) => void; 422 650 onOpenThread: (uri: string) => void; 423 651 query: string; 424 652 }, ··· 434 662 435 663 function SearchState( 436 664 props: { 665 + actorResults: ActorSearchResult | null; 437 666 error: string | null; 438 667 hasLocalPosts: boolean; 439 668 hasSearched: boolean; 669 + isActorTab: boolean; 440 670 isLocalMode: boolean; 441 671 localResults: LocalPostResult[]; 442 672 networkResults: NetworkSearchResult | null; 673 + onOpenActor: (actor: Pick<ProfileViewBasic, "did" | "handle">) => void; 443 674 onOpenThread: (uri: string) => void; 444 675 query: string; 445 676 }, ··· 448 679 <Presence> 449 680 <Switch> 450 681 <Match when={props.error && props.query}> 451 - <EmptyStateView reason="error" scope={props.isLocalMode ? "local" : "network"} /> 682 + <EmptyStateView 683 + reason="error" 684 + scope={props.isActorTab ? "profiles" : (props.isLocalMode ? "local" : "network")} /> 452 685 </Match> 453 686 454 - <Match when={props.isLocalMode && !props.hasLocalPosts}> 687 + <Match when={!props.isActorTab && props.isLocalMode && !props.hasLocalPosts}> 455 688 <EmptyStateView reason="no-sync" scope="local" /> 456 689 </Match> 457 690 458 691 <Match when={!props.hasSearched && !props.query}> 459 - <EmptyStateView reason="initial" scope={props.isLocalMode ? "local" : "network"} /> 692 + <EmptyStateView 693 + reason="initial" 694 + scope={props.isActorTab ? "profiles" : (props.isLocalMode ? "local" : "network")} /> 695 + </Match> 696 + 697 + <Match when={props.isActorTab && props.actorResults?.actors.length === 0}> 698 + <EmptyStateView reason="no-results" scope="profiles" /> 460 699 </Match> 461 700 462 - <Match when={props.isLocalMode && props.localResults.length === 0}> 701 + <Match when={!props.isActorTab && props.isLocalMode && props.localResults.length === 0}> 463 702 <EmptyStateView reason="no-results" scope="local" /> 464 703 </Match> 465 704 466 - <Match when={!props.isLocalMode && props.networkResults?.posts.length === 0}> 705 + <Match when={!props.isActorTab && !props.isLocalMode && props.networkResults?.posts.length === 0}> 467 706 <EmptyStateView reason="no-results" scope="network" /> 707 + </Match> 708 + 709 + <Match when={props.isActorTab && props.actorResults}> 710 + <ActorResultsList onOpenActor={props.onOpenActor} results={props.actorResults} /> 468 711 </Match> 469 712 470 713 <Match when={props.isLocalMode}> ··· 479 722 ); 480 723 } 481 724 482 - function EmptyStateView(props: { reason: "error" | "initial" | "no-results" | "no-sync"; scope: "local" | "network" }) { 725 + function EmptyStateView( 726 + props: { reason: "error" | "initial" | "no-results" | "no-sync"; scope: "local" | "network" | "profiles" }, 727 + ) { 483 728 return ( 484 729 <Motion.div 485 730 class="grid place-items-center px-6 py-16" ··· 492 737 ); 493 738 } 494 739 740 + function ActorResultsList( 741 + props: { onOpenActor: (actor: Pick<ProfileViewBasic, "did" | "handle">) => void; results: ActorSearchResult | null }, 742 + ) { 743 + return ( 744 + <Motion.div 745 + class="grid gap-2" 746 + initial={{ opacity: 0 }} 747 + animate={{ opacity: 1 }} 748 + exit={{ opacity: 0 }} 749 + transition={{ duration: 0.15 }}> 750 + <div class="grid gap-2" role="list"> 751 + <For each={props.results?.actors ?? []}> 752 + {(actor, index) => ( 753 + <Motion.div 754 + role="listitem" 755 + initial={{ opacity: 0, y: -6 }} 756 + animate={{ opacity: 1, y: 0 }} 757 + transition={{ duration: 0.2, delay: Math.min(index() * 0.03, 0.18) }}> 758 + <ActorResultCard actor={actor} onOpenActor={props.onOpenActor} /> 759 + </Motion.div> 760 + )} 761 + </For> 762 + </div> 763 + </Motion.div> 764 + ); 765 + } 766 + 767 + function ActorResultCard( 768 + props: { 769 + actor: ActorSearchResult["actors"][number]; 770 + onOpenActor: (actor: Pick<ProfileViewBasic, "did" | "handle">) => void; 771 + }, 772 + ) { 773 + return ( 774 + <button 775 + type="button" 776 + aria-label={`Open profile ${getActorSuggestionHeadline(props.actor)}`} 777 + class="grid w-full gap-3 rounded-3xl border-0 bg-white/[0.035] p-4 text-left shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 hover:-translate-y-px hover:bg-white/5.5" 778 + onClick={() => props.onOpenActor(props.actor)}> 779 + <ActorResultHeader actor={props.actor} /> 780 + <Show when={props.actor.description?.trim()}> 781 + <p class="m-0 text-sm leading-relaxed text-on-surface-variant">{props.actor.description?.trim()}</p> 782 + </Show> 783 + <p class="m-0 truncate font-mono text-[0.7rem] text-on-surface-variant/80">{props.actor.did}</p> 784 + </button> 785 + ); 786 + } 787 + 788 + function ActorResultHeader(props: { actor: ActorSearchResult["actors"][number] }) { 789 + return ( 790 + <div class="flex items-start gap-3"> 791 + <Show when={props.actor.avatar} fallback={<AvatarBadge label={props.actor.handle} tone="muted" />}> 792 + {(avatar) => <img class="h-12 w-12 rounded-full object-cover" src={avatar()} alt="" loading="lazy" />} 793 + </Show> 794 + <div class="min-w-0 flex-1"> 795 + <div class="flex flex-wrap items-center gap-2"> 796 + <p class="m-0 truncate text-sm font-medium text-on-surface">{getActorSuggestionHeadline(props.actor)}</p> 797 + <span class="rounded-full bg-primary/12 px-2 py-0.5 text-[0.68rem] font-medium uppercase tracking-[0.12em] text-primary"> 798 + Profile 799 + </span> 800 + </div> 801 + <p class="mt-1 truncate text-xs text-on-surface-variant">@{props.actor.handle.replace(/^@/, "")}</p> 802 + </div> 803 + <Icon kind="profile" class="mt-1 text-base text-on-surface-variant" /> 804 + </div> 805 + ); 806 + } 807 + 495 808 function NetworkResultsList( 496 809 props: { onOpenThread: (uri: string) => void; query: string; results: NetworkSearchResult | null }, 497 810 ) { ··· 549 862 <div class="m-0 flex items-start gap-2"> 550 863 <div>·</div> 551 864 <div>Semantic mode follows the embeddings setting and model status shown above.</div> 865 + </div> 866 + <div class="m-0 flex items-start gap-2"> 867 + <div>·</div> 868 + <div>Switch to Profiles when you want people, not posts. Actor suggestions open immediately.</div> 552 869 </div> 553 870 </div> 554 871 </div>
+20
src/components/search/SearchQueryInput.tsx
··· 1 + import type { JSX } from "solid-js"; 1 2 import { Show } from "solid-js"; 2 3 import { Icon } from "../shared/Icon"; 3 4 4 5 type SearchQueryInputProps = { 6 + ariaActivedescendant?: string; 7 + ariaAutocomplete?: "both" | "inline" | "list" | "none"; 8 + ariaControls?: string; 9 + ariaExpanded?: boolean; 10 + autocomplete?: string; 11 + children?: JSX.Element; 5 12 error: string | null; 6 13 inputRef?: (el: HTMLInputElement) => void; 7 14 loading: boolean; 15 + onFocus?: () => void; 8 16 placeholder: string; 9 17 query: string; 18 + role?: JSX.InputHTMLAttributes<HTMLInputElement>["role"]; 19 + spellcheck?: boolean; 10 20 onClear: () => void; 11 21 onKeyDown?: (event: KeyboardEvent) => void; 12 22 onQueryChange: (value: string) => void; ··· 23 33 <input 24 34 ref={props.inputRef} 25 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} 26 43 value={props.query} 27 44 placeholder={props.placeholder} 28 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" 29 46 onInput={(event) => props.onQueryChange(event.currentTarget.value)} 47 + onFocus={() => props.onFocus?.()} 30 48 onKeyDown={(event) => props.onKeyDown?.(event)} /> 31 49 32 50 <div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-2"> 33 51 <LoadingIndicator loading={props.loading} /> 34 52 <ClearButton query={props.query} loading={props.loading} onClear={props.onClear} /> 35 53 </div> 54 + 55 + {props.children} 36 56 </div> 37 57 38 58 <Show when={props.error}>
+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 + }
+8 -3
src/lib/api/search.ts
··· 5 5 6 6 export type NetworkSearchResult = { cursor?: string | null; hitsTotal?: number | null; posts: PostView[] }; 7 7 8 - export type ActorSearchResult = { 9 - cursor?: string | null; 10 - actors: { did: string; handle: string; displayName?: string | null; avatar?: string | null }[]; 8 + type TActor = { 9 + did: string; 10 + handle: string; 11 + displayName?: string | null; 12 + avatar?: string | null; 13 + description?: string | null; 11 14 }; 15 + 16 + export type ActorSearchResult = { cursor?: string | null; actors: TActor[] }; 12 17 13 18 type TStarterPack = { 14 19 uri: string;
+3 -1
src/lib/types.ts
··· 6 6 7 7 export type AppBootstrap = { activeSession: ActiveSession | null; accountList: AccountSummary[] }; 8 8 9 - export type LoginSuggestion = { did: string; handle: string; displayName?: string | null; avatar?: string | null }; 9 + export type ActorSuggestion = { did: string; handle: string; displayName?: string | null; avatar?: string | null }; 10 + 11 + export type LoginSuggestion = ActorSuggestion; 10 12 11 13 export type SavedFeedKind = "timeline" | "feed" | "list"; 12 14