use std::collections::HashSet;
use tdlib_rs::enums::MessageTopic;
use tdlib_rs::types::{self, Message};
use crate::chats;
use crate::messages::{Sender, one_line};
pub const GENERAL: i32 = 1;
pub const PAGE: i32 = 100;
const LOAD_AHEAD: usize = 20;
const DEFAULT_COLOR: u32 = 0x6F_B9_F0;
pub struct Topic {
pub id: i32,
pub name: String,
pub general: bool,
pub closed: bool,
pub color: u32,
pub pinned: bool,
order: i64,
pub unread: i32,
pub mentions: i32,
pub from: Option<Sender>,
pub yours: bool,
pub preview: String,
newest: i64,
read_inbox: i64,
}
impl Topic {
pub fn of(topic: &types::ForumTopic) -> Self {
let mut new = Self {
id: topic.info.forum_topic_id,
name: String::new(),
general: false,
closed: false,
color: DEFAULT_COLOR,
pinned: topic.is_pinned,
order: topic.order,
unread: topic.unread_count,
mentions: topic.unread_mention_count,
from: None,
yours: false,
preview: String::new(),
newest: 0,
read_inbox: topic.last_read_inbox_message_id,
};
new.set_info(&topic.info);
if let Some(message) = &topic.last_message {
new.set_last(&Arrived::of(message, new.id));
}
new
}
pub fn local(id: i32, name: &str, color: u32, unread: i32, preview: &str) -> Self {
Self {
id,
name: name.into(),
general: id == GENERAL,
closed: false,
color,
pinned: false,
order: 0,
unread,
mentions: 0,
from: None,
yours: false,
preview: preview.into(),
newest: 0,
read_inbox: 0,
}
}
pub fn accent(&self) -> usize {
match self.color {
0xFB_6F_5F => 0,
0xFF_D6_7E => 1,
0xCB_86_DB => 2,
0x8E_EE_98 => 3,
0xFF_93_B2 => 6,
_ => 5,
}
}
fn set_info(&mut self, info: &types::ForumTopicInfo) {
self.name = one_line(&info.name);
self.general = info.is_general;
self.closed = info.is_closed;
if info.icon.color != 0 {
self.color = info.icon.color as u32 & 0xFF_FF_FF;
}
}
fn set_last(&mut self, message: &Arrived) {
self.from = (!message.outgoing).then_some(message.sender);
self.yours = message.outgoing;
self.preview = message.preview.clone();
if message.sent {
self.newest = self.newest.max(message.id);
}
}
}
pub struct Arrived {
pub id: i64,
pub topic: i32,
pub outgoing: bool,
pub sent: bool,
pub sender: Sender,
pub preview: String,
}
impl Arrived {
fn of(message: &Message, topic: i32) -> Self {
Self {
id: message.id,
topic,
outgoing: message.is_outgoing,
sent: message.sending_state.is_none(),
sender: Sender::from(&message.sender_id),
preview: chats::snippet(message),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Offset {
pub date: i32,
pub message_id: i64,
pub topic_id: i32,
}
pub struct Forum {
pub chat_id: i64,
topics: Vec<Topic>,
pub selected: Option<i32>,
pub loading: Option<(u32, Offset)>,
next: Option<Offset>,
asking: HashSet<i32>,
}
impl Forum {
pub fn new(chat_id: i64) -> Self {
Self {
chat_id,
topics: Vec::new(),
selected: None,
loading: None,
next: Some(Offset::default()),
asking: HashSet::new(),
}
}
pub fn topics(&self) -> &[Topic] {
&self.topics
}
pub fn get(&self, id: i32) -> Option<&Topic> {
self.topics.iter().find(|t| t.id == id)
}
pub fn current(&self) -> Option<&Topic> {
match self.selected {
Some(id) => self.get(id),
None => self.topics.first(),
}
}
pub fn topic_of(&self, message: &Message) -> i32 {
match &message.topic_id {
Some(MessageTopic::Forum(t)) => t.forum_topic_id,
_ => self.general(),
}
}
pub fn arrived(&self, message: &Message) -> Arrived {
Arrived::of(message, self.topic_of(message))
}
fn general(&self) -> i32 {
self.topics
.iter()
.find(|t| t.general)
.map_or(GENERAL, |t| t.id)
}
pub fn all_loaded(&self) -> bool {
self.next.is_none()
}
pub fn page_to_ask(&mut self, request: u32) -> Option<Offset> {
let next = self.next.filter(|_| self.loading.is_none())?;
let at = self
.selected
.and_then(|id| self.topics.iter().position(|t| t.id == id))
.unwrap_or(0);
if !self.topics.is_empty() && at + LOAD_AHEAD < self.topics.len() {
return None;
}
self.loading = Some((request, next));
Some(next)
}
pub fn add_page(&mut self, request: u32, page: Option<&types::ForumTopics>) {
let Some((_, from)) = self.loading.filter(|&(asked, _)| asked == request) else {
return;
};
self.loading = None;
let Some(page) = page else {
self.next = None;
return;
};
let before = self.topics.len();
for topic in &page.topics {
self.upsert(topic);
}
let next = Offset {
date: page.next_offset_date,
message_id: page.next_offset_message_id,
topic_id: page.next_offset_forum_topic_id,
};
let done = self.topics.len() == before
|| next == Offset::default()
|| next == from
|| self.topics.len() >= page.total_count.max(0) as usize;
self.next = (!done).then_some(next);
}
pub fn upsert(&mut self, topic: &types::ForumTopic) {
self.asking.remove(&topic.info.forum_topic_id);
let new = Topic::of(topic);
match self.topics.iter_mut().find(|t| t.id == new.id) {
Some(old) => *old = new,
None => self.topics.push(new),
}
self.sort();
}
pub fn not_found(&mut self, id: i32) {
self.asking.remove(&id);
}
pub fn ask(&mut self, id: i32) -> bool {
self.asking.insert(id)
}
pub fn set_info(&mut self, info: &types::ForumTopicInfo) {
if let Some(topic) = self.topics.iter_mut().find(|t| t.id == info.forum_topic_id) {
topic.set_info(info);
}
}
pub fn set_state(&mut self, update: &types::UpdateForumTopic) -> Option<i32> {
let topic = self
.topics
.iter_mut()
.find(|t| t.id == update.forum_topic_id)?;
topic.mentions = update.unread_mention_count;
let read_before = topic.read_inbox;
topic.read_inbox = update.last_read_inbox_message_id;
let pinned = topic.pinned != update.is_pinned;
topic.pinned = update.is_pinned;
let ask = if topic.read_inbox >= topic.newest {
topic.unread = 0;
None
} else {
(topic.read_inbox > read_before).then_some(topic.id)
};
if pinned {
self.sort();
}
ask
}
pub fn add_message(&mut self, message: &Arrived) -> Option<i32> {
let top = self.topics.iter().map(|t| t.order).max().unwrap_or(0);
let Some(topic) = self.topics.iter_mut().find(|t| t.id == message.topic) else {
return Some(message.topic);
};
if !message.outgoing && message.id > topic.read_inbox && message.id > topic.newest {
topic.unread += 1;
}
topic.set_last(message);
topic.order = top.saturating_add(1);
self.sort();
None
}
pub fn deleted(&self, message_ids: &[i64]) -> Vec<i32> {
self.topics
.iter()
.filter(|t| message_ids.contains(&t.newest))
.map(|t| t.id)
.collect()
}
pub fn move_cursor(&mut self, delta: isize) {
if self.topics.is_empty() {
return;
}
let last = self.topics.len() - 1;
let at = self
.selected
.and_then(|id| self.topics.iter().position(|t| t.id == id))
.unwrap_or(0)
.saturating_add_signed(delta)
.min(last);
self.selected = Some(self.topics[at].id);
}
pub fn add_local(&mut self, mut topic: Topic) {
topic.order = -(self.topics.len() as i64);
self.topics.push(topic);
self.next = None;
}
fn sort(&mut self) {
self.topics
.sort_by_key(|t| std::cmp::Reverse((t.pinned, t.order, t.id)));
}
}
#[cfg(test)]
mod tests {
use tdlib_rs::enums::MessageSender;
use tdlib_rs::types::{
ChatNotificationSettings, ForumTopicIcon, ForumTopicInfo, MessageSenderUser,
};
use super::*;
fn info(id: i32, name: &str) -> ForumTopicInfo {
ForumTopicInfo {
chat_id: -100,
forum_topic_id: id,
name: name.into(),
icon: ForumTopicIcon {
color: 0xFF_93_B2,
custom_emoji_id: 0,
},
creation_date: 0,
creator_id: MessageSender::User(MessageSenderUser { user_id: 2 }),
is_general: id == GENERAL,
is_outgoing: false,
is_closed: false,
is_hidden: false,
is_name_implicit: false,
}
}
fn topic(id: i32, name: &str, order: i64, unread: i32) -> types::ForumTopic {
types::ForumTopic {
info: info(id, name),
last_message: None,
order,
is_pinned: false,
unread_count: unread,
last_read_inbox_message_id: 0,
last_read_outbox_message_id: 0,
unread_mention_count: 0,
unread_reaction_count: 0,
notification_settings: ChatNotificationSettings::default(),
draft_message: None,
}
}
fn page(topics: Vec<types::ForumTopic>, next: Offset, total: i32) -> types::ForumTopics {
types::ForumTopics {
total_count: total,
topics,
next_offset_date: next.date,
next_offset_message_id: next.message_id,
next_offset_forum_topic_id: next.topic_id,
}
}
fn message(id: i64, topic: i32, outgoing: bool) -> Arrived {
Arrived {
id,
topic,
outgoing,
sent: true,
sender: Sender::User(2),
preview: "hello".into(),
}
}
fn names(forum: &Forum) -> Vec<&str> {
forum.topics().iter().map(|t| t.name.as_str()).collect()
}
#[test]
fn topics_are_listed_pinned_first_then_by_tdlibs_order() {
let mut forum = Forum::new(-100);
assert!(forum.page_to_ask(1).is_some(), "the first page");
let mut pinned = topic(3, "Rules", 1, 0);
pinned.is_pinned = true;
let topics = vec![topic(2, "Help", 50, 0), pinned, topic(1, "General", 90, 0)];
forum.add_page(1, Some(&page(topics, Offset::default(), 3)));
assert_eq!(names(&forum), ["Rules", "General", "Help"]);
assert!(forum.all_loaded());
}
#[test]
fn more_topics_load_as_the_cursor_nears_the_end() {
let mut forum = Forum::new(-100);
assert!(forum.page_to_ask(1).is_some());
assert_eq!(forum.page_to_ask(2), None, "one page at a time");
let topics: Vec<_> = (1..=30)
.map(|i| topic(i, "t", 100 - i64::from(i), 0))
.collect();
let next = Offset {
date: 5,
message_id: 6,
topic_id: 30,
};
forum.add_page(1, Some(&page(topics, next, 200)));
assert_eq!(forum.page_to_ask(2), None, "the cursor is far from the end");
forum.move_cursor(isize::MAX);
assert_eq!(forum.page_to_ask(3), Some(next));
forum.add_page(1, Some(&page(vec![topic(99, "late", 0, 0)], next, 200)));
assert!(forum.get(99).is_none());
let again: Vec<_> = (1..=5).map(|i| topic(i, "t", 0, 0)).collect();
let elsewhere = Offset { date: 9, ..next };
forum.add_page(3, Some(&page(again, elsewhere, 200)));
assert!(forum.all_loaded());
assert_eq!(forum.page_to_ask(4), None);
}
#[test]
fn a_new_message_moves_its_topic_up_and_counts_as_unread_unless_yours() {
let mut forum = Forum::new(-100);
forum.page_to_ask(1);
let topics = vec![topic(1, "General", 90, 0), topic(2, "Help", 50, 0)];
forum.add_page(1, Some(&page(topics, Offset::default(), 2)));
assert_eq!(forum.add_message(&message(10, 2, false)), None);
assert_eq!(names(&forum), ["Help", "General"]);
let help = forum.get(2).unwrap();
assert_eq!((help.unread, help.preview.as_str()), (1, "hello"));
assert_eq!(help.from, Some(Sender::User(2)));
forum.add_message(&message(11, 1, true));
assert_eq!(names(&forum), ["General", "Help"]);
let general = forum.get(1).unwrap();
assert_eq!(general.unread, 0, "your own");
assert!(general.yours && general.from.is_none());
assert_eq!(
forum.add_message(&message(12, 7, false)),
Some(7),
"not loaded"
);
}
#[test]
fn reading_up_to_the_newest_message_clears_the_count_else_tdlib_is_asked() {
let mut forum = Forum::new(-100);
forum.page_to_ask(1);
forum.add_page(
1,
Some(&page(vec![topic(2, "Help", 50, 0)], Offset::default(), 1)),
);
forum.add_message(&message(10, 2, false));
forum.add_message(&message(11, 2, false));
let read = |up_to, reactions| types::UpdateForumTopic {
chat_id: -100,
forum_topic_id: 2,
is_pinned: false,
last_read_inbox_message_id: up_to,
last_read_outbox_message_id: 0,
unread_mention_count: 0,
unread_reaction_count: reactions,
notification_settings: ChatNotificationSettings::default(),
draft_message: None,
};
assert_eq!(forum.set_state(&read(10, 0)), Some(2));
assert_eq!(forum.set_state(&read(10, 1)), None);
assert_eq!(forum.set_state(&read(10, 0)), None);
assert_eq!(forum.set_state(&read(11, 0)), None);
assert_eq!(forum.get(2).unwrap().unread, 0);
let other = types::UpdateForumTopic {
forum_topic_id: 9,
..read(11, 0)
};
assert_eq!(forum.set_state(&other), None);
}
#[test]
fn names_are_cleaned_onto_one_line() {
let mut forum = Forum::new(-100);
forum.set_info(&info(4, "New"));
assert!(forum.get(4).is_none());
forum.upsert(&topic(4, "Bugs\u{202E}\nand more", 1, 0));
assert!(forum.ask(4));
assert!(!forum.ask(4), "already asked");
assert_eq!(forum.get(4).unwrap().name, "Bugs and more");
}
#[test]
fn the_cursor_stays_on_its_topic_as_topics_move() {
let mut forum = Forum::new(-100);
forum.add_local(Topic::local(1, "General", 0, 0, ""));
forum.add_local(Topic::local(2, "Help", 0, 0, ""));
forum.move_cursor(1);
assert_eq!(forum.current().map(|t| t.id), Some(2));
forum.add_message(&message(10, 2, false));
assert_eq!(names(&forum), ["Help", "General"]);
assert_eq!(forum.current().map(|t| t.id), Some(2));
forum.selected = Some(7);
assert!(forum.current().is_none());
forum.move_cursor(1);
assert_eq!(forum.current().map(|t| t.id), Some(1));
}
}