this repo has no description
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}