use std::collections::{HashMap, HashSet};
use tdlib_rs::enums::{
ChatAction, ChatList, ChatType, MessageContent, MessageSender, NotificationSettingsScope,
UserStatus,
};
use tdlib_rs::types::{
self, AccentColor, ChatNotificationSettings, ChatPhotoInfo, ChatPosition, FormattedText,
Message,
};
use crate::images::Thumbnail;
use crate::messages::{Sender, decode_minithumbnail, without_spoilers};
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>,
pub pinned: bool,
notifications: ChatNotificationSettings,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Peer {
User(i64),
BasicGroup(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>,
usernames: HashMap<Peer, String>,
left: HashSet<i64>,
default_mute: [i32; 3],
presence: HashMap<i64, Presence>,
bots: HashSet<i64>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Presence {
Online(i32),
Offline(i32),
Recently,
LastWeek,
LastMonth,
LongAgo,
}
impl Presence {
pub fn of(status: &UserStatus) -> Self {
match status {
UserStatus::Online(s) => Presence::Online(s.expires),
UserStatus::Offline(s) => Presence::Offline(s.was_online),
UserStatus::Recently(_) => Presence::Recently,
UserStatus::LastWeek(_) => Presence::LastWeek,
UserStatus::LastMonth(_) => Presence::LastMonth,
UserStatus::Empty => Presence::LongAgo,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Seen {
Bot,
Person(Presence),
}
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(b) => Some(Peer::BasicGroup(b.basic_group_id)),
};
let main = main_position(&chat.positions);
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.map_or(0, |p| p.order),
photo: chat.photo.as_ref().map(ChatPhoto::new),
accent: chat.accent_color_id,
activity: Vec::new(),
peer,
pinned: main.is_some_and(|p| p.is_pinned),
notifications: chat.notification_settings,
};
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;
chat.pinned = position.is_pinned;
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(main) = main_position(positions) {
chat.order = main.order;
chat.pinned = main.is_pinned;
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 chat = &by_id[&id];
let unread = chat.unread > 0 || held == Some(id);
std::cmp::Reverse((chat.pinned, unread, chat.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 set_username(&mut self, peer: Peer, usernames: Option<&types::Usernames>) {
let first = usernames.and_then(|u| u.active_usernames.first());
match first {
Some(name) => self.usernames.insert(peer, text::clean(name)),
None => self.usernames.remove(&peer),
};
}
pub fn username(&self, chat_id: i64) -> Option<&str> {
let peer = self.by_id.get(&chat_id)?.peer?;
self.usernames.get(&peer).map(String::as_str)
}
pub fn user_username(&self, user_id: i64) -> Option<&str> {
self.usernames.get(&Peer::User(user_id)).map(String::as_str)
}
pub fn set_notifications(&mut self, chat_id: i64, settings: ChatNotificationSettings) {
if let Some(chat) = self.by_id.get_mut(&chat_id) {
chat.notifications = settings;
}
}
pub fn set_default_mute(&mut self, scope: &NotificationSettingsScope, mute_for: i32) {
self.default_mute[scope_index(scope)] = mute_for;
}
pub fn muted(&self, chat_id: i64) -> bool {
let Some(chat) = self.by_id.get(&chat_id) else {
return false;
};
let mute_for = if chat.notifications.use_default_mute_for {
let scope = match () {
_ if chat.is_private => NotificationSettingsScope::PrivateChats,
_ if chat.is_channel => NotificationSettingsScope::ChannelChats,
_ => NotificationSettingsScope::GroupChats,
};
self.default_mute[scope_index(&scope)]
} else {
chat.notifications.mute_for
};
mute_for > 0
}
pub fn with_mute(&self, chat_id: i64, mute: bool) -> Option<ChatNotificationSettings> {
let chat = self.by_id.get(&chat_id)?;
Some(ChatNotificationSettings {
use_default_mute_for: false,
mute_for: if mute { i32::MAX } else { 0 },
..chat.notifications.clone()
})
}
pub fn set_presence(&mut self, user_id: i64, presence: Presence) {
self.presence.insert(user_id, presence);
}
pub fn set_bot(&mut self, user_id: i64, bot: bool) {
if bot {
self.bots.insert(user_id);
} else {
self.bots.remove(&user_id);
}
}
pub fn is_bot(&self, user_id: i64) -> bool {
self.bots.contains(&user_id)
}
pub fn seen(&self, chat_id: i64) -> Option<Seen> {
if self.is_saved(chat_id) {
return None;
}
let Some(Peer::User(user_id)) = self.by_id.get(&chat_id)?.peer else {
return None;
};
if self.bots.contains(&user_id) {
return Some(Seen::Bot);
}
self.presence.get(&user_id).copied().map(Seen::Person)
}
pub fn set_member(&mut self, supergroup_id: i64, member: bool) {
if member {
self.left.remove(&supergroup_id);
} else {
self.left.insert(supergroup_id);
}
}
pub fn joined(&self, chat_id: i64) -> bool {
match self.by_id.get(&chat_id).and_then(|c| c.peer) {
Some(Peer::Supergroup(id)) => !self.left.contains(&id),
_ => true,
}
}
pub fn listed(&self, chat_id: i64) -> bool {
self.by_id.get(&chat_id).is_some_and(|c| c.order != 0)
}
pub fn matching(&self, query: &str) -> Vec<i64> {
let query = query.trim();
let username = query.strip_prefix('@').unwrap_or(query);
let mut ids: Vec<i64> = self
.by_id
.iter()
.filter(|(_, chat)| chat.order != 0)
.map(|(&id, _)| id)
.filter(|&id| {
username.is_empty()
|| !search::find(self.title(id).unwrap_or_default(), query).is_empty()
|| self
.username(id)
.is_some_and(|name| !search::find(name, username).is_empty())
})
.collect();
ids.sort_unstable_by_key(|&id| std::cmp::Reverse((self.by_id[&id].order, id)));
ids
}
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,
pinned: false,
notifications: ChatNotificationSettings::default(),
})
.into_mut()
}
}
fn scope_index(scope: &NotificationSettingsScope) -> usize {
match scope {
NotificationSettingsScope::PrivateChats => 0,
NotificationSettingsScope::GroupChats => 1,
NotificationSettingsScope::ChannelChats => 2,
}
}
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_position(positions: &[ChatPosition]) -> Option<&ChatPosition> {
positions.iter().find(|p| matches!(p.list, ChatList::Main))
}
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 {
labeled_text(content, without_spoilers)
}
pub fn content_text_as_sent(content: &MessageContent) -> String {
labeled_text(content, |text| text.text.clone())
}
fn labeled_text(content: &MessageContent, text: impl Fn(&FormattedText) -> String) -> String {
let labeled = |label: &str, caption: &FormattedText| {
let caption = text(caption);
if caption.is_empty() {
format!("[{label}]")
} else {
format!("[{label}] {caption}")
}
};
match content {
MessageContent::MessageText(m) => text(&m.text),
MessageContent::MessagePhoto(m) => labeled("Photo", &m.caption),
MessageContent::MessageVideo(m) => labeled("Video", &m.caption),
MessageContent::MessageAnimation(m) => labeled("GIF", &m.caption),
MessageContent::MessageDocument(m) => {
labeled(&format!("File: {}", m.document.file_name), &m.caption)
}
MessageContent::MessageAudio(m) => labeled("Audio", &m.caption),
MessageContent::MessageVoiceNote(m) => labeled("Voice message", &m.caption),
MessageContent::MessageVideoNote(_) => "[Video message]".into(),
MessageContent::MessageSticker(m) => format!("[Sticker {}]", m.sticker.emoji),
MessageContent::MessagePoll(m) => labeled("Poll", &m.poll.question),
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,
pinned: false,
notifications: ChatNotificationSettings::default(),
},
);
}
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 pinned_chats_stay_on_top_then_unread_ones() {
let mut list = chats(&[(1, 50, 0), (2, 40, 3), (3, 30, 0)]);
list.by_id.get_mut(&3).unwrap().pinned = true;
list.dirty = true;
list.refresh();
assert_eq!(list.ids(), [3, 2, 1]);
}
#[test]
fn a_chat_is_muted_by_its_own_setting_or_its_kinds_default() {
let mut list = chats(&[(1, 50, 0), (2, 40, 0)]);
let own = |mute_for| ChatNotificationSettings {
mute_for,
..ChatNotificationSettings::default()
};
let default = || ChatNotificationSettings {
use_default_mute_for: true,
..ChatNotificationSettings::default()
};
list.set_notifications(1, own(3600));
list.set_notifications(2, default());
assert!(list.muted(1));
assert!(!list.muted(2));
list.set_default_mute(&NotificationSettingsScope::GroupChats, i32::MAX);
assert!(list.muted(2), "groups are muted by default now");
let unmute = list.with_mute(1, false).unwrap();
assert!(!unmute.use_default_mute_for && unmute.mute_for == 0);
assert_eq!(list.with_mute(2, true).unwrap().mute_for, i32::MAX);
}
#[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,
pinned: false,
notifications: ChatNotificationSettings::default(),
},
);
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));
}
}