Skip to main content

org_social_lib_rs/
threading.rs

1//! Threading module for creating tree views of org-social posts.
2//!
3//! This module provides functionality to organize posts into threaded conversations
4//! based on reply relationships, creating hierarchical tree structures for display.
5
6use crate::{feed::Feed, feed_view::FeedView, poll::Poll, post::Post};
7use chrono::{DateTime, FixedOffset};
8use std::collections::HashMap;
9use std::rc::Rc;
10use std::cell::RefCell;
11
12/// Represents a node in a threaded conversation tree.
13#[derive(Clone)]
14pub struct ThreadNode {
15    /// The post at this node
16    pub post: Rc<RefCell<Post>>,
17    /// Direct replies to this post
18    pub replies: Vec<ThreadNode>,
19    /// Depth level in the conversation (0 = root)
20    pub depth: usize,
21    /// Latest activity time in this node's subtree (including this post and all replies)
22    pub latest_activity_time: Option<DateTime<FixedOffset>>,
23}
24
25/// Represents a collection of threaded conversations.
26pub struct ThreadView {
27    /// Root posts (posts that are not replies to anything)
28    pub roots: Vec<ThreadNode>,
29    /// Map of post IDs to their full identifiers for quick lookup
30    id_map: HashMap<String, String>,
31    /// Temporary map for placeholder posts during construction
32    placeholder_map: HashMap<String, ThreadNode>,
33}
34
35impl ThreadNode {
36    pub fn new(post: Rc<RefCell<Post>>, depth: usize) -> Self {
37        let latest_activity_time = post.borrow().time();
38        Self {
39            post,
40            replies: Vec::new(),
41            depth,
42            latest_activity_time,
43        }
44    }
45
46    pub fn add_reply(&mut self, reply_node: ThreadNode) {
47        self.replies.push(reply_node);
48    }
49
50    /// Calculate and update the latest activity time for this node and all its descendants.
51    /// This should be called after the tree structure is complete.
52    pub fn update_latest_activity_time(&mut self) {
53        // Everybody loves recursion, right?
54        for reply in &mut self.replies {
55            reply.update_latest_activity_time();
56        }
57        
58        // Start with this post's own time
59        let mut latest_time = self.post.borrow().time();
60        
61        // Check all replies for later times
62        for reply in &self.replies {
63            match (latest_time, reply.latest_activity_time) {
64                (Some(current), Some(reply_time)) => {
65                    if reply_time > current {
66                        latest_time = Some(reply_time);
67                    }
68                }
69                (None, Some(reply_time)) => {
70                    latest_time = Some(reply_time);
71                }
72                _ => {} // Keep current latest_time
73            }
74        }
75        
76        self.latest_activity_time = latest_time;
77    }
78
79    pub fn sort_replies(&mut self) {
80        self.replies.sort_by(|a, b| {
81            match (a.latest_activity_time, b.latest_activity_time) {
82                (Some(time_a), Some(time_b)) => time_a.cmp(&time_b), // Chronological order
83                (Some(_), None) => std::cmp::Ordering::Less,
84                (None, Some(_)) => std::cmp::Ordering::Greater,
85                (None, None) => std::cmp::Ordering::Equal,
86            }
87        });
88        
89        // Recursively sort replies of replies
90        for reply in &mut self.replies {
91            reply.sort_replies();
92        }
93    }
94
95    pub fn count_posts(&self) -> usize {
96        1 + self.replies.iter().map(|r| r.count_posts()).sum::<usize>()
97    }
98
99    pub fn flatten(&self) -> Vec<&Rc<RefCell<Post>>> {
100        let mut posts = vec![&self.post];
101        for reply in &self.replies {
102            posts.extend(reply.flatten());
103        }
104        posts
105    }
106}
107
108impl ThreadView {
109    /// Create a threaded view from a Feed.
110    pub fn from_feed(feed: &Feed) -> Self {
111        let mut thread_view = Self {
112            roots: Vec::new(),
113            id_map: HashMap::new(),
114            placeholder_map: HashMap::new(),
115        };
116        let mut post_map: HashMap<String, ThreadNode> = HashMap::new();
117        let mut reply_map: HashMap<String, Vec<ThreadNode>> = HashMap::new();
118
119        // Build ID mapping for quick lookups
120        for post in feed.posts.iter() {
121            let post_ref = post.borrow();
122            let full_id: String = post_ref.full_id();
123            thread_view.id_map.insert(post_ref.id().to_string(), full_id);
124        }
125
126        // First pass: create nodes for all posts
127        for post in feed.posts.iter() {
128            let post_ref = post.borrow();
129            let node = ThreadNode::new(post.clone(), 0);
130            let full_id = post_ref.full_id();
131            post_map.insert(full_id, node);
132        }
133
134        // Second pass: organize into threads and create placeholders for missing parents
135        let post_map_clone = post_map.clone();
136        for (_post_id, mut node) in post_map {
137            let reply_to = node.post.borrow().reply_to().clone();
138            if let Some(reply_to) = reply_to {
139                // This is a reply to another post
140                let reply_target = Self::resolve_reply_target(&reply_to, &thread_view.id_map);
141                
142                if let Some(parent_node) = post_map_clone.get(&reply_target) {
143                    // Parent exists, add to reply map
144                    node.depth = parent_node.depth + 1;
145                    reply_map.entry(reply_target).or_default().push(node);
146                } else {
147                    // Parent doesn't exist, try timestamp-based fallback
148                    if let Some(fallback_target) = Self::find_by_timestamp_fallback(&reply_target, &post_map_clone) {
149                        // Found a post with matching timestamp, use it
150                        node.depth = post_map_clone.get(&fallback_target).unwrap().depth + 1;
151                        reply_map.entry(fallback_target).or_default().push(node);
152                    } else {
153                        // No match found even by timestamp, create a placeholder
154                        let placeholder_post = Self::create_placeholder_post(&reply_target);
155                        let placeholder_node = ThreadNode::new(Rc::new(RefCell::new(placeholder_post)), 0);
156                        node.depth = 1; // Reply to placeholder at depth 0
157                        
158                        // Add placeholder to placeholder_map and this node as its reply
159                        thread_view.placeholder_map.insert(reply_target.clone(), placeholder_node);
160                        reply_map.entry(reply_target).or_default().push(node);
161                    }
162                }
163            } else {
164                // This is a root post
165                thread_view.roots.push(node);
166            }
167        }
168
169        // Third pass: attach replies to their parents (including placeholders)
170        thread_view.attach_replies(&reply_map);
171
172        // Fourth pass: attach replies to placeholder nodes and move them to roots
173        for (_, mut placeholder_node) in thread_view.placeholder_map.clone() {
174            Self::attach_replies_to_node(&mut placeholder_node, &reply_map);
175            thread_view.roots.push(placeholder_node);
176        }
177
178        // Sort all threads
179        thread_view.sort_threads();
180
181        thread_view
182    }
183
184    /// Resolve a reply_to target to a full post identifier.
185    fn resolve_reply_target(reply_to: &str, id_map: &HashMap<String, String>) -> String {
186        if reply_to.contains('#') {
187            // Already a full identifier (url#id or nick#id)
188            reply_to.to_string()
189        } else {
190            // Just an ID, look it up in the map
191            id_map.get(reply_to).cloned().unwrap_or_else(|| reply_to.to_string())
192        }
193    }
194
195    /// Attempt to find a post by timestamp-only matching when the full ID is not found.
196    /// 
197    /// This extracts the timestamp portion from the reply target and searches for any
198    /// post with a matching timestamp ID, regardless of source.
199    ///
200    /// # Arguments
201    /// * `reply_target` - The full reply target that couldn't be found
202    /// * `post_map` - Map of all available posts
203    ///
204    /// # Returns
205    /// The full ID of a matching post if found, None otherwise
206    fn find_by_timestamp_fallback(reply_target: &str, post_map: &HashMap<String, ThreadNode>) -> Option<String> {
207        // Extract timestamp from reply target
208        let timestamp = if reply_target.contains('#') {
209            // Extract the part after the last '#' which should be the timestamp
210            reply_target.split('#').next_back()?
211        } else {
212            // Already just a timestamp
213            reply_target
214        };
215
216        // Search through all posts for one with this timestamp as the ID
217        for (full_id, node) in post_map {
218            if node.post.borrow().id() == timestamp {
219                return Some(full_id.clone());
220            }
221        }
222
223        None
224    }
225
226    /// Create a placeholder post for missing reply targets.
227    fn create_placeholder_post(reply_target: &str) -> Post {
228        let placeholder_id = if reply_target.contains('#') {
229            // Extract just the ID part after the hash
230            reply_target.split('#').next_back().unwrap_or("unknown").to_string()
231        } else {
232            reply_target.to_string()
233        };
234        
235        let mut placeholder = Post::new(placeholder_id, "[Post not available]".to_string());
236        placeholder.set_author("unknown".to_string());
237        
238        // If the reply_target has a source part (before #), set it
239        if let Some(hash_pos) = reply_target.find('#') {
240            let source = &reply_target[..hash_pos];
241            if !source.is_empty() {
242                placeholder.set_source(Some(source.to_string()));
243            }
244        }
245        
246        placeholder
247    }
248
249    /// Attach replies to their parent nodes recursively.
250    fn attach_replies(&mut self, reply_map: &HashMap<String, Vec<ThreadNode>>) {
251        // Attach replies to root nodes
252        for root in &mut self.roots {
253            Self::attach_replies_to_node(root, reply_map);
254        }
255    }
256
257    /// Recursively attach replies to a specific node.
258    fn attach_replies_to_node(node: &mut ThreadNode, reply_map: &HashMap<String, Vec<ThreadNode>>) {
259        let node_id = node.post.borrow().full_id();
260        if let Some(replies) = reply_map.get(&node_id) {
261            for mut reply in replies.clone() {
262                Self::attach_replies_to_node(&mut reply, reply_map);
263                node.add_reply(reply);
264            }
265        }
266    }
267
268    /// Sort all threads and their replies chronologically.
269    pub fn sort_threads(&mut self) {
270        // First, update latest activity times for all threads
271        for root in &mut self.roots {
272            root.update_latest_activity_time();
273        }
274        
275        // Sort root posts (latest activity first)
276        self.roots.sort_by(|a, b| {
277            match (a.latest_activity_time, b.latest_activity_time) {
278                (Some(time_a), Some(time_b)) => time_b.cmp(&time_a), // Reverse for latest activity first
279                (Some(_), None) => std::cmp::Ordering::Less,
280                (None, Some(_)) => std::cmp::Ordering::Greater,
281                (None, None) => std::cmp::Ordering::Equal,
282            }
283        });
284
285        // Sort replies within each thread
286        for root in &mut self.roots {
287            root.sort_replies();
288        }
289    }
290
291    pub fn thread_count(&self) -> usize {
292        self.roots.len()
293    }
294
295    pub fn total_posts(&self) -> usize {
296        self.roots.iter().map(|r| r.count_posts()).sum()
297    }
298
299    pub fn flatten(&self) -> Vec<&Rc<RefCell<Post>>> {
300        let mut posts = Vec::new();
301        for root in &self.roots {
302            posts.extend(root.flatten());
303        }
304        posts
305    }
306
307    pub fn is_empty(&self) -> bool {
308        self.roots.is_empty()
309    }
310
311    pub fn update_poll_node(&self, post_node: &ThreadNode, poll: &mut Poll) {
312        poll.clear_votes();
313        for reply in &post_node.replies {
314            poll.add_vote_from_reply(&reply.post.borrow());
315        }
316    }
317
318    /// Add a new post to the thread tree.
319    /// 
320    /// If the post is a reply, it will be added to the appropriate parent node.
321    /// If the parent doesn't exist, a placeholder will be created.
322    /// If it's not a reply, it will be added as a new root thread.
323    /// 
324    /// After adding the post, latest activity times will be updated and threads will be re-sorted.
325    ///
326    /// # Arguments
327    /// * `post` - The new post to add to the thread tree
328    pub fn add_post(&mut self, post: Rc<RefCell<Post>>) {
329        if let Some(reply_to) = post.borrow().reply_to().clone() {
330            let reply_target = Self::resolve_reply_target(&reply_to, &self.id_map);
331            
332            // Try to find the parent in existing threads
333            if self.find_and_add_reply(&reply_target, post.clone()).is_some() {
334                let post_borrow = post.borrow();
335                self.id_map.insert(post_borrow.id().to_string(), post_borrow.full_id());
336                
337                self.sort_threads();
338            } else {
339                // Parent not found - create placeholder and add as new root thread
340                let placeholder_post = Self::create_placeholder_post(&reply_target);
341                let mut placeholder_node = ThreadNode::new(Rc::new(RefCell::new(placeholder_post)), 0);
342                
343                let reply_node = ThreadNode::new(post.clone(), 1);
344                placeholder_node.add_reply(reply_node);
345                
346                placeholder_node.update_latest_activity_time();
347                
348                self.roots.push(placeholder_node);
349                
350                let post_borrow = post.borrow();
351                self.id_map.insert(post_borrow.id().to_string(), post_borrow.full_id());
352                
353                // Resort threads
354                self.sort_threads();
355            }
356        } else {
357            // This is a root post
358            let new_root = ThreadNode::new(post.clone(), 0);
359            self.roots.push(new_root);
360            
361            let post_borrow = post.borrow();
362            self.id_map.insert(post_borrow.id().to_string(), post_borrow.full_id());
363            
364            // Resort threads
365            self.sort_threads();
366        }
367    }
368
369    /// Recursively search for a target post ID and add a reply to it.
370    /// Returns Some(depth) if the reply was successfully added, None if target not found.
371    fn find_and_add_reply(&mut self, target_id: &str, reply_post: Rc<RefCell<Post>>) -> Option<usize> {
372        for root in &mut self.roots {
373            if let Some(depth) = Self::find_and_add_reply_to_node(root, target_id, reply_post.clone()) {
374                return Some(depth);
375            }
376        }
377        None
378    }
379
380    /// Recursively search within a specific node and its descendants for the target ID.
381    /// Returns Some(depth) if the reply was successfully added, None if target not found.
382    fn find_and_add_reply_to_node(node: &mut ThreadNode, target_id: &str, reply_post: Rc<RefCell<Post>>) -> Option<usize> {
383        // Check if this node is the target
384        if node.post.borrow().full_id() == target_id {
385            let reply_depth = node.depth + 1;
386            let reply_node = ThreadNode::new(reply_post, reply_depth);
387            node.add_reply(reply_node);
388            
389            // Update latest activity time for this node and propagate upwards
390            node.update_latest_activity_time();
391            
392            return Some(reply_depth);
393        }
394        
395        // Search in replies
396        for reply in &mut node.replies {
397            if let Some(depth) = Self::find_and_add_reply_to_node(reply, target_id, reply_post.clone()) {
398                // Update our latest activity time
399                node.update_latest_activity_time();
400                return Some(depth);
401            }
402        }
403        
404        None
405    }
406}
407
408impl From<&Feed> for ThreadView {
409    fn from(feed: &Feed) -> Self {
410        ThreadView::from_feed(feed)
411    }
412}
413
414impl FeedView for ThreadView {
415    fn len(&self) -> usize {
416        self.total_posts()
417    }
418
419    fn update_content(&mut self, feed: &Feed) {
420        // TODO: More efficient incremental update could be implemented
421        let new_view = ThreadView::from_feed(feed);
422        *self = new_view;
423    }
424
425    fn view_name(&self) -> &str {
426        "Thread View"
427    }
428
429    fn refresh(&mut self) {
430        // Re-sort threads and update flattened view
431        self.sort_threads();
432    }
433
434    /// Apply a filter closure to the posts in this view
435    /// This keeps only branches of the thread tree where at least one post matches the filter.
436    fn apply_filter(&mut self, filter_fn: Box<dyn Fn(&Post) -> bool>) {
437        fn filter_node(node: &ThreadNode, filter_fn: &dyn Fn(&Post) -> bool) -> Option<ThreadNode> {
438            let mut filtered_replies = Vec::new();
439            for reply in &node.replies {
440                if let Some(filtered_reply) = filter_node(reply, filter_fn) {
441                    filtered_replies.push(filtered_reply);
442                }
443            }
444            let matches = filter_fn(&node.post.borrow());
445            if matches || !filtered_replies.is_empty() {
446                let mut new_node = node.clone();
447                new_node.replies = filtered_replies;
448                Some(new_node)
449            } else {
450                None
451            }
452        }
453
454        self.roots = self.roots
455            .iter()
456            .filter_map(|root| filter_node(root, &*filter_fn))
457            .collect();
458        // After filtering, update latest activity times and sort threads
459        self.sort_threads();
460    }
461}
462
463impl Default for ThreadView {
464    fn default() -> Self {
465        panic!("ThreadView::default() is not supported, use ThreadView::new()");
466    }
467}
468
469impl std::fmt::Display for ThreadView {
470    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
471        writeln!(f, "Thread View with {} conversations:", self.thread_count())?;
472        
473        for (i, root) in self.roots.iter().enumerate() {
474            writeln!(f, "\n--- Thread {} ---", i + 1)?;
475            Self::display_node(f, root, "")?;
476        }
477        
478        Ok(())
479    }
480}
481
482impl ThreadView {
483    /// Helper method to display a thread node with proper indentation.
484    fn display_node(f: &mut std::fmt::Formatter<'_>, node: &ThreadNode, prefix: &str) -> std::fmt::Result {
485        // Display the post with indentation
486        let indent = "  ".repeat(node.depth);
487        let post = node.post.borrow();
488        writeln!(f, "{}{}Post ID: {}", prefix, indent, post.id())?;
489        
490        if let Some(time) = post.time() {
491            writeln!(f, "{prefix}{indent}Time: {time}")?;
492        }
493        
494        if let Some(author) = post.author() {
495            writeln!(f, "{prefix}{indent}Author: {author}")?;
496        }
497        
498        writeln!(f, "{}{}Content: {}", prefix, indent, post.content())?;
499        
500        // Display replies
501        for reply in &node.replies {
502            Self::display_node(f, reply, prefix)?;
503        }
504        
505        Ok(())
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use crate::post::Post;
513
514    #[test]
515    fn test_placeholder_parent_linking() {
516        // Create a post that replies to a non-existent post
517        let mut reply_post = Post::new("reply1".to_string(), "This is a reply".to_string());
518        reply_post.set_reply_to(Some("missing_post".to_string()));
519        reply_post.set_author("user1".to_string());
520
521        let posts = vec![reply_post.clone()];
522        
523        // Create thread view
524        let feed = crate::feed::Feed::from_posts(posts.clone());
525        let thread_view = ThreadView::from_feed(&feed);
526        
527        // Should have one root thread (the placeholder)
528        assert_eq!(thread_view.thread_count(), 1);
529        
530        // The root should be a placeholder post
531        let root = &thread_view.roots[0];
532        assert_eq!(root.post.borrow().id(), "missing_post");
533        assert_eq!(root.post.borrow().content(), "[Post not available]");
534        assert_eq!(root.post.borrow().author().as_deref(), Some("unknown"));
535        
536        // The placeholder should have one reply
537        assert_eq!(root.replies.len(), 1);
538        
539        // The reply should be our original post
540        let reply_node = &root.replies[0];
541        assert_eq!(reply_node.post.borrow().id(), "reply1");
542        assert_eq!(reply_node.post.borrow().content(), "This is a reply");
543        assert_eq!(reply_node.depth, 1);
544    }
545
546    #[test]
547    fn test_multiple_replies_to_missing_post() {
548        // Create multiple posts that reply to the same non-existent post
549        let mut reply1 = Post::new("reply1".to_string(), "First reply".to_string());
550        reply1.set_reply_to(Some("missing_post".to_string()));
551        
552        let mut reply2 = Post::new("reply2".to_string(), "Second reply".to_string());
553        reply2.set_reply_to(Some("missing_post".to_string()));
554
555        let posts = vec![reply1, reply2];
556        
557        // Create thread view
558        let feed = crate::feed::Feed::from_posts(posts.clone());
559        let thread_view = ThreadView::from_feed(&feed);
560        
561        // Should have one root thread (the placeholder)
562        assert_eq!(thread_view.thread_count(), 1);
563        
564        // The root should be a placeholder post with two replies
565        let root = &thread_view.roots[0];
566        assert_eq!(root.post.borrow().id(), "missing_post");
567        assert_eq!(root.replies.len(), 2);
568        
569        // Both replies should be at depth 1
570        for reply in &root.replies {
571            assert_eq!(reply.depth, 1);
572        }
573    }
574
575    #[test]
576    fn test_timestamp_fallback_matching() {
577        // Create a post with a timestamp ID
578        let original_post = Post::new("2025-08-15T10:30:00+00:00".to_string(), "Original post".to_string());
579        
580        // Create a reply that targets the same timestamp but from a different source
581        let mut reply_post = Post::new("reply1".to_string(), "This is a reply".to_string());
582        reply_post.set_reply_to(Some("https://external.site/social.org/#2025-08-15T10:30:00+00:00".to_string()));
583        reply_post.set_author("user1".to_string());
584
585        let posts = vec![original_post.clone(), reply_post.clone()];
586        
587        // Create thread view
588        let feed = crate::feed::Feed::from_posts(posts.clone());
589        let thread_view = ThreadView::from_feed(&feed);
590        
591        // Should have one root thread (the original post)
592        assert_eq!(thread_view.thread_count(), 1);
593        
594        // The root should be the original post
595        let root = &thread_view.roots[0];
596        assert_eq!(root.post.borrow().id(), "2025-08-15T10:30:00+00:00");
597        assert_eq!(root.post.borrow().content(), "Original post");
598        
599        // The original post should have one reply
600        assert_eq!(root.replies.len(), 1);
601        
602        // The reply should be our reply post
603        let reply_node = &root.replies[0];
604        assert_eq!(reply_node.post.borrow().id(), "reply1");
605        assert_eq!(reply_node.post.borrow().content(), "This is a reply");
606        assert_eq!(reply_node.depth, 1);
607    }
608
609    #[test]
610    fn test_timestamp_fallback_no_match_creates_placeholder() {
611        // Create a reply that targets a timestamp that doesn't exist
612        let mut reply_post = Post::new("reply1".to_string(), "This is a reply".to_string());
613        reply_post.set_reply_to(Some("https://external.site/social.org/#2025-12-25T00:00:00+00:00".to_string()));
614        reply_post.set_author("user1".to_string());
615
616        let posts = vec![reply_post.clone()];
617        
618        // Create thread view
619        let feed = crate::feed::Feed::from_posts(posts.clone());
620        let thread_view = ThreadView::from_feed(&feed);
621        
622        // Should have one root thread (the placeholder)
623        assert_eq!(thread_view.thread_count(), 1);
624        
625        // The root should be a placeholder post
626        let root = &thread_view.roots[0];
627        assert_eq!(root.post.borrow().id(), "2025-12-25T00:00:00+00:00");
628        assert_eq!(root.post.borrow().content(), "[Post not available]");
629        assert_eq!(root.post.borrow().author().as_deref(), Some("unknown"));
630        
631        // The placeholder should have one reply
632        assert_eq!(root.replies.len(), 1);
633        
634        // The reply should be our original post
635        let reply_node = &root.replies[0];
636        assert_eq!(reply_node.post.borrow().id(), "reply1");
637        assert_eq!(reply_node.post.borrow().content(), "This is a reply");
638        assert_eq!(reply_node.depth, 1);
639    }
640}