Something went wrong. Try again.
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617use 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<usize>, /// 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<EmbedContent>, }, Like { subject_uri: String, subject: Option<Box<CardContent>>, }, Follow { subject_did: String, subject_profile: Option<SubjectProfile>, }, Identity { handle: Option<String>, }, Account { active: bool, status: Option<String>, }, Unknown { collection: String, fields: Vec<FieldDisplay>, },}
pub enum EmbedContent { Images(Vec<ImageEmbed>), External { title: String, description: String, thumb_cid: Option<String>, author_did: String, }, Record { uri: String, content: Option<Box<CardContent>>, }, RecordWithMedia { record_uri: String, content: Option<Box<CardContent>>, images: Vec<ImageEmbed>, },}
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<String>, 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<DataDisplay>, }, Object { type_hint: Option<String>, fields: Vec<FieldDisplay>, }, 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<crate::UiFont>) { // 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::<SlideMode>().active; let last_slide = world.resource::<CardRenderState>().last_slide_index;
if let Some(index) = slide_index { let slide_gen = world.resource::<CardRenderState>().slide_generation; let last_slide_gen = world.resource::<CardRenderState>().last_slide_generation; if last_slide == Some(index) && slide_gen == last_slide_gen { return; } world .resource_mut::<CardRenderState>() .last_slide_generation = slide_gen; world.resource_mut::<CardRenderState>().last_slide_index = Some(index);
let slide = { let deck = world.resource::<SlideDeck>(); deck.slides.get(index).cloned() };
if let Some(slide) = slide { set_card_presentation_mode(world, true); hide_event_sections(world); set_display::<SlideContent>(world, true); render_slide_content(world, &slide); } return; }
if last_slide.is_some() { world.resource_mut::<CardRenderState>().last_slide_index = None; set_card_presentation_mode(world, false); set_display::<SlideContent>(world, false); // Restore avatar visibility after slides hid it let mut q = world.query_filtered::<&mut Node, With<AvatarImage>>(); for mut node in q.iter_mut(world) { node.display = Display::Flex; } let mut q = world.query_filtered::<&mut Node, With<DetailCard>>(); for mut node in q.iter_mut(world) { node.display = Display::None; } }
let needs_render = { let selected = world.resource::<SelectedEvent>(); let render_state = world.resource::<CardRenderState>(); let profile_cache = world.resource::<ProfileCache>(); let record_cache = world.resource::<RecordCache>(); 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::<ProfileCache>().generation; let record_gen = world.resource::<RecordCache>().generation; let mut render_state = world.resource_mut::<CardRenderState>(); render_state.last_profile_gen = profile_gen; render_state.last_record_gen = record_gen; }
let entity = { let selected = world.resource::<SelectedEvent>(); selected.entity };
let Some(entity) = entity else { // No selection → hide card world.resource_mut::<SelectedEvent>().previous_entity = None; let mut q = world.query_filtered::<&mut Node, With<DetailCard>>(); 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::<SelectedEvent>(); selected.entity = None; selected.previous_entity = None; let mut q = world.query_filtered::<&mut Node, With<DetailCard>>(); for mut node in q.iter_mut(world) { node.display = Display::None; } return; } } };
// Show card let mut q = world.query_filtered::<&mut Node, With<DetailCard>>(); 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::<ContentText>(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::<CollectionLabel>(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::<TimestampLabel>( 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<String> = 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::<SubjectLabel>(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::<SubjectLabel>(world, &label); render_subject_record(world, subject_uri); } } }
// Show/hide sections set_display::<EmbedSection>(world, show_embed); set_display::<ImagesRow>(world, show_images); set_display::<QuotedPostBox>(world, show_quote); set_display::<ExternalLinkBox>(world, show_ext); set_display::<SubjectSection>(world, show_subject);
// Stats let show_stats = if let Some(ref uri) = stats_uri { let cache = world.resource::<RecordCache>(); 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::<StatsText>(world, &text); true } else { false } } else { false }; set_display::<StatsRow>(world, show_stats);
world.resource_mut::<SelectedEvent>().previous_entity = Some(entity);}
fn set_text<M: Component>(world: &mut World, value: &str) { let mut q = world.query_filtered::<&mut Text, With<M>>(); for mut text in q.iter_mut(world) { **text = value.to_string(); }}
fn set_display<M: Component>(world: &mut World, show: bool) { let mut q = world.query_filtered::<&mut Node, With<M>>(); 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<DetailCard>>(); 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::<DisplayNameText>(world, ""); set_text::<HandleText>(world, ""); set_text::<ContentText>(world, ""); set_text::<CollectionLabel>(world, ""); set_text::<TimestampLabel>(world, ""); set_display::<EmbedSection>(world, false); set_display::<SubjectSection>(world, false); set_display::<StatsRow>(world, false);
let mut q = world.query_filtered::<&mut Node, With<AvatarImage>>(); 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::<Entity, With<SlideContent>>(); q.iter(world).next() }; let Some(content_entity) = content_entity else { return; };
world.entity_mut(content_entity).despawn_children();
let font_handle = world.resource::<crate::UiFont>().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::<AssetServer>(); let handle: Handle<Image> = 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<bevy::text::Font>, 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::<RecordCache>(); 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::<ProfileCache>(); 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::<RecordCache>(); !cache.records.contains_key(uri) && !cache.records_pending.contains(uri) }; if needs_fetch { let mut cache = world.resource_mut::<RecordCache>(); 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::<crate::net::AtpClient>() { let client = atp_client.0.clone(); let uri_owned = uri.to_string(); let runtime = world.resource::<bevy_tokio_tasks::TokioTasksRuntime>(); 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::<RecordCache>(); 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::<CardRenderState>(); render.slide_generation += 1; } }) .await; }); } }}
fn spawn_styled_spans( world: &mut World, font: &Handle<bevy::text::Font>, spans: &[crate::slides::StyledSpan], font_size: f32, bright: Color, _dim: Color,) -> Entity { let ui_font = world.resource::<crate::UiFont>(); 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<jacquard::client::BasicClient>, 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<Image> { let url = bsky_cdn_url(did, cid); let server = world.resource::<AssetServer>(); server.load_with_settings::<Image, ImageLoaderSettings>( 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::<ExternalLinkTitle>(world, title); set_text::<ExternalLinkDescription>(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<ExternalLinkThumb>>(); for (mut node, mut img) in q.iter_mut(world) { *img = ImageNode::new(handle.clone()); node.display = Display::Flex; } } else { set_display::<ExternalLinkThumb>(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::<Entity, With<ImagesRow>>(); 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::<RecordCache>(); 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::<QuotedPostText>(world, &post_text);
let profile_cache = world.resource::<ProfileCache>(); 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::<QuotedPostAuthor>(world, &author_text); } else { set_text::<QuotedPostAuthor>(world, &(author_did.as_str())); } } else { set_text::<QuotedPostText>(world, &format!("loading {uri}…")); set_text::<QuotedPostAuthor>(world, ""); }}
fn render_subject_record(world: &mut World, subject_uri: &str) { let cache = world.resource::<RecordCache>(); 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::<SubjectContent>(world, &post_text);
let profile_cache = world.resource::<ProfileCache>(); if let Some(profile) = profile_cache.profiles.get(&author_did) { let name = profile.display_name.as_deref().unwrap_or(&profile.handle); set_text::<SubjectAuthor>(world, &format!("{name} (@{})", profile.handle)); } else { set_text::<SubjectAuthor>(world, &(author_did.as_str())); } } else { set_text::<SubjectContent>(world, &format!("loading {subject_uri}…")); set_text::<SubjectAuthor>(world, ""); }}
fn render_subject_profile(world: &mut World, subject_did: &str) { use smol_str::ToSmolStr; let profile_cache = world.resource::<ProfileCache>(); 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::<SubjectContent>(world, &text); set_text::<SubjectAuthor>(world, ""); return; } } set_text::<SubjectContent>(world, &format!("loading {subject_did}…")); set_text::<SubjectAuthor>(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 == <Post<jacquard::DefaultStr> as Collection>::NSID { call(|| build_post_content(record, &author_did)) } else if nsid == <Like<jacquard::DefaultStr> as Collection>::NSID { call(|| build_like_content(record)) } else if nsid == <Follow<jacquard::DefaultStr> 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<EmbedContent> { 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<ImageEmbed> { 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<String> { 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<FieldDisplay> { 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<DataDisplay> = 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<FieldDisplay> = 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<String>, pub handle: String, pub avatar_handle: Option<Handle<Image>>,}
/// Resource: cache of resolved profiles, keyed by DID.#[derive(Resource, Default)]pub struct ProfileCache { pub profiles: HashMap<Did, CachedProfile>, pub pending: HashSet<Did>, 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<Did>, 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<String, CachedRecord>, pub records_pending: HashSet<String>, pub stats: HashMap<String, RecordStats>, pub stats_pending: HashSet<String>, 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<S: BosStr = DefaultStr> { pub total: u64, #[serde(default)] pub records: Vec<BacklinkRecord<S>>, pub cursor: Option<S>,}
#[derive(Serialize, Deserialize, IntoStatic)]#[serde(bound(deserialize = "S: Deserialize<'de> + BosStr"))]pub struct BacklinkRecord<S: BosStr = DefaultStr> { 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<S: BosStr = DefaultStr> { pub subject: S, #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option<S>, #[serde(default, skip_serializing_if = "Option::is_none")] pub limit: Option<u32>, #[serde(default, skip_serializing_if = "Option::is_none")] pub cursor: Option<S>,}
/// Constellation base URL.const CONSTELLATION_URL: &str = "https://constellation.microcosm.blue";
#[allow(clippy::too_many_arguments)]pub fn request_profile_for_selected( selected: Res<SelectedEvent>, events: Query<&JetstreamEventMarker>, mut profile_cache: ResMut<ProfileCache>, mut req_state: ResMut<ProfileRequestState>, mut display_name: Query<&mut Text, With<DisplayNameText>>, mut handle_text: Query<&mut Text, (With<HandleText>, Without<DisplayNameText>)>, mut avatar_image: Query<&mut ImageNode, With<AvatarImage>>, time: Res<Time>, runtime: ResMut<bevy_tokio_tasks::TokioTasksRuntime>, atp_client: Res<crate::net::AtpClient>,) { let Some(entity) = selected.entity else { req_state.last_did = None; return; };
let Ok(event) = events.get(entity) else { return; };
let did = event.did().clone();
// Debounce: track when the DID changed if req_state.last_did.as_ref() != Some(&did) { req_state.last_did = Some(did.clone()); req_state.last_did_change = time.elapsed_secs(); } let dwell_time = time.elapsed_secs() - req_state.last_did_change; const DEBOUNCE_SECS: f32 = 0.1;
// If already cached, update display immediately if let Some(cached) = profile_cache.profiles.get(&did) { let name = cached .display_name .as_deref() .unwrap_or(cached.handle.as_str()) .to_owned(); let handle_str = format!("@{}", cached.handle);
for mut text in display_name.iter_mut() { **text = name.clone(); } for mut text in handle_text.iter_mut() { **text = handle_str.clone(); } for mut img in avatar_image.iter_mut() { *img = match &cached.avatar_handle { Some(h) => ImageNode::new(h.clone()), None => ImageNode::default(), }; } return; }
// Show placeholder for mut text in display_name.iter_mut() { **text = did.as_str().to_owned(); } for mut text in handle_text.iter_mut() { **text = "loading…".to_owned(); } for mut img in avatar_image.iter_mut() { *img = ImageNode::default(); }
// Debounce if dwell_time < DEBOUNCE_SECS { return; }
// Don't duplicate in-flight requests if profile_cache.pending.contains(&did) { return; }
profile_cache.pending.insert(did.clone());
let client = atp_client.clone(); let did_clone = did.clone();
runtime.spawn_background_task(move |mut ctx| async move { use jacquard::prelude::*;
let actor: jacquard::types::ident::AtIdentifier = did_clone.clone().into(); let request = GetProfile::new().actor(actor).build(); let result = client.0.send(request).await;
let profile_data = match result { Ok(response) => match response.into_output() { Ok(output) => { let profile = output.value; let display_name: Option<String> = profile.display_name.as_ref().map(|s| { let s: &str = s.as_ref(); s.to_owned() }); let handle = profile.handle.as_str().to_owned(); let avatar_url = profile.avatar.as_ref().map(|uri| { let mut url = uri.as_str().to_owned(); url.push_str("@jpeg"); url }); Some((display_name, handle, avatar_url)) } Err(e) => { warn!( "failed to parse GetProfile for {}: {e:?}", did_clone.as_str() ); None } }, Err(e) => { warn!("XRPC error for {}: {e:?}", did_clone.as_str()); None } };
let did_main = did_clone; ctx.run_on_main_thread(move |ctx| { let world = ctx.world;
let mut cache = world.resource_mut::<ProfileCache>(); cache.pending.remove(&did_main);
// Evict if too large if cache.profiles.len() >= 256 { cache.profiles.clear(); }
match profile_data { Some((display_name, handle, avatar_url)) => { let avatar_handle = avatar_url.as_ref().and_then(|url| { world.get_resource::<AssetServer>().map(|server| { use bevy::image::{ImageFormatSetting, ImageLoaderSettings}; server.load_with_settings::<Image, ImageLoaderSettings>( url.clone(), |settings: &mut ImageLoaderSettings| { settings.format = ImageFormatSetting::Guess; }, ) }) });
let name_text = display_name .as_deref() .unwrap_or(handle.as_str()) .to_owned(); let handle_display = format!("@{handle}");
let mut q = world.query_filtered::<&mut Text, With<DisplayNameText>>(); for mut text in q.iter_mut(world) { **text = name_text.clone(); } let mut q = world .query_filtered::<&mut Text, (With<HandleText>, Without<DisplayNameText>)>( ); for mut text in q.iter_mut(world) { **text = handle_display.clone(); } if let Some(ref h) = avatar_handle { let mut q = world.query_filtered::<&mut ImageNode, With<AvatarImage>>(); for mut img in q.iter_mut(world) { *img = ImageNode::new(h.clone()); } }
let mut cache = world.resource_mut::<ProfileCache>(); cache.profiles.insert( did_main, CachedProfile { display_name, handle, avatar_handle, }, ); cache.bump(); } None => { cache.profiles.insert( did_main, CachedProfile { display_name: None, handle: String::new(), avatar_handle: None, }, ); } } }) .await; });}
/// System: fetch subject records and constellation stats for the selected event./// Runs after request_profile_for_selected.#[allow(clippy::too_many_arguments)]pub fn request_enrichment_for_selected( selected: Res<SelectedEvent>, events: Query<&JetstreamEventMarker>, mut record_cache: ResMut<RecordCache>, mut profile_cache: ResMut<ProfileCache>, runtime: ResMut<bevy_tokio_tasks::TokioTasksRuntime>, atp_client: Res<crate::net::AtpClient>,) { let Some(entity) = selected.entity else { return; }; let Ok(event) = events.get(entity) else { return; };
let author_did = event.did().as_str().to_string(); let card_content = dispatch_card_content(event);
// Collect URIs that need fetching let mut uris_to_fetch: Vec<String> = Vec::new(); let mut stats_uris: Vec<String> = Vec::new();
// Generic: any record with a subject.uri should fetch that subject if let Some(record) = event.record() { if let Some(subject_uri) = record.get_at_path("subject.uri").and_then(|d| d.as_str()) { uris_to_fetch.push(subject_uri.to_string()); stats_uris.push(subject_uri.to_string()); } }
match &card_content { CardContent::Post { embed, .. } => { // Build AT-URI for this post for stats if let JetstreamMessage::Commit { did, commit, .. } = &event.0 { let post_uri = format!( "at://{}/{}/{}", did.as_str(), commit.collection.as_str(), commit.rkey.as_str() ); stats_uris.push(post_uri); } // Fetch quoted post records if let Some(EmbedContent::Record { uri, .. }) = embed { uris_to_fetch.push(uri.clone()); } if let Some(EmbedContent::RecordWithMedia { record_uri, .. }) = embed { if !record_uri.is_empty() { uris_to_fetch.push(record_uri.clone()); } } } CardContent::Follow { subject_did, .. } => { // Fetch the followed user's profile use smol_str::ToSmolStr; if let Ok(did) = Did::new(subject_did.to_smolstr()) { if !profile_cache.profiles.contains_key(&did) && !profile_cache.pending.contains(&did) { profile_cache.pending.insert(did.clone()); let client = atp_client.clone(); runtime.spawn_background_task(move |mut ctx| async move { use jacquard::prelude::*; let actor: jacquard::types::ident::AtIdentifier = did.clone().into(); let request = GetProfile::new().actor(actor).build(); let result = client.0.send(request).await; let profile_data = match result { Ok(response) => match response.into_output() { Ok(output) => { let profile = output.value; let display_name: Option<String> = profile.display_name.as_ref().map(|s| { let s: &str = s.as_ref(); s.to_owned() }); let handle = profile.handle.as_str().to_owned(); let avatar_url = profile.avatar.as_ref().map(|uri| { let mut url = uri.as_str().to_owned(); url.push_str("@jpeg"); url }); Some((display_name, handle, avatar_url)) } Err(_) => None, }, Err(_) => None, }; let did_main = did; ctx.run_on_main_thread(move |ctx| { let world = ctx.world; let mut cache = world.resource_mut::<ProfileCache>(); cache.pending.remove(&did_main); match profile_data { Some((display_name, handle, avatar_url)) => { let avatar_handle = avatar_url.as_ref().and_then(|url| { world.get_resource::<AssetServer>().map(|server| { use bevy::image::{ ImageFormatSetting, ImageLoaderSettings, }; server.load_with_settings::<Image, ImageLoaderSettings>( url.clone(), |settings: &mut ImageLoaderSettings| { settings.format = ImageFormatSetting::Guess; }, ) }) }); let mut cache = world.resource_mut::<ProfileCache>(); cache.profiles.insert( did_main, CachedProfile { display_name, handle, avatar_handle, }, ); cache.bump(); } None => { cache.profiles.insert( did_main, CachedProfile { display_name: None, handle: String::new(), avatar_handle: None, }, ); } } }) .await; }); } } } _ => {} }
// Fetch subject records via slingshot for uri in uris_to_fetch { if record_cache.records.contains_key(&uri) || record_cache.records_pending.contains(&uri) { continue; } if !uri.starts_with("at://") { continue; } record_cache.records_pending.insert(uri.clone());
let client = atp_client.clone(); let uri_clone = uri.clone();
runtime.spawn_background_task(move |mut ctx| async move { let parsed = match AtUri::new(uri_clone.to_smolstr()) { Ok(u) => u, Err(e) => { warn!("invalid AT-URI {uri_clone}: {e:?}"); ctx.run_on_main_thread(move |ctx| { let mut cache = ctx.world.resource_mut::<RecordCache>(); cache.records_pending.remove(&uri_clone); }) .await; return; } };
let result = client.0.fetch_record_slingshot(&parsed).await;
let uri_main = uri_clone.clone(); match result { Ok(output) => { let data = output.value; // Extract author DID from the URI let author = parsed.authority().as_str().to_smolstr(); let author_did = Did::new(author) .unwrap_or_else(|_| Did::new_static("did:plc:unknown").unwrap());
ctx.run_on_main_thread(move |ctx| { let mut cache = ctx.world.resource_mut::<RecordCache>(); cache.records_pending.remove(&uri_main); if cache.records.len() >= 256 { cache.records.clear(); } cache .records .insert(uri_main, CachedRecord { data, author_did }); cache.bump(); }) .await; } Err(e) => { warn!("fetch_record_slingshot failed for {uri_clone}: {e:?}"); let uri_err = uri_main; ctx.run_on_main_thread(move |ctx| { let mut cache = ctx.world.resource_mut::<RecordCache>(); cache.records_pending.remove(&uri_err); }) .await; } } }); }
// Fetch constellation stats for uri in stats_uris { if record_cache.stats.contains_key(&uri) || record_cache.stats_pending.contains(&uri) { continue; } record_cache.stats_pending.insert(uri.clone());
let client = atp_client.clone(); let uri_clone = uri.clone();
runtime.spawn_background_task(move |mut ctx| async move { use jacquard::common::xrpc::XrpcExt; use smol_str::SmolStr;
let constellation_base = jacquard::deps::fluent_uri::Uri::parse(CONSTELLATION_URL) .expect("constellation URL is valid");
// Fetch like count let like_query = GetBacklinksQuery { subject: SmolStr::new(&uri_clone), source: Some(SmolStr::new_static("app.bsky.feed.like:subject.uri")), limit: Some(1), cursor: None, }; let likes = match client.0.xrpc(constellation_base).send(&like_query).await { Ok(resp) => match resp.into_output() { Ok(output) => output.total, Err(_) => 0, }, Err(e) => { debug!("constellation likes query failed: {e:?}"); 0 } };
// Fetch repost count let repost_query = GetBacklinksQuery { subject: SmolStr::new(&uri_clone), source: Some(SmolStr::new_static("app.bsky.feed.repost:subject.uri")), limit: Some(1), cursor: None, }; let reposts = match client.0.xrpc(constellation_base).send(&repost_query).await { Ok(resp) => match resp.into_output() { Ok(output) => output.total, Err(_) => 0, }, Err(e) => { debug!("constellation reposts query failed: {e:?}"); 0 } };
let uri_main = uri_clone; ctx.run_on_main_thread(move |ctx| { let mut cache = ctx.world.resource_mut::<RecordCache>(); cache.stats_pending.remove(&uri_main); cache.stats.insert(uri_main, RecordStats { likes, reposts }); cache.bump(); }) .await; }); }}
#[cfg(test)]mod tests { use super::*; use jacquard::jetstream::{ CommitOperation, JetstreamAccount, JetstreamCommit, JetstreamIdentity, }; use jacquard::types::string::{Datetime, Did, Nsid, Rkey};
fn make_post_marker(text: &str) -> JetstreamEventMarker { let did = Did::new_static("did:plc:testuser123456789").expect("valid DID"); let collection = Nsid::new_static("app.bsky.feed.post").expect("valid NSID"); let rkey = Rkey::new_static("3jui7kd54c2").expect("valid rkey");
let record: jacquard::Data = serde_json::from_str(&format!( r#"{{"$type":"app.bsky.feed.post","text":{text:?},"createdAt":"2024-01-01T00:00:00Z"}}"# )) .expect("valid JSON");
let commit = JetstreamCommit { rev: jacquard::deps::smol_str::SmolStr::new_static("rev1"), operation: CommitOperation::Create, collection, rkey, record: Some(record), cid: None, }; JetstreamEventMarker(JetstreamMessage::Commit { did, time_us: 1_700_000_000_000_000, commit, }) }
fn make_like_marker(subject_uri: &str) -> JetstreamEventMarker { let did = Did::new_static("did:plc:testuser123456789").expect("valid DID"); let collection = Nsid::new_static("app.bsky.feed.like").expect("valid NSID"); let rkey = Rkey::new_static("3jui7kd54c3").expect("valid rkey");
let record: jacquard::Data = serde_json::from_str(&format!( r#"{{"$type":"app.bsky.feed.like","subject":{{"uri":{subject_uri:?},"cid":"bafy123"}},"createdAt":"2024-01-01T00:00:00Z"}}"# )) .expect("valid JSON");
let commit = JetstreamCommit { rev: jacquard::deps::smol_str::SmolStr::new_static("rev1"), operation: CommitOperation::Create, collection, rkey, record: Some(record), cid: None, }; JetstreamEventMarker(JetstreamMessage::Commit { did, time_us: 1_700_000_000_000_000, commit, }) }
fn make_follow_marker(subject_did: &str) -> JetstreamEventMarker { let did = Did::new_static("did:plc:testuser123456789").expect("valid DID"); let collection = Nsid::new_static("app.bsky.graph.follow").expect("valid NSID"); let rkey = Rkey::new_static("3jui7kd54c4").expect("valid rkey");
let record: jacquard::Data = serde_json::from_str(&format!( r#"{{"$type":"app.bsky.graph.follow","subject":{subject_did:?},"createdAt":"2024-01-01T00:00:00Z"}}"# )) .expect("valid JSON");
let commit = JetstreamCommit { rev: jacquard::deps::smol_str::SmolStr::new_static("rev1"), operation: CommitOperation::Create, collection, rkey, record: Some(record), cid: None, }; JetstreamEventMarker(JetstreamMessage::Commit { did, time_us: 1_700_000_000_000_000, commit, }) }
fn make_unknown_marker(collection_str: &'static str) -> JetstreamEventMarker { let did = Did::new_static("did:plc:testuser123456789").expect("valid DID"); let collection = Nsid::new_static(collection_str).expect("valid NSID"); let rkey = Rkey::new_static("3jui7kd54c5").expect("valid rkey");
let record: jacquard::Data = serde_json::from_str(r#"{"name":"my-list","description":"a test list"}"#) .expect("valid JSON");
let commit = JetstreamCommit { rev: jacquard::deps::smol_str::SmolStr::new_static("rev1"), operation: CommitOperation::Create, collection, rkey, record: Some(record), cid: None, }; JetstreamEventMarker(JetstreamMessage::Commit { did, time_us: 1_700_000_000_000_000, commit, }) }
fn make_identity_marker() -> JetstreamEventMarker { let did = Did::new_static("did:plc:testuser123456789").expect("valid DID"); let identity = JetstreamIdentity { did: did.clone(), handle: None, seq: 1, time: Datetime::now(), }; JetstreamEventMarker(JetstreamMessage::Identity { did, time_us: 1_700_000_000_000_001, identity, }) }
fn make_account_marker() -> JetstreamEventMarker { let did = Did::new_static("did:plc:testuser123456789").expect("valid DID"); let account = JetstreamAccount { did: did.clone(), active: true, seq: 2, time: Datetime::now(), status: None, }; JetstreamEventMarker(JetstreamMessage::Account { did, time_us: 1_700_000_000_000_002, account, }) }
#[test] fn dispatch_routes_post_to_text_content() { let marker = make_post_marker("Hello, ATmosphere!"); let result = dispatch_card_content(&marker); match &result { CardContent::Post { text, .. } => assert_eq!(text, "Hello, ATmosphere!"), _ => panic!("expected Post, got primary_text: {}", result.primary_text()), } }
#[test] fn dispatch_routes_like_to_subject_uri() { let uri = "at://did:plc:abc/app.bsky.feed.post/xyz"; let marker = make_like_marker(uri); let result = dispatch_card_content(&marker); match &result { CardContent::Like { subject_uri, .. } => assert_eq!(subject_uri, uri), _ => panic!("expected Like, got primary_text: {}", result.primary_text()), } }
#[test] fn dispatch_routes_follow_to_subject_did() { let subject = "did:plc:followeduser"; let marker = make_follow_marker(subject); let result = dispatch_card_content(&marker); match &result { CardContent::Follow { subject_did, .. } => assert_eq!(subject_did, subject), _ => panic!( "expected Follow, got primary_text: {}", result.primary_text() ), } }
#[test] fn dispatch_routes_unknown_collection_to_field_inspector() { let marker = make_unknown_marker("app.bsky.graph.list"); let result = dispatch_card_content(&marker); match &result { CardContent::Unknown { collection, fields } => { assert_eq!(collection, "app.bsky.graph.list"); assert!( fields.iter().any(|f| f.key == "name"), "should have 'name' field, got keys: {:?}", fields.iter().map(|f| &f.key).collect::<Vec<_>>() ); } _ => panic!( "expected Unknown, got primary_text: {}", result.primary_text() ), } }
#[test] fn dispatch_identity_event() { let marker = make_identity_marker(); let result = dispatch_card_content(&marker); assert!(matches!(result, CardContent::Identity { .. })); }
#[test] fn dispatch_account_event() { let marker = make_account_marker(); let result = dispatch_card_content(&marker); match &result { CardContent::Account { active, .. } => assert!(*active), _ => panic!("expected Account"), } }
#[test] fn post_extracts_text_field() { let data: jacquard::Data = serde_json::from_str( r#"{"text":"test post content","createdAt":"2024-01-01T00:00:00Z"}"#, ) .expect("valid JSON"); let result = build_post_content(Some(&data), "did:plc:test"); match &result { CardContent::Post { text, .. } => assert_eq!(text, "test post content"), _ => panic!("expected Post"), } }
#[test] fn post_no_record_returns_placeholder() { let result = build_post_content(None, "did:plc:test"); match &result { CardContent::Post { text, .. } => assert!(text.contains("no record data")), _ => panic!("expected Post"), } }
#[test] fn post_missing_text_field() { let data: jacquard::Data = serde_json::from_str(r#"{"createdAt":"2024-01-01T00:00:00Z"}"#).expect("valid JSON"); let result = build_post_content(Some(&data), "did:plc:test"); match &result { CardContent::Post { text, .. } => assert_eq!(text, "[no text]"), _ => panic!("expected Post"), } }
#[test] fn unknown_shows_collection_nsid() { let nsid = Nsid::new_static("app.bsky.graph.list").expect("valid NSID"); let data: jacquard::Data = serde_json::from_str(r#"{"name":"my list"}"#).expect("valid JSON"); let result = build_unknown_content(&nsid, Some(&data), "did:plc:test"); match &result { CardContent::Unknown { collection, .. } => { assert_eq!(collection, "app.bsky.graph.list"); } _ => panic!("expected Unknown"), } }
#[test] fn unknown_has_name_field() { let nsid = Nsid::new_static("app.bsky.graph.list").expect("valid NSID"); let data: jacquard::Data = serde_json::from_str(r#"{"name":"my list"}"#).expect("valid JSON"); let result = build_unknown_content(&nsid, Some(&data), "did:plc:test"); match &result { CardContent::Unknown { fields, .. } => { let name_field = fields.iter().find(|f| f.key == "name"); assert!(name_field.is_some(), "should have 'name' field"); match &name_field.unwrap().value { DataDisplay::Text(s) => assert_eq!(s, "my list"), other => panic!("expected Text, got: {:?}", std::mem::discriminant(other)), } } _ => panic!("expected Unknown"), } }
#[test] fn unknown_shows_custom_fields() { let nsid = Nsid::new_static("app.bsky.graph.list").expect("valid NSID"); let data: jacquard::Data = serde_json::from_str(r#"{"customField":"custom value"}"#).expect("valid JSON"); let result = build_unknown_content(&nsid, Some(&data), "did:plc:test"); match &result { CardContent::Unknown { fields, .. } => { assert!(fields.iter().any(|f| f.key == "customField")); } _ => panic!("expected Unknown"), } }
#[test] fn unknown_no_record() { let nsid = Nsid::new_static("app.bsky.graph.list").expect("valid NSID"); let result = build_unknown_content(&nsid, None, "did:plc:test"); match &result { CardContent::Unknown { collection, fields } => { assert_eq!(collection, "app.bsky.graph.list"); assert!(fields.is_empty()); } _ => panic!("expected Unknown"), } }
#[test] fn parse_image_embed() { let data: jacquard::Data = serde_json::from_str(r#"{ "$type": "app.bsky.embed.images", "images": [{"alt": "test image", "image": {"$type": "blob", "ref": {"$link": "bafyabc123"}, "mimeType": "image/jpeg", "size": 1234}}] }"#).expect("valid JSON"); let result = parse_embed(&data, "did:plc:test"); assert!(result.is_some()); match result.unwrap() { EmbedContent::Images(imgs) => { assert_eq!(imgs.len(), 1); assert_eq!(imgs[0].alt, "test image"); assert_eq!(imgs[0].author_did, "did:plc:test"); } _ => panic!("expected Images"), } }
#[test] fn parse_external_embed() { let data: jacquard::Data = serde_json::from_str(r#"{ "$type": "app.bsky.embed.external", "external": {"title": "Cool Site", "description": "A cool website", "uri": "https://example.com"} }"#).expect("valid JSON"); let result = parse_embed(&data, "did:plc:test"); assert!(result.is_some()); match result.unwrap() { EmbedContent::External { title, description, .. } => { assert_eq!(title, "Cool Site"); assert_eq!(description, "A cool website"); } _ => panic!("expected External"), } }
#[test] fn parse_record_embed() { let data: jacquard::Data = serde_json::from_str( r#"{ "$type": "app.bsky.embed.record", "record": {"uri": "at://did:plc:abc/app.bsky.feed.post/xyz", "cid": "bafy123"} }"#, ) .expect("valid JSON"); let result = parse_embed(&data, "did:plc:test"); assert!(result.is_some()); match result.unwrap() { EmbedContent::Record { uri, .. } => { assert_eq!(uri, "at://did:plc:abc/app.bsky.feed.post/xyz"); } _ => panic!("expected Record"), } }}