use jacquard::deps::smol_str::{SmolStr, ToSmolStr}; use jacquard::types::did::Did; use jacquard::types::handle::Handle; use jacquard::types::string::Datetime; use serde::{Deserialize, Serialize}; #[cfg(test)] use vodplace_api::tv_ionosphere::talk::Talk; /// Unified facet data for both chat messages and ionosphere comments. /// Converts from both `place.stream.richtext.facet::Facet` and `app.bsky.richtext.facet::Facet`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct FacetData { pub byte_start: usize, pub byte_end: usize, pub features: Vec, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub enum FacetFeatureData { Mention { did: Did }, Link { uri: SmolStr }, Tag { tag: SmolStr }, } /// Convert place.stream richtext facets to unified FacetData. /// Validates byte ranges (rejects negative, clamps overflow). pub fn convert_facets(facets: &[vodplace_api::place_stream::richtext::facet::Facet]) -> Vec { facets .iter() .filter_map(|f| { let byte_start = usize::try_from(f.index.byte_start).ok()?; let byte_end = usize::try_from(f.index.byte_end).ok()?; if byte_start > byte_end { return None; } let features = f .features .iter() .filter_map(|feat| { use vodplace_api::place_stream::richtext::facet::FacetFeaturesItem; match feat { FacetFeaturesItem::FacetMention(m) => { Some(FacetFeatureData::Mention { did: m.did.clone() }) } FacetFeaturesItem::FacetLink(l) => Some(FacetFeatureData::Link { uri: SmolStr::new(l.uri.as_ref()), }), _ => None, // open union unknown variants } }) .collect(); Some(FacetData { byte_start, byte_end, features }) }) .collect() } /// Convert app.bsky richtext facets to unified FacetData. /// Includes the Tag variant in addition to Mention and Link. pub fn convert_bsky_facets( facets: &[vodplace_api::app_bsky::richtext::facet::Facet], ) -> Vec { facets .iter() .filter_map(|f| { let byte_start = usize::try_from(f.index.byte_start).ok()?; let byte_end = usize::try_from(f.index.byte_end).ok()?; if byte_start > byte_end { return None; } let features = f .features .iter() .filter_map(|feat| { use vodplace_api::app_bsky::richtext::facet::FacetFeaturesItem; match feat { FacetFeaturesItem::Mention(m) => { Some(FacetFeatureData::Mention { did: m.did.clone() }) } FacetFeaturesItem::Link(l) => Some(FacetFeatureData::Link { uri: SmolStr::new(l.uri.as_ref()), }), FacetFeaturesItem::Tag(t) => Some(FacetFeatureData::Tag { tag: t.tag.to_smolstr(), }), _ => None, // open union unknown variants } }) .collect(); Some(FacetData { byte_start, byte_end, features }) }) .collect() } /// The resolved chat context for a VOD, used to fetch and display chat replay. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ChatContext { pub streamer_did: Did, pub anchor: StreamAnchorTime, pub window_start_ms: i64, pub window_end_ms: i64, } /// A batch of chat messages for a VOD time window, returned by `get_chat_messages`. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ChatMessageBatch { pub messages: Vec, pub has_more: bool, } /// A single chat message ready for display, with resolved author handle and VOD-relative time. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ChatMessageView { pub tid: SmolStr, pub text: SmolStr, pub author_did: Did, pub author_handle: Handle, pub vod_relative_ms: i64, pub reply_snippet: Option, pub facets: Vec, } /// A truncated preview of the parent message for reply display. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ReplySnippet { pub author_did: Did, pub author_handle: Handle, pub text_preview: SmolStr, pub parent_tid: SmolStr, } /// Truncate text for reply preview. Uses char-level truncation (not grapheme clusters, /// which would require the unicode-segmentation crate). Appends an ellipsis if truncated. pub(crate) fn truncate_preview(text: &str, max_chars: usize) -> SmolStr { use jacquard::deps::smol_str::format_smolstr; match text.char_indices().nth(max_chars) { None => SmolStr::new(text), Some((byte_offset, _)) => format_smolstr!("{}\u{2026}", &text[..byte_offset]), } } /// Identifies which timestamp source was used to anchor a stream in wall-clock time. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] pub enum AnchorSource { /// Anchor derived from a scheduled talk's `starts_at` field. Talk, /// Anchor derived from the livestream record's `created_at` field. Livestream, /// Anchor derived from the video record's `created_at` field (fallback). Video, } /// The resolved wall-clock anchor for a stream, used to convert chat message /// timestamps to VOD-relative offsets. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct StreamAnchorTime { /// Unix epoch milliseconds of the anchor point. pub timestamp_ms: i64, /// Which source was used to determine the anchor. pub source: AnchorSource, } /// Resolve the best available anchor time for a stream. /// /// Priority order: /// 1. `talk.starts_at` — scheduled start time from ionosphere talk record /// 2. `livestream_created_at` — when the livestream started /// 3. `video_created_at` — fallback: when the VOD record was created #[cfg(test)] pub(crate) fn resolve_anchor_time( talk: Option<&Talk>, livestream_created_at: Option<&Datetime>, video_created_at: &Datetime, ) -> StreamAnchorTime { resolve_anchor_time_from_fields( talk.and_then(|t| t.starts_at.as_ref()), livestream_created_at, video_created_at, ) } /// Resolve the end time of the stream in epoch milliseconds. /// /// Priority order: /// 1. `talk.ends_at` — scheduled end time from ionosphere talk record /// 2. `livestream_ended_at` — when the livestream ended /// 3. `anchor.timestamp_ms + duration_ns / 1_000_000` — computed from video duration #[cfg(test)] pub(crate) fn resolve_end_time( talk: Option<&Talk>, livestream_ended_at: Option<&Datetime>, anchor: &StreamAnchorTime, duration_ns: u64, ) -> i64 { resolve_end_time_from_fields( talk.and_then(|t| t.ends_at.as_ref()), livestream_ended_at, anchor, duration_ns, ) } /// Compute the wall-clock window of chat messages to fetch. /// /// Expands the \[anchor, end\] window by `fuzz_minutes` on each side to account /// for clock skew and messages posted slightly before/after the stream. pub(crate) fn compute_time_window( anchor: &StreamAnchorTime, end_ms: i64, fuzz_minutes: u32, ) -> (i64, i64) { let fuzz_ms = fuzz_minutes as i64 * 60_000; let start = anchor.timestamp_ms - fuzz_ms; let end = end_ms + fuzz_ms; (start, end) } /// Convert a chat message's wall-clock timestamp to a VOD-relative offset in milliseconds. /// /// Returns a positive value if the message was posted after the anchor (during playback), /// or negative if it was posted before. pub(crate) fn to_vod_relative_ms(message_created_at_ms: i64, anchor_ms: i64) -> i64 { message_created_at_ms - anchor_ms } /// Convert a `Datetime` to Unix epoch milliseconds. /// /// Assumes valid AT Protocol datetime range (post-1970, within i64 bounds). pub(crate) fn datetime_to_ms(dt: &Datetime) -> i64 { dt.as_ref().timestamp_millis() } /// Resolve anchor time from individual timing fields (avoids needing a full Talk record). pub(crate) fn resolve_anchor_time_from_fields( talk_starts_at: Option<&Datetime>, livestream_created_at: Option<&Datetime>, video_created_at: &Datetime, ) -> StreamAnchorTime { if let Some(starts_at) = talk_starts_at { return StreamAnchorTime { timestamp_ms: datetime_to_ms(starts_at), source: AnchorSource::Talk, }; } if let Some(livestream_dt) = livestream_created_at { return StreamAnchorTime { timestamp_ms: datetime_to_ms(livestream_dt), source: AnchorSource::Livestream, }; } StreamAnchorTime { timestamp_ms: datetime_to_ms(video_created_at), source: AnchorSource::Video, } } /// Resolve end time from individual timing fields. pub(crate) fn resolve_end_time_from_fields( talk_ends_at: Option<&Datetime>, livestream_ended_at: Option<&Datetime>, anchor: &StreamAnchorTime, duration_ns: u64, ) -> i64 { if let Some(ends_at) = talk_ends_at { return datetime_to_ms(ends_at); } if let Some(ended_at) = livestream_ended_at { return datetime_to_ms(ended_at); } anchor.timestamp_ms + (duration_ns / 1_000_000) as i64 } #[cfg(test)] mod tests { use super::*; use vodplace_api::tv_ionosphere::talk::Talk; fn test_talk(starts_at: Option<&str>, ends_at: Option<&str>) -> Talk { Talk { title: "Test Talk".into(), event_uri: "at://did:plc:test/tv.ionosphere.event/1".parse().unwrap(), starts_at: starts_at.map(|s| s.parse().unwrap()), ends_at: ends_at.map(|s| s.parse().unwrap()), category: None, description: None, document: None, duration: None, room: None, schedule_uri: None, speaker_uris: None, talk_type: None, video_uri: None, extra_data: None, } } fn test_datetime(iso: &str) -> Datetime { iso.parse().unwrap() } // AC3.1: talk with starts_at → AnchorSource::Talk #[test] fn resolve_anchor_prefers_talk_starts_at() { let talk = test_talk(Some("2026-04-01T10:00:00Z"), None); let video_dt = test_datetime("2026-04-01T09:00:00Z"); let anchor = resolve_anchor_time(Some(&talk), None, &video_dt); assert_eq!(anchor.source, AnchorSource::Talk); // 2026-04-01T10:00:00Z = 1775037600000 ms assert_eq!(anchor.timestamp_ms, 1775037600000); } // AC3.1 edge: talk present but starts_at = None → falls through to livestream #[test] fn resolve_anchor_talk_without_starts_at_falls_through_to_livestream() { let talk = test_talk(None, None); let livestream_dt = test_datetime("2026-04-01T10:30:00Z"); let video_dt = test_datetime("2026-04-01T09:00:00Z"); let anchor = resolve_anchor_time(Some(&talk), Some(&livestream_dt), &video_dt); assert_eq!(anchor.source, AnchorSource::Livestream); assert_eq!(anchor.timestamp_ms, datetime_to_ms(&livestream_dt)); } // AC3.2: no talk, livestream_created_at present → AnchorSource::Livestream #[test] fn resolve_anchor_uses_livestream_when_no_talk() { let livestream_dt = test_datetime("2026-04-01T10:30:00Z"); let video_dt = test_datetime("2026-04-01T09:00:00Z"); let anchor = resolve_anchor_time(None, Some(&livestream_dt), &video_dt); assert_eq!(anchor.source, AnchorSource::Livestream); assert_eq!(anchor.timestamp_ms, datetime_to_ms(&livestream_dt)); } // AC3.3: talk = None, livestream_created_at = None → AnchorSource::Video #[test] fn resolve_anchor_falls_back_to_video_created_at() { let video_dt = test_datetime("2026-04-01T09:00:00Z"); let anchor = resolve_anchor_time(None, None, &video_dt); assert_eq!(anchor.source, AnchorSource::Video); assert_eq!(anchor.timestamp_ms, datetime_to_ms(&video_dt)); } // AC3.4: compute_time_window with fuzz_minutes=5 #[test] fn compute_time_window_applies_fuzz_on_both_sides() { let anchor = StreamAnchorTime { timestamp_ms: 1_000_000, source: AnchorSource::Video, }; let end_ms = 2_000_000i64; let fuzz_minutes = 5u32; let (start, end) = compute_time_window(&anchor, end_ms, fuzz_minutes); let fuzz_ms = 5 * 60_000i64; assert_eq!(start, 1_000_000 - fuzz_ms); assert_eq!(end, 2_000_000 + fuzz_ms); } // AC3.5: resolve_end_time with all None → computed from anchor + duration #[test] fn resolve_end_time_falls_back_to_duration() { let anchor = StreamAnchorTime { timestamp_ms: 1_000_000, source: AnchorSource::Video, }; let duration_ns = 3_600_000_000_000u64; // 1 hour in nanoseconds let end_ms = resolve_end_time(None, None, &anchor, duration_ns); assert_eq!(end_ms, 1_000_000 + 3_600_000); } // AC3.5: talk.ends_at = Some → uses it #[test] fn resolve_end_time_prefers_talk_ends_at() { let talk = test_talk(None, Some("2026-04-01T12:00:00Z")); let anchor = StreamAnchorTime { timestamp_ms: 0, source: AnchorSource::Video, }; let end_ms = resolve_end_time(Some(&talk), None, &anchor, 0); assert_eq!(end_ms, datetime_to_ms(&test_datetime("2026-04-01T12:00:00Z"))); } // AC3.5: livestream_ended_at = Some → uses it (talk has no ends_at) #[test] fn resolve_end_time_uses_livestream_ended_at_when_talk_has_no_ends_at() { let talk = test_talk(None, None); let livestream_ended_at = test_datetime("2026-04-01T11:00:00Z"); let anchor = StreamAnchorTime { timestamp_ms: 0, source: AnchorSource::Video, }; let end_ms = resolve_end_time(Some(&talk), Some(&livestream_ended_at), &anchor, 0); assert_eq!(end_ms, datetime_to_ms(&livestream_ended_at)); } // AC3.6: to_vod_relative_ms for message 30s before anchor (negative) #[test] fn to_vod_relative_ms_negative_for_message_before_anchor() { let anchor_ms = 1_000_000i64; let msg_ms = 1_000_000 - 30_000; // 30 seconds before anchor let offset = to_vod_relative_ms(msg_ms, anchor_ms); assert_eq!(offset, -30_000); } // AC3.6: to_vod_relative_ms for message 30s after anchor (positive) #[test] fn to_vod_relative_ms_positive_for_message_after_anchor() { let anchor_ms = 1_000_000i64; let msg_ms = 1_000_000 + 30_000; // 30 seconds after anchor let offset = to_vod_relative_ms(msg_ms, anchor_ms); assert_eq!(offset, 30_000); } // truncate_preview tests #[test] fn truncate_preview_short_text_unchanged() { let text = "Hello, world!"; assert_eq!(truncate_preview(text, 80).as_str(), "Hello, world!"); } #[test] fn truncate_preview_exact_length_unchanged() { let text: String = "a".repeat(80); assert_eq!(truncate_preview(&text, 80).as_str(), text.as_str()); } #[test] fn truncate_preview_long_text_truncated_with_ellipsis() { let text: String = "a".repeat(100); let result = truncate_preview(&text, 80); // Should be 80 'a's + ellipsis assert_eq!(result.len(), 80 + "\u{2026}".len()); assert!(result.ends_with('\u{2026}')); } #[test] fn truncate_preview_empty_text() { assert_eq!(truncate_preview("", 80).as_str(), ""); } }