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: moderation labels

+1206 -204
+4
CHANGELOG.md
··· 2 2 3 3 ## v0.1.0 - Unreleased 4 4 5 + ### 2025-04-12 6 + 7 + - Added labels to posts & profiles 8 + 5 9 ### 2026-04-08 6 10 7 11 - Forward/Back history navigation in the app rail/navigation, and thread drawer
+119 -36
src-tauri/src/feed.rs
··· 18 18 use jacquard::api::app_bsky::feed::get_actor_likes::GetActorLikes; 19 19 use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; 20 20 use jacquard::api::app_bsky::feed::get_feed::GetFeed; 21 + use jacquard::api::app_bsky::feed::get_feed_generator::GetFeedGenerator; 21 22 use jacquard::api::app_bsky::feed::get_feed_generators::GetFeedGenerators; 22 23 use jacquard::api::app_bsky::feed::get_list_feed::GetListFeed; 23 24 use jacquard::api::app_bsky::feed::get_post_thread::GetPostThread; ··· 25 26 use jacquard::api::app_bsky::feed::like::Like; 26 27 use jacquard::api::app_bsky::feed::post::{Post, PostEmbed, ReplyRef}; 27 28 use jacquard::api::app_bsky::feed::repost::Repost; 29 + use jacquard::api::app_bsky::feed::GeneratorView; 28 30 use jacquard::api::app_bsky::graph::block::Block; 29 31 use jacquard::api::app_bsky::graph::follow::Follow; 30 32 use jacquard::api::app_bsky::graph::get_followers::GetFollowers; ··· 60 62 use tokio::sync::Semaphore; 61 63 use tokio::task::JoinSet; 62 64 use tokio::time::sleep; 65 + 66 + const FEED_GENERATOR_BATCH_SIZE: usize = 10; 67 + 68 + const FOLLOW_COLLECTION_NSID: &str = "app.bsky.graph.follow"; 69 + const FOLLOW_HYGIENE_PROGRESS_EVENT: &str = "follow-hygiene:progress"; 70 + const FOLLOW_AUDIT_PAGE_LIMIT: i64 = 100; 71 + const FOLLOW_AUDIT_PROFILE_BATCH_SIZE: usize = 25; 72 + const FOLLOW_AUDIT_PROFILE_BATCH_CONCURRENCY: usize = 3; 73 + const FOLLOW_AUDIT_INTER_BATCH_DELAY: Duration = Duration::from_millis(250); 74 + const FOLLOW_AUDIT_RETRY_AFTER_DEFAULT: Duration = Duration::from_secs(2); 75 + const FOLLOW_AUDIT_MAX_RATE_LIMIT_RETRIES: usize = 5; 76 + const FOLLOW_UNFOLLOW_WRITE_CHUNK_SIZE: usize = 200; 77 + 78 + const FOLLOW_STATUS_DELETED: u8 = 1 << 0; 79 + const FOLLOW_STATUS_DEACTIVATED: u8 = 1 << 1; 80 + const FOLLOW_STATUS_SUSPENDED: u8 = 1 << 2; 81 + const FOLLOW_STATUS_BLOCKED_BY: u8 = 1 << 3; 82 + const FOLLOW_STATUS_BLOCKING: u8 = 1 << 4; 83 + const FOLLOW_STATUS_HIDDEN: u8 = 1 << 5; 84 + const FOLLOW_STATUS_SELF_FOLLOW: u8 = 1 << 6; 63 85 64 86 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 65 87 #[serde(tag = "status", rename_all = "camelCase")] ··· 339 361 pub cid: String, 340 362 } 341 363 342 - const FOLLOW_COLLECTION_NSID: &str = "app.bsky.graph.follow"; 343 - const FOLLOW_HYGIENE_PROGRESS_EVENT: &str = "follow-hygiene:progress"; 344 - const FOLLOW_AUDIT_PAGE_LIMIT: i64 = 100; 345 - const FOLLOW_AUDIT_PROFILE_BATCH_SIZE: usize = 25; 346 - const FOLLOW_AUDIT_PROFILE_BATCH_CONCURRENCY: usize = 3; 347 - const FOLLOW_AUDIT_INTER_BATCH_DELAY: Duration = Duration::from_millis(250); 348 - const FOLLOW_AUDIT_RETRY_AFTER_DEFAULT: Duration = Duration::from_secs(2); 349 - const FOLLOW_AUDIT_MAX_RATE_LIMIT_RETRIES: usize = 5; 350 - const FOLLOW_UNFOLLOW_WRITE_CHUNK_SIZE: usize = 200; 351 - 352 - const FOLLOW_STATUS_DELETED: u8 = 1 << 0; 353 - const FOLLOW_STATUS_DEACTIVATED: u8 = 1 << 1; 354 - const FOLLOW_STATUS_SUSPENDED: u8 = 1 << 2; 355 - const FOLLOW_STATUS_BLOCKED_BY: u8 = 1 << 3; 356 - const FOLLOW_STATUS_BLOCKING: u8 = 1 << 4; 357 - const FOLLOW_STATUS_HIDDEN: u8 = 1 << 5; 358 - const FOLLOW_STATUS_SELF_FOLLOW: u8 = 1 << 6; 359 - 360 364 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 361 365 #[serde(rename_all = "camelCase")] 362 366 pub struct FlaggedFollow { ··· 411 415 } 412 416 413 417 let session = get_session(state).await?; 414 - let parsed: std::result::Result<Vec<AtUri<'_>>, _> = uris.iter().map(|u| AtUri::new(u)).collect(); 415 - let feeds = parsed.map_err(|error| { 416 - log::warn!("invalid feed URI in get_feed_generators input: {:?}", error); 417 - AppError::validation("invalid feed URI") 418 - })?; 418 + let feeds = uris 419 + .iter() 420 + .filter_map(|uri| { 421 + AtUri::new(uri) 422 + .map(IntoStatic::into_static) 423 + .map_err(|error| { 424 + log::warn!("skipping invalid feed URI in get_feed_generators input ({uri}): {error}"); 425 + error 426 + }) 427 + .ok() 428 + }) 429 + .collect::<Vec<AtUri<'static>>>(); 430 + 431 + if feeds.is_empty() { 432 + return Ok(serde_json::json!({ "feeds": [] })); 433 + } 434 + 435 + let mut collected = Vec::<GeneratorView<'static>>::new(); 436 + let mut seen = HashSet::<String>::new(); 437 + 438 + for chunk in feeds.chunks(FEED_GENERATOR_BATCH_SIZE) { 439 + let request = GetFeedGenerators::new().feeds(chunk.to_vec()).build(); 440 + match session.send(request).await { 441 + Ok(response) => match response.into_output() { 442 + Ok(output) => { 443 + for feed in output.feeds.into_iter().map(IntoStatic::into_static) { 444 + let uri = feed.uri.as_ref().to_string(); 445 + if seen.insert(uri) { 446 + collected.push(feed); 447 + } 448 + } 449 + } 450 + Err(error) => { 451 + log::info!( 452 + "getFeedGenerators decode failed for batch ({} feeds); trying per-feed fallback: {error}", 453 + chunk.len() 454 + ); 455 + let recovered = 456 + fetch_feed_generators_individually(chunk, session.as_ref(), &mut collected, &mut seen).await; 457 + if recovered == 0 { 458 + log::warn!( 459 + "getFeedGenerators fallback failed for batch ({} feeds); no generator metadata recovered", 460 + chunk.len() 461 + ); 462 + } 463 + } 464 + }, 465 + Err(error) => { 466 + log::info!( 467 + "getFeedGenerators request failed for batch ({} feeds); trying per-feed fallback: {error}", 468 + chunk.len() 469 + ); 470 + let recovered = 471 + fetch_feed_generators_individually(chunk, session.as_ref(), &mut collected, &mut seen).await; 472 + if recovered == 0 { 473 + log::warn!( 474 + "getFeedGenerators fallback request failed for batch ({} feeds); no generator metadata recovered", 475 + chunk.len() 476 + ); 477 + } 478 + } 479 + } 480 + } 419 481 420 - let output = session 421 - .send(GetFeedGenerators::new().feeds(feeds).build()) 422 - .await 423 - .map_err(|error| { 424 - log::error!("getFeedGenerators error: {error}"); 425 - AppError::validation("getFeedGenerators error") 426 - })? 427 - .into_output() 428 - .map_err(|error| { 429 - log::error!("getFeedGenerators output error: {error}"); 430 - AppError::validation("getFeedGenerators output error") 431 - })?; 482 + Ok(serde_json::json!({ "feeds": collected })) 483 + } 432 484 433 - serde_json::to_value(&output).map_err(AppError::from) 485 + async fn fetch_feed_generators_individually( 486 + feeds: &[AtUri<'static>], session: &LazuriteOAuthSession, out: &mut Vec<GeneratorView<'static>>, 487 + seen: &mut HashSet<String>, 488 + ) -> usize { 489 + let mut recovered = 0; 490 + for feed in feeds { 491 + let request = GetFeedGenerator::new().feed(feed.clone()).build(); 492 + let response = match session.send(request).await { 493 + Ok(response) => response, 494 + Err(error) => { 495 + log::debug!("getFeedGenerator request failed for {}: {error}", feed.as_ref()); 496 + continue; 497 + } 498 + }; 499 + 500 + let output = match response.into_output() { 501 + Ok(output) => output, 502 + Err(error) => { 503 + log::debug!("getFeedGenerator decode failed for {}: {error}", feed.as_ref()); 504 + continue; 505 + } 506 + }; 507 + 508 + let view = output.view.into_static(); 509 + let uri = view.uri.as_ref().to_string(); 510 + if seen.insert(uri) { 511 + out.push(view); 512 + recovered += 1; 513 + } 514 + } 515 + 516 + recovered 434 517 } 435 518 436 519 pub async fn get_timeline(cursor: Option<String>, limit: u32, state: &AppState) -> Result<serde_json::Value> {
+272 -2
src-tauri/src/moderation.rs
··· 1 1 use super::auth::LazuriteOAuthSession; 2 2 use super::error::{AppError, Result}; 3 3 use super::state::AppState; 4 + use jacquard::api::app_bsky::actor::get_preferences::GetPreferences; 5 + use jacquard::api::app_bsky::actor::PreferencesItem; 4 6 use jacquard::api::app_bsky::labeler::get_services::GetServices; 5 7 use jacquard::api::app_bsky::labeler::get_services::GetServicesOutputViewsItem; 6 8 use jacquard::api::com_atproto::admin::RepoRef; ··· 31 33 /// Maximum number of user-subscribed labelers (Bluesky limit). 32 34 pub const MAX_CUSTOM_LABELERS: usize = 20; 33 35 36 + /// Synthetic key for globally-scoped content label preferences. 37 + const GLOBAL_LABEL_PREFS_KEY: &str = "__global__"; 38 + 34 39 /// User's moderation preferences, persisted as JSON in `app_settings`. 35 40 /// 36 41 /// Key in the table: `moderation_preferences::{did}` ··· 50 55 /// The UI action the frontend should apply to a piece of content. 51 56 #[derive(Debug, Clone, Serialize)] 52 57 #[serde(rename_all = "camelCase")] 58 + pub struct ModerationBadge { 59 + pub label: String, 60 + pub source: String, 61 + pub description: Option<String>, 62 + /// "alert" | "inform" | "label" 63 + pub tone: String, 64 + } 65 + 66 + /// The UI action the frontend should apply to a piece of content. 67 + #[derive(Debug, Clone, Serialize)] 68 + #[serde(rename_all = "camelCase")] 53 69 pub struct ModerationUI { 54 70 /// Hide content completely. 55 71 pub filter: bool, ··· 61 77 pub inform: bool, 62 78 /// User cannot override the decision (e.g. legal takedown). 63 79 pub no_override: bool, 80 + /// Optional resolved moderation badges with display labels (including custom labels). 81 + pub badges: Vec<ModerationBadge>, 64 82 } 65 83 66 84 impl From<ModerationDecision> for ModerationUI { ··· 75 93 alert: d.alert, 76 94 inform: d.inform, 77 95 no_override: d.no_override, 96 + badges: Vec::new(), 78 97 } 79 98 } 80 99 } ··· 435 454 (Some(fallback.name.clone()), Some(fallback.description.clone())) 436 455 } 437 456 457 + fn preferred_label_definition_strings(def: &LabelValueDefinition<'_>) -> (Option<String>, Option<String>) { 458 + if def.locales.is_empty() { 459 + return (None, None); 460 + } 461 + 462 + if let Some(en) = def 463 + .locales 464 + .iter() 465 + .find(|locale| locale.lang.as_ref().eq_ignore_ascii_case("en")) 466 + { 467 + return ( 468 + Some(en.name.as_ref().to_string()), 469 + Some(en.description.as_ref().to_string()), 470 + ); 471 + } 472 + 473 + if let Some(en_region) = def 474 + .locales 475 + .iter() 476 + .find(|locale| locale.lang.as_ref().to_ascii_lowercase().starts_with("en-")) 477 + { 478 + return ( 479 + Some(en_region.name.as_ref().to_string()), 480 + Some(en_region.description.as_ref().to_string()), 481 + ); 482 + } 483 + 484 + let fallback = &def.locales[0]; 485 + ( 486 + Some(fallback.name.as_ref().to_string()), 487 + Some(fallback.description.as_ref().to_string()), 488 + ) 489 + } 490 + 491 + fn humanize_label(value: &str) -> String { 492 + let cleaned = value.replace('!', "").replace('-', " ").trim().to_string(); 493 + if cleaned.is_empty() { 494 + return "Sensitive content".to_string(); 495 + } 496 + 497 + cleaned 498 + .split_whitespace() 499 + .map(|word| { 500 + let mut chars = word.chars(); 501 + match chars.next() { 502 + Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(), 503 + None => String::new(), 504 + } 505 + }) 506 + .collect::<Vec<String>>() 507 + .join(" ") 508 + } 509 + 510 + fn moderation_badges_from_decision(decision: &ModerationDecision, defs: &LabelerDefs<'_>) -> Vec<ModerationBadge> { 511 + if decision.causes.is_empty() { 512 + return Vec::new(); 513 + } 514 + 515 + let tone = if decision.alert { 516 + "alert".to_string() 517 + } else if decision.inform { 518 + "inform".to_string() 519 + } else { 520 + "label".to_string() 521 + }; 522 + 523 + let mut badges = Vec::new(); 524 + let mut seen = std::collections::HashSet::new(); 525 + 526 + for cause in &decision.causes { 527 + let source_did = cause.source.as_ref().to_string(); 528 + let label_identifier = cause.label.as_ref(); 529 + 530 + let (display_name, description) = defs 531 + .find_def(&cause.source, label_identifier) 532 + .map(preferred_label_definition_strings) 533 + .unwrap_or_else(|| (None, None)); 534 + 535 + let label = display_name.unwrap_or_else(|| humanize_label(label_identifier)); 536 + let source = if source_did == BUILTIN_LABELER_DID { "Bluesky".to_string() } else { source_did.clone() }; 537 + 538 + let key = format!("{tone}|{source}|{label}"); 539 + if !seen.insert(key) { 540 + continue; 541 + } 542 + 543 + badges.push(ModerationBadge { label, source, description, tone: tone.clone() }); 544 + } 545 + 546 + badges 547 + } 548 + 438 549 fn normalize_label_definition(def: &LabelValueDefinition<'_>) -> ModerationLabelPolicyDefinition { 439 550 let mut locales = def 440 551 .locales ··· 652 763 653 764 /// Convert stored prefs to the jacquard `ModerationPrefs` type. 654 765 fn to_jacquard_prefs(prefs: &StoredModerationPrefs) -> ModerationPrefs<'static> { 766 + let global_labels = prefs 767 + .label_preferences 768 + .get(GLOBAL_LABEL_PREFS_KEY) 769 + .map(|label_map| { 770 + label_map 771 + .iter() 772 + .map(|(label, vis)| (CowStr::from(label.clone()), parse_label_pref(vis))) 773 + .collect::<HashMap<CowStr<'static>, LabelPref>>() 774 + }) 775 + .unwrap_or_default(); 776 + 655 777 let labelers = prefs 656 778 .label_preferences 657 779 .iter() 780 + .filter(|(did_str, _)| did_str.as_str() != GLOBAL_LABEL_PREFS_KEY) 658 781 .filter_map(|(did_str, label_map)| { 659 782 let did = Did::new(did_str).ok()?.into_static(); 660 783 let pref_map = label_map ··· 668 791 }) 669 792 .collect(); 670 793 671 - ModerationPrefs { adult_content_enabled: prefs.adult_content_enabled, labels: HashMap::new(), labelers } 794 + ModerationPrefs { adult_content_enabled: prefs.adult_content_enabled, labels: global_labels, labelers } 672 795 } 673 796 674 797 fn parse_label_pref(s: &str) -> LabelPref { 675 798 match s { 676 799 "hide" => LabelPref::Hide, 677 800 "warn" => LabelPref::Warn, 801 + "show" => LabelPref::Ignore, 678 802 _ => LabelPref::Ignore, 679 803 } 680 804 } ··· 701 825 702 826 let record = LabeledRecord { record: (), labels }; 703 827 let decision = moderate(&record, &jacquard_prefs, defs, &accepted_labelers); 704 - Ok(ModerationUI::from(decision)) 828 + let mut ui = ModerationUI::from(decision.clone()); 829 + ui.badges = moderation_badges_from_decision(&decision, defs); 830 + Ok(ui) 831 + } 832 + 833 + fn visibility_for_storage(value: &str) -> String { 834 + match value { 835 + "hide" => "hide".to_string(), 836 + "warn" => "warn".to_string(), 837 + "show" | "ignore" => "ignore".to_string(), 838 + _ => "ignore".to_string(), 839 + } 840 + } 841 + 842 + fn stored_prefs_from_actor_preferences(items: &[PreferencesItem<'_>]) -> StoredModerationPrefs { 843 + let mut adult_content_enabled = false; 844 + let mut subscribed_labelers: Vec<String> = Vec::new(); 845 + let mut label_preferences: HashMap<String, HashMap<String, String>> = HashMap::new(); 846 + 847 + for item in items { 848 + match item { 849 + PreferencesItem::AdultContentPref(pref) => { 850 + adult_content_enabled = pref.enabled; 851 + } 852 + PreferencesItem::LabelersPref(pref) => { 853 + subscribed_labelers = pref 854 + .labelers 855 + .iter() 856 + .map(|entry| entry.did.as_ref().to_string()) 857 + .filter(|did| did.starts_with("did:") && did != BUILTIN_LABELER_DID) 858 + .collect(); 859 + } 860 + PreferencesItem::ContentLabelPref(pref) => { 861 + let key = pref 862 + .labeler_did 863 + .as_ref() 864 + .map(|did| did.as_ref().to_string()) 865 + .unwrap_or_else(|| GLOBAL_LABEL_PREFS_KEY.to_string()); 866 + 867 + label_preferences.entry(key).or_default().insert( 868 + pref.label.as_ref().to_string(), 869 + visibility_for_storage(pref.visibility.as_ref()), 870 + ); 871 + } 872 + _ => {} 873 + } 874 + } 875 + 876 + subscribed_labelers.sort(); 877 + subscribed_labelers.dedup(); 878 + 879 + StoredModerationPrefs { adult_content_enabled, subscribed_labelers, label_preferences } 880 + } 881 + 882 + /// Pull moderation preferences from the network and persist them locally. 883 + pub async fn sync_prefs_from_network( 884 + state: &AppState, did: &str, session: &LazuriteOAuthSession, 885 + ) -> Result<StoredModerationPrefs> { 886 + let output = session 887 + .send(GetPreferences) 888 + .await 889 + .map_err(|error| { 890 + log::warn!("failed to fetch moderation preferences from network: {error}"); 891 + AppError::validation("could not refresh moderation preferences") 892 + })? 893 + .into_output() 894 + .map_err(|error| { 895 + log::warn!("failed to decode moderation preferences response: {error}"); 896 + AppError::validation("could not refresh moderation preferences") 897 + })?; 898 + 899 + let prefs = stored_prefs_from_actor_preferences(&output.preferences); 900 + let conn = state.auth_store.lock_connection()?; 901 + save_prefs(&conn, did, &prefs)?; 902 + Ok(prefs) 705 903 } 706 904 707 905 /// Load moderation preferences for the currently active account. ··· 1020 1218 assert_eq!(ui.blur, "none"); 1021 1219 assert!(!ui.alert); 1022 1220 assert!(!ui.inform); 1221 + } 1222 + 1223 + #[test] 1224 + fn parse_label_pref_treats_show_as_ignore() { 1225 + assert!(matches!(parse_label_pref("show"), LabelPref::Ignore)); 1226 + } 1227 + 1228 + #[test] 1229 + fn to_jacquard_prefs_includes_global_labels() { 1230 + let mut prefs = StoredModerationPrefs::default(); 1231 + prefs 1232 + .label_preferences 1233 + .entry(GLOBAL_LABEL_PREFS_KEY.to_string()) 1234 + .or_default() 1235 + .insert("custom-label".into(), "warn".into()); 1236 + 1237 + let moderation_prefs = to_jacquard_prefs(&prefs); 1238 + let visibility = moderation_prefs 1239 + .labels 1240 + .get(&CowStr::from("custom-label")) 1241 + .expect("global label should be present"); 1242 + assert!(matches!(visibility, LabelPref::Warn)); 1243 + assert!( 1244 + moderation_prefs.labelers.is_empty(), 1245 + "global labels should not create labeler-pref entries" 1246 + ); 1247 + } 1248 + 1249 + #[test] 1250 + fn stored_prefs_from_actor_preferences_maps_global_and_labeler_items() { 1251 + let items = serde_json::from_str::<Vec<PreferencesItem<'_>>>( 1252 + r#"[ 1253 + { 1254 + "$type":"app.bsky.actor.defs#adultContentPref", 1255 + "enabled":true 1256 + }, 1257 + { 1258 + "$type":"app.bsky.actor.defs#labelersPref", 1259 + "labelers":[ 1260 + { "did":"did:plc:ar7c4by46qjdydhdevvrndac" }, 1261 + { "did":"did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" } 1262 + ] 1263 + }, 1264 + { 1265 + "$type":"app.bsky.actor.defs#contentLabelPref", 1266 + "label":"custom-label", 1267 + "visibility":"show" 1268 + }, 1269 + { 1270 + "$type":"app.bsky.actor.defs#contentLabelPref", 1271 + "label":"toxicity", 1272 + "labelerDid":"did:plc:aaaaaaaaaaaaaaaaaaaaaaaa", 1273 + "visibility":"hide" 1274 + } 1275 + ]"#, 1276 + ) 1277 + .expect("preferences should deserialize"); 1278 + 1279 + let stored = stored_prefs_from_actor_preferences(&items); 1280 + assert!(stored.adult_content_enabled); 1281 + assert_eq!( 1282 + stored.subscribed_labelers, 1283 + vec!["did:plc:aaaaaaaaaaaaaaaaaaaaaaaa".to_string()] 1284 + ); 1285 + assert_eq!( 1286 + stored.label_preferences[GLOBAL_LABEL_PREFS_KEY]["custom-label"], 1287 + "ignore" 1288 + ); 1289 + assert_eq!( 1290 + stored.label_preferences["did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"]["toxicity"], 1291 + "hide" 1292 + ); 1023 1293 } 1024 1294 1025 1295 #[test]
+14 -4
src-tauri/src/state.rs
··· 17 17 use tauri::{AppHandle, Emitter, Manager}; 18 18 use tauri_plugin_log::log; 19 19 20 + const INITIAL_DELAY: Duration = Duration::from_secs(30); 21 + const REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60); 22 + 20 23 #[derive(Clone, Debug, Serialize)] 21 24 #[serde(rename_all = "camelCase")] 22 25 pub struct ActiveSession { ··· 54 57 log::info!("bootstrapping application state"); 55 58 let auth_store = PersistentAuthStore::new(db_pool.clone()); 56 59 auth_store.prune_orphaned_sessions()?; 60 + 57 61 let oauth_client = build_oauth_client(auth_store.clone()); 58 62 let accounts = auth_store.load_accounts()?; 59 63 log::info!("loaded {} stored account(s)", accounts.len()); ··· 212 216 } 213 217 214 218 async fn apply_moderation_headers_for_did(&self, did: &str, session: &LazuriteOAuthSession) { 215 - let prefs = match self.auth_store.lock_connection() { 219 + let mut prefs = match self.auth_store.lock_connection() { 216 220 Ok(conn) => match moderation::load_prefs(&conn, did) { 217 221 Ok(prefs) => prefs, 218 222 Err(error) => { ··· 226 230 } 227 231 }; 228 232 233 + match moderation::sync_prefs_from_network(self, did, session).await { 234 + Ok(network_prefs) => { 235 + prefs = network_prefs; 236 + } 237 + Err(error) => { 238 + log::warn!("failed to sync moderation preferences from network for {did}; using local prefs: {error}"); 239 + } 240 + } 241 + 229 242 moderation::apply_labeler_headers(session, &prefs).await; 230 243 } 231 244 ··· 439 452 /// 440 453 /// Adds a short initial delay to quickly retry if bootstrap restore failed 441 454 pub fn spawn_token_refresh_task(app: AppHandle) { 442 - const INITIAL_DELAY: Duration = Duration::from_secs(30); 443 - const REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60); 444 - 445 455 tauri::async_runtime::spawn(async move { 446 456 tokio::time::sleep(INITIAL_DELAY).await; 447 457
+78 -51
src/components/deck/DiagnosticsPanel.tsx
··· 1 1 import { RecordBacklinksPanel } from "$/components/diagnostics/RecordBacklinksPanel"; 2 + import { useModerationDecision } from "$/components/moderation/hooks/useModerationDecision"; 3 + import { ModeratedAvatar } from "$/components/moderation/ModeratedAvatar"; 4 + import { ModerationBadgeRow } from "$/components/moderation/ModerationBadgeRow"; 2 5 import { Icon } from "$/components/shared/Icon"; 3 6 import { useAppSession } from "$/contexts/app-session"; 4 - import { 5 - type DiagnosticBlockItem, 6 - type DiagnosticDidProfile, 7 - type DiagnosticLabel, 8 - type DiagnosticList, 9 - type DiagnosticStarterPack, 10 - getAccountBlockedBy, 11 - getAccountBlocking, 12 - getAccountLabels, 13 - getAccountLists, 14 - getAccountStarterPacks, 7 + import type { 8 + DiagnosticBlockItem, 9 + DiagnosticDidProfile, 10 + DiagnosticLabel, 11 + DiagnosticList, 12 + DiagnosticStarterPack, 15 13 } from "$/lib/api/diagnostics"; 14 + import { DiagnosticsController } from "$/lib/api/diagnostics"; 15 + import { collectModerationLabels } from "$/lib/moderation"; 16 16 import { asRecord, getStringProperty } from "$/lib/type-guards"; 17 17 import { shouldIgnoreKey } from "$/lib/utils/events"; 18 18 import { formatHandle, initials, normalizeError } from "$/lib/utils/text"; ··· 278 278 279 279 async function loadLists(currentRequest: number, did: string) { 280 280 try { 281 - const response = await getAccountLists(did); 281 + const response = await DiagnosticsController.getAccountLists(did); 282 282 if (currentRequest !== requestId) return; 283 283 setState({ lists: response.lists, listsError: null, listsLoading: false }); 284 284 } catch (error) { ··· 291 291 292 292 async function loadLabels(currentRequest: number, did: string) { 293 293 try { 294 - const response = await getAccountLabels(did); 294 + const response = await DiagnosticsController.getAccountLabels(did); 295 295 if (currentRequest !== requestId) return; 296 296 setState({ 297 297 labels: response.labels, ··· 308 308 } 309 309 310 310 async function loadBlocks(currentRequest: number, did: string) { 311 - const [blockedBy, blocking] = await Promise.allSettled([getAccountBlockedBy(did, 25), getAccountBlocking(did)]); 311 + const [blockedBy, blocking] = await Promise.allSettled([ 312 + DiagnosticsController.getAccountBlockedBy(did, 25), 313 + DiagnosticsController.getAccountBlocking(did), 314 + ]); 312 315 313 316 if (currentRequest !== requestId) { 314 317 return; ··· 333 336 334 337 async function loadStarterPacks(currentRequest: number, did: string) { 335 338 try { 336 - const response = await getAccountStarterPacks(did); 339 + const response = await DiagnosticsController.getAccountStarterPacks(did); 337 340 if (currentRequest !== requestId) return; 338 341 setState({ starterPacks: response.starterPacks, starterPacksError: null, starterPacksLoading: false }); 339 342 } catch (error) { ··· 668 671 description: item.availability === "available" ? item.profile?.description ?? null : null, 669 672 displayName: item.profile?.displayName ?? null, 670 673 handle: getDiagnosticEntryHandle(item), 674 + labels: item.availability === "available" ? item.profile?.labels ?? null : null, 671 675 unavailableMessage: item.unavailableMessage ?? "Profile unavailable", 672 676 })) 673 677 ); ··· 874 878 description?: string | null; 875 879 displayName?: string | null; 876 880 handle: string; 881 + labels?: DiagnosticLabel[] | null; 877 882 unavailableMessage: string; 878 883 } 879 884 >; ··· 884 889 <div class="grid gap-3 rounded-3xl bg-surface-container-high p-4 shadow-(--inset-shadow)"> 885 890 <p class="m-0 text-sm font-semibold text-on-surface">{props.title}</p> 886 891 <div class="grid gap-3"> 887 - <For each={props.items}> 888 - {(item, index) => { 889 - const name = () => item.displayName ?? item.handle; 890 - return ( 891 - <Motion.div 892 - class="flex items-start gap-3 rounded-2xl p-3" 893 - classList={{ "ui-input-strong": item.available, "tone-muted opacity-70": !item.available }} 894 - aria-disabled={!item.available} 895 - initial={{ opacity: 0, y: 8 }} 896 - animate={{ opacity: 1, y: 0 }} 897 - transition={{ delay: Math.min(index() * 0.04, 0.16), duration: 0.16 }}> 898 - <div class="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-surface-container-high text-xs font-semibold text-on-surface-variant"> 899 - <Show 900 - when={item.available && item.avatar} 901 - fallback={item.available 902 - ? <span>{initials(name())}</span> 903 - : <Icon kind="danger" aria-hidden="true" />}> 904 - {(src) => <img alt="" class="h-full w-full object-cover" src={src()} />} 905 - </Show> 906 - </div> 907 - <div class="min-w-0"> 908 - <p class="m-0 text-sm font-medium text-on-surface">{name()}</p> 909 - <p class="m-0 text-xs text-on-surface-variant">{formatHandle(item.handle, null)}</p> 910 - <Show when={item.available && item.description}> 911 - {(description) => ( 912 - <p class="m-0 mt-2 text-xs leading-relaxed text-on-surface-variant">{description()}</p> 913 - )} 914 - </Show> 915 - <Show when={!item.available}> 916 - <p class="m-0 mt-2 text-xs leading-relaxed text-on-surface-variant">{item.unavailableMessage}</p> 917 - </Show> 918 - </div> 919 - </Motion.div> 920 - ); 921 - }} 922 - </For> 892 + <For each={props.items}>{(item, index) => <BlockProfileRow index={index()} item={item} />}</For> 923 893 </div> 924 894 </div> 925 895 ); 926 896 } 897 + 898 + function BlockProfileRow( 899 + props: { 900 + index: number; 901 + item: { 902 + available: boolean; 903 + avatar?: string | null; 904 + description?: string | null; 905 + displayName?: string | null; 906 + handle: string; 907 + labels?: DiagnosticLabel[] | null; 908 + unavailableMessage: string; 909 + }; 910 + }, 911 + ) { 912 + const name = createMemo(() => props.item.displayName ?? props.item.handle); 913 + const profileLabels = () => collectModerationLabels({ labels: props.item.labels ?? null }); 914 + const avatarDecision = useModerationDecision(profileLabels, "avatar"); 915 + const profileDecision = useModerationDecision(profileLabels, "profileList"); 916 + 917 + return ( 918 + <Motion.div 919 + class="flex items-start gap-3 rounded-2xl p-3" 920 + classList={{ "ui-input-strong": props.item.available, "tone-muted opacity-70": !props.item.available }} 921 + aria-disabled={!props.item.available} 922 + initial={{ opacity: 0, y: 8 }} 923 + animate={{ opacity: 1, y: 0 }} 924 + transition={{ delay: Math.min(props.index * 0.04, 0.16), duration: 0.16 }}> 925 + <Show 926 + when={props.item.available} 927 + fallback={ 928 + <div class="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-surface-container-high text-xs font-semibold text-on-surface-variant"> 929 + <Icon kind="danger" aria-hidden="true" /> 930 + </div> 931 + }> 932 + <ModeratedAvatar 933 + avatar={props.item.avatar} 934 + class="h-10 w-10 shrink-0 overflow-hidden rounded-full bg-surface-container-high" 935 + hidden={avatarDecision().filter || avatarDecision().blur !== "none"} 936 + label={initials(name())} 937 + fallbackClass="text-xs font-semibold text-on-surface-variant" /> 938 + </Show> 939 + 940 + <div class="min-w-0"> 941 + <p class="m-0 text-sm font-medium text-on-surface">{name()}</p> 942 + <p class="m-0 text-xs text-on-surface-variant">{formatHandle(props.item.handle, null)}</p> 943 + <ModerationBadgeRow class="mt-1" decision={profileDecision()} labels={profileLabels()} /> 944 + <Show when={props.item.available && props.item.description}> 945 + {(description) => <p class="m-0 mt-2 text-xs leading-relaxed text-on-surface-variant">{description()}</p>} 946 + </Show> 947 + <Show when={!props.item.available}> 948 + <p class="m-0 mt-2 text-xs leading-relaxed text-on-surface-variant">{props.item.unavailableMessage}</p> 949 + </Show> 950 + </div> 951 + </Motion.div> 952 + ); 953 + }
+81 -8
src/components/deck/tests/DiagnosticsPanel.test.tsx
··· 9 9 const getAccountBlockingMock = vi.hoisted(() => vi.fn()); 10 10 const getAccountStarterPacksMock = vi.hoisted(() => vi.fn()); 11 11 const getRecordBacklinksMock = vi.hoisted(() => vi.fn()); 12 + const moderateContentMock = vi.hoisted(() => vi.fn()); 12 13 13 14 vi.mock( 14 15 "$/lib/api/diagnostics", 15 16 () => ({ 16 - getAccountBlockedBy: getAccountBlockedByMock, 17 - getAccountBlocking: getAccountBlockingMock, 18 - getAccountLabels: getAccountLabelsMock, 19 - getAccountLists: getAccountListsMock, 20 - getAccountStarterPacks: getAccountStarterPacksMock, 21 - getRecordBacklinks: getRecordBacklinksMock, 17 + DiagnosticsController: { 18 + getAccountBlockedBy: getAccountBlockedByMock, 19 + getAccountBlocking: getAccountBlockingMock, 20 + getAccountLabels: getAccountLabelsMock, 21 + getAccountLists: getAccountListsMock, 22 + getAccountStarterPacks: getAccountStarterPacksMock, 23 + getRecordBacklinks: getRecordBacklinksMock, 24 + }, 22 25 }), 23 26 ); 27 + vi.mock("$/lib/api/moderation", () => ({ ModerationController: { moderateContent: moderateContentMock } })); 24 28 25 - function renderPanel() { 29 + function renderPanel(recordUri?: string) { 26 30 render(() => ( 27 31 <AppTestProviders session={{ activeDid: "did:plc:test", activeHandle: "test.bsky.social" }}> 28 - <DiagnosticsPanel did="did:plc:test" onClose={vi.fn()} /> 32 + <DiagnosticsPanel did="did:plc:test" onClose={vi.fn()} recordUri={recordUri ?? null} /> 29 33 </AppTestProviders> 30 34 )); 31 35 } ··· 38 42 getAccountBlockingMock.mockReset(); 39 43 getAccountStarterPacksMock.mockReset(); 40 44 getRecordBacklinksMock.mockReset(); 45 + moderateContentMock.mockReset(); 46 + moderateContentMock.mockResolvedValue({ 47 + alert: false, 48 + blur: "none", 49 + filter: false, 50 + inform: false, 51 + noOverride: false, 52 + }); 41 53 42 54 getAccountListsMock.mockResolvedValue({ 43 55 lists: [{ ··· 144 156 expect(screen.getByText("This profile is unavailable right now.")).toBeInTheDocument(); 145 157 }); 146 158 159 + it("renders moderation badges for labeled block-list profiles", async () => { 160 + getAccountBlockedByMock.mockResolvedValueOnce({ 161 + cursor: null, 162 + items: [{ 163 + availability: "available", 164 + did: "did:plc:blocker", 165 + profile: { handle: "blocker.test", labels: [{ src: "did:plc:labeler", val: "sexual" }] }, 166 + }], 167 + total: 1, 168 + }); 169 + moderateContentMock.mockImplementation(async (_labels, context: string) => { 170 + if (context === "profileList") { 171 + return { alert: true, blur: "none", filter: false, inform: false, noOverride: false }; 172 + } 173 + 174 + return { alert: false, blur: "none", filter: false, inform: false, noOverride: false }; 175 + }); 176 + 177 + renderPanel(); 178 + 179 + fireEvent.click(await screen.findByRole("button", { name: "Blocks" })); 180 + fireEvent.click(screen.getByRole("button", { name: /show details/i })); 181 + 182 + expect(await screen.findByText("blocker.test")).toBeInTheDocument(); 183 + expect(await screen.findByText("Alert")).toBeInTheDocument(); 184 + }); 185 + 147 186 it("explains backlinks when no record URI is selected", async () => { 148 187 renderPanel(); 149 188 ··· 152 191 expect(await screen.findByText(/Backlinks are record-specific engagement context/i)).toBeInTheDocument(); 153 192 expect(screen.getByText(/Open a post or record to inspect the public references pointing at it/i)) 154 193 .toBeInTheDocument(); 194 + }); 195 + 196 + it("renders moderation badges for labeled backlink profiles", async () => { 197 + getRecordBacklinksMock.mockResolvedValueOnce({ 198 + likes: { 199 + cursor: null, 200 + records: [{ 201 + collection: "app.bsky.feed.like", 202 + did: "did:plc:fan", 203 + profile: { handle: "fan.test", labels: [{ src: "did:plc:labeler", val: "sexual" }] }, 204 + rkey: "1", 205 + uri: "at://did:plc:fan/app.bsky.feed.like/1", 206 + }], 207 + total: 1, 208 + }, 209 + quotes: { cursor: null, records: [], total: 0 }, 210 + replies: { cursor: null, records: [], total: 0 }, 211 + reposts: { cursor: null, records: [], total: 0 }, 212 + }); 213 + moderateContentMock.mockImplementation(async (_labels, context: string) => { 214 + if (context === "profileList") { 215 + return { alert: true, blur: "none", filter: false, inform: false, noOverride: false }; 216 + } 217 + 218 + return { alert: false, blur: "none", filter: false, inform: false, noOverride: false }; 219 + }); 220 + 221 + renderPanel("at://did:plc:test/app.bsky.feed.post/123"); 222 + 223 + fireEvent.click(await screen.findByRole("button", { name: "Backlinks" })); 224 + fireEvent.click(await screen.findByRole("button", { name: /likes/i })); 225 + 226 + expect(await screen.findByText("fan.test")).toBeInTheDocument(); 227 + expect(await screen.findByText("Alert")).toBeInTheDocument(); 155 228 }); 156 229 });
+17 -7
src/components/diagnostics/RecordBacklinksPanel.tsx
··· 1 + import { useModerationDecision } from "$/components/moderation/hooks/useModerationDecision"; 2 + import { ModeratedAvatar } from "$/components/moderation/ModeratedAvatar"; 3 + import { ModerationBadgeRow } from "$/components/moderation/ModerationBadgeRow"; 1 4 import { ArrowIcon, Icon } from "$/components/shared/Icon"; 2 - import { type DiagnosticBacklinkGroup, type DiagnosticBacklinkItem, getRecordBacklinks } from "$/lib/api/diagnostics"; 5 + import type { DiagnosticBacklinkGroup, DiagnosticBacklinkItem } from "$/lib/api/diagnostics"; 6 + import { DiagnosticsController } from "$/lib/api/diagnostics"; 7 + import { collectModerationLabels } from "$/lib/moderation"; 3 8 import { formatHandle, initials, normalizeError } from "$/lib/utils/text"; 4 9 import * as logger from "@tauri-apps/plugin-log"; 5 10 import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js"; ··· 65 70 66 71 async function loadBacklinks(currentRequest: number, uri: string) { 67 72 try { 68 - const response = await getRecordBacklinks(uri); 73 + const response = await DiagnosticsController.getRecordBacklinks(uri); 69 74 if (currentRequest !== requestId) { 70 75 return; 71 76 } ··· 208 213 props.item.profile?.displayName ?? props.item.profile?.handle ?? props.item.did ?? "Unknown" 209 214 ); 210 215 const handleLabel = createMemo(() => formatHandle(props.item.profile?.handle, props.item.did)); 216 + const profileLabels = () => collectModerationLabels(props.item.profile); 217 + const avatarDecision = useModerationDecision(profileLabels, "avatar"); 218 + const profileDecision = useModerationDecision(profileLabels, "profileList"); 211 219 212 220 return ( 213 221 <Motion.div ··· 215 223 initial={{ opacity: 0, y: 8 }} 216 224 animate={{ opacity: 1, y: 0 }} 217 225 transition={{ delay: Math.min(props.index * 0.04, 0.16), duration: 0.16 }}> 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"> 219 - <Show when={props.item.profile?.avatar} fallback={<span>{initials(actorLabel())}</span>}> 220 - {(src) => <img alt={actorLabel()} class="h-full w-full object-cover" src={src()} />} 221 - </Show> 222 - </div> 226 + <ModeratedAvatar 227 + avatar={props.item.profile?.avatar} 228 + class="h-10 w-10 shrink-0 overflow-hidden rounded-full bg-white/8" 229 + hidden={avatarDecision().filter || avatarDecision().blur !== "none"} 230 + label={initials(actorLabel())} 231 + fallbackClass="text-xs font-semibold text-on-surface-variant" /> 223 232 224 233 <div class="min-w-0"> 225 234 <div class="flex flex-wrap items-center gap-2"> ··· 229 238 </span> 230 239 </div> 231 240 <p class="m-0 mt-1 text-xs text-on-surface-variant">{handleLabel()}</p> 241 + <ModerationBadgeRow class="mt-1" decision={profileDecision()} labels={profileLabels()} /> 232 242 <p class="m-0 mt-2 break-all font-mono text-xs leading-relaxed text-on-surface-variant">{props.item.uri}</p> 233 243 </div> 234 244 </Motion.div>
+1 -1
src/components/explorer/tests/ExplorerPanel.test.tsx
··· 33 33 ); 34 34 35 35 vi.mock("$/lib/api/profile", () => ({ ProfileController: { getProfile: getProfileMock } })); 36 - vi.mock("$/lib/api/diagnostics", () => ({ getRecordBacklinks: getRecordBacklinksMock })); 36 + vi.mock("$/lib/api/diagnostics", () => ({ DiagnosticsController: { getRecordBacklinks: getRecordBacklinksMock } })); 37 37 vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); 38 38 39 39 function renderPanel() {
+1 -1
src/components/explorer/views/tests/RecordView.test.tsx
··· 4 4 5 5 const getRecordBacklinksMock = vi.hoisted(() => vi.fn()); 6 6 7 - vi.mock("$/lib/api/diagnostics", () => ({ getRecordBacklinks: getRecordBacklinksMock })); 7 + vi.mock("$/lib/api/diagnostics", () => ({ DiagnosticsController: { getRecordBacklinks: getRecordBacklinksMock } })); 8 8 9 9 describe("RecordView", () => { 10 10 it("renders falsey JSON values and moderation labels", () => {
+5 -2
src/components/feeds/PostCard.tsx
··· 369 369 const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(view.post.author))); 370 370 const contentLabels = () => collectModerationLabels(view.post); 371 371 const mediaLabels = () => collectModerationLabels(view.post, view.post.embed); 372 - const avatarLabels = () => collectModerationLabels(view.post.author); 372 + const authorLabels = () => collectModerationLabels(view.post.author); 373 373 const contentDecision = useModerationDecision(contentLabels, "contentList"); 374 374 const mediaDecision = useModerationDecision(mediaLabels, "contentMedia"); 375 - const avatarDecision = useModerationDecision(avatarLabels, "avatar"); 375 + const avatarDecision = useModerationDecision(authorLabels, "avatar"); 376 + const authorDecision = useModerationDecision(authorLabels, "profileList"); 376 377 const contentHidden = createMemo(() => isDecisionHidden(contentDecision())); 377 378 const mediaHidden = createMemo(() => isDecisionHidden(mediaDecision())); 378 379 const mergeBodyAndEmbedModeration = createMemo(() => contentHidden() && mediaHidden()); ··· 630 631 authorHandle={authorHandle()} 631 632 authorHref={profileHref()} 632 633 createdAt={createdAt()} /> 634 + 635 + <ModerationBadgeRow decision={authorDecision()} labels={authorLabels()} /> 633 636 634 637 <ModerationBadgeRow decision={contentDecision()} labels={contentLabels()} /> 635 638
+18
src/components/feeds/tests/PostCard.test.tsx
··· 329 329 expect(screen.getAllByRole("button", { name: "Show content" })).toHaveLength(1); 330 330 }); 331 331 332 + it("renders author profile labels in post cards when the author is labeled", async () => { 333 + render(() => ( 334 + <PostCard 335 + post={{ 336 + ...createPost(), 337 + author: { ...createPost().author, labels: [{ src: "did:plc:labeler", val: "profile-label" }] }, 338 + }} /> 339 + )); 340 + 341 + expect(await screen.findByText(/profile-label/i)).toBeInTheDocument(); 342 + }); 343 + 344 + it("renders post labels in post cards when post labels are present", async () => { 345 + render(() => <PostCard post={{ ...createPost(), labels: [{ src: "did:plc:labeler", val: "post-label" }] }} />); 346 + 347 + expect(await screen.findByText(/post-label/i)).toBeInTheDocument(); 348 + }); 349 + 332 350 it("opens gallery on image click and supports right-click save", async () => { 333 351 downloadImageMock.mockResolvedValue({ bytes: 40, path: "/tmp/post-image.jpg" }); 334 352 render(() => (
+40 -14
src/components/messages/MessagesPanel.tsx
··· 1 + import { useModerationDecision } from "$/components/moderation/hooks/useModerationDecision"; 2 + import { ModeratedAvatar } from "$/components/moderation/ModeratedAvatar"; 3 + import { ModerationBadgeRow } from "$/components/moderation/ModerationBadgeRow"; 1 4 import { useAppSession } from "$/contexts/app-session"; 2 - import { getConvoForMembers, getMessages, listConvos, sendMessage, updateRead } from "$/lib/api/conversations"; 3 - import { formatRelativeTime, getDisplayName } from "$/lib/feeds"; 5 + import { ConvoController } from "$/lib/api/conversations"; 6 + import { formatRelativeTime, getAvatarLabel, getDisplayName } from "$/lib/feeds"; 7 + import { collectModerationLabels } from "$/lib/moderation"; 4 8 import type { ConvoView, DeletedMessageView, MessageView } from "$/lib/types"; 5 9 import { normalizeError } from "$/lib/utils/text"; 6 10 import * as logger from "@tauri-apps/plugin-log"; ··· 132 136 const displayName = createMemo(() => getConvoDisplayName(props.convo, props.selfDid)); 133 137 const lastText = createMemo(() => getLastMessageText(props.convo)); 134 138 const lastTime = createMemo(() => getLastMessageTime(props.convo)); 139 + const avatarLabel = createMemo(() => getAvatarLabel(other() ?? { did: "unknown", handle: "unknown" })); 140 + const profileLabels = () => collectModerationLabels(other()); 141 + const avatarDecision = useModerationDecision(profileLabels, "avatar"); 142 + const profileDecision = useModerationDecision(profileLabels, "profileList"); 135 143 136 144 return ( 137 145 <button ··· 140 148 classList={{ "bg-primary/10 hover:bg-primary/12": props.active }} 141 149 onClick={() => props.onClick()}> 142 150 <div class="flex items-start gap-3"> 143 - <AvatarBadge label={other()?.handle ?? "?"} src={other()?.avatar} tone="primary" /> 144 - <MessageMeta name={displayName()} text={lastText()} time={lastTime()} unread={props.convo.unreadCount ?? 0} /> 151 + <ModeratedAvatar 152 + avatar={other()?.avatar} 153 + class="h-10 w-10 shrink-0 overflow-hidden rounded-full bg-surface-container-high" 154 + hidden={avatarDecision().filter || avatarDecision().blur !== "none"} 155 + label={avatarLabel()} 156 + fallbackClass="text-xs font-semibold text-on-surface" /> 157 + <div class="min-w-0 flex-1"> 158 + <MessageMeta name={displayName()} text={lastText()} time={lastTime()} unread={props.convo.unreadCount ?? 0} /> 159 + <ModerationBadgeRow class="mt-1" decision={profileDecision()} labels={profileLabels()} /> 160 + </div> 145 161 </div> 146 162 </button> 147 163 ); ··· 299 315 ) { 300 316 const otherMember = createMemo(() => getConvoOtherMember(props.convo, props.selfDid)); 301 317 const displayName = createMemo(() => getConvoDisplayName(props.convo, props.selfDid)); 318 + const otherLabels = () => collectModerationLabels(otherMember()); 319 + const headerAvatarLabel = createMemo(() => getAvatarLabel(otherMember() ?? { did: "unknown", handle: "unknown" })); 320 + const headerAvatarDecision = useModerationDecision(otherLabels, "avatar"); 321 + const headerProfileDecision = useModerationDecision(otherLabels, "profileList"); 302 322 303 323 function getMemberAvatar(did: string) { 304 324 return props.convo.members.find((member) => member.did === did)?.avatar; ··· 306 326 307 327 return ( 308 328 <> 309 - <header class="flex shrink-0 items-center gap-3 border-b border-white/5 bg-surface-container/80 px-5 py-3.5 backdrop-blur-[12px]"> 310 - <AvatarBadge label={otherMember()?.handle ?? "?"} src={otherMember()?.avatar} tone="primary" /> 329 + <header class="flex shrink-0 items-center gap-3 border-b border-white/5 bg-surface-container/80 px-5 py-3.5 backdrop-blur-md"> 330 + <ModeratedAvatar 331 + avatar={otherMember()?.avatar} 332 + class="h-10 w-10 shrink-0 overflow-hidden rounded-full bg-surface-container-high" 333 + hidden={headerAvatarDecision().filter || headerAvatarDecision().blur !== "none"} 334 + label={headerAvatarLabel()} 335 + fallbackClass="text-xs font-semibold text-on-surface" /> 311 336 <div class="min-w-0 flex-1"> 312 337 <p class="m-0 truncate text-sm font-semibold text-on-surface">{displayName()}</p> 313 338 <p class="m-0 truncate text-xs text-on-surface-variant">@{otherMember()?.handle ?? ""}</p> 339 + <ModerationBadgeRow class="mt-1" decision={headerProfileDecision()} labels={otherLabels()} /> 314 340 </div> 315 341 </header> 316 342 ··· 393 419 setListState((prev) => ({ ...prev, loadingConvos: true, convoError: null })); 394 420 395 421 try { 396 - const response = await listConvos(); 422 + const response = await ConvoController.listConvos(); 397 423 if (currentRequest !== convoListRequest) { 398 424 return; 399 425 } ··· 448 474 setListState((prev) => ({ ...prev, loadingConvos: true })); 449 475 450 476 try { 451 - const response = await listConvos(cursor); 477 + const response = await ConvoController.listConvos(cursor); 452 478 if (currentRequest !== convoListRequest) { 453 479 return; 454 480 } ··· 473 499 const currentRequest = ++openMemberRequest; 474 500 475 501 try { 476 - const response = await getConvoForMembers([memberDid]); 502 + const response = await ConvoController.getConvoForMembers([memberDid]); 477 503 if (currentRequest !== openMemberRequest) { 478 504 return; 479 505 } ··· 496 522 setChatState(createConvoContentState(true)); 497 523 498 524 try { 499 - const response = await getMessages(convo.id); 525 + const response = await ConvoController.getMessages(convo.id); 500 526 if (currentRequest !== messageRequest || activeConvoId() !== convo.id) { 501 527 return; 502 528 } ··· 512 538 513 539 if ((convo.unreadCount ?? 0) > 0) { 514 540 const newestMessage = ordered.at(-1); 515 - void updateRead(convo.id, newestMessage?.id ?? null).catch(() => { 541 + void ConvoController.updateRead(convo.id, newestMessage?.id ?? null).catch(() => { 516 542 logger.error("updateRead failed", { keyValues: { convoId: convo.id } }); 517 543 }); 518 544 ··· 545 571 setChatState((prev) => ({ ...prev, loadingMessages: true })); 546 572 547 573 try { 548 - const response = await getMessages(convoId, cursor); 574 + const response = await ConvoController.getMessages(convoId, cursor); 549 575 if (activeConvoId() !== convoId) { 550 576 return; 551 577 } ··· 580 606 setChatState((prev) => ({ ...prev, messageError: null, sending: true })); 581 607 582 608 try { 583 - const message = await sendMessage(convoId, text); 609 + const message = await ConvoController.sendMessage(convoId, text); 584 610 if (activeConvoId() !== convoId) { 585 611 return false; 586 612 } ··· 618 644 "max-w-64 min-w-40 w-[44%]": props.embedded, 619 645 "w-80": !props.embedded, 620 646 }}> 621 - <header class="flex shrink-0 items-center justify-between border-b border-white/5 bg-surface-container/80 px-5 py-4 backdrop-blur-[12px]"> 647 + <header class="flex shrink-0 items-center justify-between border-b border-white/5 bg-surface-container/80 px-5 py-4 backdrop-blur-md"> 622 648 <div> 623 649 <h1 class="m-0 text-lg font-semibold tracking-tight text-on-surface">Messages</h1> 624 650 </div>
+97
src/components/messages/tests/MessagesPanel.test.tsx
··· 1 + import { AppTestProviders } from "$/test/providers"; 2 + import { render, screen, waitFor } from "@solidjs/testing-library"; 3 + import { beforeEach, describe, expect, it, vi } from "vitest"; 4 + import { MessagesPanel } from "../MessagesPanel"; 5 + 6 + const getConvoForMembersMock = vi.hoisted(() => vi.fn()); 7 + const getMessagesMock = vi.hoisted(() => vi.fn()); 8 + const listConvosMock = vi.hoisted(() => vi.fn()); 9 + const sendMessageMock = vi.hoisted(() => vi.fn()); 10 + const updateReadMock = vi.hoisted(() => vi.fn()); 11 + const moderateContentMock = vi.hoisted(() => vi.fn()); 12 + 13 + vi.mock( 14 + "$/lib/api/conversations", 15 + () => ({ 16 + ConvoController: { 17 + getConvoForMembers: getConvoForMembersMock, 18 + getMessages: getMessagesMock, 19 + listConvos: listConvosMock, 20 + sendMessage: sendMessageMock, 21 + updateRead: updateReadMock, 22 + }, 23 + }), 24 + ); 25 + vi.mock("$/lib/api/moderation", () => ({ ModerationController: { moderateContent: moderateContentMock } })); 26 + 27 + describe("MessagesPanel", () => { 28 + beforeEach(() => { 29 + vi.resetAllMocks(); 30 + listConvosMock.mockResolvedValue({ 31 + convos: [{ 32 + id: "convo-1", 33 + lastMessage: { 34 + id: "msg-1", 35 + rev: "1", 36 + sender: { did: "did:plc:bob" }, 37 + sentAt: "2026-03-29T12:00:00.000Z", 38 + text: "Hello there", 39 + }, 40 + members: [{ did: "did:plc:alice", handle: "alice.test" }, { 41 + did: "did:plc:bob", 42 + displayName: "Bob", 43 + handle: "bob.test", 44 + labels: [{ src: "did:plc:labeler", val: "sexual" }], 45 + }], 46 + muted: false, 47 + rev: "1", 48 + unreadCount: 0, 49 + }], 50 + cursor: null, 51 + }); 52 + getMessagesMock.mockResolvedValue({ 53 + cursor: null, 54 + messages: [{ 55 + id: "msg-1", 56 + rev: "1", 57 + sender: { did: "did:plc:bob" }, 58 + sentAt: "2026-03-29T12:00:00.000Z", 59 + text: "Hello there", 60 + }], 61 + }); 62 + getConvoForMembersMock.mockResolvedValue({ convo: null }); 63 + sendMessageMock.mockResolvedValue({ 64 + id: "msg-2", 65 + rev: "2", 66 + sender: { did: "did:plc:alice" }, 67 + sentAt: "2026-03-29T12:01:00.000Z", 68 + text: "Reply", 69 + }); 70 + updateReadMock.mockResolvedValue(void 0); 71 + moderateContentMock.mockImplementation(async (_labels, context: string) => { 72 + if (context === "profileList") { 73 + return { alert: true, blur: "none", filter: false, inform: false, noOverride: false }; 74 + } 75 + 76 + return { alert: false, blur: "none", filter: false, inform: false, noOverride: false }; 77 + }); 78 + }); 79 + 80 + it("renders moderation badges for labeled conversation profiles", async () => { 81 + render(() => ( 82 + <AppTestProviders 83 + session={{ 84 + activeDid: "did:plc:alice", 85 + activeHandle: "alice.test", 86 + activeSession: { did: "did:plc:alice", handle: "alice.test" }, 87 + }}> 88 + <MessagesPanel /> 89 + </AppTestProviders> 90 + )); 91 + 92 + expect(await screen.findAllByText("Bob")).toBeTruthy(); 93 + await waitFor(() => { 94 + expect(screen.getAllByText("Alert").length).toBeGreaterThan(0); 95 + }); 96 + }); 97 + });
+74 -12
src/components/moderation/ModerationBadgeRow.tsx
··· 1 1 import { summarizeModerationLabels } from "$/lib/moderation"; 2 2 import type { ModerationLabel, ModerationUiDecision } from "$/lib/types"; 3 - import { createMemo, Show } from "solid-js"; 3 + import { createMemo, For, Show } from "solid-js"; 4 4 import { Icon } from "../shared/Icon"; 5 5 6 6 type ModerationBadgeRowProps = { decision: ModerationUiDecision; labels: ModerationLabel[]; class?: string }; 7 + type BadgeTone = "alert" | "inform" | "label"; 8 + type ModerationRowBadge = { key: string; label: string; source: string; description?: string | null; tone: BadgeTone }; 7 9 8 10 export function ModerationBadgeRow(props: ModerationBadgeRowProps) { 9 11 const summaries = createMemo(() => summarizeModerationLabels(props.labels, 3)); 10 - const sourceText = createMemo(() => { 11 - if (summaries().length === 0) { 12 - return null; 12 + const fallbackTone = createMemo<BadgeTone>(() => { 13 + if (props.decision.alert) { 14 + return "alert"; 13 15 } 14 16 15 - return summaries().map((summary) => `${summary.value} · ${summary.source}`).join(" | "); 17 + if (props.decision.inform) { 18 + return "inform"; 19 + } 20 + 21 + return "label"; 16 22 }); 23 + const decisionBadges = createMemo<ModerationRowBadge[]>(() => 24 + (props.decision.badges ?? []).flatMap((badge, index) => { 25 + if (!badge?.label?.trim()) { 26 + return []; 27 + } 28 + 29 + const tone = badge.tone === "alert" || badge.tone === "inform" ? badge.tone : "label"; 30 + const source = badge.source?.trim() || "Unknown"; 31 + const label = badge.label.trim(); 32 + return [{ key: `${tone}:${source}:${label}:${index}`, label, source, description: badge.description, tone }]; 33 + }) 34 + ); 35 + const summaryBadges = createMemo<ModerationRowBadge[]>(() => 36 + summaries().map((summary, index) => ({ 37 + key: `${fallbackTone()}:${summary.source}:${summary.value}:${index}`, 38 + label: summary.value, 39 + source: summary.source, 40 + description: `${summary.value} (${summary.source})`, 41 + tone: fallbackTone(), 42 + })) 43 + ); 44 + const badges = createMemo(() => { 45 + const decision = decisionBadges(); 46 + if (decision.length > 0) { 47 + return decision; 48 + } 49 + 50 + return summaryBadges(); 51 + }); 52 + const shouldRender = createMemo(() => badges().length > 0 || props.decision.alert || props.decision.inform); 53 + const showGenericStatusPill = createMemo(() => badges().length === 0); 54 + const badgeIcon = (tone: BadgeTone) => { 55 + if (tone === "alert") { 56 + return "i-ri-alarm-warning-line"; 57 + } 58 + 59 + if (tone === "inform") { 60 + return "i-ri-information-line"; 61 + } 62 + 63 + return "i-ri-price-tag-3-line"; 64 + }; 17 65 18 66 return ( 19 - <Show when={props.decision.alert || props.decision.inform}> 67 + <Show when={shouldRender()}> 20 68 <div class="mt-2 flex flex-wrap items-center gap-2" classList={{ [props.class ?? ""]: !!props.class }}> 21 - <Show when={props.decision.alert}> 69 + <Show when={showGenericStatusPill() && props.decision.alert}> 22 70 <span class="inline-flex items-center gap-1 rounded-full bg-red-500/18 px-2.5 py-1 text-[0.68rem] font-medium uppercase tracking-[0.08em] text-red-200"> 23 - <Icon aria-hidden="true" iconClass="i-ri-alarm-warning-line" class="text-xs" /> 71 + <Icon aria-hidden="true" class="text-xs" iconClass="i-ri-alarm-warning-line" /> 24 72 Alert 25 73 </span> 26 74 </Show> 27 - <Show when={props.decision.inform}> 75 + <Show when={showGenericStatusPill() && !props.decision.alert && props.decision.inform}> 28 76 <span class="inline-flex items-center gap-1 rounded-full bg-primary/18 px-2.5 py-1 text-[0.68rem] font-medium uppercase tracking-[0.08em] text-primary"> 29 - <Icon aria-hidden="true" iconClass="i-ri-information-line" class="text-xs" /> 30 - Inform 77 + <Icon aria-hidden="true" class="text-xs" iconClass="i-ri-information-line" /> 78 + Advisory 31 79 </span> 32 80 </Show> 33 - <Show when={sourceText()}>{(text) => <span class="text-xs text-on-surface-variant">{text()}</span>}</Show> 81 + <For each={badges()}> 82 + {(badge) => ( 83 + <span 84 + class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[0.68rem] font-medium uppercase tracking-[0.08em]" 85 + classList={{ 86 + "bg-red-500/18 text-red-200": badge.tone === "alert", 87 + "bg-primary/18 text-primary": badge.tone === "inform", 88 + "bg-surface-bright text-on-surface-variant": badge.tone === "label", 89 + }} 90 + title={badge.description ? `${badge.description} — ${badge.source}` : badge.source}> 91 + <Icon aria-hidden="true" class="text-xs" iconClass={badgeIcon(badge.tone)} /> 92 + {badge.label} 93 + </span> 94 + )} 95 + </For> 34 96 </div> 35 97 </Show> 36 98 );
+36
src/components/moderation/tests/ModerationBadgeRow.test.tsx
··· 1 + import { render, screen } from "@solidjs/testing-library"; 2 + import { describe, expect, it } from "vitest"; 3 + import { ModerationBadgeRow } from "../ModerationBadgeRow"; 4 + 5 + describe("ModerationBadgeRow", () => { 6 + it("renders a neutral label badge when labels exist but moderation is neutral", async () => { 7 + render(() => ( 8 + <ModerationBadgeRow 9 + decision={{ alert: false, blur: "none", filter: false, inform: false, noOverride: false }} 10 + labels={[{ src: "did:plc:labeler", val: "my-label" }]} /> 11 + )); 12 + 13 + expect(await screen.findByText(/my-label/i)).toBeInTheDocument(); 14 + }); 15 + 16 + it("renders nothing for empty labels with a neutral decision", () => { 17 + const { container } = render(() => ( 18 + <ModerationBadgeRow 19 + decision={{ alert: false, blur: "none", filter: false, inform: false, noOverride: false }} 20 + labels={[]} /> 21 + )); 22 + 23 + expect(container).toBeEmptyDOMElement(); 24 + }); 25 + 26 + it("hides generic advisory pill when concrete label badges are available", async () => { 27 + render(() => ( 28 + <ModerationBadgeRow 29 + decision={{ alert: false, blur: "none", filter: false, inform: true, noOverride: false }} 30 + labels={[{ src: "did:plc:labeler", val: "my-label" }]} /> 31 + )); 32 + 33 + expect(await screen.findByText(/my-label/i)).toBeInTheDocument(); 34 + expect(screen.queryByText("Advisory")).not.toBeInTheDocument(); 35 + }); 36 + });
+4
src/components/notifications/NotificationItem.tsx
··· 49 49 }); 50 50 const detail = createMemo(() => postText() ?? followDetail(props.notification)); 51 51 const avatarLabels = () => collectModerationLabels(props.notification.author); 52 + const profileLabels = () => collectModerationLabels(props.notification.author); 52 53 const contentLabels = () => collectModerationLabels(props.notification); 53 54 const avatarDecision = useModerationDecision(avatarLabels, "avatar"); 55 + const profileDecision = useModerationDecision(profileLabels, "profileList"); 54 56 const contentDecision = useModerationDecision(contentLabels, "contentList"); 55 57 56 58 function openBodyTarget() { ··· 117 119 originalPostHref={originalPostHref()} 118 120 reason={props.notification.reason} /> 119 121 </p> 122 + 123 + <ModerationBadgeRow decision={profileDecision()} labels={profileLabels()} class="mt-1" /> 120 124 121 125 <ModerationBadgeRow decision={contentDecision()} labels={contentLabels()} class="mt-1" /> 122 126
+9 -3
src/components/notifications/NotificationsPanel.tsx
··· 1 1 import { useModerationDecision } from "$/components/moderation/hooks/useModerationDecision"; 2 2 import { ModeratedAvatar } from "$/components/moderation/ModeratedAvatar"; 3 + import { ModerationBadgeRow } from "$/components/moderation/ModerationBadgeRow"; 3 4 import { useThreadOverlayNavigation } from "$/components/posts/hooks/useThreadOverlayNavigation"; 4 5 import { useAppSession } from "$/contexts/app-session"; 5 6 import { listNotifications, updateSeen } from "$/lib/api/notifications"; ··· 19 20 import { 20 21 buildAllNotificationsFeed, 21 22 groupActivityNotifications, 22 - type GroupedNotificationFeedItem, 23 23 isMentionNotification, 24 - type NotificationFeedItem, 25 - type SingleNotificationFeedItem, 26 24 splitByReadState, 27 25 toSingleFeedItems, 26 + } from "./notification-grouping"; 27 + import type { 28 + GroupedNotificationFeedItem, 29 + NotificationFeedItem, 30 + SingleNotificationFeedItem, 28 31 } from "./notification-grouping"; 29 32 import { NotificationItem } from "./NotificationItem"; 30 33 ··· 471 474 const time = createMemo(() => formatRelativeTime(props.item.latestIndexedAt)); 472 475 const summary = createMemo(() => groupedSummary(props.item)); 473 476 const actors = createMemo(() => props.item.actors.slice(0, 3)); 477 + const profileLabels = () => collectModerationLabels(...props.item.actors); 478 + const profileDecision = useModerationDecision(profileLabels, "profileList"); 474 479 const bodyTargetUri = createMemo(() => props.item.reasonSubject ?? null); 475 480 const bodyInteractive = createMemo(() => !!bodyTargetUri()); 476 481 const memberUris = createMemo(() => props.item.notifications.map((notification) => notification.uri)); ··· 502 507 </div> 503 508 504 509 <p class="m-0 text-sm leading-relaxed text-on-surface">{summary()}</p> 510 + <ModerationBadgeRow decision={profileDecision()} labels={profileLabels()} class="mt-1" /> 505 511 506 512 <Show when={props.item.sampleRecordText}> 507 513 {(value) => <p class="mt-1 line-clamp-2 text-sm text-on-secondary-container">{value()}</p>}
+63
src/components/notifications/tests/NotificationsPanel.test.tsx
··· 8 8 const updateSeenMock = vi.hoisted(() => vi.fn()); 9 9 const listenMock = vi.hoisted(() => vi.fn()); 10 10 const warnMock = vi.hoisted(() => vi.fn()); 11 + const moderateContentMock = vi.hoisted(() => vi.fn()); 11 12 12 13 vi.mock("$/lib/api/notifications", () => ({ listNotifications: listNotificationsMock, updateSeen: updateSeenMock })); 13 14 vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); 14 15 vi.mock("@tauri-apps/plugin-log", () => ({ warn: warnMock })); 16 + vi.mock("$/lib/api/moderation", () => ({ ModerationController: { moderateContent: moderateContentMock } })); 15 17 16 18 function createNotification(reason: string, overrides: Record<string, unknown> = {}) { 17 19 return { ··· 51 53 updateSeenMock.mockReset(); 52 54 listenMock.mockReset(); 53 55 warnMock.mockReset(); 56 + moderateContentMock.mockReset(); 54 57 updateSeenMock.mockResolvedValue(void 0); 55 58 listenMock.mockResolvedValue(() => {}); 59 + moderateContentMock.mockResolvedValue({ 60 + alert: false, 61 + blur: "none", 62 + filter: false, 63 + inform: false, 64 + noOverride: false, 65 + }); 56 66 }); 57 67 58 68 it("defaults to the all tab and does not auto-mark seen", async () => { ··· 402 412 expect(await screen.findByText("notification fetch failed")).toBeInTheDocument(); 403 413 expect(updateSeenMock).not.toHaveBeenCalled(); 404 414 expect(warnMock).not.toHaveBeenCalled(); 415 + }); 416 + 417 + it("shows profile moderation badges for single and grouped activity rows", async () => { 418 + listNotificationsMock.mockResolvedValue({ 419 + cursor: null, 420 + notifications: [ 421 + createNotification("follow", { 422 + author: { 423 + did: "did:plc:single", 424 + displayName: "Single Author", 425 + handle: "single.test", 426 + labels: [{ src: "did:plc:labeler", val: "sexual" }], 427 + }, 428 + uri: "at://did:plc:single/app.bsky.notification/1", 429 + }), 430 + createNotification("like", { 431 + author: { 432 + did: "did:plc:alice", 433 + displayName: "Alice", 434 + handle: "alice.test", 435 + labels: [{ src: "did:plc:labeler", val: "sexual" }], 436 + }, 437 + reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 438 + uri: "at://did:plc:like/app.bsky.notification/2", 439 + }), 440 + createNotification("like", { 441 + author: { 442 + did: "did:plc:bob", 443 + displayName: "Bob", 444 + handle: "bob.test", 445 + labels: [{ src: "did:plc:labeler", val: "sexual" }], 446 + }, 447 + reasonSubject: "at://did:plc:post/app.bsky.feed.post/1", 448 + uri: "at://did:plc:like/app.bsky.notification/3", 449 + }), 450 + ], 451 + seenAt: null, 452 + }); 453 + moderateContentMock.mockImplementation(async (_labels, context: string) => { 454 + if (context === "profileList") { 455 + return { alert: true, blur: "none", filter: false, inform: false, noOverride: false }; 456 + } 457 + 458 + return { alert: false, blur: "none", filter: false, inform: false, noOverride: false }; 459 + }); 460 + 461 + renderNotificationsPanelWithRouter(); 462 + await screen.findByLabelText("Single Author followed you"); 463 + await waitFor(() => expect(screen.getAllByText("Alert").length).toBeGreaterThan(0)); 464 + 465 + fireEvent.click(screen.getByRole("button", { name: /activity/i })); 466 + await waitFor(() => expect(screen.getByText("Alice and Bob liked your post")).toBeInTheDocument()); 467 + await waitFor(() => expect(screen.getAllByText("Alert").length).toBeGreaterThan(0)); 405 468 }); 406 469 });
+24 -8
src/components/posts/PostEngagementPanel.tsx
··· 1 + import { useModerationDecision } from "$/components/moderation/hooks/useModerationDecision"; 2 + import { ModeratedAvatar } from "$/components/moderation/ModeratedAvatar"; 3 + import { ModerationBadgeRow } from "$/components/moderation/ModerationBadgeRow"; 1 4 import { usePostNavigation } from "$/components/posts/hooks/usePostNavigation"; 2 5 import { Icon } from "$/components/shared/Icon"; 3 6 import { QuotedPostPreview } from "$/components/shared/QuotedPostPreview"; 4 - import { type DiagnosticBacklinkGroup, type DiagnosticBacklinkItem, getRecordBacklinks } from "$/lib/api/diagnostics"; 7 + import type { DiagnosticBacklinkGroup, DiagnosticBacklinkItem } from "$/lib/api/diagnostics"; 8 + import { DiagnosticsController } from "$/lib/api/diagnostics"; 9 + import { collectModerationLabels } from "$/lib/moderation"; 5 10 import { 6 11 buildPostEngagementTabRoute, 7 12 parsePostEngagementTab, ··· 69 74 70 75 async function loadEngagement(nextRequestId: number, uri: string) { 71 76 try { 72 - const response = await getRecordBacklinks(uri); 77 + const response = await DiagnosticsController.getRecordBacklinks(uri); 73 78 if (nextRequestId !== requestId || uri !== activeUri()) { 74 79 return; 75 80 } ··· 228 233 const interactive = createMemo(() => quoteInteractive() || profileInteractive()); 229 234 const quoteText = createMemo(() => getQuoteText(props.item)); 230 235 const quoteAuthor = createMemo(() => getQuoteAuthor(props.item)); 236 + const profileLabels = () => collectModerationLabels(props.item.profile); 237 + const avatarDecision = useModerationDecision(profileLabels, "avatar"); 238 + const profileDecision = useModerationDecision(profileLabels, "profileList"); 231 239 232 240 return ( 233 241 <button ··· 242 250 243 251 props.onOpenProfile(props.item); 244 252 }}> 245 - <div class="ui-input-strong flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-full text-xs font-semibold text-on-surface-variant"> 246 - <Show when={props.item.profile?.avatar} fallback={<span>{initials(actorLabel())}</span>}> 247 - {(src) => <img alt={actorLabel()} class="h-full w-full object-cover" src={src()} />} 248 - </Show> 249 - </div> 253 + <ModeratedAvatar 254 + avatar={props.item.profile?.avatar} 255 + class="ui-input-strong h-11 w-11 shrink-0 overflow-hidden rounded-full" 256 + hidden={avatarDecision().filter || avatarDecision().blur !== "none"} 257 + label={initials(actorLabel())} 258 + fallbackClass="text-xs font-semibold text-on-surface-variant" /> 250 259 <div class="min-w-0 flex-1"> 251 260 <div class="flex flex-wrap items-center gap-2"> 252 261 <p class="m-0 text-sm font-medium text-on-surface">{actorLabel()}</p> ··· 259 268 </Show> 260 269 </div> 261 270 <p class="m-0 mt-1 text-xs text-on-surface-variant">{handleLabel()}</p> 271 + <ModerationBadgeRow class="mt-1" decision={profileDecision()} labels={profileLabels()} /> 262 272 <Show 263 273 when={props.kind === "quotes"} 264 274 fallback={ ··· 299 309 return null; 300 310 } 301 311 302 - return { did, handle, avatar: item.profile?.avatar ?? null, displayName: item.profile?.displayName ?? null }; 312 + return { 313 + did, 314 + handle, 315 + avatar: item.profile?.avatar ?? null, 316 + displayName: item.profile?.displayName ?? null, 317 + labels: item.profile?.labels ?? null, 318 + }; 303 319 } 304 320 305 321 function PanelMessage(props: { body: string; title: string }) {
+38 -1
src/components/posts/tests/PostEngagementPanel.test.tsx
··· 4 4 import { PostEngagementPanel } from "../PostEngagementPanel"; 5 5 6 6 const getRecordBacklinksMock = vi.hoisted(() => vi.fn()); 7 + const moderateContentMock = vi.hoisted(() => vi.fn()); 7 8 const postNavigationMock = vi.hoisted(() => ({ 8 9 backFromPost: vi.fn(), 9 10 buildPostHref: vi.fn(), ··· 12 13 openPostScreen: vi.fn(), 13 14 })); 14 15 15 - vi.mock("$/lib/api/diagnostics", () => ({ getRecordBacklinks: getRecordBacklinksMock })); 16 + vi.mock("$/lib/api/diagnostics", () => ({ DiagnosticsController: { getRecordBacklinks: getRecordBacklinksMock } })); 16 17 vi.mock("$/components/posts/hooks/usePostNavigation", () => ({ usePostNavigation: () => postNavigationMock })); 18 + vi.mock("$/lib/api/moderation", () => ({ ModerationController: { moderateContent: moderateContentMock } })); 17 19 18 20 const POST_URI = "at://did:plc:alice/app.bsky.feed.post/123"; 19 21 ··· 29 31 describe("PostEngagementPanel", () => { 30 32 beforeEach(() => { 31 33 vi.resetAllMocks(); 34 + moderateContentMock.mockResolvedValue({ 35 + alert: false, 36 + blur: "none", 37 + filter: false, 38 + inform: false, 39 + noOverride: false, 40 + }); 32 41 getRecordBacklinksMock.mockResolvedValue({ 33 42 likes: { 34 43 cursor: null, ··· 88 97 89 98 await waitFor(() => expect(globalThis.location.hash).toContain("tab=reposts")); 90 99 expect(await screen.findByText("Dana")).toBeInTheDocument(); 100 + }); 101 + 102 + it("renders profile moderation badges for labeled engagement actors", async () => { 103 + getRecordBacklinksMock.mockResolvedValueOnce({ 104 + likes: { 105 + cursor: null, 106 + records: [{ 107 + did: "did:plc:bob", 108 + profile: { handle: "bob.test", displayName: "Bob", labels: [{ src: "did:plc:labeler", val: "sexual" }] }, 109 + uri: "at://did:plc:bob/app.bsky.feed.like/1", 110 + }], 111 + total: 1, 112 + }, 113 + quotes: { cursor: null, records: [], total: 0 }, 114 + replies: { cursor: null, records: [], total: 0 }, 115 + reposts: { cursor: null, records: [], total: 0 }, 116 + }); 117 + moderateContentMock.mockImplementation(async (_labels, context: string) => { 118 + if (context === "profileList") { 119 + return { alert: true, blur: "none", filter: false, inform: false, noOverride: false }; 120 + } 121 + 122 + return { alert: false, blur: "none", filter: false, inform: false, noOverride: false }; 123 + }); 124 + 125 + renderPanel(); 126 + expect(await screen.findByText("Bob")).toBeInTheDocument(); 127 + expect(await screen.findByText("Alert")).toBeInTheDocument(); 91 128 }); 92 129 });
+3 -1
src/components/profile/FollowHygienePanel.tsx
··· 86 86 } 87 87 88 88 function deriveFilters( 89 - selectedUris: Set<string>, flagged: FlaggedFollow[], filters: Record<StatusCategoryKey, StatusCategoryState>, 89 + selectedUris: Set<string>, 90 + flagged: FlaggedFollow[], 91 + filters: Record<StatusCategoryKey, StatusCategoryState>, 90 92 ) { 91 93 const nextFilters = { ...filters }; 92 94 for (const category of STATUS_CATEGORIES) {
+1 -1
src/components/profile/FollowHygieneToolbar.tsx
··· 25 25 </div> 26 26 <p class="m-0 text-xs text-on-surface-variant"> 27 27 Scanning batches: {Math.min(props.current, props.total)} / {props.total} 28 - <Show when={props.batchSize > 0}> ({props.batchSize} per batch)</Show> 28 + <Show when={props.batchSize > 0}>({props.batchSize} per batch)</Show> 29 29 </p> 30 30 </div> 31 31 );
+19 -14
src/components/profile/ProfileActorList.tsx
··· 1 + import { useModerationDecision } from "$/components/moderation/hooks/useModerationDecision"; 2 + import { ModeratedAvatar } from "$/components/moderation/ModeratedAvatar"; 3 + import { ModerationBadgeRow } from "$/components/moderation/ModerationBadgeRow"; 1 4 import { Icon } from "$/components/shared/Icon"; 2 5 import { getAvatarLabel, getDisplayName } from "$/lib/feeds"; 3 - import type { ProfileViewBasic } from "$/lib/types"; 6 + import { collectModerationLabels } from "$/lib/moderation"; 7 + import type { ModerationLabel, ModerationUiDecision, ProfileViewBasic } from "$/lib/types"; 4 8 import { createMemo, For, onMount, Show } from "solid-js"; 5 9 import { Motion } from "solid-motionone"; 6 10 import type { ActorListState } from "./profile-state"; ··· 97 101 const label = createMemo(() => getAvatarLabel(props.actor)); 98 102 const name = createMemo(() => getDisplayName(props.actor)); 99 103 const isFollowing = createMemo(() => !!props.actor.viewer?.following); 104 + const labels = () => collectModerationLabels(props.actor); 105 + const avatarDecision = useModerationDecision(labels, "avatar"); 106 + const profileDecision = useModerationDecision(labels, "profileList"); 100 107 101 108 return ( 102 109 <article class="tone-muted rounded-3xl p-4 shadow-(--inset-shadow)"> ··· 105 112 class="flex min-w-0 flex-1 items-start gap-3 border-0 bg-transparent p-0 text-left transition hover:opacity-90" 106 113 type="button" 107 114 onClick={() => props.onSelect()}> 108 - <div class="h-11 w-11 shrink-0 overflow-hidden rounded-full bg-surface-container-high"> 109 - <Show 110 - when={props.actor.avatar} 111 - fallback={ 112 - <div class="flex h-full w-full items-center justify-center text-sm font-semibold text-on-surface"> 113 - {label()} 114 - </div> 115 - }> 116 - {(avatar) => <img alt="" class="h-full w-full object-cover" src={avatar()} />} 117 - </Show> 118 - </div> 119 - <ActorCardDetails actor={props.actor} name={name()} /> 115 + <ModeratedAvatar 116 + avatar={props.actor.avatar} 117 + class="h-11 w-11 shrink-0 overflow-hidden rounded-full bg-surface-container-high" 118 + hidden={avatarDecision().filter || avatarDecision().blur !== "none"} 119 + label={label()} 120 + fallbackClass="text-sm font-semibold text-on-surface" /> 121 + <ActorCardDetails actor={props.actor} decision={profileDecision()} labels={labels()} name={name()} /> 120 122 </button> 121 123 122 124 <Show when={!props.isSelf}> ··· 131 133 ); 132 134 } 133 135 134 - function ActorCardDetails(props: { actor: ProfileViewBasic; name: string }) { 136 + function ActorCardDetails( 137 + props: { actor: ProfileViewBasic; decision: ModerationUiDecision; labels: ModerationLabel[]; name: string }, 138 + ) { 135 139 return ( 136 140 <div class="grid min-w-0 flex-1 gap-1"> 137 141 <div class="flex flex-wrap items-center gap-2"> ··· 147 151 </p> 148 152 )} 149 153 </Show> 154 + <ModerationBadgeRow class="mt-1" decision={props.decision} labels={props.labels} /> 150 155 </div> 151 156 ); 152 157 }
+13 -5
src/components/profile/ProfileHero.tsx
··· 4 4 import { Icon } from "$/components/shared/Icon"; 5 5 import { getAvatarLabel, getDisplayName } from "$/lib/feeds"; 6 6 import { collectModerationLabels } from "$/lib/moderation"; 7 - import type { ProfileViewDetailed } from "$/lib/types"; 7 + import type { ModerationLabel, ModerationUiDecision, ProfileViewDetailed } from "$/lib/types"; 8 8 import { formatCount } from "$/lib/utils/text"; 9 9 import { createMemo, For, Show } from "solid-js"; 10 10 ··· 14 14 followLoading: boolean; 15 15 isFollowing: boolean; 16 16 isSelf: boolean; 17 + moderationDecision: ModerationUiDecision; 18 + moderationLabels: ModerationLabel[]; 17 19 onFollow: () => void; 18 20 onMessage: () => void; 19 21 onOpenFollowHygiene: () => void; ··· 43 45 </button> 44 46 </Show> 45 47 <ProfileBadgeRow badges={props.badges} isSelf={props.isSelf} /> 48 + <ModerationBadgeRow 49 + class="mt-1 justify-end" 50 + decision={props.moderationDecision} 51 + labels={props.moderationLabels} /> 46 52 </div> 47 53 ); 48 54 } ··· 257 263 followLoading={props.followLoading} 258 264 isFollowing={isFollowing()} 259 265 isSelf={props.isSelf} 266 + moderationDecision={profileDecision()} 267 + moderationLabels={profileLabels()} 260 268 onFollow={props.onFollow} 261 269 onMessage={props.onMessage} 262 270 onOpenFollowHygiene={props.onOpenFollowHygiene} 263 271 onUnfollow={props.onUnfollow} /> 264 272 </div> 265 - 266 - <ModerationBadgeRow decision={profileDecision()} labels={profileLabels()} /> 267 273 268 274 <ProfileMetaRow 269 275 did={props.profile.did} ··· 303 309 const displayName = createMemo(() => getDisplayName(props.profile)); 304 310 const visibleBadges = createMemo(() => props.profileBadges.slice(0, 2)); 305 311 const labels = () => collectModerationLabels(props.profile); 306 - const decision = useModerationDecision(labels, "avatar"); 312 + const avatarDecision = useModerationDecision(labels, "avatar"); 313 + const profileDecision = useModerationDecision(labels, "profileView"); 307 314 308 315 return ( 309 316 <div ··· 313 320 <ModeratedAvatar 314 321 avatar={props.profile.avatar} 315 322 class="relative h-12 w-12 shrink-0 overflow-hidden rounded-full bg-surface-container-high shadow-[0_0_0_2px_var(--surface),0_0_0_3px_rgba(125,175,255,0.22)]" 316 - hidden={decision().filter || decision().blur !== "none"} 323 + hidden={avatarDecision().filter || avatarDecision().blur !== "none"} 317 324 label={avatarLabel()} 318 325 fallbackClass="text-sm font-semibold text-on-surface" /> 319 326 ··· 324 331 <p class="m-0 truncate text-sm leading-tight text-on-surface-variant"> 325 332 @{props.profile.handle.replace(/^@/, "")} 326 333 </p> 334 + <ModerationBadgeRow class="mt-1" decision={profileDecision()} labels={labels()} /> 327 335 </div> 328 336 329 337 <Show when={visibleBadges().length > 0}>
+63 -6
src/components/profile/tests/ProfilePanel.test.tsx
··· 17 17 const getFollowersMock = vi.hoisted(() => vi.fn()); 18 18 const getFollowsMock = vi.hoisted(() => vi.fn()); 19 19 const getProfileMock = vi.hoisted(() => vi.fn()); 20 + const moderateContentMock = vi.hoisted(() => vi.fn()); 20 21 const navigateMock = vi.hoisted(() => vi.fn()); 21 22 const unfollowActorMock = vi.hoisted(() => vi.fn()); 22 23 const postNavigationMock = vi.hoisted(() => ({ ··· 45 46 vi.mock( 46 47 "$/lib/api/diagnostics", 47 48 () => ({ 48 - getAccountBlockedBy: getAccountBlockedByMock, 49 - getAccountBlocking: getAccountBlockingMock, 50 - getAccountLabels: getAccountLabelsMock, 51 - getAccountLists: getAccountListsMock, 52 - getAccountStarterPacks: getAccountStarterPacksMock, 53 - getRecordBacklinks: getRecordBacklinksMock, 49 + DiagnosticsController: { 50 + getAccountBlockedBy: getAccountBlockedByMock, 51 + getAccountBlocking: getAccountBlockingMock, 52 + getAccountLabels: getAccountLabelsMock, 53 + getAccountLists: getAccountListsMock, 54 + getAccountStarterPacks: getAccountStarterPacksMock, 55 + getRecordBacklinks: getRecordBacklinksMock, 56 + }, 54 57 }), 55 58 ); 59 + 60 + vi.mock("$/lib/api/moderation", () => ({ ModerationController: { moderateContent: moderateContentMock } })); 56 61 57 62 vi.mock("@solidjs/router", () => ({ useNavigate: () => navigateMock })); 58 63 vi.mock("$/components/posts/hooks/usePostNavigation", () => ({ usePostNavigation: () => postNavigationMock })); ··· 135 140 batchUnfollowMock.mockResolvedValue({ deleted: 0, failed: [] }); 136 141 followActorMock.mockResolvedValue({ cid: "cid-follow", uri: "at://did:plc:alice/app.bsky.graph.follow/1" }); 137 142 unfollowActorMock.mockResolvedValue(void 0); 143 + moderateContentMock.mockResolvedValue({ 144 + alert: false, 145 + blur: "none", 146 + filter: false, 147 + inform: false, 148 + noOverride: false, 149 + }); 138 150 }); 139 151 140 152 it("shows follow hygiene entry on the signed-in profile", async () => { ··· 147 159 expect(await screen.findByRole("button", { name: "Audit follows" })).toBeInTheDocument(); 148 160 }); 149 161 162 + it("shows profile labels beneath the current account badge when labels are present", async () => { 163 + getProfileMock.mockResolvedValueOnce({ 164 + status: "available", 165 + profile: { ...createProfile(), labels: [{ src: "did:plc:labeler", val: "my-label" }] }, 166 + }); 167 + 168 + renderProfilePanel("bob.test", { 169 + activeDid: "did:plc:bob", 170 + activeHandle: "bob.test", 171 + activeSession: { did: "did:plc:bob", handle: "bob.test" }, 172 + }); 173 + 174 + expect(await screen.findByText("Current account")).toBeInTheDocument(); 175 + expect(await screen.findByText(/my-label/i)).toBeInTheDocument(); 176 + }); 177 + 150 178 it("optimistically follows and unfollows from the hero while keeping badges in sync", async () => { 151 179 const followRequest = deferred<{ cid: string; uri: string }>(); 152 180 followActorMock.mockReturnValueOnce(followRequest.promise); ··· 248 276 await waitFor(() => { 249 277 expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); 250 278 }); 279 + }); 280 + 281 + it("renders moderation badges in follower rows when labels are present", async () => { 282 + getFollowersMock.mockResolvedValueOnce({ 283 + actors: [{ 284 + description: "Writes about decentralised UI and protocol design.", 285 + did: "did:plc:charlie", 286 + displayName: "Charlie", 287 + handle: "charlie.test", 288 + labels: [{ src: "did:plc:labeler", val: "sexual" }], 289 + viewer: { following: null }, 290 + }], 291 + cursor: null, 292 + }); 293 + moderateContentMock.mockImplementation(async (_labels, context: string) => { 294 + if (context === "profileList") { 295 + return { alert: true, blur: "none", filter: false, inform: false, noOverride: false }; 296 + } 297 + 298 + return { alert: false, blur: "none", filter: false, inform: false, noOverride: false }; 299 + }); 300 + 301 + renderProfilePanel(); 302 + expect(await screen.findByRole("button", { name: "Follow" })).toBeInTheDocument(); 303 + 304 + fireEvent.click(screen.getByRole("button", { name: /followers/i })); 305 + const dialog = await screen.findByRole("dialog"); 306 + 307 + expect(await within(dialog).findByText("Alert")).toBeInTheDocument(); 251 308 }); 252 309 253 310 it("renders diagnostics in the Context tab without making it the default tab", async () => {
+20 -7
src/components/search/SearchPanel.tsx
··· 1 1 import { ActorSuggestionList, getActorSuggestionHeadline } from "$/components/actors/ActorSearch"; 2 - import { AvatarBadge } from "$/components/AvatarBadge"; 3 2 import { PostCard } from "$/components/feeds/PostCard"; 3 + import { useModerationDecision } from "$/components/moderation/hooks/useModerationDecision"; 4 + import { ModeratedAvatar } from "$/components/moderation/ModeratedAvatar"; 5 + import { ModerationBadgeRow } from "$/components/moderation/ModerationBadgeRow"; 4 6 import { Icon, SearchModeIcon } from "$/components/shared/Icon"; 5 7 import type { 6 8 ActorResult, ··· 9 11 NetworkSearchResult, 10 12 SearchMode, 11 13 } from "$/lib/api/types/search"; 14 + import { getAvatarLabel } from "$/lib/feeds"; 15 + import { collectModerationLabels } from "$/lib/moderation"; 12 16 import type { PostSearchFilters, SearchTab } from "$/lib/search-routes"; 13 - import type { ProfileViewBasic } from "$/lib/types"; 17 + import type { ModerationUiDecision, ProfileViewBasic } from "$/lib/types"; 14 18 import { createContext, createEffect, createMemo, createSignal, For, Match, Show, Switch, useContext } from "solid-js"; 15 19 import { Motion, Presence } from "solid-motionone"; 16 20 import { PostCount } from "../shared/PostCount"; ··· 648 652 }; 649 653 650 654 function ActorResultCard(props: ActorResultCardProps) { 655 + const labels = () => collectModerationLabels(props.actor); 656 + const avatarLabel = createMemo(() => getAvatarLabel(props.actor)); 657 + const avatarDecision = useModerationDecision(labels, "avatar"); 658 + const profileDecision = useModerationDecision(labels, "profileList"); 659 + 651 660 return ( 652 661 <button 653 662 type="button" 654 663 aria-label={`Open profile ${getActorSuggestionHeadline(props.actor)}`} 655 664 class="grid w-full gap-3 rounded-3xl border-0 bg-white/[0.035] p-4 text-left shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)] transition duration-150 hover:-translate-y-px hover:bg-white/5.5" 656 665 onClick={() => props.onOpenActor(props.actor)}> 657 - <ActorResultHeader actor={props.actor} /> 666 + <ActorResultHeader actor={props.actor} avatarDecision={avatarDecision()} avatarLabel={avatarLabel()} /> 658 667 <Show when={props.actor.description?.trim()}> 659 668 <p class="m-0 text-sm leading-relaxed text-on-surface-variant">{props.actor.description?.trim()}</p> 660 669 </Show> 670 + <ModerationBadgeRow class="mt-1" decision={profileDecision()} labels={labels()} /> 661 671 <p class="m-0 truncate font-mono text-[0.7rem] text-on-surface-variant/80">{props.actor.did}</p> 662 672 </button> 663 673 ); 664 674 } 665 675 666 - function ActorResultHeader(props: { actor: ActorSearchResult["actors"][number] }) { 676 + function ActorResultHeader(props: { actor: ActorResult; avatarDecision: ModerationUiDecision; avatarLabel: string }) { 667 677 return ( 668 678 <div class="flex items-start gap-3"> 669 - <Show when={props.actor.avatar} fallback={<AvatarBadge label={props.actor.handle} tone="muted" />}> 670 - {(avatar) => <img class="h-12 w-12 rounded-full object-cover" src={avatar()} alt="" loading="lazy" />} 671 - </Show> 679 + <ModeratedAvatar 680 + avatar={props.actor.avatar} 681 + class="h-12 w-12 shrink-0 overflow-hidden rounded-full bg-surface-container-high" 682 + hidden={props.avatarDecision.filter || props.avatarDecision.blur !== "none"} 683 + label={props.avatarLabel} 684 + fallbackClass="text-sm font-semibold text-on-surface" /> 672 685 <div class="min-w-0 flex-1"> 673 686 <div class="flex flex-wrap items-center gap-2"> 674 687 <p class="m-0 truncate text-sm font-medium text-on-surface">{getActorSuggestionHeadline(props.actor)}</p>
+37
src/components/search/tests/SearchPanel.test.tsx
··· 11 11 const searchPostsNetworkMock = vi.hoisted(() => vi.fn()); 12 12 const getSyncStatusMock = vi.hoisted(() => vi.fn()); 13 13 const syncPostsMock = vi.hoisted(() => vi.fn()); 14 + const moderateContentMock = vi.hoisted(() => vi.fn()); 14 15 const postNavigationMock = vi.hoisted(() => ({ backFromPost: vi.fn(), buildPostHref: vi.fn(), openPost: vi.fn() })); 15 16 16 17 vi.mock( ··· 35 36 }), 36 37 ); 37 38 vi.mock("$/components/posts/usePostNavigation", () => ({ usePostNavigation: () => postNavigationMock })); 39 + vi.mock("$/lib/api/moderation", () => ({ ModerationController: { moderateContent: moderateContentMock } })); 38 40 39 41 vi.mock("@tauri-apps/plugin-log", () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); 40 42 ··· 65 67 searchPostsNetworkMock.mockReset(); 66 68 getSyncStatusMock.mockReset(); 67 69 syncPostsMock.mockReset(); 70 + moderateContentMock.mockReset(); 68 71 postNavigationMock.openPost.mockReset(); 69 72 70 73 getSyncStatusMock.mockResolvedValue([]); 71 74 searchActorTypeaheadMock.mockResolvedValue([]); 72 75 searchActorsMock.mockResolvedValue({ actors: [], cursor: null }); 76 + moderateContentMock.mockResolvedValue({ 77 + alert: false, 78 + blur: "none", 79 + filter: false, 80 + inform: false, 81 + noOverride: false, 82 + }); 73 83 syncPostsMock.mockResolvedValue({ 74 84 did: "did:plc:test", 75 85 source: "like", ··· 250 260 await flushRouter(); 251 261 252 262 expect(globalThis.location.hash).toBe("#/profile/bob.test"); 263 + }); 264 + 265 + it("renders moderation badges for labeled profile search results", async () => { 266 + searchActorsMock.mockResolvedValue({ 267 + actors: [{ 268 + avatar: null, 269 + description: "Builds search systems.", 270 + did: "did:plc:bob", 271 + displayName: "Bob Example", 272 + handle: "bob.test", 273 + labels: [{ src: "did:plc:labeler", val: "sexual" }], 274 + }], 275 + cursor: null, 276 + }); 277 + moderateContentMock.mockImplementation(async (_labels, context: string) => { 278 + if (context === "profileList") { 279 + return { alert: true, blur: "none", filter: false, inform: false, noOverride: false }; 280 + } 281 + 282 + return { alert: false, blur: "none", filter: false, inform: false, noOverride: false }; 283 + }); 284 + 285 + renderSearchPanel("#/search?tab=profiles&q=bob"); 286 + await vi.advanceTimersByTimeAsync(350); 287 + 288 + expect(await screen.findByText("Builds search systems.")).toBeInTheDocument(); 289 + expect(screen.getByText("Alert")).toBeInTheDocument(); 253 290 }); 254 291 });
+9 -9
src/lib/api/conversations.ts
··· 1 + import { asModerationLabels } from "$/lib/moderation"; 1 2 import type { 2 3 ConvoView, 3 4 DeletedMessageView, ··· 31 32 did: record.did, 32 33 displayName: optionalString(record.displayName), 33 34 handle: record.handle, 35 + labels: asModerationLabels(record), 34 36 viewer: viewer ? { following: optionalString(viewer.following) } : null, 35 37 }; 36 38 } ··· 193 195 return message; 194 196 } 195 197 196 - export async function listConvos(cursor?: string | null, limit?: number): Promise<ListConvosResponse> { 198 + async function listConvos(cursor?: string | null, limit?: number): Promise<ListConvosResponse> { 197 199 return invoke("list_convos", { cursor: cursor ?? null, limit: limit ?? null }).then(parseListConvosResponse); 198 200 } 199 201 200 - export async function getConvoForMembers(members: string[]): Promise<GetConvoForMembersResponse> { 202 + async function getConvoForMembers(members: string[]): Promise<GetConvoForMembersResponse> { 201 203 return invoke("get_convo_for_members", { members }).then(parseGetConvoForMembersResponse); 202 204 } 203 205 204 - export async function getMessages( 205 - convoId: string, 206 - cursor?: string | null, 207 - limit?: number, 208 - ): Promise<GetMessagesResponse> { 206 + async function getMessages(convoId: string, cursor?: string | null, limit?: number): Promise<GetMessagesResponse> { 209 207 return invoke("get_messages", { convoId, cursor: cursor ?? null, limit: limit ?? null }).then( 210 208 parseGetMessagesResponse, 211 209 ); 212 210 } 213 211 214 - export async function sendMessage(convoId: string, text: string): Promise<MessageView> { 212 + async function sendMessage(convoId: string, text: string): Promise<MessageView> { 215 213 return invoke("send_message", { convoId, text }).then(parseSendMessageResponse); 216 214 } 217 215 218 - export async function updateRead(convoId: string, messageId?: string | null): Promise<void> { 216 + async function updateRead(convoId: string, messageId?: string | null): Promise<void> { 219 217 return invoke("update_read", { convoId, messageId: messageId ?? null }); 220 218 } 219 + 220 + export const ConvoController = { listConvos, getConvoForMembers, getMessages, sendMessage, updateRead };
+24 -8
src/lib/api/diagnostics.ts
··· 1 - import type { ProfileUnavailableReason } from "$/lib/types"; 1 + import type { ModerationLabel, ProfileUnavailableReason } from "$/lib/types"; 2 2 import { invoke } from "@tauri-apps/api/core"; 3 3 4 - type TProfile = { did?: string | null; handle?: string | null; displayName?: string | null; avatar?: string | null }; 4 + type TProfile = { 5 + did?: string | null; 6 + handle?: string | null; 7 + displayName?: string | null; 8 + avatar?: string | null; 9 + labels?: ModerationLabel[] | null; 10 + }; 5 11 6 12 type TAvailability = "available" | "unavailable"; 7 13 ··· 80 86 }; 81 87 82 88 type AccountListsResult = { lists: DiagnosticList[]; total: number; truncated: boolean }; 89 + 83 90 type AccountLabelsResult = { 84 91 labels: DiagnosticLabel[]; 85 92 sourceProfiles: Record<string, unknown>; ··· 100 107 quotes: DiagnosticBacklinkGroup; 101 108 }; 102 109 103 - export function getAccountLists(did: string): Promise<AccountListsResult> { 110 + function getAccountLists(did: string): Promise<AccountListsResult> { 104 111 return invoke("get_account_lists", { did }); 105 112 } 106 113 107 - export function getAccountLabels(did: string): Promise<AccountLabelsResult> { 114 + function getAccountLabels(did: string): Promise<AccountLabelsResult> { 108 115 return invoke("get_account_labels", { did }); 109 116 } 110 117 111 - export function getAccountBlockedBy( 118 + function getAccountBlockedBy( 112 119 did: string, 113 120 limit?: number | null, 114 121 cursor?: string | null, ··· 116 123 return invoke("get_account_blocked_by", { did, limit: limit ?? null, cursor: cursor ?? null }); 117 124 } 118 125 119 - export function getAccountBlocking(did: string, cursor?: string | null): Promise<AccountBlockingResult> { 126 + function getAccountBlocking(did: string, cursor?: string | null): Promise<AccountBlockingResult> { 120 127 return invoke("get_account_blocking", { did, cursor: cursor ?? null }); 121 128 } 122 129 123 - export function getAccountStarterPacks(did: string): Promise<AccountStarterPacksResult> { 130 + function getAccountStarterPacks(did: string): Promise<AccountStarterPacksResult> { 124 131 return invoke("get_account_starter_packs", { did }); 125 132 } 126 133 127 - export function getRecordBacklinks(uri: string): Promise<RecordBacklinksResult> { 134 + function getRecordBacklinks(uri: string): Promise<RecordBacklinksResult> { 128 135 return invoke("get_record_backlinks", { uri }); 129 136 } 137 + 138 + export const DiagnosticsController = { 139 + getAccountLists, 140 + getAccountLabels, 141 + getAccountBlockedBy, 142 + getAccountBlocking, 143 + getAccountStarterPacks, 144 + getRecordBacklinks, 145 + };
+7 -1
src/lib/api/feeds.ts
··· 8 8 UserPreferences, 9 9 } from "$/lib/types"; 10 10 import { invoke } from "@tauri-apps/api/core"; 11 + import * as logger from "@tauri-apps/plugin-log"; 11 12 12 13 function getPreferences() { 13 14 return invoke<UserPreferences>("get_preferences"); 14 15 } 15 16 16 17 async function getFeedGenerators(uris: string[]) { 17 - return parseFeedGeneratorsResponse(await invoke("get_feed_generators", { uris })); 18 + try { 19 + return parseFeedGeneratorsResponse(await invoke("get_feed_generators", { uris })); 20 + } catch (error) { 21 + logger.warn(`getFeedGenerators failed; continuing without hydrated metadata: ${String(error)}`); 22 + return { feeds: [] }; 23 + } 18 24 } 19 25 20 26 async function getFeedPage(feed: SavedFeedItem, cursor: string | null, limit: number) {
+5 -1
src/lib/api/tests/conversations.test.ts
··· 37 37 38 38 describe("conversation payload parsers", () => { 39 39 it("parses the conversation list response", () => { 40 - const response = parseListConvosResponse({ convos: [createConvo()], cursor: "cursor-1" }); 40 + const response = parseListConvosResponse({ 41 + convos: [createConvo({ members: [createMember({ labels: [{ src: "did:plc:labeler", val: "sexual" }] })] })], 42 + cursor: "cursor-1", 43 + }); 41 44 42 45 expect(response.cursor).toBe("cursor-1"); 43 46 expect(response.convos).toHaveLength(1); 44 47 expect(response.convos[0]?.members[0]?.handle).toBe("bob.test"); 48 + expect(response.convos[0]?.members[0]?.labels).toEqual([{ src: "did:plc:labeler", val: "sexual" }]); 45 49 }); 46 50 47 51 it("parses a conversation lookup response", () => {
+2 -1
src/lib/api/types/search.ts
··· 1 - import type { PostView } from "$/lib/types"; 1 + import type { ModerationLabel, PostView } from "$/lib/types"; 2 2 3 3 export type SearchMode = "network" | "keyword" | "semantic" | "hybrid"; 4 4 ··· 24 24 displayName?: string | null; 25 25 avatar?: string | null; 26 26 description?: string | null; 27 + labels?: ModerationLabel[] | null; 27 28 }; 28 29 29 30 export type ActorSearchResult = { cursor?: string | null; actors: ActorResult[] };
+8
src/lib/types.ts
··· 2 2 3 3 export type ModerationLabel = { src?: string; uri?: string; val?: string; [key: string]: unknown }; 4 4 5 + export type ModerationUiBadge = { 6 + label: string; 7 + source: string; 8 + description?: string | null; 9 + tone?: "alert" | "inform" | "label" | string; 10 + }; 11 + 5 12 export type ModerationUiDecision = { 6 13 filter: boolean; 7 14 blur: "none" | "content" | "media" | string; 8 15 alert: boolean; 9 16 inform: boolean; 10 17 noOverride: boolean; 18 + badges?: ModerationUiBadge[] | null; 11 19 }; 12 20 13 21 export type ModerationLabelVisibility = "ignore" | "warn" | "hide";