use std::collections::{BTreeMap, HashMap};
use std::ops::Range;
use base64::Engine;
use tdlib_rs::enums::{
LinkPreviewType, MessageContent, MessageOrigin, MessageReplyTo, MessageSender,
MessageSendingState, StickerFormat, TextEntityType, ThumbnailFormat,
};
use tdlib_rs::types::{self, Message};
use unicode_width::UnicodeWidthStr;
use crate::attach::{Attachment, Dropped};
use crate::chats::content_text_as_sent;
use crate::complete::Commands;
use crate::images::Thumbnail;
use crate::poll::Poll;
use crate::reactions::{self, Reaction, ReactionKind};
use crate::search::MessageSearch;
use crate::text;
use crate::tg::Page;
const PHOTO_MIN_SIDE: i32 = 640;
const THUMBNAIL_MIN_SIDE: i32 = 200;
const MAX_FOLLOWED: usize = 1000;
const MAX_LOADED: usize = 2 * MAX_FOLLOWED;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Sender {
User(i64),
Chat(i64),
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum SendState {
Sent,
Pending,
Failed,
}
#[derive(Clone)]
pub struct Preview {
pub file_id: i32,
pub width: u32,
pub height: u32,
pub thumbnail: Option<Thumbnail>,
pub sticker: bool,
}
impl Preview {
fn from_photo(photo: &types::Photo) -> Option<Self> {
Self::sized(photo, PHOTO_MIN_SIDE)
}
fn sized(photo: &types::Photo, min_side: i32) -> Option<Self> {
let size = photo
.sizes
.iter()
.filter(|s| s.width.max(s.height) >= min_side)
.min_by_key(|s| s.width)
.or_else(|| largest(photo))?;
Some(Self {
file_id: size.photo.id,
width: size.width.max(1) as u32,
height: size.height.max(1) as u32,
thumbnail: decode_minithumbnail(photo.minithumbnail.as_ref()),
sticker: false,
})
}
fn from_sticker(sticker: &types::Sticker) -> Option<Self> {
let file_id = match sticker.format {
StickerFormat::Webp => sticker.sticker.id,
StickerFormat::Tgs | StickerFormat::Webm => {
sticker
.thumbnail
.as_ref()
.filter(|t| decodable(&t.format))?
.file
.id
}
};
Some(Self {
file_id,
width: sticker.width.max(1) as u32,
height: sticker.height.max(1) as u32,
thumbnail: None,
sticker: true,
})
}
pub fn sticker_thumbnail(sticker: &types::Sticker) -> Option<Self> {
let thumbnail = sticker.thumbnail.as_ref().filter(|t| decodable(&t.format));
let file_id = match (thumbnail, &sticker.format) {
(Some(thumbnail), _) => thumbnail.file.id,
(None, StickerFormat::Webp) => sticker.sticker.id,
(None, _) => return None,
};
Some(Self {
file_id,
width: sticker.width.max(1) as u32,
height: sticker.height.max(1) as u32,
thumbnail: None,
sticker: true,
})
}
fn from_video(video: &types::Video) -> Option<Self> {
let thumbnail = video.thumbnail.as_ref().filter(|t| decodable(&t.format))?;
let (width, height) = if video.width > 0 && video.height > 0 {
(video.width, video.height)
} else {
(thumbnail.width, thumbnail.height)
};
Some(Self {
file_id: thumbnail.file.id,
width: width.max(1) as u32,
height: height.max(1) as u32,
thumbnail: decode_minithumbnail(video.minithumbnail.as_ref()),
sticker: false,
})
}
}
fn decodable(format: &ThumbnailFormat) -> bool {
matches!(
format,
ThumbnailFormat::Jpeg | ThumbnailFormat::Png | ThumbnailFormat::Webp | ThumbnailFormat::Gif
)
}
fn largest(photo: &types::Photo) -> Option<&types::PhotoSize> {
photo.sizes.iter().max_by_key(|s| s.width)
}
pub fn decode_minithumbnail(mini: Option<&types::Minithumbnail>) -> Option<Thumbnail> {
let data = &mini?.data;
base64::engine::general_purpose::STANDARD
.decode(data)
.ok()
.map(Thumbnail::from)
}
pub struct Msg {
pub sender: Sender,
pub outgoing: bool,
pub date: i32,
pub text: String,
pub source_text: String,
pub preview: Option<Preview>,
pub file: Option<MediaFile>,
pub links: Vec<Link>,
pub link_ranges: Vec<Range<usize>>,
pub styles: Vec<Styled>,
pub revealed: bool,
pub forwarded: Option<Origin>,
pub poll: Option<Poll>,
pub card: Option<Card>,
pub state: SendState,
pub reply_to: Option<ReplyTo>,
pub editable: Editable,
pub formatted: bool,
pub edited: bool,
pub album: i64,
pub reactions: Vec<Reaction>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Editable {
No,
Text,
Caption { above: bool },
}
pub struct Editing {
pub id: i64,
pub snippet: String,
pub original: String,
pub editable: Editable,
pub draft: String,
pub reply: Option<Replied>,
pub attachments: Vec<Attachment>,
}
impl Msg {
pub fn snippet(&self) -> String {
let text = one_line(&hide_spoilers(&self.text, &self.styles));
match &self.file {
_ if !text.is_empty() => text,
Some(file) => file.label.clone(),
None => "Message".into(),
}
}
pub fn hides_spoilers(&self) -> bool {
!self.revealed && self.styles.iter().any(|s| s.format.spoiler)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Format {
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub strike: bool,
pub code: bool,
pub spoiler: bool,
pub quote: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Styled {
pub range: Range<usize>,
pub format: Format,
}
#[derive(Clone, Copy)]
enum Mark {
Bold,
Italic,
Underline,
Strike,
Code,
Spoiler,
Quote,
}
const MARKS: usize = 7;
impl Mark {
fn of(kind: &TextEntityType) -> Option<Self> {
use TextEntityType as T;
Some(match kind {
T::Bold => Mark::Bold,
T::Italic => Mark::Italic,
T::Underline => Mark::Underline,
T::Strikethrough => Mark::Strike,
T::Code | T::Pre | T::PreCode(_) => Mark::Code,
T::Spoiler => Mark::Spoiler,
T::BlockQuote | T::ExpandableBlockQuote => Mark::Quote,
_ => return None,
})
}
}
impl Format {
fn of(open: &[u32; MARKS]) -> Self {
let on = |mark: Mark| open[mark as usize] > 0;
Format {
bold: on(Mark::Bold),
italic: on(Mark::Italic),
underline: on(Mark::Underline),
strike: on(Mark::Strike),
code: on(Mark::Code),
spoiler: on(Mark::Spoiler),
quote: on(Mark::Quote),
}
}
}
fn styles(text: &types::FormattedText) -> Vec<Styled> {
let mut edges = Vec::new();
for entity in &text.entities {
let Some(mark) = Mark::of(&entity.r#type) else {
continue;
};
let start = byte_offset(&text.text, entity.offset);
let end = byte_offset(&text.text, entity.offset.saturating_add(entity.length));
if start < end {
edges.push((start, true, mark));
edges.push((end, false, mark));
}
}
edges.sort_by_key(|&(at, _, _)| at);
let mut open = [0u32; MARKS];
let mut out: Vec<Styled> = Vec::new();
let mut from = 0;
for (at, starts, mark) in edges {
if at > from {
let format = Format::of(&open);
if format != Format::default() {
match out.last_mut() {
Some(last) if last.range.end == from && last.format == format => {
last.range.end = at;
}
_ => out.push(Styled {
range: from..at,
format,
}),
}
}
from = at;
}
let count = &mut open[mark as usize];
*count = if starts {
count.saturating_add(1)
} else {
count.saturating_sub(1)
};
}
out
}
pub const SPOILER: &str = "⠿";
fn hide_spoilers(text: &str, styles: &[Styled]) -> String {
let mut out = String::with_capacity(text.len());
let mut done = 0;
for styled in styles.iter().filter(|s| s.format.spoiler) {
let Some(hidden) = text.get(styled.range.clone()) else {
continue;
};
out.push_str(&text[done..styled.range.start]);
out.push_str(&SPOILER.repeat(hidden.width()));
done = styled.range.end;
}
out.push_str(&text[done..]);
out
}
pub fn without_spoilers(text: &types::FormattedText) -> String {
hide_spoilers(&text.text, &styles(text))
}
#[derive(Clone)]
pub struct Card {
pub host: String,
pub title: String,
pub description: String,
pub image: Option<Preview>,
}
impl Card {
fn new(preview: &types::LinkPreview) -> Option<Self> {
let host = link_host(&preview.url)?;
let title = match one_line(&preview.title) {
title if title.is_empty() => one_line(&preview.site_name),
title => title,
};
let description = one_line(&without_spoilers(&preview.description));
if title.is_empty() && description.is_empty() {
return None;
}
let small = |photo: &types::Photo| Preview::sized(photo, THUMBNAIL_MIN_SIDE);
let image = match &preview.r#type {
LinkPreviewType::Article(a) => a.photo.as_ref().and_then(small),
LinkPreviewType::Photo(p) => small(&p.photo),
LinkPreviewType::Video(v) => v
.cover
.as_ref()
.and_then(small)
.or_else(|| Preview::from_video(&v.video)),
_ => None,
};
Some(Card {
host: text::clean(&host),
title,
description,
image,
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Origin {
User(i64),
Hidden(String),
Chat {
chat_id: i64,
signature: String,
},
}
impl From<&MessageOrigin> for Origin {
fn from(origin: &MessageOrigin) -> Self {
match origin {
MessageOrigin::User(o) => Origin::User(o.sender_user_id),
MessageOrigin::HiddenUser(o) => Origin::Hidden(text::clean(&o.sender_name)),
MessageOrigin::Chat(o) => Origin::Chat {
chat_id: o.sender_chat_id,
signature: text::clean(&o.author_signature),
},
MessageOrigin::Channel(o) => Origin::Chat {
chat_id: o.chat_id,
signature: text::clean(&o.author_signature),
},
}
}
}
fn one_line(text: &str) -> String {
let line = text::clean(text)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
text::first_chars(&line, SNIPPET_CHARS).to_string()
}
const SNIPPET_CHARS: usize = 300;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Link {
pub url: String,
pub disguise: Option<String>,
}
impl From<&str> for Link {
fn from(url: &str) -> Self {
Link {
url: url.into(),
disguise: None,
}
}
}
pub struct ReplyTo {
pub message_id: Option<i64>,
pub quote: Option<String>,
}
#[derive(Clone)]
pub struct Replied {
pub id: i64,
pub sender: Sender,
pub outgoing: bool,
pub snippet: String,
}
impl Replied {
pub fn new(id: i64, msg: &Msg) -> Self {
Self {
id,
sender: msg.sender,
outgoing: msg.outgoing,
snippet: msg.snippet(),
}
}
}
#[derive(Clone)]
pub struct MediaFile {
pub id: i32,
pub label: String,
pub photo: bool,
}
struct Body {
text: String,
source_text: String,
preview: Option<Preview>,
file: Option<MediaFile>,
links: Vec<Link>,
link_ranges: Vec<Range<usize>>,
styles: Vec<Styled>,
poll: Option<Poll>,
card: Option<Card>,
editable: Editable,
formatted: bool,
}
fn body(content: &MessageContent) -> Body {
use MessageContent as C;
let mut body = Body {
text: content_text_as_sent(content),
source_text: String::new(),
preview: None,
file: None,
links: Vec::new(),
link_ranges: Vec::new(),
styles: Vec::new(),
poll: None,
card: None,
editable: Editable::No,
formatted: false,
};
let file = |id: i32, label: String| {
Some(MediaFile {
id,
label,
photo: false,
})
};
let mut source = None;
match content {
C::MessageText(m) => {
source = Some(&m.text);
body.editable = Editable::Text;
body.card = m.link_preview.as_ref().and_then(Card::new);
}
C::MessagePhoto(m) => {
body.editable = Editable::Caption {
above: m.show_caption_above_media,
};
body.preview = Preview::from_photo(&m.photo);
if body.preview.is_some() {
body.text = m.caption.text.clone();
}
body.file = largest(&m.photo).map(|s| MediaFile {
id: s.photo.id,
label: "Photo".into(),
photo: true,
});
source = Some(&m.caption);
}
C::MessageVideo(m) => {
body.editable = Editable::Caption {
above: m.show_caption_above_media,
};
body.preview = Preview::from_video(&m.video);
if body.preview.is_some() {
body.text = format!("▶ {}", duration(m.video.duration));
if !m.caption.text.is_empty() {
body.text = format!("{}\n{}", body.text, m.caption.text);
}
}
let label = format!("Video {}", duration(m.video.duration));
body.file = file(m.video.video.id, label);
source = Some(&m.caption);
}
C::MessageAnimation(m) => {
body.editable = Editable::Caption {
above: m.show_caption_above_media,
};
body.file = file(m.animation.animation.id, "GIF".into());
source = Some(&m.caption);
}
C::MessageDocument(m) => {
body.editable = Editable::Caption { above: false };
let label = format!("File: {}", text::clean(&m.document.file_name));
body.file = file(m.document.document.id, label);
source = Some(&m.caption);
}
C::MessageAudio(m) => {
let a = &m.audio;
let name = if a.title.is_empty() {
&a.file_name
} else {
&a.title
};
body.file = file(a.audio.id, format!("Audio: {}", text::clean(name)));
body.editable = Editable::Caption { above: false };
source = Some(&m.caption);
}
C::MessageVoiceNote(m) => {
body.file = file(m.voice_note.voice.id, "Voice message".into());
body.editable = Editable::Caption { above: false };
source = Some(&m.caption);
}
C::MessageVideoNote(m) => body.file = file(m.video_note.video.id, "Video message".into()),
C::MessagePoll(m) => {
let poll = Poll::new(&m.poll);
body.text = poll.text();
body.source_text = body.text.clone();
body.poll = Some(poll);
}
C::MessageSticker(m) => {
body.preview = Preview::from_sticker(&m.sticker);
if body.preview.is_some() {
body.text = String::new();
}
body.file = file(m.sticker.sticker.id, "Sticker".into());
}
_ => {}
}
if let Some(source) = source {
body.source_text = text::clean(&source.text);
body.formatted = source
.entities
.iter()
.any(|e| !found_by_telegram(&e.r#type));
let found = links(source);
if body.text.ends_with(&source.text) {
let shift = body.text.len() - source.text.len();
body.link_ranges = found
.iter()
.map(|(_, r)| r.start + shift..r.end + shift)
.collect();
body.link_ranges.sort_by_key(|r| r.start);
body.styles = styles(source);
for styled in &mut body.styles {
styled.range = styled.range.start + shift..styled.range.end + shift;
}
}
for (link, _) in found {
match body.links.iter_mut().find(|l| l.url == link.url) {
Some(seen) => {
if seen.disguise.is_none() {
seen.disguise = link.disguise;
}
}
None => body.links.push(link),
}
}
}
let bare = |host: &str| host.strip_prefix("www.").unwrap_or(host).to_string();
if let Some(card) = &body.card
&& !body
.links
.iter()
.filter_map(|l| link_host(&l.url))
.any(|host| bare(&text::clean(&host)) == bare(&card.host))
{
body.card = None;
}
let ranges = body
.link_ranges
.iter_mut()
.chain(body.styles.iter_mut().map(|s| &mut s.range));
body.text = normalize(&body.text, ranges);
body.styles.retain(|s| !s.range.is_empty());
body
}
pub fn found_by_telegram(kind: &TextEntityType) -> bool {
use TextEntityType as T;
matches!(
kind,
T::Mention
| T::Hashtag
| T::Cashtag
| T::BotCommand
| T::Url
| T::EmailAddress
| T::PhoneNumber
| T::BankCardNumber
| T::MediaTimestamp(_)
)
}
fn normalize<'a>(text: &str, ranges: impl IntoIterator<Item = &'a mut Range<usize>>) -> String {
if !text.contains(|c| c == '\t' || text::is_hidden(c)) {
return text.to_string();
}
let mut out = String::with_capacity(text.len());
let mut map = vec![0; text.len() + 1];
for (i, c) in text.char_indices() {
map[i..i + c.len_utf8()].fill(out.len());
match c {
'\t' => out.push_str(" "),
c if text::is_hidden(c) => {}
c => out.push(c),
}
}
map[text.len()] = out.len();
for range in ranges {
*range = map[range.start]..map[range.end];
}
out
}
fn links(text: &types::FormattedText) -> Vec<(Link, Range<usize>)> {
let mut out = Vec::new();
for entity in &text.entities {
let start = byte_offset(&text.text, entity.offset);
let end = byte_offset(&text.text, entity.offset.saturating_add(entity.length));
let Some(shown) = text.text.get(start..end).filter(|s| !s.is_empty()) else {
continue;
};
let (url, hidden) = match &entity.r#type {
TextEntityType::Url => (shown, false),
TextEntityType::TextUrl(t) => (t.url.as_str(), true),
_ => continue,
};
if let Some(url) = web_url(url) {
let disguise = (hidden && !same_place(shown, &url)).then(|| one_line(shown));
out.push((Link { url, disguise }, start..end));
}
}
out
}
fn same_place(shown: &str, url: &str) -> bool {
let bare = |s: &str| {
let s = s.trim().to_lowercase();
let s = s
.strip_prefix("https://")
.or(s.strip_prefix("http://"))
.unwrap_or(&s);
let s = s.strip_prefix("www.").unwrap_or(s);
s.trim_end_matches('/').to_string()
};
bare(shown) == bare(url)
}
pub fn byte_offset(text: &str, utf16: i32) -> usize {
let mut units = 0;
for (i, c) in text.char_indices() {
if units >= utf16.max(0) as usize {
return i;
}
units += c.len_utf16();
}
text.len()
}
fn web_url(url: &str) -> Option<String> {
let url = text::clean(url);
let url = url.trim();
if url.contains(char::is_whitespace) {
return None;
}
let lower = url.to_ascii_lowercase();
if lower.starts_with("https://") || lower.starts_with("http://") {
Some(url.to_string())
} else if !url.is_empty() && !url.contains("://") && !url.contains(':') {
Some(format!("https://{url}"))
} else {
None
}
}
pub fn link_host(url: &str) -> Option<String> {
let lower = url.to_ascii_lowercase();
let rest = lower
.strip_prefix("https:")
.or_else(|| lower.strip_prefix("http:"))?;
let rest = rest.trim_start_matches(['/', '\\']);
let authority = &rest[..rest.find(['/', '\\', '?', '#']).unwrap_or(rest.len())];
let host = authority
.rsplit_once('@')
.map_or(authority, |(_, host)| host);
let host = match host.find(']') {
Some(end) if host.starts_with('[') => &host[..=end],
_ => host.rsplit_once(':').map_or(host, |(host, _)| host),
};
(!host.is_empty()).then(|| host.to_string())
}
fn duration(seconds: i32) -> String {
let (h, m, s) = (seconds / 3600, seconds / 60 % 60, seconds % 60);
if h > 0 {
format!("{h}:{m:02}:{s:02}")
} else {
format!("{m}:{s:02}")
}
}
impl From<&MessageSender> for Sender {
fn from(sender: &MessageSender) -> Self {
match sender {
MessageSender::User(u) => Sender::User(u.user_id),
MessageSender::Chat(c) => Sender::Chat(c.chat_id),
}
}
}
impl From<Message> for Msg {
fn from(message: Message) -> Self {
let sender = Sender::from(&message.sender_id);
let state = match message.sending_state {
None => SendState::Sent,
Some(MessageSendingState::Pending(_)) => SendState::Pending,
Some(MessageSendingState::Failed(_)) => SendState::Failed,
};
let reply_to = match message.reply_to {
Some(MessageReplyTo::Message(r)) => Some(ReplyTo {
message_id: (r.chat_id == message.chat_id).then_some(r.message_id),
quote: r.quote.map(|q| one_line(&without_spoilers(&q.text))),
}),
_ => None,
};
let body = body(&message.content);
Self {
sender,
outgoing: message.is_outgoing,
date: message.date,
text: body.text,
source_text: body.source_text,
preview: body.preview,
file: body.file,
links: body.links,
link_ranges: body.link_ranges,
styles: body.styles,
revealed: false,
forwarded: message.forward_info.map(|f| Origin::from(&f.origin)),
poll: body.poll,
card: body.card,
state,
reply_to,
editable: body.editable,
formatted: body.formatted,
edited: message.edit_date != 0,
album: message.media_album_id,
reactions: reactions::from_info(message.interaction_info.as_ref()),
}
}
}
pub enum Fetched {
Loading,
Found(Replied),
Missing,
}
#[derive(Clone, Copy)]
pub struct ScrollAnchor {
pub msg_id: i64,
pub offset: usize,
}
pub struct OpenChat {
pub chat_id: i64,
pub seen: i64,
pub messages: BTreeMap<i64, Msg>,
pub selected: Option<i64>,
pub scroll: Option<ScrollAnchor>,
pub loading: Option<Page>,
pub all_loaded: bool,
pub at_newest: bool,
pub search: Option<MessageSearch>,
pub reply: Option<Replied>,
pub editing: Option<Editing>,
pub attachments: Vec<Attachment>,
pub dropped: Option<Dropped>,
pub as_files: bool,
pub uploads: HashMap<i32, u8>,
pub replied: HashMap<i64, Fetched>,
pub jumps: Vec<i64>,
pub commands: Commands,
}
impl OpenChat {
pub fn new(chat_id: i64) -> Self {
Self {
chat_id,
seen: 0,
messages: BTreeMap::new(),
selected: None,
scroll: None,
loading: None,
all_loaded: false,
at_newest: true,
search: None,
reply: None,
editing: None,
attachments: Vec::new(),
dropped: None,
as_files: false,
uploads: HashMap::new(),
replied: HashMap::new(),
jumps: Vec::new(),
commands: Commands::NotAsked,
}
}
pub fn undo_drop(&mut self) -> Option<String> {
let dropped = self.dropped.take()?;
let kept = self.attachments.len().saturating_sub(dropped.count);
self.attachments.truncate(kept);
Some(dropped.text)
}
pub fn set_upload(&mut self, file: &types::File) {
let total = if file.size > 0 {
file.size
} else {
file.expected_size
};
if file.remote.is_uploading_active && total > 0 {
let done = file.remote.uploaded_size.clamp(0, total) * 100 / total;
self.uploads.insert(file.id, done.min(99) as u8);
} else {
self.uploads.remove(&file.id);
}
}
pub fn upload_progress(&self, msg: &Msg) -> Option<u8> {
let photo = msg.preview.as_ref().map(|p| p.file_id);
[msg.file.as_ref().map(|f| f.id), photo]
.into_iter()
.flatten()
.find_map(|id| self.uploads.get(&id).copied())
}
pub fn edit_target(&self) -> Option<i64> {
let id = self.cursor_id()?;
let mut captioned = self
.bubble(id)
.into_iter()
.filter(|(_, m)| !m.source_text.is_empty())
.map(|(id, _)| id);
match (captioned.next(), captioned.next()) {
(Some(holder), None) => Some(holder),
_ => Some(id),
}
}
pub fn cursor_id(&self) -> Option<i64> {
self.selected.or_else(|| self.newest_id())
}
pub fn cant_edit(&self, id: i64) -> Option<&'static str> {
let msg = self.messages.get(&id)?;
match msg.state {
SendState::Pending => Some("Wait until it's sent"),
SendState::Failed => Some("This message wasn't sent"),
SendState::Sent if msg.editable == Editable::No => {
Some("This message has no text to edit")
}
SendState::Sent => None,
}
}
fn bubble(&self, id: i64) -> Vec<(i64, &Msg)> {
let Some(msg) = self.messages.get(&id) else {
return Vec::new();
};
if msg.album != 0 {
let photos: Vec<(i64, &Msg)> = self
.messages
.iter()
.filter(|(_, m)| m.album == msg.album)
.map(|(&id, m)| (id, m))
.collect();
if photos
.iter()
.all(|(_, m)| m.preview.as_ref().is_some_and(|p| !p.sticker))
{
return photos;
}
}
vec![(id, msg)]
}
pub fn forward_ids(&self, id: i64) -> Vec<i64> {
self.bubble(id)
.into_iter()
.filter(|(_, m)| m.state == SendState::Sent)
.map(|(id, _)| id)
.collect()
}
pub fn hides_spoilers(&self, id: i64) -> bool {
self.bubble(id).iter().any(|(_, m)| m.hides_spoilers())
}
pub fn reveal_spoilers(&mut self, id: i64) -> bool {
let hiding: Vec<i64> = self
.bubble(id)
.into_iter()
.filter(|(_, m)| m.hides_spoilers())
.map(|(id, _)| id)
.collect();
for id in &hiding {
if let Some(msg) = self.messages.get_mut(id) {
msg.revealed = true;
}
}
!hiding.is_empty()
}
pub fn react_target(&self) -> Option<i64> {
let bubble = self.bubble(self.cursor_id()?);
bubble
.iter()
.find(|(_, m)| !m.reactions.is_empty())
.or(bubble.first())
.map(|&(id, _)| id)
}
pub fn your_reactions(&self) -> Vec<(i64, ReactionKind)> {
self.cursor_id()
.map_or_else(Vec::new, |id| self.your_reactions_on(id))
}
pub fn your_emoji(&self, id: i64) -> Vec<(i64, String)> {
self.your_reactions_on(id)
.into_iter()
.filter_map(|(id, kind)| match kind {
ReactionKind::Emoji(emoji) => Some((id, emoji)),
_ => None,
})
.collect()
}
fn your_reactions_on(&self, id: i64) -> Vec<(i64, ReactionKind)> {
self.bubble(id)
.into_iter()
.flat_map(|(id, m)| {
m.reactions
.iter()
.filter(|r| r.chosen && r.kind != ReactionKind::Paid)
.map(move |r| (id, r.kind.clone()))
})
.collect()
}
pub fn set_reactions(&mut self, message_id: i64, info: Option<&types::MessageInteractionInfo>) {
if let Some(msg) = self.messages.get_mut(&message_id) {
msg.reactions = reactions::from_info(info);
}
}
pub fn set_edited(&mut self, message_id: i64) {
if let Some(msg) = self.messages.get_mut(&message_id) {
msg.edited = true;
}
}
pub fn replied_jump(&self) -> Result<(i64, i64), &'static str> {
let from = self.cursor_id().ok_or("No message selected")?;
let reply = self
.messages
.get(&from)
.and_then(|m| m.reply_to.as_ref())
.ok_or("Not a reply")?;
let to = reply
.message_id
.ok_or("It answers a message in another chat")?;
if !self.messages.contains_key(&to)
&& matches!(self.replied.get(&from), Some(Fetched::Missing))
{
return Err("The message it answers was deleted");
}
Ok((from, to))
}
pub fn missing_replied(&mut self) -> Vec<i64> {
let missing: Vec<i64> = self
.messages
.iter()
.filter(|(id, msg)| {
msg.state == SendState::Sent
&& !self.replied.contains_key(id)
&& msg.reply_to.as_ref().is_some_and(|r| {
r.message_id
.is_none_or(|answered| !self.messages.contains_key(&answered))
})
})
.map(|(&id, _)| id)
.collect();
for &id in &missing {
self.replied.insert(id, Fetched::Loading);
}
missing
}
pub fn set_replied(&mut self, reply_id: i64, replied: Option<Message>) {
let fetched = match replied {
Some(message) => Fetched::Found(Replied::new(message.id, &message.into())),
None => Fetched::Missing,
};
self.replied.insert(reply_id, fetched);
}
pub fn insert(&mut self, message: Message) {
self.add_new(message.id, message.into());
}
fn add_new(&mut self, id: i64, msg: Msg) {
self.messages.insert(id, msg);
if self.loading.is_some() {
return;
}
let loaded = self.messages.len();
if self.selected.is_none() {
while self.messages.len() > MAX_FOLLOWED {
self.messages.pop_first();
self.all_loaded = false;
}
} else if self.messages.len() > MAX_LOADED {
while self.messages.len() > MAX_LOADED
&& self.messages.last_key_value().map(|(&id, _)| id) != self.selected
{
self.messages.pop_last();
}
self.at_newest = false;
}
if self.messages.len() < loaded {
self.prune_replied();
}
}
fn prune_replied(&mut self) {
let messages = &self.messages;
self.replied.retain(|id, _| messages.contains_key(id));
}
pub fn add_page(&mut self, page: Page, messages: Vec<(i64, Msg)>) {
let (oldest, newest) = (self.oldest_id(), self.newest_id());
match page {
Page::Latest => {
let page_newest = messages.iter().map(|(id, _)| *id).max();
self.messages.retain(|&id, _| Some(id) > page_newest);
self.at_newest = true;
self.all_loaded = messages.is_empty();
}
Page::Around(target) => {
if messages.is_empty() {
return;
}
self.messages.clear();
self.scroll = None;
self.at_newest = false;
self.all_loaded = false;
self.messages.extend(messages);
self.selected = self
.messages
.range(..=target)
.next_back()
.or_else(|| self.messages.first_key_value())
.map(|(&id, _)| id);
self.prune_replied();
return;
}
Page::Older(_) => {
self.all_loaded = !messages
.iter()
.any(|(id, _)| oldest.is_none_or(|o| *id < o));
}
Page::Newer(_) => {
self.at_newest = !messages
.iter()
.any(|(id, _)| newest.is_none_or(|n| *id > n));
}
}
self.messages.extend(messages);
self.prune_replied();
}
pub fn replace(&mut self, old_id: i64, message: Message) {
if self.messages.remove(&old_id).is_none() {
return;
}
if self.selected == Some(old_id) {
self.selected = Some(message.id);
}
if let Some(reply) = self.reply.as_mut().filter(|r| r.id == old_id) {
reply.id = message.id;
}
self.insert(message);
}
pub fn set_content(&mut self, message_id: i64, content: &MessageContent) {
if let Some(msg) = self.messages.get_mut(&message_id) {
let body = body(content);
(msg.text, msg.preview, msg.file) = (body.text, body.preview, body.file);
msg.source_text = body.source_text;
(msg.links, msg.link_ranges) = (body.links, body.link_ranges);
(msg.editable, msg.formatted) = (body.editable, body.formatted);
(msg.styles, msg.revealed) = (body.styles, false);
(msg.poll, msg.card) = (body.poll, body.card);
if let Some(reply) = self.reply.as_mut().filter(|r| r.id == message_id) {
reply.snippet = msg.snippet();
}
}
}
pub fn remove(&mut self, message_ids: &[i64]) {
for id in message_ids {
self.messages.remove(id);
if self.selected == Some(*id) {
self.selected = None;
}
if self.reply.as_ref().is_some_and(|r| r.id == *id) {
self.reply = None;
}
}
}
pub fn oldest_id(&self) -> Option<i64> {
self.messages.keys().next().copied()
}
pub fn newest_id(&self) -> Option<i64> {
self.messages.keys().next_back().copied()
}
pub fn move_cursor(&mut self, delta: isize) -> Option<usize> {
let ids: Vec<i64> = self.messages.keys().copied().collect();
let last = ids.len().checked_sub(1)?;
let current = self
.selected
.and_then(|id| ids.binary_search(&id).ok())
.unwrap_or(last);
let index = current.saturating_add_signed(delta).min(last);
self.selected = (index != last || !self.at_newest).then(|| ids[index]);
Some(index)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn video(format: ThumbnailFormat, caption: &str) -> MessageContent {
MessageContent::MessageVideo(types::MessageVideo {
video: types::Video {
duration: 65,
width: 1920,
height: 1080,
file_name: "clip.mp4".into(),
mime_type: "video/mp4".into(),
has_stickers: false,
supports_streaming: true,
minithumbnail: None,
thumbnail: Some(types::Thumbnail {
format,
width: 320,
height: 180,
file: types::File {
id: 9,
..Default::default()
},
}),
video: types::File {
id: 10,
..Default::default()
},
},
alternative_videos: Vec::new(),
storyboards: Vec::new(),
cover: None,
start_timestamp: 0,
caption: types::FormattedText {
text: caption.into(),
..Default::default()
},
show_caption_above_media: false,
has_spoiler: false,
is_secret: false,
})
}
#[test]
fn videos_preview_their_thumbnail_and_open_the_video() {
let body = body(&video(ThumbnailFormat::Jpeg, "our trip"));
let preview = body.preview.expect("jpeg thumbnails are shown");
assert_eq!(preview.file_id, 9, "downloads the thumbnail");
assert_eq!((preview.width, preview.height), (1920, 1080));
assert_eq!(body.text, "▶ 1:05\nour trip");
let file = body.file.unwrap();
assert_eq!(
(file.id, file.label.as_str()),
(10, "Video 1:05"),
"Enter opens the video itself"
);
}
#[test]
fn videos_with_clip_thumbnails_fall_back_to_a_label() {
let body = body(&video(ThumbnailFormat::Mpeg4, ""));
assert!(body.preview.is_none());
assert_eq!(body.text, "[Video]");
assert_eq!(body.file.map(|f| f.id), Some(10));
}
fn entity(offset: i32, length: i32, kind: TextEntityType) -> types::TextEntity {
types::TextEntity {
offset,
length,
r#type: kind,
}
}
#[test]
fn links_come_from_telegram_entities() {
let text = "🎉 see: example.com/a and docs, then https://x.dev twice: https://x.dev";
let at = |needle: &str| {
let byte = text.find(needle).unwrap();
text[..byte].encode_utf16().count() as i32
};
let text = types::FormattedText {
text: text.into(),
entities: vec![
entity(at("example.com"), 13, TextEntityType::Url),
entity(
at("docs"),
4,
TextEntityType::TextUrl(types::TextEntityTypeTextUrl {
url: "https://docs.rs".into(),
}),
),
entity(at("https://x.dev"), 13, TextEntityType::Url),
entity(
text.rfind("https")
.map(|b| text[..b].encode_utf16().count())
.unwrap() as i32,
13,
TextEntityType::Url,
),
entity(
0,
2,
TextEntityType::TextUrl(types::TextEntityTypeTextUrl {
url: "file:///etc/passwd".into(),
}),
),
],
};
let found = links(&text);
let urls: Vec<&str> = found.iter().map(|(l, _)| l.url.as_str()).collect();
assert_eq!(
urls,
[
"https://example.com/a",
"https://docs.rs",
"https://x.dev",
"https://x.dev"
],
"scheme added, hidden link kept, file:// dropped"
);
let shown: Vec<&str> = found.iter().map(|(_, r)| &text.text[r.clone()]).collect();
assert_eq!(
shown,
["example.com/a", "docs", "https://x.dev", "https://x.dev"]
);
}
#[test]
fn links_hidden_behind_other_words_are_marked_and_bad_entities_skipped() {
let text_url = |offset, length, url: &str| types::TextEntity {
offset,
length,
r#type: TextEntityType::TextUrl(types::TextEntityTypeTextUrl { url: url.into() }),
};
let text = types::FormattedText {
text: "bank.com Example.com/ here".into(),
entities: vec![
text_url(0, 8, "https://evil.example"),
text_url(9, 12, "https://www.example.com"),
text_url(22, 4, "https://elsewhere.dev"),
text_url(5, -3, "https://a.b"),
text_url(30, 4, "https://c.d"),
],
};
let found: Vec<Link> = links(&text).into_iter().map(|(l, _)| l).collect();
assert_eq!(
found,
[
Link {
url: "https://evil.example".into(),
disguise: Some("bank.com".into()),
},
Link::from("https://www.example.com"),
Link {
url: "https://elsewhere.dev".into(),
disguise: Some("here".into()),
},
]
);
}
#[test]
fn a_hidden_link_keeps_its_warning_when_its_url_is_also_shown_plainly() {
let text = |entities| {
body(&MessageContent::MessageText(types::MessageText {
text: types::FormattedText {
text: "evil.example then bank.com".into(),
entities,
},
link_preview: None,
link_preview_options: None,
}))
};
let hidden = TextEntityType::TextUrl(types::TextEntityTypeTextUrl {
url: "https://evil.example".into(),
});
let both = text(vec![
entity(0, 12, TextEntityType::Url),
entity(18, 8, hidden),
]);
assert_eq!(
both.links,
[Link {
url: "https://evil.example".into(),
disguise: Some("bank.com".into()),
}]
);
}
#[test]
fn a_links_host_is_read_the_way_browsers_read_it() {
let host = |url| link_host(url);
assert_eq!(
host("https://bank.com.secure-login.evil.example/x").as_deref(),
Some("bank.com.secure-login.evil.example")
);
assert_eq!(
host("https://bank.com@evil.example/").as_deref(),
Some("evil.example")
);
assert_eq!(
host("https://evil.example\\@bank.com/").as_deref(),
Some("evil.example")
);
assert_eq!(
host("HTTPS:///Evil.Example:8443?x").as_deref(),
Some("evil.example")
);
assert_eq!(host("http://[::1]:80/").as_deref(), Some("[::1]"));
assert_eq!(host("https:///"), None);
assert_eq!(host("ftp://a.example"), None);
}
#[test]
fn urls_with_spaces_or_line_breaks_are_not_links() {
assert_eq!(web_url("https://a.example/x y"), None);
assert_eq!(web_url("a.example/\nx"), None);
assert_eq!(
web_url("a.example/x").as_deref(),
Some("https://a.example/x")
);
}
fn sticker(format: StickerFormat, thumbnail: Option<ThumbnailFormat>) -> MessageContent {
MessageContent::MessageSticker(types::MessageSticker {
sticker: types::Sticker {
id: 1,
set_id: 2,
width: 512,
height: 512,
emoji: "😀".into(),
format,
full_type: tdlib_rs::enums::StickerFullType::Regular(
types::StickerFullTypeRegular {
premium_animation: None,
},
),
thumbnail: thumbnail.map(|format| types::Thumbnail {
format,
width: 128,
height: 128,
file: types::File {
id: 7,
..Default::default()
},
}),
sticker: types::File {
id: 8,
..Default::default()
},
},
is_premium: false,
})
}
#[test]
fn stickers_show_the_image_or_a_still_thumbnail() {
let still = body(&sticker(StickerFormat::Webp, None));
let preview = still.preview.expect("WebP stickers are images");
assert_eq!((preview.file_id, preview.sticker), (8, true));
assert_eq!(still.text, "", "no caption under a sticker");
let animated = body(&sticker(StickerFormat::Tgs, Some(ThumbnailFormat::Webp)));
assert_eq!(animated.preview.map(|p| p.file_id), Some(7), "thumbnail");
let unknown = body(&sticker(StickerFormat::Tgs, Some(ThumbnailFormat::Tgs)));
assert!(unknown.preview.is_none());
assert_eq!(unknown.text, "[Sticker 😀]");
}
#[test]
fn the_sticker_panel_shows_a_thumbnail_or_the_still_sticker_itself() {
let shown = |format, thumbnail| {
let MessageContent::MessageSticker(m) = sticker(format, thumbnail) else {
unreachable!();
};
Preview::sticker_thumbnail(&m.sticker).map(|p| p.file_id)
};
let webp = Some(ThumbnailFormat::Webp);
assert_eq!(
shown(StickerFormat::Webp, webp),
Some(7),
"the smaller file"
);
assert_eq!(shown(StickerFormat::Webp, None), Some(8));
assert_eq!(shown(StickerFormat::Tgs, Some(ThumbnailFormat::Tgs)), None);
}
#[test]
fn messages_know_what_an_edit_can_change() {
let text = |entities| {
body(&MessageContent::MessageText(types::MessageText {
text: types::FormattedText {
text: "see x.dev now".into(),
entities,
},
link_preview: None,
link_preview_options: None,
}))
};
let plain = text(vec![entity(4, 5, TextEntityType::Url)]);
assert_eq!(plain.editable, Editable::Text);
assert!(!plain.formatted, "Telegram finds the link again by itself");
assert!(text(vec![entity(0, 3, TextEntityType::Bold)]).formatted);
let video = body(&video(ThumbnailFormat::Jpeg, "our trip"));
assert_eq!(video.editable, Editable::Caption { above: false });
let sticker = body(&sticker(StickerFormat::Tgs, Some(ThumbnailFormat::Tgs)));
assert_eq!(sticker.editable, Editable::No);
}
#[test]
fn only_sent_messages_with_words_can_be_edited() {
let mut open = OpenChat::new(1);
let mut msgs = page([1, 2, 3, 4]);
msgs[1].1.state = SendState::Pending;
msgs[2].1.state = SendState::Failed;
msgs[3].1.editable = Editable::No;
open.messages.extend(msgs);
assert_eq!(open.cant_edit(1), None, "TDLib decides the rest");
assert_eq!(open.cant_edit(2), Some("Wait until it's sent"));
assert_eq!(open.cant_edit(3), Some("This message wasn't sent"));
assert_eq!(open.cant_edit(4), Some("This message has no text to edit"));
open.set_edited(1);
assert!(open.messages[&1].edited);
}
#[test]
fn caption_links_line_up_after_a_label() {
let caption = types::FormattedText {
text: "see x.dev".into(),
entities: vec![entity(4, 5, TextEntityType::Url)],
};
let content = MessageContent::MessageDocument(types::MessageDocument {
document: types::Document {
file_name: "a.pdf".into(),
mime_type: "application/pdf".into(),
minithumbnail: None,
thumbnail: None,
document: types::File::default(),
},
caption,
});
let body = body(&content);
assert_eq!(body.text, "[File: a.pdf] see x.dev");
assert_eq!(body.source_text, "see x.dev", "copies only the caption");
let range = body.link_ranges[0].clone();
assert_eq!(&body.text[range], "x.dev");
}
fn text_message(text: &str, entities: Vec<types::TextEntity>) -> MessageContent {
MessageContent::MessageText(types::MessageText {
text: types::FormattedText {
text: text.into(),
entities,
},
link_preview: None,
link_preview_options: None,
})
}
fn styled(body: &Body) -> Vec<(&str, Format)> {
body.styles
.iter()
.map(|s| (&body.text[s.range.clone()], s.format))
.collect()
}
#[test]
fn nested_and_overlapping_formatting_is_cut_into_stretches() {
use TextEntityType as T;
let content = text_message(
"🎉 bold both italic code",
vec![
entity(3, 9, T::Bold),
entity(8, 11, T::Italic),
entity(20, 4, T::Code),
entity(0, 0, T::Strikethrough),
entity(50, 4, T::Underline),
],
);
let body = body(&content);
let bold = Format {
bold: true,
..Format::default()
};
let italic = Format {
italic: true,
..Format::default()
};
let code = Format {
code: true,
..Format::default()
};
let both = Format {
bold: true,
italic: true,
..Format::default()
};
assert_eq!(
styled(&body),
[
("bold ", bold),
("both", both),
(" italic", italic),
("code", code),
],
"empty and out-of-range entities are skipped"
);
assert!(body.formatted);
}
#[test]
fn caption_formatting_lines_up_after_a_label_and_expanded_tabs() {
let caption = types::FormattedText {
text: "a\tb bold".into(),
entities: vec![entity(4, 4, TextEntityType::Bold)],
};
let content = MessageContent::MessageDocument(types::MessageDocument {
document: types::Document {
file_name: "a.pdf".into(),
mime_type: "application/pdf".into(),
minithumbnail: None,
thumbnail: None,
document: types::File::default(),
},
caption,
});
let body = body(&content);
assert_eq!(body.text, "[File: a.pdf] a b bold");
assert_eq!(styled(&body)[0].0, "bold");
}
#[test]
fn spoilers_stay_hidden_everywhere_but_the_bubble() {
let content = text_message(
"the butler did it",
vec![entity(4, 6, TextEntityType::Spoiler)],
);
assert_eq!(crate::chats::content_text(&content), "the ⠿⠿⠿⠿⠿⠿ did it");
let body = body(&content);
assert_eq!(
body.text, "the butler did it",
"the bubble blots it out itself"
);
let mut msg = page([1]).remove(0).1;
(msg.text, msg.styles) = (body.text, body.styles);
assert_eq!(msg.snippet(), "the ⠿⠿⠿⠿⠿⠿ did it");
assert!(msg.hides_spoilers());
msg.revealed = true;
assert!(!msg.hides_spoilers());
assert_eq!(msg.snippet(), "the ⠿⠿⠿⠿⠿⠿ did it", "snippets never reveal");
let wide = text_message("答えは東京", vec![entity(3, 2, TextEntityType::Spoiler)]);
assert_eq!(crate::chats::content_text(&wide), "答えは⠿⠿⠿⠿");
}
#[test]
fn enter_shows_the_spoilers_of_the_whole_album() {
let mut open = OpenChat::new(1);
let mut msgs = page([1, 2, 3]);
for (_, msg) in &mut msgs[..2] {
msg.album = 7;
msg.preview = Some(Preview {
file_id: 1,
width: 10,
height: 10,
thumbnail: None,
sticker: false,
});
}
msgs[0].1.styles = vec![Styled {
range: 0..7,
format: Format {
spoiler: true,
..Format::default()
},
}];
open.messages.extend(msgs);
assert!(open.hides_spoilers(2));
assert!(!open.hides_spoilers(3));
assert!(open.reveal_spoilers(2), "from the other photo");
assert!(open.messages[&1].revealed);
assert!(!open.reveal_spoilers(2), "nothing left to show");
}
#[test]
fn a_link_preview_shows_only_for_a_link_in_the_message() {
let with_preview = |text: &str, preview_url: &str| {
let start = text.find("https://").unwrap_or(0);
let entities = if text.contains("https://") {
let length = (text.len() - start) as i32;
vec![entity(start as i32, length, TextEntityType::Url)]
} else {
Vec::new()
};
body(&MessageContent::MessageText(types::MessageText {
text: types::FormattedText {
text: text.into(),
entities,
},
link_preview: Some(types::LinkPreview {
url: preview_url.into(),
display_url: preview_url.into(),
site_name: "Site".into(),
title: "Log in".into(),
description: types::FormattedText::default(),
author: String::new(),
r#type: LinkPreviewType::Unsupported,
has_large_media: false,
show_large_media: false,
show_media_above_description: false,
skip_confirmation: false,
show_above_text: false,
instant_view_version: 0,
}),
link_preview_options: None,
}))
};
let shown = with_preview("see https://www.example.com/a", "https://example.com/a");
assert_eq!(shown.card.map(|c| c.host).as_deref(), Some("example.com"));
let elsewhere = with_preview(
"sign in: https://paypa1-login.example/x",
"https://www.paypal.com/signin",
);
assert!(
elsewhere.card.is_none(),
"it would vouch for the other link"
);
assert!(
with_preview("no link here", "https://example.com")
.card
.is_none()
);
}
fn page(ids: impl IntoIterator<Item = i64>) -> Vec<(i64, Msg)> {
ids.into_iter()
.map(|id| {
let msg = Msg {
sender: Sender::User(1),
outgoing: false,
date: 0,
text: format!("message {id}"),
source_text: format!("message {id}"),
preview: None,
file: None,
links: Vec::new(),
link_ranges: Vec::new(),
styles: Vec::new(),
revealed: false,
forwarded: None,
poll: None,
card: None,
state: SendState::Sent,
reply_to: None,
editable: Editable::Text,
formatted: false,
edited: false,
album: 0,
reactions: Vec::new(),
};
(id, msg)
})
.collect()
}
fn ids(open: &OpenChat) -> Vec<i64> {
open.messages.keys().copied().collect()
}
#[test]
fn a_busy_chat_keeps_only_the_newest_messages_while_following_them() {
let mut open = OpenChat::new(1);
open.add_page(Page::Latest, page(0..10));
for (id, msg) in page(10..MAX_FOLLOWED as i64 + 50) {
open.add_new(id, msg);
}
assert_eq!(open.messages.len(), MAX_FOLLOWED);
assert_eq!(open.newest_id(), Some(MAX_FOLLOWED as i64 + 49));
assert!(
!open.all_loaded,
"the dropped ones load again on scrolling up"
);
open.selected = open.oldest_id();
for (id, msg) in page(5000..5010) {
open.add_new(id, msg);
}
assert_eq!(open.messages.len(), MAX_FOLLOWED + 10);
for (id, msg) in page(6000..6000 + MAX_LOADED as i64) {
open.add_new(id, msg);
}
assert_eq!(open.messages.len(), MAX_LOADED, "then new ones wait");
assert!(!open.at_newest, "and load again on the way down");
assert_eq!(open.selected, open.oldest_id(), "the cursor stays put");
}
#[test]
fn what_replies_answer_is_forgotten_with_the_replies() {
let mut open = OpenChat::new(1);
open.add_page(Page::Latest, page(0..10));
open.set_replied(3, None);
open.set_replied(4, None);
for (id, msg) in page(10..MAX_FOLLOWED as i64 + 50) {
open.add_new(id, msg);
}
assert!(open.replied.is_empty(), "replies 3 and 4 were unloaded");
}
#[test]
fn jumping_to_an_old_message_loads_around_it() {
let mut open = OpenChat::new(1);
open.add_page(Page::Latest, page(90..100));
assert!(open.at_newest);
open.add_page(Page::Around(20), page(15..26));
assert_eq!(
ids(&open),
(15..26).collect::<Vec<_>>(),
"replaces the rest"
);
assert_eq!(open.selected, Some(20));
assert!(!open.at_newest);
open.move_cursor(isize::MAX);
assert_eq!(open.selected, Some(25));
open.add_page(Page::Newer(25), page(25..40));
assert!(!open.at_newest, "more came, there may be more still");
open.add_page(Page::Newer(39), page(39..40));
assert!(open.at_newest, "nothing newer: caught up");
open.move_cursor(isize::MAX);
assert_eq!(open.selected, None, "follows new messages again");
}
#[test]
fn a_jump_to_a_deleted_message_lands_next_to_it() {
let mut open = OpenChat::new(1);
open.add_page(Page::Around(20), page([17, 18, 22, 23]));
assert_eq!(open.selected, Some(18));
open.add_page(Page::Around(5), page([8, 9]));
assert_eq!(open.selected, Some(8));
}
#[test]
fn older_pages_tell_when_the_start_is_reached() {
let mut open = OpenChat::new(1);
open.add_page(Page::Latest, page(50..60));
open.add_page(Page::Older(50), page(40..50));
assert!(!open.all_loaded);
open.add_page(Page::Older(40), page([40]));
assert!(open.all_loaded);
}
#[test]
fn the_latest_page_keeps_messages_that_arrived_meanwhile() {
let mut open = OpenChat::new(1);
open.messages.extend(page([3, 4, 101]));
open.add_page(Page::Latest, page(90..100));
assert_eq!(ids(&open), (90..100).chain([101]).collect::<Vec<_>>());
}
#[test]
fn snippets_put_a_message_on_one_line_or_name_what_it_holds() {
let mut msgs = page([1]);
let msg = &mut msgs[0].1;
msg.text = "first line\n\nsecond\tline ".into();
assert_eq!(msg.snippet(), "first line second line");
msg.text = String::new();
msg.file = Some(MediaFile {
id: 3,
label: "Photo".into(),
photo: true,
});
assert_eq!(msg.snippet(), "Photo", "a photo without a caption");
}
#[test]
fn uploads_count_up_to_99_percent_until_the_message_is_sent() {
let mut open = OpenChat::new(1);
let mut msg = page([1]).remove(0).1;
msg.file = Some(MediaFile {
id: 5,
label: "File: map.pdf".into(),
photo: false,
});
let mut file = types::File {
id: 5,
size: 2000,
..Default::default()
};
file.remote.is_uploading_active = true;
file.remote.uploaded_size = 500;
open.set_upload(&file);
assert_eq!(open.upload_progress(&msg), Some(25));
file.remote.uploaded_size = 2000;
open.set_upload(&file);
assert_eq!(open.upload_progress(&msg), Some(99));
file.remote.is_uploading_active = false;
file.remote.is_uploading_completed = true;
open.set_upload(&file);
assert_eq!(open.upload_progress(&msg), None);
}
#[test]
fn e_in_an_album_edits_the_photo_with_the_caption() {
let mut open = OpenChat::new(1);
open.messages = page([1, 2, 3, 4]).into_iter().collect();
for (id, caption) in [(1, "the view"), (2, ""), (3, "")] {
let msg = open.messages.get_mut(&id).unwrap();
msg.album = 9;
msg.source_text = caption.into();
msg.preview = Some(Preview {
file_id: id as i32,
width: 10,
height: 10,
thumbnail: None,
sticker: false,
});
}
open.messages.get_mut(&4).unwrap().source_text = "after".into();
open.selected = Some(3);
assert_eq!(open.edit_target(), Some(1));
open.selected = Some(4);
assert_eq!(open.edit_target(), Some(4), "not in the album");
open.messages.get_mut(&2).unwrap().source_text = "second caption".into();
open.selected = Some(3);
assert_eq!(open.edit_target(), Some(3), "with two captions, its own");
open.messages.get_mut(&2).unwrap().source_text = String::new();
for id in [1, 2, 3] {
open.messages.get_mut(&id).unwrap().preview = None;
}
open.selected = Some(2);
assert_eq!(open.edit_target(), Some(2), "not file 1's caption");
}
#[test]
fn r_in_an_album_reacts_where_it_already_has_reactions_else_on_its_first_photo() {
let mut open = OpenChat::new(1);
open.messages = page([1, 2, 3, 4]).into_iter().collect();
for id in [1, 2, 3] {
let msg = open.messages.get_mut(&id).unwrap();
msg.album = 9;
msg.preview = Some(Preview {
file_id: id as i32,
width: 10,
height: 10,
thumbnail: None,
sticker: false,
});
}
open.selected = Some(3);
assert_eq!(open.react_target(), Some(1));
let heart = Reaction {
kind: ReactionKind::Emoji("❤".into()),
count: 1,
chosen: false,
};
open.messages.get_mut(&2).unwrap().reactions = vec![heart];
assert_eq!(open.react_target(), Some(2));
open.selected = Some(4);
assert_eq!(open.react_target(), Some(4), "not in the album");
let mine = |emoji: &str| Reaction {
kind: ReactionKind::Emoji(emoji.into()),
count: 2,
chosen: true,
};
open.messages.get_mut(&1).unwrap().reactions = vec![mine("👍")];
open.messages.get_mut(&3).unwrap().reactions = vec![
mine("🔥"),
Reaction {
kind: ReactionKind::Paid,
count: 1,
chosen: true,
},
];
assert!(open.your_reactions().is_empty(), "nothing of yours on 4");
open.selected = Some(2);
assert_eq!(
open.your_reactions(),
[
(1, ReactionKind::Emoji("👍".into())),
(3, ReactionKind::Emoji("🔥".into())),
]
);
assert_eq!(
open.your_emoji(2),
[(1, "👍".to_string()), (3, "🔥".to_string())]
);
let info = types::MessageInteractionInfo {
view_count: 0,
forward_count: 0,
reply_info: None,
reactions: None,
};
open.set_reactions(2, Some(&info));
assert!(open.messages[&2].reactions.is_empty());
}
#[test]
fn ctrl_z_takes_back_only_the_files_the_paste_attached() {
let file = |name: &str| Attachment {
path: name.into(),
name: name.into(),
size: 1,
kind: crate::attach::Kind::File,
identity: Default::default(),
};
let mut open = OpenChat::new(1);
open.attachments = vec![file("chosen.pdf"), file("a.txt"), file("b.txt")];
open.dropped = Some(Dropped {
text: "/tmp/a.txt /tmp/b.txt".into(),
count: 2,
});
assert_eq!(open.undo_drop().as_deref(), Some("/tmp/a.txt /tmp/b.txt"));
let names: Vec<&str> = open.attachments.iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, ["chosen.pdf"], "the file attached before stays");
assert_eq!(open.undo_drop(), None, "only once");
}
#[test]
fn deleting_the_message_being_replied_to_ends_the_reply() {
let mut open = OpenChat::new(1);
open.add_page(Page::Latest, page(1..5));
open.reply = Some(Replied::new(3, &open.messages[&3]));
assert_eq!(open.reply.as_ref().unwrap().snippet, "message 3");
open.remove(&[2]);
assert!(open.reply.is_some(), "another message went");
open.remove(&[3]);
assert!(open.reply.is_none());
}
#[test]
fn replied_messages_that_arent_loaded_are_asked_for_once() {
let mut open = OpenChat::new(1);
let mut messages = page(10..15);
let mut reply = |at: usize, message_id| {
messages[at].1.reply_to = Some(ReplyTo {
message_id,
quote: None,
})
};
reply(1, Some(10)); reply(2, Some(3)); reply(3, None); open.add_page(Page::Latest, messages);
assert_eq!(open.missing_replied(), [12, 13]);
assert!(open.missing_replied().is_empty(), "already asked");
open.set_replied(12, None);
assert!(matches!(open.replied[&12], Fetched::Missing));
open.remove(&[10]);
assert_eq!(open.missing_replied(), [11]);
}
#[test]
fn gd_goes_from_a_reply_to_the_message_it_answers() {
let mut open = OpenChat::new(1);
let mut messages = page(10..15);
let mut reply = |at: usize, message_id| {
messages[at].1.reply_to = Some(ReplyTo {
message_id,
quote: None,
})
};
reply(1, Some(10));
reply(2, Some(3)); reply(3, None); reply(4, Some(4)); open.add_page(Page::Latest, messages);
open.replied.insert(14, Fetched::Missing);
let from = |open: &mut OpenChat, id| {
open.selected = Some(id);
open.replied_jump()
};
assert_eq!(from(&mut open, 11), Ok((11, 10)));
assert_eq!(from(&mut open, 12), Ok((12, 3)), "loads around it");
assert_eq!(
from(&mut open, 13),
Err("It answers a message in another chat")
);
assert_eq!(from(&mut open, 10), Err("Not a reply"));
open.selected = None;
assert_eq!(
open.replied_jump(),
Err("The message it answers was deleted"),
"no selection means the newest"
);
}
#[test]
fn durations_read_like_a_clock() {
assert_eq!(duration(5), "0:05");
assert_eq!(duration(65), "1:05");
assert_eq!(duration(3725), "1:02:05");
}
}