From a9179600d194906174a62135979724ada1b9843c Mon Sep 17 00:00:00 2001 From: Orual Date: Sun, 12 Apr 2026 18:19:59 -0400 Subject: [PATCH] feat: use FacetText for chat message rendering --- src/comments.rs | 6 +- src/components/facet_text.rs | 217 ++++++++++++++++++++++++----------- src/main.rs | 47 +++++++- src/player/chat_sidebar.rs | 9 +- 4 files changed, 205 insertions(+), 74 deletions(-) diff --git a/src/comments.rs b/src/comments.rs index 8a9b456..48d1c7e 100644 --- a/src/comments.rs +++ b/src/comments.rs @@ -12,7 +12,7 @@ use crate::catalog::{ByteRangeData, CommentData}; use vodplace_subtitle::TranscriptData; /// A comment node in the reply tree, with its nested replies. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct CommentNode { pub comment: CommentData, pub replies: Vec, @@ -20,7 +20,7 @@ pub struct CommentNode { /// An anchored comment: a [`CommentNode`] together with the word-index range /// that the anchor byte range maps to. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct AnchoredComment { pub comment_node: CommentNode, /// First word index (inclusive) the anchor covers. @@ -33,7 +33,7 @@ pub struct AnchoredComment { /// /// Anchored comments are sorted by `byte_start`. Non-anchored comments are /// sorted by `created_at`. Replies within each node are sorted by `created_at`. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct CommentTree { pub anchored: Vec, pub non_anchored: Vec, diff --git a/src/components/facet_text.rs b/src/components/facet_text.rs index feacf88..fbb9be3 100644 --- a/src/components/facet_text.rs +++ b/src/components/facet_text.rs @@ -15,83 +15,118 @@ pub enum TextSegment { /// Segment `text` into runs of plain text and facet-annotated spans. /// -/// Algorithm: -/// 1. Sort facets by byte_start ascending. -/// 2. Walk text with a byte cursor. -/// 3. Emit Plain for text before the next facet. -/// 4. At a facet: extract the text slice, match first feature → Mention/Link/Tag. -/// 5. Advance cursor past facet end. -/// 6. Overlapping: if a facet starts before the cursor, skip it. -/// 7. Clamp out-of-bounds facet ranges to text length — no panics. -/// 8. Emit remaining text as Plain. -/// 9. Skip empty Plain segments. +/// Handles overlapping facets with "inner wins" semantics: when facets overlap, +/// the narrower (more specific) facet takes precedence. This matches Bluesky's +/// own rendering behaviour. +/// +/// Algorithm: collect boundary events (open/close) for each facet, sweep through +/// sorted boundaries, and at each position emit a segment annotated by the +/// innermost (narrowest) active facet. pub fn segment_faceted_text(text: &str, facets: &[FacetData]) -> Vec { let text_len = text.len(); + if text_len == 0 { + return Vec::new(); + } + + // Clamp and validate facets, collecting (start, end, facet_index) triples. + let mut valid: Vec<(usize, usize, usize)> = facets + .iter() + .enumerate() + .filter_map(|(i, f)| { + let start = f.byte_start.min(text_len); + let end = f.byte_end.min(text_len); + if start < end { + Some((start, end, i)) + } else { + None + } + }) + .collect(); - // Sort facets by byte_start, then by byte_end descending for ties. - let mut sorted: Vec<&FacetData> = facets.iter().collect(); - sorted.sort_by(|a, b| { - a.byte_start - .cmp(&b.byte_start) - .then_with(|| b.byte_end.cmp(&a.byte_end)) + if valid.is_empty() { + return vec![TextSegment::Plain(SmolStr::new(text))]; + } + + // Collect all boundary positions where the active facet might change. + let mut boundaries = Vec::with_capacity(valid.len() * 2 + 2); + boundaries.push(0); + for &(start, end, _) in &valid { + boundaries.push(start); + boundaries.push(end); + } + boundaries.push(text_len); + boundaries.sort_unstable(); + boundaries.dedup(); + + // Sort valid facets: narrower ranges (smaller span) have higher priority. + // For equal spans, earlier start wins. + valid.sort_by(|a, b| { + let span_a = a.1 - a.0; + let span_b = b.1 - b.0; + span_a.cmp(&span_b).then_with(|| a.0.cmp(&b.0)) }); let mut segments = Vec::new(); - let mut cursor = 0usize; - for facet in sorted { - // Clamp byte range to text length. - let start = facet.byte_start.min(text_len); - let end = facet.byte_end.min(text_len); - - // Skip malformed (start > end after clamping) or overlapping facets. - if start >= end || start < cursor { + // Sweep through adjacent boundary pairs. + for window in boundaries.windows(2) { + let seg_start = window[0]; + let seg_end = window[1]; + if seg_start >= seg_end { continue; } - // Emit Plain for any gap before this facet. - if cursor < start { - let plain = &text[cursor..start]; - if !plain.is_empty() { - segments.push(TextSegment::Plain(SmolStr::new(plain))); - } - } + let slice = &text[seg_start..seg_end]; - // Extract facet text. - let facet_text = SmolStr::new(&text[start..end]); - - // Match first feature; skip facets with no recognised features. - let segment = facet.features.first().and_then(|feat| match feat { - FacetFeatureData::Mention { did } => Some(TextSegment::Mention { - did: did.clone(), - text: facet_text.clone(), - }), - FacetFeatureData::Link { uri } => Some(TextSegment::Link { - uri: uri.clone(), - text: facet_text.clone(), - }), - FacetFeatureData::Tag { tag } => Some(TextSegment::Tag { - tag: tag.clone(), - text: facet_text.clone(), - }), - }); - - if let Some(seg) = segment { - segments.push(seg); - } + // Find the innermost (narrowest) facet covering this segment. + // `valid` is sorted narrowest-first, so the first match wins. + let active = valid.iter().find(|&&(fs, fe, _)| fs <= seg_start && seg_end <= fe); - cursor = end; + match active { + Some(&(_, _, idx)) => { + if let Some(seg) = facet_to_segment(&facets[idx], slice) { + segments.push(seg); + } else { + segments.push(TextSegment::Plain(SmolStr::new(slice))); + } + } + None => { + segments.push(TextSegment::Plain(SmolStr::new(slice))); + } + } } - // Emit any remaining text. - if cursor < text_len { - let tail = &text[cursor..]; - if !tail.is_empty() { - segments.push(TextSegment::Plain(SmolStr::new(tail))); + // Merge adjacent Plain segments. + let mut merged = Vec::with_capacity(segments.len()); + for seg in segments { + match (&seg, merged.last()) { + (TextSegment::Plain(new_text), Some(TextSegment::Plain(prev_text))) => { + let combined = format!("{}{}", prev_text, new_text); + *merged.last_mut().unwrap() = TextSegment::Plain(SmolStr::new(&combined)); + } + _ => merged.push(seg), } } - segments + merged +} + +/// 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 { + FacetFeatureData::Mention { did } => TextSegment::Mention { + did: did.clone(), + text: SmolStr::new(slice), + }, + FacetFeatureData::Link { uri } => TextSegment::Link { + uri: uri.clone(), + text: SmolStr::new(slice), + }, + FacetFeatureData::Tag { tag } => TextSegment::Tag { + tag: tag.clone(), + text: SmolStr::new(slice), + }, + }) } /// Render richtext with AT Protocol facets (mentions, links, tags) as inline HTML. @@ -306,29 +341,73 @@ mod tests { ); } - // Overlapping facets: second facet starts before first one ends → skip second. + // Overlapping facets: inner (narrower) wins over outer (wider). #[test] - fn overlapping_facets_second_skipped() { + fn overlapping_facets_inner_wins() { // "hello world" - // Facet A: [0, 5) = "hello" - // Facet B: [3, 8) = overlaps A — starts before cursor after A + // Facet A (wide): [0, 11) = "hello world" — link + // Facet B (narrow): [6, 11) = "world" — mention + // Expected: "hello " as Link(A), "world" as Mention(B) let text = "hello world"; let facets = vec![ - link_facet(0, 5, "https://a.com"), - link_facet(3, 8, "https://b.com"), + link_facet(0, 11, "https://a.com"), + mention_facet(6, 11, "did:plc:alice"), ]; let segments = segment_faceted_text(text, &facets); - // Only A is emitted; B is skipped; trailing " world" is Plain. assert_eq!(segments.len(), 2); assert_eq!( segments[0], TextSegment::Link { uri: SmolStr::new("https://a.com"), - text: SmolStr::new("hello"), + text: SmolStr::new("hello "), + } + ); + assert_eq!( + segments[1], + TextSegment::Mention { + did: did("did:plc:alice"), + text: SmolStr::new("world"), + } + ); + } + + // Nested facet: mention inside a link range. + #[test] + fn nested_mention_inside_link() { + // "see @alice here" + // Link: [0, 15) entire text + // Mention: [4, 10) "@alice" + // Expected: "see " as Link, "@alice" as Mention, " here" as Link + let text = "see @alice here"; + let facets = vec![ + link_facet(0, 15, "https://example.com"), + mention_facet(4, 10, "did:plc:alice"), + ]; + let segments = segment_faceted_text(text, &facets); + + assert_eq!(segments.len(), 3); + assert_eq!( + segments[0], + TextSegment::Link { + uri: SmolStr::new("https://example.com"), + text: SmolStr::new("see "), + } + ); + assert_eq!( + segments[1], + TextSegment::Mention { + did: did("did:plc:alice"), + text: SmolStr::new("@alice"), + } + ); + assert_eq!( + segments[2], + TextSegment::Link { + uri: SmolStr::new("https://example.com"), + text: SmolStr::new(" here"), } ); - assert_eq!(segments[1], TextSegment::Plain(SmolStr::new(" world"))); } // Facet with start == end (zero-length) → skip it. diff --git a/src/main.rs b/src/main.rs index 08c0385..7a282f2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,11 @@ use dioxus::prelude::*; use jacquard::deps::smol_str::{SmolStr, ToSmolStr}; use jacquard::types::aturi::AtUri; -use catalog::{EnrichedVideo, filter_and_sort, get_transcript, get_video_catalog, get_video_metadata}; +use catalog::{ + CommentData, EnrichedVideo, filter_and_sort, get_comments, get_transcript, get_video_catalog, + get_video_metadata, +}; +use comments::{CommentTree, build_comment_tree}; use components::{AtUriDisplay, VideoCard}; // Vancouver: UTC-7 (PDT). Hardcoded for now. @@ -145,6 +149,7 @@ fn Watch(uri: SmolStr) -> Element { let mut transcript_data: Signal> = use_signal(|| None); let mut transcript_visible: Signal = use_signal(|| false); + let mut comment_data: Signal>> = use_signal(|| None); let anchor_source: Signal> = use_signal(|| None); let chat_visible: Signal = use_signal(|| true); @@ -274,6 +279,46 @@ fn Watch(uri: SmolStr) -> Element { }); } + // Fetch ionosphere comments when a talk is available. + // Non-blocking — comment fetch failure does not affect video or transcript. + { + let _comment_resource = use_resource(move || async move { + let talk_uri = match &*metadata.read() { + Some(Some(meta)) => meta.talk_uri.clone(), + _ => None, + }; + let Some(talk_uri) = talk_uri else { + return; + }; + // Pass None for transcript_uri — the server queries the talk URI which + // covers the main case. Transcript URI is not available client-side. + match get_comments(talk_uri, None).await { + Ok(comments) => { + comment_data.set(Some(comments)); + } + Err(e) => { + tracing::warn!("failed to fetch comments: {e}"); + } + } + }); + } + + // Build the comment tree client-side, mapping byte ranges to word indices. + // Depends on both comment data and transcript data (both must be available for + // anchored comments to be fully resolved). + let comment_tree: Memo> = use_memo(move || { + let comments = comment_data.read(); + let transcript = transcript_data.read(); + let comments = comments.as_ref()?; + Some(build_comment_tree( + comments, + transcript.as_ref(), + )) + }); + + // Suppress unused variable warning — comment_tree will be consumed by Phase 4 UI. + let _ = comment_tree; + // Seek callback for transcript panel — directly sets video currentTime. #[allow(unused_variables)] let on_transcript_seek = { diff --git a/src/player/chat_sidebar.rs b/src/player/chat_sidebar.rs index c186ced..016ba2e 100644 --- a/src/player/chat_sidebar.rs +++ b/src/player/chat_sidebar.rs @@ -5,6 +5,7 @@ use jacquard::deps::smol_str::SmolStr; use crate::catalog::{get_chat_colours, get_chat_messages, resolve_chat_context}; use crate::chat::{AnchorSource, ChatContext, ChatMessageView}; +use crate::components::facet_text::FacetText; use crate::player::time_format::format_time; /// Maximum number of manual retries offered before showing a persistent error. @@ -419,6 +420,7 @@ pub fn ChatSidebar( let author_label = msg.author_handle.as_ref().to_string(); let timestamp_label = format_chat_timestamp(msg.vod_relative_ms); let msg_text = msg.text.clone(); + let msg_facets = msg.facets.clone(); let tid = msg.tid.clone(); let author_did_key = msg.author_did.as_str().to_string(); let colour = colours.get(&author_did_key).and_then(|c| *c); @@ -507,7 +509,12 @@ pub fn ChatSidebar( style: "{author_style}", "{author_label}" } - span { class: "chat-text", "{msg_text}" } + span { class: "chat-text", + FacetText { + text: msg_text, + facets: msg_facets, + } + } } } } -- 2.51.2