From ee911ea32909a21fcacc77bb6b45e18bd7ab6903 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Mon, 6 Apr 2026 18:38:32 -0500 Subject: [PATCH] feat: add lexicon favicon support & typehead to at explorer --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/commands/explorer.rs | 8 + src-tauri/src/explorer.rs | 569 +++++++++++++++++- src-tauri/src/lib.rs | 1 + src/App.css | 5 +- src/components/actors/actor-search.tsx | 2 +- .../explorer/ExplorerPanel.test.tsx | 62 +- src/components/explorer/ExplorerPanel.tsx | 147 ++++- .../explorer/ExplorerUrlBar.test.tsx | 107 ++++ src/components/explorer/ExplorerUrlBar.tsx | 90 ++- src/components/explorer/LexiconIcon.tsx | 20 + src/components/explorer/explorer-state.ts | 19 +- src/components/explorer/types.ts | 1 + .../explorer/views/CollectionView.tsx | 8 +- src/components/explorer/views/RepoView.tsx | 7 +- src/lib/api/explorer.ts | 4 + 17 files changed, 1003 insertions(+), 49 deletions(-) create mode 100644 src/components/explorer/ExplorerUrlBar.test.tsx create mode 100644 src/components/explorer/LexiconIcon.tsx diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2ae76bd..57532a3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3848,6 +3848,7 @@ dependencies = [ name = "lazurite-desktop" version = "0.1.0" dependencies = [ + "base64 0.22.1", "fastembed", "hf-hub 0.5.0", "jacquard", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a1f7e0d..7dae296 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,6 +25,7 @@ tauri-plugin-global-shortcut = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12.28", features = ["json"] } +base64 = "0.22.1" rusqlite = { version = "0.37.0", features = ["bundled"] } jacquard = "0.11.0" sqlite-vec = "0.1.7" diff --git a/src-tauri/src/commands/explorer.rs b/src-tauri/src/commands/explorer.rs index c341900..e54fcc5 100644 --- a/src-tauri/src/commands/explorer.rs +++ b/src-tauri/src/commands/explorer.rs @@ -1,5 +1,6 @@ use crate::error::AppError; use crate::explorer; +use std::collections::HashMap; use tauri::AppHandle; #[tauri::command] @@ -38,3 +39,10 @@ pub async fn export_repo_car(did: String, app: AppHandle) -> Result Result { explorer::query_labels(uri).await } + +#[tauri::command] +pub async fn get_lexicon_favicons( + collections: Vec, app: AppHandle, +) -> Result>, AppError> { + explorer::get_lexicon_favicons(collections, &app).await +} diff --git a/src-tauri/src/explorer.rs b/src-tauri/src/explorer.rs index 279d31c..295225b 100644 --- a/src-tauri/src/explorer.rs +++ b/src-tauri/src/explorer.rs @@ -1,4 +1,5 @@ use crate::error::{AppError, Result}; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use jacquard::api::com_atproto::identity::resolve_handle::ResolveHandle; use jacquard::api::com_atproto::label::query_labels::QueryLabels; use jacquard::api::com_atproto::repo::describe_repo::DescribeRepo; @@ -21,12 +22,17 @@ use jacquard::xrpc::XrpcClient; use jacquard::IntoStatic; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::HashMap; use std::path::PathBuf; +use std::time::Duration; use tauri::{AppHandle, Emitter, Manager}; +use tauri_plugin_log::log; pub const EXPLORER_NAVIGATION_EVENT: &str = "navigation:explorer-resolved"; const PDS_REPO_LIST_LIMIT: i64 = 100; const QUERY_LABELS_LIMIT: i64 = 100; +const FAVICON_FETCH_TIMEOUT: Duration = Duration::from_secs(2); +const LEXICON_FAVICON_HOST_OVERRIDES: &[(&str, &str)] = &[("sh.tangled.", "tangled.org")]; type ExplorerClient = Agent>; @@ -246,6 +252,34 @@ pub async fn query_labels(uri: String) -> Result { serde_json::to_value(output).map_err(AppError::from) } +pub async fn get_lexicon_favicons( + collections: Vec, app: &AppHandle, +) -> Result>> { + let client = match reqwest::Client::builder().timeout(FAVICON_FETCH_TIMEOUT).build() { + Ok(client) => client, + Err(error) => { + log::warn!("failed to construct favicon client: {error}"); + return Ok(collections.into_iter().map(|collection| (collection, None)).collect()); + } + }; + let cache_dir = match resolve_favicon_cache_dir(app) { + Ok(cache_dir) => Some(cache_dir), + Err(error) => { + log::warn!("failed to resolve explorer favicon cache directory: {error}"); + None + } + }; + + let mut icons = HashMap::with_capacity(collections.len()); + + for collection in collections { + let icon = resolve_lexicon_favicon_data_url(&client, cache_dir.as_deref(), &collection).await; + icons.insert(collection, icon); + } + + Ok(icons) +} + pub async fn emit_explorer_navigation(app: &AppHandle, raw: &str) -> Result<()> { let target = resolve_input(raw.to_string()).await?; app.emit(EXPLORER_NAVIGATION_EVENT, ExplorerNavigation { target })?; @@ -522,6 +556,378 @@ fn resolve_car_export_path(app: &AppHandle, did: &str) -> Result { Ok(app_data_dir) } +fn resolve_favicon_cache_dir(app: &AppHandle) -> Result { + let mut cache_dir = app + .path() + .app_cache_dir() + .map_err(|error| AppError::PathResolve(error.to_string()))?; + cache_dir.push("explorer"); + cache_dir.push("favicons"); + Ok(cache_dir) +} + +async fn resolve_lexicon_favicon_data_url( + client: &reqwest::Client, cache_dir: Option<&std::path::Path>, collection: &str, +) -> Option { + let hosts = lexicon_favicon_hosts(collection) + .map_err(|error| { + log::warn!("failed to derive favicon hosts for {collection}: {error}"); + error + }) + .ok()?; + + for host in hosts { + if let Some(cache_dir) = cache_dir { + if let Some(cached) = read_cached_favicon_data_url(cache_dir, &host) { + return Some(cached); + } + } + + if let Some(icon) = fetch_host_favicon(client, &host).await { + if let Some(cache_dir) = cache_dir { + write_cached_favicon(cache_dir, &host, &icon); + } + return Some(icon.data_url); + } + } + + None +} + +async fn fetch_host_favicon(client: &reqwest::Client, host: &str) -> Option { + let favicon_url = format!("https://{host}/favicon.ico"); + if let Some(icon) = fetch_favicon_from_url(client, &favicon_url).await { + return Some(icon); + } + + let root_url = format!("https://{host}/"); + let html = match fetch_html_document(client, &root_url).await { + Some(html) => html, + None => return None, + }; + let base_url = match reqwest::Url::parse(&root_url) { + Ok(url) => url, + Err(error) => { + log::warn!("failed to parse root favicon fallback URL {root_url}: {error}"); + return None; + } + }; + + for candidate_url in extract_favicon_urls(&html, &base_url) { + if let Some(icon) = fetch_favicon_from_url(client, candidate_url.as_str()).await { + return Some(icon); + } + } + + None +} + +async fn fetch_favicon_from_url(client: &reqwest::Client, favicon_url: &str) -> Option { + let response = match client.get(favicon_url).send().await { + Ok(response) => response, + Err(error) => { + log::warn!("failed to fetch favicon from {favicon_url}: {error}"); + return None; + } + }; + + if !response.status().is_success() { + log::warn!("favicon request to {favicon_url} returned {}", response.status()); + return None; + } + + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let bytes = match response.bytes().await { + Ok(bytes) => bytes.to_vec(), + Err(error) => { + log::warn!("failed to read favicon bytes from {favicon_url}: {error}"); + return None; + } + }; + let mime = match detect_favicon_mime(content_type.as_deref(), &bytes) { + Some(mime) => mime, + None => { + log::warn!("favicon response from {favicon_url} was not a recognized image"); + return None; + } + }; + + Some(CachedFavicon { + bytes: bytes.clone(), + mime: mime.clone(), + data_url: format!("data:{mime};base64,{}", BASE64_STANDARD.encode(&bytes)), + }) +} + +async fn fetch_html_document(client: &reqwest::Client, root_url: &str) -> Option { + let response = match client.get(root_url).send().await { + Ok(response) => response, + Err(error) => { + log::warn!("failed to fetch HTML fallback document from {root_url}: {error}"); + return None; + } + }; + + if !response.status().is_success() { + log::warn!("HTML fallback request to {root_url} returned {}", response.status()); + return None; + } + + match response.text().await { + Ok(html) => Some(html), + Err(error) => { + log::warn!("failed to read HTML fallback document from {root_url}: {error}"); + None + } + } +} + +fn extract_favicon_urls(html: &str, base_url: &reqwest::Url) -> Vec { + let resolved_base_url = resolve_html_base_url(html, base_url); + let lowercase = html.to_ascii_lowercase(); + let mut cursor = 0; + let mut urls = Vec::new(); + + while let Some(relative_start) = lowercase[cursor..].find("') else { + break; + }; + let end = start + relative_end + 1; + let tag = &html[start..end]; + + let rel = extract_html_attribute(tag, "rel"); + let href = extract_html_attribute(tag, "href"); + + if let (Some(rel), Some(href)) = (rel, href) { + if !rel_indicates_favicon(&rel) { + cursor = end; + continue; + } + + if let Ok(url) = resolved_base_url.join(&href) { + if matches!(url.scheme(), "http" | "https") && !urls.iter().any(|existing| existing == &url) { + urls.push(url); + } + } + } + + cursor = end; + } + + urls +} + +fn extract_html_attribute(tag: &str, attribute: &str) -> Option { + let lowercase = tag.to_ascii_lowercase(); + let lowercase_bytes = lowercase.as_bytes(); + let bytes = tag.as_bytes(); + let attribute_bytes = attribute.as_bytes(); + let mut cursor = 0; + + while cursor + attribute_bytes.len() <= lowercase_bytes.len() { + let start = lowercase[cursor..].find(attribute)? + cursor; + let before = start + .checked_sub(1) + .and_then(|index| lowercase_bytes.get(index)) + .copied(); + let after = lowercase_bytes.get(start + attribute_bytes.len()).copied(); + + let invalid_before = before + .is_some_and(|character| character.is_ascii_alphanumeric() || matches!(character, b'-' | b'_' | b':')); + let invalid_after = + after.is_some_and(|character| character.is_ascii_alphanumeric() || matches!(character, b'-' | b'_' | b':')); + + if invalid_before || invalid_after { + cursor = start + attribute_bytes.len(); + continue; + } + + let mut value_start = start + attribute_bytes.len(); + while bytes.get(value_start).is_some_and(u8::is_ascii_whitespace) { + value_start += 1; + } + + if bytes.get(value_start) != Some(&b'=') { + cursor = start + attribute_bytes.len(); + continue; + } + + value_start += 1; + while bytes.get(value_start).is_some_and(u8::is_ascii_whitespace) { + value_start += 1; + } + + let quote = *bytes.get(value_start)?; + if quote == b'"' || quote == b'\'' { + let value_end = tag[value_start + 1..].find(char::from(quote))?; + return Some(tag[value_start + 1..value_start + 1 + value_end].trim().to_string()); + } + + let value_end = tag[value_start..] + .find(|character: char| character.is_whitespace() || character == '>') + .unwrap_or(tag.len() - value_start); + return Some(tag[value_start..value_start + value_end].trim().to_string()); + } + + None +} + +fn resolve_html_base_url(html: &str, request_url: &reqwest::Url) -> reqwest::Url { + let lowercase = html.to_ascii_lowercase(); + let mut cursor = 0; + + while let Some(relative_start) = lowercase[cursor..].find("') else { + break; + }; + let end = start + relative_end + 1; + let tag = &html[start..end]; + + if let Some(href) = extract_html_attribute(tag, "href") { + if let Ok(base_url) = request_url.join(&href) { + if matches!(base_url.scheme(), "http" | "https") { + return base_url; + } + } + } + + cursor = end; + } + + request_url.clone() +} + +fn rel_indicates_favicon(rel: &str) -> bool { + rel.to_ascii_lowercase().contains("icon") +} + +fn lexicon_favicon_hosts(collection: &str) -> Result> { + let domain_authority = parse_collection(collection)?.domain_authority().to_string(); + let authority_labels: Vec<&str> = domain_authority.split('.').collect(); + let mut hosts = Vec::new(); + + for (prefix, host) in LEXICON_FAVICON_HOST_OVERRIDES { + if collection.starts_with(prefix) && !hosts.iter().any(|candidate| candidate == host) { + hosts.push((*host).to_string()); + } + } + + if authority_labels.len() >= 2 { + let canonical_host = format!("{}.{}", authority_labels[1], authority_labels[0]); + if !hosts.iter().any(|candidate| candidate == &canonical_host) { + hosts.push(canonical_host); + } + } + + Ok(hosts) +} + +fn read_cached_favicon_data_url(cache_dir: &std::path::Path, host: &str) -> Option { + let (bytes_path, mime_path) = favicon_cache_paths(cache_dir, host); + let mime = match std::fs::read_to_string(&mime_path) { + Ok(mime) => mime.trim().to_string(), + Err(error) => { + if mime_path.exists() { + log::warn!("failed to read cached favicon mime for {host}: {error}"); + } + return None; + } + }; + let bytes = match std::fs::read(&bytes_path) { + Ok(bytes) => bytes, + Err(error) => { + if bytes_path.exists() { + log::warn!("failed to read cached favicon bytes for {host}: {error}"); + } + return None; + } + }; + + Some(format!("data:{mime};base64,{}", BASE64_STANDARD.encode(bytes))) +} + +fn write_cached_favicon(cache_dir: &std::path::Path, host: &str, icon: &CachedFavicon) { + if let Err(error) = std::fs::create_dir_all(cache_dir) { + log::warn!( + "failed to create favicon cache directory {}: {error}", + cache_dir.display() + ); + return; + } + + let (bytes_path, mime_path) = favicon_cache_paths(cache_dir, host); + + if let Err(error) = std::fs::write(&bytes_path, &icon.bytes) { + log::warn!("failed to write cached favicon bytes for {host}: {error}"); + return; + } + + if let Err(error) = std::fs::write(&mime_path, &icon.mime) { + log::warn!("failed to write cached favicon mime for {host}: {error}"); + } +} + +fn favicon_cache_paths(cache_dir: &std::path::Path, host: &str) -> (PathBuf, PathBuf) { + let safe_host = sanitize_host_for_filename(host); + ( + cache_dir.join(format!("{safe_host}.bin")), + cache_dir.join(format!("{safe_host}.mime")), + ) +} + +fn sanitize_host_for_filename(host: &str) -> String { + host.chars() + .map(|character| match character { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' => character, + _ => '_', + }) + .collect() +} + +fn detect_favicon_mime(content_type: Option<&str>, bytes: &[u8]) -> Option { + if let Some(content_type) = content_type { + let mime = content_type.split(';').next()?.trim().to_ascii_lowercase(); + if mime.starts_with("image/") { + return Some(mime); + } + } + + if bytes.starts_with(&[0x89, b'P', b'N', b'G']) { + return Some("image/png".to_string()); + } + + if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { + return Some("image/jpeg".to_string()); + } + + if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + return Some("image/gif".to_string()); + } + + if bytes.starts_with(&[0x00, 0x00, 0x01, 0x00]) { + return Some("image/x-icon".to_string()); + } + + if String::from_utf8_lossy(bytes).contains(", + mime: String, + data_url: String, +} + fn repo_car_filename(did: &str) -> String { format!("{}.car", sanitize_did_for_filename(did)) } @@ -538,12 +944,18 @@ fn sanitize_did_for_filename(did: &str) -> String { #[cfg(test)] mod tests { use super::{ - build_resolved_at_uri, canonical_at_uri, detect_input_kind, normalize_handle, normalize_pds_url, - repo_car_filename, repo_metadata_from_did_doc, sanitize_did_for_filename, ExplorerInputKind, - ExplorerTargetKind, + build_resolved_at_uri, canonical_at_uri, detect_favicon_mime, detect_input_kind, extract_favicon_urls, + extract_html_attribute, lexicon_favicon_hosts, normalize_handle, normalize_pds_url, + read_cached_favicon_data_url, rel_indicates_favicon, repo_car_filename, repo_metadata_from_did_doc, + resolve_html_base_url, resolve_lexicon_favicon_data_url, sanitize_did_for_filename, write_cached_favicon, + CachedFavicon, ExplorerInputKind, ExplorerTargetKind, }; use jacquard::types::aturi::AtUri; use jacquard::types::did_doc::DidDocument; + use reqwest::Client; + use std::fs; + use std::time::Duration; + use uuid::Uuid; #[test] fn detects_all_supported_input_kinds() { @@ -623,6 +1035,151 @@ mod tests { assert_eq!(repo_car_filename("did:plc:alice-123"), "did_plc_alice-123.car"); } + #[test] + fn derives_candidate_hosts_from_lexicon_nsids() { + assert_eq!( + lexicon_favicon_hosts("app.bsky.feed.post").expect("nsid should parse"), + vec!["bsky.app".to_string()] + ); + assert_eq!( + lexicon_favicon_hosts("sh.tangled.repo.issue").expect("override nsid should parse"), + vec!["tangled.org".to_string(), "tangled.sh".to_string()] + ); + assert!(lexicon_favicon_hosts("not-a-valid-nsid").is_err()); + } + + #[test] + fn detects_supported_favicon_mime_types() { + assert_eq!( + detect_favicon_mime(Some("image/vnd.microsoft.icon"), &[0x00, 0x00, 0x01, 0x00]), + Some("image/vnd.microsoft.icon".to_string()) + ); + assert_eq!( + detect_favicon_mime(None, &[0x89, b'P', b'N', b'G', 0x0D, 0x0A]), + Some("image/png".to_string()) + ); + assert!(detect_favicon_mime(Some("text/html"), b"").is_none()); + } + + #[test] + fn extracts_favicon_urls_from_html_link_elements() { + let base_url = reqwest::Url::parse("https://bsky.app/").expect("base URL should parse"); + let urls = extract_favicon_urls( + r#" + + + + + + + + + "#, + &base_url, + ); + + assert_eq!( + urls, + vec![ + reqwest::Url::parse("https://bsky.app/favicon-32.png").expect("relative favicon URL should resolve"), + reqwest::Url::parse("https://cdn.example.com/favicon.ico").expect("absolute favicon URL should parse"), + reqwest::Url::parse("https://bsky.app/apple-touch.png") + .expect("apple touch favicon URL should resolve"), + ] + ); + } + + #[test] + fn resolves_relative_favicon_urls_like_tangled() { + let base_url = reqwest::Url::parse("https://tangled.org/").expect("base URL should parse"); + let urls = extract_favicon_urls( + r#""#, + &base_url, + ); + + assert_eq!( + urls, + vec![reqwest::Url::parse("https://tangled.org/static/logos/dolly.svg") + .expect("tangled favicon URL should resolve")] + ); + } + + #[test] + fn extracts_html_attributes_with_whitespace_and_quotes() { + let tag = r#""#; + + assert_eq!(extract_html_attribute(tag, "rel"), Some("icon".to_string())); + assert_eq!( + extract_html_attribute(tag, "href"), + Some("/static/logos/dolly.svg".to_string()) + ); + assert_eq!(extract_html_attribute(tag, "type"), Some("image/svg+xml".to_string())); + } + + #[test] + fn honors_html_base_href_when_resolving_favicon_urls() { + let request_url = reqwest::Url::parse("https://example.com/app/").expect("request URL should parse"); + let html = r#" + + + + + "#; + + assert_eq!( + resolve_html_base_url(html, &request_url), + reqwest::Url::parse("https://cdn.example.com/assets/").expect("base href should resolve") + ); + assert_eq!( + extract_favicon_urls(html, &request_url), + vec![reqwest::Url::parse("https://cdn.example.com/assets/favicons/app.svg") + .expect("favicon URL should resolve against base href")] + ); + } + + #[test] + fn recognizes_common_favicon_rel_patterns() { + assert!(rel_indicates_favicon("icon")); + assert!(rel_indicates_favicon("shortcut icon")); + assert!(rel_indicates_favicon("apple-touch-icon")); + assert!(rel_indicates_favicon("mask-icon")); + assert!(!rel_indicates_favicon("stylesheet")); + } + + #[tokio::test] + async fn returns_cached_lexicon_favicon_without_fetching() { + let cache_dir = create_temp_cache_dir(); + write_cached_favicon( + &cache_dir, + "bsky.app", + &CachedFavicon { + bytes: vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A], + mime: "image/png".to_string(), + data_url: "data:image/png;base64,ignored".to_string(), + }, + ); + + let client = Client::builder() + .timeout(Duration::from_millis(200)) + .build() + .expect("client should build"); + let icon = resolve_lexicon_favicon_data_url(&client, Some(cache_dir.as_path()), "app.bsky.feed.post").await; + + assert_eq!(icon, read_cached_favicon_data_url(&cache_dir, "bsky.app"),); + + fs::remove_dir_all(cache_dir).expect("temporary cache directory should be removed"); + } + + #[tokio::test] + async fn failed_favicon_fetches_return_none() { + let client = Client::builder() + .timeout(Duration::from_millis(200)) + .build() + .expect("client should build"); + + assert!(super::fetch_host_favicon(&client, "127.0.0.1:9").await.is_none()); + } + #[test] fn at_uri_parser_distinguishes_repo_collection_and_record_levels() { let repo_uri = AtUri::new("at://did:plc:alice").expect("repo uri should parse"); @@ -672,4 +1229,10 @@ mod tests { ExplorerTargetKind::Record ); } + + fn create_temp_cache_dir() -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("lazurite-explorer-cache-{}", Uuid::new_v4())); + fs::create_dir_all(&path).expect("temporary cache directory should be created"); + path + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6ad49bd..5b3dd72 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -114,6 +114,7 @@ pub fn run() { cmd::explorer::get_record, cmd::explorer::export_repo_car, cmd::explorer::query_labels, + cmd::explorer::get_lexicon_favicons, cmd::search::search_posts_network, cmd::search::search_posts, cmd::search::search_actors, diff --git a/src/App.css b/src/App.css index b27d792..d2200f6 100644 --- a/src/App.css +++ b/src/App.css @@ -10,6 +10,7 @@ --color-surface: var(--surface); --color-surface-container: var(--surface-container); --color-surface-container-high: var(--surface-container-high); + --color-surface-container-highest: var(--surface-container-highest); --color-surface-bright: var(--surface-bright); --color-primary: var(--primary); --color-on-primary-fixed: var(--on-primary-fixed); @@ -26,7 +27,7 @@ --surface: #0e0e0e; --surface-container: #191919; --surface-container-high: #1f1f1f; - --surface-container-highest: rgba(36, 36, 36, 0.7); + --surface-container-highest: rgb(36, 36, 36); --surface-bright: rgba(255, 255, 255, 0.05); --primary: #7dafff; --primary-dim: #0073de; @@ -45,7 +46,7 @@ --surface: #ffffff; --surface-container: #ebeffb; --surface-container-high: #e1e8fa; - --surface-container-highest: rgba(234, 241, 255, 0.72); + --surface-container-highest: rgb(234, 241, 255); --surface-bright: rgba(24, 37, 66, 0.07); --on-surface: #0f1523; --on-surface-variant: #4e5e7e; diff --git a/src/components/actors/actor-search.tsx b/src/components/actors/actor-search.tsx index ca3de45..4dfd093 100644 --- a/src/components/actors/actor-search.tsx +++ b/src/components/actors/actor-search.tsx @@ -145,7 +145,7 @@ export function ActorSuggestionList(
+ 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]">

{props.title}

diff --git a/src/components/explorer/ExplorerPanel.test.tsx b/src/components/explorer/ExplorerPanel.test.tsx index 315921e..63538f7 100644 --- a/src/components/explorer/ExplorerPanel.test.tsx +++ b/src/components/explorer/ExplorerPanel.test.tsx @@ -5,6 +5,7 @@ import { ExplorerPanel } from "./ExplorerPanel"; const describeRepoMock = vi.hoisted(() => vi.fn()); const describeServerMock = vi.hoisted(() => vi.fn()); const exportRepoCarMock = vi.hoisted(() => vi.fn()); +const getLexiconFaviconsMock = vi.hoisted(() => vi.fn()); const getRecordMock = vi.hoisted(() => vi.fn()); const getRecordBacklinksMock = vi.hoisted(() => vi.fn()); const getProfileMock = vi.hoisted(() => vi.fn()); @@ -19,6 +20,7 @@ vi.mock( describeRepo: describeRepoMock, describeServer: describeServerMock, exportRepoCar: exportRepoCarMock, + getLexiconFavicons: getLexiconFaviconsMock, getRecord: getRecordMock, listRecords: listRecordsMock, queryLabels: queryLabelsMock, @@ -39,6 +41,7 @@ describe("ExplorerPanel", () => { describeRepoMock.mockReset(); describeServerMock.mockReset(); exportRepoCarMock.mockReset(); + getLexiconFaviconsMock.mockReset(); getRecordMock.mockReset(); getRecordBacklinksMock.mockReset(); getProfileMock.mockReset(); @@ -48,11 +51,10 @@ describe("ExplorerPanel", () => { listenMock.mockReset(); exportRepoCarMock.mockResolvedValue({ did: "did:plc:alice", path: "/tmp/alice.car", bytesWritten: 64 }); + getLexiconFaviconsMock.mockResolvedValue({}); getProfileMock.mockResolvedValue({ - did: "did:plc:alice", - handle: "alice.test", - followersCount: 28, - followsCount: 14, + status: "available", + profile: { did: "did:plc:alice", handle: "alice.test", followersCount: 28, followsCount: 14 }, }); getRecordBacklinksMock.mockResolvedValue({ likes: { cursor: null, records: [], total: 3 }, @@ -95,6 +97,17 @@ describe("ExplorerPanel", () => { expect(screen.queryByText("Count unavailable")).not.toBeInTheDocument(); }); + it("renders the initial empty state and submits example chips", async () => { + resolveInputMock.mockRejectedValueOnce(new Error("network unavailable")); + + renderPanel(); + + expect(screen.getByText("Start from a handle, DID, URI, or PDS.")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /@alice\.bsky\.social/u })); + + await waitFor(() => expect(resolveInputMock).toHaveBeenCalledWith("@alice.bsky.social")); + }); + it("loads additional collection pages", async () => { resolveInputMock.mockResolvedValue({ input: "at://did:plc:alice/app.bsky.feed.post", @@ -224,4 +237,45 @@ describe("ExplorerPanel", () => { expect(await screen.findByText("3 records")).toBeInTheDocument(); expect(screen.getByText("4 records")).toBeInTheDocument(); }); + + it("renders lexicon favicons in repo and collection views when available", async () => { + resolveInputMock.mockResolvedValueOnce({ + input: "@alice.test", + inputKind: "handle", + targetKind: "repo", + normalizedInput: "did:plc:alice", + uri: "at://did:plc:alice", + did: "did:plc:alice", + handle: "alice.test", + pdsUrl: "https://pds.example.com", + collection: null, + rkey: null, + }).mockResolvedValueOnce({ + input: "at://did:plc:alice/app.bsky.feed.post", + inputKind: "atUri", + targetKind: "collection", + normalizedInput: "at://did:plc:alice/app.bsky.feed.post", + uri: "at://did:plc:alice/app.bsky.feed.post", + did: "did:plc:alice", + handle: "alice.test", + pdsUrl: "https://pds.example.com", + collection: "app.bsky.feed.post", + rkey: null, + }); + describeRepoMock.mockResolvedValue({ collections: ["app.bsky.feed.post"] }); + listRecordsMock.mockResolvedValue({ cursor: null, records: [] }); + getLexiconFaviconsMock.mockResolvedValue({ "app.bsky.feed.post": "data:image/png;base64,Zm9v" }); + + renderPanel(); + + const input = screen.getByPlaceholderText(/at:\/\/did:\.\.\. or @handle or https:\/\/pds/u); + fireEvent.input(input, { target: { value: "@alice.test" } }); + fireEvent.submit(input.closest("form")!); + + expect(await screen.findByAltText("app.bsky.feed.post favicon")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /app\.bsky\.feed\.post/u })); + + expect(await screen.findAllByAltText("app.bsky.feed.post favicon")).not.toHaveLength(0); + }); }); diff --git a/src/components/explorer/ExplorerPanel.tsx b/src/components/explorer/ExplorerPanel.tsx index acbad55..ec9af82 100644 --- a/src/components/explorer/ExplorerPanel.tsx +++ b/src/components/explorer/ExplorerPanel.tsx @@ -2,6 +2,7 @@ import { describeRepo, describeServer, exportRepoCar, + getLexiconFavicons, getRecord, listRecords, queryLabels, @@ -12,6 +13,7 @@ import type { ExplorerNavigation, ExplorerTargetKind } from "$/lib/api/types/exp import { NAVIGATION_EVENT } from "$/lib/constants/events"; import { consumeQueuedExplorerTarget } from "$/lib/explorer-navigation"; import { listen } from "@tauri-apps/api/event"; +import * as logger from "@tauri-apps/plugin-log"; import { createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; import { produce } from "solid-js/store"; import { Motion, Presence } from "solid-motionone"; @@ -66,6 +68,10 @@ function extractCollections(repoData: Record): Array<{ nsid: st return collections.toSorted((left, right) => left.nsid.localeCompare(right.nsid)); } +function hasCachedLexiconIcon(icons: Record, collection: string) { + return Object.prototype.hasOwnProperty.call(icons, collection); +} + export function ExplorerPanel() { const explorer = createExplorerState(); const [statusMessage, setStatusMessage] = createSignal<{ kind: "error" | "success"; text: string } | null>(null); @@ -94,6 +100,25 @@ export function ExplorerPanel() { })); } + async function hydrateLexiconIcons(collections: string[]) { + const pendingCollections = [...new Set(collections)].filter((collection) => collection.trim().length > 0).filter(( + collection, + ) => !hasCachedLexiconIcon(explorer.state.lexiconIcons, collection)); + + if (pendingCollections.length === 0) { + return; + } + + try { + const icons = await getLexiconFavicons(pendingCollections); + explorer.mergeLexiconIcons(icons); + } catch (error) { + logger.warn("Failed to load lexicon favicons for explorer", { + keyValues: { collections: pendingCollections.join(","), error: String(error) }, + }); + } + } + async function handleResolveInput(input: string) { if (!input.trim()) return; const submittedInput = input.trim(); @@ -194,6 +219,12 @@ export function ExplorerPanel() { if (requestId !== resolveRequestId) return; explorer.pushView(finalViewState); + + if (finalViewState.repoData) { + void hydrateLexiconIcons(finalViewState.repoData.collections.map((collection) => collection.nsid)); + } else if (finalViewState.collectionData) { + void hydrateLexiconIcons([finalViewState.collectionData.collection]); + } } catch (error) { if (requestId !== resolveRequestId) return; setCurrentView({ @@ -414,9 +445,9 @@ export function ExplorerPanel() {
- - - {(view) => ( + }> + {(view) => ( + - +
- {view.error} + {view().error}
- + - - + + - + handleCollectionClick(view.repoData!.did, collection)} - pdsUrl={view.repoData!.pdsUrl} - onPdsClick={() => view.repoData?.pdsUrl && void handleResolveInput(view.repoData.pdsUrl)} - socialSummary={view.repoData!.socialSummary} /> + collections={view().repoData!.collections} + did={view().repoData!.did} + handle={view().repoData!.handle} + lexiconIcons={explorer.state.lexiconIcons} + onCollectionClick={(collection: string) => + handleCollectionClick(view().repoData!.did, collection)} + pdsUrl={view().repoData!.pdsUrl} + onPdsClick={() => { + const pdsUrl = view().repoData?.pdsUrl; + if (pdsUrl) { + void handleResolveInput(pdsUrl); + } + }} + socialSummary={view().repoData!.socialSummary} /> - + - handleRecordClick(view.collectionData!.did, view.collectionData!.collection, rkey)} /> + handleRecordClick(view().collectionData!.did, view().collectionData!.collection, rkey)} /> - + + record={view().recordData!.record} + cid={view().recordData!.cid} + uri={view().recordData!.uri} + labels={view().recordData!.labels} /> - +
- )} -
-
+ + )} +
); } +function InitialEmptyPanel(props: { onExampleClick: (value: string) => void | Promise }) { + const examples = [{ label: "@handle", value: "@alice.bsky.social" }, { label: "did", value: "did:plc:alice" }, { + label: "at://", + value: "at://did:plc:alice/app.bsky.feed.post/123", + }, { label: "PDS URL", value: "https://pds.example.com" }]; + + return ( +
+
+
+

AT Protocol Explorer

+

+ Start from a handle, DID, URI, or PDS. +

+

+ Browse repositories, collections, records, and server metadata without leaving Lazurite. +

+
+ +
+ + {(example) => ( + + )} + +
+ +

+ Tip: start with @ to get handle suggestions in the explorer bar. +

+
+
+ ); +} + function EmptyPanel() { return (
diff --git a/src/components/explorer/ExplorerUrlBar.test.tsx b/src/components/explorer/ExplorerUrlBar.test.tsx new file mode 100644 index 0000000..084f8d4 --- /dev/null +++ b/src/components/explorer/ExplorerUrlBar.test.tsx @@ -0,0 +1,107 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ExplorerUrlBar } from "./ExplorerUrlBar"; + +const searchActorSuggestionsMock = vi.hoisted(() => vi.fn()); + +vi.mock("$/lib/api/actors", () => ({ searchActorSuggestions: searchActorSuggestionsMock })); + +describe("ExplorerUrlBar", () => { + beforeEach(() => { + searchActorSuggestionsMock.mockReset(); + }); + + it("opens typeahead for @ input and not for other input kinds", async () => { + const onInput = vi.fn(); + const onSubmit = vi.fn(); + searchActorSuggestionsMock.mockResolvedValue([{ did: "did:plc:alice", handle: "alice.test" }]); + + render(() => ( + {}} + onForward={() => {}} + onExport={() => {}} /> + )); + + const input = screen.getByRole("combobox"); + input.focus(); + fireEvent.focus(input); + + await waitFor(() => expect(searchActorSuggestionsMock).toHaveBeenCalledWith("ali")); + await waitFor(() => expect(input).toHaveAttribute("aria-expanded", "true")); + expect(await screen.findByRole("option", { name: /alice\.test/u })).toBeInTheDocument(); + + cleanup(); + render(() => ( + {}} + onForward={() => {}} + onExport={() => {}} /> + )); + + expect(screen.queryByRole("option", { name: /alice\.test/u })).not.toBeInTheDocument(); + }); + + it("submits the highlighted suggestion on enter and on click", async () => { + const onInput = vi.fn(); + const onSubmit = vi.fn(); + searchActorSuggestionsMock.mockResolvedValue([{ did: "did:plc:alice", handle: "alice.test" }]); + + render(() => ( + {}} + onForward={() => {}} + onExport={() => {}} /> + )); + + const input = screen.getByRole("combobox"); + input.focus(); + fireEvent.focus(input); + await waitFor(() => expect(input).toHaveAttribute("aria-expanded", "true")); + await screen.findByRole("option", { name: /alice\.test/u }); + + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onInput).toHaveBeenCalledWith("@alice.test"); + expect(onSubmit).toHaveBeenCalledWith("@alice.test"); + + cleanup(); + render(() => ( + {}} + onForward={() => {}} + onExport={() => {}} /> + )); + + const rerenderedInput = screen.getByRole("combobox"); + rerenderedInput.focus(); + fireEvent.focus(rerenderedInput); + fireEvent.click(await screen.findByRole("option", { name: /alice\.test/u })); + + expect(onSubmit).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/components/explorer/ExplorerUrlBar.tsx b/src/components/explorer/ExplorerUrlBar.tsx index 90a59f4..d4fc55c 100644 --- a/src/components/explorer/ExplorerUrlBar.tsx +++ b/src/components/explorer/ExplorerUrlBar.tsx @@ -1,4 +1,7 @@ +import { ActorSuggestionList, useActorSuggestions } from "$/components/actors/actor-search"; import { ArrowIcon, Icon } from "$/components/shared/Icon"; +import type { LoginSuggestion } from "$/lib/types"; +import { createEffect, createSignal, Show } from "solid-js"; type ExplorerUrlBarProps = { value: string; @@ -26,28 +29,113 @@ function NavButton(props: { direction: "left" | "right"; disabled: boolean; onCl } function UrlInputForm(props: { value: string; onInput: (value: string) => void; onSubmit: (value: string) => void }) { + let container: HTMLFormElement | undefined; + let input: HTMLInputElement | undefined; + const [focused, setFocused] = createSignal(false); + const typeahead = useActorSuggestions({ + container: () => container, + disabled: () => !props.value.trim().startsWith("@"), + input: () => input, + value: () => props.value, + }); + + createEffect(() => { + if (focused() && typeahead.suggestions().length > 0 && props.value.trim().startsWith("@")) { + typeahead.focus(); + } + }); + function handleSubmit(event: Event) { event.preventDefault(); props.onSubmit(props.value); } + function applySuggestion(suggestion: LoginSuggestion) { + const nextValue = suggestion.handle.startsWith("@") ? suggestion.handle : `@${suggestion.handle}`; + props.onInput(nextValue); + typeahead.close(); + props.onSubmit(nextValue); + input?.focus(); + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "ArrowDown") { + event.preventDefault(); + typeahead.moveActiveIndex(1); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + typeahead.moveActiveIndex(-1); + return; + } + + if (event.key === "Escape") { + typeahead.close(); + return; + } + + if (event.key === "Enter" && typeahead.open() && typeahead.activeSuggestion()) { + event.preventDefault(); + applySuggestion(typeahead.activeSuggestion() as LoginSuggestion); + } + } + return ( -
+ { + container = element; + }} + onSubmit={handleSubmit} + class="flex-1 relative">
{ + input = element; + }} data-explorer-input type="text" + role="combobox" + aria-autocomplete="list" + aria-controls="explorer-suggestions" + aria-activedescendant={typeahead.activeIndex() >= 0 + ? `explorer-suggestions-option-${typeahead.activeIndex()}` + : undefined} + aria-expanded={typeahead.open()} value={props.value} + spellcheck={false} onInput={(event) => props.onInput(event.currentTarget.value)} + onFocus={() => { + setFocused(true); + typeahead.focus(); + }} + onBlur={() => { + setFocused(false); + typeahead.close(); + }} + onKeyDown={(event) => handleKeyDown(event)} class="flex-1 bg-transparent text-sm font-mono outline-none text-on-surface placeholder:text-on-surface-variant/50" placeholder="at://did:... or @handle or https://pds..." /> + + + +
+ ); } diff --git a/src/components/explorer/LexiconIcon.tsx b/src/components/explorer/LexiconIcon.tsx new file mode 100644 index 0000000..aed8eb7 --- /dev/null +++ b/src/components/explorer/LexiconIcon.tsx @@ -0,0 +1,20 @@ +import { Icon } from "$/components/shared/Icon"; +import { Show } from "solid-js"; + +type LexiconIconProps = { class?: string; src: string | null; title: string }; + +export function LexiconIcon(props: LexiconIconProps) { + return ( +
+ }> + {(src) => ( + {`${props.title} + )} + +
+ ); +} diff --git a/src/components/explorer/explorer-state.ts b/src/components/explorer/explorer-state.ts index 1ee09ac..8c369a2 100644 --- a/src/components/explorer/explorer-state.ts +++ b/src/components/explorer/explorer-state.ts @@ -8,6 +8,7 @@ export function createExplorerState() { current: null, history: [], historyIndex: -1, + lexiconIcons: {}, }); function setInputValue(value: string) { @@ -68,6 +69,10 @@ export function createExplorerState() { return state.historyIndex < state.history.length - 1; } + function mergeLexiconIcons(icons: Record) { + setState("lexiconIcons", (current) => ({ ...current, ...icons })); + } + function getBreadcrumb(): Array<{ label: string; level: ExplorerTargetKind; active: boolean }> { const current = state.current; if (!current || !current.resolved) return []; @@ -105,7 +110,19 @@ export function createExplorerState() { return crumbs; } - return { state, setState, setInputValue, pushView, goBack, goForward, goUp, canGoBack, canGoForward, getBreadcrumb }; + return { + state, + setState, + setInputValue, + pushView, + goBack, + goForward, + goUp, + canGoBack, + canGoForward, + mergeLexiconIcons, + getBreadcrumb, + }; } export type ExplorerStore = ReturnType; diff --git a/src/components/explorer/types.ts b/src/components/explorer/types.ts index 4137cce..d2e11bb 100644 --- a/src/components/explorer/types.ts +++ b/src/components/explorer/types.ts @@ -49,6 +49,7 @@ export type ExplorerState = { current: ExplorerViewState | null; history: ExplorerViewState[]; historyIndex: number; + lexiconIcons: Record; }; export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; diff --git a/src/components/explorer/views/CollectionView.tsx b/src/components/explorer/views/CollectionView.tsx index 20251bf..60f008d 100644 --- a/src/components/explorer/views/CollectionView.tsx +++ b/src/components/explorer/views/CollectionView.tsx @@ -1,9 +1,11 @@ -import { ArrowIcon, Icon } from "$/components/shared/Icon"; +import { LexiconIcon } from "$/components/explorer/LexiconIcon"; +import { ArrowIcon } from "$/components/shared/Icon"; import { For, Show } from "solid-js"; interface CollectionViewProps { did: string; collection: string; + lexiconIcon: string | null; records: Array>; cursor: string | null; loadingMore: boolean; @@ -34,9 +36,7 @@ export function CollectionView(props: CollectionViewProps) {
-
- -
+

{collectionName()}

{props.collection}

diff --git a/src/components/explorer/views/RepoView.tsx b/src/components/explorer/views/RepoView.tsx index 32b76d3..ab6b386 100644 --- a/src/components/explorer/views/RepoView.tsx +++ b/src/components/explorer/views/RepoView.tsx @@ -1,3 +1,4 @@ +import { LexiconIcon } from "$/components/explorer/LexiconIcon"; import { ArrowIcon, Icon } from "$/components/shared/Icon"; import { For, Show } from "solid-js"; @@ -5,6 +6,7 @@ type RepoViewProps = { collections: Array<{ nsid: string }>; did: string; handle: string; + lexiconIcons: Record; onCollectionClick: (collection: string) => void; onPdsClick: () => void; pdsUrl: string | null; @@ -76,7 +78,10 @@ export function RepoView(props: RepoViewProps) { onClick={() => props.onCollectionClick(collection.nsid)} class="flex w-full items-center justify-between p-4 text-left transition-colors hover:bg-white/5">
- + {collection.nsid}
diff --git a/src/lib/api/explorer.ts b/src/lib/api/explorer.ts index b30d084..b450d83 100644 --- a/src/lib/api/explorer.ts +++ b/src/lib/api/explorer.ts @@ -28,3 +28,7 @@ export async function exportRepoCar(did: string): Promise { export async function queryLabels(uri: string): Promise> { return invoke("query_labels", { uri }); } + +export async function getLexiconFavicons(collections: string[]): Promise> { + return invoke("get_lexicon_favicons", { collections }); +} -- 2.51.2