use bevy::asset::Handle; use bevy::image::{ImageFormatSetting, ImageLoaderSettings}; use bevy::prelude::*; use jacquard::Data; use jacquard::jetstream::JetstreamMessage; use jacquard::types::collection::Collection; use jacquard::types::string::{AtUri, Nsid}; use jacquard::client::AgentSessionExt; use crate::ingest::JetstreamEventMarker; use crate::interaction::SelectedEvent; use smol_str::ToSmolStr; /// Root node of the event detail card. #[derive(Component)] pub struct DetailCard; /// The avatar image node inside the author row. #[derive(Component)] pub struct AvatarImage; /// The display name text node inside the author name column. #[derive(Component)] pub struct DisplayNameText; /// The handle text node inside the author name column. #[derive(Component)] pub struct HandleText; /// The post content text node. #[derive(Component)] pub struct ContentText; /// The collection NSID label at the bottom of the card. #[derive(Component)] pub struct CollectionLabel; /// The timestamp label at the bottom of the card. #[derive(Component)] pub struct TimestampLabel; /// Container for embed content (images, quoted posts, external links). #[derive(Component)] pub struct EmbedSection; /// A single image thumbnail in the images row. #[derive(Component)] pub struct EmbedImage; /// The images row container (flex-wrap, up to 4 thumbnails). #[derive(Component)] pub struct ImagesRow; /// Quoted post container within the embed section. #[derive(Component)] pub struct QuotedPostBox; /// Author text inside a quoted post. #[derive(Component)] pub struct QuotedPostAuthor; /// Text content inside a quoted post. #[derive(Component)] pub struct QuotedPostText; /// External link card within the embed section. #[derive(Component)] pub struct ExternalLinkBox; /// Title of an external link embed. #[derive(Component)] pub struct ExternalLinkTitle; /// Description of an external link embed. #[derive(Component)] pub struct ExternalLinkDescription; /// Thumbnail image for an external link embed. #[derive(Component)] pub struct ExternalLinkThumb; /// Subject section (for likes: the liked post, for follows: the followed user). #[derive(Component)] pub struct SubjectSection; /// Label inside subject section ("Liked:" / "Followed:"). #[derive(Component)] pub struct SubjectLabel; /// Content inside subject section (post text or profile info). #[derive(Component)] pub struct SubjectContent; /// Subject author info (display name + handle for liked post author). #[derive(Component)] pub struct SubjectAuthor; /// Stats row at the bottom of the card. #[derive(Component)] pub struct StatsRow; /// Stats text ("42 likes · 7 reposts"). #[derive(Component)] pub struct StatsText; /// Container for slide content when in presentation mode. #[derive(Component)] pub struct SlideContent; /// Tracks what cache generation was last rendered so the card /// re-renders when new data arrives without re-rendering every frame. #[derive(Resource, Default)] pub struct CardRenderState { pub last_profile_gen: u64, pub last_record_gen: u64, /// Tracks which slide was last rendered (for change detection). pub last_slide_index: Option, /// Bumped when slide content changes (hot-reload), forces re-render. pub slide_generation: u64, pub last_slide_generation: u64, } /// Top-level content produced by dispatch_card_content. pub enum CardContent { Post { text: String, embed: Option, }, Like { subject_uri: String, subject: Option>, }, Follow { subject_did: String, subject_profile: Option, }, Identity { handle: Option, }, Account { active: bool, status: Option, }, Unknown { collection: String, fields: Vec, }, } pub enum EmbedContent { Images(Vec), External { title: String, description: String, thumb_cid: Option, author_did: String, }, Record { uri: String, content: Option>, }, RecordWithMedia { record_uri: String, content: Option>, images: Vec, }, } pub struct ImageEmbed { pub cid: String, pub alt: String, pub author_did: String, /// Aspect ratio width/height from the embed record, if available. pub aspect_ratio: Option<(u32, u32)>, } pub struct SubjectProfile { pub display_name: Option, pub handle: String, } pub struct FieldDisplay { pub key: String, pub value: DataDisplay, } pub enum DataDisplay { Text(String), Number(i64), Bool(bool), Blob { mime: String, cid: String, author_did: String, }, Link(String), Array { count: usize, preview: Vec, }, Object { type_hint: Option, fields: Vec, }, Null, } impl CardContent { /// Flatten the content tree into a primary display string. pub fn primary_text(&self) -> String { match self { CardContent::Post { text, .. } => text.clone(), // Likes and follows show their content via the subject section, not here CardContent::Like { .. } => String::new(), CardContent::Follow { .. } => String::new(), CardContent::Identity { handle } => { format!( "Identity update\nHandle: {}", handle.as_deref().unwrap_or("(none)") ) } CardContent::Account { active, status } => { format!( "Account event\nActive: {active}\nStatus: {}", status.as_deref().unwrap_or("none") ) } CardContent::Unknown { collection, fields } => { let mut lines = vec![collection.clone()]; for f in fields.iter().take(12) { lines.push(format!("{}: {}", f.key, f.value.display_string(0))); } lines.join("\n") } } } } impl DataDisplay { fn display_string(&self, depth: usize) -> String { if depth > 3 { return "…".into(); } let indent = " ".repeat(depth); match self { DataDisplay::Text(s) => s.clone(), DataDisplay::Number(n) => n.to_string(), DataDisplay::Bool(b) => b.to_string(), DataDisplay::Null => "null".into(), DataDisplay::Link(uri) => uri.clone(), DataDisplay::Blob { mime, .. } => format!("[{mime}]"), DataDisplay::Array { count, preview } => { let mut lines = vec![format!("[{count} items]")]; for (i, item) in preview.iter().enumerate() { lines.push(format!( "{indent} [{i}]: {}", item.display_string(depth + 1) )); } if *count > preview.len() { lines.push(format!("{indent} …{} more", count - preview.len())); } lines.join("\n") } DataDisplay::Object { type_hint, fields } => { let mut lines = Vec::new(); if let Some(hint) = type_hint { lines.push(format!("[{hint}]")); } for f in fields.iter().take(8) { lines.push(format!( "{indent} {}: {}", f.key, f.value.display_string(depth + 1) )); } if fields.len() > 8 { lines.push(format!("{indent} …{} more fields", fields.len() - 8)); } lines.join("\n") } } } } /// Spawn the detail card UI hierarchy. Starts hidden. /// /// ```text /// Card (absolute, right:16, top:16, 520px) /// ├── Author row (avatar + name column) /// ├── Content text /// ├── Embed section (images / quoted post / external link) /// ├── Subject section (liked post / followed user) /// ├── Stats row /// ├── Footer (collection label + timestamp) /// ``` pub fn setup_detail_card(mut commands: Commands, font: Res) { // Blueprint palette — cyan glow let glow = Color::srgba(0.6, 0.8, 1.0, 0.5); let border_color = glow; let dim = TextColor(Color::srgba(0.6, 0.7, 0.9, 0.7)); let bright = TextColor(Color::srgb(0.8, 0.9, 1.0)); let font_handle = font.primary.clone(); let make_font = |size: f32| TextFont { font: font_handle.clone(), font_size: size, ..default() }; let thin_border = UiRect::all(Val::Px(1.0)); commands .spawn(( DetailCard, Node { display: Display::None, position_type: PositionType::Absolute, right: Val::Px(16.0), top: Val::Px(16.0), width: Val::Px(720.0), max_height: Val::Percent(90.0), overflow: Overflow::clip_y(), flex_direction: FlexDirection::Column, padding: UiRect::all(Val::Px(16.0)), row_gap: Val::Px(10.0), border: thin_border, ..default() }, BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.7)), BorderColor::all(border_color), )) .with_children(|card| { // --- Author row --- card.spawn(Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: Val::Px(8.0), ..default() }) .with_children(|row| { // Avatar: square, thin border row.spawn(( AvatarImage, Node { width: Val::Px(72.0), height: Val::Px(72.0), border: thin_border, ..default() }, ImageNode::default(), BorderColor::all(border_color), )); // Name column row.spawn(Node { flex_direction: FlexDirection::Column, row_gap: Val::Px(1.0), ..default() }) .with_children(|col| { col.spawn((DisplayNameText, Text::new("—"), make_font(26.0), bright)); col.spawn((HandleText, Text::new("—"), make_font(22.0), dim)); }); }); // --- Separator --- card.spawn(( Node { height: Val::Px(1.0), width: Val::Percent(100.0), ..default() }, BackgroundColor(border_color), )); // --- Content area --- card.spawn(Node { max_width: Val::Percent(100.0), ..default() }) .with_children(|content| { content.spawn(( ContentText, Text::new(""), TextLayout::new_with_linebreak(LineBreak::WordBoundary), make_font(24.0), bright, )); }); // --- Embed section --- card.spawn(( EmbedSection, Node { display: Display::None, flex_direction: FlexDirection::Column, row_gap: Val::Px(4.0), ..default() }, )) .with_children(|embed| { // Images row embed.spawn(( ImagesRow, Node { display: Display::None, flex_direction: FlexDirection::Row, flex_wrap: FlexWrap::Wrap, column_gap: Val::Px(2.0), row_gap: Val::Px(2.0), ..default() }, )); // Quoted post: thin border box embed .spawn(( QuotedPostBox, Node { display: Display::None, flex_direction: FlexDirection::Column, padding: UiRect::all(Val::Px(8.0)), row_gap: Val::Px(2.0), border: thin_border, ..default() }, BorderColor::all(border_color), )) .with_children(|qp| { qp.spawn((QuotedPostAuthor, Text::new(""), make_font(20.0), dim)); qp.spawn(( QuotedPostText, Text::new(""), TextLayout::new_with_linebreak(LineBreak::WordBoundary), make_font(22.0), bright, )); }); // External link: thin border box embed .spawn(( ExternalLinkBox, Node { display: Display::None, flex_direction: FlexDirection::Column, padding: UiRect::all(Val::Px(8.0)), row_gap: Val::Px(2.0), border: thin_border, ..default() }, BorderColor::all(border_color), )) .with_children(|ext| { ext.spawn(( ExternalLinkThumb, Node { display: Display::None, width: Val::Percent(100.0), max_height: Val::Px(140.0), ..default() }, ImageNode::default(), )); ext.spawn((ExternalLinkTitle, Text::new(""), make_font(22.0), bright)); ext.spawn(( ExternalLinkDescription, Text::new(""), TextLayout::new_with_linebreak(LineBreak::WordBoundary), make_font(20.0), dim, )); }); }); // --- Subject section: thin border box --- card.spawn(( SubjectSection, Node { display: Display::None, flex_direction: FlexDirection::Column, padding: UiRect::all(Val::Px(8.0)), row_gap: Val::Px(2.0), border: thin_border, ..default() }, BorderColor::all(border_color), )) .with_children(|subj| { subj.spawn((SubjectLabel, Text::new(""), make_font(20.0), dim)); subj.spawn((SubjectAuthor, Text::new(""), make_font(20.0), dim)); subj.spawn(( SubjectContent, Text::new(""), TextLayout::new_with_linebreak(LineBreak::WordBoundary), make_font(22.0), bright, )); }); // --- Stats row --- card.spawn(( StatsRow, Node { display: Display::None, ..default() }, )) .with_children(|stats| { stats.spawn((StatsText, Text::new(""), make_font(20.0), dim)); }); // --- Separator --- card.spawn(( Node { height: Val::Px(1.0), width: Val::Percent(100.0), ..default() }, BackgroundColor(border_color), )); // --- Footer: collection + timestamp --- card.spawn(Node { flex_direction: FlexDirection::Row, justify_content: JustifyContent::SpaceBetween, ..default() }) .with_children(|footer| { footer.spawn((CollectionLabel, Text::new(""), make_font(20.0), dim)); footer.spawn((TimestampLabel, Text::new(""), make_font(20.0), dim)); }); // --- Slide content container (hidden by default) --- card.spawn(( SlideContent, Node { display: Display::None, flex_direction: FlexDirection::Column, row_gap: Val::Px(16.0), width: Val::Percent(100.0), ..default() }, )); }); } /// Exclusive system: update detail card or render slide. pub fn update_detail_card(world: &mut World) { use crate::slides::{SlideDeck, SlideMode}; let slide_index = world.resource::().active; let last_slide = world.resource::().last_slide_index; if let Some(index) = slide_index { let slide_gen = world.resource::().slide_generation; let last_slide_gen = world.resource::().last_slide_generation; if last_slide == Some(index) && slide_gen == last_slide_gen { return; } world .resource_mut::() .last_slide_generation = slide_gen; world.resource_mut::().last_slide_index = Some(index); let slide = { let deck = world.resource::(); deck.slides.get(index).cloned() }; if let Some(slide) = slide { set_card_presentation_mode(world, true); hide_event_sections(world); set_display::(world, true); render_slide_content(world, &slide); } return; } if last_slide.is_some() { world.resource_mut::().last_slide_index = None; set_card_presentation_mode(world, false); set_display::(world, false); // Restore avatar visibility after slides hid it let mut q = world.query_filtered::<&mut Node, With>(); for mut node in q.iter_mut(world) { node.display = Display::Flex; } let mut q = world.query_filtered::<&mut Node, With>(); for mut node in q.iter_mut(world) { node.display = Display::None; } } let needs_render = { let selected = world.resource::(); let render_state = world.resource::(); let profile_cache = world.resource::(); let record_cache = world.resource::(); selected.entity != selected.previous_entity || (selected.entity.is_some() && (render_state.last_profile_gen != profile_cache.generation || render_state.last_record_gen != record_cache.generation)) }; if !needs_render { return; } { let profile_gen = world.resource::().generation; let record_gen = world.resource::().generation; let mut render_state = world.resource_mut::(); render_state.last_profile_gen = profile_gen; render_state.last_record_gen = record_gen; } let entity = { let selected = world.resource::(); selected.entity }; let Some(entity) = entity else { // No selection → hide card world.resource_mut::().previous_entity = None; let mut q = world.query_filtered::<&mut Node, With>(); for mut node in q.iter_mut(world) { node.display = Display::None; } return; }; // Get event data let event = { let mut q = world.query::<&JetstreamEventMarker>(); match q.get(world, entity) { Ok(e) => e.clone(), Err(_) => { let mut selected = world.resource_mut::(); selected.entity = None; selected.previous_entity = None; let mut q = world.query_filtered::<&mut Node, With>(); for mut node in q.iter_mut(world) { node.display = Display::None; } return; } } }; // Show card let mut q = world.query_filtered::<&mut Node, With>(); for mut node in q.iter_mut(world) { node.display = Display::Flex; } use bevy::app::hotpatch::call; // Dispatch content tree (hot-patchable) let card_content = call(|| dispatch_card_content(&event)); // --- Content text --- let content_text = call(|| card_content.primary_text()); set_text::(world, &content_text); // --- Collection label --- let col_text = match &event.0 { JetstreamMessage::Commit { commit, .. } => commit.collection.to_string(), JetstreamMessage::Identity { .. } => "identity".to_owned(), JetstreamMessage::Account { .. } => "account".to_owned(), }; set_text::(world, &col_text); // --- Timestamp --- let time_us = event.time_us(); let secs = time_us / 1_000_000; let hours = (secs % 86400) / 3600; let minutes = (secs % 3600) / 60; let secs_remainder = secs % 60; set_text::( world, &format!("{hours:02}:{minutes:02}:{secs_remainder:02}"), ); // --- Embed, subject, stats sections --- let mut show_embed = false; let mut show_images = false; let mut show_quote = false; let mut show_ext = false; let mut show_subject = false; let mut stats_uri: Option = None; match &card_content { CardContent::Post { embed, .. } => { let author_did = event.did().as_str().to_string(); if let Some(embed_content) = embed { show_embed = true; render_embed( world, embed_content, &author_did, &mut show_images, &mut show_quote, &mut show_ext, ); } if let JetstreamMessage::Commit { did, commit, .. } = &event.0 { stats_uri = Some(format!( "at://{}/{}/{}", did.as_str(), commit.collection.as_str(), commit.rkey.as_str() )); } } CardContent::Follow { subject_did, .. } => { show_subject = true; set_text::(world, "Followed:"); render_subject_profile(world, subject_did); } _ => {} } // Generic subject handling: any record with subject.uri gets the subject section if !show_subject { if let Some(record) = event.record() { if let Some(subject_uri) = record.get_at_path("subject.uri").and_then(|d| d.as_str()) { show_subject = true; stats_uri = Some(subject_uri.to_string()); // Label based on collection let label = match &event.0 { JetstreamMessage::Commit { commit, .. } => { let short = commit .collection .as_str() .rsplit('.') .next() .unwrap_or(commit.collection.as_str()); // capitalize first letter let mut label = short.to_string(); if let Some(first) = label.get_mut(..1) { first.make_ascii_uppercase(); } format!("{label}d:") } _ => "Subject:".to_string(), }; set_text::(world, &label); render_subject_record(world, subject_uri); } } } // Show/hide sections set_display::(world, show_embed); set_display::(world, show_images); set_display::(world, show_quote); set_display::(world, show_ext); set_display::(world, show_subject); // Stats let show_stats = if let Some(ref uri) = stats_uri { let cache = world.resource::(); if let Some(stats) = cache.stats.get(uri) { let mut parts = Vec::new(); if stats.likes > 0 { parts.push(format!("{} likes", stats.likes)); } if stats.reposts > 0 { parts.push(format!("{} reposts", stats.reposts)); } let text = parts.join(" · "); set_text::(world, &text); true } else { false } } else { false }; set_display::(world, show_stats); world.resource_mut::().previous_entity = Some(entity); } fn set_text(world: &mut World, value: &str) { let mut q = world.query_filtered::<&mut Text, With>(); for mut text in q.iter_mut(world) { **text = value.to_string(); } } fn set_display(world: &mut World, show: bool) { let mut q = world.query_filtered::<&mut Node, With>(); for mut node in q.iter_mut(world) { node.display = if show { Display::Flex } else { Display::None }; } } fn set_card_presentation_mode(world: &mut World, presentation: bool) { let mut q = world.query_filtered::<&mut Node, With>(); for mut node in q.iter_mut(world) { if presentation { node.display = Display::Flex; node.position_type = PositionType::Absolute; node.left = Val::Percent(5.0); node.right = Val::Percent(5.0); node.top = Val::Percent(5.0); node.bottom = Val::Percent(5.0); node.width = Val::Percent(90.0); node.max_height = Val::Percent(90.0); node.padding = UiRect::all(Val::Px(32.0)); node.row_gap = Val::Px(12.0); node.justify_content = JustifyContent::Center; } else { node.position_type = PositionType::Absolute; node.left = Val::Auto; node.right = Val::Px(16.0); node.top = Val::Px(16.0); node.bottom = Val::Auto; node.width = Val::Px(720.0); node.max_height = Val::Percent(90.0); node.padding = UiRect::all(Val::Px(16.0)); node.row_gap = Val::Px(10.0); node.justify_content = JustifyContent::default(); } } } fn hide_event_sections(world: &mut World) { set_text::(world, ""); set_text::(world, ""); set_text::(world, ""); set_text::(world, ""); set_text::(world, ""); set_display::(world, false); set_display::(world, false); set_display::(world, false); let mut q = world.query_filtered::<&mut Node, With>(); for mut node in q.iter_mut(world) { node.display = Display::None; } } fn render_slide_content(world: &mut World, slide: &crate::slides::Slide) { use crate::slides::SlideFragment; let content_entity = { let mut q = world.query_filtered::>(); q.iter(world).next() }; let Some(content_entity) = content_entity else { return; }; world.entity_mut(content_entity).despawn_children(); let font_handle = world.resource::().primary.clone(); let bright = Color::srgb(0.6, 0.9, 1.0); let dim = Color::srgba(0.4, 0.7, 0.9, 0.7); let code_bg = Color::srgba(0.1, 0.15, 0.2, 0.8); let glow = Color::srgba(0.3, 0.8, 1.0, 0.5); for fragment in &slide.fragments { match fragment { SlideFragment::Heading(level, text) => { let font_size = match level { 1 => 72.0, 2 => 56.0, 3 => 44.0, _ => 36.0, }; let child = world .spawn(( Text::new(text.clone()), TextFont { font: font_handle.clone(), font_size, ..default() }, TextColor(bright), TextLayout::new_with_linebreak(LineBreak::WordBoundary), Node { margin: UiRect::bottom(Val::Px(8.0)), ..default() }, )) .id(); world.entity_mut(content_entity).add_child(child); } SlideFragment::Body(spans) => { let child = spawn_styled_spans(world, &font_handle, spans, 36.0, bright, dim); world.entity_mut(content_entity).add_child(child); } SlideFragment::ListItem(spans) => { let row = world .spawn(Node { flex_direction: FlexDirection::Row, column_gap: Val::Px(12.0), align_items: AlignItems::FlexStart, ..default() }) .id(); let bullet = world .spawn(( Text::new("•"), TextFont { font: font_handle.clone(), font_size: 36.0, ..default() }, TextColor(glow), )) .id(); world.entity_mut(row).add_child(bullet); let text_node = spawn_styled_spans(world, &font_handle, spans, 36.0, bright, dim); world.entity_mut(row).add_child(text_node); world.entity_mut(content_entity).add_child(row); } SlideFragment::Code(code) => { let child = world .spawn(( Node { padding: UiRect::all(Val::Px(16.0)), ..default() }, BackgroundColor(code_bg), )) .with_child(( Text::new(code.clone()), TextFont { font: font_handle.clone(), font_size: 28.0, ..default() }, TextColor(dim), TextLayout::new_with_linebreak(LineBreak::AnyCharacter), )) .id(); world.entity_mut(content_entity).add_child(child); } SlideFragment::Rule => { let child = world .spawn(( Node { height: Val::Px(1.0), width: Val::Percent(100.0), margin: UiRect::axes(Val::Px(0.0), Val::Px(8.0)), ..default() }, BackgroundColor(glow), )) .id(); world.entity_mut(content_entity).add_child(child); } SlideFragment::Image { path, .. } => { let server = world.resource::(); let handle: Handle = server.load(path.clone()); // Wrapper prevents flex column stretch from forcing width on the image. // The image node inside uses Auto mode — inherent aspect ratio sets height. let wrapper = world .spawn(Node { align_self: AlignSelf::Center, max_width: Val::Percent(60.0), max_height: Val::Percent(40.0), margin: UiRect::axes(Val::Px(0.0), Val::Px(8.0)), ..default() }) .id(); let img = world.spawn(ImageNode::new(handle)).id(); world.entity_mut(wrapper).add_child(img); world.entity_mut(content_entity).add_child(wrapper); } SlideFragment::AtEmbed(uri) => { render_slide_at_embed(world, content_entity, uri, &font_handle, bright, dim); } } } } /// Render an `at://` URI embed inside a slide, using the record cache. fn render_slide_at_embed( world: &mut World, parent: Entity, uri: &str, font: &Handle, bright: Color, dim: Color, ) { let glow = Color::srgba(0.3, 0.8, 1.0, 0.5); // Check if the record is already cached let cached_text = { let cache = world.resource::(); cache.records.get(uri).map(|cached| { let text = cached .data .get_at_path("text") .and_then(|d| d.as_str()) .unwrap_or("[no text]") .to_string(); let author = cached.author_did.as_str().to_string(); (text, author) }) }; let border = UiRect::all(Val::Px(1.0)); let (display_text, author_label) = cached_text.unwrap_or_else(|| (format!("loading {uri}…"), String::new())); let container = world .spawn(( Node { flex_direction: FlexDirection::Column, padding: UiRect::all(Val::Px(12.0)), row_gap: Val::Px(4.0), border, ..default() }, BorderColor::all(glow), )) .id(); if !author_label.is_empty() { // Try to resolve author display name from profile cache let author_display = { use smol_str::ToSmolStr; let profile_cache = world.resource::(); jacquard::types::string::Did::new(author_label.to_smolstr()) .ok() .and_then(|did| profile_cache.profiles.get(&did)) .map(|p| { let name = p.display_name.as_deref().unwrap_or(&p.handle); format!("{name} (@{})", p.handle) }) .unwrap_or(author_label) }; let author_node = world .spawn(( Text::new(author_display), TextFont { font: font.clone(), font_size: 28.0, ..default() }, TextColor(dim), )) .id(); world.entity_mut(container).add_child(author_node); } let text_node = world .spawn(( Text::new(display_text), TextFont { font: font.clone(), font_size: 32.0, ..default() }, TextColor(bright), TextLayout::new_with_linebreak(LineBreak::WordBoundary), )) .id(); world.entity_mut(container).add_child(text_node); world.entity_mut(parent).add_child(container); // Trigger a fetch if not cached let needs_fetch = { let cache = world.resource::(); !cache.records.contains_key(uri) && !cache.records_pending.contains(uri) }; if needs_fetch { let mut cache = world.resource_mut::(); cache.records_pending.insert(uri.to_string()); // The existing request_enrichment_for_selected system won't fetch this // since it's not a selected event. We'll trigger it via the XRPC pipe. if let Some(atp_client) = world.get_resource::() { let client = atp_client.0.clone(); let uri_owned = uri.to_string(); let runtime = world.resource::(); runtime.spawn_background_task(move |mut ctx| async move { let result = fetch_record_by_uri(&client, &uri_owned).await; ctx.run_on_main_thread(move |ctx| { let world = ctx.world; let mut cache = world.resource_mut::(); cache.records_pending.remove(&uri_owned); if let Some((data, author_did)) = result { cache .records .insert(uri_owned, CachedRecord { data, author_did }); cache.bump(); // Trigger slide re-render let mut render = world.resource_mut::(); render.slide_generation += 1; } }) .await; }); } } } fn spawn_styled_spans( world: &mut World, font: &Handle, spans: &[crate::slides::StyledSpan], font_size: f32, bright: Color, _dim: Color, ) -> Entity { let ui_font = world.resource::(); let bold_font = ui_font.bold.clone(); let italic_font = ui_font.italic.clone(); // If all spans share the same style, use a single Text node let all_same = spans.iter().all(|s| !s.bold && !s.italic && !s.code); if all_same { let mut full_text = String::new(); for span in spans { full_text.push_str(&span.text); } return world .spawn(( Text::new(full_text), TextFont { font: font.clone(), font_size, ..default() }, TextColor(bright), TextLayout::new_with_linebreak(LineBreak::WordBoundary), )) .id(); } // Mixed styles: use a row of individually-styled text nodes let row = world .spawn(Node { flex_direction: FlexDirection::Row, flex_wrap: FlexWrap::Wrap, ..default() }) .id(); for span in spans { let span_font = if span.bold { bold_font.clone() } else if span.italic { italic_font.clone() } else { font.clone() }; let child = world .spawn(( Text::new(span.text.clone()), TextFont { font: span_font, font_size, ..default() }, TextColor(bright), )) .id(); world.entity_mut(row).add_child(child); } row } async fn fetch_record_by_uri( client: &std::sync::Arc, uri: &str, ) -> Option<(jacquard::Data, jacquard::types::string::Did)> { use jacquard::types::string::Did; use smol_str::ToSmolStr; let parsed = match AtUri::new(uri.to_smolstr()) { Ok(u) => u, Err(e) => { warn!("fetch_record_by_uri: invalid AT-URI {uri}: {e:?}"); return None; } }; let output = match client.fetch_record_slingshot(&parsed).await { Ok(o) => o, Err(e) => { warn!("fetch_record_by_uri: slingshot failed for {uri}: {e:?}"); return None; } }; let author = Did::new(parsed.authority().as_str().to_smolstr()).ok()?; Some((output.value, author)) } fn load_cdn_image(world: &World, did: &str, cid: &str) -> Handle { let url = bsky_cdn_url(did, cid); let server = world.resource::(); server.load_with_settings::( url, |settings: &mut ImageLoaderSettings| { settings.format = ImageFormatSetting::Guess; }, ) } fn render_embed( world: &mut World, embed: &EmbedContent, author_did: &str, show_images: &mut bool, show_quote: &mut bool, show_ext: &mut bool, ) { match embed { EmbedContent::Images(images) => { *show_images = true; spawn_embed_images(world, images); } EmbedContent::External { title, description, thumb_cid, author_did: ext_did, } => { *show_ext = true; set_text::(world, title); set_text::(world, description); if let Some(cid) = thumb_cid { let handle = load_cdn_image(world, ext_did, cid); let mut q = world.query_filtered::<(&mut Node, &mut ImageNode), With>(); for (mut node, mut img) in q.iter_mut(world) { *img = ImageNode::new(handle.clone()); node.display = Display::Flex; } } else { set_display::(world, false); } } EmbedContent::Record { uri, .. } => { *show_quote = true; render_quoted_post(world, uri); } EmbedContent::RecordWithMedia { record_uri, images, .. } => { if !images.is_empty() { *show_images = true; spawn_embed_images(world, images); } if !record_uri.is_empty() { *show_quote = true; render_quoted_post(world, record_uri); } } } } fn spawn_embed_images(world: &mut World, images: &[ImageEmbed]) { // Find the images row entity let row_entity = { let mut q = world.query_filtered::>(); q.iter(world).next() }; let Some(row_entity) = row_entity else { return; }; // Despawn old children world.entity_mut(row_entity).despawn_children(); // Load image handles with sizing info let img_data: Vec<_> = images .iter() .take(4) .map(|img| { let handle = load_cdn_image(world, &img.author_did, &img.cid); (handle, img.aspect_ratio, images.len() == 1) }) .collect(); // Spawn new image children with aspect-ratio-correct sizing for (handle, aspect_ratio, single) in img_data { let max_w = if single { 490.0_f32 } else { 240.0_f32 }; let max_h = if single { 300.0_f32 } else { 160.0_f32 }; let (w, h) = match aspect_ratio { Some((aw, ah)) => { let ar = aw as f32 / ah as f32; if ar > max_w / max_h { // width-constrained (max_w, max_w / ar) } else { // height-constrained (max_h * ar, max_h) } } None => (max_w, max_h), }; let child = world .spawn(( EmbedImage, Node { width: Val::Px(w), height: Val::Px(h), ..default() }, ImageNode::new(handle), )) .id(); world.entity_mut(row_entity).add_child(child); } } fn render_quoted_post(world: &mut World, uri: &str) { let cache = world.resource::(); if let Some(cached) = cache.records.get(uri) { let post_text = cached .data .get_at_path("text") .and_then(|d| d.as_str()) .unwrap_or("[no text]") .to_string(); let author_did = cached.author_did.clone(); set_text::(world, &post_text); let profile_cache = world.resource::(); if let Some(profile) = profile_cache.profiles.get(&author_did) { let name = profile.display_name.as_deref().unwrap_or(&profile.handle); let author_text = format!("{name} (@{})", profile.handle); set_text::(world, &author_text); } else { set_text::(world, &(author_did.as_str())); } } else { set_text::(world, &format!("loading {uri}…")); set_text::(world, ""); } } fn render_subject_record(world: &mut World, subject_uri: &str) { let cache = world.resource::(); if let Some(cached) = cache.records.get(subject_uri) { let post_text = cached .data .get_at_path("text") .and_then(|d| d.as_str()) .unwrap_or("[no text]") .to_string(); let author_did = cached.author_did.clone(); set_text::(world, &post_text); let profile_cache = world.resource::(); if let Some(profile) = profile_cache.profiles.get(&author_did) { let name = profile.display_name.as_deref().unwrap_or(&profile.handle); set_text::(world, &format!("{name} (@{})", profile.handle)); } else { set_text::(world, &(author_did.as_str())); } } else { set_text::(world, &format!("loading {subject_uri}…")); set_text::(world, ""); } } fn render_subject_profile(world: &mut World, subject_did: &str) { use smol_str::ToSmolStr; let profile_cache = world.resource::(); if let Ok(did) = Did::new(subject_did.to_smolstr()) { if let Some(cached) = profile_cache.profiles.get(&did) { let name = cached.display_name.as_deref().unwrap_or(&cached.handle); let text = format!("{name} (@{})", cached.handle); set_text::(world, &text); set_text::(world, ""); return; } } set_text::(world, &format!("loading {subject_did}…")); set_text::(world, ""); } /// Dispatch card content rendering based on collection NSID. /// Returns a semantic CardContent tree. pub fn dispatch_card_content(event: &JetstreamEventMarker) -> CardContent { use bevy::app::hotpatch::call; let record = event.record(); let author_did = event.did().as_str().to_string(); match &event.0 { JetstreamMessage::Commit { commit, .. } => { let nsid = commit.collection.as_str(); use jacquard::api::app_bsky::feed::like::Like; use jacquard::api::app_bsky::feed::post::Post; use jacquard::api::app_bsky::graph::follow::Follow; if nsid == as Collection>::NSID { call(|| build_post_content(record, &author_did)) } else if nsid == as Collection>::NSID { call(|| build_like_content(record)) } else if nsid == as Collection>::NSID { call(|| build_follow_content(record)) } else { call(|| build_unknown_content(&commit.collection, record, &author_did)) } } JetstreamMessage::Identity { identity, .. } => CardContent::Identity { handle: identity.handle.as_ref().map(|h| h.as_str().to_string()), }, JetstreamMessage::Account { account, .. } => CardContent::Account { active: account.active, status: account.status.as_ref().map(|s| format!("{s:?}")), }, } } fn build_post_content(record: Option<&Data>, author_did: &str) -> CardContent { let Some(data) = record else { return CardContent::Post { text: "(no record data)".into(), embed: None, }; }; let text = data .get_at_path("text") .and_then(|d| d.as_str()) .unwrap_or("[no text]") .to_string(); let embed = data .get_at_path("embed") .and_then(|e| parse_embed(e, author_did)); CardContent::Post { text, embed } } fn build_like_content(record: Option<&Data>) -> CardContent { let Some(data) = record else { return CardContent::Like { subject_uri: "[unknown]".into(), subject: None, }; }; let subject_uri = data .get_at_path("subject.uri") .and_then(|d| d.as_str()) .unwrap_or("[unknown subject]") .to_string(); CardContent::Like { subject_uri, subject: None, } } fn build_follow_content(record: Option<&Data>) -> CardContent { let Some(data) = record else { return CardContent::Follow { subject_did: "[unknown]".into(), subject_profile: None, }; }; let subject_did = data .get_at_path("subject") .and_then(|d| d.as_str()) .unwrap_or("[unknown subject]") .to_string(); CardContent::Follow { subject_did, subject_profile: None, } } fn build_unknown_content( collection: &Nsid, record: Option<&Data>, author_did: &str, ) -> CardContent { let Some(data) = record else { return CardContent::Unknown { collection: collection.to_string(), fields: vec![], }; }; let fields = data_to_fields(data, author_did, 0); CardContent::Unknown { collection: collection.to_string(), fields, } } /// Parse an embed Data value into an EmbedContent. fn parse_embed(embed: &Data, author_did: &str) -> Option { let type_str = embed.type_discriminator().unwrap_or(""); if type_str.contains("embed.images") || type_str.contains("embed#images") { let images = parse_embed_images(embed, author_did); if images.is_empty() { return None; } return Some(EmbedContent::Images(images)); } if type_str.contains("embed.external") || type_str.contains("embed#external") { let ext = embed.get_at_path("external")?; let title = ext .get_at_path("title") .and_then(|d| d.as_str()) .unwrap_or("") .to_string(); let description = ext .get_at_path("description") .and_then(|d| d.as_str()) .unwrap_or("") .to_string(); let thumb_cid = extract_blob_cid(ext.get_at_path("thumb")); return Some(EmbedContent::External { title, description, thumb_cid, author_did: author_did.to_string(), }); } if type_str.contains("embed.record#") || (type_str.contains("embed.record") && !type_str.contains("WithMedia")) { let uri = embed .get_at_path("record.uri") .and_then(|d| d.as_str()) .unwrap_or("") .to_string(); if uri.is_empty() { return None; } return Some(EmbedContent::Record { uri, content: None }); } if type_str.contains("recordWithMedia") { let record_uri = embed .get_at_path("record.record.uri") .and_then(|d| d.as_str()) .unwrap_or("") .to_string(); let images = embed .get_at_path("media") .map(|m| parse_embed_images(m, author_did)) .unwrap_or_default(); return Some(EmbedContent::RecordWithMedia { record_uri, content: None, images, }); } None } fn parse_embed_images(embed: &Data, author_did: &str) -> Vec { let mut images = Vec::new(); if let Some(arr) = embed.get_at_path("images").and_then(|d| d.as_array()) { for img in arr.iter() { let cid = if let Some(Data::Blob(blob)) = img.get_at_path("image") { blob.cid().as_str().to_string() } else { img.get_at_path("image.ref.$link") .or_else(|| img.get_at_path("image.ref.link")) .and_then(|d| d.as_str()) .unwrap_or("") .to_string() }; if cid.is_empty() { continue; } let alt = img .get_at_path("alt") .and_then(|d| d.as_str()) .unwrap_or("") .to_string(); let aspect_ratio = img.get_at_path("aspectRatio").and_then(|ar| { let w = ar.get_at_path("width").and_then(|d| d.as_integer())? as u32; let h = ar.get_at_path("height").and_then(|d| d.as_integer())? as u32; if w > 0 && h > 0 { Some((w, h)) } else { None } }); images.push(ImageEmbed { cid, alt, author_did: author_did.to_string(), aspect_ratio, }); } } images } fn extract_blob_cid(val: Option<&Data>) -> Option { let val = val?; if let Data::Blob(blob) = val { return Some(blob.cid().as_str().to_string()); } val.get_at_path("ref.$link") .or_else(|| val.get_at_path("ref.link")) .and_then(|d| d.as_str()) .map(|s| s.to_string()) } /// Convert a Data value into a list of FieldDisplay entries for unknown records. fn data_to_fields(data: &Data, author_did: &str, depth: usize) -> Vec { let mut fields = Vec::new(); if let Some(obj) = data.as_object() { for (key, val) in obj.iter() { if key.as_str() == "$type" || key.as_str() == "createdAt" { continue; } fields.push(FieldDisplay { key: key.to_string(), value: data_to_display(val, author_did, depth), }); } } fields } /// Convert a single Data value into a DataDisplay node. fn data_to_display(val: &Data, author_did: &str, depth: usize) -> DataDisplay { if depth > 4 { return DataDisplay::Text("…".into()); } match val { Data::Null => DataDisplay::Null, Data::Boolean(b) => DataDisplay::Bool(*b), Data::Integer(i) => DataDisplay::Number(*i), Data::String(_) => { let s = val.as_str().unwrap_or(""); if s.starts_with("at://") { DataDisplay::Link(s.to_string()) } else { DataDisplay::Text(s.to_string()) } } Data::Bytes(b) => DataDisplay::Text(format!("[{} bytes]", b.len())), Data::CidLink(cid) => DataDisplay::Link(format!("cid:{}", cid.as_str())), Data::Blob(blob) => DataDisplay::Blob { mime: blob.mime_type.as_str().to_string(), cid: blob.cid().as_str().to_string(), author_did: author_did.to_string(), }, Data::Array(arr) => { let preview: Vec = arr .iter() .take(3) .map(|item| data_to_display(item, author_did, depth + 1)) .collect(); DataDisplay::Array { count: arr.len(), preview, } } Data::Object(obj) => { let type_hint = obj .type_discriminator() .map(|t| t.rsplit('.').next().unwrap_or(t).to_string()); let fields: Vec = obj .iter() .filter(|(k, _)| k.as_str() != "$type") .take(8) .map(|(k, v)| FieldDisplay { key: k.to_string(), value: data_to_display(v, author_did, depth + 1), }) .collect(); DataDisplay::Object { type_hint, fields } } Data::InvalidNumber(s) => { let s: &str = s.as_ref(); DataDisplay::Text(format!("[invalid: {s}]")) } } } /// Build a bsky CDN URL for an image blob. pub fn bsky_cdn_url(did: &str, cid: &str) -> String { format!("https://cdn.bsky.app/img/feed_thumbnail/plain/{did}/{cid}@jpeg") } use std::collections::{HashMap, HashSet}; use jacquard::api::app_bsky::actor::get_profile::GetProfile; use jacquard::types::string::Did; /// Cached profile information for a single user. pub struct CachedProfile { pub display_name: Option, pub handle: String, pub avatar_handle: Option>, } /// Resource: cache of resolved profiles, keyed by DID. #[derive(Resource, Default)] pub struct ProfileCache { pub profiles: HashMap, pub pending: HashSet, pub generation: u64, } impl ProfileCache { pub fn bump(&mut self) { self.generation += 1; } } /// Tracks debounce state for profile requests. #[derive(Resource, Default)] pub struct ProfileRequestState { pub last_did: Option, pub last_did_change: f32, } /// A cached AT Protocol record fetched via slingshot. pub struct CachedRecord { pub data: Data, pub author_did: Did, } /// Engagement stats for a record (from constellation). pub struct RecordStats { pub likes: u64, pub reposts: u64, } /// Resource: cache of fetched records and stats, keyed by AT-URI string. #[derive(Resource, Default)] pub struct RecordCache { pub records: HashMap, pub records_pending: HashSet, pub stats: HashMap, pub stats_pending: HashSet, pub generation: u64, } impl RecordCache { pub fn bump(&mut self) { self.generation += 1; } } use jacquard::{BosStr, DefaultStr, IntoStatic, XrpcRequest}; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, IntoStatic)] #[serde(bound(deserialize = "S: Deserialize<'de> + BosStr"))] pub struct GetBacklinksResponse { pub total: u64, #[serde(default)] pub records: Vec>, pub cursor: Option, } #[derive(Serialize, Deserialize, IntoStatic)] #[serde(bound(deserialize = "S: Deserialize<'de> + BosStr"))] pub struct BacklinkRecord { pub did: S, pub collection: S, pub rkey: S, } #[derive(Serialize, Deserialize, XrpcRequest)] #[xrpc(nsid = "blue.microcosm.links.getBacklinks", method = Query, output = GetBacklinksResponse)] pub struct GetBacklinksQuery { pub subject: S, #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub limit: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cursor: Option, } /// Constellation base URL. const CONSTELLATION_URL: &str = "https://constellation.microcosm.blue"; #[allow(clippy::too_many_arguments)] pub fn request_profile_for_selected( selected: Res, events: Query<&JetstreamEventMarker>, mut profile_cache: ResMut, mut req_state: ResMut, mut display_name: Query<&mut Text, With>, mut handle_text: Query<&mut Text, (With, Without)>, mut avatar_image: Query<&mut ImageNode, With>, time: Res