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 lexicon favicon support & typehead to at explorer

+1003 -49
+1
src-tauri/Cargo.lock
··· 3848 3848 name = "lazurite-desktop" 3849 3849 version = "0.1.0" 3850 3850 dependencies = [ 3851 + "base64 0.22.1", 3851 3852 "fastembed", 3852 3853 "hf-hub 0.5.0", 3853 3854 "jacquard",
+1
src-tauri/Cargo.toml
··· 25 25 serde = { version = "1", features = ["derive"] } 26 26 serde_json = "1" 27 27 reqwest = { version = "0.12.28", features = ["json"] } 28 + base64 = "0.22.1" 28 29 rusqlite = { version = "0.37.0", features = ["bundled"] } 29 30 jacquard = "0.11.0" 30 31 sqlite-vec = "0.1.7"
+8
src-tauri/src/commands/explorer.rs
··· 1 1 use crate::error::AppError; 2 2 use crate::explorer; 3 + use std::collections::HashMap; 3 4 use tauri::AppHandle; 4 5 5 6 #[tauri::command] ··· 38 39 pub async fn query_labels(uri: String) -> Result<serde_json::Value, AppError> { 39 40 explorer::query_labels(uri).await 40 41 } 42 + 43 + #[tauri::command] 44 + pub async fn get_lexicon_favicons( 45 + collections: Vec<String>, app: AppHandle, 46 + ) -> Result<HashMap<String, Option<String>>, AppError> { 47 + explorer::get_lexicon_favicons(collections, &app).await 48 + }
+566 -3
src-tauri/src/explorer.rs
··· 1 1 use crate::error::{AppError, Result}; 2 + use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; 2 3 use jacquard::api::com_atproto::identity::resolve_handle::ResolveHandle; 3 4 use jacquard::api::com_atproto::label::query_labels::QueryLabels; 4 5 use jacquard::api::com_atproto::repo::describe_repo::DescribeRepo; ··· 21 22 use jacquard::IntoStatic; 22 23 use serde::{Deserialize, Serialize}; 23 24 use serde_json::Value; 25 + use std::collections::HashMap; 24 26 use std::path::PathBuf; 27 + use std::time::Duration; 25 28 use tauri::{AppHandle, Emitter, Manager}; 29 + use tauri_plugin_log::log; 26 30 27 31 pub const EXPLORER_NAVIGATION_EVENT: &str = "navigation:explorer-resolved"; 28 32 const PDS_REPO_LIST_LIMIT: i64 = 100; 29 33 const QUERY_LABELS_LIMIT: i64 = 100; 34 + const FAVICON_FETCH_TIMEOUT: Duration = Duration::from_secs(2); 35 + const LEXICON_FAVICON_HOST_OVERRIDES: &[(&str, &str)] = &[("sh.tangled.", "tangled.org")]; 30 36 31 37 type ExplorerClient = Agent<UnauthenticatedSession<JacquardResolver>>; 32 38 ··· 246 252 serde_json::to_value(output).map_err(AppError::from) 247 253 } 248 254 255 + pub async fn get_lexicon_favicons( 256 + collections: Vec<String>, app: &AppHandle, 257 + ) -> Result<HashMap<String, Option<String>>> { 258 + let client = match reqwest::Client::builder().timeout(FAVICON_FETCH_TIMEOUT).build() { 259 + Ok(client) => client, 260 + Err(error) => { 261 + log::warn!("failed to construct favicon client: {error}"); 262 + return Ok(collections.into_iter().map(|collection| (collection, None)).collect()); 263 + } 264 + }; 265 + let cache_dir = match resolve_favicon_cache_dir(app) { 266 + Ok(cache_dir) => Some(cache_dir), 267 + Err(error) => { 268 + log::warn!("failed to resolve explorer favicon cache directory: {error}"); 269 + None 270 + } 271 + }; 272 + 273 + let mut icons = HashMap::with_capacity(collections.len()); 274 + 275 + for collection in collections { 276 + let icon = resolve_lexicon_favicon_data_url(&client, cache_dir.as_deref(), &collection).await; 277 + icons.insert(collection, icon); 278 + } 279 + 280 + Ok(icons) 281 + } 282 + 249 283 pub async fn emit_explorer_navigation(app: &AppHandle, raw: &str) -> Result<()> { 250 284 let target = resolve_input(raw.to_string()).await?; 251 285 app.emit(EXPLORER_NAVIGATION_EVENT, ExplorerNavigation { target })?; ··· 522 556 Ok(app_data_dir) 523 557 } 524 558 559 + fn resolve_favicon_cache_dir(app: &AppHandle) -> Result<PathBuf> { 560 + let mut cache_dir = app 561 + .path() 562 + .app_cache_dir() 563 + .map_err(|error| AppError::PathResolve(error.to_string()))?; 564 + cache_dir.push("explorer"); 565 + cache_dir.push("favicons"); 566 + Ok(cache_dir) 567 + } 568 + 569 + async fn resolve_lexicon_favicon_data_url( 570 + client: &reqwest::Client, cache_dir: Option<&std::path::Path>, collection: &str, 571 + ) -> Option<String> { 572 + let hosts = lexicon_favicon_hosts(collection) 573 + .map_err(|error| { 574 + log::warn!("failed to derive favicon hosts for {collection}: {error}"); 575 + error 576 + }) 577 + .ok()?; 578 + 579 + for host in hosts { 580 + if let Some(cache_dir) = cache_dir { 581 + if let Some(cached) = read_cached_favicon_data_url(cache_dir, &host) { 582 + return Some(cached); 583 + } 584 + } 585 + 586 + if let Some(icon) = fetch_host_favicon(client, &host).await { 587 + if let Some(cache_dir) = cache_dir { 588 + write_cached_favicon(cache_dir, &host, &icon); 589 + } 590 + return Some(icon.data_url); 591 + } 592 + } 593 + 594 + None 595 + } 596 + 597 + async fn fetch_host_favicon(client: &reqwest::Client, host: &str) -> Option<CachedFavicon> { 598 + let favicon_url = format!("https://{host}/favicon.ico"); 599 + if let Some(icon) = fetch_favicon_from_url(client, &favicon_url).await { 600 + return Some(icon); 601 + } 602 + 603 + let root_url = format!("https://{host}/"); 604 + let html = match fetch_html_document(client, &root_url).await { 605 + Some(html) => html, 606 + None => return None, 607 + }; 608 + let base_url = match reqwest::Url::parse(&root_url) { 609 + Ok(url) => url, 610 + Err(error) => { 611 + log::warn!("failed to parse root favicon fallback URL {root_url}: {error}"); 612 + return None; 613 + } 614 + }; 615 + 616 + for candidate_url in extract_favicon_urls(&html, &base_url) { 617 + if let Some(icon) = fetch_favicon_from_url(client, candidate_url.as_str()).await { 618 + return Some(icon); 619 + } 620 + } 621 + 622 + None 623 + } 624 + 625 + async fn fetch_favicon_from_url(client: &reqwest::Client, favicon_url: &str) -> Option<CachedFavicon> { 626 + let response = match client.get(favicon_url).send().await { 627 + Ok(response) => response, 628 + Err(error) => { 629 + log::warn!("failed to fetch favicon from {favicon_url}: {error}"); 630 + return None; 631 + } 632 + }; 633 + 634 + if !response.status().is_success() { 635 + log::warn!("favicon request to {favicon_url} returned {}", response.status()); 636 + return None; 637 + } 638 + 639 + let content_type = response 640 + .headers() 641 + .get(reqwest::header::CONTENT_TYPE) 642 + .and_then(|value| value.to_str().ok()) 643 + .map(str::to_owned); 644 + let bytes = match response.bytes().await { 645 + Ok(bytes) => bytes.to_vec(), 646 + Err(error) => { 647 + log::warn!("failed to read favicon bytes from {favicon_url}: {error}"); 648 + return None; 649 + } 650 + }; 651 + let mime = match detect_favicon_mime(content_type.as_deref(), &bytes) { 652 + Some(mime) => mime, 653 + None => { 654 + log::warn!("favicon response from {favicon_url} was not a recognized image"); 655 + return None; 656 + } 657 + }; 658 + 659 + Some(CachedFavicon { 660 + bytes: bytes.clone(), 661 + mime: mime.clone(), 662 + data_url: format!("data:{mime};base64,{}", BASE64_STANDARD.encode(&bytes)), 663 + }) 664 + } 665 + 666 + async fn fetch_html_document(client: &reqwest::Client, root_url: &str) -> Option<String> { 667 + let response = match client.get(root_url).send().await { 668 + Ok(response) => response, 669 + Err(error) => { 670 + log::warn!("failed to fetch HTML fallback document from {root_url}: {error}"); 671 + return None; 672 + } 673 + }; 674 + 675 + if !response.status().is_success() { 676 + log::warn!("HTML fallback request to {root_url} returned {}", response.status()); 677 + return None; 678 + } 679 + 680 + match response.text().await { 681 + Ok(html) => Some(html), 682 + Err(error) => { 683 + log::warn!("failed to read HTML fallback document from {root_url}: {error}"); 684 + None 685 + } 686 + } 687 + } 688 + 689 + fn extract_favicon_urls(html: &str, base_url: &reqwest::Url) -> Vec<reqwest::Url> { 690 + let resolved_base_url = resolve_html_base_url(html, base_url); 691 + let lowercase = html.to_ascii_lowercase(); 692 + let mut cursor = 0; 693 + let mut urls = Vec::new(); 694 + 695 + while let Some(relative_start) = lowercase[cursor..].find("<link") { 696 + let start = cursor + relative_start; 697 + let Some(relative_end) = lowercase[start..].find('>') else { 698 + break; 699 + }; 700 + let end = start + relative_end + 1; 701 + let tag = &html[start..end]; 702 + 703 + let rel = extract_html_attribute(tag, "rel"); 704 + let href = extract_html_attribute(tag, "href"); 705 + 706 + if let (Some(rel), Some(href)) = (rel, href) { 707 + if !rel_indicates_favicon(&rel) { 708 + cursor = end; 709 + continue; 710 + } 711 + 712 + if let Ok(url) = resolved_base_url.join(&href) { 713 + if matches!(url.scheme(), "http" | "https") && !urls.iter().any(|existing| existing == &url) { 714 + urls.push(url); 715 + } 716 + } 717 + } 718 + 719 + cursor = end; 720 + } 721 + 722 + urls 723 + } 724 + 725 + fn extract_html_attribute(tag: &str, attribute: &str) -> Option<String> { 726 + let lowercase = tag.to_ascii_lowercase(); 727 + let lowercase_bytes = lowercase.as_bytes(); 728 + let bytes = tag.as_bytes(); 729 + let attribute_bytes = attribute.as_bytes(); 730 + let mut cursor = 0; 731 + 732 + while cursor + attribute_bytes.len() <= lowercase_bytes.len() { 733 + let start = lowercase[cursor..].find(attribute)? + cursor; 734 + let before = start 735 + .checked_sub(1) 736 + .and_then(|index| lowercase_bytes.get(index)) 737 + .copied(); 738 + let after = lowercase_bytes.get(start + attribute_bytes.len()).copied(); 739 + 740 + let invalid_before = before 741 + .is_some_and(|character| character.is_ascii_alphanumeric() || matches!(character, b'-' | b'_' | b':')); 742 + let invalid_after = 743 + after.is_some_and(|character| character.is_ascii_alphanumeric() || matches!(character, b'-' | b'_' | b':')); 744 + 745 + if invalid_before || invalid_after { 746 + cursor = start + attribute_bytes.len(); 747 + continue; 748 + } 749 + 750 + let mut value_start = start + attribute_bytes.len(); 751 + while bytes.get(value_start).is_some_and(u8::is_ascii_whitespace) { 752 + value_start += 1; 753 + } 754 + 755 + if bytes.get(value_start) != Some(&b'=') { 756 + cursor = start + attribute_bytes.len(); 757 + continue; 758 + } 759 + 760 + value_start += 1; 761 + while bytes.get(value_start).is_some_and(u8::is_ascii_whitespace) { 762 + value_start += 1; 763 + } 764 + 765 + let quote = *bytes.get(value_start)?; 766 + if quote == b'"' || quote == b'\'' { 767 + let value_end = tag[value_start + 1..].find(char::from(quote))?; 768 + return Some(tag[value_start + 1..value_start + 1 + value_end].trim().to_string()); 769 + } 770 + 771 + let value_end = tag[value_start..] 772 + .find(|character: char| character.is_whitespace() || character == '>') 773 + .unwrap_or(tag.len() - value_start); 774 + return Some(tag[value_start..value_start + value_end].trim().to_string()); 775 + } 776 + 777 + None 778 + } 779 + 780 + fn resolve_html_base_url(html: &str, request_url: &reqwest::Url) -> reqwest::Url { 781 + let lowercase = html.to_ascii_lowercase(); 782 + let mut cursor = 0; 783 + 784 + while let Some(relative_start) = lowercase[cursor..].find("<base") { 785 + let start = cursor + relative_start; 786 + let Some(relative_end) = lowercase[start..].find('>') else { 787 + break; 788 + }; 789 + let end = start + relative_end + 1; 790 + let tag = &html[start..end]; 791 + 792 + if let Some(href) = extract_html_attribute(tag, "href") { 793 + if let Ok(base_url) = request_url.join(&href) { 794 + if matches!(base_url.scheme(), "http" | "https") { 795 + return base_url; 796 + } 797 + } 798 + } 799 + 800 + cursor = end; 801 + } 802 + 803 + request_url.clone() 804 + } 805 + 806 + fn rel_indicates_favicon(rel: &str) -> bool { 807 + rel.to_ascii_lowercase().contains("icon") 808 + } 809 + 810 + fn lexicon_favicon_hosts(collection: &str) -> Result<Vec<String>> { 811 + let domain_authority = parse_collection(collection)?.domain_authority().to_string(); 812 + let authority_labels: Vec<&str> = domain_authority.split('.').collect(); 813 + let mut hosts = Vec::new(); 814 + 815 + for (prefix, host) in LEXICON_FAVICON_HOST_OVERRIDES { 816 + if collection.starts_with(prefix) && !hosts.iter().any(|candidate| candidate == host) { 817 + hosts.push((*host).to_string()); 818 + } 819 + } 820 + 821 + if authority_labels.len() >= 2 { 822 + let canonical_host = format!("{}.{}", authority_labels[1], authority_labels[0]); 823 + if !hosts.iter().any(|candidate| candidate == &canonical_host) { 824 + hosts.push(canonical_host); 825 + } 826 + } 827 + 828 + Ok(hosts) 829 + } 830 + 831 + fn read_cached_favicon_data_url(cache_dir: &std::path::Path, host: &str) -> Option<String> { 832 + let (bytes_path, mime_path) = favicon_cache_paths(cache_dir, host); 833 + let mime = match std::fs::read_to_string(&mime_path) { 834 + Ok(mime) => mime.trim().to_string(), 835 + Err(error) => { 836 + if mime_path.exists() { 837 + log::warn!("failed to read cached favicon mime for {host}: {error}"); 838 + } 839 + return None; 840 + } 841 + }; 842 + let bytes = match std::fs::read(&bytes_path) { 843 + Ok(bytes) => bytes, 844 + Err(error) => { 845 + if bytes_path.exists() { 846 + log::warn!("failed to read cached favicon bytes for {host}: {error}"); 847 + } 848 + return None; 849 + } 850 + }; 851 + 852 + Some(format!("data:{mime};base64,{}", BASE64_STANDARD.encode(bytes))) 853 + } 854 + 855 + fn write_cached_favicon(cache_dir: &std::path::Path, host: &str, icon: &CachedFavicon) { 856 + if let Err(error) = std::fs::create_dir_all(cache_dir) { 857 + log::warn!( 858 + "failed to create favicon cache directory {}: {error}", 859 + cache_dir.display() 860 + ); 861 + return; 862 + } 863 + 864 + let (bytes_path, mime_path) = favicon_cache_paths(cache_dir, host); 865 + 866 + if let Err(error) = std::fs::write(&bytes_path, &icon.bytes) { 867 + log::warn!("failed to write cached favicon bytes for {host}: {error}"); 868 + return; 869 + } 870 + 871 + if let Err(error) = std::fs::write(&mime_path, &icon.mime) { 872 + log::warn!("failed to write cached favicon mime for {host}: {error}"); 873 + } 874 + } 875 + 876 + fn favicon_cache_paths(cache_dir: &std::path::Path, host: &str) -> (PathBuf, PathBuf) { 877 + let safe_host = sanitize_host_for_filename(host); 878 + ( 879 + cache_dir.join(format!("{safe_host}.bin")), 880 + cache_dir.join(format!("{safe_host}.mime")), 881 + ) 882 + } 883 + 884 + fn sanitize_host_for_filename(host: &str) -> String { 885 + host.chars() 886 + .map(|character| match character { 887 + 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' => character, 888 + _ => '_', 889 + }) 890 + .collect() 891 + } 892 + 893 + fn detect_favicon_mime(content_type: Option<&str>, bytes: &[u8]) -> Option<String> { 894 + if let Some(content_type) = content_type { 895 + let mime = content_type.split(';').next()?.trim().to_ascii_lowercase(); 896 + if mime.starts_with("image/") { 897 + return Some(mime); 898 + } 899 + } 900 + 901 + if bytes.starts_with(&[0x89, b'P', b'N', b'G']) { 902 + return Some("image/png".to_string()); 903 + } 904 + 905 + if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { 906 + return Some("image/jpeg".to_string()); 907 + } 908 + 909 + if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { 910 + return Some("image/gif".to_string()); 911 + } 912 + 913 + if bytes.starts_with(&[0x00, 0x00, 0x01, 0x00]) { 914 + return Some("image/x-icon".to_string()); 915 + } 916 + 917 + if String::from_utf8_lossy(bytes).contains("<svg") { 918 + return Some("image/svg+xml".to_string()); 919 + } 920 + 921 + None 922 + } 923 + 924 + #[derive(Debug, Clone)] 925 + struct CachedFavicon { 926 + bytes: Vec<u8>, 927 + mime: String, 928 + data_url: String, 929 + } 930 + 525 931 fn repo_car_filename(did: &str) -> String { 526 932 format!("{}.car", sanitize_did_for_filename(did)) 527 933 } ··· 538 944 #[cfg(test)] 539 945 mod tests { 540 946 use super::{ 541 - build_resolved_at_uri, canonical_at_uri, detect_input_kind, normalize_handle, normalize_pds_url, 542 - repo_car_filename, repo_metadata_from_did_doc, sanitize_did_for_filename, ExplorerInputKind, 543 - ExplorerTargetKind, 947 + build_resolved_at_uri, canonical_at_uri, detect_favicon_mime, detect_input_kind, extract_favicon_urls, 948 + extract_html_attribute, lexicon_favicon_hosts, normalize_handle, normalize_pds_url, 949 + read_cached_favicon_data_url, rel_indicates_favicon, repo_car_filename, repo_metadata_from_did_doc, 950 + resolve_html_base_url, resolve_lexicon_favicon_data_url, sanitize_did_for_filename, write_cached_favicon, 951 + CachedFavicon, ExplorerInputKind, ExplorerTargetKind, 544 952 }; 545 953 use jacquard::types::aturi::AtUri; 546 954 use jacquard::types::did_doc::DidDocument; 955 + use reqwest::Client; 956 + use std::fs; 957 + use std::time::Duration; 958 + use uuid::Uuid; 547 959 548 960 #[test] 549 961 fn detects_all_supported_input_kinds() { ··· 624 1036 } 625 1037 626 1038 #[test] 1039 + fn derives_candidate_hosts_from_lexicon_nsids() { 1040 + assert_eq!( 1041 + lexicon_favicon_hosts("app.bsky.feed.post").expect("nsid should parse"), 1042 + vec!["bsky.app".to_string()] 1043 + ); 1044 + assert_eq!( 1045 + lexicon_favicon_hosts("sh.tangled.repo.issue").expect("override nsid should parse"), 1046 + vec!["tangled.org".to_string(), "tangled.sh".to_string()] 1047 + ); 1048 + assert!(lexicon_favicon_hosts("not-a-valid-nsid").is_err()); 1049 + } 1050 + 1051 + #[test] 1052 + fn detects_supported_favicon_mime_types() { 1053 + assert_eq!( 1054 + detect_favicon_mime(Some("image/vnd.microsoft.icon"), &[0x00, 0x00, 0x01, 0x00]), 1055 + Some("image/vnd.microsoft.icon".to_string()) 1056 + ); 1057 + assert_eq!( 1058 + detect_favicon_mime(None, &[0x89, b'P', b'N', b'G', 0x0D, 0x0A]), 1059 + Some("image/png".to_string()) 1060 + ); 1061 + assert!(detect_favicon_mime(Some("text/html"), b"<html></html>").is_none()); 1062 + } 1063 + 1064 + #[test] 1065 + fn extracts_favicon_urls_from_html_link_elements() { 1066 + let base_url = reqwest::Url::parse("https://bsky.app/").expect("base URL should parse"); 1067 + let urls = extract_favicon_urls( 1068 + r#" 1069 + <html> 1070 + <head> 1071 + <link rel="stylesheet" href="/styles.css"> 1072 + <link rel="icon" href="/favicon-32.png"> 1073 + <link rel="shortcut icon" href="https://cdn.example.com/favicon.ico"> 1074 + <link rel="apple-touch-icon" href="/apple-touch.png"> 1075 + </head> 1076 + </html> 1077 + "#, 1078 + &base_url, 1079 + ); 1080 + 1081 + assert_eq!( 1082 + urls, 1083 + vec![ 1084 + reqwest::Url::parse("https://bsky.app/favicon-32.png").expect("relative favicon URL should resolve"), 1085 + reqwest::Url::parse("https://cdn.example.com/favicon.ico").expect("absolute favicon URL should parse"), 1086 + reqwest::Url::parse("https://bsky.app/apple-touch.png") 1087 + .expect("apple touch favicon URL should resolve"), 1088 + ] 1089 + ); 1090 + } 1091 + 1092 + #[test] 1093 + fn resolves_relative_favicon_urls_like_tangled() { 1094 + let base_url = reqwest::Url::parse("https://tangled.org/").expect("base URL should parse"); 1095 + let urls = extract_favicon_urls( 1096 + r#"<link rel="icon" href="/static/logos/dolly.svg" sizes="any" type="image/svg+xml">"#, 1097 + &base_url, 1098 + ); 1099 + 1100 + assert_eq!( 1101 + urls, 1102 + vec![reqwest::Url::parse("https://tangled.org/static/logos/dolly.svg") 1103 + .expect("tangled favicon URL should resolve")] 1104 + ); 1105 + } 1106 + 1107 + #[test] 1108 + fn extracts_html_attributes_with_whitespace_and_quotes() { 1109 + let tag = r#"<link rel = "icon" href = '/static/logos/dolly.svg' type="image/svg+xml">"#; 1110 + 1111 + assert_eq!(extract_html_attribute(tag, "rel"), Some("icon".to_string())); 1112 + assert_eq!( 1113 + extract_html_attribute(tag, "href"), 1114 + Some("/static/logos/dolly.svg".to_string()) 1115 + ); 1116 + assert_eq!(extract_html_attribute(tag, "type"), Some("image/svg+xml".to_string())); 1117 + } 1118 + 1119 + #[test] 1120 + fn honors_html_base_href_when_resolving_favicon_urls() { 1121 + let request_url = reqwest::Url::parse("https://example.com/app/").expect("request URL should parse"); 1122 + let html = r#" 1123 + <head> 1124 + <base href="https://cdn.example.com/assets/"> 1125 + <link rel="icon" href="favicons/app.svg"> 1126 + </head> 1127 + "#; 1128 + 1129 + assert_eq!( 1130 + resolve_html_base_url(html, &request_url), 1131 + reqwest::Url::parse("https://cdn.example.com/assets/").expect("base href should resolve") 1132 + ); 1133 + assert_eq!( 1134 + extract_favicon_urls(html, &request_url), 1135 + vec![reqwest::Url::parse("https://cdn.example.com/assets/favicons/app.svg") 1136 + .expect("favicon URL should resolve against base href")] 1137 + ); 1138 + } 1139 + 1140 + #[test] 1141 + fn recognizes_common_favicon_rel_patterns() { 1142 + assert!(rel_indicates_favicon("icon")); 1143 + assert!(rel_indicates_favicon("shortcut icon")); 1144 + assert!(rel_indicates_favicon("apple-touch-icon")); 1145 + assert!(rel_indicates_favicon("mask-icon")); 1146 + assert!(!rel_indicates_favicon("stylesheet")); 1147 + } 1148 + 1149 + #[tokio::test] 1150 + async fn returns_cached_lexicon_favicon_without_fetching() { 1151 + let cache_dir = create_temp_cache_dir(); 1152 + write_cached_favicon( 1153 + &cache_dir, 1154 + "bsky.app", 1155 + &CachedFavicon { 1156 + bytes: vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A], 1157 + mime: "image/png".to_string(), 1158 + data_url: "data:image/png;base64,ignored".to_string(), 1159 + }, 1160 + ); 1161 + 1162 + let client = Client::builder() 1163 + .timeout(Duration::from_millis(200)) 1164 + .build() 1165 + .expect("client should build"); 1166 + let icon = resolve_lexicon_favicon_data_url(&client, Some(cache_dir.as_path()), "app.bsky.feed.post").await; 1167 + 1168 + assert_eq!(icon, read_cached_favicon_data_url(&cache_dir, "bsky.app"),); 1169 + 1170 + fs::remove_dir_all(cache_dir).expect("temporary cache directory should be removed"); 1171 + } 1172 + 1173 + #[tokio::test] 1174 + async fn failed_favicon_fetches_return_none() { 1175 + let client = Client::builder() 1176 + .timeout(Duration::from_millis(200)) 1177 + .build() 1178 + .expect("client should build"); 1179 + 1180 + assert!(super::fetch_host_favicon(&client, "127.0.0.1:9").await.is_none()); 1181 + } 1182 + 1183 + #[test] 627 1184 fn at_uri_parser_distinguishes_repo_collection_and_record_levels() { 628 1185 let repo_uri = AtUri::new("at://did:plc:alice").expect("repo uri should parse"); 629 1186 let collection_uri = AtUri::new("at://did:plc:alice/app.bsky.feed.post").expect("collection uri should parse"); ··· 671 1228 .target_kind, 672 1229 ExplorerTargetKind::Record 673 1230 ); 1231 + } 1232 + 1233 + fn create_temp_cache_dir() -> std::path::PathBuf { 1234 + let path = std::env::temp_dir().join(format!("lazurite-explorer-cache-{}", Uuid::new_v4())); 1235 + fs::create_dir_all(&path).expect("temporary cache directory should be created"); 1236 + path 674 1237 } 675 1238 }
+1
src-tauri/src/lib.rs
··· 114 114 cmd::explorer::get_record, 115 115 cmd::explorer::export_repo_car, 116 116 cmd::explorer::query_labels, 117 + cmd::explorer::get_lexicon_favicons, 117 118 cmd::search::search_posts_network, 118 119 cmd::search::search_posts, 119 120 cmd::search::search_actors,
+3 -2
src/App.css
··· 10 10 --color-surface: var(--surface); 11 11 --color-surface-container: var(--surface-container); 12 12 --color-surface-container-high: var(--surface-container-high); 13 + --color-surface-container-highest: var(--surface-container-highest); 13 14 --color-surface-bright: var(--surface-bright); 14 15 --color-primary: var(--primary); 15 16 --color-on-primary-fixed: var(--on-primary-fixed); ··· 26 27 --surface: #0e0e0e; 27 28 --surface-container: #191919; 28 29 --surface-container-high: #1f1f1f; 29 - --surface-container-highest: rgba(36, 36, 36, 0.7); 30 + --surface-container-highest: rgb(36, 36, 36); 30 31 --surface-bright: rgba(255, 255, 255, 0.05); 31 32 --primary: #7dafff; 32 33 --primary-dim: #0073de; ··· 45 46 --surface: #ffffff; 46 47 --surface-container: #ebeffb; 47 48 --surface-container-high: #e1e8fa; 48 - --surface-container-highest: rgba(234, 241, 255, 0.72); 49 + --surface-container-highest: rgb(234, 241, 255); 49 50 --surface-bright: rgba(24, 37, 66, 0.07); 50 51 --on-surface: #0f1523; 51 52 --on-surface-variant: #4e5e7e;
+1 -1
src/components/actors/actor-search.tsx
··· 145 145 <div 146 146 id={props.id} 147 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]"> 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 149 <p class="px-2 pb-2 text-[0.68rem] uppercase tracking-[0.12em] text-on-surface-variant">{props.title}</p> 150 150 <div class="grid gap-1.5"> 151 151 <For each={props.suggestions}>
+58 -4
src/components/explorer/ExplorerPanel.test.tsx
··· 5 5 const describeRepoMock = vi.hoisted(() => vi.fn()); 6 6 const describeServerMock = vi.hoisted(() => vi.fn()); 7 7 const exportRepoCarMock = vi.hoisted(() => vi.fn()); 8 + const getLexiconFaviconsMock = vi.hoisted(() => vi.fn()); 8 9 const getRecordMock = vi.hoisted(() => vi.fn()); 9 10 const getRecordBacklinksMock = vi.hoisted(() => vi.fn()); 10 11 const getProfileMock = vi.hoisted(() => vi.fn()); ··· 19 20 describeRepo: describeRepoMock, 20 21 describeServer: describeServerMock, 21 22 exportRepoCar: exportRepoCarMock, 23 + getLexiconFavicons: getLexiconFaviconsMock, 22 24 getRecord: getRecordMock, 23 25 listRecords: listRecordsMock, 24 26 queryLabels: queryLabelsMock, ··· 39 41 describeRepoMock.mockReset(); 40 42 describeServerMock.mockReset(); 41 43 exportRepoCarMock.mockReset(); 44 + getLexiconFaviconsMock.mockReset(); 42 45 getRecordMock.mockReset(); 43 46 getRecordBacklinksMock.mockReset(); 44 47 getProfileMock.mockReset(); ··· 48 51 listenMock.mockReset(); 49 52 50 53 exportRepoCarMock.mockResolvedValue({ did: "did:plc:alice", path: "/tmp/alice.car", bytesWritten: 64 }); 54 + getLexiconFaviconsMock.mockResolvedValue({}); 51 55 getProfileMock.mockResolvedValue({ 52 - did: "did:plc:alice", 53 - handle: "alice.test", 54 - followersCount: 28, 55 - followsCount: 14, 56 + status: "available", 57 + profile: { did: "did:plc:alice", handle: "alice.test", followersCount: 28, followsCount: 14 }, 56 58 }); 57 59 getRecordBacklinksMock.mockResolvedValue({ 58 60 likes: { cursor: null, records: [], total: 3 }, ··· 93 95 expect(screen.getByText("14")).toBeInTheDocument(); 94 96 expect(screen.queryByText("0 records")).not.toBeInTheDocument(); 95 97 expect(screen.queryByText("Count unavailable")).not.toBeInTheDocument(); 98 + }); 99 + 100 + it("renders the initial empty state and submits example chips", async () => { 101 + resolveInputMock.mockRejectedValueOnce(new Error("network unavailable")); 102 + 103 + renderPanel(); 104 + 105 + expect(screen.getByText("Start from a handle, DID, URI, or PDS.")).toBeInTheDocument(); 106 + fireEvent.click(screen.getByRole("button", { name: /@alice\.bsky\.social/u })); 107 + 108 + await waitFor(() => expect(resolveInputMock).toHaveBeenCalledWith("@alice.bsky.social")); 96 109 }); 97 110 98 111 it("loads additional collection pages", async () => { ··· 223 236 expect(await screen.findByText("Backlinks")).toBeInTheDocument(); 224 237 expect(await screen.findByText("3 records")).toBeInTheDocument(); 225 238 expect(screen.getByText("4 records")).toBeInTheDocument(); 239 + }); 240 + 241 + it("renders lexicon favicons in repo and collection views when available", async () => { 242 + resolveInputMock.mockResolvedValueOnce({ 243 + input: "@alice.test", 244 + inputKind: "handle", 245 + targetKind: "repo", 246 + normalizedInput: "did:plc:alice", 247 + uri: "at://did:plc:alice", 248 + did: "did:plc:alice", 249 + handle: "alice.test", 250 + pdsUrl: "https://pds.example.com", 251 + collection: null, 252 + rkey: null, 253 + }).mockResolvedValueOnce({ 254 + input: "at://did:plc:alice/app.bsky.feed.post", 255 + inputKind: "atUri", 256 + targetKind: "collection", 257 + normalizedInput: "at://did:plc:alice/app.bsky.feed.post", 258 + uri: "at://did:plc:alice/app.bsky.feed.post", 259 + did: "did:plc:alice", 260 + handle: "alice.test", 261 + pdsUrl: "https://pds.example.com", 262 + collection: "app.bsky.feed.post", 263 + rkey: null, 264 + }); 265 + describeRepoMock.mockResolvedValue({ collections: ["app.bsky.feed.post"] }); 266 + listRecordsMock.mockResolvedValue({ cursor: null, records: [] }); 267 + getLexiconFaviconsMock.mockResolvedValue({ "app.bsky.feed.post": "data:image/png;base64,Zm9v" }); 268 + 269 + renderPanel(); 270 + 271 + const input = screen.getByPlaceholderText(/at:\/\/did:\.\.\. or @handle or https:\/\/pds/u); 272 + fireEvent.input(input, { target: { value: "@alice.test" } }); 273 + fireEvent.submit(input.closest("form")!); 274 + 275 + expect(await screen.findByAltText("app.bsky.feed.post favicon")).toBeInTheDocument(); 276 + 277 + fireEvent.click(screen.getByRole("button", { name: /app\.bsky\.feed\.post/u })); 278 + 279 + expect(await screen.findAllByAltText("app.bsky.feed.post favicon")).not.toHaveLength(0); 226 280 }); 227 281 });
+115 -32
src/components/explorer/ExplorerPanel.tsx
··· 2 2 describeRepo, 3 3 describeServer, 4 4 exportRepoCar, 5 + getLexiconFavicons, 5 6 getRecord, 6 7 listRecords, 7 8 queryLabels, ··· 12 13 import { NAVIGATION_EVENT } from "$/lib/constants/events"; 13 14 import { consumeQueuedExplorerTarget } from "$/lib/explorer-navigation"; 14 15 import { listen } from "@tauri-apps/api/event"; 16 + import * as logger from "@tauri-apps/plugin-log"; 15 17 import { createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; 16 18 import { produce } from "solid-js/store"; 17 19 import { Motion, Presence } from "solid-motionone"; ··· 66 68 return collections.toSorted((left, right) => left.nsid.localeCompare(right.nsid)); 67 69 } 68 70 71 + function hasCachedLexiconIcon(icons: Record<string, string | null>, collection: string) { 72 + return Object.prototype.hasOwnProperty.call(icons, collection); 73 + } 74 + 69 75 export function ExplorerPanel() { 70 76 const explorer = createExplorerState(); 71 77 const [statusMessage, setStatusMessage] = createSignal<{ kind: "error" | "success"; text: string } | null>(null); ··· 94 100 })); 95 101 } 96 102 103 + async function hydrateLexiconIcons(collections: string[]) { 104 + const pendingCollections = [...new Set(collections)].filter((collection) => collection.trim().length > 0).filter(( 105 + collection, 106 + ) => !hasCachedLexiconIcon(explorer.state.lexiconIcons, collection)); 107 + 108 + if (pendingCollections.length === 0) { 109 + return; 110 + } 111 + 112 + try { 113 + const icons = await getLexiconFavicons(pendingCollections); 114 + explorer.mergeLexiconIcons(icons); 115 + } catch (error) { 116 + logger.warn("Failed to load lexicon favicons for explorer", { 117 + keyValues: { collections: pendingCollections.join(","), error: String(error) }, 118 + }); 119 + } 120 + } 121 + 97 122 async function handleResolveInput(input: string) { 98 123 if (!input.trim()) return; 99 124 const submittedInput = input.trim(); ··· 194 219 195 220 if (requestId !== resolveRequestId) return; 196 221 explorer.pushView(finalViewState); 222 + 223 + if (finalViewState.repoData) { 224 + void hydrateLexiconIcons(finalViewState.repoData.collections.map((collection) => collection.nsid)); 225 + } else if (finalViewState.collectionData) { 226 + void hydrateLexiconIcons([finalViewState.collectionData.collection]); 227 + } 197 228 } catch (error) { 198 229 if (requestId !== resolveRequestId) return; 199 230 setCurrentView({ ··· 414 445 </Show> 415 446 416 447 <div class="flex-1 overflow-hidden"> 417 - <Presence exitBeforeEnter> 418 - <Show when={currentView()} keyed> 419 - {(view) => ( 448 + <Show when={currentView()} fallback={<InitialEmptyPanel onExampleClick={handleResolveInput} />}> 449 + {(view) => ( 450 + <Presence exitBeforeEnter> 420 451 <Motion.div 421 452 initial={{ opacity: 0, y: 8 }} 422 453 animate={{ opacity: 1, y: 0 }} ··· 424 455 transition={{ duration: 0.2 }} 425 456 class="h-full overflow-auto p-6"> 426 457 <Switch> 427 - <Match when={view.error}> 458 + <Match when={view().error}> 428 459 <div class="rounded-3xl bg-[rgba(138,31,31,0.2)] p-4 text-sm text-error shadow-[inset_0_0_0_1px_rgba(255,128,128,0.2)]"> 429 - {view.error} 460 + {view().error} 430 461 </div> 431 462 </Match> 432 463 433 - <Match when={view.loading}> 464 + <Match when={view().loading}> 434 465 <ExplorerSkeleton /> 435 466 </Match> 436 467 437 - <Match when={view.level === "pds" && view.pdsData}> 438 - <PdsView server={view.pdsData!.server} repos={view.pdsData!.repos} onRepoClick={handleRepoClick} /> 468 + <Match when={view().level === "pds" && view().pdsData}> 469 + <PdsView 470 + server={view().pdsData!.server} 471 + repos={view().pdsData!.repos} 472 + onRepoClick={handleRepoClick} /> 439 473 </Match> 440 474 441 - <Match when={view.level === "repo" && view.repoData}> 475 + <Match when={view().level === "repo" && view().repoData}> 442 476 <RepoView 443 - collections={view.repoData!.collections} 444 - did={view.repoData!.did} 445 - handle={view.repoData!.handle} 446 - onCollectionClick={(collection: string) => handleCollectionClick(view.repoData!.did, collection)} 447 - pdsUrl={view.repoData!.pdsUrl} 448 - onPdsClick={() => view.repoData?.pdsUrl && void handleResolveInput(view.repoData.pdsUrl)} 449 - socialSummary={view.repoData!.socialSummary} /> 477 + collections={view().repoData!.collections} 478 + did={view().repoData!.did} 479 + handle={view().repoData!.handle} 480 + lexiconIcons={explorer.state.lexiconIcons} 481 + onCollectionClick={(collection: string) => 482 + handleCollectionClick(view().repoData!.did, collection)} 483 + pdsUrl={view().repoData!.pdsUrl} 484 + onPdsClick={() => { 485 + const pdsUrl = view().repoData?.pdsUrl; 486 + if (pdsUrl) { 487 + void handleResolveInput(pdsUrl); 488 + } 489 + }} 490 + socialSummary={view().repoData!.socialSummary} /> 450 491 </Match> 451 492 452 - <Match when={view.level === "collection" && view.collectionData}> 493 + <Match when={view().level === "collection" && view().collectionData}> 453 494 <CollectionView 454 - did={view.collectionData!.did} 455 - collection={view.collectionData!.collection} 456 - records={view.collectionData!.records} 457 - cursor={view.collectionData!.cursor} 458 - loadingMore={view.collectionData!.loadingMore} 495 + did={view().collectionData!.did} 496 + collection={view().collectionData!.collection} 497 + lexiconIcon={explorer.state.lexiconIcons[view().collectionData!.collection] ?? null} 498 + records={view().collectionData!.records} 499 + cursor={view().collectionData!.cursor} 500 + loadingMore={view().collectionData!.loadingMore} 459 501 onLoadMore={handleLoadMore} 460 502 onRecordClick={(rkey) => 461 - handleRecordClick(view.collectionData!.did, view.collectionData!.collection, rkey)} /> 503 + handleRecordClick(view().collectionData!.did, view().collectionData!.collection, rkey)} /> 462 504 </Match> 463 505 464 - <Match when={view.level === "record" && view.recordData}> 506 + <Match when={view().level === "record" && view().recordData}> 465 507 <RecordView 466 - record={view.recordData!.record} 467 - cid={view.recordData!.cid} 468 - uri={view.recordData!.uri} 469 - labels={view.recordData!.labels} /> 508 + record={view().recordData!.record} 509 + cid={view().recordData!.cid} 510 + uri={view().recordData!.uri} 511 + labels={view().recordData!.labels} /> 470 512 </Match> 471 513 472 - <Match when={!view.loading && !view.error}> 514 + <Match when={!view().loading && !view().error}> 473 515 <EmptyPanel /> 474 516 </Match> 475 517 </Switch> 476 518 </Motion.div> 519 + </Presence> 520 + )} 521 + </Show> 522 + </div> 523 + </div> 524 + ); 525 + } 526 + 527 + function InitialEmptyPanel(props: { onExampleClick: (value: string) => void | Promise<void> }) { 528 + const examples = [{ label: "@handle", value: "@alice.bsky.social" }, { label: "did", value: "did:plc:alice" }, { 529 + label: "at://", 530 + value: "at://did:plc:alice/app.bsky.feed.post/123", 531 + }, { label: "PDS URL", value: "https://pds.example.com" }]; 532 + 533 + return ( 534 + <div class="flex h-full items-start overflow-auto p-6"> 535 + <section class="mx-auto grid w-full max-w-4xl gap-6 rounded-[1.75rem] bg-white/3 p-8 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 536 + <div class="grid gap-2"> 537 + <p class="overline-copy text-xs text-primary/80">AT Protocol Explorer</p> 538 + <h1 class="m-0 text-[2rem] font-medium tracking-[-0.03em] text-on-surface"> 539 + Start from a handle, DID, URI, or PDS. 540 + </h1> 541 + <p class="m-0 max-w-2xl text-sm leading-6 text-on-surface-variant"> 542 + Browse repositories, collections, records, and server metadata without leaving Lazurite. 543 + </p> 544 + </div> 545 + 546 + <div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> 547 + <For each={examples}> 548 + {(example) => ( 549 + <button 550 + type="button" 551 + onClick={() => void props.onExampleClick(example.value)} 552 + class="rounded-2xl bg-white/4 px-4 py-4 text-left transition duration-150 ease-out hover:bg-white/7 hover:-translate-y-px"> 553 + <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{example.label}</p> 554 + <p class="mt-2 truncate text-sm font-mono text-primary">{example.value}</p> 555 + </button> 477 556 )} 478 - </Show> 479 - </Presence> 480 - </div> 557 + </For> 558 + </div> 559 + 560 + <p class="m-0 text-xs text-on-surface-variant"> 561 + Tip: start with <span class="font-mono text-primary">@</span> to get handle suggestions in the explorer bar. 562 + </p> 563 + </section> 481 564 </div> 482 565 ); 483 566 }
+107
src/components/explorer/ExplorerUrlBar.test.tsx
··· 1 + import { cleanup, fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 2 + import { beforeEach, describe, expect, it, vi } from "vitest"; 3 + import { ExplorerUrlBar } from "./ExplorerUrlBar"; 4 + 5 + const searchActorSuggestionsMock = vi.hoisted(() => vi.fn()); 6 + 7 + vi.mock("$/lib/api/actors", () => ({ searchActorSuggestions: searchActorSuggestionsMock })); 8 + 9 + describe("ExplorerUrlBar", () => { 10 + beforeEach(() => { 11 + searchActorSuggestionsMock.mockReset(); 12 + }); 13 + 14 + it("opens typeahead for @ input and not for other input kinds", async () => { 15 + const onInput = vi.fn(); 16 + const onSubmit = vi.fn(); 17 + searchActorSuggestionsMock.mockResolvedValue([{ did: "did:plc:alice", handle: "alice.test" }]); 18 + 19 + render(() => ( 20 + <ExplorerUrlBar 21 + value="@ali" 22 + canGoBack={false} 23 + canGoForward={false} 24 + canExport={false} 25 + onInput={onInput} 26 + onSubmit={onSubmit} 27 + onBack={() => {}} 28 + onForward={() => {}} 29 + onExport={() => {}} /> 30 + )); 31 + 32 + const input = screen.getByRole("combobox"); 33 + input.focus(); 34 + fireEvent.focus(input); 35 + 36 + await waitFor(() => expect(searchActorSuggestionsMock).toHaveBeenCalledWith("ali")); 37 + await waitFor(() => expect(input).toHaveAttribute("aria-expanded", "true")); 38 + expect(await screen.findByRole("option", { name: /alice\.test/u })).toBeInTheDocument(); 39 + 40 + cleanup(); 41 + render(() => ( 42 + <ExplorerUrlBar 43 + value="did:plc:alice" 44 + canGoBack={false} 45 + canGoForward={false} 46 + canExport={false} 47 + onInput={onInput} 48 + onSubmit={onSubmit} 49 + onBack={() => {}} 50 + onForward={() => {}} 51 + onExport={() => {}} /> 52 + )); 53 + 54 + expect(screen.queryByRole("option", { name: /alice\.test/u })).not.toBeInTheDocument(); 55 + }); 56 + 57 + it("submits the highlighted suggestion on enter and on click", async () => { 58 + const onInput = vi.fn(); 59 + const onSubmit = vi.fn(); 60 + searchActorSuggestionsMock.mockResolvedValue([{ did: "did:plc:alice", handle: "alice.test" }]); 61 + 62 + render(() => ( 63 + <ExplorerUrlBar 64 + value="@ali" 65 + canGoBack={false} 66 + canGoForward={false} 67 + canExport={false} 68 + onInput={onInput} 69 + onSubmit={onSubmit} 70 + onBack={() => {}} 71 + onForward={() => {}} 72 + onExport={() => {}} /> 73 + )); 74 + 75 + const input = screen.getByRole("combobox"); 76 + input.focus(); 77 + fireEvent.focus(input); 78 + await waitFor(() => expect(input).toHaveAttribute("aria-expanded", "true")); 79 + await screen.findByRole("option", { name: /alice\.test/u }); 80 + 81 + fireEvent.keyDown(input, { key: "Enter" }); 82 + 83 + expect(onInput).toHaveBeenCalledWith("@alice.test"); 84 + expect(onSubmit).toHaveBeenCalledWith("@alice.test"); 85 + 86 + cleanup(); 87 + render(() => ( 88 + <ExplorerUrlBar 89 + value="@ali" 90 + canGoBack={false} 91 + canGoForward={false} 92 + canExport={false} 93 + onInput={onInput} 94 + onSubmit={onSubmit} 95 + onBack={() => {}} 96 + onForward={() => {}} 97 + onExport={() => {}} /> 98 + )); 99 + 100 + const rerenderedInput = screen.getByRole("combobox"); 101 + rerenderedInput.focus(); 102 + fireEvent.focus(rerenderedInput); 103 + fireEvent.click(await screen.findByRole("option", { name: /alice\.test/u })); 104 + 105 + expect(onSubmit).toHaveBeenCalledTimes(2); 106 + }); 107 + });
+89 -1
src/components/explorer/ExplorerUrlBar.tsx
··· 1 + import { ActorSuggestionList, useActorSuggestions } from "$/components/actors/actor-search"; 1 2 import { ArrowIcon, Icon } from "$/components/shared/Icon"; 3 + import type { LoginSuggestion } from "$/lib/types"; 4 + import { createEffect, createSignal, Show } from "solid-js"; 2 5 3 6 type ExplorerUrlBarProps = { 4 7 value: string; ··· 26 29 } 27 30 28 31 function UrlInputForm(props: { value: string; onInput: (value: string) => void; onSubmit: (value: string) => void }) { 32 + let container: HTMLFormElement | undefined; 33 + let input: HTMLInputElement | undefined; 34 + const [focused, setFocused] = createSignal(false); 35 + const typeahead = useActorSuggestions({ 36 + container: () => container, 37 + disabled: () => !props.value.trim().startsWith("@"), 38 + input: () => input, 39 + value: () => props.value, 40 + }); 41 + 42 + createEffect(() => { 43 + if (focused() && typeahead.suggestions().length > 0 && props.value.trim().startsWith("@")) { 44 + typeahead.focus(); 45 + } 46 + }); 47 + 29 48 function handleSubmit(event: Event) { 30 49 event.preventDefault(); 31 50 props.onSubmit(props.value); 32 51 } 33 52 53 + function applySuggestion(suggestion: LoginSuggestion) { 54 + const nextValue = suggestion.handle.startsWith("@") ? suggestion.handle : `@${suggestion.handle}`; 55 + props.onInput(nextValue); 56 + typeahead.close(); 57 + props.onSubmit(nextValue); 58 + input?.focus(); 59 + } 60 + 61 + function handleKeyDown(event: KeyboardEvent) { 62 + if (event.key === "ArrowDown") { 63 + event.preventDefault(); 64 + typeahead.moveActiveIndex(1); 65 + return; 66 + } 67 + 68 + if (event.key === "ArrowUp") { 69 + event.preventDefault(); 70 + typeahead.moveActiveIndex(-1); 71 + return; 72 + } 73 + 74 + if (event.key === "Escape") { 75 + typeahead.close(); 76 + return; 77 + } 78 + 79 + if (event.key === "Enter" && typeahead.open() && typeahead.activeSuggestion()) { 80 + event.preventDefault(); 81 + applySuggestion(typeahead.activeSuggestion() as LoginSuggestion); 82 + } 83 + } 84 + 34 85 return ( 35 - <form onSubmit={handleSubmit} class="flex-1 relative"> 86 + <form 87 + ref={(element) => { 88 + container = element; 89 + }} 90 + onSubmit={handleSubmit} 91 + class="flex-1 relative"> 36 92 <div class="flex items-center gap-3 px-4 py-2 rounded-xl bg-black/40 shadow-[inset_0_0_0_1px_rgba(125,175,255,0.12)]"> 37 93 <Icon kind="explore" class="text-primary/80" /> 38 94 <input 95 + ref={(element) => { 96 + input = element; 97 + }} 39 98 data-explorer-input 40 99 type="text" 100 + role="combobox" 101 + aria-autocomplete="list" 102 + aria-controls="explorer-suggestions" 103 + aria-activedescendant={typeahead.activeIndex() >= 0 104 + ? `explorer-suggestions-option-${typeahead.activeIndex()}` 105 + : undefined} 106 + aria-expanded={typeahead.open()} 41 107 value={props.value} 108 + spellcheck={false} 42 109 onInput={(event) => props.onInput(event.currentTarget.value)} 110 + onFocus={() => { 111 + setFocused(true); 112 + typeahead.focus(); 113 + }} 114 + onBlur={() => { 115 + setFocused(false); 116 + typeahead.close(); 117 + }} 118 + onKeyDown={(event) => handleKeyDown(event)} 43 119 class="flex-1 bg-transparent text-sm font-mono outline-none text-on-surface placeholder:text-on-surface-variant/50" 44 120 placeholder="at://did:... or @handle or https://pds..." /> 121 + <Show when={typeahead.loading()}> 122 + <span class="flex items-center text-on-surface-variant"> 123 + <Icon kind="loader" aria-hidden="true" /> 124 + </span> 125 + </Show> 45 126 <button 46 127 type="submit" 47 128 class="p-1.5 rounded-lg text-on-surface-variant hover:text-on-surface hover:bg-white/5 transition-all"> 48 129 <Icon kind="search" /> 49 130 </button> 50 131 </div> 132 + <ActorSuggestionList 133 + activeIndex={typeahead.activeIndex()} 134 + id="explorer-suggestions" 135 + open={typeahead.open()} 136 + suggestions={typeahead.suggestions()} 137 + title="Suggested handles" 138 + onSelect={applySuggestion} /> 51 139 </form> 52 140 ); 53 141 }
+20
src/components/explorer/LexiconIcon.tsx
··· 1 + import { Icon } from "$/components/shared/Icon"; 2 + import { Show } from "solid-js"; 3 + 4 + type LexiconIconProps = { class?: string; src: string | null; title: string }; 5 + 6 + export function LexiconIcon(props: LexiconIconProps) { 7 + return ( 8 + <div class={`flex items-center justify-center rounded-xl bg-primary/15 ${props.class ?? ""}`.trim()}> 9 + <Show when={props.src} fallback={<Icon kind="folder" class="text-primary text-xl" />}> 10 + {(src) => ( 11 + <img 12 + class="h-full w-full rounded-xl object-cover p-2" 13 + src={src()} 14 + alt={`${props.title} favicon`} 15 + loading="lazy" /> 16 + )} 17 + </Show> 18 + </div> 19 + ); 20 + }
+18 -1
src/components/explorer/explorer-state.ts
··· 8 8 current: null, 9 9 history: [], 10 10 historyIndex: -1, 11 + lexiconIcons: {}, 11 12 }); 12 13 13 14 function setInputValue(value: string) { ··· 68 69 return state.historyIndex < state.history.length - 1; 69 70 } 70 71 72 + function mergeLexiconIcons(icons: Record<string, string | null>) { 73 + setState("lexiconIcons", (current) => ({ ...current, ...icons })); 74 + } 75 + 71 76 function getBreadcrumb(): Array<{ label: string; level: ExplorerTargetKind; active: boolean }> { 72 77 const current = state.current; 73 78 if (!current || !current.resolved) return []; ··· 105 110 return crumbs; 106 111 } 107 112 108 - return { state, setState, setInputValue, pushView, goBack, goForward, goUp, canGoBack, canGoForward, getBreadcrumb }; 113 + return { 114 + state, 115 + setState, 116 + setInputValue, 117 + pushView, 118 + goBack, 119 + goForward, 120 + goUp, 121 + canGoBack, 122 + canGoForward, 123 + mergeLexiconIcons, 124 + getBreadcrumb, 125 + }; 109 126 } 110 127 111 128 export type ExplorerStore = ReturnType<typeof createExplorerState>;
+1
src/components/explorer/types.ts
··· 49 49 current: ExplorerViewState | null; 50 50 history: ExplorerViewState[]; 51 51 historyIndex: number; 52 + lexiconIcons: Record<string, string | null>; 52 53 }; 53 54 54 55 export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
+4 -4
src/components/explorer/views/CollectionView.tsx
··· 1 - import { ArrowIcon, Icon } from "$/components/shared/Icon"; 1 + import { LexiconIcon } from "$/components/explorer/LexiconIcon"; 2 + import { ArrowIcon } from "$/components/shared/Icon"; 2 3 import { For, Show } from "solid-js"; 3 4 4 5 interface CollectionViewProps { 5 6 did: string; 6 7 collection: string; 8 + lexiconIcon: string | null; 7 9 records: Array<Record<string, unknown>>; 8 10 cursor: string | null; 9 11 loadingMore: boolean; ··· 34 36 <div class="grid gap-6"> 35 37 <section class="rounded-2xl border border-white/5 p-6"> 36 38 <div class="flex items-center gap-3 mb-4"> 37 - <div class="w-12 h-12 rounded-xl flex items-center justify-center bg-primary/15"> 38 - <Icon kind="folder" class="text-primary text-xl" /> 39 - </div> 39 + <LexiconIcon class="h-12 w-12" src={props.lexiconIcon} title={props.collection} /> 40 40 <div> 41 41 <h1 class="text-lg font-medium">{collectionName()}</h1> 42 42 <p class="text-xs font-mono text-on-surface-variant">{props.collection}</p>
+6 -1
src/components/explorer/views/RepoView.tsx
··· 1 + import { LexiconIcon } from "$/components/explorer/LexiconIcon"; 1 2 import { ArrowIcon, Icon } from "$/components/shared/Icon"; 2 3 import { For, Show } from "solid-js"; 3 4 ··· 5 6 collections: Array<{ nsid: string }>; 6 7 did: string; 7 8 handle: string; 9 + lexiconIcons: Record<string, string | null>; 8 10 onCollectionClick: (collection: string) => void; 9 11 onPdsClick: () => void; 10 12 pdsUrl: string | null; ··· 76 78 onClick={() => props.onCollectionClick(collection.nsid)} 77 79 class="flex w-full items-center justify-between p-4 text-left transition-colors hover:bg-white/5"> 78 80 <div class="flex items-center gap-3"> 79 - <Icon kind="folder" class="text-on-surface-variant" /> 81 + <LexiconIcon 82 + class="h-10 w-10" 83 + src={props.lexiconIcons[collection.nsid] ?? null} 84 + title={collection.nsid} /> 80 85 <span class="text-sm">{collection.nsid}</span> 81 86 </div> 82 87 <ArrowIcon direction="right" class="text-on-surface-variant" />
+4
src/lib/api/explorer.ts
··· 28 28 export async function queryLabels(uri: string): Promise<Record<string, unknown>> { 29 29 return invoke("query_labels", { uri }); 30 30 } 31 + 32 + export async function getLexiconFavicons(collections: string[]): Promise<Record<string, string | null>> { 33 + return invoke("get_lexicon_favicons", { collections }); 34 + }