1use 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#[derive(Clone)]
14pub struct ThreadNode {
15 pub post: Rc<RefCell<Post>>,
17 pub replies: Vec<ThreadNode>,
19 pub depth: usize,
21 pub latest_activity_time: Option<DateTime<FixedOffset>>,
23}
24
25pub struct ThreadView {
27 pub roots: Vec<ThreadNode>,
29 id_map: HashMap<String, String>,
31 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 pub fn update_latest_activity_time(&mut self) {
53 for reply in &mut self.replies {
55 reply.update_latest_activity_time();
56 }
57
58 let mut latest_time = self.post.borrow().time();
60
61 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 _ => {} }
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), (Some(_), None) => std::cmp::Ordering::Less,
84 (None, Some(_)) => std::cmp::Ordering::Greater,
85 (None, None) => std::cmp::Ordering::Equal,
86 }
87 });
88
89 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 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 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 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 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 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 node.depth = parent_node.depth + 1;
145 reply_map.entry(reply_target).or_default().push(node);
146 } else {
147 if let Some(fallback_target) = Self::find_by_timestamp_fallback(&reply_target, &post_map_clone) {
149 node.depth = post_map_clone.get(&fallback_target).unwrap().depth + 1;
151 reply_map.entry(fallback_target).or_default().push(node);
152 } else {
153 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; 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 thread_view.roots.push(node);
166 }
167 }
168
169 thread_view.attach_replies(&reply_map);
171
172 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 thread_view.sort_threads();
180
181 thread_view
182 }
183
184 fn resolve_reply_target(reply_to: &str, id_map: &HashMap<String, String>) -> String {
186 if reply_to.contains('#') {
187 reply_to.to_string()
189 } else {
190 id_map.get(reply_to).cloned().unwrap_or_else(|| reply_to.to_string())
192 }
193 }
194
195 fn find_by_timestamp_fallback(reply_target: &str, post_map: &HashMap<String, ThreadNode>) -> Option<String> {
207 let timestamp = if reply_target.contains('#') {
209 reply_target.split('#').next_back()?
211 } else {
212 reply_target
214 };
215
216 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 fn create_placeholder_post(reply_target: &str) -> Post {
228 let placeholder_id = if reply_target.contains('#') {
229 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 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 fn attach_replies(&mut self, reply_map: &HashMap<String, Vec<ThreadNode>>) {
251 for root in &mut self.roots {
253 Self::attach_replies_to_node(root, reply_map);
254 }
255 }
256
257 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 pub fn sort_threads(&mut self) {
270 for root in &mut self.roots {
272 root.update_latest_activity_time();
273 }
274
275 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), (Some(_), None) => std::cmp::Ordering::Less,
280 (None, Some(_)) => std::cmp::Ordering::Greater,
281 (None, None) => std::cmp::Ordering::Equal,
282 }
283 });
284
285 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 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 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 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 self.sort_threads();
355 }
356 } else {
357 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 self.sort_threads();
366 }
367 }
368
369 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 fn find_and_add_reply_to_node(node: &mut ThreadNode, target_id: &str, reply_post: Rc<RefCell<Post>>) -> Option<usize> {
383 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 node.update_latest_activity_time();
391
392 return Some(reply_depth);
393 }
394
395 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 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 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 self.sort_threads();
432 }
433
434 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 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 fn display_node(f: &mut std::fmt::Formatter<'_>, node: &ThreadNode, prefix: &str) -> std::fmt::Result {
485 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 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 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 let feed = crate::feed::Feed::from_posts(posts.clone());
525 let thread_view = ThreadView::from_feed(&feed);
526
527 assert_eq!(thread_view.thread_count(), 1);
529
530 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 assert_eq!(root.replies.len(), 1);
538
539 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 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 let feed = crate::feed::Feed::from_posts(posts.clone());
559 let thread_view = ThreadView::from_feed(&feed);
560
561 assert_eq!(thread_view.thread_count(), 1);
563
564 let root = &thread_view.roots[0];
566 assert_eq!(root.post.borrow().id(), "missing_post");
567 assert_eq!(root.replies.len(), 2);
568
569 for reply in &root.replies {
571 assert_eq!(reply.depth, 1);
572 }
573 }
574
575 #[test]
576 fn test_timestamp_fallback_matching() {
577 let original_post = Post::new("2025-08-15T10:30:00+00:00".to_string(), "Original post".to_string());
579
580 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 let feed = crate::feed::Feed::from_posts(posts.clone());
589 let thread_view = ThreadView::from_feed(&feed);
590
591 assert_eq!(thread_view.thread_count(), 1);
593
594 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 assert_eq!(root.replies.len(), 1);
601
602 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 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 let feed = crate::feed::Feed::from_posts(posts.clone());
620 let thread_view = ThreadView::from_feed(&feed);
621
622 assert_eq!(thread_view.thread_count(), 1);
624
625 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 assert_eq!(root.replies.len(), 1);
633
634 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}