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: handle profile edge cases in context view

+903 -266
+26
pnpm-lock.yaml
··· 330 330 resolution: {integrity: sha512-xmZ8oyKX2zb1ZbJZVXN4y6IlzsDEH46iPj0w+bzlS0v7ggAQrZhZ2KmZUmxtKFIGHZuKx81Lj+gNmw/zf4tpyA==} 331 331 cpu: [arm64] 332 332 os: [linux] 333 + libc: [glibc] 333 334 334 335 '@dprint/linux-arm64-musl@0.53.1': 335 336 resolution: {integrity: sha512-xIxUZxvgaWDQSpcVN9R1I6w+njgWuYVaTq+0Xt/oyMESaIpPsJTZP18qjQDDEXvXJ0cEbaoi3nuud1CvtJheXQ==} 336 337 cpu: [arm64] 337 338 os: [linux] 339 + libc: [musl] 338 340 339 341 '@dprint/linux-loong64-glibc@0.53.1': 340 342 resolution: {integrity: sha512-tTvoHRsiOV9Ttx3bc7/i5KR98izFEBVkT0rM/HyFUMuTz6mMZhqGY9dZAN3cDwaLHAnxuSprag2PVaa6v5eL/g==} 341 343 cpu: [loong64] 342 344 os: [linux] 345 + libc: [glibc] 343 346 344 347 '@dprint/linux-loong64-musl@0.53.1': 345 348 resolution: {integrity: sha512-TW7ZWZvShuyMMmFm/bgCV5jldDUwZZQTLFYMqugIAIcCGE3Bs6m02CgpWiUBueudPk9Gb0nz17Y4PXAv3Sg3GQ==} 346 349 cpu: [loong64] 347 350 os: [linux] 351 + libc: [musl] 348 352 349 353 '@dprint/linux-riscv64-glibc@0.53.1': 350 354 resolution: {integrity: sha512-Q8BDW+EWxzF4Es4KFOrJ8Y2bFL1nYcnwOX69kOo6bZgzQeRqr2WuEbFV70GClPy1xeBQLgKwAwYDmYjAxT75kQ==} 351 355 cpu: [riscv64] 352 356 os: [linux] 357 + libc: [glibc] 353 358 354 359 '@dprint/linux-x64-glibc@0.53.1': 355 360 resolution: {integrity: sha512-3nrrZjsBObS6OF50yvn0flXbbeMaP8lJrrpGSJ8+B6qKySZJ9ugDTCGMgXyD4y1MzuTj45zBwudJKywhRZ7pRw==} 356 361 cpu: [x64] 357 362 os: [linux] 363 + libc: [glibc] 358 364 359 365 '@dprint/linux-x64-musl@0.53.1': 360 366 resolution: {integrity: sha512-T2JZr/b8YZYyEnunZuXx7h09ngqTNx9sH2VaKwqRf4/CC7oR1RTAAyHaTFsL2/Ce+WTYb/LF/a1BkJEH2Q2LZw==} 361 367 cpu: [x64] 362 368 os: [linux] 369 + libc: [musl] 363 370 364 371 '@dprint/win32-arm64@0.53.1': 365 372 resolution: {integrity: sha512-FF2U+1Uke8/uQ0xwIJc75MtysAophXjFvhVbywGl+arjmChlpyb1KHB6l8eobjt/zXMyoNOOCm3mi8Zh9bQivg==} ··· 569 576 engines: {node: ^20.19.0 || >=22.12.0} 570 577 cpu: [arm64] 571 578 os: [linux] 579 + libc: [glibc] 572 580 573 581 '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': 574 582 resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} 575 583 engines: {node: ^20.19.0 || >=22.12.0} 576 584 cpu: [arm64] 577 585 os: [linux] 586 + libc: [musl] 578 587 579 588 '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': 580 589 resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} 581 590 engines: {node: ^20.19.0 || >=22.12.0} 582 591 cpu: [ppc64] 583 592 os: [linux] 593 + libc: [glibc] 584 594 585 595 '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': 586 596 resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} 587 597 engines: {node: ^20.19.0 || >=22.12.0} 588 598 cpu: [s390x] 589 599 os: [linux] 600 + libc: [glibc] 590 601 591 602 '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': 592 603 resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} 593 604 engines: {node: ^20.19.0 || >=22.12.0} 594 605 cpu: [x64] 595 606 os: [linux] 607 + libc: [glibc] 596 608 597 609 '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': 598 610 resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} 599 611 engines: {node: ^20.19.0 || >=22.12.0} 600 612 cpu: [x64] 601 613 os: [linux] 614 + libc: [musl] 602 615 603 616 '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': 604 617 resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} ··· 707 720 engines: {node: '>= 20'} 708 721 cpu: [arm64] 709 722 os: [linux] 723 + libc: [glibc] 710 724 711 725 '@tailwindcss/oxide-linux-arm64-musl@4.2.2': 712 726 resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} 713 727 engines: {node: '>= 20'} 714 728 cpu: [arm64] 715 729 os: [linux] 730 + libc: [musl] 716 731 717 732 '@tailwindcss/oxide-linux-x64-gnu@4.2.2': 718 733 resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} 719 734 engines: {node: '>= 20'} 720 735 cpu: [x64] 721 736 os: [linux] 737 + libc: [glibc] 722 738 723 739 '@tailwindcss/oxide-linux-x64-musl@4.2.2': 724 740 resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} 725 741 engines: {node: '>= 20'} 726 742 cpu: [x64] 727 743 os: [linux] 744 + libc: [musl] 728 745 729 746 '@tailwindcss/oxide-wasm32-wasi@4.2.2': 730 747 resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} ··· 785 802 engines: {node: '>= 10'} 786 803 cpu: [arm64] 787 804 os: [linux] 805 + libc: [glibc] 788 806 789 807 '@tauri-apps/cli-linux-arm64-musl@2.10.1': 790 808 resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==} 791 809 engines: {node: '>= 10'} 792 810 cpu: [arm64] 793 811 os: [linux] 812 + libc: [musl] 794 813 795 814 '@tauri-apps/cli-linux-riscv64-gnu@2.10.1': 796 815 resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==} 797 816 engines: {node: '>= 10'} 798 817 cpu: [riscv64] 799 818 os: [linux] 819 + libc: [glibc] 800 820 801 821 '@tauri-apps/cli-linux-x64-gnu@2.10.1': 802 822 resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==} 803 823 engines: {node: '>= 10'} 804 824 cpu: [x64] 805 825 os: [linux] 826 + libc: [glibc] 806 827 807 828 '@tauri-apps/cli-linux-x64-musl@2.10.1': 808 829 resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==} 809 830 engines: {node: '>= 10'} 810 831 cpu: [x64] 811 832 os: [linux] 833 + libc: [musl] 812 834 813 835 '@tauri-apps/cli-win32-arm64-msvc@2.10.1': 814 836 resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==} ··· 1836 1858 engines: {node: '>= 12.0.0'} 1837 1859 cpu: [arm64] 1838 1860 os: [linux] 1861 + libc: [glibc] 1839 1862 1840 1863 lightningcss-linux-arm64-musl@1.32.0: 1841 1864 resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} 1842 1865 engines: {node: '>= 12.0.0'} 1843 1866 cpu: [arm64] 1844 1867 os: [linux] 1868 + libc: [musl] 1845 1869 1846 1870 lightningcss-linux-x64-gnu@1.32.0: 1847 1871 resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} 1848 1872 engines: {node: '>= 12.0.0'} 1849 1873 cpu: [x64] 1850 1874 os: [linux] 1875 + libc: [glibc] 1851 1876 1852 1877 lightningcss-linux-x64-musl@1.32.0: 1853 1878 resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} 1854 1879 engines: {node: '>= 12.0.0'} 1855 1880 cpu: [x64] 1856 1881 os: [linux] 1882 + libc: [musl] 1857 1883 1858 1884 lightningcss-win32-arm64-msvc@1.32.0: 1859 1885 resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
+134
src-tauri/src/actors.rs
··· 1 + use jacquard::types::did::Did; 2 + use jacquard::types::handle::Handle; 3 + use serde::{Deserialize, Serialize}; 4 + 5 + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] 6 + #[serde(rename_all = "camelCase")] 7 + pub enum ActorAvailability { 8 + Available, 9 + Unavailable, 10 + } 11 + 12 + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] 13 + #[serde(rename_all = "camelCase")] 14 + pub enum ActorAvailabilityReason { 15 + NotFound, 16 + Suspended, 17 + Deactivated, 18 + Unavailable, 19 + } 20 + 21 + pub fn requested_actor_hints(actor: &str) -> (Option<String>, Option<String>) { 22 + let trimmed = actor.trim(); 23 + if trimmed.is_empty() { 24 + return (None, None); 25 + } 26 + 27 + if let Ok(did) = Did::new(trimmed) { 28 + return (Some(did.to_string()), None); 29 + } 30 + 31 + let normalized_handle = trimmed.trim_start_matches('@'); 32 + if let Ok(handle) = Handle::new(normalized_handle) { 33 + return (None, Some(handle.to_string())); 34 + } 35 + 36 + (None, None) 37 + } 38 + 39 + pub fn classify_actor_unavailability(error: &impl std::fmt::Display) -> Option<ActorAvailabilityReason> { 40 + let message = error.to_string().to_ascii_lowercase(); 41 + 42 + if mentions_not_found(&message) { 43 + return Some(ActorAvailabilityReason::NotFound); 44 + } 45 + 46 + if message.contains("suspended") || message.contains("taken down") || message.contains("takendown") { 47 + return Some(ActorAvailabilityReason::Suspended); 48 + } 49 + 50 + if message.contains("deactivated") || message.contains("deleted account") { 51 + return Some(ActorAvailabilityReason::Deactivated); 52 + } 53 + 54 + if message.contains("profile unavailable") 55 + || message.contains("account unavailable") 56 + || message.contains("repo unavailable") 57 + { 58 + return Some(ActorAvailabilityReason::Unavailable); 59 + } 60 + 61 + None 62 + } 63 + 64 + pub fn actor_unavailable_message(reason: ActorAvailabilityReason) -> &'static str { 65 + match reason { 66 + ActorAvailabilityReason::NotFound => "This profile could not be found.", 67 + ActorAvailabilityReason::Suspended => "This profile is unavailable because the account is suspended.", 68 + ActorAvailabilityReason::Deactivated => "This profile is unavailable because the account is deactivated.", 69 + ActorAvailabilityReason::Unavailable => "This profile is unavailable right now.", 70 + } 71 + } 72 + 73 + fn mentions_not_found(message: &str) -> bool { 74 + message.contains("actornotfound") 75 + || message.contains("profile not found") 76 + || message.contains("profile notfound") 77 + || message.contains("repo not found") 78 + || message.contains("repo notfound") 79 + || message.contains("account not found") 80 + || message.contains("could not resolve") 81 + || message.contains("not found") 82 + || message.contains("notfound") 83 + } 84 + 85 + #[cfg(test)] 86 + mod tests { 87 + use super::{ 88 + actor_unavailable_message, classify_actor_unavailability, requested_actor_hints, ActorAvailabilityReason, 89 + }; 90 + 91 + #[test] 92 + fn classifies_not_found_actor_errors() { 93 + assert_eq!( 94 + classify_actor_unavailability(&"ActorNotFound: profile not found"), 95 + Some(ActorAvailabilityReason::NotFound) 96 + ); 97 + assert_eq!( 98 + classify_actor_unavailability(&"repo not found"), 99 + Some(ActorAvailabilityReason::NotFound) 100 + ); 101 + } 102 + 103 + #[test] 104 + fn classifies_suspended_and_deactivated_actor_errors() { 105 + assert_eq!( 106 + classify_actor_unavailability(&"account is suspended"), 107 + Some(ActorAvailabilityReason::Suspended) 108 + ); 109 + assert_eq!( 110 + classify_actor_unavailability(&"account is deactivated"), 111 + Some(ActorAvailabilityReason::Deactivated) 112 + ); 113 + } 114 + 115 + #[test] 116 + fn builds_requested_actor_hints() { 117 + assert_eq!( 118 + requested_actor_hints("did:plc:xg2vq45muivyy3xwatcehspu"), 119 + (Some("did:plc:xg2vq45muivyy3xwatcehspu".to_string()), None) 120 + ); 121 + assert_eq!( 122 + requested_actor_hints("@desertthunder.dev"), 123 + (None, Some("desertthunder.dev".to_string())) 124 + ); 125 + } 126 + 127 + #[test] 128 + fn returns_human_messages() { 129 + assert_eq!( 130 + actor_unavailable_message(ActorAvailabilityReason::Unavailable), 131 + "This profile is unavailable right now." 132 + ); 133 + } 134 + }
+2 -46
src-tauri/src/constellation.rs
··· 1 1 use crate::error::{AppError, Result}; 2 - use reqwest::{Client, StatusCode, Url}; 2 + use reqwest::{Client, Url}; 3 3 use serde::de::DeserializeOwned; 4 4 use serde::{Deserialize, Serialize}; 5 5 use tauri_plugin_log::log; ··· 8 8 const USER_AGENT: &str = "lazurite-desktop"; 9 9 const GET_BACKLINKS_COUNT_NSID: &str = "blue.microcosm.links.getBacklinksCount"; 10 10 const GET_BACKLINKS_NSID: &str = "blue.microcosm.links.getBacklinks"; 11 - const GET_DISTINCT_NSID: &str = "blue.microcosm.links.getDistinct"; 12 - const GET_BACKLINK_DIDS_NSID: &str = "blue.microcosm.links.getBacklinkDids"; 13 11 const GET_MANY_TO_MANY_COUNTS_NSID: &str = "blue.microcosm.links.getManyToManyCounts"; 14 12 const GET_MANY_TO_MANY_NSID: &str = "blue.microcosm.links.getManyToMany"; 15 13 ··· 37 35 pub total: u64, 38 36 #[serde(default)] 39 37 pub records: Vec<ConstellationLinkRecord>, 40 - pub cursor: Option<String>, 41 - } 42 - 43 - #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] 44 - pub struct DistinctDidsResponse { 45 - pub total: u64, 46 - #[serde(default, alias = "linking_dids", alias = "linkingDids")] 47 - pub dids: Vec<String>, 48 38 pub cursor: Option<String>, 49 39 } 50 40 ··· 117 107 self.get_json(GET_BACKLINKS_NSID, &query).await 118 108 } 119 109 120 - pub async fn get_distinct_dids( 121 - &self, subject: String, source: String, limit: Option<u32>, cursor: Option<String>, 122 - ) -> Result<DistinctDidsResponse> { 123 - let mut query = vec![("subject", subject.clone()), ("source", source.clone())]; 124 - if let Some(limit) = limit { 125 - query.push(("limit", limit.to_string())); 126 - } 127 - if let Some(cursor) = cursor.clone() { 128 - query.push(("cursor", cursor)); 129 - } 130 - 131 - let response = self.send(GET_DISTINCT_NSID, &query).await?; 132 - if response.status() == StatusCode::NOT_FOUND { 133 - log::warn!( 134 - "Constellation {} returned 404; falling back to {}", 135 - GET_DISTINCT_NSID, 136 - GET_BACKLINK_DIDS_NSID 137 - ); 138 - return self.get_json(GET_BACKLINK_DIDS_NSID, &query).await; 139 - } 140 - 141 - Self::decode_json(response, GET_DISTINCT_NSID).await 142 - } 143 - 144 110 pub async fn get_many_to_many_counts( 145 111 &self, subject: String, source: String, path_to_other: String, 146 112 ) -> Result<ManyToManyCountsResponse> { ··· 202 168 203 169 #[cfg(test)] 204 170 mod tests { 205 - use super::{DistinctDidsResponse, ManyToManyCountsResponse}; 206 - 207 - #[test] 208 - fn distinct_response_accepts_backlink_dids_shape() { 209 - let parsed: DistinctDidsResponse = 210 - serde_json::from_str(r#"{"total":2,"linking_dids":["did:plc:one","did:plc:two"],"cursor":"abc"}"#) 211 - .expect("backlink dids response should deserialize"); 212 - 213 - assert_eq!(parsed.dids, vec!["did:plc:one", "did:plc:two"]); 214 - assert_eq!(parsed.cursor.as_deref(), Some("abc")); 215 - } 171 + use super::ManyToManyCountsResponse; 216 172 217 173 #[test] 218 174 fn many_to_many_counts_accepts_subject_field() {
+248 -10
src-tauri/src/diagnostics.rs
··· 1 + use crate::actors::{ 2 + actor_unavailable_message, classify_actor_unavailability, ActorAvailability, ActorAvailabilityReason, 3 + }; 1 4 use crate::constellation::{BacklinksResponse, ConstellationClient, ConstellationLinkRecord}; 2 5 use crate::error::{AppError, Result}; 3 6 use crate::explorer; 4 7 use crate::settings; 5 8 use crate::state::AppState; 9 + use jacquard::api::app_bsky::actor::get_profile::GetProfile; 6 10 use jacquard::api::app_bsky::actor::get_profiles::GetProfiles; 7 11 use jacquard::api::app_bsky::graph::get_list::GetList; 12 + use jacquard::api::app_bsky::graph::get_relationships::{GetRelationships, GetRelationshipsOutputRelationshipsItem}; 8 13 use jacquard::api::app_bsky::graph::get_starter_packs::GetStarterPacks; 9 14 use jacquard::api::com_atproto::label::query_labels::QueryLabels; 10 15 use jacquard::client::{Agent, UnauthenticatedSession}; ··· 59 64 #[serde(rename_all = "camelCase")] 60 65 pub struct DidProfileItem { 61 66 pub did: String, 67 + pub availability: ActorAvailability, 62 68 pub profile: Option<Value>, 69 + pub unavailable_reason: Option<ActorAvailabilityReason>, 70 + pub unavailable_message: Option<String>, 63 71 } 64 72 65 73 #[derive(Debug, Clone, Serialize)] ··· 84 92 pub cid: String, 85 93 pub subject_did: String, 86 94 pub created_at: Option<String>, 95 + pub availability: ActorAvailability, 87 96 pub value: Value, 88 97 pub profile: Option<Value>, 98 + pub unavailable_reason: Option<ActorAvailabilityReason>, 99 + pub unavailable_message: Option<String>, 89 100 } 90 101 91 102 #[derive(Debug, Clone, Serialize)] ··· 247 258 let normalized_did = normalize_did(&did)?; 248 259 let client = constellation_client(state)?; 249 260 let response = match client 250 - .get_distinct_dids( 251 - normalized_did, 261 + .get_backlinks( 262 + normalized_did.clone(), 252 263 BLOCK_SOURCE.to_string(), 253 264 limit.or(Some(BLOCK_PREVIEW_LIMIT)), 254 265 cursor, ··· 267 278 } 268 279 }; 269 280 270 - let profiles = fetch_profiles_map(&response.dids).await?; 271 - let items = response 272 - .dids 281 + let candidate_dids = extract_blocker_dids(&response.records); 282 + let confirmed_dids = confirm_blocked_by(&normalized_did, &candidate_dids).await?; 283 + let actor_states = fetch_actor_states(&confirmed_dids).await?; 284 + let items = confirmed_dids 273 285 .into_iter() 274 - .map(|entry_did| DidProfileItem { profile: profiles.get(&entry_did).cloned(), did: entry_did }) 275 - .collect(); 286 + .map(|entry_did| build_did_profile_item(entry_did.clone(), actor_states.get(&entry_did))) 287 + .collect::<Vec<_>>(); 276 288 277 289 Ok(AccountBlockedByResult { total: response.total, items, cursor: response.cursor }) 278 290 } ··· 302 314 .iter() 303 315 .filter_map(|record| extract_subject_did(&record.value)) 304 316 .collect::<Vec<_>>(); 305 - let profiles = fetch_profiles_map(&subject_dids).await?; 317 + let actor_states = fetch_actor_states(&subject_dids).await?; 306 318 307 319 let items = parsed 308 320 .records 309 321 .into_iter() 310 322 .filter_map(|record| { 311 323 let subject_did = extract_subject_did(&record.value)?; 324 + let actor_state = actor_states.get(&subject_did); 312 325 Some(AccountBlockingItem { 313 326 created_at: extract_created_at(&record.value), 314 - profile: profiles.get(&subject_did).cloned(), 327 + availability: actor_state 328 + .map(|state| state.availability) 329 + .unwrap_or(ActorAvailability::Unavailable), 330 + profile: actor_state.and_then(|state| state.profile.clone()), 315 331 uri: record.uri, 316 332 cid: record.cid, 333 + unavailable_reason: actor_state.and_then(|state| state.unavailable_reason), 334 + unavailable_message: actor_state.and_then(|state| state.unavailable_message.clone()), 317 335 subject_did, 318 336 value: record.value, 319 337 }) ··· 476 494 Ok(AtIdentifier::Did(Did::new(did)?.into_static())) 477 495 } 478 496 497 + #[derive(Debug, Clone)] 498 + struct ActorState { 499 + availability: ActorAvailability, 500 + profile: Option<Value>, 501 + unavailable_reason: Option<ActorAvailabilityReason>, 502 + unavailable_message: Option<String>, 503 + } 504 + 505 + async fn fetch_actor_states(dids: &[String]) -> Result<BTreeMap<String, ActorState>> { 506 + let unique_dids = dedupe_preserve_order(dids.to_vec()); 507 + if unique_dids.is_empty() { 508 + return Ok(BTreeMap::new()); 509 + } 510 + 511 + let profiles = fetch_profiles_map(&unique_dids).await?; 512 + let mut states = profiles 513 + .into_iter() 514 + .map(|(did, profile)| { 515 + ( 516 + did, 517 + ActorState { 518 + availability: ActorAvailability::Available, 519 + profile: Some(profile), 520 + unavailable_reason: None, 521 + unavailable_message: None, 522 + }, 523 + ) 524 + }) 525 + .collect::<BTreeMap<_, _>>(); 526 + 527 + for did in unique_dids { 528 + if states.contains_key(&did) { 529 + continue; 530 + } 531 + 532 + states.insert(did.clone(), fetch_missing_actor_state(&did).await); 533 + } 534 + 535 + Ok(states) 536 + } 537 + 479 538 async fn fetch_profiles_map(dids: &[String]) -> Result<BTreeMap<String, Value>> { 480 539 let unique_dids = dedupe_preserve_order(dids.to_vec()); 481 540 if unique_dids.is_empty() { ··· 525 584 Ok(profiles) 526 585 } 527 586 587 + async fn fetch_missing_actor_state(did: &str) -> ActorState { 588 + let actor = match did_identifier(did) { 589 + Ok(actor) => actor, 590 + Err(error) => { 591 + log_missing_resource("profile", did, error); 592 + return unavailable_actor_state(ActorAvailabilityReason::Unavailable); 593 + } 594 + }; 595 + let client = public_client(); 596 + 597 + let output = match client.send(GetProfile::new().actor(actor).build()).await { 598 + Ok(output) => output, 599 + Err(error) => { 600 + log::warn!("failed to load missing actor profile for {did}: {error}"); 601 + return actor_state_from_error(&error); 602 + } 603 + }; 604 + 605 + match output.into_output() { 606 + Ok(output) => match serde_json::to_value(output.value) { 607 + Ok(profile) => ActorState { 608 + availability: ActorAvailability::Available, 609 + profile: Some(profile), 610 + unavailable_reason: None, 611 + unavailable_message: None, 612 + }, 613 + Err(error) => { 614 + log::warn!("failed to serialize actor profile for {did}: {error}"); 615 + unavailable_actor_state(ActorAvailabilityReason::Unavailable) 616 + } 617 + }, 618 + Err(error) => { 619 + log::warn!("failed to decode actor profile for {did}: {error}"); 620 + actor_state_from_error(&error) 621 + } 622 + } 623 + } 624 + 625 + fn actor_state_from_error(error: &impl std::fmt::Display) -> ActorState { 626 + unavailable_actor_state(classify_actor_unavailability(error).unwrap_or(ActorAvailabilityReason::Unavailable)) 627 + } 628 + 629 + fn unavailable_actor_state(reason: ActorAvailabilityReason) -> ActorState { 630 + ActorState { 631 + availability: ActorAvailability::Unavailable, 632 + profile: None, 633 + unavailable_reason: Some(reason), 634 + unavailable_message: Some(actor_unavailable_message(reason).to_string()), 635 + } 636 + } 637 + 638 + fn build_did_profile_item(did: String, actor_state: Option<&ActorState>) -> DidProfileItem { 639 + DidProfileItem { 640 + availability: actor_state 641 + .map(|state| state.availability) 642 + .unwrap_or(ActorAvailability::Unavailable), 643 + did, 644 + profile: actor_state.and_then(|state| state.profile.clone()), 645 + unavailable_reason: actor_state.and_then(|state| state.unavailable_reason), 646 + unavailable_message: actor_state.and_then(|state| state.unavailable_message.clone()), 647 + } 648 + } 649 + 528 650 async fn fetch_lists(list_uris: &[String]) -> Result<Vec<Value>> { 529 651 let client = public_client(); 530 652 let mut lists = Vec::new(); ··· 649 771 Ok(fetch_profiles_map(dids).await?.into_iter().collect()) 650 772 } 651 773 774 + fn extract_blocker_dids(records: &[ConstellationLinkRecord]) -> Vec<String> { 775 + dedupe_preserve_order(records.iter().map(|record| record.did.clone()).collect()) 776 + } 777 + 778 + async fn confirm_blocked_by(actor_did: &str, candidate_dids: &[String]) -> Result<Vec<String>> { 779 + if candidate_dids.is_empty() { 780 + return Ok(Vec::new()); 781 + } 782 + 783 + let actor = did_identifier(actor_did)?; 784 + let client = public_client(); 785 + let mut confirmed = BTreeSet::new(); 786 + 787 + for chunk in candidate_dids.chunks(PUBLIC_BATCH_LIMIT) { 788 + let others = chunk 789 + .iter() 790 + .filter_map(|did| match did_identifier(did) { 791 + Ok(actor) => Some(actor), 792 + Err(error) => { 793 + log_missing_resource("relationship", did, error); 794 + None 795 + } 796 + }) 797 + .collect::<Vec<_>>(); 798 + if others.is_empty() { 799 + continue; 800 + } 801 + 802 + let output = client 803 + .send(GetRelationships::new().actor(actor.clone()).others(others).build()) 804 + .await 805 + .map_err(|error| AppError::diagnostics("Couldn't confirm who blocks this profile.", error))? 806 + .into_output() 807 + .map_err(|error| AppError::diagnostics("Couldn't read who blocks this profile.", error))? 808 + .into_static(); 809 + 810 + for did in extract_confirmed_blocked_by_dids(&output.relationships) { 811 + confirmed.insert(did); 812 + } 813 + } 814 + 815 + Ok(candidate_dids 816 + .iter() 817 + .filter(|did| confirmed.contains(did.as_str())) 818 + .cloned() 819 + .collect()) 820 + } 821 + 822 + fn extract_confirmed_blocked_by_dids(relationships: &[GetRelationshipsOutputRelationshipsItem<'_>]) -> Vec<String> { 823 + relationships 824 + .iter() 825 + .filter_map(|relationship| match relationship { 826 + GetRelationshipsOutputRelationshipsItem::Relationship(relationship) 827 + if relationship.blocked_by.is_some() => 828 + { 829 + Some(relationship.did.to_string()) 830 + } 831 + _ => None, 832 + }) 833 + .collect() 834 + } 835 + 652 836 fn extract_subject_did(value: &Value) -> Option<String> { 653 837 value.get("subject").and_then(Value::as_str).map(str::to_string) 654 838 } ··· 659 843 660 844 #[cfg(test)] 661 845 mod tests { 662 - use super::{dedupe_preserve_order, extract_created_at, extract_subject_did, should_skip_missing_resource}; 846 + use super::{ 847 + dedupe_preserve_order, extract_blocker_dids, extract_confirmed_blocked_by_dids, extract_created_at, 848 + extract_subject_did, should_skip_missing_resource, 849 + }; 850 + use crate::constellation::ConstellationLinkRecord; 851 + use jacquard::api::app_bsky::graph::{get_relationships::GetRelationshipsOutputRelationshipsItem, Relationship}; 852 + use jacquard::types::{aturi::AtUri, did::Did}; 663 853 use serde_json::json; 664 854 665 855 #[test] ··· 690 880 )); 691 881 assert!(should_skip_missing_resource(&"repo not found")); 692 882 assert!(!should_skip_missing_resource(&"rate limit exceeded")); 883 + } 884 + 885 + #[test] 886 + fn extract_blocker_dids_preserves_order_and_dedupes() { 887 + let records = vec![ 888 + ConstellationLinkRecord { 889 + did: "did:plc:one".to_string(), 890 + collection: "app.bsky.graph.block".to_string(), 891 + rkey: "1".to_string(), 892 + }, 893 + ConstellationLinkRecord { 894 + did: "did:plc:two".to_string(), 895 + collection: "app.bsky.graph.block".to_string(), 896 + rkey: "2".to_string(), 897 + }, 898 + ConstellationLinkRecord { 899 + did: "did:plc:one".to_string(), 900 + collection: "app.bsky.graph.block".to_string(), 901 + rkey: "3".to_string(), 902 + }, 903 + ]; 904 + 905 + assert_eq!( 906 + extract_blocker_dids(&records), 907 + vec!["did:plc:one".to_string(), "did:plc:two".to_string()] 908 + ); 909 + } 910 + 911 + #[test] 912 + fn extracts_only_confirmed_blocked_by_relationships() { 913 + let relationships = vec![ 914 + GetRelationshipsOutputRelationshipsItem::Relationship(Box::new( 915 + Relationship::new() 916 + .did(Did::new("did:plc:one").expect("did should parse")) 917 + .blocked_by(AtUri::new("at://did:plc:one/app.bsky.graph.block/1").expect("uri should parse")) 918 + .build(), 919 + )), 920 + GetRelationshipsOutputRelationshipsItem::Relationship(Box::new( 921 + Relationship::new() 922 + .did(Did::new("did:plc:two").expect("did should parse")) 923 + .build(), 924 + )), 925 + ]; 926 + 927 + assert_eq!( 928 + extract_confirmed_blocked_by_dids(&relationships), 929 + vec!["did:plc:one".to_string()] 930 + ); 693 931 } 694 932 }
+56 -11
src-tauri/src/feed.rs
··· 1 + use super::actors::{ 2 + actor_unavailable_message, classify_actor_unavailability, requested_actor_hints, ActorAvailabilityReason, 3 + }; 1 4 use super::auth::LazuriteOAuthSession; 2 5 use super::error::{AppError, Result}; 3 6 use super::state::AppState; ··· 42 45 use serde::{Deserialize, Serialize}; 43 46 use std::sync::Arc; 44 47 use tauri_plugin_log::log; 48 + 49 + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 50 + #[serde(tag = "status", rename_all = "camelCase")] 51 + pub enum ProfileLookupResult { 52 + Available { 53 + profile: serde_json::Value, 54 + }, 55 + Unavailable { 56 + requested_actor: String, 57 + did: Option<String>, 58 + handle: Option<String>, 59 + reason: ActorAvailabilityReason, 60 + message: String, 61 + }, 62 + } 45 63 46 64 async fn get_session(state: &AppState) -> Result<Arc<LazuriteOAuthSession>> { 47 65 let did = state ··· 436 454 437 455 pub async fn get_profile(actor: String, state: &AppState) -> Result<serde_json::Value> { 438 456 let session = get_session(state).await?; 457 + let requested_actor = actor.trim().to_string(); 458 + let (did, handle) = requested_actor_hints(&requested_actor); 439 459 let actor = parse_actor_identifier(&actor)?; 440 460 441 - let output = session 442 - .send(GetProfile::new().actor(actor).build()) 443 - .await 444 - .map_err(|error| { 461 + let output = match session.send(GetProfile::new().actor(actor).build()).await { 462 + Ok(output) => output, 463 + Err(error) => { 445 464 log::error!("getProfile error: {error}"); 446 - AppError::validation("getProfile") 447 - })? 448 - .into_output() 449 - .map_err(|error| { 465 + if let Some(reason) = classify_actor_unavailability(&error) { 466 + return serde_json::to_value(ProfileLookupResult::Unavailable { 467 + requested_actor, 468 + did, 469 + handle, 470 + reason, 471 + message: actor_unavailable_message(reason).to_string(), 472 + }) 473 + .map_err(AppError::from); 474 + } 475 + 476 + return Err(AppError::validation("Couldn't load this profile right now.")); 477 + } 478 + }; 479 + let output = match output.into_output() { 480 + Ok(output) => output, 481 + Err(error) => { 450 482 log::error!("getProfile output error: {error}"); 451 - AppError::validation("getProfile output") 452 - })?; 483 + if let Some(reason) = classify_actor_unavailability(&error) { 484 + return serde_json::to_value(ProfileLookupResult::Unavailable { 485 + requested_actor, 486 + did, 487 + handle, 488 + reason, 489 + message: actor_unavailable_message(reason).to_string(), 490 + }) 491 + .map_err(AppError::from); 492 + } 453 493 454 - serde_json::to_value(output.value).map_err(AppError::from) 494 + return Err(AppError::validation("Couldn't load this profile right now.")); 495 + } 496 + }; 497 + 498 + serde_json::to_value(ProfileLookupResult::Available { profile: serde_json::to_value(output.value)? }) 499 + .map_err(AppError::from) 455 500 } 456 501 457 502 pub async fn get_author_feed(
+1
src-tauri/src/lib.rs
··· 1 + mod actors; 1 2 mod auth; 2 3 mod columns; 3 4 mod commands;
+23 -2
src/components/deck/DiagnosticsPanel.test.tsx
··· 63 63 }); 64 64 getAccountBlockedByMock.mockResolvedValue({ 65 65 cursor: null, 66 - items: [{ did: "did:plc:blocker", profile: { handle: "blocker.test" } }], 66 + items: [{ availability: "available", did: "did:plc:blocker", profile: { handle: "blocker.test" } }], 67 67 total: 1, 68 68 }); 69 69 getAccountBlockingMock.mockResolvedValue({ 70 70 cursor: null, 71 - items: [{ subjectDid: "did:plc:boundary", profile: { handle: "boundary.test" } }], 71 + items: [{ availability: "available", subjectDid: "did:plc:boundary", profile: { handle: "boundary.test" } }], 72 72 }); 73 73 getAccountStarterPacksMock.mockResolvedValue({ 74 74 starterPacks: [{ ··· 121 121 fireEvent.click(screen.getByRole("button", { name: "Starter Packs" })); 122 122 expect(await screen.findByText("Newcomers")).toBeInTheDocument(); 123 123 expect(screen.getByText("8 members")).toBeInTheDocument(); 124 + }); 125 + 126 + it("renders unavailable block rows without breaking the section", async () => { 127 + getAccountBlockedByMock.mockResolvedValueOnce({ 128 + cursor: null, 129 + items: [{ 130 + availability: "unavailable", 131 + did: "did:plc:missing", 132 + unavailableMessage: "This profile is unavailable right now.", 133 + }], 134 + total: 1, 135 + }); 136 + 137 + renderPanel(); 138 + 139 + fireEvent.click(await screen.findByRole("button", { name: "Blocks" })); 140 + fireEvent.click(screen.getByRole("button", { name: /show details/i })); 141 + 142 + const missing = await screen.findAllByText("did:plc:missing"); 143 + expect(missing.length).toBeGreaterThan(0); 144 + expect(screen.getByText("This profile is unavailable right now.")).toBeInTheDocument(); 124 145 }); 125 146 126 147 it("explains backlinks when no record URI is selected", async () => {
+40 -72
src/components/deck/DiagnosticsPanel.tsx
··· 1 1 import { RecordBacklinksPanel } from "$/components/diagnostics/RecordBacklinksPanel"; 2 + import { Icon } from "$/components/shared/Icon"; 2 3 import { useAppSession } from "$/contexts/app-session"; 3 4 import { 4 5 type DiagnosticBlockItem, ··· 14 15 } from "$/lib/api/diagnostics"; 15 16 import { asRecord, getStringProperty } from "$/lib/type-guards"; 16 17 import { shouldIgnoreKey } from "$/lib/utils/events"; 17 - import { normalizeError } from "$/lib/utils/text"; 18 + import { formatHandle, initials, normalizeError } from "$/lib/utils/text"; 18 19 import * as logger from "@tauri-apps/plugin-log"; 19 20 import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; 20 21 import { createStore } from "solid-js/store"; 21 22 import { Motion, Presence } from "solid-motionone"; 22 - import { Icon } from "../shared/Icon"; 23 + import { 24 + DiagnosticsBlockSkeleton, 25 + DiagnosticsLabelSkeleton, 26 + DiagnosticsListSkeleton, 27 + DiagnosticsStarterPackSkeleton, 28 + } from "./DiagnosticsSkeleton"; 23 29 24 30 type DiagnosticsTab = "lists" | "labels" | "blocks" | "starterPacks" | "backlinks"; 25 31 ··· 133 139 return grouped.length > 0 ? grouped : [{ label: "Lists", items: lists }]; 134 140 } 135 141 136 - function initials(name: string) { 137 - return name.trim().slice(0, 1).toUpperCase() || "?"; 138 - } 139 - 140 - function formatHandle(handle: string | null | undefined) { 141 - if (!handle) { 142 - return "Unknown"; 143 - } 144 - 145 - return handle.startsWith("did:") || handle.startsWith("@") ? handle : `@${handle}`; 146 - } 147 - 148 142 function getDiagnosticEntryHandle(item: DiagnosticBlockItem | DiagnosticDidProfile) { 149 143 if (item.profile?.handle) { 150 144 return item.profile.handle; ··· 210 204 return src; 211 205 } 212 206 213 - return getStringProperty(profile, "displayName") ?? formatHandle(getStringProperty(profile, "handle")) ?? src; 207 + return getStringProperty(profile, "displayName") ?? formatHandle(getStringProperty(profile, "handle"), null) ?? src; 214 208 } 215 209 216 210 export function DiagnosticsPanel(props: DiagnosticsPanelProps) { ··· 652 646 ) { 653 647 const items = createMemo(() => 654 648 props.items.map((item) => ({ 655 - avatar: item.profile?.avatar ?? null, 656 - description: item.profile?.description ?? null, 649 + available: item.availability === "available", 650 + avatar: item.availability === "available" ? item.profile?.avatar ?? null : null, 651 + description: item.availability === "available" ? item.profile?.description ?? null : null, 657 652 displayName: item.profile?.displayName ?? null, 658 653 handle: getDiagnosticEntryHandle(item), 654 + unavailableMessage: item.unavailableMessage ?? "Profile unavailable", 659 655 })) 660 656 ); 661 657 ··· 781 777 {purposeLabel(props.list.purpose)} 782 778 </span> 783 779 </div> 784 - <p class="m-0 mt-1 text-sm text-on-surface-variant">Owner: {formatHandle(props.list.creator?.handle)}</p> 780 + <p class="m-0 mt-1 text-sm text-on-surface-variant"> 781 + Owner: {formatHandle(props.list.creator?.handle, null)} 782 + </p> 785 783 <p class="m-0 mt-3 text-sm leading-relaxed text-on-surface-variant"> 786 784 {props.list.description ?? "No description provided."} 787 785 </p> ··· 817 815 <div class="min-w-0"> 818 816 <p class="m-0 text-base font-semibold text-on-surface">{title()}</p> 819 817 <p class="m-0 mt-1 text-sm text-on-surface-variant"> 820 - Creator: {formatHandle(props.pack.creator?.handle ?? null)} 818 + Creator: {formatHandle(props.pack.creator?.handle ?? null, null)} 821 819 </p> 822 820 <p class="m-0 mt-3 text-sm leading-relaxed text-on-surface-variant"> 823 821 {props.pack.description ?? props.pack.record?.description ?? "No description provided."} ··· 846 844 847 845 function BlockProfileList( 848 846 props: { 849 - items: Array<{ avatar?: string | null; description?: string | null; displayName?: string | null; handle: string }>; 847 + items: Array< 848 + { 849 + available: boolean; 850 + avatar?: string | null; 851 + description?: string | null; 852 + displayName?: string | null; 853 + handle: string; 854 + unavailableMessage: string; 855 + } 856 + >; 850 857 title: string; 851 858 }, 852 859 ) { ··· 859 866 const name = () => item.displayName ?? item.handle; 860 867 return ( 861 868 <Motion.div 862 - class="flex items-start gap-3 rounded-2xl bg-black/20 p-3" 869 + class="flex items-start gap-3 rounded-2xl p-3" 870 + classList={{ "bg-black/20": item.available, "bg-white/4 opacity-70": !item.available }} 871 + aria-disabled={!item.available} 863 872 initial={{ opacity: 0, y: 8 }} 864 873 animate={{ opacity: 1, y: 0 }} 865 874 transition={{ delay: Math.min(index() * 0.04, 0.16), duration: 0.16 }}> 866 875 <div class="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-white/8 text-xs font-semibold text-on-surface-variant"> 867 - <Show when={item.avatar} fallback={<span>{initials(name())}</span>}> 876 + <Show 877 + when={item.available && item.avatar} 878 + fallback={item.available 879 + ? <span>{initials(name())}</span> 880 + : <Icon kind="danger" aria-hidden="true" />}> 868 881 {(src) => <img alt="" class="h-full w-full object-cover" src={src()} />} 869 882 </Show> 870 883 </div> 871 884 <div class="min-w-0"> 872 885 <p class="m-0 text-sm font-medium text-on-surface">{name()}</p> 873 - <p class="m-0 text-xs text-on-surface-variant">{formatHandle(item.handle)}</p> 874 - <Show when={item.description}> 886 + <p class="m-0 text-xs text-on-surface-variant">{formatHandle(item.handle, null)}</p> 887 + <Show when={item.available && item.description}> 875 888 {(description) => ( 876 889 <p class="m-0 mt-2 text-xs leading-relaxed text-on-surface-variant">{description()}</p> 877 890 )} 878 891 </Show> 892 + <Show when={!item.available}> 893 + <p class="m-0 mt-2 text-xs leading-relaxed text-on-surface-variant">{item.unavailableMessage}</p> 894 + </Show> 879 895 </div> 880 896 </Motion.div> 881 897 ); ··· 885 901 </div> 886 902 ); 887 903 } 888 - 889 - function DiagnosticsListSkeleton() { 890 - return ( 891 - <div class="grid gap-4"> 892 - <For each={Array.from({ length: 3 })}> 893 - {() => ( 894 - <div class="grid h-32 gap-3 rounded-3xl bg-white/3 p-4"> 895 - <div class="h-4 w-28 rounded-full bg-white/6" /> 896 - <div class="h-4 w-44 rounded-full bg-white/6" /> 897 - <div class="h-4 w-full rounded-full bg-white/6" /> 898 - </div> 899 - )} 900 - </For> 901 - </div> 902 - ); 903 - } 904 - 905 - function DiagnosticsLabelSkeleton() { 906 - return ( 907 - <div class="flex flex-wrap gap-2"> 908 - <For each={Array.from({ length: 5 })}>{() => <div class="h-10 w-32 rounded-full bg-white/3" />}</For> 909 - </div> 910 - ); 911 - } 912 - 913 - function DiagnosticsStarterPackSkeleton() { 914 - return ( 915 - <div class="grid gap-3"> 916 - <For each={Array.from({ length: 2 })}> 917 - {() => ( 918 - <div class="grid h-28 gap-3 rounded-3xl bg-white/3 p-4"> 919 - <div class="h-4 w-40 rounded-full bg-white/6" /> 920 - <div class="h-4 w-28 rounded-full bg-white/6" /> 921 - <div class="h-4 w-full rounded-full bg-white/6" /> 922 - </div> 923 - )} 924 - </For> 925 - </div> 926 - ); 927 - } 928 - 929 - function DiagnosticsBlockSkeleton() { 930 - return ( 931 - <div class="grid gap-3"> 932 - <For each={Array.from({ length: 2 })}>{() => <div class="h-24 rounded-3xl bg-white/3" />}</For> 933 - </div> 934 - ); 935 - }
+49
src/components/deck/DiagnosticsSkeleton.tsx
··· 1 + import { For } from "solid-js"; 2 + 3 + export function DiagnosticsListSkeleton() { 4 + return ( 5 + <div class="grid gap-4"> 6 + <For each={Array.from({ length: 3 })}> 7 + {() => ( 8 + <div class="grid h-32 gap-3 rounded-3xl bg-white/3 p-4"> 9 + <div class="h-4 w-28 rounded-full bg-white/6" /> 10 + <div class="h-4 w-44 rounded-full bg-white/6" /> 11 + <div class="h-4 w-full rounded-full bg-white/6" /> 12 + </div> 13 + )} 14 + </For> 15 + </div> 16 + ); 17 + } 18 + 19 + export function DiagnosticsLabelSkeleton() { 20 + return ( 21 + <div class="flex flex-wrap gap-2"> 22 + <For each={Array.from({ length: 5 })}>{() => <div class="h-10 w-32 rounded-full bg-white/3" />}</For> 23 + </div> 24 + ); 25 + } 26 + 27 + export function DiagnosticsStarterPackSkeleton() { 28 + return ( 29 + <div class="grid gap-3"> 30 + <For each={Array.from({ length: 2 })}> 31 + {() => ( 32 + <div class="grid h-28 gap-3 rounded-3xl bg-white/3 p-4"> 33 + <div class="h-4 w-40 rounded-full bg-white/6" /> 34 + <div class="h-4 w-28 rounded-full bg-white/6" /> 35 + <div class="h-4 w-full rounded-full bg-white/6" /> 36 + </div> 37 + )} 38 + </For> 39 + </div> 40 + ); 41 + } 42 + 43 + export function DiagnosticsBlockSkeleton() { 44 + return ( 45 + <div class="grid gap-3"> 46 + <For each={Array.from({ length: 2 })}>{() => <div class="h-24 rounded-3xl bg-white/3" />}</For> 47 + </div> 48 + ); 49 + }
+3 -15
src/components/diagnostics/RecordBacklinksPanel.tsx
··· 1 + import { ArrowIcon, Icon } from "$/components/shared/Icon"; 1 2 import { type DiagnosticBacklinkGroup, type DiagnosticBacklinkItem, getRecordBacklinks } from "$/lib/api/diagnostics"; 2 - import { normalizeError } from "$/lib/utils/text"; 3 + import { formatHandle, initials, normalizeError } from "$/lib/utils/text"; 3 4 import * as logger from "@tauri-apps/plugin-log"; 4 5 import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js"; 5 6 import { createStore } from "solid-js/store"; 6 7 import { Motion, Presence } from "solid-motionone"; 7 - import { ArrowIcon, Icon } from "../shared/Icon"; 8 8 9 9 type GroupKey = "likes" | "reposts" | "replies" | "quotes"; 10 10 ··· 36 36 groups: { likes: EMPTY_GROUP, quotes: EMPTY_GROUP, replies: EMPTY_GROUP, reposts: EMPTY_GROUP }, 37 37 loading: false, 38 38 }; 39 - } 40 - 41 - function initials(name: string) { 42 - return name.trim().slice(0, 1).toUpperCase() || "?"; 43 - } 44 - 45 - function formatHandle(handle: string | null | undefined, did: string | null | undefined) { 46 - if (handle) { 47 - return handle.startsWith("@") ? handle : `@${handle}`; 48 - } 49 - 50 - return did ?? "Unknown"; 51 39 } 52 40 53 41 export function RecordBacklinksPanel(props: RecordBacklinksPanelProps) { ··· 229 217 transition={{ delay: Math.min(props.index * 0.04, 0.16), duration: 0.16 }}> 230 218 <div class="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-white/8 text-xs font-semibold text-on-surface-variant"> 231 219 <Show when={props.item.profile?.avatar} fallback={<span>{initials(actorLabel())}</span>}> 232 - {(src) => <img alt="" class="h-full w-full object-cover" src={src()} />} 220 + {(src) => <img alt={actorLabel()} class="h-full w-full object-cover" src={src()} />} 233 221 </Show> 234 222 </div> 235 223
+6 -2
src/components/explorer/ExplorerPanel.tsx
··· 133 133 describeRepo(resolved.did), 134 134 getProfile(resolved.did).catch(() => null), 135 135 ]); 136 + const profileData = profile?.status === "available" ? profile.profile : null; 136 137 const collections = extractCollections(repoData); 137 138 finalViewState = { 138 139 ...viewState, ··· 142 143 did: resolved.did, 143 144 handle: resolved.handle || resolved.did, 144 145 pdsUrl: resolved.pdsUrl, 145 - socialSummary: profile 146 - ? { followerCount: profile.followersCount ?? null, followingCount: profile.followsCount ?? null } 146 + socialSummary: profileData 147 + ? { 148 + followerCount: profileData.followersCount ?? null, 149 + followingCount: profileData.followsCount ?? null, 150 + } 147 151 : null, 148 152 }, 149 153 };
+21 -1
src/components/profile/ProfilePanel.test.tsx
··· 104 104 describe("ProfilePanel", () => { 105 105 beforeEach(() => { 106 106 vi.resetAllMocks(); 107 - getProfileMock.mockResolvedValue(createProfile()); 107 + getProfileMock.mockResolvedValue({ status: "available", profile: createProfile() }); 108 108 getAuthorFeedMock.mockResolvedValue({ cursor: null, feed: [] }); 109 109 getActorLikesMock.mockResolvedValue({ cursor: null, feed: [] }); 110 110 getAccountListsMock.mockResolvedValue({ ··· 248 248 expect(await screen.findByText("Social Diagnostics")).toBeInTheDocument(); 249 249 expect(await screen.findByText("Builders")).toBeInTheDocument(); 250 250 expect(screen.getByText("Public social context for this account")).toBeInTheDocument(); 251 + }); 252 + 253 + it("shows an unavailable profile state and skips profile interactions when the actor is unavailable", async () => { 254 + getProfileMock.mockResolvedValueOnce({ 255 + status: "unavailable", 256 + requestedActor: "missing.test", 257 + handle: "missing.test", 258 + reason: "notFound", 259 + message: "This profile could not be found.", 260 + }); 261 + 262 + renderProfilePanel("missing.test"); 263 + 264 + expect(await screen.findByText("Profile unavailable")).toBeInTheDocument(); 265 + expect(screen.getByText("missing.test")).toBeInTheDocument(); 266 + expect(screen.getByText("This profile could not be found.")).toBeInTheDocument(); 267 + expect(screen.queryByRole("button", { name: "Follow" })).not.toBeInTheDocument(); 268 + expect(screen.queryByRole("button", { name: "Context" })).not.toBeInTheDocument(); 269 + expect(getAuthorFeedMock).not.toHaveBeenCalled(); 270 + expect(getActorLikesMock).not.toHaveBeenCalled(); 251 271 }); 252 272 });
+131 -60
src/components/profile/ProfilePanel.tsx
··· 16 16 import { queueExplorerTarget } from "$/lib/explorer-navigation"; 17 17 import { patchFeedItems } from "$/lib/feeds"; 18 18 import { buildProfileRoute, filterProfileFeed, getProfileRouteActor, type ProfileTab } from "$/lib/profile"; 19 - import type { ActorListResponse, FeedResponse, FeedViewPost, ProfileViewBasic } from "$/lib/types"; 19 + import type { 20 + ActorListResponse, 21 + FeedResponse, 22 + FeedViewPost, 23 + ProfileLookupUnavailable, 24 + ProfileViewBasic, 25 + } from "$/lib/types"; 20 26 import { formatJoinedDate, normalizeError } from "$/lib/utils/text"; 21 27 import { useNavigate } from "@solidjs/router"; 22 28 import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; 23 29 import { createStore } from "solid-js/store"; 24 30 import { Presence } from "solid-motionone"; 31 + import { Icon } from "../shared/Icon"; 25 32 import { createActorListState, createFeedState, createProfilePanelState, tabLabel } from "./profile-state"; 26 33 import type { ProfilePanelState } from "./profile-state"; 27 34 import { ActorListOverlay } from "./ProfileActorList"; ··· 91 98 requestSequence += 1; 92 99 const sequence = requestSequence; 93 100 setState({ 101 + actorList: createActorListState(), 94 102 authorFeed: createFeedState(), 95 103 likesFeed: createFeedState(), 96 104 profile: null, 97 105 profileError: null, 98 106 profileLoading: true, 107 + profileUnavailable: null, 99 108 scrollTop: 0, 100 109 }); 101 110 ··· 104 113 105 114 createEffect(() => { 106 115 const actor = activeActor(); 107 - if (!actor || state.profileLoading || !!state.profileError) { 116 + if (!actor || state.profileLoading || !!state.profileError || !!state.profileUnavailable) { 108 117 return; 109 118 } 110 119 ··· 126 135 127 136 async function loadProfile(sequence: number, actor: string) { 128 137 try { 129 - const profile = await getProfile(actor); 138 + const result = await getProfile(actor); 130 139 if (sequence !== requestSequence || actor !== activeActor()) { 131 140 return; 132 141 } 133 142 134 - setState({ profile, profileError: null, profileLoading: false }); 143 + if (result.status === "available") { 144 + setState({ profile: result.profile, profileError: null, profileLoading: false, profileUnavailable: null }); 145 + return; 146 + } 147 + 148 + setState({ profile: null, profileError: null, profileLoading: false, profileUnavailable: result }); 135 149 } catch (error) { 136 150 if (sequence !== requestSequence || actor !== activeActor()) { 137 151 return; 138 152 } 139 153 140 - setState({ profile: null, profileError: normalizeError(error), profileLoading: false }); 154 + setState({ profile: null, profileError: normalizeError(error), profileLoading: false, profileUnavailable: null }); 141 155 } 142 156 } 143 157 ··· 235 249 } 236 250 } 237 251 252 + function retryProfile() { 253 + const actor = activeActor(); 254 + if (!actor) { 255 + return; 256 + } 257 + 258 + requestSequence += 1; 259 + const sequence = requestSequence; 260 + setState({ 261 + actorList: createActorListState(), 262 + authorFeed: createFeedState(), 263 + likesFeed: createFeedState(), 264 + profile: null, 265 + profileError: null, 266 + profileLoading: true, 267 + profileUnavailable: null, 268 + scrollTop: 0, 269 + }); 270 + void loadProfile(sequence, actor); 271 + } 272 + 238 273 function openThread(uri: string) { 239 274 void threadOverlay.openThread(uri); 240 275 } ··· 412 447 onScroll={(event) => setState("scrollTop", event.currentTarget.scrollTop)}> 413 448 <Show when={!state.profileLoading} fallback={<ProfileLoadingView />}> 414 449 <Show 415 - when={!state.profileError && activeProfile()} 416 - fallback={<ProfileErrorView error={state.profileError} />}> 417 - {(profile) => ( 418 - <> 419 - <ProfileHero 420 - coverOffset={coverOffset()} 421 - followLoading={state.followLoading} 422 - isSelf={isSelf()} 423 - joinedLabel={joinedLabel()} 424 - onFollow={handleFollow} 425 - onMessage={handleMessage} 426 - onOpenFollowers={() => openActorList("followers")} 427 - onOpenFollows={() => openActorList("follows")} 428 - onUnfollow={handleUnfollow} 429 - pinnedPostHref={pinnedPostHref()} 430 - profile={profile()} 431 - profileBadges={profileBadges()} 432 - rootRef={(element) => { 433 - setHeroHeight(element.offsetHeight || null); 434 - }} 435 - viewLabel={viewLabel()} /> 450 + when={state.profileUnavailable} 451 + fallback={ 452 + <Show 453 + when={!state.profileError && activeProfile()} 454 + fallback={<ProfileErrorView error={state.profileError} onRetry={retryProfile} />}> 455 + {(profile) => ( 456 + <> 457 + <ProfileHero 458 + coverOffset={coverOffset()} 459 + followLoading={state.followLoading} 460 + isSelf={isSelf()} 461 + joinedLabel={joinedLabel()} 462 + onFollow={handleFollow} 463 + onMessage={handleMessage} 464 + onOpenFollowers={() => openActorList("followers")} 465 + onOpenFollows={() => openActorList("follows")} 466 + onUnfollow={handleUnfollow} 467 + pinnedPostHref={pinnedPostHref()} 468 + profile={profile()} 469 + profileBadges={profileBadges()} 470 + rootRef={(element) => { 471 + setHeroHeight(element.offsetHeight || null); 472 + }} 473 + viewLabel={viewLabel()} /> 436 474 437 - <Show when={showCompactHeader()}> 438 - <ProfileStickyHeader profile={profile()} profileBadges={profileBadges()} /> 439 - </Show> 475 + <Show when={showCompactHeader()}> 476 + <ProfileStickyHeader profile={profile()} profileBadges={profileBadges()} /> 477 + </Show> 440 478 441 - <ProfileTabs 442 - activeTab={state.activeTab} 443 - compactHeaderVisible={showCompactHeader()} 444 - onSelect={selectTab} /> 445 - 446 - <Show 447 - when={state.activeTab === "context"} 448 - fallback={ 449 - <ProfileFeedSection 479 + <ProfileTabs 450 480 activeTab={state.activeTab} 451 - bookmarkPendingByUri={interactions.bookmarkPendingByUri()} 452 - cursor={activeFeedState().cursor} 453 - error={activeFeedState().error} 454 - items={visibleItems()} 455 - likePendingByUri={interactions.likePendingByUri()} 456 - loading={activeFeedState().loading} 457 - loadingMore={activeFeedState().loadingMore} 458 - onBookmark={(post) => void interactions.toggleBookmark(post)} 459 - onLike={(post) => void interactions.toggleLike(post)} 460 - onLoadMore={handleLoadMore} 461 - onOpenThread={openThread} 462 - onRepost={(post) => void interactions.toggleRepost(post)} 463 - repostPendingByUri={interactions.repostPendingByUri()} /> 464 - }> 465 - <div class="px-3 pb-4 max-[520px]:px-2"> 466 - <DiagnosticsPanel did={profile().did} embedded onOpenExplorerTarget={openExplorerTarget} /> 467 - </div> 468 - </Show> 469 - </> 470 - )} 481 + compactHeaderVisible={showCompactHeader()} 482 + onSelect={selectTab} /> 483 + 484 + <Show 485 + when={state.activeTab === "context"} 486 + fallback={ 487 + <ProfileFeedSection 488 + activeTab={state.activeTab} 489 + bookmarkPendingByUri={interactions.bookmarkPendingByUri()} 490 + cursor={activeFeedState().cursor} 491 + error={activeFeedState().error} 492 + items={visibleItems()} 493 + likePendingByUri={interactions.likePendingByUri()} 494 + loading={activeFeedState().loading} 495 + loadingMore={activeFeedState().loadingMore} 496 + onBookmark={(post) => void interactions.toggleBookmark(post)} 497 + onLike={(post) => void interactions.toggleLike(post)} 498 + onLoadMore={handleLoadMore} 499 + onOpenThread={openThread} 500 + onRepost={(post) => void interactions.toggleRepost(post)} 501 + repostPendingByUri={interactions.repostPendingByUri()} /> 502 + }> 503 + <div class="px-3 pb-4 max-[520px]:px-2"> 504 + <DiagnosticsPanel did={profile().did} embedded onOpenExplorerTarget={openExplorerTarget} /> 505 + </div> 506 + </Show> 507 + </> 508 + )} 509 + </Show> 510 + }> 511 + {(unavailable) => <ProfileUnavailableView unavailable={unavailable()} />} 471 512 </Show> 472 513 </Show> 473 514 </div> ··· 502 543 ); 503 544 } 504 545 505 - function ProfileErrorView(props: { error: string | null }) { 546 + function ProfileUnavailableView(props: { unavailable: ProfileLookupUnavailable }) { 547 + const title = () => props.unavailable.handle ?? props.unavailable.did ?? props.unavailable.requestedActor; 548 + 549 + return ( 550 + <div class="grid min-h-120 place-items-center p-6"> 551 + <div class="grid max-w-lg gap-4 rounded-4xl bg-white/3 p-6 text-left shadow-[inset_0_0_0_1px_rgba(255,255,255,0.03)]"> 552 + <div class="flex items-center gap-3"> 553 + <span class="flex h-12 w-12 items-center justify-center rounded-full bg-white/6 text-on-surface-variant"> 554 + <Icon kind="danger" aria-hidden="true" /> 555 + </span> 556 + <div class="min-w-0"> 557 + <p class="m-0 text-sm text-on-surface-variant">Profile unavailable</p> 558 + <h2 class="m-0 truncate text-lg font-semibold text-on-surface">{title()}</h2> 559 + </div> 560 + </div> 561 + <p class="m-0 text-sm leading-relaxed text-on-surface-variant">{props.unavailable.message}</p> 562 + </div> 563 + </div> 564 + ); 565 + } 566 + 567 + function ProfileErrorView(props: { error: string | null; onRetry: () => void }) { 506 568 const error = () => props.error ?? "The profile could not be loaded."; 507 569 return ( 508 570 <div class="grid min-h-120 place-items-center p-6"> 509 - <ProfileFeedMessage body={error()} title="Profile unavailable" /> 571 + <div class="grid gap-4"> 572 + <ProfileFeedMessage body={error()} title="Profile couldn't be loaded" /> 573 + <button 574 + type="button" 575 + class="inline-flex items-center justify-center gap-2 rounded-full border-0 bg-surface-container-high px-4 py-2 text-sm font-medium text-on-surface transition duration-150 hover:-translate-y-px" 576 + onClick={() => props.onRetry()}> 577 + <Icon kind="refresh" aria-hidden="true" /> 578 + Retry 579 + </button> 580 + </div> 510 581 </div> 511 582 ); 512 583 }
+3 -1
src/components/profile/profile-state.ts
··· 1 1 import type { ProfileTab } from "$/lib/profile"; 2 - import type { FeedViewPost, ProfileViewBasic, ProfileViewDetailed } from "$/lib/types"; 2 + import type { FeedViewPost, ProfileLookupUnavailable, ProfileViewBasic, ProfileViewDetailed } from "$/lib/types"; 3 3 4 4 export type FeedState = { 5 5 cursor: string | null; ··· 29 29 profile: ProfileViewDetailed | null; 30 30 profileError: string | null; 31 31 profileLoading: boolean; 32 + profileUnavailable: ProfileLookupUnavailable | null; 32 33 scrollTop: number; 33 34 }; 34 35 ··· 58 59 profile: null, 59 60 profileError: null, 60 61 profileLoading: true, 62 + profileUnavailable: null, 61 63 scrollTop: 0, 62 64 }; 63 65 }
+1 -3
src/components/search/HashtagPanel.tsx
··· 188 188 </div> 189 189 <div class="grid gap-1"> 190 190 <h1 class="m-0 text-3xl font-semibold tracking-[-0.03em] text-on-surface">{props.hashtagLabel}</h1> 191 - <p class="m-0 text-sm text-on-surface-variant"> 192 - Search Bluesky for this hashtag. 193 - </p> 191 + <p class="m-0 text-sm text-on-surface-variant">Search Bluesky for this hashtag.</p> 194 192 </div> 195 193 </div> 196 194 </div>
+9 -9
src/components/search/SearchPreflightPanel.tsx
··· 15 15 return ( 16 16 <div 17 17 class="grid gap-3 rounded-3xl p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]" 18 - classList={{ 19 - "bg-white/[0.035]": props.tone !== "primary", 20 - "bg-primary/10": props.tone === "primary", 21 - }}> 18 + classList={{ "bg-white/[0.035]": props.tone !== "primary", "bg-primary/10": props.tone === "primary" }}> 22 19 <div class="flex items-center gap-3"> 23 20 <span 24 21 class="flex h-11 w-11 items-center justify-center rounded-2xl" ··· 180 177 </section> 181 178 182 179 <Presence> 183 - <Show when={config()?.enabled && (prepareRequested() || config()?.downloadActive || config()?.lastError || !config()?.downloaded)}> 180 + <Show 181 + when={config()?.enabled 182 + && (prepareRequested() || config()?.downloadActive || config()?.lastError || !config()?.downloaded)}> 184 183 <Motion.section 185 184 class="grid gap-3 rounded-[2rem] bg-primary/8 p-5 shadow-[inset_0_0_0_1px_rgba(125,175,255,0.12)]" 186 185 initial={{ opacity: 0, height: 0 }} ··· 226 225 <Show when={config()?.downloadEtaSeconds}> 227 226 {(seconds) => <p class="m-0">ETA: {formatEtaSeconds(seconds())}</p>} 228 227 </Show> 229 - <Show when={config()?.lastError}> 230 - {(message) => <p class="m-0 text-red-200">{message()}</p>} 231 - </Show> 228 + <Show when={config()?.lastError}>{(message) => <p class="m-0 text-red-200">{message()}</p>}</Show> 232 229 </div> 233 230 </Motion.section> 234 231 </Show> ··· 256 253 onClick={() => void enableSemanticSearch()} 257 254 disabled={activating()} 258 255 class="inline-flex items-center gap-2 rounded-full border-0 bg-primary px-4 py-2 text-sm font-medium text-on-primary-fixed transition hover:bg-primary-dim disabled:cursor-not-allowed disabled:opacity-50"> 259 - <Icon kind={activating() ? "loader" : "download"} iconClass={activating() ? "i-ri-loader-4-line animate-spin" : undefined} class="text-sm" /> 256 + <Icon 257 + kind={activating() ? "loader" : "download"} 258 + iconClass={activating() ? "i-ri-loader-4-line animate-spin" : undefined} 259 + class="text-sm" /> 260 260 <span>{activating() ? "Downloading model..." : "Enable semantic search"}</span> 261 261 </button> 262 262 </div>
+6 -12
src/components/search/SyncStatusPanel.test.tsx
··· 142 142 143 143 it("fades the activity bar out after sync completes", async () => { 144 144 vi.useRealTimers(); 145 - syncPostsMock.mockImplementation( 146 - () => 147 - new Promise((resolve) => { 148 - setTimeout(() => { 149 - resolve({ 150 - did: "did:plc:test", 151 - source: "like", 152 - postCount: 150, 153 - lastSyncedAt: "2026-03-29T13:00:00.000Z", 154 - }); 155 - }, 20); 156 - }), 145 + syncPostsMock.mockImplementation(() => 146 + new Promise((resolve) => { 147 + setTimeout(() => { 148 + resolve({ did: "did:plc:test", source: "like", postCount: 150, lastSyncedAt: "2026-03-29T13:00:00.000Z" }); 149 + }, 20); 150 + }) 157 151 ); 158 152 159 153 render(() => <SyncStatusPanel did="did:plc:test" />);
+5 -8
src/components/search/SyncStatusPanel.tsx
··· 76 76 <div class="grid gap-1"> 77 77 <div class="flex items-center gap-2"> 78 78 <p class="m-0 text-sm font-medium text-on-surface">Sync Status</p> 79 - <span class={`rounded-full px-2.5 py-1 text-[0.68rem] font-medium uppercase tracking-[0.12em] ${props.tone.className}`}> 79 + <span 80 + class={`rounded-full px-2.5 py-1 text-[0.68rem] font-medium uppercase tracking-[0.12em] ${props.tone.className}`}> 80 81 {props.tone.label} 81 82 </span> 82 83 </div> ··· 99 100 ); 100 101 } 101 102 102 - function SyncActions(props: { 103 - hasAnyPosts: boolean; 104 - isReindexing: boolean; 105 - isSyncing: boolean; 106 - onReindex: () => void; 107 - onSync: () => void; 108 - }) { 103 + function SyncActions( 104 + props: { hasAnyPosts: boolean; isReindexing: boolean; isSyncing: boolean; onReindex: () => void; onSync: () => void }, 105 + ) { 109 106 return ( 110 107 <div class="flex flex-wrap items-center gap-2"> 111 108 <Show when={props.hasAnyPosts}>
+1 -1
src/components/settings/SettingsPanel.tsx
··· 19 19 import { Icon } from "../shared/Icon"; 20 20 import { SettingsAbout } from "./SettingsAbout"; 21 21 import { AccountControl } from "./SettingsAccount"; 22 - import { SettingsData } from "./SettingsData"; 23 22 import { SettingsDangerZone } from "./SettingsDangerZone"; 23 + import { SettingsData } from "./SettingsData"; 24 24 import { SettingsLogs } from "./SettingsLogs"; 25 25 import { NotificationsControl } from "./SettingsNotification"; 26 26 import { SettingsService } from "./SettingsService";
+12 -1
src/lib/api/diagnostics.ts
··· 1 + import type { ProfileUnavailableReason } from "$/lib/types"; 1 2 import { invoke } from "@tauri-apps/api/core"; 2 3 3 4 type TProfile = { did?: string | null; handle?: string | null; displayName?: string | null; avatar?: string | null }; 5 + type TAvailability = "available" | "unavailable"; 4 6 5 7 export type DiagnosticList = { 6 8 avatar?: string | null; ··· 24 26 sig?: string | null; 25 27 }; 26 28 27 - export type DiagnosticDidProfile = { did: string; profile?: (TProfile & { description?: string | null }) | null }; 29 + export type DiagnosticDidProfile = { 30 + availability: TAvailability; 31 + did: string; 32 + profile?: (TProfile & { description?: string | null }) | null; 33 + unavailableReason?: ProfileUnavailableReason | null; 34 + unavailableMessage?: string | null; 35 + }; 28 36 29 37 export type DiagnosticBlockItem = { 38 + availability: TAvailability; 30 39 cid?: string | null; 31 40 createdAt?: string | null; 32 41 profile?: (TProfile & { description?: string | null }) | null; 33 42 subjectDid?: string | null; 43 + unavailableReason?: ProfileUnavailableReason | null; 44 + unavailableMessage?: string | null; 34 45 uri?: string | null; 35 46 value?: Record<string, unknown> | null; 36 47 };
+4 -4
src/lib/api/profile.ts
··· 1 - import { parseActorList, parseProfile, parseProfileFeed } from "$/lib/profile"; 2 - import type { CreateRecordResult } from "$/lib/types"; 1 + import { parseActorList, parseProfileFeed, parseProfileResult } from "$/lib/profile"; 2 + import type { CreateRecordResult, ProfileLookupResult } from "$/lib/types"; 3 3 import { invoke } from "@tauri-apps/api/core"; 4 4 5 - export async function getProfile(actor: string) { 6 - return parseProfile(await invoke("get_profile", { actor })); 5 + export async function getProfile(actor: string): Promise<ProfileLookupResult> { 6 + return parseProfileResult(await invoke("get_profile", { actor })); 7 7 } 8 8 9 9 export async function getAuthorFeed(actor: string, cursor?: string | null, limit?: number) {
+46 -1
src/lib/profile.test.ts
··· 1 1 import { describe, expect, it } from "vitest"; 2 - import { filterProfileFeed, parseActorList } from "./profile"; 2 + import { filterProfileFeed, parseActorList, parseProfileResult } from "./profile"; 3 3 import type { FeedViewPost } from "./types"; 4 4 5 5 function createFeedItem(overrides: Partial<FeedViewPost> = {}): FeedViewPost { ··· 63 63 expect(filterProfileFeed([base, reply, media], "replies")).toEqual([reply]); 64 64 expect(filterProfileFeed([base, reply, media], "media")).toEqual([media]); 65 65 expect(filterProfileFeed([base, reply, media], "likes")).toEqual([base, reply, media]); 66 + }); 67 + 68 + it("parses available and unavailable profile results", () => { 69 + expect( 70 + parseProfileResult({ 71 + status: "available", 72 + profile: { did: "did:plc:bob", handle: "bob.test", displayName: "Bob" }, 73 + }), 74 + ).toEqual({ 75 + status: "available", 76 + profile: { 77 + avatar: null, 78 + banner: null, 79 + createdAt: null, 80 + description: null, 81 + did: "did:plc:bob", 82 + displayName: "Bob", 83 + followersCount: null, 84 + followsCount: null, 85 + handle: "bob.test", 86 + indexedAt: null, 87 + pinnedPost: null, 88 + postsCount: null, 89 + pronouns: null, 90 + viewer: null, 91 + website: null, 92 + }, 93 + }); 94 + 95 + expect( 96 + parseProfileResult({ 97 + status: "unavailable", 98 + requestedActor: "missing.test", 99 + handle: "missing.test", 100 + reason: "notFound", 101 + message: "This profile could not be found.", 102 + }), 103 + ).toEqual({ 104 + status: "unavailable", 105 + requestedActor: "missing.test", 106 + did: null, 107 + handle: "missing.test", 108 + reason: "notFound", 109 + message: "This profile could not be found.", 110 + }); 66 111 }); 67 112 });
+42 -1
src/lib/profile.ts
··· 1 1 import { isReplyItem, parseFeedResponse } from "$/lib/feeds"; 2 - import type { ActorListResponse, FeedResponse, FeedViewPost, ProfileViewBasic, ProfileViewDetailed } from "$/lib/types"; 2 + import type { 3 + ActorListResponse, 4 + FeedResponse, 5 + FeedViewPost, 6 + ProfileLookupResult, 7 + ProfileUnavailableReason, 8 + ProfileViewBasic, 9 + ProfileViewDetailed, 10 + } from "$/lib/types"; 3 11 import { asArray, asRecord, optionalNumber, optionalString } from "./type-guards"; 4 12 5 13 export type ProfileTab = "posts" | "replies" | "media" | "likes" | "context"; ··· 58 66 }; 59 67 } 60 68 69 + export function parseProfileResult(value: unknown): ProfileLookupResult { 70 + const record = asRecord(value); 71 + if (!record || record.status === "available" && !asRecord(record.profile)) { 72 + throw new Error("profile result payload is invalid"); 73 + } 74 + 75 + if (record.status === "available") { 76 + return { status: "available", profile: parseProfile(record.profile) }; 77 + } 78 + 79 + if ( 80 + record.status !== "unavailable" 81 + || typeof record.requestedActor !== "string" 82 + || typeof record.message !== "string" 83 + || !isProfileUnavailableReason(record.reason) 84 + ) { 85 + throw new Error("profile result payload is invalid"); 86 + } 87 + 88 + return { 89 + status: "unavailable", 90 + requestedActor: record.requestedActor, 91 + did: optionalString(record.did), 92 + handle: optionalString(record.handle), 93 + reason: record.reason, 94 + message: record.message, 95 + }; 96 + } 97 + 61 98 export function parseProfileFeed(value: unknown): FeedResponse { 62 99 return parseFeedResponse(value); 63 100 } ··· 123 160 muted: typeof record.muted === "boolean" ? record.muted : null, 124 161 }; 125 162 } 163 + 164 + function isProfileUnavailableReason(value: unknown): value is ProfileUnavailableReason { 165 + return value === "notFound" || value === "suspended" || value === "deactivated" || value === "unavailable"; 166 + }
+15
src/lib/types.ts
··· 45 45 muted?: boolean | null; 46 46 }; 47 47 48 + export type ProfileUnavailableReason = "notFound" | "suspended" | "deactivated" | "unavailable"; 49 + 48 50 export type ProfileViewDetailed = ProfileViewBasic & { 49 51 banner?: string | null; 50 52 createdAt?: string | null; ··· 58 60 viewer?: ProfileViewerState | null; 59 61 website?: string | null; 60 62 }; 63 + 64 + export type ProfileLookupAvailable = { status: "available"; profile: ProfileViewDetailed }; 65 + 66 + export type ProfileLookupUnavailable = { 67 + status: "unavailable"; 68 + requestedActor: string; 69 + did?: string | null; 70 + handle?: string | null; 71 + reason: ProfileUnavailableReason; 72 + message: string; 73 + }; 74 + 75 + export type ProfileLookupResult = ProfileLookupAvailable | ProfileLookupUnavailable; 61 76 62 77 export type ActorListResponse = { cursor?: string | null; actors: ProfileViewBasic[] }; 63 78
+12
src/lib/utils/text.ts
··· 86 86 87 87 return parsed.toLocaleDateString(undefined, { month: "long", year: "numeric" }); 88 88 } 89 + 90 + export function initials(name: string) { 91 + return name.trim().slice(0, 1).toUpperCase() || "?"; 92 + } 93 + 94 + export function formatHandle(handle: string | null | undefined, did: string | null | undefined) { 95 + if (!handle) { 96 + return did ?? "Unknown"; 97 + } 98 + 99 + return handle.startsWith("did:") || handle.startsWith("@") ? handle : `@${handle}`; 100 + }
+1 -4
src/router.test.tsx
··· 17 17 "$/components/search/HashtagPanel", 18 18 () => ({ HashtagPanel: () => <div data-testid="hashtag-view">hashtag</div> }), 19 19 ); 20 - vi.mock( 21 - "$/components/search/SearchPanel", 22 - () => ({ SearchPanel: () => <div data-testid="search-view">search</div> }), 23 - ); 20 + vi.mock("$/components/search/SearchPanel", () => ({ SearchPanel: () => <div data-testid="search-view">search</div> })); 24 21 vi.mock( 25 22 "$/components/search/SearchPreflightPanel", 26 23 () => ({ SearchPreflightPanel: () => <div data-testid="search-preflight-view">preflight</div> }),
+6 -2
src/router.tsx
··· 9 9 import { ExplorerPanel } from "./components/explorer/ExplorerPanel"; 10 10 import { SavedPostsPanel } from "./components/saved/SavedPostsPanel"; 11 11 import { HashtagPanel } from "./components/search/HashtagPanel"; 12 - import { SearchPreflightPanel } from "./components/search/SearchPreflightPanel"; 13 12 import { SearchPanel } from "./components/search/SearchPanel"; 13 + import { SearchPreflightPanel } from "./components/search/SearchPreflightPanel"; 14 14 import { SettingsPanel } from "./components/settings/SettingsPanel"; 15 15 import { decodeMessagesRouteMemberDid } from "./lib/conversations"; 16 16 import { TIMELINE_ROUTE } from "./lib/feeds"; ··· 70 70 71 71 const TimelineRoute = () => <ProtectedRouteView>{props.renderTimeline()}</ProtectedRouteView>; 72 72 73 - const SearchRoute = () => <ProtectedRouteView><SearchRouteGate /></ProtectedRouteView>; 73 + const SearchRoute = () => ( 74 + <ProtectedRouteView> 75 + <SearchRouteGate /> 76 + </ProtectedRouteView> 77 + ); 74 78 75 79 const SearchPreflightRoute = () => ( 76 80 <ProtectedRouteView>