diff --git a/src/player/chat_sidebar.rs b/src/player/chat_sidebar.rs index bdc2b3d..8d6e5fe 100644 --- a/src/player/chat_sidebar.rs +++ b/src/player/chat_sidebar.rs @@ -44,7 +44,10 @@ pub fn ChatSidebar( // Chat context saved after initial load — needed for refetching on seek/prefetch. let mut chat_ctx: Signal> = use_signal(|| None); - // Previous playback time — used for seek detection (>10s jump). + // Previous playback time — used for seek detection (>10s jump). Only + // read inside the WASM-gated sliding window effect; suppress lints + // on non-WASM targets where it is not referenced. + #[allow(unused_mut, unused_variables)] let mut prev_playback_time: Signal = use_signal(|| 0.0_f64); // Fetch chat data: resolve context then get messages. @@ -413,6 +416,20 @@ pub fn ChatSidebar( .map(|(r, g, b)| format!("color: rgb({r},{g},{b})")) .unwrap_or_default(); + // Pre-resolve reply indicator state for this message. + // Determines label text, parent_tid, and orphan status + // without needing DOM queries at render time. + let reply_info = msg.reply_snippet.as_ref().map(|snippet| { + let parent_tid = snippet.parent_tid.clone(); + let parent_in_buffer = buffered_tids.contains(&parent_tid); + let label = format!( + "\u{21A9} {}: {}", + snippet.author_handle.as_ref(), + snippet.text_preview, + ); + (parent_tid, parent_in_buffer, label) + }); + rsx! { // "Now" divider before the first message that is in the future. if idx == divider_idx { @@ -424,10 +441,52 @@ pub fn ChatSidebar( "data-tid": "{tid}", onclick: move |_| on_seek.call(seek_time), - // Reply indicator (deferred: Task 4) - if let Some(ref snippet) = msg.reply_snippet { - div { class: "chat-reply-indicator", - "\u{21A9} {snippet.author_handle}: {snippet.text_preview}" + // Reply indicator: clickable when parent is in buffer, + // styled as orphan otherwise. + if let Some((parent_tid, parent_in_buffer, label)) = reply_info { + if parent_in_buffer { + div { + class: "chat-reply-indicator", + onclick: move |evt| { + // Don't also seek when clicking the reply. + evt.stop_propagation(); + // parent_tid is captured and used in the + // query selector on WASM; suppress lint on + // non-WASM targets where it's not used. + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] + let _ = &parent_tid; + #[cfg(all(target_family = "wasm", target_os = "unknown"))] + { + let selector = + format!("[data-tid=\"{parent_tid}\"]"); + if let Some(doc) = web_sys::window() + .and_then(|w| w.document()) + { + if let Ok(Some(target_el)) = + doc.query_selector(&selector) + { + let opts = + web_sys::ScrollIntoViewOptions::new( + ); + opts.set_behavior( + web_sys::ScrollBehavior::Smooth, + ); + opts.set_block( + web_sys::ScrollLogicalPosition::Nearest, + ); + target_el + .scroll_into_view_with_scroll_into_view_options(&opts); + } + } + } + }, + "{label}" + } + } else { + div { + class: "chat-reply-indicator chat-reply-indicator--orphan", + "{label}" + } } } @@ -492,4 +551,92 @@ mod tests { fn format_chat_timestamp_hours() { assert_eq!(format_chat_timestamp(3_661_000), "01:01:01"); } + + /// Reply indicator orphan detection: parent tid known → in_buffer = true. + #[test] + fn reply_orphan_detection_parent_in_buffer() { + let tids: std::collections::HashSet = + ["tid_abc".into(), "tid_def".into()].into_iter().collect(); + assert!(tids.contains::(&"tid_abc".into())); + } + + /// Reply indicator orphan detection: parent tid unknown → orphan. + #[test] + fn reply_orphan_detection_parent_not_in_buffer() { + let tids: std::collections::HashSet = + ["tid_abc".into()].into_iter().collect(); + assert!(!tids.contains::(&"tid_xyz".into())); + } + + /// Seek detection threshold: jump exactly at 10s boundary is not a seek. + #[test] + fn seek_detection_threshold_not_exceeded() { + let prev = 100.0_f64; + let current = 110.0_f64; + let jump = (current - prev).abs(); + assert!(jump <= 10.0, "10.0s jump should not exceed the >10s threshold"); + } + + /// Seek detection threshold: jump above 10s triggers seek. + #[test] + fn seek_detection_threshold_exceeded() { + let prev = 100.0_f64; + let current = 111.0_f64; + let jump = (current - prev).abs(); + assert!(jump > 10.0, "11.0s jump should exceed the >10s threshold"); + } + + /// Prefetch merge preserves sort order and deduplicates by tid. + #[test] + fn prefetch_merge_inserts_sorted_and_deduplicates() { + use jacquard::deps::smol_str::SmolStr; + use jacquard::types::did::Did; + use jacquard::types::handle::Handle; + use crate::chat::ChatMessageView; + + let make_msg = |tid: &str, ms: i64| -> ChatMessageView { + ChatMessageView { + tid: SmolStr::new(tid), + text: SmolStr::new("hi"), + author_did: "did:plc:test".parse::().unwrap(), + author_handle: "test.bsky.social".parse::().unwrap(), + vod_relative_ms: ms, + reply_snippet: None, + facets: vec![], + } + }; + + let existing = vec![ + make_msg("a", 1_000), + make_msg("b", 3_000), + make_msg("c", 5_000), + ]; + let existing_tids: std::collections::HashSet = + existing.iter().map(|m| m.tid.clone()).collect(); + + let new_msgs = vec![ + make_msg("b", 3_000), // duplicate — should be skipped + make_msg("d", 4_000), // new, fits between b and c + make_msg("e", 6_000), // new, after c + ]; + + let mut merged = existing; + for new_msg in new_msgs { + if !existing_tids.contains(&new_msg.tid) { + let pos = merged.partition_point(|m| m.vod_relative_ms < new_msg.vod_relative_ms); + merged.insert(pos, new_msg); + } + } + + assert_eq!(merged.len(), 5, "should have 5 messages (3 original + 2 new)"); + // Verify sort order. + for i in 0..merged.len() - 1 { + assert!( + merged[i].vod_relative_ms <= merged[i + 1].vod_relative_ms, + "messages should remain sorted by vod_relative_ms" + ); + } + assert_eq!(merged[2].tid, SmolStr::new("d"), "new message d should be at index 2"); + assert_eq!(merged[4].tid, SmolStr::new("e"), "new message e should be at index 4"); + } }