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: typeahead fetch on login

+727 -24
+1
src-tauri/Cargo.lock
··· 3730 3730 dependencies = [ 3731 3731 "fastembed", 3732 3732 "jacquard", 3733 + "reqwest 0.12.28", 3733 3734 "rusqlite", 3734 3735 "serde", 3735 3736 "serde_json",
+1
src-tauri/Cargo.toml
··· 22 22 tauri-plugin-opener = "2" 23 23 serde = { version = "1", features = ["derive"] } 24 24 serde_json = "1" 25 + reqwest = { version = "0.12.28", features = ["json"] } 25 26 rusqlite = { version = "0.37.0", features = ["bundled"] } 26 27 jacquard = "0.11.0" 27 28 sqlite-vec = "0.1.7"
+245 -2
src-tauri/src/auth.rs
··· 1 1 use super::db::DbPool; 2 - use super::error::AppError; 2 + use super::error::{AppError, TypeaheadFetchError, TypeaheadFetchErrorKind}; 3 3 use super::state::{AccountSummary, ActiveSession}; 4 4 use jacquard::api::com_atproto::server::get_session::GetSession; 5 5 use jacquard::common::session::SessionStoreError; ··· 14 14 use jacquard::xrpc::XrpcClient; 15 15 use jacquard::IntoStatic; 16 16 use rusqlite::{params, OptionalExtension}; 17 - use serde::Serialize; 17 + use serde::{Deserialize, Serialize}; 18 18 use std::collections::HashMap; 19 19 use std::net::SocketAddr; 20 20 use std::sync::{MutexGuard, RwLock}; 21 + use std::time::Duration; 21 22 use tauri::{AppHandle, Emitter}; 22 23 23 24 pub const ACCOUNT_SWITCHED_EVENT: &str = "auth:account-switched"; 24 25 pub const AT_URI_OPEN_EVENT: &str = "navigation:open-at-uri"; 25 26 const CLIENT_NAME: &str = "Lazurite"; 27 + const LOGIN_TYPEAHEAD_LIMIT: usize = 6; 28 + const LOGIN_TYPEAHEAD_CLIENT: &str = "lazurite-desktop"; 29 + const LOGIN_TYPEAHEAD_PRIMARY_URL: &str = "https://typeahead.waow.tech"; 30 + const LOGIN_TYPEAHEAD_FALLBACK_URL: &str = "https://public.api.bsky.app"; 26 31 27 32 pub type LazuriteOAuthClient = OAuthClient<jacquard::identity::JacquardResolver, PersistentAuthStore>; 28 33 pub type LazuriteOAuthSession = OAuthSession<jacquard::identity::JacquardResolver, PersistentAuthStore>; ··· 45 50 #[serde(rename_all = "camelCase")] 46 51 pub struct AtUriNavigation { 47 52 pub uri: String, 53 + } 54 + 55 + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] 56 + #[serde(rename_all = "camelCase")] 57 + pub struct LoginSuggestion { 58 + pub did: String, 59 + pub handle: String, 60 + pub display_name: Option<String>, 61 + pub avatar: Option<String>, 62 + } 63 + 64 + #[derive(Debug, Deserialize)] 65 + struct TypeaheadResponse { 66 + #[serde(default)] 67 + actors: Vec<TypeaheadActor>, 68 + } 69 + 70 + #[derive(Debug, Deserialize)] 71 + #[serde(rename_all = "camelCase")] 72 + struct TypeaheadActor { 73 + did: String, 74 + handle: String, 75 + display_name: Option<String>, 76 + avatar: Option<String>, 48 77 } 49 78 50 79 impl PersistentAuthStore { ··· 178 207 Ok(()) 179 208 } 180 209 210 + pub fn prune_orphaned_sessions(&self) -> Result<(), AppError> { 211 + let connection = self.lock_connection()?; 212 + connection.execute( 213 + " 214 + DELETE FROM oauth_sessions 215 + WHERE did NOT IN (SELECT did FROM accounts) 216 + ", 217 + [], 218 + )?; 219 + Ok(()) 220 + } 221 + 222 + pub fn delete_persisted_session(&self, did: &str, session_id: &str) -> Result<(), AppError> { 223 + let connection = self.lock_connection()?; 224 + connection.execute( 225 + "DELETE FROM oauth_sessions WHERE did = ?1 AND session_id = ?2", 226 + params![did, session_id], 227 + )?; 228 + Ok(()) 229 + } 230 + 181 231 pub fn delete_account(&self, did: &str) -> Result<Option<String>, AppError> { 182 232 let mut connection = self.lock_connection()?; 183 233 let transaction = connection.transaction()?; ··· 190 240 .unwrap_or_default() 191 241 == 1; 192 242 243 + transaction.execute("DELETE FROM oauth_sessions WHERE did = ?1", params![did])?; 193 244 transaction.execute("DELETE FROM accounts WHERE did = ?1", params![did])?; 194 245 195 246 let next_active = if was_active { ··· 444 495 fn app_to_store_error(error: AppError) -> SessionStoreError { 445 496 SessionStoreError::Other(Box::new(error)) 446 497 } 498 + 499 + pub async fn search_login_suggestions(query: &str) -> Result<Vec<LoginSuggestion>, AppError> { 500 + let Some(normalized_query) = normalize_login_suggestion_query(query) else { 501 + return Ok(Vec::new()); 502 + }; 503 + 504 + let client = reqwest::Client::builder().timeout(Duration::from_secs(4)).build()?; 505 + 506 + match fetch_login_suggestions_from_endpoint(&client, LOGIN_TYPEAHEAD_PRIMARY_URL, normalized_query).await { 507 + Ok(suggestions) => Ok(suggestions), 508 + Err(error) if should_fallback_to_public_typeahead(&error) => { 509 + fetch_login_suggestions_from_endpoint(&client, LOGIN_TYPEAHEAD_FALLBACK_URL, normalized_query) 510 + .await 511 + .map_err(|fallback_error| { 512 + AppError::validation(format!("{error}; fallback request also failed: {fallback_error}")) 513 + }) 514 + } 515 + Err(error) => Err(AppError::validation(error.to_string())), 516 + } 517 + } 518 + 519 + async fn fetch_login_suggestions_from_endpoint( 520 + client: &reqwest::Client, base_url: &str, query: &str, 521 + ) -> Result<Vec<LoginSuggestion>, TypeaheadFetchError> { 522 + let response = client 523 + .get(format!("{base_url}/xrpc/app.bsky.actor.searchActorsTypeahead")) 524 + .header("X-Client", LOGIN_TYPEAHEAD_CLIENT) 525 + .query(&[("q", query), ("limit", "6")]) 526 + .send() 527 + .await 528 + .map_err(TypeaheadFetchError::transport)?; 529 + 530 + let status = response.status(); 531 + if !status.is_success() { 532 + return Err(TypeaheadFetchError::status(status)); 533 + } 534 + 535 + let payload = response 536 + .json::<TypeaheadResponse>() 537 + .await 538 + .map_err(TypeaheadFetchError::decode)?; 539 + 540 + Ok(payload 541 + .actors 542 + .into_iter() 543 + .filter(|actor| !actor.handle.trim().is_empty()) 544 + .map(|actor| LoginSuggestion { 545 + did: actor.did, 546 + handle: actor.handle, 547 + display_name: actor.display_name, 548 + avatar: actor.avatar, 549 + }) 550 + .take(LOGIN_TYPEAHEAD_LIMIT) 551 + .collect()) 552 + } 553 + 554 + fn normalize_login_suggestion_query(query: &str) -> Option<&str> { 555 + let trimmed = query.trim(); 556 + if trimmed.len() < 2 557 + || trimmed.starts_with("did:") 558 + || trimmed.starts_with("http://") 559 + || trimmed.starts_with("https://") 560 + { 561 + return None; 562 + } 563 + 564 + Some(trimmed.trim_start_matches('@')) 565 + } 566 + 567 + fn should_fallback_to_public_typeahead(error: &TypeaheadFetchError) -> bool { 568 + match error.kind { 569 + TypeaheadFetchErrorKind::Decode | TypeaheadFetchErrorKind::Transport => true, 570 + TypeaheadFetchErrorKind::Status(status) => { 571 + status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error() 572 + } 573 + } 574 + } 575 + 576 + #[cfg(test)] 577 + mod tests { 578 + use super::{ 579 + should_fallback_to_public_typeahead, LoginSuggestion, PersistentAuthStore, TypeaheadFetchError, 580 + TypeaheadFetchErrorKind, 581 + }; 582 + use crate::db::DbPool; 583 + use reqwest::StatusCode; 584 + use rusqlite::{params, Connection}; 585 + use std::sync::{Arc, Mutex}; 586 + 587 + fn auth_store_with_schema(schema: &str) -> PersistentAuthStore { 588 + let connection = Connection::open_in_memory().expect("in-memory db should open"); 589 + connection.execute_batch(schema).expect("schema should apply"); 590 + 591 + let pool: DbPool = Arc::new(Mutex::new(connection)); 592 + PersistentAuthStore::new(pool) 593 + } 594 + 595 + #[test] 596 + fn prunes_orphaned_oauth_sessions() { 597 + let store = auth_store_with_schema( 598 + " 599 + CREATE TABLE accounts ( 600 + did TEXT PRIMARY KEY, 601 + handle TEXT, 602 + pds_url TEXT, 603 + session_id TEXT, 604 + active INTEGER NOT NULL DEFAULT 0 605 + ); 606 + 607 + CREATE TABLE oauth_sessions ( 608 + did TEXT NOT NULL, 609 + session_id TEXT NOT NULL, 610 + session_json TEXT NOT NULL, 611 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 612 + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 613 + PRIMARY KEY (did, session_id) 614 + ); 615 + ", 616 + ); 617 + 618 + let connection = store.lock_connection().expect("connection should lock"); 619 + connection 620 + .execute( 621 + "INSERT INTO accounts(did, handle) VALUES (?1, ?2)", 622 + params!["did:plc:kept", "kept.test"], 623 + ) 624 + .expect("account should insert"); 625 + connection 626 + .execute( 627 + "INSERT INTO oauth_sessions(did, session_id, session_json) VALUES (?1, ?2, ?3)", 628 + params!["did:plc:kept", "session-kept", "{}"], 629 + ) 630 + .expect("owned oauth session should insert"); 631 + connection 632 + .execute( 633 + "INSERT INTO oauth_sessions(did, session_id, session_json) VALUES (?1, ?2, ?3)", 634 + params!["did:plc:orphan", "session-orphan", "{}"], 635 + ) 636 + .expect("orphan oauth session should insert"); 637 + drop(connection); 638 + 639 + store.prune_orphaned_sessions().expect("orphan pruning should succeed"); 640 + 641 + let connection = store.lock_connection().expect("connection should relock"); 642 + let dids: Vec<String> = connection 643 + .prepare("SELECT did FROM oauth_sessions ORDER BY did ASC") 644 + .expect("statement should prepare") 645 + .query_map([], |row| row.get(0)) 646 + .expect("query should run") 647 + .collect::<Result<_, _>>() 648 + .expect("rows should collect"); 649 + 650 + assert_eq!(dids, vec!["did:plc:kept".to_string()]); 651 + } 652 + 653 + #[test] 654 + fn falls_back_to_public_typeahead_on_rate_limit() { 655 + let error = TypeaheadFetchError { 656 + kind: TypeaheadFetchErrorKind::Status(StatusCode::TOO_MANY_REQUESTS), 657 + message: "rate limited".to_string(), 658 + }; 659 + 660 + assert!(should_fallback_to_public_typeahead(&error)); 661 + } 662 + 663 + #[test] 664 + fn does_not_fallback_to_public_typeahead_on_client_input_errors() { 665 + let error = TypeaheadFetchError { 666 + kind: TypeaheadFetchErrorKind::Status(StatusCode::BAD_REQUEST), 667 + message: "bad request".to_string(), 668 + }; 669 + 670 + assert!(!should_fallback_to_public_typeahead(&error)); 671 + } 672 + 673 + #[test] 674 + fn login_suggestion_serialization_shape_matches_frontend_contract() { 675 + let suggestion = LoginSuggestion { 676 + did: "did:plc:alice".to_string(), 677 + handle: "alice.bsky.social".to_string(), 678 + display_name: Some("Alice".to_string()), 679 + avatar: Some("https://cdn.example/alice.jpg".to_string()), 680 + }; 681 + 682 + let payload = serde_json::to_value(suggestion).expect("login suggestion should serialize"); 683 + 684 + assert_eq!(payload["did"], "did:plc:alice"); 685 + assert_eq!(payload["handle"], "alice.bsky.social"); 686 + assert_eq!(payload["displayName"], "Alice"); 687 + assert_eq!(payload["avatar"], "https://cdn.example/alice.jpg"); 688 + } 689 + }
+6
src-tauri/src/commands.rs
··· 1 + use super::auth::{self, LoginSuggestion}; 1 2 use super::error::AppError; 2 3 use super::feed::{self, CreateRecordResult, EmbedInput, ReplyRefInput, UserPreferences}; 3 4 use super::state::{AccountSummary, AppBootstrap, AppState}; ··· 32 33 #[tauri::command] 33 34 pub async fn set_active_account(did: String, app: AppHandle, state: State<'_, AppState>) -> Result<(), AppError> { 34 35 state.switch_account(&app, &did).await 36 + } 37 + 38 + #[tauri::command] 39 + pub async fn search_login_suggestions(query: String) -> Result<Vec<LoginSuggestion>, AppError> { 40 + auth::search_login_suggestions(&query).await 35 41 } 36 42 37 43 #[tauri::command]
+78
src-tauri/src/db.rs
··· 27 27 const MIGRATIONS: &[Migration] = &[ 28 28 Migration::new(1, "initial_schema", include_str!("migrations/001_initial.sql")), 29 29 Migration::new(2, "oauth_storage", include_str!("migrations/002_auth_storage.sql")), 30 + Migration::new( 31 + 3, 32 + "oauth_sessions_without_fk", 33 + include_str!("migrations/003_oauth_sessions_without_fk.sql"), 34 + ), 30 35 ]; 31 36 32 37 pub fn initialize_database(app: &AppHandle) -> Result<DbPool, AppError> { ··· 113 118 114 119 Ok(()) 115 120 } 121 + 122 + #[cfg(test)] 123 + mod tests { 124 + use rusqlite::{params, Connection}; 125 + 126 + fn auth_schema_connection() -> Connection { 127 + let connection = Connection::open_in_memory().expect("in-memory db should open"); 128 + connection 129 + .pragma_update(None, "foreign_keys", "ON") 130 + .expect("foreign keys should enable"); 131 + connection 132 + .execute_batch( 133 + " 134 + CREATE TABLE accounts ( 135 + did TEXT PRIMARY KEY, 136 + handle TEXT, 137 + pds_url TEXT, 138 + active INTEGER NOT NULL DEFAULT 0 CHECK(active IN (0, 1)) 139 + ); 140 + ", 141 + ) 142 + .expect("accounts table should apply"); 143 + connection 144 + .execute_batch(include_str!("migrations/002_auth_storage.sql")) 145 + .expect("auth storage schema should apply"); 146 + connection 147 + } 148 + 149 + #[test] 150 + fn oauth_sessions_require_accounts_before_migration_three() { 151 + let connection = auth_schema_connection(); 152 + 153 + let error = connection 154 + .execute( 155 + " 156 + INSERT INTO oauth_sessions(did, session_id, session_json, updated_at) 157 + VALUES (?1, ?2, ?3, CURRENT_TIMESTAMP) 158 + ", 159 + params!["did:plc:ghost", "session-1", "{}"], 160 + ) 161 + .expect_err("foreign key should reject oauth sessions without an account row"); 162 + 163 + assert!(error.to_string().contains("FOREIGN KEY constraint failed")); 164 + } 165 + 166 + #[test] 167 + fn migration_three_allows_oauth_sessions_before_account_insert() { 168 + let connection = auth_schema_connection(); 169 + connection 170 + .execute_batch(include_str!("migrations/003_oauth_sessions_without_fk.sql")) 171 + .expect("migration three should apply"); 172 + 173 + connection 174 + .execute( 175 + " 176 + INSERT INTO oauth_sessions(did, session_id, session_json, updated_at) 177 + VALUES (?1, ?2, ?3, CURRENT_TIMESTAMP) 178 + ", 179 + params!["did:plc:ghost", "session-1", "{}"], 180 + ) 181 + .expect("oauth session insert should succeed after migration three"); 182 + 183 + let stored_count: i64 = connection 184 + .query_row( 185 + "SELECT COUNT(*) FROM oauth_sessions WHERE did = ?1", 186 + params!["did:plc:ghost"], 187 + |row| row.get(0), 188 + ) 189 + .expect("oauth session count should query"); 190 + 191 + assert_eq!(stored_count, 1); 192 + } 193 + }
+42 -2
src-tauri/src/error.rs
··· 1 + pub type Result<T> = std::result::Result<T, AppError>; 2 + 3 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 4 + pub enum TypeaheadFetchErrorKind { 5 + Decode, 6 + Status(reqwest::StatusCode), 7 + Transport, 8 + } 9 + 10 + #[derive(Debug, thiserror::Error)] 11 + #[error("{message}")] 12 + pub struct TypeaheadFetchError { 13 + pub kind: TypeaheadFetchErrorKind, 14 + pub message: String, 15 + } 16 + 17 + impl TypeaheadFetchError { 18 + pub fn decode(error: reqwest::Error) -> Self { 19 + Self { 20 + kind: TypeaheadFetchErrorKind::Decode, 21 + message: format!("failed to decode typeahead response: {error}"), 22 + } 23 + } 24 + 25 + pub fn status(status: reqwest::StatusCode) -> Self { 26 + Self { 27 + kind: TypeaheadFetchErrorKind::Status(status), 28 + message: format!("typeahead endpoint returned {}", status.as_u16()), 29 + } 30 + } 31 + 32 + pub fn transport(error: reqwest::Error) -> Self { 33 + Self { 34 + kind: TypeaheadFetchErrorKind::Transport, 35 + message: format!("failed to reach typeahead endpoint: {error}"), 36 + } 37 + } 38 + } 39 + 1 40 #[derive(Debug, thiserror::Error)] 2 41 pub enum AppError { 3 42 #[error("database error: {0}")] ··· 17 56 18 57 #[error("serialization error: {0}")] 19 58 SerdeJson(#[from] serde_json::Error), 59 + 60 + #[error("http error: {0}")] 61 + Http(#[from] reqwest::Error), 20 62 21 63 #[error("invalid atproto identifier: {0}")] 22 64 AtIdentifier(#[from] jacquard::types::string::AtStrError), ··· 54 96 AppError::Validation(msg.into()) 55 97 } 56 98 } 57 - 58 - pub type Result<T> = std::result::Result<T, AppError>;
+1
src-tauri/src/lib.rs
··· 53 53 cmd::logout, 54 54 cmd::switch_account, 55 55 cmd::set_active_account, 56 + cmd::search_login_suggestions, 56 57 cmd::get_preferences, 57 58 cmd::get_feed_generators, 58 59 cmd::get_timeline,
+19
src-tauri/src/migrations/003_oauth_sessions_without_fk.sql
··· 1 + CREATE TABLE oauth_sessions_v3 ( 2 + did TEXT NOT NULL, 3 + session_id TEXT NOT NULL, 4 + session_json TEXT NOT NULL, 5 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 + PRIMARY KEY (did, session_id) 8 + ); 9 + 10 + INSERT INTO oauth_sessions_v3(did, session_id, session_json, created_at, updated_at) 11 + SELECT did, session_id, session_json, created_at, updated_at 12 + FROM oauth_sessions; 13 + 14 + DROP TABLE oauth_sessions; 15 + 16 + ALTER TABLE oauth_sessions_v3 RENAME TO oauth_sessions; 17 + 18 + CREATE INDEX IF NOT EXISTS idx_oauth_sessions_did_updated_at 19 + ON oauth_sessions(did, updated_at DESC);
+17 -4
src-tauri/src/state.rs
··· 48 48 impl AppState { 49 49 pub async fn bootstrap(db_pool: DbPool) -> Result<Self, AppError> { 50 50 let auth_store = PersistentAuthStore::new(db_pool.clone()); 51 + auth_store.prune_orphaned_sessions()?; 51 52 let oauth_client = build_oauth_client(auth_store.clone()); 52 53 let accounts = auth_store.load_accounts()?; 53 54 let app_state = Self { ··· 90 91 pub async fn login(&self, app: &AppHandle, identifier: String) -> Result<AccountSummary, AppError> { 91 92 let session = Arc::new(login_with_loopback(&self.oauth_client, identifier.trim()).await?); 92 93 let (did, session_id) = session.session_info().await; 93 - let account_summary = fetch_account_summary(&session, true).await?; 94 + let did = did.to_string(); 95 + let session_id = session_id.to_string(); 96 + let account_summary_result = async { 97 + let account_summary = fetch_account_summary(&session, true).await?; 98 + self.auth_store.upsert_account(&account_summary, &session_id, true)?; 99 + Ok::<_, AppError>(account_summary) 100 + } 101 + .await; 102 + let account_summary = match account_summary_result { 103 + Ok(account_summary) => account_summary, 104 + Err(error) => { 105 + self.auth_store.delete_persisted_session(&did, &session_id)?; 106 + return Err(error); 107 + } 108 + }; 94 109 95 - self.auth_store 96 - .upsert_account(&account_summary, session_id.as_ref(), true)?; 97 110 self.sessions 98 111 .write() 99 112 .map_err(|_| AppError::StatePoisoned("sessions"))? 100 - .insert(did.to_string(), session); 113 + .insert(did, session); 101 114 102 115 self.refresh_account_cache()?; 103 116 emit_account_switch(app, self.current_active_session()?)?;
+71 -3
src/components/LoginPanel.test.tsx
··· 1 - import { render, screen } from "@solidjs/testing-library"; 2 - import { describe, expect, it, vi } from "vitest"; 1 + import { fireEvent, render, screen } from "@solidjs/testing-library"; 2 + import { createSignal } from "solid-js"; 3 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 4 import { LoginPanel } from "./LoginPanel"; 4 5 6 + const invokeMock = vi.hoisted(() => vi.fn()); 7 + 8 + vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); 9 + 5 10 function renderPanel(overrides: Partial<Parameters<typeof LoginPanel>[0]> = {}) { 6 11 const defaults = { value: "", pending: false, shakeCount: 0, onInput: vi.fn(), onSubmit: vi.fn() }; 7 12 8 13 return render(() => <LoginPanel {...{ ...defaults, ...overrides }} />); 9 14 } 10 15 16 + function renderInteractivePanel() { 17 + const onSubmit = vi.fn(); 18 + 19 + return { 20 + onSubmit, 21 + ...render(() => { 22 + const [value, setValue] = createSignal(""); 23 + return <LoginPanel value={value()} pending={false} shakeCount={0} onInput={setValue} onSubmit={onSubmit} />; 24 + }), 25 + }; 26 + } 27 + 11 28 describe("LoginPanel", () => { 29 + beforeEach(() => { 30 + vi.useFakeTimers(); 31 + invokeMock.mockReset(); 32 + invokeMock.mockResolvedValue([]); 33 + }); 34 + 35 + afterEach(() => { 36 + vi.useRealTimers(); 37 + }); 38 + 12 39 it("renders branded header with Lazurite logo", () => { 13 40 renderPanel(); 14 41 15 42 expect(screen.getByText("Lazurite")).toBeInTheDocument(); 16 43 expect(screen.getByText("Powered by Bluesky")).toBeInTheDocument(); 17 - expect(screen.getByText("Sign in with your Internet Handle or DID")).toBeInTheDocument(); 44 + expect(screen.getByText(/sign in with your/i)).toBeInTheDocument(); 45 + expect(screen.getByRole("link", { name: "Internet Handle" })).toBeInTheDocument(); 18 46 19 47 const svg = document.querySelector("svg"); 20 48 expect(svg).toBeInTheDocument(); ··· 42 70 43 71 expect(screen.getByText("Opening sign-in...")).toBeInTheDocument(); 44 72 expect(screen.getByRole("button")).toBeDisabled(); 73 + }); 74 + 75 + it("requests autocomplete suggestions for handle-like input", async () => { 76 + invokeMock.mockResolvedValue([{ 77 + did: "did:plc:alice", 78 + handle: "alice.bsky.social", 79 + displayName: "Alice Example", 80 + avatar: null, 81 + }]); 82 + 83 + renderInteractivePanel(); 84 + 85 + const input = screen.getByPlaceholderText("alice.bsky.social"); 86 + input.focus(); 87 + fireEvent.input(input, { target: { value: "ali" } }); 88 + await vi.advanceTimersByTimeAsync(200); 89 + 90 + expect(invokeMock).toHaveBeenCalledWith("search_login_suggestions", { query: "ali" }); 91 + expect(await screen.findByText("Alice Example")).toBeInTheDocument(); 92 + expect(screen.getByText("@alice.bsky.social")).toBeInTheDocument(); 93 + }); 94 + 95 + it("applies the highlighted suggestion on enter instead of submitting immediately", async () => { 96 + const { onSubmit } = renderInteractivePanel(); 97 + invokeMock.mockResolvedValue([{ 98 + did: "did:plc:alice", 99 + handle: "alice.bsky.social", 100 + displayName: "Alice Example", 101 + avatar: null, 102 + }]); 103 + 104 + const input = screen.getByPlaceholderText("alice.bsky.social"); 105 + input.focus(); 106 + fireEvent.input(input, { target: { value: "ali" } }); 107 + await vi.advanceTimersByTimeAsync(200); 108 + 109 + fireEvent.keyDown(input, { key: "Enter" }); 110 + 111 + expect(screen.getByDisplayValue("alice.bsky.social")).toBeInTheDocument(); 112 + expect(onSubmit).not.toHaveBeenCalled(); 45 113 }); 46 114 });
+244 -13
src/components/LoginPanel.tsx
··· 1 - import { createEffect, Show } from "solid-js"; 1 + import { invoke } from "@tauri-apps/api/core"; 2 + import { createEffect, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 2 3 import { Motion } from "solid-motionone"; 4 + import type { LoginSuggestion } from "../lib/types"; 5 + import { AvatarBadge } from "./AvatarBadge"; 3 6 import { Icon } from "./shared/Icon"; 4 7 import { LazuriteLogo } from "./Wordmark"; 8 + 9 + const LOGIN_TYPEAHEAD_DEBOUNCE_MS = 180; 5 10 6 11 function LoginSubmitButton(props: { pending: boolean }) { 7 12 return ( ··· 32 37 }; 33 38 34 39 export function LoginPanel(props: LoginPanelProps) { 40 + let container: HTMLDivElement | undefined; 35 41 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[]>([]); 36 47 37 48 createEffect(() => { 38 49 if (props.shakeCount > 0) { ··· 41 52 } 42 53 }); 43 54 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 + function applySuggestion(suggestion: LoginSuggestion) { 117 + props.onInput(suggestion.handle); 118 + setOpen(false); 119 + setActiveIndex(-1); 120 + input?.focus(); 121 + } 122 + 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 + function handleKeyDown(event: KeyboardEvent) { 140 + if (event.key === "ArrowDown") { 141 + event.preventDefault(); 142 + moveActiveIndex(1); 143 + return; 144 + } 145 + 146 + if (event.key === "ArrowUp") { 147 + event.preventDefault(); 148 + moveActiveIndex(-1); 149 + return; 150 + } 151 + 152 + if (event.key === "Escape") { 153 + setOpen(false); 154 + setActiveIndex(-1); 155 + return; 156 + } 157 + 158 + if (event.key === "Enter" && open() && activeIndex() >= 0) { 159 + event.preventDefault(); 160 + applySuggestion(suggestions()[activeIndex()]); 161 + } 162 + } 163 + 44 164 return ( 45 - <article class="panel-surface grid gap-5 p-5"> 165 + <article 166 + class="panel-surface grid gap-5 p-5" 167 + ref={(element) => { 168 + container = element as HTMLDivElement; 169 + }}> 46 170 <div class="grid place-items-center gap-3 py-2"> 47 171 <span class="grid place-items-center text-primary"> 48 172 <LazuriteLogo class="h-14 w-14" /> ··· 66 190 <span class="overline-copy text-xs tracking-[0.08em] text-on-surface-variant"> 67 191 {/* TODO: use tauri opener */} 68 192 Sign in with your <a href="https://internethandle.org" class="text-primary underline">Internet Handle</a> 193 + {" "} 69 194 or DID 70 195 </span> 71 - <input 72 - ref={(element) => { 73 - input = element; 74 - }} 75 - class="min-h-[3.4rem] w-full rounded-xl border-0 bg-white/4 px-[1.15rem] text-on-surface shadow-[inset_0_0_0_1px_rgba(125,175,255,0.16)] focus:outline focus:outline-primary/50 focus:shadow-[inset_0_0_0_1px_rgba(125,175,255,0.35),0_0_28px_rgba(125,175,255,0.12)]" 76 - type="text" 77 - autocomplete="username" 78 - spellcheck={false} 79 - value={props.value} 80 - placeholder="alice.bsky.social" 81 - onInput={(event) => props.onInput(event.currentTarget.value)} /> 196 + <div class="relative"> 197 + <input 198 + ref={(element) => { 199 + input = element; 200 + }} 201 + class="min-h-[3.4rem] w-full rounded-xl border-0 bg-white/4 px-[1.15rem] pr-11 text-on-surface shadow-[inset_0_0_0_1px_rgba(125,175,255,0.16)] focus:outline focus:outline-primary/50 focus:shadow-[inset_0_0_0_1px_rgba(125,175,255,0.35),0_0_28px_rgba(125,175,255,0.12)]" 202 + type="text" 203 + role="combobox" 204 + aria-autocomplete="list" 205 + aria-controls="login-suggestions" 206 + aria-activedescendant={activeIndex() >= 0 ? `login-suggestion-${activeIndex()}` : undefined} 207 + aria-expanded={open()} 208 + autocomplete="username" 209 + spellcheck={false} 210 + value={props.value} 211 + placeholder="alice.bsky.social" 212 + onFocus={() => setOpen(suggestions().length > 0)} 213 + onInput={(event) => props.onInput(event.currentTarget.value)} 214 + onKeyDown={(event) => handleKeyDown(event)} /> 215 + <LoginLoadingIndicator visible={loading()} /> 216 + <LoginTypeaheadPanel 217 + activeIndex={activeIndex()} 218 + open={open()} 219 + suggestions={suggestions()} 220 + onSelect={applySuggestion} /> 221 + </div> 82 222 </label> 83 223 <LoginSubmitButton pending={props.pending} /> 84 224 </Motion.form> 85 225 </article> 86 226 ); 87 227 } 228 + 229 + function LoginLoadingIndicator(props: { visible: boolean }) { 230 + return ( 231 + <Show when={props.visible}> 232 + <span class="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 text-on-surface-variant"> 233 + <Icon kind="loader" aria-hidden="true" /> 234 + </span> 235 + </Show> 236 + ); 237 + } 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-[1.35rem] 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-[1.05rem] 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 + }
+2
src/lib/types.ts
··· 4 4 5 5 export type AppBootstrap = { activeSession: ActiveSession | null; accountList: AccountSummary[] }; 6 6 7 + export type LoginSuggestion = { did: string; handle: string; displayName?: string | null; avatar?: string | null }; 8 + 7 9 export type SavedFeedItem = { id: string; type: string; value: string; pinned: boolean }; 8 10 9 11 export type FeedViewPrefItem = {