use ammonia::Builder; use anyhow::Result; use atproto_record::aturi::ATURI; use chrono_tz::Tz; use serde::Serialize; use std::collections::HashMap; use std::collections::HashSet; use std::str::FromStr; use crate::http::errors::EventViewError; /// Display struct for address locations (up to 5 shown on view page) #[derive(Serialize, Debug, Clone)] pub struct AddressDisplay { pub formatted: String, } /// Display struct for geo locations (up to 10 shown on map) #[derive(Serialize, Debug, Clone)] pub struct GeoLocation { pub latitude: String, pub longitude: String, pub name: Option, } use crate::{ http::utils::truncate_text, storage::{ StoragePool, errors::StorageError, event::{ count_event_rsvps, extract_event_details, get_event_rsvp_counts, model::{Event, EventWithRole}, }, identity_profile::{handles_by_did, model::IdentityProfile}, }, }; use atproto_record::lexicon::community::lexicon::calendar::event::NSID as LexiconCommunityEventNSID; #[derive(Serialize, Debug, Clone)] pub struct EventView { pub site_url: String, pub aturi: String, pub cid: String, pub repository: String, pub collection: String, pub organizer_did: String, pub organizer_display_name: String, pub starts_at_machine: Option, pub starts_at_human: Option, pub starts_at_date: Option, pub starts_at_time: Option, pub starts_in_hours: Option, pub is_in_progress: bool, pub ends_at_machine: Option, pub ends_at_human: Option, pub ends_at_date: Option, pub ends_at_time: Option, pub name: String, pub description: Option, pub description_short: Option, pub description_html: Option, pub count_going: u32, pub count_notgoing: u32, pub count_interested: u32, pub mode: Option, pub status: Option, pub address_display: Option, pub links: Vec<(String, Option)>, // (uri, name) pub description_links: Vec, // facet links from description pub description_tags: Vec, // facet tags from description pub header: Option<(String, String)>, // (cid, alt text) pub header_cid: Option, pub thumbnail_cid: Option, pub thumbnail_alt: Option, pub require_confirmed_email: bool, /// When true, disable direct RSVPs and redirect to external ticketing URL pub disable_direct_rsvp: bool, /// External ticketing URL (e.g., ti.to) for ticket purchases pub rsvp_redirect_url: Option, /// First geo location latitude (if available) pub geo_latitude: Option, /// First geo location longitude (if available) pub geo_longitude: Option, /// First geo location name (if available) pub geo_name: Option, /// Multiple address locations (up to 5) pub addresses: Vec, /// Multiple geo locations for map display (up to 10) pub geo_locations: Vec, } impl TryFrom<( Option<&IdentityProfile>, Option<&IdentityProfile>, &Event, &crate::facets::FacetLimits, )> for EventView { type Error = anyhow::Error; fn try_from( (viewer, organizer, event, facet_limits): ( Option<&IdentityProfile>, Option<&IdentityProfile>, &Event, &crate::facets::FacetLimits, ), ) -> Result { // Time zones are used to display date/time values from the perspective // of the viewer. The timezone is selected with this priority: // 1. If the viewer is a logged in user, use their time zone // 2. If the event has a starts at, use the time zone associated with it (not possible with current model) // 3. If the event has a ends at, use the time zone associated with it (not possible with current model) // 4. If the event organizer is known and has a time zone set // 5. UTC let tz = match (viewer, organizer) { (Some(handle), _) => handle.tz.parse::().ok(), (_, Some(handle)) => handle.tz.parse::().ok(), _ => None, } .unwrap_or(Tz::UTC); let aturi = ATURI::from_str(&event.aturi)?; let (repository, collection, rkey) = (aturi.authority, aturi.collection, aturi.record_key); // Only support community event format if collection != LexiconCommunityEventNSID { return Err(EventViewError::InvalidCollection(collection).into()); } let organizer_did = repository.clone(); let organizer_display_name = organizer .map(|value| value.handle.clone()) .unwrap_or_else(|| organizer_did.clone()); // Extract event details using our new helper let details = extract_event_details(event); let header_image = find_header(event); // Clean the name - we use Ammonia here since the name has no facets let event_name = Builder::new() .tags(HashSet::new()) .clean(&details.name) .to_string(); // Keep the original description text without HTML escaping // render_text_with_facets_html will handle escaping while preserving facet byte offsets let event_description = if details.description.is_empty() { None } else { Some(details.description.to_string()) }; // Simplify mode and status strings let mode = details.mode.as_deref().map(|mode_str| { if mode_str.contains("inperson") { "inperson".to_string() } else if mode_str.contains("virtual") { "virtual".to_string() } else if mode_str.contains("hybrid") { "hybrid".to_string() } else { mode_str.to_string() } }); let status = details.status.as_deref().map(|status_str| { if status_str.contains("planned") { "planned".to_string() } else if status_str.contains("scheduled") { "scheduled".to_string() } else if status_str.contains("rescheduled") { "rescheduled".to_string() } else if status_str.contains("cancelled") { "cancelled".to_string() } else if status_str.contains("postponed") { "postponed".to_string() } else { status_str.to_string() } }); let name = Some(event_name); let description = event_description; let starts_at = details.starts_at; let ends_at = details.ends_at; let name = name.ok_or(EventViewError::MissingEventName)?; // Create a truncated and HTML-cleaned version for the short description let description_short = description.as_ref().map(|value| { let truncated = truncate_text(value, 200, Some("...".to_string())); Builder::new() .tags(HashSet::new()) .clean(&truncated) .to_string() }); // Extract links from EventLink objects (structured links with optional titles) // These take priority over facet-extracted links let structured_links: Vec<(String, Option)> = details .uris .iter() .map(|typed_link| { let link = &typed_link.inner; (link.uri.clone(), link.name.clone()) }) .collect(); // Collect structured link URIs for deduplication let structured_link_uris: HashSet<&str> = structured_links .iter() .map(|(uri, _)| uri.as_str()) .collect(); // Extract facets from the event record and render HTML description let (description_html, description_links, description_tags) = if let Some(desc_text) = &description { // Try to extract facets from the event record's extra fields let facets = event .record .as_object() .and_then(|obj| obj.get("facets")) .and_then(|facets_value| { serde_json::from_value::< Vec, >(facets_value.clone()) .ok() }); // Extract link facets for the quick links section // Filter out links that already exist in structured links to avoid duplicates let facet_links = facets .as_ref() .map(|facets_vec| { facets_vec .iter() .filter_map(|facet| { facet.features.first().and_then(|feature| { if let atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature::Link(link) = feature { // Only include if not already in structured links if !structured_link_uris.contains(link.uri.as_str()) { Some(link.uri.clone()) } else { None } } else { None } }) }) .collect::>() }) .unwrap_or_default(); // Extract tag facets for tags display let mut facet_tags = facets .as_ref() .map(|facets_vec| { facets_vec .iter() .filter_map(|facet| { facet.features.first().and_then(|feature| { if let atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature::Tag(tag) = feature { Some(tag.tag.clone()) } else { None } }) }) .collect::>() }) .unwrap_or_default(); // Sort tags alphabetically facet_tags.sort(); // Render the description with facets let html = Some(crate::facets::render_text_with_facets_html( desc_text, facets.as_ref(), facet_limits, )); (html, facet_links, facet_tags) } else { (None, Vec::new(), Vec::new()) }; let starts_at_human = starts_at.as_ref().map(|value| { value .with_timezone(&tz) .format("%e %B %Y %I:%M %P %Z") .to_string() }); let starts_at_machine = starts_at .as_ref() .map(|value| value.with_timezone(&tz).to_string()); // Separate date and time for the redesigned template let starts_at_date = starts_at .as_ref() .map(|value| value.with_timezone(&tz).format("%B %e, %Y").to_string()); let starts_at_time = starts_at .as_ref() .map(|value| value.with_timezone(&tz).format("%I:%M %p %Z").to_string()); // Calculate hours until event starts (for countdown) let starts_in_hours = starts_at.as_ref().and_then(|value| { let now = chrono::Utc::now(); let diff = value.signed_duration_since(now); if diff.num_hours() >= 0 && diff.num_hours() < 24 { Some(diff.num_hours()) } else { None } }); // Check if event is currently in progress let is_in_progress = match (starts_at.as_ref(), ends_at.as_ref()) { (Some(start), Some(end)) => { let now = chrono::Utc::now(); now >= *start && now <= *end } _ => false, }; let ends_at_human = ends_at.as_ref().map(|value| { value .with_timezone(&tz) .format("%e %B %Y %I:%M %P %Z") .to_string() }); let ends_at_machine = ends_at .as_ref() .map(|value| value.with_timezone(&tz).to_string()); let ends_at_date = ends_at .as_ref() .map(|value| value.with_timezone(&tz).format("%B %e, %Y").to_string()); let ends_at_time = ends_at .as_ref() .map(|value| value.with_timezone(&tz).format("%I:%M %p %Z").to_string()); let site_url = if event.lexicon == LexiconCommunityEventNSID { format!("/{}/{}", repository, rkey) } else { format!("/{}/{}?collection={}", repository, rkey, event.lexicon) }; // Extract address locations (up to 5) let addresses: Vec = details .locations .iter() .filter_map(|loc| { if let atproto_record::lexicon::community::lexicon::location::LocationOrRef::InlineAddress( typed_address, ) = loc { Some(AddressDisplay { formatted: crate::storage::event::format_address(&typed_address.inner), }) } else { None } }) .take(5) .collect(); // Extract geo locations for map display (up to 10) let geo_locations: Vec = details .locations .iter() .filter_map(|loc| { if let atproto_record::lexicon::community::lexicon::location::LocationOrRef::InlineGeo( typed_geo, ) = loc { let geo = &typed_geo.inner; Some(GeoLocation { latitude: geo.latitude.clone(), longitude: geo.longitude.clone(), name: geo.name.clone(), }) } else { None } }) .take(10) .collect(); // Keep backwards compatibility: first address for display let address_display = addresses.first().map(|a| a.formatted.clone()); // Keep backwards compatibility: first geo location let (geo_latitude, geo_longitude, geo_name) = geo_locations .first() .map(|g| { ( Some(g.latitude.clone()), Some(g.longitude.clone()), g.name.clone(), ) }) .unwrap_or((None, None, None)); Ok(EventView { site_url, aturi: event.aturi.clone(), cid: event.cid.clone(), repository, collection, organizer_did, organizer_display_name, starts_at_machine, starts_at_human, starts_at_date, starts_at_time, starts_in_hours, is_in_progress, ends_at_machine, ends_at_human, ends_at_date, ends_at_time, name, description, description_short, description_html, count_going: 0, count_notgoing: 0, count_interested: 0, mode, status, address_display, links: structured_links, description_links, description_tags, header: header_image.clone(), header_cid: header_image.map(|(cid, _)| cid), thumbnail_cid: find_thumbnail(event).map(|(cid, _)| cid), thumbnail_alt: find_thumbnail(event) .and_then(|(_, alt)| if alt.is_empty() { None } else { Some(alt) }), require_confirmed_email: event.require_confirmed_email, disable_direct_rsvp: event.disable_direct_rsvp, rsvp_redirect_url: event.rsvp_redirect_url.clone(), geo_latitude, geo_longitude, geo_name, addresses, geo_locations, }) } } pub(crate) async fn hydrate_event_organizers( pool: &StoragePool, events: &[EventWithRole], ) -> Result> { if events.is_empty() { return Ok(HashMap::default()); } let event_creator_dids = events .iter() .map(|event| event.event.did.clone()) .collect::>(); handles_by_did(pool, event_creator_dids) .await .map_err(|err| err.into()) } pub async fn hydrate_event_rsvp_counts( pool: &StoragePool, events: &mut [EventView], ) -> Result<(), anyhow::Error> { if events.is_empty() { return Ok(()); } let aturis = events.iter().map(|e| e.aturi.clone()).collect::>(); let res = get_event_rsvp_counts(pool, aturis).await; match res { Ok(counts) => { for event in events.iter_mut() { let key_going = (event.aturi.clone(), "going".to_string()); let key_interested = (event.aturi.clone(), "interested".to_string()); let key_notgoing = (event.aturi.clone(), "notgoing".to_string()); event.count_going = counts.get(&key_going).cloned().unwrap_or(0) as u32; event.count_interested = counts.get(&key_interested).cloned().unwrap_or(0) as u32; event.count_notgoing = counts.get(&key_notgoing).cloned().unwrap_or(0) as u32; } Ok(()) } Err(StorageError::CannotBeginDatabaseTransaction(_)) => { // Fall back to individual counts if the batched query fails for event in events.iter_mut() { event.count_going = count_event_rsvps(pool, &event.aturi, "going") .await .unwrap_or_default(); event.count_interested = count_event_rsvps(pool, &event.aturi, "interested") .await .unwrap_or_default(); event.count_notgoing = count_event_rsvps(pool, &event.aturi, "notgoing") .await .unwrap_or_default(); } Ok(()) } Err(e) => Err(EventViewError::FailedToHydrateRsvpCounts(e.to_string()).into()), } } use atproto_record::lexicon::community::lexicon::calendar::event::MediaList; fn find_header(event: &Event) -> Option<(String, String)> { let all_media = if let Some(value) = event .record .as_object() .and_then(|value| value.get("media")) { serde_json::from_value::(value.clone()) } else { Ok(Vec::default()) }; if all_media.is_err() { return None; } let all_media = all_media.unwrap(); for media in &all_media { if media.role != "header" || media.aspect_ratio.is_none() { continue; } let content = &media.content; let aspect_ratio = media.aspect_ratio.clone().unwrap(); let alt = media.alt.clone(); let (reported_height, reported_width) = (aspect_ratio.height, aspect_ratio.width); if !(200..=12000).contains(&reported_height) || !(reported_height..=12000).contains(&reported_width) { continue; } let ratio = (reported_width as f64) / (reported_height as f64); // Accept 3:1 (new format) or 16:9 (legacy format) let is_3_1 = (ratio - 3.0).abs() / 3.0 < 0.10; let is_16_9 = (ratio - (16.0 / 9.0)).abs() < 0.02; if is_3_1 || is_16_9 { // Access the CID from the TypedBlob structure let blob_ref = &content.inner.ref_.link; return Some((blob_ref.clone(), alt)); } } None } fn find_thumbnail(event: &Event) -> Option<(String, String)> { let all_media = if let Some(value) = event .record .as_object() .and_then(|value| value.get("media")) { serde_json::from_value::(value.clone()) } else { Ok(Vec::default()) }; if all_media.is_err() { return None; } let all_media = all_media.unwrap(); for media in &all_media { if media.role != "thumbnail" || media.aspect_ratio.is_none() { continue; } let content = &media.content; let aspect_ratio = media.aspect_ratio.clone().unwrap(); let alt = media.alt.clone(); let (reported_height, reported_width) = (aspect_ratio.height, aspect_ratio.width); // Validate size: min 512x512, max 1024x1024 if !(512..=1024).contains(&reported_height) || !(512..=1024).contains(&reported_width) { continue; } // Validate 1:1 aspect ratio (within 5% tolerance) let ratio = (reported_width as f64) / (reported_height as f64); if (ratio - 1.0).abs() > 0.05 { continue; } // Access the CID from the TypedBlob structure let blob_ref = &content.inner.ref_.link; return Some((blob_ref.clone(), alt)); } None }