mirror of
https://github.com/chatmail/core.git
synced 2026-04-17 21:46:35 +03:00
430 lines
16 KiB
Rust
430 lines
16 KiB
Rust
//! # Message summary for chatlist.
|
||
|
||
use std::borrow::Cow;
|
||
use std::fmt;
|
||
use std::str;
|
||
|
||
use crate::chat::Chat;
|
||
use crate::constants::Chattype;
|
||
use crate::contact::{Contact, ContactId};
|
||
use crate::context::Context;
|
||
use crate::message::{Message, MessageState, Viewtype};
|
||
use crate::mimeparser::SystemMessage;
|
||
use crate::param::Param;
|
||
use crate::stock_str;
|
||
use crate::stock_str::msg_reacted;
|
||
use crate::tools::truncate;
|
||
use anyhow::Result;
|
||
|
||
/// Prefix displayed before message and separated by ":" in the chatlist.
|
||
#[derive(Debug)]
|
||
pub enum SummaryPrefix {
|
||
/// Username.
|
||
Username(String),
|
||
|
||
/// Stock string saying "Draft".
|
||
Draft(String),
|
||
|
||
/// Stock string saying "Me".
|
||
Me(String),
|
||
}
|
||
|
||
impl fmt::Display for SummaryPrefix {
|
||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||
match self {
|
||
SummaryPrefix::Username(username) => write!(f, "{username}"),
|
||
SummaryPrefix::Draft(text) => write!(f, "{text}"),
|
||
SummaryPrefix::Me(text) => write!(f, "{text}"),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Message summary.
|
||
#[derive(Debug, Default)]
|
||
pub struct Summary {
|
||
/// Part displayed before ":", such as an username or a string "Draft".
|
||
pub prefix: Option<SummaryPrefix>,
|
||
|
||
/// Summary text, always present.
|
||
pub text: String,
|
||
|
||
/// Message timestamp.
|
||
pub timestamp: i64,
|
||
|
||
/// Message state.
|
||
pub state: MessageState,
|
||
|
||
/// Message preview image path
|
||
pub thumbnail_path: Option<String>,
|
||
}
|
||
|
||
impl Summary {
|
||
/// Constructs chatlist summary
|
||
/// from the provided message, chat and message author contact snapshots.
|
||
pub async fn new_with_reaction_details(
|
||
context: &Context,
|
||
msg: &Message,
|
||
chat: &Chat,
|
||
contact: Option<&Contact>,
|
||
) -> Result<Summary> {
|
||
if let Some((reaction_msg, reaction_contact_id, reaction)) = chat
|
||
.get_last_reaction_if_newer_than(context, msg.timestamp_sort)
|
||
.await?
|
||
{
|
||
// there is a reaction newer than the latest message, show that.
|
||
// sorting and therefore date is still the one of the last message,
|
||
// the reaction is is more sth. that overlays temporarily.
|
||
let summary = reaction_msg.get_summary_text_without_prefix(context).await;
|
||
return Ok(Summary {
|
||
prefix: None,
|
||
text: msg_reacted(context, reaction_contact_id, &reaction, &summary).await,
|
||
timestamp: msg.get_timestamp(), // message timestamp (not reaction) to make timestamps more consistent with chats ordering
|
||
state: msg.state, // message state (not reaction) - indicating if it was me sending the last message
|
||
thumbnail_path: None,
|
||
});
|
||
}
|
||
Self::new(context, msg, chat, contact).await
|
||
}
|
||
|
||
/// Constructs search result summary
|
||
/// from the provided message, chat and message author contact snapshots.
|
||
pub async fn new(
|
||
context: &Context,
|
||
msg: &Message,
|
||
chat: &Chat,
|
||
contact: Option<&Contact>,
|
||
) -> Result<Summary> {
|
||
let prefix = if msg.state == MessageState::OutDraft {
|
||
Some(SummaryPrefix::Draft(stock_str::draft(context).await))
|
||
} else if msg.from_id == ContactId::SELF {
|
||
if msg.is_info() || chat.is_self_talk() {
|
||
None
|
||
} else {
|
||
Some(SummaryPrefix::Me(stock_str::self_msg(context).await))
|
||
}
|
||
} else {
|
||
match chat.typ {
|
||
Chattype::Group | Chattype::Broadcast | Chattype::Mailinglist => {
|
||
if msg.is_info() || contact.is_none() {
|
||
None
|
||
} else {
|
||
msg.get_override_sender_name()
|
||
.or_else(|| contact.map(|contact| msg.get_sender_name(contact)))
|
||
.map(SummaryPrefix::Username)
|
||
}
|
||
}
|
||
Chattype::Single => None,
|
||
}
|
||
};
|
||
|
||
let mut text = msg.get_summary_text(context).await;
|
||
|
||
if text.is_empty() && msg.quoted_text().is_some() {
|
||
text = stock_str::reply_noun(context).await
|
||
}
|
||
|
||
let thumbnail_path = if msg.viewtype == Viewtype::Image
|
||
|| msg.viewtype == Viewtype::Gif
|
||
|| msg.viewtype == Viewtype::Sticker
|
||
{
|
||
msg.get_file(context)
|
||
.and_then(|path| path.to_str().map(|p| p.to_owned()))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
Ok(Summary {
|
||
prefix,
|
||
text,
|
||
timestamp: msg.get_timestamp(),
|
||
state: msg.state,
|
||
thumbnail_path,
|
||
})
|
||
}
|
||
|
||
/// Returns the [`Summary::text`] attribute truncated to an approximate length.
|
||
pub fn truncated_text(&self, approx_chars: usize) -> Cow<str> {
|
||
truncate(&self.text, approx_chars)
|
||
}
|
||
}
|
||
|
||
impl Message {
|
||
/// Returns a summary text.
|
||
pub(crate) async fn get_summary_text(&self, context: &Context) -> String {
|
||
let summary = self.get_summary_text_without_prefix(context).await;
|
||
|
||
if self.is_forwarded() {
|
||
format!("{}: {}", stock_str::forwarded(context).await, summary)
|
||
} else {
|
||
summary
|
||
}
|
||
}
|
||
|
||
/// Returns a summary text without "Forwarded:" prefix.
|
||
async fn get_summary_text_without_prefix(&self, context: &Context) -> String {
|
||
let (emoji, type_name, type_file, append_text);
|
||
match self.viewtype {
|
||
Viewtype::Image => {
|
||
emoji = Some("📷");
|
||
type_name = Some(stock_str::image(context).await);
|
||
type_file = None;
|
||
append_text = true;
|
||
}
|
||
Viewtype::Gif => {
|
||
emoji = None;
|
||
type_name = Some(stock_str::gif(context).await);
|
||
type_file = None;
|
||
append_text = true;
|
||
}
|
||
Viewtype::Sticker => {
|
||
emoji = None;
|
||
type_name = Some(stock_str::sticker(context).await);
|
||
type_file = None;
|
||
append_text = true;
|
||
}
|
||
Viewtype::Video => {
|
||
emoji = Some("🎥");
|
||
type_name = Some(stock_str::video(context).await);
|
||
type_file = None;
|
||
append_text = true;
|
||
}
|
||
Viewtype::Voice => {
|
||
emoji = Some("🎤");
|
||
type_name = Some(stock_str::voice_message(context).await);
|
||
type_file = None;
|
||
append_text = true;
|
||
}
|
||
Viewtype::Audio => {
|
||
emoji = Some("🎵");
|
||
type_name = Some(stock_str::audio(context).await);
|
||
type_file = self.get_filename();
|
||
append_text = true
|
||
}
|
||
Viewtype::File => {
|
||
if self.param.get_cmd() == SystemMessage::AutocryptSetupMessage {
|
||
emoji = None;
|
||
type_name = Some(stock_str::ac_setup_msg_subject(context).await);
|
||
type_file = None;
|
||
append_text = false;
|
||
} else {
|
||
emoji = Some("📎");
|
||
type_name = Some(stock_str::file(context).await);
|
||
type_file = self.get_filename();
|
||
append_text = true
|
||
}
|
||
}
|
||
Viewtype::VideochatInvitation => {
|
||
emoji = None;
|
||
type_name = Some(stock_str::videochat_invitation(context).await);
|
||
type_file = None;
|
||
append_text = false;
|
||
}
|
||
Viewtype::Webxdc => {
|
||
emoji = None;
|
||
type_name = None;
|
||
type_file = Some(
|
||
self.get_webxdc_info(context)
|
||
.await
|
||
.map(|info| info.name)
|
||
.unwrap_or_else(|_| "ErrWebxdcName".to_string()),
|
||
);
|
||
append_text = true;
|
||
}
|
||
Viewtype::Vcard => {
|
||
emoji = Some("👤");
|
||
type_name = None;
|
||
type_file = self.param.get(Param::Summary1).map(|s| s.to_string());
|
||
append_text = true;
|
||
}
|
||
Viewtype::Text | Viewtype::Unknown => {
|
||
emoji = None;
|
||
if self.param.get_cmd() == SystemMessage::LocationOnly {
|
||
type_name = Some(stock_str::location(context).await);
|
||
type_file = None;
|
||
append_text = false;
|
||
} else {
|
||
type_name = None;
|
||
type_file = None;
|
||
append_text = true;
|
||
}
|
||
}
|
||
};
|
||
|
||
let text = self.text.clone();
|
||
|
||
let summary = if let Some(type_file) = type_file {
|
||
if append_text && !text.is_empty() {
|
||
format!("{type_file} – {text}")
|
||
} else {
|
||
type_file
|
||
}
|
||
} else if append_text && !text.is_empty() {
|
||
if emoji.is_some() {
|
||
text
|
||
} else if let Some(type_name) = type_name {
|
||
format!("{type_name} – {text}")
|
||
} else {
|
||
text
|
||
}
|
||
} else if let Some(type_name) = type_name {
|
||
type_name
|
||
} else {
|
||
"".to_string()
|
||
};
|
||
|
||
let summary = if let Some(emoji) = emoji {
|
||
format!("{emoji} {summary}")
|
||
} else {
|
||
summary
|
||
};
|
||
|
||
summary.split_whitespace().collect::<Vec<&str>>().join(" ")
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::chat::ChatId;
|
||
use crate::param::Param;
|
||
use crate::test_utils as test;
|
||
|
||
async fn assert_summary_texts(msg: &Message, ctx: &Context, expected: &str) {
|
||
assert_eq!(msg.get_summary_text(ctx).await, expected);
|
||
assert_eq!(msg.get_summary_text_without_prefix(ctx).await, expected);
|
||
}
|
||
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||
async fn test_get_summary_text() {
|
||
let d = test::TestContext::new().await;
|
||
let ctx = &d.ctx;
|
||
let chat_id = ChatId::create_for_contact(ctx, ContactId::SELF)
|
||
.await
|
||
.unwrap();
|
||
let some_text = " bla \t\n\tbla\n\t".to_string();
|
||
|
||
let mut msg = Message::new(Viewtype::Text);
|
||
msg.set_text(some_text.to_string());
|
||
assert_summary_texts(&msg, ctx, "bla bla").await; // for simple text, the type is not added to the summary
|
||
|
||
let mut msg = Message::new(Viewtype::Image);
|
||
msg.set_file("foo.jpg", None);
|
||
assert_summary_texts(&msg, ctx, "📷 Image").await; // file names are not added for images
|
||
|
||
let mut msg = Message::new(Viewtype::Image);
|
||
msg.set_text(some_text.to_string());
|
||
msg.set_file("foo.jpg", None);
|
||
assert_summary_texts(&msg, ctx, "📷 bla bla").await; // type is visible by emoji if text is set
|
||
|
||
let mut msg = Message::new(Viewtype::Video);
|
||
msg.set_file("foo.mp4", None);
|
||
assert_summary_texts(&msg, ctx, "🎥 Video").await; // file names are not added for videos
|
||
|
||
let mut msg = Message::new(Viewtype::Video);
|
||
msg.set_text(some_text.to_string());
|
||
msg.set_file("foo.mp4", None);
|
||
assert_summary_texts(&msg, ctx, "🎥 bla bla").await; // type is visible by emoji if text is set
|
||
|
||
let mut msg = Message::new(Viewtype::Gif);
|
||
msg.set_file("foo.gif", None);
|
||
assert_summary_texts(&msg, ctx, "GIF").await; // file names are not added for GIFs
|
||
|
||
let mut msg = Message::new(Viewtype::Gif);
|
||
msg.set_text(some_text.to_string());
|
||
msg.set_file("foo.gif", None);
|
||
assert_summary_texts(&msg, ctx, "GIF \u{2013} bla bla").await; // file names are not added for GIFs
|
||
|
||
let mut msg = Message::new(Viewtype::Sticker);
|
||
msg.set_file("foo.png", None);
|
||
assert_summary_texts(&msg, ctx, "Sticker").await; // file names are not added for stickers
|
||
|
||
let mut msg = Message::new(Viewtype::Voice);
|
||
msg.set_file("foo.mp3", None);
|
||
assert_summary_texts(&msg, ctx, "🎤 Voice message").await; // file names are not added for voice messages
|
||
|
||
let mut msg = Message::new(Viewtype::Voice);
|
||
msg.set_text(some_text.clone());
|
||
msg.set_file("foo.mp3", None);
|
||
assert_summary_texts(&msg, ctx, "🎤 bla bla").await;
|
||
|
||
let mut msg = Message::new(Viewtype::Audio);
|
||
msg.set_file("foo.mp3", None);
|
||
assert_summary_texts(&msg, ctx, "🎵 foo.mp3").await; // file name is added for audio
|
||
|
||
let mut msg = Message::new(Viewtype::Audio);
|
||
msg.set_text(some_text.clone());
|
||
msg.set_file("foo.mp3", None);
|
||
assert_summary_texts(&msg, ctx, "🎵 foo.mp3 \u{2013} bla bla").await; // file name and text added for audio
|
||
|
||
let mut msg = Message::new(Viewtype::File);
|
||
msg.set_file("foo.bar", None);
|
||
assert_summary_texts(&msg, ctx, "📎 foo.bar").await; // file name is added for files
|
||
|
||
let mut msg = Message::new(Viewtype::File);
|
||
msg.set_text(some_text.clone());
|
||
msg.set_file("foo.bar", None);
|
||
assert_summary_texts(&msg, ctx, "📎 foo.bar \u{2013} bla bla").await; // file name is added for files
|
||
|
||
let mut msg = Message::new(Viewtype::VideochatInvitation);
|
||
msg.set_text(some_text.clone());
|
||
msg.set_file("foo.bar", None);
|
||
assert_summary_texts(&msg, ctx, "Video chat invitation").await; // text is not added for videochat invitations
|
||
|
||
let mut msg = Message::new(Viewtype::Vcard);
|
||
msg.set_file_from_bytes(ctx, "foo.vcf", b"", None)
|
||
.await
|
||
.unwrap();
|
||
chat_id.set_draft(ctx, Some(&mut msg)).await.unwrap();
|
||
// If a vCard can't be parsed, the message becomes `Viewtype::File`.
|
||
assert_eq!(msg.viewtype, Viewtype::File);
|
||
assert_summary_texts(&msg, ctx, "📎 foo.vcf").await;
|
||
msg.set_text(some_text.clone());
|
||
assert_summary_texts(&msg, ctx, "📎 foo.vcf \u{2013} bla bla").await;
|
||
|
||
for vt in [Viewtype::Vcard, Viewtype::File] {
|
||
let mut msg = Message::new(vt);
|
||
msg.set_file_from_bytes(
|
||
ctx,
|
||
"alice.vcf",
|
||
b"BEGIN:VCARD\n\
|
||
VERSION:4.0\n\
|
||
FN:Alice Wonderland\n\
|
||
EMAIL;TYPE=work:alice@example.org\n\
|
||
END:VCARD",
|
||
None,
|
||
)
|
||
.await
|
||
.unwrap();
|
||
chat_id.set_draft(ctx, Some(&mut msg)).await.unwrap();
|
||
assert_eq!(msg.viewtype, Viewtype::Vcard);
|
||
assert_summary_texts(&msg, ctx, "👤 Alice Wonderland").await;
|
||
}
|
||
|
||
// Forwarded
|
||
let mut msg = Message::new(Viewtype::Text);
|
||
msg.set_text(some_text.clone());
|
||
msg.param.set_int(Param::Forwarded, 1);
|
||
assert_eq!(msg.get_summary_text(ctx).await, "Forwarded: bla bla"); // for simple text, the type is not added to the summary
|
||
assert_eq!(msg.get_summary_text_without_prefix(ctx).await, "bla bla"); // skipping prefix used for reactions summaries
|
||
|
||
let mut msg = Message::new(Viewtype::File);
|
||
msg.set_text(some_text.clone());
|
||
msg.set_file("foo.bar", None);
|
||
msg.param.set_int(Param::Forwarded, 1);
|
||
assert_eq!(
|
||
msg.get_summary_text(ctx).await,
|
||
"Forwarded: 📎 foo.bar \u{2013} bla bla"
|
||
);
|
||
assert_eq!(
|
||
msg.get_summary_text_without_prefix(ctx).await,
|
||
"📎 foo.bar \u{2013} bla bla"
|
||
); // skipping prefix used for reactions summaries
|
||
|
||
let mut msg = Message::new(Viewtype::File);
|
||
msg.set_text(some_text.clone());
|
||
msg.param.set(Param::File, "foo.bar");
|
||
msg.param.set_cmd(SystemMessage::AutocryptSetupMessage);
|
||
assert_summary_texts(&msg, ctx, "Autocrypt Setup Message").await; // file name is not added for autocrypt setup messages
|
||
}
|
||
}
|