diff --git a/src/catalog.rs b/src/catalog.rs index 7496127..d619e57 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -62,7 +62,8 @@ pub struct SpeakerInfo { pub struct CommentData { pub uri: AtUri, pub text: SmolStr, - pub author: ProfileInfo, + pub author_did: Did, + pub author_handle: Handle, pub created_at: Datetime, /// Byte range anchoring this comment to a substring of the transcript text. /// `None` means the comment is not anchored to any specific transcript position. @@ -143,12 +144,18 @@ pub struct BacklinkRecord { #[allow(dead_code)] pub const SLINGSHOT_URL: &str = "https://slingshot.microcosm.blue"; -/// User-Agent header value for outbound requests. -/// -/// TODO: wire into per-request headers once the jacquard XRPC builder -/// exposes a clean header-setting API. -#[allow(dead_code)] -pub const USER_AGENT: &str = "vodplace/0.1.0"; +/// User-Agent header value for outbound constellation/slingshot requests. +#[cfg(feature = "server")] +const USER_AGENT: &str = "vodplace/0.1.0"; + +/// User-Agent header components for xrpc requests. +#[cfg(feature = "server")] +const fn user_agent_header() -> (axum::http::HeaderName, axum::http::HeaderValue) { + ( + axum::http::HeaderName::from_static("user-agent"), + axum::http::HeaderValue::from_static(USER_AGENT), + ) +} /// Request body for slingshot's hydrateQueryResponse endpoint. #[derive(Serialize, Deserialize, Debug, Clone, jacquard_derive::XrpcRequest)] @@ -797,7 +804,13 @@ async fn query_backlinks( reverse: false, }; let constellation_uri = Uri::parse(CONSTELLATION_URL).ok()?; - let resp = client.xrpc(constellation_uri).send(&query).await.ok()?; + let (ua_name, ua_value) = user_agent_header(); + let resp = client + .xrpc(constellation_uri) + .header(ua_name, ua_value) + .send(&query) + .await + .ok()?; resp.into_output().ok() } @@ -1102,7 +1115,6 @@ pub async fn get_comments( transcript_uri: Option, ) -> Result, ServerFnError> { use crate::chat::convert_bsky_facets; - use jacquard_common::types::ident::AtIdentifier; use vodplace_api::tv_ionosphere::comment::{Comment, CommentRecord}; let client = shared_client(); @@ -1125,25 +1137,20 @@ pub async fn get_comments( }; for subject_str in &subjects_to_query { - // Try fetching backlinks via slingshot first, fall back to direct Constellation. - let backlink_uris = - fetch_comment_backlinks_slingshot(&client, &slingshot_uri, subject_str).await - .unwrap_or_else(|e| { - warn!("slingshot comment backlinks failed for {subject_str}: {e}"); - vec![] - }); - - let uris_to_add = if backlink_uris.is_empty() { - // Fallback: query Constellation directly - match fetch_comment_backlinks_direct(&client, &constellation_uri, subject_str).await { - Ok(uris) => uris, - Err(e) => { - warn!("constellation comment backlinks failed for {subject_str}: {e}"); - vec![] + // Try slingshot first. An empty Ok result is valid (no comments exist); + // only fall back to constellation on actual failure. + let uris_to_add = match fetch_comment_backlinks_slingshot(&client, &slingshot_uri, subject_str).await { + Ok(uris) => uris, + Err(e) => { + warn!("slingshot comment backlinks failed for {subject_str}, trying constellation: {e}"); + match fetch_comment_backlinks_direct(&client, &constellation_uri, subject_str).await { + Ok(uris) => uris, + Err(e2) => { + warn!("constellation comment backlinks also failed for {subject_str}: {e2}"); + vec![] + } } } - } else { - backlink_uris }; for uri_str in uris_to_add { @@ -1192,7 +1199,8 @@ pub async fn get_comments( .flatten() .collect(); - // Collect unique author DIDs and resolve profiles in parallel. + // Resolve unique author handles via lightweight DID document lookup. + // This is much cheaper than resolve_profile (no GetProfile API call). let unique_dids: Vec = { let mut seen = std::collections::HashSet::new(); fetched @@ -1208,30 +1216,27 @@ pub async fn get_comments( .collect() }; - let profile_futures = unique_dids.iter().map(|did| { + let handle_futures = unique_dids.iter().map(|did| { let client = client.clone(); let did = did.clone(); async move { - let ident = AtIdentifier::Did(did.clone()); - match resolve_profile(&client, &ident).await { - Ok(profile) => Some((did, profile)), + match crate::identity::resolve_handle_for_did(&*client, &did).await { + Ok(handle) => Some((did, handle)), Err(e) => { - warn!(did = %did.as_str(), "failed to resolve comment author profile: {e}"); + warn!(did = %did.as_str(), "failed to resolve comment author handle: {e}"); None } } } }); - let profile_map: HashMap = join_all(profile_futures) + let handle_map: HashMap = join_all(handle_futures) .await .into_iter() .flatten() .collect(); - // Build the fallback profile for authors whose resolution failed. - let fallback_handle: jacquard::types::handle::Handle = - "handle.invalid".parse().expect("valid handle literal"); + let fallback_handle: Handle = "handle.invalid".parse().expect("valid handle literal"); // Convert fetched records to CommentData. let comments: Vec = fetched @@ -1245,14 +1250,10 @@ pub async fn get_comments( } }; - let author = profile_map.get(&author_did).cloned().unwrap_or_else(|| { - ProfileInfo { - did: author_did.clone(), - handle: fallback_handle.clone(), - display_name: None, - avatar_url: None, - } - }); + let author_handle = handle_map + .get(&author_did) + .cloned() + .unwrap_or_else(|| fallback_handle.clone()); let anchor = comment.anchor.and_then(|a| { // Reject negative values — treat comment as non-anchored. @@ -1262,15 +1263,22 @@ pub async fn get_comments( if byte_start > byte_end { return None; } - Some(ByteRangeData { byte_start, byte_end }) + Some(ByteRangeData { + byte_start, + byte_end, + }) }); - let facets = comment.facets.as_deref().map(convert_bsky_facets).filter(|v| !v.is_empty()); + let facets = comment.facets.as_deref().and_then(|fs| { + let v = convert_bsky_facets(fs); + if v.is_empty() { None } else { Some(v) } + }); Some(CommentData { uri, text: SmolStr::new(comment.text.as_ref() as &str), - author, + author_did, + author_handle, created_at: comment.created_at, anchor, subject_uri: comment.subject, @@ -1315,8 +1323,10 @@ async fn fetch_comment_backlinks_slingshot( hydration_sources: vec![], }; + let (ua_name, ua_value) = user_agent_header(); let resp = client .xrpc(slingshot_uri.clone()) + .header(ua_name, ua_value) .send(&hydrate_req) .await .map_err(|e| format!("slingshot request failed: {e}"))?; @@ -1359,8 +1369,10 @@ async fn fetch_comment_backlinks_direct( reverse: false, }; + let (ua_name, ua_value) = user_agent_header(); let resp = client .xrpc(constellation_uri.clone()) + .header(ua_name, ua_value) .send(&query) .await .map_err(|e| format!("constellation request failed: {e}"))?; @@ -1655,8 +1667,7 @@ pub async fn get_chat_colours( } // Construct the profile URI: at://did/place.stream.chat.profile/self - let profile_uri_str = - format!("at://{}/place.stream.chat.profile/self", did.as_str()); + let profile_uri_str = format!("at://{}/place.stream.chat.profile/self", did.as_str()); let profile_uri: AtUri = match profile_uri_str.parse() { Ok(u) => u, Err(e) => { @@ -1691,10 +1702,8 @@ pub async fn get_chat_colours( } }); - let results: HashMap> = join_all(fetch_futures) - .await - .into_iter() - .collect(); + let results: HashMap> = + join_all(fetch_futures).await.into_iter().collect(); Ok(results) } @@ -1825,8 +1834,10 @@ async fn fetch_via_slingshot( }], }; + let (ua_name, ua_value) = user_agent_header(); let resp = client .xrpc(slingshot_uri.clone()) + .header(ua_name, ua_value) .send(&hydrate_req) .await .map_err(|e| format!("slingshot request failed: {e}"))?; @@ -2013,8 +2024,10 @@ async fn fetch_fallback( reverse, }; + let (ua_name, ua_value) = user_agent_header(); let resp = client .xrpc(constellation_uri) + .header(ua_name, ua_value) .send(&query) .await .map_err(|e| format!("constellation request failed: {e}"))?; diff --git a/src/comments.rs b/src/comments.rs index 48d1c7e..7e05fda 100644 --- a/src/comments.rs +++ b/src/comments.rs @@ -8,7 +8,7 @@ //! `(first_word_index, last_word_index)` pair, with expansion to full word boundaries //! and clamping to the transcript length. -use crate::catalog::{ByteRangeData, CommentData}; +use crate::catalog::CommentData; use vodplace_subtitle::TranscriptData; /// A comment node in the reply tree, with its nested replies. @@ -207,7 +207,7 @@ mod tests { use jacquard::types::{aturi::AtUri, string::Datetime}; use vodplace_subtitle::{TranscriptData, TranscriptWord}; - use crate::catalog::{ByteRangeData, CommentData, ProfileInfo}; + use crate::catalog::{ByteRangeData, CommentData}; // ── helpers ─────────────────────────────────────────────────────────────── @@ -215,17 +215,8 @@ mod tests { format!("did:plc:{:0>24}", n).parse().unwrap() } - fn make_handle(s: &str) -> jacquard::types::handle::Handle { - s.parse().unwrap() - } - - fn make_profile(n: u8) -> ProfileInfo { - ProfileInfo { - did: make_did(n), - handle: make_handle(&format!("user{n}.test")), - display_name: None, - avatar_url: None, - } + fn make_handle(n: u8) -> jacquard::types::handle::Handle { + format!("user{n}.test").parse().unwrap() } fn make_uri(s: &str) -> AtUri { @@ -247,7 +238,8 @@ mod tests { CommentData { uri: make_uri(&uri_str), text: SmolStr::new("comment text"), - author: make_profile(1), + author_did: make_did(1), + author_handle: make_handle(1), created_at: make_dt(created_at), anchor, subject_uri: make_uri(subject), @@ -261,7 +253,8 @@ mod tests { CommentData { uri: make_uri(&uri_str), text: SmolStr::new("reply text"), - author: make_profile(2), + author_did: make_did(2), + author_handle: make_handle(2), created_at: make_dt(created_at), anchor: None, subject_uri: make_uri(parent_uri), @@ -483,7 +476,8 @@ mod tests { CommentData { uri: uri_b.parse().unwrap(), text: SmolStr::new("another comment"), - author: make_profile(3), + author_did: make_did(3), + author_handle: make_handle(3), created_at: make_dt("2024-01-01T00:02:00.000Z"), anchor: Some(anchor_b), subject_uri: make_uri(TALK_URI), diff --git a/src/components/comment_body.rs b/src/components/comment_body.rs new file mode 100644 index 0000000..9adbe1a --- /dev/null +++ b/src/components/comment_body.rs @@ -0,0 +1,82 @@ +//! `CommentBody` — renders a single comment node with author, divider, text, +//! and recursively renders replies with threading. + +use dioxus::prelude::*; + +use crate::comments::CommentNode; +use crate::components::facet_text::FacetText; + +/// Derive a short hex string from an AT URI for use in DOM `id` attributes. +/// +/// Uses `DefaultHasher` — only suitable for DOM ID generation, not security. +fn uri_hash(uri: &str) -> String { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + uri.hash(&mut hasher); + format!("{:x}", hasher.finish()) +} + +/// Render a single `CommentNode`, including its nested replies. +/// +/// # Threading rules +/// +/// - **Linear chain (exactly one reply):** the reply is rendered at the same +/// indentation level — no `div.comment-replies` wrapper, no extra indent. +/// - **Branching (two or more replies):** replies are wrapped in +/// `div.comment-replies` which applies `margin-inline-start: 1.5rem`. +/// +/// `is_last_sibling` is stored as a `data-last-sibling` attribute so the SVG +/// connector overlay (Task 3) can distinguish `└` corners from `├` tees. +#[component] +pub fn CommentBody(node: CommentNode, is_last_sibling: bool) -> Element { + let hash = uri_hash(node.comment.uri.as_str()); + let body_id = format!("comment-body-{hash}"); + let rule_id = format!("comment-rule-{hash}"); + + let author = node.comment.author_handle.as_str().to_owned(); + let text = node.comment.text.clone(); + let facets = node.comment.facets.clone().unwrap_or_default(); + + let reply_count = node.replies.len(); + + rsx! { + div { + id: "{body_id}", + class: "comment-body", + "data-last-sibling": if is_last_sibling { "true" } else { "false" }, + + div { + class: "comment-author", + "@{author}" + } + div { + id: "{rule_id}", + class: "comment-rule", + } + div { + class: "comment-text", + FacetText { text: text, facets: facets } + } + + // Render replies. + if reply_count == 1 { + // Linear chain — stay flat, no indent wrapper. + CommentBody { + node: node.replies.into_iter().next().unwrap(), + is_last_sibling: true, + } + } else if reply_count > 1 { + // Branching — indent with comment-replies wrapper. + div { + class: "comment-replies", + for (i, reply) in node.replies.into_iter().enumerate() { + CommentBody { + node: reply, + is_last_sibling: i + 1 == reply_count, + } + } + } + } + } + } +} diff --git a/src/components/facet_text.rs b/src/components/facet_text.rs index fbb9be3..f7ee382 100644 --- a/src/components/facet_text.rs +++ b/src/components/facet_text.rs @@ -29,12 +29,14 @@ pub fn segment_faceted_text(text: &str, facets: &[FacetData]) -> Vec = facets .iter() .enumerate() .filter_map(|(i, f)| { - let start = f.byte_start.min(text_len); - let end = f.byte_end.min(text_len); + let start = snap_to_char_boundary(text, f.byte_start.min(text_len)); + let end = snap_to_char_boundary(text, f.byte_end.min(text_len)); if start < end { Some((start, end, i)) } else { @@ -48,11 +50,17 @@ pub fn segment_faceted_text(text: &str, facets: &[FacetData]) -> Vec Vec usize { + let mut pos = offset.min(text.len()); + while pos > 0 && !text.is_char_boundary(pos) { + pos -= 1; + } + pos +} + /// Convert a facet's first recognised feature into a TextSegment for the given text slice. fn facet_to_segment(facet: &FacetData, slice: &str) -> Option { facet.features.first().map(|feat| match feat { @@ -430,4 +448,73 @@ mod tests { assert_eq!(segments, vec![TextSegment::Plain(SmolStr::new("hi"))]); } + + // Multi-byte UTF-8: facet correctly spanning emoji bytes. + #[test] + fn multibyte_emoji_facet_correct_boundaries() { + // \u{1F600} is 4 bytes (F0 9F 98 80), starts at byte 3 + let text = "hi \u{1F600} bye"; + // Facet covers just the emoji: bytes [3, 7) + let facets = vec![link_facet(3, 7, "https://emoji.com")]; + let segments = segment_faceted_text(text, &facets); + + assert_eq!(segments.len(), 3); + assert_eq!(segments[0], TextSegment::Plain(SmolStr::new("hi "))); + assert_eq!( + segments[1], + TextSegment::Link { + uri: SmolStr::new("https://emoji.com"), + text: SmolStr::new("\u{1F600}"), + } + ); + assert_eq!(segments[2], TextSegment::Plain(SmolStr::new(" bye"))); + } + + // Multi-byte UTF-8: facet with byte offset landing mid-codepoint. + // Should snap to char boundary instead of panicking, and produce correct output. + #[test] + fn multibyte_mid_codepoint_offset_snaps_correctly() { + // \u{1F600} occupies bytes [3, 7) + let text = "hi \u{1F600} bye"; + // Facet starts at byte 5 (mid-emoji) — should snap back to byte 3 + // Facet ends at byte 7 — valid boundary + // After snapping: facet covers [3, 7) = the full emoji + let facets = vec![link_facet(5, 7, "https://broken.com")]; + let segments = segment_faceted_text(text, &facets); + + assert_eq!(segments.len(), 3); + assert_eq!(segments[0], TextSegment::Plain(SmolStr::new("hi "))); + assert_eq!( + segments[1], + TextSegment::Link { + uri: SmolStr::new("https://broken.com"), + text: SmolStr::new("\u{1F600}"), + } + ); + assert_eq!(segments[2], TextSegment::Plain(SmolStr::new(" bye"))); + } + + // Two facets with identical byte range: first in input order wins + // (narrowest-first sort, then by start position — identical spans have + // identical sort keys, so input order is preserved by the stable sort). + #[test] + fn identical_span_facets_first_wins() { + let text = "hello"; + // Both facets cover [0, 5) but with different features. + let facets = vec![ + mention_facet(0, 5, "did:plc:alice"), + link_facet(0, 5, "https://example.com"), + ]; + let segments = segment_faceted_text(text, &facets); + + // Mention was first in input → it should win. + assert_eq!(segments.len(), 1); + assert_eq!( + segments[0], + TextSegment::Mention { + did: did("did:plc:alice"), + text: SmolStr::new("hello"), + } + ); + } } diff --git a/src/components/mod.rs b/src/components/mod.rs index 476929a..d938254 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,6 +1,8 @@ pub mod at_uri_display; +pub mod comment_body; pub mod facet_text; pub mod video_card; pub use at_uri_display::AtUriDisplay; +pub use comment_body::CommentBody; pub use facet_text::FacetText; pub use video_card::VideoCard; diff --git a/src/player/transcript_panel.rs b/src/player/transcript_panel.rs index f50597e..21020f3 100644 --- a/src/player/transcript_panel.rs +++ b/src/player/transcript_panel.rs @@ -1,6 +1,10 @@ +use std::collections::HashMap; + use dioxus::prelude::*; use vodplace_subtitle::{TranscriptData, TranscriptWord}; +use crate::comments::{AnchoredComment, CommentTree}; + /// Range of highlighted words in the transcript. #[derive(Clone, Copy, Debug, PartialEq)] struct ActiveRange { @@ -58,11 +62,17 @@ fn find_active_range(words: &[TranscriptWord], current_ms: u64) -> Option`. The word whose time range /// contains `current_time` receives the `transcript-word-active` class and is /// auto-scrolled into view unless the user has manually scrolled. +/// +/// If `comment_tree` is provided, anchored comments highlight the covered word +/// spans with the `comment-anchor` class, and inline placeholder bodies are +/// rendered after the last word of each anchor. Non-anchored comments appear in +/// a header section above the transcript words. #[component] pub fn TranscriptPanel( transcript_data: Signal>, current_time: Signal, on_seek: EventHandler, + comment_tree: ReadOnlySignal>, ) -> Element { let mut user_scrolled = use_signal(|| false); let mut last_active_start: Signal> = use_signal(|| None); @@ -158,15 +168,72 @@ pub fn TranscriptPanel( breaks }; + // Read comment tree once for this render pass. + let tree_read = comment_tree.read(); + let tree_ref: Option<&CommentTree> = tree_read.as_ref(); + + // Build a map: word_index → anchored comments whose range ends at that word. + // An `AnchoredComment` with `word_start=3, word_end=5` covers words 3, 4, 5. + // The placeholder body is rendered after the LAST word of each anchor (word_end). + // + // Also build a set of word indices that are inside any anchor range, for CSS + // class annotation. We keep a Vec<&AnchoredComment> per word_end index so we + // can emit all comment bodies after the final covered word. + let mut anchor_end_map: HashMap> = HashMap::new(); + let mut anchored_word_ids: HashMap> = HashMap::new(); + + if let Some(tree) = tree_ref { + for anchored in &tree.anchored { + // Register the comment URI at every word index in the range. + for word_idx in anchored.word_start..=anchored.word_end { + anchored_word_ids + .entry(word_idx) + .or_default() + .push(anchored.comment_node.comment.uri.as_str()); + } + // Register the comment at its ending word for body rendering. + anchor_end_map + .entry(anchored.word_end) + .or_default() + .push(anchored); + } + } + + // Snapshot of non-anchored comments for rendering above the transcript. + let non_anchored: Vec<_> = tree_ref + .map(|t| t.non_anchored.as_slice()) + .unwrap_or_default() + .iter() + .collect(); + rsx! { div { class: "transcript-panel", onscroll: move |_| { user_scrolled.set(true); }, + + // Non-anchored comments appear at the top of the transcript panel. + if !non_anchored.is_empty() { + div { class: "transcript-comments-header", + for node in non_anchored.iter() { + div { + class: "comment-body-margin", + "data-comment-id": "{node.comment.uri}", + div { class: "comment-author", + "@{node.comment.author_handle}" + } + div { class: "comment-rule" } + div { class: "comment-text", "{node.comment.text}" } + } + } + } + } + for (i, word) in transcript.words.iter().enumerate() { { - let class = match active_range { + // Base class from active-range state. + let base_class = match active_range { Some(r) if i >= r.start && i <= r.end => { "transcript-word transcript-word-active" } @@ -175,20 +242,65 @@ pub fn TranscriptPanel( } _ => "transcript-word", }; + + // Determine if this word is covered by any comment anchor. + let anchor_ids = anchored_word_ids.get(&i); + let is_anchored = anchor_ids.is_some(); + + // Build the final CSS class string. + let class = if is_anchored { + format!("{base_class} comment-anchor") + } else { + base_class.to_string() + }; + + // data-comment-id: space-separated list of all comment URIs + // covering this word (supports multiple overlapping anchors). + let comment_id_attr: String = anchor_ids + .map(|ids| ids.join(" ")) + .unwrap_or_default(); + let start_sec = word.start_ms as f64 / 1000.0; let text = &word.text; let should_break = break_after[i]; + // Comments whose range ends at this word — emit placeholder bodies. + let ending_anchors = anchor_end_map.get(&i); + rsx! { span { class: "{class}", "data-word-index": "{i}", + "data-comment-id": "{comment_id_attr}", onclick: move |_| on_seek.call(start_sec), "{text} " } if should_break { div { class: "transcript-break" } } + // Placeholder comment bodies — rendered inline after the last + // covered word. Task 2's CommentBody component will replace this + // once it's available. + if let Some(anchors) = ending_anchors { + for anchored in anchors.iter() { + { + let comment = &anchored.comment_node.comment; + let uri_str = comment.uri.as_str().to_string(); + let handle = format!("@{}", comment.author_handle); + let text = comment.text.to_string(); + rsx! { + div { + class: "comment-body-margin", + id: "comment-body-{uri_str}", + "data-comment-id": "{uri_str}", + div { class: "comment-author", "{handle}" } + div { class: "comment-rule" } + div { class: "comment-text", "{text}" } + } + } + } + } + } } } }