use std::collections::{HashMap, HashSet};
use tdlib_rs::enums::{ChatAction, ChatList, ChatType, MessageContent, MessageSender};
use tdlib_rs::types::{self, AccentColor, ChatPhotoInfo, ChatPosition, Message};
use crate::images::Thumbnail;
use crate::messages::{Sender, decode_minithumbnail};
use crate::search;
use crate::text;
pub struct Chat {
pub title: String,
pub is_channel: bool,
pub is_private: bool,
pub unread: i32,
pub read_outbox: i64,
pub preview: String,
order: i64,
pub photo: Option<ChatPhoto>,
accent: i32,
pub activity: Vec<(Sender, &'static str)>,
pub peer: Option<Peer>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Peer {
User(i64),
Supergroup(i64),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Badge {
Scam,
Fake,
Official,
}
impl Badge {
pub fn of(status: Option<&types::VerificationStatus>, official: bool) -> Option<Self> {
match status {
Some(s) if s.is_scam => Some(Badge::Scam),
Some(s) if s.is_fake => Some(Badge::Fake),
Some(s) if s.is_verified => Some(Badge::Official),
_ => official.then_some(Badge::Official),
}
}
pub fn mark(self) -> &'static str {
match self {
Badge::Scam => " SCAM",
Badge::Fake => " FAKE",
Badge::Official => " ✓",
}
}
}
#[derive(Clone)]
pub struct ChatPhoto {
pub file_id: i32,
pub path: Option<String>,
pub thumbnail: Option<Thumbnail>,
}
impl ChatPhoto {
fn new(info: &ChatPhotoInfo) -> Self {
let local = &info.small.local;
Self {
file_id: info.small.id,
path: local.is_downloading_completed.then(|| local.path.clone()),
thumbnail: decode_minithumbnail(info.minithumbnail.as_ref()),
}
}
}
const SAVED_MESSAGES: &str = "Saved Messages";
#[derive(Default)]
pub struct Chats {
by_id: HashMap<i64, Chat>,
sorted: Vec<i64>,
dirty: bool,
my_id: Option<i64>,
held: Option<i64>,
highlighted: HashSet<i64>,
filter: String,
total: usize,
accent_colors: HashMap<i32, i32>,
badges: HashMap<Peer, Badge>,
}
impl Chats {
pub fn insert(&mut self, chat: types::Chat) {
let is_channel = matches!(&chat.r#type, ChatType::Supergroup(s) if s.is_channel);
let is_private = matches!(chat.r#type, ChatType::Private(_) | ChatType::Secret(_));
let peer = match &chat.r#type {
ChatType::Private(p) => Some(Peer::User(p.user_id)),
ChatType::Secret(s) => Some(Peer::User(s.user_id)),
ChatType::Supergroup(s) => Some(Peer::Supergroup(s.supergroup_id)),
ChatType::BasicGroup(_) => None,
};
let entry = Chat {
title: text::clean(&chat.title),
is_channel,
is_private,
unread: chat.unread_count,
read_outbox: chat.last_read_outbox_message_id,
preview: chat.last_message.as_ref().map(preview).unwrap_or_default(),
order: main_order(&chat.positions).unwrap_or(0),
photo: chat.photo.as_ref().map(ChatPhoto::new),
accent: chat.accent_color_id,
activity: Vec::new(),
peer,
};
self.by_id.insert(chat.id, entry);
self.dirty = true;
}
pub fn set_position(&mut self, chat_id: i64, position: &ChatPosition) {
if let ChatList::Main = position.list
&& let Some(chat) = self.by_id.get_mut(&chat_id)
{
chat.order = position.order;
self.dirty = true;
}
}
pub fn set_last_message(
&mut self,
chat_id: i64,
message: Option<&Message>,
positions: &[ChatPosition],
) {
if let Some(chat) = self.by_id.get_mut(&chat_id) {
chat.preview = message.map(preview).unwrap_or_default();
if let Some(order) = main_order(positions) {
chat.order = order;
self.dirty = true;
}
}
}
pub fn set_action(&mut self, chat_id: i64, sender: &MessageSender, action: &ChatAction) {
let Some(chat) = self.by_id.get_mut(&chat_id) else {
return;
};
let sender = Sender::from(sender);
let at = chat.activity.iter().position(|(s, _)| *s == sender);
match (at, activity(action)) {
(Some(i), Some(doing)) => chat.activity[i].1 = doing,
(Some(i), None) => {
chat.activity.remove(i);
}
(None, Some(doing)) => chat.activity.push((sender, doing)),
(None, None) => {}
}
}
pub fn set_title(&mut self, chat_id: i64, title: String) {
if let Some(chat) = self.by_id.get_mut(&chat_id) {
chat.title = text::clean(&title);
self.dirty = true;
}
}
pub fn set_unread(&mut self, chat_id: i64, unread: i32) {
if let Some(chat) = self.by_id.get_mut(&chat_id) {
chat.unread = unread;
self.dirty = true;
}
}
pub fn set_read_outbox(&mut self, chat_id: i64, message_id: i64) {
if let Some(chat) = self.by_id.get_mut(&chat_id) {
chat.read_outbox = message_id;
}
}
pub fn set_photo(&mut self, chat_id: i64, photo: Option<&ChatPhotoInfo>) {
if let Some(chat) = self.by_id.get_mut(&chat_id) {
chat.photo = photo.map(ChatPhoto::new);
}
}
pub fn set_accent(&mut self, chat_id: i64, accent_color_id: i32) {
if let Some(chat) = self.by_id.get_mut(&chat_id) {
chat.accent = accent_color_id;
}
}
pub fn set_accent_colors(&mut self, colors: &[AccentColor]) {
self.accent_colors = colors
.iter()
.map(|c| (c.id, c.built_in_accent_color_id))
.collect();
}
pub fn accent(&self, chat_id: i64) -> usize {
let id = self.by_id.get(&chat_id).map_or(0, |c| c.accent);
let built_in = match id {
0..7 => id,
_ => self.accent_colors.get(&id).copied().unwrap_or(id),
};
built_in.rem_euclid(7) as usize
}
pub fn opened(&mut self, chat_id: i64) {
self.held = Some(chat_id).filter(|id| self.by_id.get(id).is_some_and(|c| c.unread > 0));
self.dirty = true;
}
pub fn refresh(&mut self) {
if !self.dirty {
return;
}
self.sorted = self
.by_id
.iter()
.filter(|(_, chat)| chat.order != 0)
.map(|(&id, _)| id)
.collect();
self.total = self.sorted.len();
if !self.filter.is_empty() {
let mut sorted = std::mem::take(&mut self.sorted);
sorted.retain(|&id| {
let title = self.title(id).unwrap_or_default();
!search::find(title, &self.filter).is_empty()
});
self.sorted = sorted;
}
let (by_id, held) = (&self.by_id, self.held);
self.sorted.sort_unstable_by_key(|&id| {
let unread = by_id[&id].unread > 0 || held == Some(id);
std::cmp::Reverse((unread, by_id[&id].order, id))
});
self.dirty = false;
}
pub fn set_filter(&mut self, query: &str) {
if self.filter != query {
self.filter = query.to_string();
self.dirty = true;
}
}
pub fn filter(&self) -> &str {
&self.filter
}
pub fn total(&self) -> usize {
self.total
}
pub fn set_my_id(&mut self, id: i64) {
self.my_id = Some(id);
}
pub fn is_saved(&self, chat_id: i64) -> bool {
self.my_id == Some(chat_id)
}
pub fn is_highlighted(&self, chat_id: i64) -> bool {
self.highlighted.contains(&chat_id)
}
pub fn toggle_highlight(&mut self, chat_id: i64) -> Vec<i64> {
if !self.highlighted.remove(&chat_id) {
self.highlighted.insert(chat_id);
}
let mut ids: Vec<i64> = self.highlighted.iter().copied().collect();
ids.sort_unstable();
ids
}
pub fn set_highlighted(&mut self, chat_ids: &[i64]) {
self.highlighted = chat_ids.iter().copied().collect();
}
pub fn set_badge(&mut self, peer: Peer, badge: Option<Badge>) {
match badge {
Some(badge) => self.badges.insert(peer, badge),
None => self.badges.remove(&peer),
};
}
pub fn badge(&self, chat_id: i64) -> Option<Badge> {
let peer = self.by_id.get(&chat_id)?.peer?;
self.badges.get(&peer).copied()
}
pub fn title(&self, chat_id: i64) -> Option<&str> {
if self.is_saved(chat_id) {
return Some(SAVED_MESSAGES);
}
self.by_id.get(&chat_id).map(|c| c.title.as_str())
}
pub fn ids(&self) -> &[i64] {
&self.sorted
}
pub fn get(&self, chat_id: i64) -> Option<&Chat> {
self.by_id.get(&chat_id)
}
}
impl Chats {
pub fn add_local(&mut self, id: i64, title: &str, photo: Option<ChatPhoto>) -> &mut Chat {
let order = 1000 - self.by_id.len() as i64;
self.dirty = true;
self.by_id
.entry(id)
.insert_entry(Chat {
title: title.into(),
is_channel: false,
is_private: false,
unread: 0,
read_outbox: 0,
preview: String::new(),
order,
photo,
accent: 0,
activity: Vec::new(),
peer: None,
})
.into_mut()
}
}
fn activity(action: &ChatAction) -> Option<&'static str> {
Some(match action {
ChatAction::Typing => "typing",
ChatAction::RecordingVideo => "recording a video",
ChatAction::UploadingVideo(_) => "sending a video",
ChatAction::RecordingVoiceNote => "recording a voice message",
ChatAction::UploadingVoiceNote(_) => "sending a voice message",
ChatAction::UploadingPhoto(_) => "sending a photo",
ChatAction::UploadingDocument(_) => "sending a file",
ChatAction::ChoosingSticker => "choosing a sticker",
ChatAction::ChoosingLocation => "choosing a location",
ChatAction::ChoosingContact => "choosing a contact",
ChatAction::StartPlayingGame => "playing a game",
ChatAction::RecordingVideoNote => "recording a video message",
ChatAction::UploadingVideoNote(_) => "sending a video message",
ChatAction::WatchingAnimations(_) | ChatAction::Cancel => return None,
})
}
fn main_order(positions: &[ChatPosition]) -> Option<i64> {
positions
.iter()
.find(|p| matches!(p.list, ChatList::Main))
.map(|p| p.order)
}
const PREVIEW_CHARS: usize = 300;
fn preview(message: &Message) -> String {
let text = text::clean(&content_text(&message.content));
let text = text::first_chars(&text, PREVIEW_CHARS).replace(['\n', '\t'], " ");
if message.is_outgoing {
format!("You: {text}")
} else {
text
}
}
pub fn content_text(content: &MessageContent) -> String {
let labeled = |label: &str, caption: &str| {
if caption.is_empty() {
format!("[{label}]")
} else {
format!("[{label}] {caption}")
}
};
match content {
MessageContent::MessageText(m) => m.text.text.clone(),
MessageContent::MessagePhoto(m) => labeled("Photo", &m.caption.text),
MessageContent::MessageVideo(m) => labeled("Video", &m.caption.text),
MessageContent::MessageAnimation(m) => labeled("GIF", &m.caption.text),
MessageContent::MessageDocument(m) => {
labeled(&format!("File: {}", m.document.file_name), &m.caption.text)
}
MessageContent::MessageAudio(m) => labeled("Audio", &m.caption.text),
MessageContent::MessageVoiceNote(m) => labeled("Voice message", &m.caption.text),
MessageContent::MessageVideoNote(_) => "[Video message]".into(),
MessageContent::MessageSticker(m) => format!("[Sticker {}]", m.sticker.emoji),
MessageContent::MessagePoll(_) => "[Poll]".into(),
MessageContent::MessageLocation(_) => "[Location]".into(),
MessageContent::MessageContact(_) => "[Contact]".into(),
_ => "[Message]".into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn chats(list: &[(i64, i64, i32)]) -> Chats {
let mut chats = Chats::default();
for &(id, order, unread) in list {
chats.by_id.insert(
id,
Chat {
title: format!("chat {id}"),
is_channel: false,
is_private: false,
unread,
read_outbox: 0,
preview: String::new(),
order,
photo: None,
accent: 0,
activity: Vec::new(),
peer: None,
},
);
}
chats.dirty = true;
chats.refresh();
chats
}
fn user(user_id: i64) -> MessageSender {
MessageSender::User(types::MessageSenderUser { user_id })
}
#[test]
fn activity_lasts_until_tdlib_says_it_stopped() {
let mut list = chats(&[(1, 50, 0)]);
let activity = |list: &Chats| list.get(1).unwrap().activity.clone();
list.set_action(1, &user(7), &ChatAction::Typing);
list.set_action(1, &user(8), &ChatAction::ChoosingSticker);
assert_eq!(
activity(&list),
[
(Sender::User(7), "typing"),
(Sender::User(8), "choosing a sticker")
]
);
let photo = types::ChatActionUploadingPhoto { progress: 10 };
list.set_action(1, &user(7), &ChatAction::UploadingPhoto(photo));
assert_eq!(activity(&list)[0], (Sender::User(7), "sending a photo"));
list.set_action(1, &user(7), &ChatAction::Cancel);
assert_eq!(activity(&list), [(Sender::User(8), "choosing a sticker")]);
let watching = types::ChatActionWatchingAnimations {
emoji: "🎉".into()
};
list.set_action(1, &user(9), &ChatAction::WatchingAnimations(watching));
assert_eq!(activity(&list).len(), 1);
}
#[test]
fn unread_chats_come_first_in_telegram_order() {
let mut list = chats(&[(1, 50, 0), (2, 40, 3), (3, 30, 0), (4, 20, 1), (5, 0, 9)]);
assert_eq!(list.ids(), [2, 4, 1, 3]);
list.set_unread(3, 2);
list.refresh();
assert_eq!(list.ids(), [2, 3, 4, 1], "a new message moves it up");
}
#[test]
fn an_opened_chat_keeps_its_place_until_another_is_opened() {
let mut list = chats(&[(1, 50, 0), (2, 40, 3), (3, 30, 1)]);
list.opened(3);
list.set_unread(3, 0); list.refresh();
assert_eq!(list.ids(), [2, 3, 1], "stays with the unread chats");
list.opened(2);
list.refresh();
assert_eq!(list.ids(), [2, 1, 3], "drops to its place once you move on");
}
#[test]
fn the_filter_keeps_matching_titles_in_order() {
let mut list = chats(&[(1, 50, 0), (2, 40, 3), (3, 30, 0)]);
list.set_title(1, "Alice".into());
list.set_title(2, "Bob".into());
list.set_title(3, "alina".into());
list.set_my_id(4);
list.by_id.insert(
4,
Chat {
title: "Eric".into(),
is_channel: false,
is_private: true,
unread: 0,
read_outbox: 0,
preview: String::new(),
order: 10,
photo: None,
accent: 0,
activity: Vec::new(),
peer: None,
},
);
list.set_filter("ALI");
list.refresh();
assert_eq!(list.ids(), [1, 3]);
assert_eq!(list.total(), 4);
list.set_filter("saved");
list.refresh();
assert_eq!(list.ids(), [4], "Saved Messages matches by its shown name");
list.set_filter("");
list.refresh();
assert_eq!(list.ids(), [2, 1, 3, 4]);
}
#[test]
fn accent_colors_past_the_built_in_seven_map_to_one_of_them() {
let mut list = chats(&[(1, 50, 0), (2, 40, 0), (3, 30, 0)]);
list.set_accent(1, 3);
list.set_accent(2, 9);
list.set_accent(3, 12);
list.set_accent_colors(&[AccentColor {
id: 9,
built_in_accent_color_id: 5,
light_theme_colors: Vec::new(),
dark_theme_colors: Vec::new(),
min_channel_chat_boost_level: 0,
}]);
assert_eq!(list.accent(1), 3);
assert_eq!(list.accent(2), 5);
assert_eq!(list.accent(3), 5, "an unknown id still gets a color");
}
#[test]
fn highlights_toggle() {
let mut list = chats(&[(1, 50, 0), (2, 40, 0)]);
assert_eq!(list.toggle_highlight(2), [2]);
assert_eq!(list.toggle_highlight(1), [1, 2]);
assert!(list.is_highlighted(2));
assert_eq!(list.toggle_highlight(2), [1]);
assert!(!list.is_highlighted(2));
}
}