this repo has no description
at main 2616 lines 92 kB view raw
1use bevy::asset::Handle; 2use bevy::image::{ImageFormatSetting, ImageLoaderSettings}; 3use bevy::prelude::*; 4 5use jacquard::Data; 6use jacquard::jetstream::JetstreamMessage; 7use jacquard::types::collection::Collection; 8use jacquard::types::string::{AtUri, Nsid}; 9 10use jacquard::client::AgentSessionExt; 11 12use crate::ingest::JetstreamEventMarker; 13use crate::interaction::SelectedEvent; 14use smol_str::ToSmolStr; 15 16/// Root node of the event detail card. 17#[derive(Component)] 18pub struct DetailCard; 19 20/// The avatar image node inside the author row. 21#[derive(Component)] 22pub struct AvatarImage; 23 24/// The display name text node inside the author name column. 25#[derive(Component)] 26pub struct DisplayNameText; 27 28/// The handle text node inside the author name column. 29#[derive(Component)] 30pub struct HandleText; 31 32/// The post content text node. 33#[derive(Component)] 34pub struct ContentText; 35 36/// The collection NSID label at the bottom of the card. 37#[derive(Component)] 38pub struct CollectionLabel; 39 40/// The timestamp label at the bottom of the card. 41#[derive(Component)] 42pub struct TimestampLabel; 43 44/// Container for embed content (images, quoted posts, external links). 45#[derive(Component)] 46pub struct EmbedSection; 47 48/// A single image thumbnail in the images row. 49#[derive(Component)] 50pub struct EmbedImage; 51 52/// The images row container (flex-wrap, up to 4 thumbnails). 53#[derive(Component)] 54pub struct ImagesRow; 55 56/// Quoted post container within the embed section. 57#[derive(Component)] 58pub struct QuotedPostBox; 59 60/// Author text inside a quoted post. 61#[derive(Component)] 62pub struct QuotedPostAuthor; 63 64/// Text content inside a quoted post. 65#[derive(Component)] 66pub struct QuotedPostText; 67 68/// External link card within the embed section. 69#[derive(Component)] 70pub struct ExternalLinkBox; 71 72/// Title of an external link embed. 73#[derive(Component)] 74pub struct ExternalLinkTitle; 75 76/// Description of an external link embed. 77#[derive(Component)] 78pub struct ExternalLinkDescription; 79 80/// Thumbnail image for an external link embed. 81#[derive(Component)] 82pub struct ExternalLinkThumb; 83 84/// Subject section (for likes: the liked post, for follows: the followed user). 85#[derive(Component)] 86pub struct SubjectSection; 87 88/// Label inside subject section ("Liked:" / "Followed:"). 89#[derive(Component)] 90pub struct SubjectLabel; 91 92/// Content inside subject section (post text or profile info). 93#[derive(Component)] 94pub struct SubjectContent; 95 96/// Subject author info (display name + handle for liked post author). 97#[derive(Component)] 98pub struct SubjectAuthor; 99 100/// Stats row at the bottom of the card. 101#[derive(Component)] 102pub struct StatsRow; 103 104/// Stats text ("42 likes · 7 reposts"). 105#[derive(Component)] 106pub struct StatsText; 107 108/// Container for slide content when in presentation mode. 109#[derive(Component)] 110pub struct SlideContent; 111 112/// Tracks what cache generation was last rendered so the card 113/// re-renders when new data arrives without re-rendering every frame. 114#[derive(Resource, Default)] 115pub struct CardRenderState { 116 pub last_profile_gen: u64, 117 pub last_record_gen: u64, 118 /// Tracks which slide was last rendered (for change detection). 119 pub last_slide_index: Option<usize>, 120 /// Bumped when slide content changes (hot-reload), forces re-render. 121 pub slide_generation: u64, 122 pub last_slide_generation: u64, 123} 124 125/// Top-level content produced by dispatch_card_content. 126pub enum CardContent { 127 Post { 128 text: String, 129 embed: Option<EmbedContent>, 130 }, 131 Like { 132 subject_uri: String, 133 subject: Option<Box<CardContent>>, 134 }, 135 Follow { 136 subject_did: String, 137 subject_profile: Option<SubjectProfile>, 138 }, 139 Identity { 140 handle: Option<String>, 141 }, 142 Account { 143 active: bool, 144 status: Option<String>, 145 }, 146 Unknown { 147 collection: String, 148 fields: Vec<FieldDisplay>, 149 }, 150} 151 152pub enum EmbedContent { 153 Images(Vec<ImageEmbed>), 154 External { 155 title: String, 156 description: String, 157 thumb_cid: Option<String>, 158 author_did: String, 159 }, 160 Record { 161 uri: String, 162 content: Option<Box<CardContent>>, 163 }, 164 RecordWithMedia { 165 record_uri: String, 166 content: Option<Box<CardContent>>, 167 images: Vec<ImageEmbed>, 168 }, 169} 170 171pub struct ImageEmbed { 172 pub cid: String, 173 pub alt: String, 174 pub author_did: String, 175 /// Aspect ratio width/height from the embed record, if available. 176 pub aspect_ratio: Option<(u32, u32)>, 177} 178 179pub struct SubjectProfile { 180 pub display_name: Option<String>, 181 pub handle: String, 182} 183 184pub struct FieldDisplay { 185 pub key: String, 186 pub value: DataDisplay, 187} 188 189pub enum DataDisplay { 190 Text(String), 191 Number(i64), 192 Bool(bool), 193 Blob { 194 mime: String, 195 cid: String, 196 author_did: String, 197 }, 198 Link(String), 199 Array { 200 count: usize, 201 preview: Vec<DataDisplay>, 202 }, 203 Object { 204 type_hint: Option<String>, 205 fields: Vec<FieldDisplay>, 206 }, 207 Null, 208} 209 210impl CardContent { 211 /// Flatten the content tree into a primary display string. 212 pub fn primary_text(&self) -> String { 213 match self { 214 CardContent::Post { text, .. } => text.clone(), 215 // Likes and follows show their content via the subject section, not here 216 CardContent::Like { .. } => String::new(), 217 CardContent::Follow { .. } => String::new(), 218 CardContent::Identity { handle } => { 219 format!( 220 "Identity update\nHandle: {}", 221 handle.as_deref().unwrap_or("(none)") 222 ) 223 } 224 CardContent::Account { active, status } => { 225 format!( 226 "Account event\nActive: {active}\nStatus: {}", 227 status.as_deref().unwrap_or("none") 228 ) 229 } 230 CardContent::Unknown { collection, fields } => { 231 let mut lines = vec![collection.clone()]; 232 for f in fields.iter().take(12) { 233 lines.push(format!("{}: {}", f.key, f.value.display_string(0))); 234 } 235 lines.join("\n") 236 } 237 } 238 } 239} 240 241impl DataDisplay { 242 fn display_string(&self, depth: usize) -> String { 243 if depth > 3 { 244 return "".into(); 245 } 246 let indent = " ".repeat(depth); 247 match self { 248 DataDisplay::Text(s) => s.clone(), 249 DataDisplay::Number(n) => n.to_string(), 250 DataDisplay::Bool(b) => b.to_string(), 251 DataDisplay::Null => "null".into(), 252 DataDisplay::Link(uri) => uri.clone(), 253 DataDisplay::Blob { mime, .. } => format!("[{mime}]"), 254 DataDisplay::Array { count, preview } => { 255 let mut lines = vec![format!("[{count} items]")]; 256 for (i, item) in preview.iter().enumerate() { 257 lines.push(format!( 258 "{indent} [{i}]: {}", 259 item.display_string(depth + 1) 260 )); 261 } 262 if *count > preview.len() { 263 lines.push(format!("{indent}{} more", count - preview.len())); 264 } 265 lines.join("\n") 266 } 267 DataDisplay::Object { type_hint, fields } => { 268 let mut lines = Vec::new(); 269 if let Some(hint) = type_hint { 270 lines.push(format!("[{hint}]")); 271 } 272 for f in fields.iter().take(8) { 273 lines.push(format!( 274 "{indent} {}: {}", 275 f.key, 276 f.value.display_string(depth + 1) 277 )); 278 } 279 if fields.len() > 8 { 280 lines.push(format!("{indent}{} more fields", fields.len() - 8)); 281 } 282 lines.join("\n") 283 } 284 } 285 } 286} 287 288/// Spawn the detail card UI hierarchy. Starts hidden. 289/// 290/// ```text 291/// Card (absolute, right:16, top:16, 520px) 292/// ├── Author row (avatar + name column) 293/// ├── Content text 294/// ├── Embed section (images / quoted post / external link) 295/// ├── Subject section (liked post / followed user) 296/// ├── Stats row 297/// ├── Footer (collection label + timestamp) 298/// ``` 299pub fn setup_detail_card(mut commands: Commands, font: Res<crate::UiFont>) { 300 // Blueprint palette — cyan glow 301 let glow = Color::srgba(0.6, 0.8, 1.0, 0.5); 302 let border_color = glow; 303 let dim = TextColor(Color::srgba(0.6, 0.7, 0.9, 0.7)); 304 let bright = TextColor(Color::srgb(0.8, 0.9, 1.0)); 305 306 let font_handle = font.primary.clone(); 307 let make_font = |size: f32| TextFont { 308 font: font_handle.clone(), 309 font_size: size, 310 ..default() 311 }; 312 313 let thin_border = UiRect::all(Val::Px(1.0)); 314 315 commands 316 .spawn(( 317 DetailCard, 318 Node { 319 display: Display::None, 320 position_type: PositionType::Absolute, 321 right: Val::Px(16.0), 322 top: Val::Px(16.0), 323 width: Val::Px(720.0), 324 max_height: Val::Percent(90.0), 325 overflow: Overflow::clip_y(), 326 flex_direction: FlexDirection::Column, 327 padding: UiRect::all(Val::Px(16.0)), 328 row_gap: Val::Px(10.0), 329 border: thin_border, 330 ..default() 331 }, 332 BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.7)), 333 BorderColor::all(border_color), 334 )) 335 .with_children(|card| { 336 // --- Author row --- 337 card.spawn(Node { 338 flex_direction: FlexDirection::Row, 339 align_items: AlignItems::Center, 340 column_gap: Val::Px(8.0), 341 ..default() 342 }) 343 .with_children(|row| { 344 // Avatar: square, thin border 345 row.spawn(( 346 AvatarImage, 347 Node { 348 width: Val::Px(72.0), 349 height: Val::Px(72.0), 350 border: thin_border, 351 ..default() 352 }, 353 ImageNode::default(), 354 BorderColor::all(border_color), 355 )); 356 357 // Name column 358 row.spawn(Node { 359 flex_direction: FlexDirection::Column, 360 row_gap: Val::Px(1.0), 361 ..default() 362 }) 363 .with_children(|col| { 364 col.spawn((DisplayNameText, Text::new(""), make_font(26.0), bright)); 365 col.spawn((HandleText, Text::new(""), make_font(22.0), dim)); 366 }); 367 }); 368 369 // --- Separator --- 370 card.spawn(( 371 Node { 372 height: Val::Px(1.0), 373 width: Val::Percent(100.0), 374 ..default() 375 }, 376 BackgroundColor(border_color), 377 )); 378 379 // --- Content area --- 380 card.spawn(Node { 381 max_width: Val::Percent(100.0), 382 ..default() 383 }) 384 .with_children(|content| { 385 content.spawn(( 386 ContentText, 387 Text::new(""), 388 TextLayout::new_with_linebreak(LineBreak::WordBoundary), 389 make_font(24.0), 390 bright, 391 )); 392 }); 393 394 // --- Embed section --- 395 card.spawn(( 396 EmbedSection, 397 Node { 398 display: Display::None, 399 flex_direction: FlexDirection::Column, 400 row_gap: Val::Px(4.0), 401 ..default() 402 }, 403 )) 404 .with_children(|embed| { 405 // Images row 406 embed.spawn(( 407 ImagesRow, 408 Node { 409 display: Display::None, 410 flex_direction: FlexDirection::Row, 411 flex_wrap: FlexWrap::Wrap, 412 column_gap: Val::Px(2.0), 413 row_gap: Val::Px(2.0), 414 ..default() 415 }, 416 )); 417 418 // Quoted post: thin border box 419 embed 420 .spawn(( 421 QuotedPostBox, 422 Node { 423 display: Display::None, 424 flex_direction: FlexDirection::Column, 425 padding: UiRect::all(Val::Px(8.0)), 426 row_gap: Val::Px(2.0), 427 border: thin_border, 428 ..default() 429 }, 430 BorderColor::all(border_color), 431 )) 432 .with_children(|qp| { 433 qp.spawn((QuotedPostAuthor, Text::new(""), make_font(20.0), dim)); 434 qp.spawn(( 435 QuotedPostText, 436 Text::new(""), 437 TextLayout::new_with_linebreak(LineBreak::WordBoundary), 438 make_font(22.0), 439 bright, 440 )); 441 }); 442 443 // External link: thin border box 444 embed 445 .spawn(( 446 ExternalLinkBox, 447 Node { 448 display: Display::None, 449 flex_direction: FlexDirection::Column, 450 padding: UiRect::all(Val::Px(8.0)), 451 row_gap: Val::Px(2.0), 452 border: thin_border, 453 ..default() 454 }, 455 BorderColor::all(border_color), 456 )) 457 .with_children(|ext| { 458 ext.spawn(( 459 ExternalLinkThumb, 460 Node { 461 display: Display::None, 462 width: Val::Percent(100.0), 463 max_height: Val::Px(140.0), 464 ..default() 465 }, 466 ImageNode::default(), 467 )); 468 ext.spawn((ExternalLinkTitle, Text::new(""), make_font(22.0), bright)); 469 ext.spawn(( 470 ExternalLinkDescription, 471 Text::new(""), 472 TextLayout::new_with_linebreak(LineBreak::WordBoundary), 473 make_font(20.0), 474 dim, 475 )); 476 }); 477 }); 478 479 // --- Subject section: thin border box --- 480 card.spawn(( 481 SubjectSection, 482 Node { 483 display: Display::None, 484 flex_direction: FlexDirection::Column, 485 padding: UiRect::all(Val::Px(8.0)), 486 row_gap: Val::Px(2.0), 487 border: thin_border, 488 ..default() 489 }, 490 BorderColor::all(border_color), 491 )) 492 .with_children(|subj| { 493 subj.spawn((SubjectLabel, Text::new(""), make_font(20.0), dim)); 494 subj.spawn((SubjectAuthor, Text::new(""), make_font(20.0), dim)); 495 subj.spawn(( 496 SubjectContent, 497 Text::new(""), 498 TextLayout::new_with_linebreak(LineBreak::WordBoundary), 499 make_font(22.0), 500 bright, 501 )); 502 }); 503 504 // --- Stats row --- 505 card.spawn(( 506 StatsRow, 507 Node { 508 display: Display::None, 509 ..default() 510 }, 511 )) 512 .with_children(|stats| { 513 stats.spawn((StatsText, Text::new(""), make_font(20.0), dim)); 514 }); 515 516 // --- Separator --- 517 card.spawn(( 518 Node { 519 height: Val::Px(1.0), 520 width: Val::Percent(100.0), 521 ..default() 522 }, 523 BackgroundColor(border_color), 524 )); 525 526 // --- Footer: collection + timestamp --- 527 card.spawn(Node { 528 flex_direction: FlexDirection::Row, 529 justify_content: JustifyContent::SpaceBetween, 530 ..default() 531 }) 532 .with_children(|footer| { 533 footer.spawn((CollectionLabel, Text::new(""), make_font(20.0), dim)); 534 footer.spawn((TimestampLabel, Text::new(""), make_font(20.0), dim)); 535 }); 536 537 // --- Slide content container (hidden by default) --- 538 card.spawn(( 539 SlideContent, 540 Node { 541 display: Display::None, 542 flex_direction: FlexDirection::Column, 543 row_gap: Val::Px(16.0), 544 width: Val::Percent(100.0), 545 ..default() 546 }, 547 )); 548 }); 549} 550 551/// Exclusive system: update detail card or render slide. 552pub fn update_detail_card(world: &mut World) { 553 use crate::slides::{SlideDeck, SlideMode}; 554 555 let slide_index = world.resource::<SlideMode>().active; 556 let last_slide = world.resource::<CardRenderState>().last_slide_index; 557 558 if let Some(index) = slide_index { 559 let slide_gen = world.resource::<CardRenderState>().slide_generation; 560 let last_slide_gen = world.resource::<CardRenderState>().last_slide_generation; 561 if last_slide == Some(index) && slide_gen == last_slide_gen { 562 return; 563 } 564 world 565 .resource_mut::<CardRenderState>() 566 .last_slide_generation = slide_gen; 567 world.resource_mut::<CardRenderState>().last_slide_index = Some(index); 568 569 let slide = { 570 let deck = world.resource::<SlideDeck>(); 571 deck.slides.get(index).cloned() 572 }; 573 574 if let Some(slide) = slide { 575 set_card_presentation_mode(world, true); 576 hide_event_sections(world); 577 set_display::<SlideContent>(world, true); 578 render_slide_content(world, &slide); 579 } 580 return; 581 } 582 583 if last_slide.is_some() { 584 world.resource_mut::<CardRenderState>().last_slide_index = None; 585 set_card_presentation_mode(world, false); 586 set_display::<SlideContent>(world, false); 587 // Restore avatar visibility after slides hid it 588 let mut q = world.query_filtered::<&mut Node, With<AvatarImage>>(); 589 for mut node in q.iter_mut(world) { 590 node.display = Display::Flex; 591 } 592 let mut q = world.query_filtered::<&mut Node, With<DetailCard>>(); 593 for mut node in q.iter_mut(world) { 594 node.display = Display::None; 595 } 596 } 597 598 let needs_render = { 599 let selected = world.resource::<SelectedEvent>(); 600 let render_state = world.resource::<CardRenderState>(); 601 let profile_cache = world.resource::<ProfileCache>(); 602 let record_cache = world.resource::<RecordCache>(); 603 selected.entity != selected.previous_entity 604 || (selected.entity.is_some() 605 && (render_state.last_profile_gen != profile_cache.generation 606 || render_state.last_record_gen != record_cache.generation)) 607 }; 608 if !needs_render { 609 return; 610 } 611 612 { 613 let profile_gen = world.resource::<ProfileCache>().generation; 614 let record_gen = world.resource::<RecordCache>().generation; 615 let mut render_state = world.resource_mut::<CardRenderState>(); 616 render_state.last_profile_gen = profile_gen; 617 render_state.last_record_gen = record_gen; 618 } 619 620 let entity = { 621 let selected = world.resource::<SelectedEvent>(); 622 selected.entity 623 }; 624 625 let Some(entity) = entity else { 626 // No selection → hide card 627 world.resource_mut::<SelectedEvent>().previous_entity = None; 628 let mut q = world.query_filtered::<&mut Node, With<DetailCard>>(); 629 for mut node in q.iter_mut(world) { 630 node.display = Display::None; 631 } 632 return; 633 }; 634 635 // Get event data 636 let event = { 637 let mut q = world.query::<&JetstreamEventMarker>(); 638 match q.get(world, entity) { 639 Ok(e) => e.clone(), 640 Err(_) => { 641 let mut selected = world.resource_mut::<SelectedEvent>(); 642 selected.entity = None; 643 selected.previous_entity = None; 644 let mut q = world.query_filtered::<&mut Node, With<DetailCard>>(); 645 for mut node in q.iter_mut(world) { 646 node.display = Display::None; 647 } 648 return; 649 } 650 } 651 }; 652 653 // Show card 654 let mut q = world.query_filtered::<&mut Node, With<DetailCard>>(); 655 for mut node in q.iter_mut(world) { 656 node.display = Display::Flex; 657 } 658 659 use bevy::app::hotpatch::call; 660 661 // Dispatch content tree (hot-patchable) 662 let card_content = call(|| dispatch_card_content(&event)); 663 664 // --- Content text --- 665 let content_text = call(|| card_content.primary_text()); 666 set_text::<ContentText>(world, &content_text); 667 668 // --- Collection label --- 669 let col_text = match &event.0 { 670 JetstreamMessage::Commit { commit, .. } => commit.collection.to_string(), 671 JetstreamMessage::Identity { .. } => "identity".to_owned(), 672 JetstreamMessage::Account { .. } => "account".to_owned(), 673 }; 674 set_text::<CollectionLabel>(world, &col_text); 675 676 // --- Timestamp --- 677 let time_us = event.time_us(); 678 let secs = time_us / 1_000_000; 679 let hours = (secs % 86400) / 3600; 680 let minutes = (secs % 3600) / 60; 681 let secs_remainder = secs % 60; 682 set_text::<TimestampLabel>( 683 world, 684 &format!("{hours:02}:{minutes:02}:{secs_remainder:02}"), 685 ); 686 687 // --- Embed, subject, stats sections --- 688 let mut show_embed = false; 689 let mut show_images = false; 690 let mut show_quote = false; 691 let mut show_ext = false; 692 let mut show_subject = false; 693 let mut stats_uri: Option<String> = None; 694 695 match &card_content { 696 CardContent::Post { embed, .. } => { 697 let author_did = event.did().as_str().to_string(); 698 if let Some(embed_content) = embed { 699 show_embed = true; 700 render_embed( 701 world, 702 embed_content, 703 &author_did, 704 &mut show_images, 705 &mut show_quote, 706 &mut show_ext, 707 ); 708 } 709 if let JetstreamMessage::Commit { did, commit, .. } = &event.0 { 710 stats_uri = Some(format!( 711 "at://{}/{}/{}", 712 did.as_str(), 713 commit.collection.as_str(), 714 commit.rkey.as_str() 715 )); 716 } 717 } 718 CardContent::Follow { subject_did, .. } => { 719 show_subject = true; 720 set_text::<SubjectLabel>(world, "Followed:"); 721 render_subject_profile(world, subject_did); 722 } 723 _ => {} 724 } 725 726 // Generic subject handling: any record with subject.uri gets the subject section 727 if !show_subject { 728 if let Some(record) = event.record() { 729 if let Some(subject_uri) = record.get_at_path("subject.uri").and_then(|d| d.as_str()) { 730 show_subject = true; 731 stats_uri = Some(subject_uri.to_string()); 732 // Label based on collection 733 let label = match &event.0 { 734 JetstreamMessage::Commit { commit, .. } => { 735 let short = commit 736 .collection 737 .as_str() 738 .rsplit('.') 739 .next() 740 .unwrap_or(commit.collection.as_str()); 741 // capitalize first letter 742 let mut label = short.to_string(); 743 if let Some(first) = label.get_mut(..1) { 744 first.make_ascii_uppercase(); 745 } 746 format!("{label}d:") 747 } 748 _ => "Subject:".to_string(), 749 }; 750 set_text::<SubjectLabel>(world, &label); 751 render_subject_record(world, subject_uri); 752 } 753 } 754 } 755 756 // Show/hide sections 757 set_display::<EmbedSection>(world, show_embed); 758 set_display::<ImagesRow>(world, show_images); 759 set_display::<QuotedPostBox>(world, show_quote); 760 set_display::<ExternalLinkBox>(world, show_ext); 761 set_display::<SubjectSection>(world, show_subject); 762 763 // Stats 764 let show_stats = if let Some(ref uri) = stats_uri { 765 let cache = world.resource::<RecordCache>(); 766 if let Some(stats) = cache.stats.get(uri) { 767 let mut parts = Vec::new(); 768 if stats.likes > 0 { 769 parts.push(format!("{} likes", stats.likes)); 770 } 771 if stats.reposts > 0 { 772 parts.push(format!("{} reposts", stats.reposts)); 773 } 774 let text = parts.join(" · "); 775 set_text::<StatsText>(world, &text); 776 true 777 } else { 778 false 779 } 780 } else { 781 false 782 }; 783 set_display::<StatsRow>(world, show_stats); 784 785 world.resource_mut::<SelectedEvent>().previous_entity = Some(entity); 786} 787 788fn set_text<M: Component>(world: &mut World, value: &str) { 789 let mut q = world.query_filtered::<&mut Text, With<M>>(); 790 for mut text in q.iter_mut(world) { 791 **text = value.to_string(); 792 } 793} 794 795fn set_display<M: Component>(world: &mut World, show: bool) { 796 let mut q = world.query_filtered::<&mut Node, With<M>>(); 797 for mut node in q.iter_mut(world) { 798 node.display = if show { Display::Flex } else { Display::None }; 799 } 800} 801 802fn set_card_presentation_mode(world: &mut World, presentation: bool) { 803 let mut q = world.query_filtered::<&mut Node, With<DetailCard>>(); 804 for mut node in q.iter_mut(world) { 805 if presentation { 806 node.display = Display::Flex; 807 node.position_type = PositionType::Absolute; 808 node.left = Val::Percent(5.0); 809 node.right = Val::Percent(5.0); 810 node.top = Val::Percent(5.0); 811 node.bottom = Val::Percent(5.0); 812 node.width = Val::Percent(90.0); 813 node.max_height = Val::Percent(90.0); 814 node.padding = UiRect::all(Val::Px(32.0)); 815 node.row_gap = Val::Px(12.0); 816 node.justify_content = JustifyContent::Center; 817 } else { 818 node.position_type = PositionType::Absolute; 819 node.left = Val::Auto; 820 node.right = Val::Px(16.0); 821 node.top = Val::Px(16.0); 822 node.bottom = Val::Auto; 823 node.width = Val::Px(720.0); 824 node.max_height = Val::Percent(90.0); 825 node.padding = UiRect::all(Val::Px(16.0)); 826 node.row_gap = Val::Px(10.0); 827 node.justify_content = JustifyContent::default(); 828 } 829 } 830} 831 832fn hide_event_sections(world: &mut World) { 833 set_text::<DisplayNameText>(world, ""); 834 set_text::<HandleText>(world, ""); 835 set_text::<ContentText>(world, ""); 836 set_text::<CollectionLabel>(world, ""); 837 set_text::<TimestampLabel>(world, ""); 838 set_display::<EmbedSection>(world, false); 839 set_display::<SubjectSection>(world, false); 840 set_display::<StatsRow>(world, false); 841 842 let mut q = world.query_filtered::<&mut Node, With<AvatarImage>>(); 843 for mut node in q.iter_mut(world) { 844 node.display = Display::None; 845 } 846} 847 848fn render_slide_content(world: &mut World, slide: &crate::slides::Slide) { 849 use crate::slides::SlideFragment; 850 851 let content_entity = { 852 let mut q = world.query_filtered::<Entity, With<SlideContent>>(); 853 q.iter(world).next() 854 }; 855 let Some(content_entity) = content_entity else { 856 return; 857 }; 858 859 world.entity_mut(content_entity).despawn_children(); 860 861 let font_handle = world.resource::<crate::UiFont>().primary.clone(); 862 863 let bright = Color::srgb(0.6, 0.9, 1.0); 864 let dim = Color::srgba(0.4, 0.7, 0.9, 0.7); 865 let code_bg = Color::srgba(0.1, 0.15, 0.2, 0.8); 866 let glow = Color::srgba(0.3, 0.8, 1.0, 0.5); 867 868 for fragment in &slide.fragments { 869 match fragment { 870 SlideFragment::Heading(level, text) => { 871 let font_size = match level { 872 1 => 72.0, 873 2 => 56.0, 874 3 => 44.0, 875 _ => 36.0, 876 }; 877 let child = world 878 .spawn(( 879 Text::new(text.clone()), 880 TextFont { 881 font: font_handle.clone(), 882 font_size, 883 ..default() 884 }, 885 TextColor(bright), 886 TextLayout::new_with_linebreak(LineBreak::WordBoundary), 887 Node { 888 margin: UiRect::bottom(Val::Px(8.0)), 889 ..default() 890 }, 891 )) 892 .id(); 893 world.entity_mut(content_entity).add_child(child); 894 } 895 SlideFragment::Body(spans) => { 896 let child = spawn_styled_spans(world, &font_handle, spans, 36.0, bright, dim); 897 world.entity_mut(content_entity).add_child(child); 898 } 899 SlideFragment::ListItem(spans) => { 900 let row = world 901 .spawn(Node { 902 flex_direction: FlexDirection::Row, 903 column_gap: Val::Px(12.0), 904 align_items: AlignItems::FlexStart, 905 ..default() 906 }) 907 .id(); 908 909 let bullet = world 910 .spawn(( 911 Text::new(""), 912 TextFont { 913 font: font_handle.clone(), 914 font_size: 36.0, 915 ..default() 916 }, 917 TextColor(glow), 918 )) 919 .id(); 920 world.entity_mut(row).add_child(bullet); 921 922 let text_node = spawn_styled_spans(world, &font_handle, spans, 36.0, bright, dim); 923 world.entity_mut(row).add_child(text_node); 924 925 world.entity_mut(content_entity).add_child(row); 926 } 927 SlideFragment::Code(code) => { 928 let child = world 929 .spawn(( 930 Node { 931 padding: UiRect::all(Val::Px(16.0)), 932 ..default() 933 }, 934 BackgroundColor(code_bg), 935 )) 936 .with_child(( 937 Text::new(code.clone()), 938 TextFont { 939 font: font_handle.clone(), 940 font_size: 28.0, 941 ..default() 942 }, 943 TextColor(dim), 944 TextLayout::new_with_linebreak(LineBreak::AnyCharacter), 945 )) 946 .id(); 947 world.entity_mut(content_entity).add_child(child); 948 } 949 SlideFragment::Rule => { 950 let child = world 951 .spawn(( 952 Node { 953 height: Val::Px(1.0), 954 width: Val::Percent(100.0), 955 margin: UiRect::axes(Val::Px(0.0), Val::Px(8.0)), 956 ..default() 957 }, 958 BackgroundColor(glow), 959 )) 960 .id(); 961 world.entity_mut(content_entity).add_child(child); 962 } 963 SlideFragment::Image { path, .. } => { 964 let server = world.resource::<AssetServer>(); 965 let handle: Handle<Image> = server.load(path.clone()); 966 967 // Wrapper prevents flex column stretch from forcing width on the image. 968 // The image node inside uses Auto mode — inherent aspect ratio sets height. 969 let wrapper = world 970 .spawn(Node { 971 align_self: AlignSelf::Center, 972 max_width: Val::Percent(60.0), 973 max_height: Val::Percent(40.0), 974 margin: UiRect::axes(Val::Px(0.0), Val::Px(8.0)), 975 ..default() 976 }) 977 .id(); 978 979 let img = world.spawn(ImageNode::new(handle)).id(); 980 981 world.entity_mut(wrapper).add_child(img); 982 world.entity_mut(content_entity).add_child(wrapper); 983 } 984 SlideFragment::AtEmbed(uri) => { 985 render_slide_at_embed(world, content_entity, uri, &font_handle, bright, dim); 986 } 987 } 988 } 989} 990 991/// Render an `at://` URI embed inside a slide, using the record cache. 992fn render_slide_at_embed( 993 world: &mut World, 994 parent: Entity, 995 uri: &str, 996 font: &Handle<bevy::text::Font>, 997 bright: Color, 998 dim: Color, 999) { 1000 let glow = Color::srgba(0.3, 0.8, 1.0, 0.5); 1001 1002 // Check if the record is already cached 1003 let cached_text = { 1004 let cache = world.resource::<RecordCache>(); 1005 cache.records.get(uri).map(|cached| { 1006 let text = cached 1007 .data 1008 .get_at_path("text") 1009 .and_then(|d| d.as_str()) 1010 .unwrap_or("[no text]") 1011 .to_string(); 1012 let author = cached.author_did.as_str().to_string(); 1013 (text, author) 1014 }) 1015 }; 1016 1017 let border = UiRect::all(Val::Px(1.0)); 1018 let (display_text, author_label) = 1019 cached_text.unwrap_or_else(|| (format!("loading {uri}"), String::new())); 1020 1021 let container = world 1022 .spawn(( 1023 Node { 1024 flex_direction: FlexDirection::Column, 1025 padding: UiRect::all(Val::Px(12.0)), 1026 row_gap: Val::Px(4.0), 1027 border, 1028 ..default() 1029 }, 1030 BorderColor::all(glow), 1031 )) 1032 .id(); 1033 1034 if !author_label.is_empty() { 1035 // Try to resolve author display name from profile cache 1036 let author_display = { 1037 use smol_str::ToSmolStr; 1038 let profile_cache = world.resource::<ProfileCache>(); 1039 jacquard::types::string::Did::new(author_label.to_smolstr()) 1040 .ok() 1041 .and_then(|did| profile_cache.profiles.get(&did)) 1042 .map(|p| { 1043 let name = p.display_name.as_deref().unwrap_or(&p.handle); 1044 format!("{name} (@{})", p.handle) 1045 }) 1046 .unwrap_or(author_label) 1047 }; 1048 1049 let author_node = world 1050 .spawn(( 1051 Text::new(author_display), 1052 TextFont { 1053 font: font.clone(), 1054 font_size: 28.0, 1055 ..default() 1056 }, 1057 TextColor(dim), 1058 )) 1059 .id(); 1060 world.entity_mut(container).add_child(author_node); 1061 } 1062 1063 let text_node = world 1064 .spawn(( 1065 Text::new(display_text), 1066 TextFont { 1067 font: font.clone(), 1068 font_size: 32.0, 1069 ..default() 1070 }, 1071 TextColor(bright), 1072 TextLayout::new_with_linebreak(LineBreak::WordBoundary), 1073 )) 1074 .id(); 1075 world.entity_mut(container).add_child(text_node); 1076 world.entity_mut(parent).add_child(container); 1077 1078 // Trigger a fetch if not cached 1079 let needs_fetch = { 1080 let cache = world.resource::<RecordCache>(); 1081 !cache.records.contains_key(uri) && !cache.records_pending.contains(uri) 1082 }; 1083 if needs_fetch { 1084 let mut cache = world.resource_mut::<RecordCache>(); 1085 cache.records_pending.insert(uri.to_string()); 1086 // The existing request_enrichment_for_selected system won't fetch this 1087 // since it's not a selected event. We'll trigger it via the XRPC pipe. 1088 if let Some(atp_client) = world.get_resource::<crate::net::AtpClient>() { 1089 let client = atp_client.0.clone(); 1090 let uri_owned = uri.to_string(); 1091 let runtime = world.resource::<bevy_tokio_tasks::TokioTasksRuntime>(); 1092 runtime.spawn_background_task(move |mut ctx| async move { 1093 let result = fetch_record_by_uri(&client, &uri_owned).await; 1094 ctx.run_on_main_thread(move |ctx| { 1095 let world = ctx.world; 1096 let mut cache = world.resource_mut::<RecordCache>(); 1097 cache.records_pending.remove(&uri_owned); 1098 if let Some((data, author_did)) = result { 1099 cache 1100 .records 1101 .insert(uri_owned, CachedRecord { data, author_did }); 1102 cache.bump(); 1103 // Trigger slide re-render 1104 let mut render = world.resource_mut::<CardRenderState>(); 1105 render.slide_generation += 1; 1106 } 1107 }) 1108 .await; 1109 }); 1110 } 1111 } 1112} 1113 1114fn spawn_styled_spans( 1115 world: &mut World, 1116 font: &Handle<bevy::text::Font>, 1117 spans: &[crate::slides::StyledSpan], 1118 font_size: f32, 1119 bright: Color, 1120 _dim: Color, 1121) -> Entity { 1122 let ui_font = world.resource::<crate::UiFont>(); 1123 let bold_font = ui_font.bold.clone(); 1124 let italic_font = ui_font.italic.clone(); 1125 1126 // If all spans share the same style, use a single Text node 1127 let all_same = spans.iter().all(|s| !s.bold && !s.italic && !s.code); 1128 if all_same { 1129 let mut full_text = String::new(); 1130 for span in spans { 1131 full_text.push_str(&span.text); 1132 } 1133 return world 1134 .spawn(( 1135 Text::new(full_text), 1136 TextFont { 1137 font: font.clone(), 1138 font_size, 1139 ..default() 1140 }, 1141 TextColor(bright), 1142 TextLayout::new_with_linebreak(LineBreak::WordBoundary), 1143 )) 1144 .id(); 1145 } 1146 1147 // Mixed styles: use a row of individually-styled text nodes 1148 let row = world 1149 .spawn(Node { 1150 flex_direction: FlexDirection::Row, 1151 flex_wrap: FlexWrap::Wrap, 1152 ..default() 1153 }) 1154 .id(); 1155 1156 for span in spans { 1157 let span_font = if span.bold { 1158 bold_font.clone() 1159 } else if span.italic { 1160 italic_font.clone() 1161 } else { 1162 font.clone() 1163 }; 1164 1165 let child = world 1166 .spawn(( 1167 Text::new(span.text.clone()), 1168 TextFont { 1169 font: span_font, 1170 font_size, 1171 ..default() 1172 }, 1173 TextColor(bright), 1174 )) 1175 .id(); 1176 world.entity_mut(row).add_child(child); 1177 } 1178 1179 row 1180} 1181 1182async fn fetch_record_by_uri( 1183 client: &std::sync::Arc<jacquard::client::BasicClient>, 1184 uri: &str, 1185) -> Option<(jacquard::Data, jacquard::types::string::Did)> { 1186 use jacquard::types::string::Did; 1187 use smol_str::ToSmolStr; 1188 1189 let parsed = match AtUri::new(uri.to_smolstr()) { 1190 Ok(u) => u, 1191 Err(e) => { 1192 warn!("fetch_record_by_uri: invalid AT-URI {uri}: {e:?}"); 1193 return None; 1194 } 1195 }; 1196 let output = match client.fetch_record_slingshot(&parsed).await { 1197 Ok(o) => o, 1198 Err(e) => { 1199 warn!("fetch_record_by_uri: slingshot failed for {uri}: {e:?}"); 1200 return None; 1201 } 1202 }; 1203 let author = Did::new(parsed.authority().as_str().to_smolstr()).ok()?; 1204 Some((output.value, author)) 1205} 1206 1207fn load_cdn_image(world: &World, did: &str, cid: &str) -> Handle<Image> { 1208 let url = bsky_cdn_url(did, cid); 1209 let server = world.resource::<AssetServer>(); 1210 server.load_with_settings::<Image, ImageLoaderSettings>( 1211 url, 1212 |settings: &mut ImageLoaderSettings| { 1213 settings.format = ImageFormatSetting::Guess; 1214 }, 1215 ) 1216} 1217 1218fn render_embed( 1219 world: &mut World, 1220 embed: &EmbedContent, 1221 author_did: &str, 1222 show_images: &mut bool, 1223 show_quote: &mut bool, 1224 show_ext: &mut bool, 1225) { 1226 match embed { 1227 EmbedContent::Images(images) => { 1228 *show_images = true; 1229 spawn_embed_images(world, images); 1230 } 1231 EmbedContent::External { 1232 title, 1233 description, 1234 thumb_cid, 1235 author_did: ext_did, 1236 } => { 1237 *show_ext = true; 1238 set_text::<ExternalLinkTitle>(world, title); 1239 set_text::<ExternalLinkDescription>(world, description); 1240 if let Some(cid) = thumb_cid { 1241 let handle = load_cdn_image(world, ext_did, cid); 1242 let mut q = 1243 world.query_filtered::<(&mut Node, &mut ImageNode), With<ExternalLinkThumb>>(); 1244 for (mut node, mut img) in q.iter_mut(world) { 1245 *img = ImageNode::new(handle.clone()); 1246 node.display = Display::Flex; 1247 } 1248 } else { 1249 set_display::<ExternalLinkThumb>(world, false); 1250 } 1251 } 1252 EmbedContent::Record { uri, .. } => { 1253 *show_quote = true; 1254 render_quoted_post(world, uri); 1255 } 1256 EmbedContent::RecordWithMedia { 1257 record_uri, images, .. 1258 } => { 1259 if !images.is_empty() { 1260 *show_images = true; 1261 spawn_embed_images(world, images); 1262 } 1263 if !record_uri.is_empty() { 1264 *show_quote = true; 1265 render_quoted_post(world, record_uri); 1266 } 1267 } 1268 } 1269} 1270 1271fn spawn_embed_images(world: &mut World, images: &[ImageEmbed]) { 1272 // Find the images row entity 1273 let row_entity = { 1274 let mut q = world.query_filtered::<Entity, With<ImagesRow>>(); 1275 q.iter(world).next() 1276 }; 1277 let Some(row_entity) = row_entity else { 1278 return; 1279 }; 1280 1281 // Despawn old children 1282 world.entity_mut(row_entity).despawn_children(); 1283 1284 // Load image handles with sizing info 1285 let img_data: Vec<_> = images 1286 .iter() 1287 .take(4) 1288 .map(|img| { 1289 let handle = load_cdn_image(world, &img.author_did, &img.cid); 1290 (handle, img.aspect_ratio, images.len() == 1) 1291 }) 1292 .collect(); 1293 1294 // Spawn new image children with aspect-ratio-correct sizing 1295 for (handle, aspect_ratio, single) in img_data { 1296 let max_w = if single { 490.0_f32 } else { 240.0_f32 }; 1297 let max_h = if single { 300.0_f32 } else { 160.0_f32 }; 1298 let (w, h) = match aspect_ratio { 1299 Some((aw, ah)) => { 1300 let ar = aw as f32 / ah as f32; 1301 if ar > max_w / max_h { 1302 // width-constrained 1303 (max_w, max_w / ar) 1304 } else { 1305 // height-constrained 1306 (max_h * ar, max_h) 1307 } 1308 } 1309 None => (max_w, max_h), 1310 }; 1311 let child = world 1312 .spawn(( 1313 EmbedImage, 1314 Node { 1315 width: Val::Px(w), 1316 height: Val::Px(h), 1317 ..default() 1318 }, 1319 ImageNode::new(handle), 1320 )) 1321 .id(); 1322 world.entity_mut(row_entity).add_child(child); 1323 } 1324} 1325 1326fn render_quoted_post(world: &mut World, uri: &str) { 1327 let cache = world.resource::<RecordCache>(); 1328 if let Some(cached) = cache.records.get(uri) { 1329 let post_text = cached 1330 .data 1331 .get_at_path("text") 1332 .and_then(|d| d.as_str()) 1333 .unwrap_or("[no text]") 1334 .to_string(); 1335 let author_did = cached.author_did.clone(); 1336 1337 set_text::<QuotedPostText>(world, &post_text); 1338 1339 let profile_cache = world.resource::<ProfileCache>(); 1340 if let Some(profile) = profile_cache.profiles.get(&author_did) { 1341 let name = profile.display_name.as_deref().unwrap_or(&profile.handle); 1342 let author_text = format!("{name} (@{})", profile.handle); 1343 set_text::<QuotedPostAuthor>(world, &author_text); 1344 } else { 1345 set_text::<QuotedPostAuthor>(world, &(author_did.as_str())); 1346 } 1347 } else { 1348 set_text::<QuotedPostText>(world, &format!("loading {uri}")); 1349 set_text::<QuotedPostAuthor>(world, ""); 1350 } 1351} 1352 1353fn render_subject_record(world: &mut World, subject_uri: &str) { 1354 let cache = world.resource::<RecordCache>(); 1355 if let Some(cached) = cache.records.get(subject_uri) { 1356 let post_text = cached 1357 .data 1358 .get_at_path("text") 1359 .and_then(|d| d.as_str()) 1360 .unwrap_or("[no text]") 1361 .to_string(); 1362 let author_did = cached.author_did.clone(); 1363 1364 set_text::<SubjectContent>(world, &post_text); 1365 1366 let profile_cache = world.resource::<ProfileCache>(); 1367 if let Some(profile) = profile_cache.profiles.get(&author_did) { 1368 let name = profile.display_name.as_deref().unwrap_or(&profile.handle); 1369 set_text::<SubjectAuthor>(world, &format!("{name} (@{})", profile.handle)); 1370 } else { 1371 set_text::<SubjectAuthor>(world, &(author_did.as_str())); 1372 } 1373 } else { 1374 set_text::<SubjectContent>(world, &format!("loading {subject_uri}")); 1375 set_text::<SubjectAuthor>(world, ""); 1376 } 1377} 1378 1379fn render_subject_profile(world: &mut World, subject_did: &str) { 1380 use smol_str::ToSmolStr; 1381 let profile_cache = world.resource::<ProfileCache>(); 1382 if let Ok(did) = Did::new(subject_did.to_smolstr()) { 1383 if let Some(cached) = profile_cache.profiles.get(&did) { 1384 let name = cached.display_name.as_deref().unwrap_or(&cached.handle); 1385 let text = format!("{name} (@{})", cached.handle); 1386 set_text::<SubjectContent>(world, &text); 1387 set_text::<SubjectAuthor>(world, ""); 1388 return; 1389 } 1390 } 1391 set_text::<SubjectContent>(world, &format!("loading {subject_did}")); 1392 set_text::<SubjectAuthor>(world, ""); 1393} 1394 1395/// Dispatch card content rendering based on collection NSID. 1396/// Returns a semantic CardContent tree. 1397pub fn dispatch_card_content(event: &JetstreamEventMarker) -> CardContent { 1398 use bevy::app::hotpatch::call; 1399 1400 let record = event.record(); 1401 let author_did = event.did().as_str().to_string(); 1402 1403 match &event.0 { 1404 JetstreamMessage::Commit { commit, .. } => { 1405 let nsid = commit.collection.as_str(); 1406 use jacquard::api::app_bsky::feed::like::Like; 1407 use jacquard::api::app_bsky::feed::post::Post; 1408 use jacquard::api::app_bsky::graph::follow::Follow; 1409 if nsid == <Post<jacquard::DefaultStr> as Collection>::NSID { 1410 call(|| build_post_content(record, &author_did)) 1411 } else if nsid == <Like<jacquard::DefaultStr> as Collection>::NSID { 1412 call(|| build_like_content(record)) 1413 } else if nsid == <Follow<jacquard::DefaultStr> as Collection>::NSID { 1414 call(|| build_follow_content(record)) 1415 } else { 1416 call(|| build_unknown_content(&commit.collection, record, &author_did)) 1417 } 1418 } 1419 JetstreamMessage::Identity { identity, .. } => CardContent::Identity { 1420 handle: identity.handle.as_ref().map(|h| h.as_str().to_string()), 1421 }, 1422 JetstreamMessage::Account { account, .. } => CardContent::Account { 1423 active: account.active, 1424 status: account.status.as_ref().map(|s| format!("{s:?}")), 1425 }, 1426 } 1427} 1428 1429fn build_post_content(record: Option<&Data>, author_did: &str) -> CardContent { 1430 let Some(data) = record else { 1431 return CardContent::Post { 1432 text: "(no record data)".into(), 1433 embed: None, 1434 }; 1435 }; 1436 let text = data 1437 .get_at_path("text") 1438 .and_then(|d| d.as_str()) 1439 .unwrap_or("[no text]") 1440 .to_string(); 1441 let embed = data 1442 .get_at_path("embed") 1443 .and_then(|e| parse_embed(e, author_did)); 1444 CardContent::Post { text, embed } 1445} 1446 1447fn build_like_content(record: Option<&Data>) -> CardContent { 1448 let Some(data) = record else { 1449 return CardContent::Like { 1450 subject_uri: "[unknown]".into(), 1451 subject: None, 1452 }; 1453 }; 1454 let subject_uri = data 1455 .get_at_path("subject.uri") 1456 .and_then(|d| d.as_str()) 1457 .unwrap_or("[unknown subject]") 1458 .to_string(); 1459 CardContent::Like { 1460 subject_uri, 1461 subject: None, 1462 } 1463} 1464 1465fn build_follow_content(record: Option<&Data>) -> CardContent { 1466 let Some(data) = record else { 1467 return CardContent::Follow { 1468 subject_did: "[unknown]".into(), 1469 subject_profile: None, 1470 }; 1471 }; 1472 let subject_did = data 1473 .get_at_path("subject") 1474 .and_then(|d| d.as_str()) 1475 .unwrap_or("[unknown subject]") 1476 .to_string(); 1477 CardContent::Follow { 1478 subject_did, 1479 subject_profile: None, 1480 } 1481} 1482 1483fn build_unknown_content( 1484 collection: &Nsid, 1485 record: Option<&Data>, 1486 author_did: &str, 1487) -> CardContent { 1488 let Some(data) = record else { 1489 return CardContent::Unknown { 1490 collection: collection.to_string(), 1491 fields: vec![], 1492 }; 1493 }; 1494 let fields = data_to_fields(data, author_did, 0); 1495 CardContent::Unknown { 1496 collection: collection.to_string(), 1497 fields, 1498 } 1499} 1500 1501/// Parse an embed Data value into an EmbedContent. 1502fn parse_embed(embed: &Data, author_did: &str) -> Option<EmbedContent> { 1503 let type_str = embed.type_discriminator().unwrap_or(""); 1504 1505 if type_str.contains("embed.images") || type_str.contains("embed#images") { 1506 let images = parse_embed_images(embed, author_did); 1507 if images.is_empty() { 1508 return None; 1509 } 1510 return Some(EmbedContent::Images(images)); 1511 } 1512 1513 if type_str.contains("embed.external") || type_str.contains("embed#external") { 1514 let ext = embed.get_at_path("external")?; 1515 let title = ext 1516 .get_at_path("title") 1517 .and_then(|d| d.as_str()) 1518 .unwrap_or("") 1519 .to_string(); 1520 let description = ext 1521 .get_at_path("description") 1522 .and_then(|d| d.as_str()) 1523 .unwrap_or("") 1524 .to_string(); 1525 let thumb_cid = extract_blob_cid(ext.get_at_path("thumb")); 1526 return Some(EmbedContent::External { 1527 title, 1528 description, 1529 thumb_cid, 1530 author_did: author_did.to_string(), 1531 }); 1532 } 1533 1534 if type_str.contains("embed.record#") 1535 || (type_str.contains("embed.record") && !type_str.contains("WithMedia")) 1536 { 1537 let uri = embed 1538 .get_at_path("record.uri") 1539 .and_then(|d| d.as_str()) 1540 .unwrap_or("") 1541 .to_string(); 1542 if uri.is_empty() { 1543 return None; 1544 } 1545 return Some(EmbedContent::Record { uri, content: None }); 1546 } 1547 1548 if type_str.contains("recordWithMedia") { 1549 let record_uri = embed 1550 .get_at_path("record.record.uri") 1551 .and_then(|d| d.as_str()) 1552 .unwrap_or("") 1553 .to_string(); 1554 let images = embed 1555 .get_at_path("media") 1556 .map(|m| parse_embed_images(m, author_did)) 1557 .unwrap_or_default(); 1558 return Some(EmbedContent::RecordWithMedia { 1559 record_uri, 1560 content: None, 1561 images, 1562 }); 1563 } 1564 1565 None 1566} 1567 1568fn parse_embed_images(embed: &Data, author_did: &str) -> Vec<ImageEmbed> { 1569 let mut images = Vec::new(); 1570 if let Some(arr) = embed.get_at_path("images").and_then(|d| d.as_array()) { 1571 for img in arr.iter() { 1572 let cid = if let Some(Data::Blob(blob)) = img.get_at_path("image") { 1573 blob.cid().as_str().to_string() 1574 } else { 1575 img.get_at_path("image.ref.$link") 1576 .or_else(|| img.get_at_path("image.ref.link")) 1577 .and_then(|d| d.as_str()) 1578 .unwrap_or("") 1579 .to_string() 1580 }; 1581 if cid.is_empty() { 1582 continue; 1583 } 1584 let alt = img 1585 .get_at_path("alt") 1586 .and_then(|d| d.as_str()) 1587 .unwrap_or("") 1588 .to_string(); 1589 let aspect_ratio = img.get_at_path("aspectRatio").and_then(|ar| { 1590 let w = ar.get_at_path("width").and_then(|d| d.as_integer())? as u32; 1591 let h = ar.get_at_path("height").and_then(|d| d.as_integer())? as u32; 1592 if w > 0 && h > 0 { Some((w, h)) } else { None } 1593 }); 1594 images.push(ImageEmbed { 1595 cid, 1596 alt, 1597 author_did: author_did.to_string(), 1598 aspect_ratio, 1599 }); 1600 } 1601 } 1602 images 1603} 1604 1605fn extract_blob_cid(val: Option<&Data>) -> Option<String> { 1606 let val = val?; 1607 if let Data::Blob(blob) = val { 1608 return Some(blob.cid().as_str().to_string()); 1609 } 1610 val.get_at_path("ref.$link") 1611 .or_else(|| val.get_at_path("ref.link")) 1612 .and_then(|d| d.as_str()) 1613 .map(|s| s.to_string()) 1614} 1615 1616/// Convert a Data value into a list of FieldDisplay entries for unknown records. 1617fn data_to_fields(data: &Data, author_did: &str, depth: usize) -> Vec<FieldDisplay> { 1618 let mut fields = Vec::new(); 1619 if let Some(obj) = data.as_object() { 1620 for (key, val) in obj.iter() { 1621 if key.as_str() == "$type" || key.as_str() == "createdAt" { 1622 continue; 1623 } 1624 fields.push(FieldDisplay { 1625 key: key.to_string(), 1626 value: data_to_display(val, author_did, depth), 1627 }); 1628 } 1629 } 1630 fields 1631} 1632 1633/// Convert a single Data value into a DataDisplay node. 1634fn data_to_display(val: &Data, author_did: &str, depth: usize) -> DataDisplay { 1635 if depth > 4 { 1636 return DataDisplay::Text("".into()); 1637 } 1638 match val { 1639 Data::Null => DataDisplay::Null, 1640 Data::Boolean(b) => DataDisplay::Bool(*b), 1641 Data::Integer(i) => DataDisplay::Number(*i), 1642 Data::String(_) => { 1643 let s = val.as_str().unwrap_or(""); 1644 if s.starts_with("at://") { 1645 DataDisplay::Link(s.to_string()) 1646 } else { 1647 DataDisplay::Text(s.to_string()) 1648 } 1649 } 1650 Data::Bytes(b) => DataDisplay::Text(format!("[{} bytes]", b.len())), 1651 Data::CidLink(cid) => DataDisplay::Link(format!("cid:{}", cid.as_str())), 1652 Data::Blob(blob) => DataDisplay::Blob { 1653 mime: blob.mime_type.as_str().to_string(), 1654 cid: blob.cid().as_str().to_string(), 1655 author_did: author_did.to_string(), 1656 }, 1657 Data::Array(arr) => { 1658 let preview: Vec<DataDisplay> = arr 1659 .iter() 1660 .take(3) 1661 .map(|item| data_to_display(item, author_did, depth + 1)) 1662 .collect(); 1663 DataDisplay::Array { 1664 count: arr.len(), 1665 preview, 1666 } 1667 } 1668 Data::Object(obj) => { 1669 let type_hint = obj 1670 .type_discriminator() 1671 .map(|t| t.rsplit('.').next().unwrap_or(t).to_string()); 1672 let fields: Vec<FieldDisplay> = obj 1673 .iter() 1674 .filter(|(k, _)| k.as_str() != "$type") 1675 .take(8) 1676 .map(|(k, v)| FieldDisplay { 1677 key: k.to_string(), 1678 value: data_to_display(v, author_did, depth + 1), 1679 }) 1680 .collect(); 1681 DataDisplay::Object { type_hint, fields } 1682 } 1683 Data::InvalidNumber(s) => { 1684 let s: &str = s.as_ref(); 1685 DataDisplay::Text(format!("[invalid: {s}]")) 1686 } 1687 } 1688} 1689 1690/// Build a bsky CDN URL for an image blob. 1691pub fn bsky_cdn_url(did: &str, cid: &str) -> String { 1692 format!("https://cdn.bsky.app/img/feed_thumbnail/plain/{did}/{cid}@jpeg") 1693} 1694 1695use std::collections::{HashMap, HashSet}; 1696 1697use jacquard::api::app_bsky::actor::get_profile::GetProfile; 1698use jacquard::types::string::Did; 1699 1700/// Cached profile information for a single user. 1701pub struct CachedProfile { 1702 pub display_name: Option<String>, 1703 pub handle: String, 1704 pub avatar_handle: Option<Handle<Image>>, 1705} 1706 1707/// Resource: cache of resolved profiles, keyed by DID. 1708#[derive(Resource, Default)] 1709pub struct ProfileCache { 1710 pub profiles: HashMap<Did, CachedProfile>, 1711 pub pending: HashSet<Did>, 1712 pub generation: u64, 1713} 1714 1715impl ProfileCache { 1716 pub fn bump(&mut self) { 1717 self.generation += 1; 1718 } 1719} 1720 1721/// Tracks debounce state for profile requests. 1722#[derive(Resource, Default)] 1723pub struct ProfileRequestState { 1724 pub last_did: Option<Did>, 1725 pub last_did_change: f32, 1726} 1727 1728/// A cached AT Protocol record fetched via slingshot. 1729pub struct CachedRecord { 1730 pub data: Data, 1731 pub author_did: Did, 1732} 1733 1734/// Engagement stats for a record (from constellation). 1735pub struct RecordStats { 1736 pub likes: u64, 1737 pub reposts: u64, 1738} 1739 1740/// Resource: cache of fetched records and stats, keyed by AT-URI string. 1741#[derive(Resource, Default)] 1742pub struct RecordCache { 1743 pub records: HashMap<String, CachedRecord>, 1744 pub records_pending: HashSet<String>, 1745 pub stats: HashMap<String, RecordStats>, 1746 pub stats_pending: HashSet<String>, 1747 pub generation: u64, 1748} 1749 1750impl RecordCache { 1751 pub fn bump(&mut self) { 1752 self.generation += 1; 1753 } 1754} 1755 1756use jacquard::{BosStr, DefaultStr, IntoStatic, XrpcRequest}; 1757use serde::{Deserialize, Serialize}; 1758 1759#[derive(Serialize, Deserialize, IntoStatic)] 1760#[serde(bound(deserialize = "S: Deserialize<'de> + BosStr"))] 1761pub struct GetBacklinksResponse<S: BosStr = DefaultStr> { 1762 pub total: u64, 1763 #[serde(default)] 1764 pub records: Vec<BacklinkRecord<S>>, 1765 pub cursor: Option<S>, 1766} 1767 1768#[derive(Serialize, Deserialize, IntoStatic)] 1769#[serde(bound(deserialize = "S: Deserialize<'de> + BosStr"))] 1770pub struct BacklinkRecord<S: BosStr = DefaultStr> { 1771 pub did: S, 1772 pub collection: S, 1773 pub rkey: S, 1774} 1775 1776#[derive(Serialize, Deserialize, XrpcRequest)] 1777#[xrpc(nsid = "blue.microcosm.links.getBacklinks", method = Query, output = GetBacklinksResponse)] 1778pub struct GetBacklinksQuery<S: BosStr = DefaultStr> { 1779 pub subject: S, 1780 #[serde(default, skip_serializing_if = "Option::is_none")] 1781 pub source: Option<S>, 1782 #[serde(default, skip_serializing_if = "Option::is_none")] 1783 pub limit: Option<u32>, 1784 #[serde(default, skip_serializing_if = "Option::is_none")] 1785 pub cursor: Option<S>, 1786} 1787 1788/// Constellation base URL. 1789const CONSTELLATION_URL: &str = "https://constellation.microcosm.blue"; 1790 1791#[allow(clippy::too_many_arguments)] 1792pub fn request_profile_for_selected( 1793 selected: Res<SelectedEvent>, 1794 events: Query<&JetstreamEventMarker>, 1795 mut profile_cache: ResMut<ProfileCache>, 1796 mut req_state: ResMut<ProfileRequestState>, 1797 mut display_name: Query<&mut Text, With<DisplayNameText>>, 1798 mut handle_text: Query<&mut Text, (With<HandleText>, Without<DisplayNameText>)>, 1799 mut avatar_image: Query<&mut ImageNode, With<AvatarImage>>, 1800 time: Res<Time>, 1801 runtime: ResMut<bevy_tokio_tasks::TokioTasksRuntime>, 1802 atp_client: Res<crate::net::AtpClient>, 1803) { 1804 let Some(entity) = selected.entity else { 1805 req_state.last_did = None; 1806 return; 1807 }; 1808 1809 let Ok(event) = events.get(entity) else { 1810 return; 1811 }; 1812 1813 let did = event.did().clone(); 1814 1815 // Debounce: track when the DID changed 1816 if req_state.last_did.as_ref() != Some(&did) { 1817 req_state.last_did = Some(did.clone()); 1818 req_state.last_did_change = time.elapsed_secs(); 1819 } 1820 let dwell_time = time.elapsed_secs() - req_state.last_did_change; 1821 const DEBOUNCE_SECS: f32 = 0.1; 1822 1823 // If already cached, update display immediately 1824 if let Some(cached) = profile_cache.profiles.get(&did) { 1825 let name = cached 1826 .display_name 1827 .as_deref() 1828 .unwrap_or(cached.handle.as_str()) 1829 .to_owned(); 1830 let handle_str = format!("@{}", cached.handle); 1831 1832 for mut text in display_name.iter_mut() { 1833 **text = name.clone(); 1834 } 1835 for mut text in handle_text.iter_mut() { 1836 **text = handle_str.clone(); 1837 } 1838 for mut img in avatar_image.iter_mut() { 1839 *img = match &cached.avatar_handle { 1840 Some(h) => ImageNode::new(h.clone()), 1841 None => ImageNode::default(), 1842 }; 1843 } 1844 return; 1845 } 1846 1847 // Show placeholder 1848 for mut text in display_name.iter_mut() { 1849 **text = did.as_str().to_owned(); 1850 } 1851 for mut text in handle_text.iter_mut() { 1852 **text = "loading…".to_owned(); 1853 } 1854 for mut img in avatar_image.iter_mut() { 1855 *img = ImageNode::default(); 1856 } 1857 1858 // Debounce 1859 if dwell_time < DEBOUNCE_SECS { 1860 return; 1861 } 1862 1863 // Don't duplicate in-flight requests 1864 if profile_cache.pending.contains(&did) { 1865 return; 1866 } 1867 1868 profile_cache.pending.insert(did.clone()); 1869 1870 let client = atp_client.clone(); 1871 let did_clone = did.clone(); 1872 1873 runtime.spawn_background_task(move |mut ctx| async move { 1874 use jacquard::prelude::*; 1875 1876 let actor: jacquard::types::ident::AtIdentifier = did_clone.clone().into(); 1877 let request = GetProfile::new().actor(actor).build(); 1878 let result = client.0.send(request).await; 1879 1880 let profile_data = match result { 1881 Ok(response) => match response.into_output() { 1882 Ok(output) => { 1883 let profile = output.value; 1884 let display_name: Option<String> = profile.display_name.as_ref().map(|s| { 1885 let s: &str = s.as_ref(); 1886 s.to_owned() 1887 }); 1888 let handle = profile.handle.as_str().to_owned(); 1889 let avatar_url = profile.avatar.as_ref().map(|uri| { 1890 let mut url = uri.as_str().to_owned(); 1891 url.push_str("@jpeg"); 1892 url 1893 }); 1894 Some((display_name, handle, avatar_url)) 1895 } 1896 Err(e) => { 1897 warn!( 1898 "failed to parse GetProfile for {}: {e:?}", 1899 did_clone.as_str() 1900 ); 1901 None 1902 } 1903 }, 1904 Err(e) => { 1905 warn!("XRPC error for {}: {e:?}", did_clone.as_str()); 1906 None 1907 } 1908 }; 1909 1910 let did_main = did_clone; 1911 ctx.run_on_main_thread(move |ctx| { 1912 let world = ctx.world; 1913 1914 let mut cache = world.resource_mut::<ProfileCache>(); 1915 cache.pending.remove(&did_main); 1916 1917 // Evict if too large 1918 if cache.profiles.len() >= 256 { 1919 cache.profiles.clear(); 1920 } 1921 1922 match profile_data { 1923 Some((display_name, handle, avatar_url)) => { 1924 let avatar_handle = avatar_url.as_ref().and_then(|url| { 1925 world.get_resource::<AssetServer>().map(|server| { 1926 use bevy::image::{ImageFormatSetting, ImageLoaderSettings}; 1927 server.load_with_settings::<Image, ImageLoaderSettings>( 1928 url.clone(), 1929 |settings: &mut ImageLoaderSettings| { 1930 settings.format = ImageFormatSetting::Guess; 1931 }, 1932 ) 1933 }) 1934 }); 1935 1936 let name_text = display_name 1937 .as_deref() 1938 .unwrap_or(handle.as_str()) 1939 .to_owned(); 1940 let handle_display = format!("@{handle}"); 1941 1942 let mut q = world.query_filtered::<&mut Text, With<DisplayNameText>>(); 1943 for mut text in q.iter_mut(world) { 1944 **text = name_text.clone(); 1945 } 1946 let mut q = world 1947 .query_filtered::<&mut Text, (With<HandleText>, Without<DisplayNameText>)>( 1948 ); 1949 for mut text in q.iter_mut(world) { 1950 **text = handle_display.clone(); 1951 } 1952 if let Some(ref h) = avatar_handle { 1953 let mut q = world.query_filtered::<&mut ImageNode, With<AvatarImage>>(); 1954 for mut img in q.iter_mut(world) { 1955 *img = ImageNode::new(h.clone()); 1956 } 1957 } 1958 1959 let mut cache = world.resource_mut::<ProfileCache>(); 1960 cache.profiles.insert( 1961 did_main, 1962 CachedProfile { 1963 display_name, 1964 handle, 1965 avatar_handle, 1966 }, 1967 ); 1968 cache.bump(); 1969 } 1970 None => { 1971 cache.profiles.insert( 1972 did_main, 1973 CachedProfile { 1974 display_name: None, 1975 handle: String::new(), 1976 avatar_handle: None, 1977 }, 1978 ); 1979 } 1980 } 1981 }) 1982 .await; 1983 }); 1984} 1985 1986/// System: fetch subject records and constellation stats for the selected event. 1987/// Runs after request_profile_for_selected. 1988#[allow(clippy::too_many_arguments)] 1989pub fn request_enrichment_for_selected( 1990 selected: Res<SelectedEvent>, 1991 events: Query<&JetstreamEventMarker>, 1992 mut record_cache: ResMut<RecordCache>, 1993 mut profile_cache: ResMut<ProfileCache>, 1994 runtime: ResMut<bevy_tokio_tasks::TokioTasksRuntime>, 1995 atp_client: Res<crate::net::AtpClient>, 1996) { 1997 let Some(entity) = selected.entity else { 1998 return; 1999 }; 2000 let Ok(event) = events.get(entity) else { 2001 return; 2002 }; 2003 2004 let author_did = event.did().as_str().to_string(); 2005 let card_content = dispatch_card_content(event); 2006 2007 // Collect URIs that need fetching 2008 let mut uris_to_fetch: Vec<String> = Vec::new(); 2009 let mut stats_uris: Vec<String> = Vec::new(); 2010 2011 // Generic: any record with a subject.uri should fetch that subject 2012 if let Some(record) = event.record() { 2013 if let Some(subject_uri) = record.get_at_path("subject.uri").and_then(|d| d.as_str()) { 2014 uris_to_fetch.push(subject_uri.to_string()); 2015 stats_uris.push(subject_uri.to_string()); 2016 } 2017 } 2018 2019 match &card_content { 2020 CardContent::Post { embed, .. } => { 2021 // Build AT-URI for this post for stats 2022 if let JetstreamMessage::Commit { did, commit, .. } = &event.0 { 2023 let post_uri = format!( 2024 "at://{}/{}/{}", 2025 did.as_str(), 2026 commit.collection.as_str(), 2027 commit.rkey.as_str() 2028 ); 2029 stats_uris.push(post_uri); 2030 } 2031 // Fetch quoted post records 2032 if let Some(EmbedContent::Record { uri, .. }) = embed { 2033 uris_to_fetch.push(uri.clone()); 2034 } 2035 if let Some(EmbedContent::RecordWithMedia { record_uri, .. }) = embed { 2036 if !record_uri.is_empty() { 2037 uris_to_fetch.push(record_uri.clone()); 2038 } 2039 } 2040 } 2041 CardContent::Follow { subject_did, .. } => { 2042 // Fetch the followed user's profile 2043 use smol_str::ToSmolStr; 2044 if let Ok(did) = Did::new(subject_did.to_smolstr()) { 2045 if !profile_cache.profiles.contains_key(&did) 2046 && !profile_cache.pending.contains(&did) 2047 { 2048 profile_cache.pending.insert(did.clone()); 2049 let client = atp_client.clone(); 2050 runtime.spawn_background_task(move |mut ctx| async move { 2051 use jacquard::prelude::*; 2052 let actor: jacquard::types::ident::AtIdentifier = did.clone().into(); 2053 let request = GetProfile::new().actor(actor).build(); 2054 let result = client.0.send(request).await; 2055 let profile_data = match result { 2056 Ok(response) => match response.into_output() { 2057 Ok(output) => { 2058 let profile = output.value; 2059 let display_name: Option<String> = 2060 profile.display_name.as_ref().map(|s| { 2061 let s: &str = s.as_ref(); 2062 s.to_owned() 2063 }); 2064 let handle = profile.handle.as_str().to_owned(); 2065 let avatar_url = profile.avatar.as_ref().map(|uri| { 2066 let mut url = uri.as_str().to_owned(); 2067 url.push_str("@jpeg"); 2068 url 2069 }); 2070 Some((display_name, handle, avatar_url)) 2071 } 2072 Err(_) => None, 2073 }, 2074 Err(_) => None, 2075 }; 2076 let did_main = did; 2077 ctx.run_on_main_thread(move |ctx| { 2078 let world = ctx.world; 2079 let mut cache = world.resource_mut::<ProfileCache>(); 2080 cache.pending.remove(&did_main); 2081 match profile_data { 2082 Some((display_name, handle, avatar_url)) => { 2083 let avatar_handle = avatar_url.as_ref().and_then(|url| { 2084 world.get_resource::<AssetServer>().map(|server| { 2085 use bevy::image::{ 2086 ImageFormatSetting, ImageLoaderSettings, 2087 }; 2088 server.load_with_settings::<Image, ImageLoaderSettings>( 2089 url.clone(), 2090 |settings: &mut ImageLoaderSettings| { 2091 settings.format = ImageFormatSetting::Guess; 2092 }, 2093 ) 2094 }) 2095 }); 2096 let mut cache = world.resource_mut::<ProfileCache>(); 2097 cache.profiles.insert( 2098 did_main, 2099 CachedProfile { 2100 display_name, 2101 handle, 2102 avatar_handle, 2103 }, 2104 ); 2105 cache.bump(); 2106 } 2107 None => { 2108 cache.profiles.insert( 2109 did_main, 2110 CachedProfile { 2111 display_name: None, 2112 handle: String::new(), 2113 avatar_handle: None, 2114 }, 2115 ); 2116 } 2117 } 2118 }) 2119 .await; 2120 }); 2121 } 2122 } 2123 } 2124 _ => {} 2125 } 2126 2127 // Fetch subject records via slingshot 2128 for uri in uris_to_fetch { 2129 if record_cache.records.contains_key(&uri) || record_cache.records_pending.contains(&uri) { 2130 continue; 2131 } 2132 if !uri.starts_with("at://") { 2133 continue; 2134 } 2135 record_cache.records_pending.insert(uri.clone()); 2136 2137 let client = atp_client.clone(); 2138 let uri_clone = uri.clone(); 2139 2140 runtime.spawn_background_task(move |mut ctx| async move { 2141 let parsed = match AtUri::new(uri_clone.to_smolstr()) { 2142 Ok(u) => u, 2143 Err(e) => { 2144 warn!("invalid AT-URI {uri_clone}: {e:?}"); 2145 ctx.run_on_main_thread(move |ctx| { 2146 let mut cache = ctx.world.resource_mut::<RecordCache>(); 2147 cache.records_pending.remove(&uri_clone); 2148 }) 2149 .await; 2150 return; 2151 } 2152 }; 2153 2154 let result = client.0.fetch_record_slingshot(&parsed).await; 2155 2156 let uri_main = uri_clone.clone(); 2157 match result { 2158 Ok(output) => { 2159 let data = output.value; 2160 // Extract author DID from the URI 2161 let author = parsed.authority().as_str().to_smolstr(); 2162 let author_did = Did::new(author) 2163 .unwrap_or_else(|_| Did::new_static("did:plc:unknown").unwrap()); 2164 2165 ctx.run_on_main_thread(move |ctx| { 2166 let mut cache = ctx.world.resource_mut::<RecordCache>(); 2167 cache.records_pending.remove(&uri_main); 2168 if cache.records.len() >= 256 { 2169 cache.records.clear(); 2170 } 2171 cache 2172 .records 2173 .insert(uri_main, CachedRecord { data, author_did }); 2174 cache.bump(); 2175 }) 2176 .await; 2177 } 2178 Err(e) => { 2179 warn!("fetch_record_slingshot failed for {uri_clone}: {e:?}"); 2180 let uri_err = uri_main; 2181 ctx.run_on_main_thread(move |ctx| { 2182 let mut cache = ctx.world.resource_mut::<RecordCache>(); 2183 cache.records_pending.remove(&uri_err); 2184 }) 2185 .await; 2186 } 2187 } 2188 }); 2189 } 2190 2191 // Fetch constellation stats 2192 for uri in stats_uris { 2193 if record_cache.stats.contains_key(&uri) || record_cache.stats_pending.contains(&uri) { 2194 continue; 2195 } 2196 record_cache.stats_pending.insert(uri.clone()); 2197 2198 let client = atp_client.clone(); 2199 let uri_clone = uri.clone(); 2200 2201 runtime.spawn_background_task(move |mut ctx| async move { 2202 use jacquard::common::xrpc::XrpcExt; 2203 use smol_str::SmolStr; 2204 2205 let constellation_base = jacquard::deps::fluent_uri::Uri::parse(CONSTELLATION_URL) 2206 .expect("constellation URL is valid"); 2207 2208 // Fetch like count 2209 let like_query = GetBacklinksQuery { 2210 subject: SmolStr::new(&uri_clone), 2211 source: Some(SmolStr::new_static("app.bsky.feed.like:subject.uri")), 2212 limit: Some(1), 2213 cursor: None, 2214 }; 2215 let likes = match client.0.xrpc(constellation_base).send(&like_query).await { 2216 Ok(resp) => match resp.into_output() { 2217 Ok(output) => output.total, 2218 Err(_) => 0, 2219 }, 2220 Err(e) => { 2221 debug!("constellation likes query failed: {e:?}"); 2222 0 2223 } 2224 }; 2225 2226 // Fetch repost count 2227 let repost_query = GetBacklinksQuery { 2228 subject: SmolStr::new(&uri_clone), 2229 source: Some(SmolStr::new_static("app.bsky.feed.repost:subject.uri")), 2230 limit: Some(1), 2231 cursor: None, 2232 }; 2233 let reposts = match client.0.xrpc(constellation_base).send(&repost_query).await { 2234 Ok(resp) => match resp.into_output() { 2235 Ok(output) => output.total, 2236 Err(_) => 0, 2237 }, 2238 Err(e) => { 2239 debug!("constellation reposts query failed: {e:?}"); 2240 0 2241 } 2242 }; 2243 2244 let uri_main = uri_clone; 2245 ctx.run_on_main_thread(move |ctx| { 2246 let mut cache = ctx.world.resource_mut::<RecordCache>(); 2247 cache.stats_pending.remove(&uri_main); 2248 cache.stats.insert(uri_main, RecordStats { likes, reposts }); 2249 cache.bump(); 2250 }) 2251 .await; 2252 }); 2253 } 2254} 2255 2256#[cfg(test)] 2257mod tests { 2258 use super::*; 2259 use jacquard::jetstream::{ 2260 CommitOperation, JetstreamAccount, JetstreamCommit, JetstreamIdentity, 2261 }; 2262 use jacquard::types::string::{Datetime, Did, Nsid, Rkey}; 2263 2264 fn make_post_marker(text: &str) -> JetstreamEventMarker { 2265 let did = Did::new_static("did:plc:testuser123456789").expect("valid DID"); 2266 let collection = Nsid::new_static("app.bsky.feed.post").expect("valid NSID"); 2267 let rkey = Rkey::new_static("3jui7kd54c2").expect("valid rkey"); 2268 2269 let record: jacquard::Data = serde_json::from_str(&format!( 2270 r#"{{"$type":"app.bsky.feed.post","text":{text:?},"createdAt":"2024-01-01T00:00:00Z"}}"# 2271 )) 2272 .expect("valid JSON"); 2273 2274 let commit = JetstreamCommit { 2275 rev: jacquard::deps::smol_str::SmolStr::new_static("rev1"), 2276 operation: CommitOperation::Create, 2277 collection, 2278 rkey, 2279 record: Some(record), 2280 cid: None, 2281 }; 2282 JetstreamEventMarker(JetstreamMessage::Commit { 2283 did, 2284 time_us: 1_700_000_000_000_000, 2285 commit, 2286 }) 2287 } 2288 2289 fn make_like_marker(subject_uri: &str) -> JetstreamEventMarker { 2290 let did = Did::new_static("did:plc:testuser123456789").expect("valid DID"); 2291 let collection = Nsid::new_static("app.bsky.feed.like").expect("valid NSID"); 2292 let rkey = Rkey::new_static("3jui7kd54c3").expect("valid rkey"); 2293 2294 let record: jacquard::Data = 2295 serde_json::from_str(&format!( 2296 r#"{{"$type":"app.bsky.feed.like","subject":{{"uri":{subject_uri:?},"cid":"bafy123"}},"createdAt":"2024-01-01T00:00:00Z"}}"# 2297 )) 2298 .expect("valid JSON"); 2299 2300 let commit = JetstreamCommit { 2301 rev: jacquard::deps::smol_str::SmolStr::new_static("rev1"), 2302 operation: CommitOperation::Create, 2303 collection, 2304 rkey, 2305 record: Some(record), 2306 cid: None, 2307 }; 2308 JetstreamEventMarker(JetstreamMessage::Commit { 2309 did, 2310 time_us: 1_700_000_000_000_000, 2311 commit, 2312 }) 2313 } 2314 2315 fn make_follow_marker(subject_did: &str) -> JetstreamEventMarker { 2316 let did = Did::new_static("did:plc:testuser123456789").expect("valid DID"); 2317 let collection = Nsid::new_static("app.bsky.graph.follow").expect("valid NSID"); 2318 let rkey = Rkey::new_static("3jui7kd54c4").expect("valid rkey"); 2319 2320 let record: jacquard::Data = 2321 serde_json::from_str(&format!( 2322 r#"{{"$type":"app.bsky.graph.follow","subject":{subject_did:?},"createdAt":"2024-01-01T00:00:00Z"}}"# 2323 )) 2324 .expect("valid JSON"); 2325 2326 let commit = JetstreamCommit { 2327 rev: jacquard::deps::smol_str::SmolStr::new_static("rev1"), 2328 operation: CommitOperation::Create, 2329 collection, 2330 rkey, 2331 record: Some(record), 2332 cid: None, 2333 }; 2334 JetstreamEventMarker(JetstreamMessage::Commit { 2335 did, 2336 time_us: 1_700_000_000_000_000, 2337 commit, 2338 }) 2339 } 2340 2341 fn make_unknown_marker(collection_str: &'static str) -> JetstreamEventMarker { 2342 let did = Did::new_static("did:plc:testuser123456789").expect("valid DID"); 2343 let collection = Nsid::new_static(collection_str).expect("valid NSID"); 2344 let rkey = Rkey::new_static("3jui7kd54c5").expect("valid rkey"); 2345 2346 let record: jacquard::Data = 2347 serde_json::from_str(r#"{"name":"my-list","description":"a test list"}"#) 2348 .expect("valid JSON"); 2349 2350 let commit = JetstreamCommit { 2351 rev: jacquard::deps::smol_str::SmolStr::new_static("rev1"), 2352 operation: CommitOperation::Create, 2353 collection, 2354 rkey, 2355 record: Some(record), 2356 cid: None, 2357 }; 2358 JetstreamEventMarker(JetstreamMessage::Commit { 2359 did, 2360 time_us: 1_700_000_000_000_000, 2361 commit, 2362 }) 2363 } 2364 2365 fn make_identity_marker() -> JetstreamEventMarker { 2366 let did = Did::new_static("did:plc:testuser123456789").expect("valid DID"); 2367 let identity = JetstreamIdentity { 2368 did: did.clone(), 2369 handle: None, 2370 seq: 1, 2371 time: Datetime::now(), 2372 }; 2373 JetstreamEventMarker(JetstreamMessage::Identity { 2374 did, 2375 time_us: 1_700_000_000_000_001, 2376 identity, 2377 }) 2378 } 2379 2380 fn make_account_marker() -> JetstreamEventMarker { 2381 let did = Did::new_static("did:plc:testuser123456789").expect("valid DID"); 2382 let account = JetstreamAccount { 2383 did: did.clone(), 2384 active: true, 2385 seq: 2, 2386 time: Datetime::now(), 2387 status: None, 2388 }; 2389 JetstreamEventMarker(JetstreamMessage::Account { 2390 did, 2391 time_us: 1_700_000_000_000_002, 2392 account, 2393 }) 2394 } 2395 2396 #[test] 2397 fn dispatch_routes_post_to_text_content() { 2398 let marker = make_post_marker("Hello, ATmosphere!"); 2399 let result = dispatch_card_content(&marker); 2400 match &result { 2401 CardContent::Post { text, .. } => assert_eq!(text, "Hello, ATmosphere!"), 2402 _ => panic!("expected Post, got primary_text: {}", result.primary_text()), 2403 } 2404 } 2405 2406 #[test] 2407 fn dispatch_routes_like_to_subject_uri() { 2408 let uri = "at://did:plc:abc/app.bsky.feed.post/xyz"; 2409 let marker = make_like_marker(uri); 2410 let result = dispatch_card_content(&marker); 2411 match &result { 2412 CardContent::Like { subject_uri, .. } => assert_eq!(subject_uri, uri), 2413 _ => panic!("expected Like, got primary_text: {}", result.primary_text()), 2414 } 2415 } 2416 2417 #[test] 2418 fn dispatch_routes_follow_to_subject_did() { 2419 let subject = "did:plc:followeduser"; 2420 let marker = make_follow_marker(subject); 2421 let result = dispatch_card_content(&marker); 2422 match &result { 2423 CardContent::Follow { subject_did, .. } => assert_eq!(subject_did, subject), 2424 _ => panic!( 2425 "expected Follow, got primary_text: {}", 2426 result.primary_text() 2427 ), 2428 } 2429 } 2430 2431 #[test] 2432 fn dispatch_routes_unknown_collection_to_field_inspector() { 2433 let marker = make_unknown_marker("app.bsky.graph.list"); 2434 let result = dispatch_card_content(&marker); 2435 match &result { 2436 CardContent::Unknown { collection, fields } => { 2437 assert_eq!(collection, "app.bsky.graph.list"); 2438 assert!( 2439 fields.iter().any(|f| f.key == "name"), 2440 "should have 'name' field, got keys: {:?}", 2441 fields.iter().map(|f| &f.key).collect::<Vec<_>>() 2442 ); 2443 } 2444 _ => panic!( 2445 "expected Unknown, got primary_text: {}", 2446 result.primary_text() 2447 ), 2448 } 2449 } 2450 2451 #[test] 2452 fn dispatch_identity_event() { 2453 let marker = make_identity_marker(); 2454 let result = dispatch_card_content(&marker); 2455 assert!(matches!(result, CardContent::Identity { .. })); 2456 } 2457 2458 #[test] 2459 fn dispatch_account_event() { 2460 let marker = make_account_marker(); 2461 let result = dispatch_card_content(&marker); 2462 match &result { 2463 CardContent::Account { active, .. } => assert!(*active), 2464 _ => panic!("expected Account"), 2465 } 2466 } 2467 2468 #[test] 2469 fn post_extracts_text_field() { 2470 let data: jacquard::Data = serde_json::from_str( 2471 r#"{"text":"test post content","createdAt":"2024-01-01T00:00:00Z"}"#, 2472 ) 2473 .expect("valid JSON"); 2474 let result = build_post_content(Some(&data), "did:plc:test"); 2475 match &result { 2476 CardContent::Post { text, .. } => assert_eq!(text, "test post content"), 2477 _ => panic!("expected Post"), 2478 } 2479 } 2480 2481 #[test] 2482 fn post_no_record_returns_placeholder() { 2483 let result = build_post_content(None, "did:plc:test"); 2484 match &result { 2485 CardContent::Post { text, .. } => assert!(text.contains("no record data")), 2486 _ => panic!("expected Post"), 2487 } 2488 } 2489 2490 #[test] 2491 fn post_missing_text_field() { 2492 let data: jacquard::Data = 2493 serde_json::from_str(r#"{"createdAt":"2024-01-01T00:00:00Z"}"#).expect("valid JSON"); 2494 let result = build_post_content(Some(&data), "did:plc:test"); 2495 match &result { 2496 CardContent::Post { text, .. } => assert_eq!(text, "[no text]"), 2497 _ => panic!("expected Post"), 2498 } 2499 } 2500 2501 #[test] 2502 fn unknown_shows_collection_nsid() { 2503 let nsid = Nsid::new_static("app.bsky.graph.list").expect("valid NSID"); 2504 let data: jacquard::Data = 2505 serde_json::from_str(r#"{"name":"my list"}"#).expect("valid JSON"); 2506 let result = build_unknown_content(&nsid, Some(&data), "did:plc:test"); 2507 match &result { 2508 CardContent::Unknown { collection, .. } => { 2509 assert_eq!(collection, "app.bsky.graph.list"); 2510 } 2511 _ => panic!("expected Unknown"), 2512 } 2513 } 2514 2515 #[test] 2516 fn unknown_has_name_field() { 2517 let nsid = Nsid::new_static("app.bsky.graph.list").expect("valid NSID"); 2518 let data: jacquard::Data = 2519 serde_json::from_str(r#"{"name":"my list"}"#).expect("valid JSON"); 2520 let result = build_unknown_content(&nsid, Some(&data), "did:plc:test"); 2521 match &result { 2522 CardContent::Unknown { fields, .. } => { 2523 let name_field = fields.iter().find(|f| f.key == "name"); 2524 assert!(name_field.is_some(), "should have 'name' field"); 2525 match &name_field.unwrap().value { 2526 DataDisplay::Text(s) => assert_eq!(s, "my list"), 2527 other => panic!("expected Text, got: {:?}", std::mem::discriminant(other)), 2528 } 2529 } 2530 _ => panic!("expected Unknown"), 2531 } 2532 } 2533 2534 #[test] 2535 fn unknown_shows_custom_fields() { 2536 let nsid = Nsid::new_static("app.bsky.graph.list").expect("valid NSID"); 2537 let data: jacquard::Data = 2538 serde_json::from_str(r#"{"customField":"custom value"}"#).expect("valid JSON"); 2539 let result = build_unknown_content(&nsid, Some(&data), "did:plc:test"); 2540 match &result { 2541 CardContent::Unknown { fields, .. } => { 2542 assert!(fields.iter().any(|f| f.key == "customField")); 2543 } 2544 _ => panic!("expected Unknown"), 2545 } 2546 } 2547 2548 #[test] 2549 fn unknown_no_record() { 2550 let nsid = Nsid::new_static("app.bsky.graph.list").expect("valid NSID"); 2551 let result = build_unknown_content(&nsid, None, "did:plc:test"); 2552 match &result { 2553 CardContent::Unknown { collection, fields } => { 2554 assert_eq!(collection, "app.bsky.graph.list"); 2555 assert!(fields.is_empty()); 2556 } 2557 _ => panic!("expected Unknown"), 2558 } 2559 } 2560 2561 #[test] 2562 fn parse_image_embed() { 2563 let data: jacquard::Data = serde_json::from_str(r#"{ 2564 "$type": "app.bsky.embed.images", 2565 "images": [{"alt": "test image", "image": {"$type": "blob", "ref": {"$link": "bafyabc123"}, "mimeType": "image/jpeg", "size": 1234}}] 2566 }"#).expect("valid JSON"); 2567 let result = parse_embed(&data, "did:plc:test"); 2568 assert!(result.is_some()); 2569 match result.unwrap() { 2570 EmbedContent::Images(imgs) => { 2571 assert_eq!(imgs.len(), 1); 2572 assert_eq!(imgs[0].alt, "test image"); 2573 assert_eq!(imgs[0].author_did, "did:plc:test"); 2574 } 2575 _ => panic!("expected Images"), 2576 } 2577 } 2578 2579 #[test] 2580 fn parse_external_embed() { 2581 let data: jacquard::Data = serde_json::from_str(r#"{ 2582 "$type": "app.bsky.embed.external", 2583 "external": {"title": "Cool Site", "description": "A cool website", "uri": "https://example.com"} 2584 }"#).expect("valid JSON"); 2585 let result = parse_embed(&data, "did:plc:test"); 2586 assert!(result.is_some()); 2587 match result.unwrap() { 2588 EmbedContent::External { 2589 title, description, .. 2590 } => { 2591 assert_eq!(title, "Cool Site"); 2592 assert_eq!(description, "A cool website"); 2593 } 2594 _ => panic!("expected External"), 2595 } 2596 } 2597 2598 #[test] 2599 fn parse_record_embed() { 2600 let data: jacquard::Data = serde_json::from_str( 2601 r#"{ 2602 "$type": "app.bsky.embed.record", 2603 "record": {"uri": "at://did:plc:abc/app.bsky.feed.post/xyz", "cid": "bafy123"} 2604 }"#, 2605 ) 2606 .expect("valid JSON"); 2607 let result = parse_embed(&data, "did:plc:test"); 2608 assert!(result.is_some()); 2609 match result.unwrap() { 2610 EmbedContent::Record { uri, .. } => { 2611 assert_eq!(uri, "at://did:plc:abc/app.bsky.feed.post/xyz"); 2612 } 2613 _ => panic!("expected Record"), 2614 } 2615 } 2616}