deltachat/
summary.rs

1//! # Message summary for chatlist.
2
3use std::borrow::Cow;
4use std::fmt;
5use std::str;
6
7use crate::chat::Chat;
8use crate::constants::Chattype;
9use crate::contact::{Contact, ContactId};
10use crate::context::Context;
11use crate::message::{Message, MessageState, Viewtype};
12use crate::mimeparser::SystemMessage;
13use crate::param::Param;
14use crate::stock_str;
15use crate::stock_str::msg_reacted;
16use crate::tools::truncate;
17use anyhow::Result;
18
19/// Prefix displayed before message and separated by ":" in the chatlist.
20#[derive(Debug)]
21pub enum SummaryPrefix {
22    /// Username.
23    Username(String),
24
25    /// Stock string saying "Draft".
26    Draft(String),
27
28    /// Stock string saying "Me".
29    Me(String),
30}
31
32impl fmt::Display for SummaryPrefix {
33    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
34        match self {
35            SummaryPrefix::Username(username) => write!(f, "{username}"),
36            SummaryPrefix::Draft(text) => write!(f, "{text}"),
37            SummaryPrefix::Me(text) => write!(f, "{text}"),
38        }
39    }
40}
41
42/// Message summary.
43#[derive(Debug, Default)]
44pub struct Summary {
45    /// Part displayed before ":", such as an username or a string "Draft".
46    pub prefix: Option<SummaryPrefix>,
47
48    /// Summary text, always present.
49    pub text: String,
50
51    /// Message timestamp.
52    pub timestamp: i64,
53
54    /// Message state.
55    pub state: MessageState,
56
57    /// Message preview image path
58    pub thumbnail_path: Option<String>,
59}
60
61impl Summary {
62    /// Constructs chatlist summary
63    /// from the provided message, chat and message author contact snapshots.
64    pub async fn new_with_reaction_details(
65        context: &Context,
66        msg: &Message,
67        chat: &Chat,
68        contact: Option<&Contact>,
69    ) -> Result<Summary> {
70        if let Some((reaction_msg, reaction_contact_id, reaction)) = chat
71            .get_last_reaction_if_newer_than(context, msg.timestamp_sort)
72            .await?
73        {
74            // there is a reaction newer than the latest message, show that.
75            // sorting and therefore date is still the one of the last message,
76            // the reaction is is more sth. that overlays temporarily.
77            let summary = reaction_msg.get_summary_text_without_prefix(context).await;
78            return Ok(Summary {
79                prefix: None,
80                text: msg_reacted(context, reaction_contact_id, &reaction, &summary).await,
81                timestamp: msg.get_timestamp(), // message timestamp (not reaction) to make timestamps more consistent with chats ordering
82                state: msg.state, // message state (not reaction) - indicating if it was me sending the last message
83                thumbnail_path: None,
84            });
85        }
86        Self::new(context, msg, chat, contact).await
87    }
88
89    /// Constructs search result summary
90    /// from the provided message, chat and message author contact snapshots.
91    pub async fn new(
92        context: &Context,
93        msg: &Message,
94        chat: &Chat,
95        contact: Option<&Contact>,
96    ) -> Result<Summary> {
97        let prefix = if msg.state == MessageState::OutDraft {
98            Some(SummaryPrefix::Draft(stock_str::draft(context).await))
99        } else if msg.from_id == ContactId::SELF {
100            if msg.is_info() || msg.viewtype == Viewtype::Call {
101                None
102            } else {
103                Some(SummaryPrefix::Me(stock_str::self_msg(context).await))
104            }
105        } else if chat.typ == Chattype::Group
106            || chat.typ == Chattype::OutBroadcast
107            || chat.typ == Chattype::InBroadcast
108            || chat.typ == Chattype::Mailinglist
109            || chat.is_self_talk()
110        {
111            if msg.is_info() || contact.is_none() {
112                None
113            } else {
114                msg.get_override_sender_name()
115                    .or_else(|| contact.map(|contact| msg.get_sender_name(contact)))
116                    .map(SummaryPrefix::Username)
117            }
118        } else {
119            None
120        };
121
122        let mut text = msg.get_summary_text(context).await;
123
124        if text.is_empty() && msg.quoted_text().is_some() {
125            text = stock_str::reply_noun(context).await
126        }
127
128        let thumbnail_path = if msg.viewtype == Viewtype::Image
129            || msg.viewtype == Viewtype::Gif
130            || msg.viewtype == Viewtype::Sticker
131        {
132            msg.get_file(context)
133                .and_then(|path| path.to_str().map(|p| p.to_owned()))
134        } else if msg.viewtype == Viewtype::Webxdc {
135            Some("webxdc-icon://last-msg-id".to_string())
136        } else {
137            None
138        };
139
140        Ok(Summary {
141            prefix,
142            text,
143            timestamp: msg.get_timestamp(),
144            state: msg.state,
145            thumbnail_path,
146        })
147    }
148
149    /// Returns the [`Summary::text`] attribute truncated to an approximate length.
150    pub fn truncated_text(&self, approx_chars: usize) -> Cow<'_, str> {
151        truncate(&self.text, approx_chars)
152    }
153}
154
155impl Message {
156    /// Returns a summary text.
157    pub(crate) async fn get_summary_text(&self, context: &Context) -> String {
158        let summary = self.get_summary_text_without_prefix(context).await;
159
160        if self.is_forwarded() {
161            format!("{}: {}", stock_str::forwarded(context).await, summary)
162        } else {
163            summary
164        }
165    }
166
167    /// Returns a summary text without "Forwarded:" prefix.
168    async fn get_summary_text_without_prefix(&self, context: &Context) -> String {
169        let (emoji, type_name, type_file, append_text);
170        match self.viewtype {
171            Viewtype::Image => {
172                emoji = Some("📷");
173                type_name = Some(stock_str::image(context).await);
174                type_file = None;
175                append_text = true;
176            }
177            Viewtype::Gif => {
178                emoji = None;
179                type_name = Some(stock_str::gif(context).await);
180                type_file = None;
181                append_text = true;
182            }
183            Viewtype::Sticker => {
184                emoji = None;
185                type_name = Some(stock_str::sticker(context).await);
186                type_file = None;
187                append_text = true;
188            }
189            Viewtype::Video => {
190                emoji = Some("🎥");
191                type_name = Some(stock_str::video(context).await);
192                type_file = None;
193                append_text = true;
194            }
195            Viewtype::Voice => {
196                emoji = Some("🎤");
197                type_name = Some(stock_str::voice_message(context).await);
198                type_file = None;
199                append_text = true;
200            }
201            Viewtype::Audio => {
202                emoji = Some("🎵");
203                type_name = Some(stock_str::audio(context).await);
204                type_file = self.get_filename();
205                append_text = true
206            }
207            Viewtype::File => {
208                emoji = Some("📎");
209                type_name = Some(stock_str::file(context).await);
210                type_file = self.get_filename();
211                append_text = true
212            }
213            Viewtype::VideochatInvitation => {
214                emoji = None;
215                type_name = Some(stock_str::videochat_invitation(context).await);
216                type_file = None;
217                append_text = false;
218            }
219            Viewtype::Webxdc => {
220                emoji = None;
221                type_name = None;
222                type_file = Some(
223                    self.get_webxdc_info(context)
224                        .await
225                        .map(|info| info.name)
226                        .unwrap_or_else(|_| "ErrWebxdcName".to_string()),
227                );
228                append_text = true;
229            }
230            Viewtype::Vcard => {
231                emoji = Some("👤");
232                type_name = None;
233                type_file = self.param.get(Param::Summary1).map(|s| s.to_string());
234                append_text = true;
235            }
236            Viewtype::Call => {
237                emoji = Some("📞");
238                type_name = Some(if self.from_id == ContactId::SELF {
239                    "Outgoing call".to_string()
240                } else {
241                    "Incoming call".to_string()
242                });
243                type_file = None;
244                append_text = false
245            }
246            Viewtype::Text | Viewtype::Unknown => {
247                emoji = None;
248                if self.param.get_cmd() == SystemMessage::LocationOnly {
249                    type_name = Some(stock_str::location(context).await);
250                    type_file = None;
251                    append_text = false;
252                } else {
253                    type_name = None;
254                    type_file = None;
255                    append_text = true;
256                }
257            }
258        };
259
260        let text = self.text.clone();
261
262        let summary = if let Some(type_file) = type_file {
263            if append_text && !text.is_empty() {
264                format!("{type_file} – {text}")
265            } else {
266                type_file
267            }
268        } else if append_text && !text.is_empty() {
269            if emoji.is_some() {
270                text
271            } else if let Some(type_name) = type_name {
272                format!("{type_name} – {text}")
273            } else {
274                text
275            }
276        } else if let Some(type_name) = type_name {
277            type_name
278        } else {
279            "".to_string()
280        };
281
282        let summary = if let Some(emoji) = emoji {
283            format!("{emoji} {summary}")
284        } else {
285            summary
286        };
287
288        summary.split_whitespace().collect::<Vec<&str>>().join(" ")
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use std::path::PathBuf;
295
296    use super::*;
297    use crate::chat::ChatId;
298    use crate::param::Param;
299    use crate::test_utils::TestContext;
300
301    async fn assert_summary_texts(msg: &Message, ctx: &Context, expected: &str) {
302        assert_eq!(msg.get_summary_text(ctx).await, expected);
303        assert_eq!(msg.get_summary_text_without_prefix(ctx).await, expected);
304    }
305
306    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
307    async fn test_get_summary_text() {
308        let d = TestContext::new_alice().await;
309        let ctx = &d.ctx;
310        let chat_id = ChatId::create_for_contact(ctx, ContactId::SELF)
311            .await
312            .unwrap();
313        let some_text = " bla \t\n\tbla\n\t".to_string();
314
315        async fn write_file_to_blobdir(d: &TestContext) -> PathBuf {
316            let bytes = &[38, 209, 39, 29]; // Just some random bytes
317            let file = d.get_blobdir().join("random_filename_392438");
318            tokio::fs::write(&file, bytes).await.unwrap();
319            file
320        }
321
322        let msg = Message::new_text(some_text.to_string());
323        assert_summary_texts(&msg, ctx, "bla bla").await; // for simple text, the type is not added to the summary
324
325        let file = write_file_to_blobdir(&d).await;
326        let mut msg = Message::new(Viewtype::Image);
327        msg.set_file_and_deduplicate(&d, &file, Some("foo.jpg"), None)
328            .unwrap();
329        assert_summary_texts(&msg, ctx, "📷 Image").await; // file names are not added for images
330
331        let file = write_file_to_blobdir(&d).await;
332        let mut msg = Message::new(Viewtype::Image);
333        msg.set_text(some_text.to_string());
334        msg.set_file_and_deduplicate(&d, &file, Some("foo.jpg"), None)
335            .unwrap();
336        assert_summary_texts(&msg, ctx, "📷 bla bla").await; // type is visible by emoji if text is set
337
338        let file = write_file_to_blobdir(&d).await;
339        let mut msg = Message::new(Viewtype::Video);
340        msg.set_file_and_deduplicate(&d, &file, Some("foo.mp4"), None)
341            .unwrap();
342        assert_summary_texts(&msg, ctx, "🎥 Video").await; // file names are not added for videos
343
344        let file = write_file_to_blobdir(&d).await;
345        let mut msg = Message::new(Viewtype::Video);
346        msg.set_text(some_text.to_string());
347        msg.set_file_and_deduplicate(&d, &file, Some("foo.mp4"), None)
348            .unwrap();
349        assert_summary_texts(&msg, ctx, "🎥 bla bla").await; // type is visible by emoji if text is set
350
351        let file = write_file_to_blobdir(&d).await;
352        let mut msg = Message::new(Viewtype::Gif);
353        msg.set_file_and_deduplicate(&d, &file, Some("foo.gif"), None)
354            .unwrap();
355        assert_summary_texts(&msg, ctx, "GIF").await; // file names are not added for GIFs
356
357        let file = write_file_to_blobdir(&d).await;
358        let mut msg = Message::new(Viewtype::Gif);
359        msg.set_text(some_text.to_string());
360        msg.set_file_and_deduplicate(&d, &file, Some("foo.gif"), None)
361            .unwrap();
362        assert_summary_texts(&msg, ctx, "GIF \u{2013} bla bla").await; // file names are not added for GIFs
363
364        let file = write_file_to_blobdir(&d).await;
365        let mut msg = Message::new(Viewtype::Sticker);
366        msg.set_file_and_deduplicate(&d, &file, Some("foo.png"), None)
367            .unwrap();
368        assert_summary_texts(&msg, ctx, "Sticker").await; // file names are not added for stickers
369
370        let file = write_file_to_blobdir(&d).await;
371        let mut msg = Message::new(Viewtype::Voice);
372        msg.set_file_and_deduplicate(&d, &file, Some("foo.mp3"), None)
373            .unwrap();
374        assert_summary_texts(&msg, ctx, "🎤 Voice message").await; // file names are not added for voice messages
375
376        let file = write_file_to_blobdir(&d).await;
377        let mut msg = Message::new(Viewtype::Voice);
378        msg.set_text(some_text.clone());
379        msg.set_file_and_deduplicate(&d, &file, Some("foo.mp3"), None)
380            .unwrap();
381        assert_summary_texts(&msg, ctx, "🎤 bla bla").await;
382
383        let file = write_file_to_blobdir(&d).await;
384        let mut msg = Message::new(Viewtype::Audio);
385        msg.set_file_and_deduplicate(&d, &file, Some("foo.mp3"), None)
386            .unwrap();
387        assert_summary_texts(&msg, ctx, "🎵 foo.mp3").await; // file name is added for audio
388
389        let file = write_file_to_blobdir(&d).await;
390        let mut msg = Message::new(Viewtype::Audio);
391        msg.set_text(some_text.clone());
392        msg.set_file_and_deduplicate(&d, &file, Some("foo.mp3"), None)
393            .unwrap();
394        assert_summary_texts(&msg, ctx, "🎵 foo.mp3 \u{2013} bla bla").await; // file name and text added for audio
395
396        let mut msg = Message::new(Viewtype::File);
397        let bytes = include_bytes!("../test-data/webxdc/with-minimal-manifest.xdc");
398        msg.set_file_from_bytes(ctx, "foo.xdc", bytes, None)
399            .unwrap();
400        chat_id.set_draft(ctx, Some(&mut msg)).await.unwrap();
401        assert_eq!(msg.viewtype, Viewtype::Webxdc);
402        assert_summary_texts(&msg, ctx, "nice app!").await;
403        msg.set_text(some_text.clone());
404        chat_id.set_draft(ctx, Some(&mut msg)).await.unwrap();
405        assert_summary_texts(&msg, ctx, "nice app! \u{2013} bla bla").await;
406
407        let file = write_file_to_blobdir(&d).await;
408        let mut msg = Message::new(Viewtype::File);
409        msg.set_file_and_deduplicate(&d, &file, Some("foo.bar"), None)
410            .unwrap();
411        assert_summary_texts(&msg, ctx, "📎 foo.bar").await; // file name is added for files
412
413        let file = write_file_to_blobdir(&d).await;
414        let mut msg = Message::new(Viewtype::File);
415        msg.set_text(some_text.clone());
416        msg.set_file_and_deduplicate(&d, &file, Some("foo.bar"), None)
417            .unwrap();
418        assert_summary_texts(&msg, ctx, "📎 foo.bar \u{2013} bla bla").await; // file name is added for files
419
420        let file = write_file_to_blobdir(&d).await;
421        let mut msg = Message::new(Viewtype::VideochatInvitation);
422        msg.set_text(some_text.clone());
423        msg.set_file_and_deduplicate(&d, &file, Some("foo.bar"), None)
424            .unwrap();
425        assert_summary_texts(&msg, ctx, "Video chat invitation").await; // text is not added for videochat invitations
426
427        let mut msg = Message::new(Viewtype::Vcard);
428        msg.set_file_from_bytes(ctx, "foo.vcf", b"", None).unwrap();
429        chat_id.set_draft(ctx, Some(&mut msg)).await.unwrap();
430        // If a vCard can't be parsed, the message becomes `Viewtype::File`.
431        assert_eq!(msg.viewtype, Viewtype::File);
432        assert_summary_texts(&msg, ctx, "📎 foo.vcf").await;
433        msg.set_text(some_text.clone());
434        assert_summary_texts(&msg, ctx, "📎 foo.vcf \u{2013} bla bla").await;
435
436        for vt in [Viewtype::Vcard, Viewtype::File] {
437            let mut msg = Message::new(vt);
438            msg.set_file_from_bytes(
439                ctx,
440                "alice.vcf",
441                b"BEGIN:VCARD\n\
442                  VERSION:4.0\n\
443                  FN:Alice Wonderland\n\
444                  EMAIL;TYPE=work:alice@example.org\n\
445                  END:VCARD",
446                None,
447            )
448            .unwrap();
449            chat_id.set_draft(ctx, Some(&mut msg)).await.unwrap();
450            assert_eq!(msg.viewtype, Viewtype::Vcard);
451            assert_summary_texts(&msg, ctx, "👤 Alice Wonderland").await;
452        }
453
454        // Forwarded
455        let mut msg = Message::new_text(some_text.clone());
456        msg.param.set_int(Param::Forwarded, 1);
457        assert_eq!(msg.get_summary_text(ctx).await, "Forwarded: bla bla"); // for simple text, the type is not added to the summary
458        assert_eq!(msg.get_summary_text_without_prefix(ctx).await, "bla bla"); // skipping prefix used for reactions summaries
459
460        let file = write_file_to_blobdir(&d).await;
461        let mut msg = Message::new(Viewtype::File);
462        msg.set_text(some_text.clone());
463        msg.set_file_and_deduplicate(&d, &file, Some("foo.bar"), None)
464            .unwrap();
465        msg.param.set_int(Param::Forwarded, 1);
466        assert_eq!(
467            msg.get_summary_text(ctx).await,
468            "Forwarded: 📎 foo.bar \u{2013} bla bla"
469        );
470        assert_eq!(
471            msg.get_summary_text_without_prefix(ctx).await,
472            "📎 foo.bar \u{2013} bla bla"
473        ); // skipping prefix used for reactions summaries
474
475        let mut msg = Message::new(Viewtype::File);
476        msg.set_file_from_bytes(ctx, "autocrypt-setup-message.html", b"data", None)
477            .unwrap();
478        msg.param.set_cmd(SystemMessage::AutocryptSetupMessage);
479        assert_summary_texts(&msg, ctx, "📎 autocrypt-setup-message.html").await;
480        // no special handling of ASM
481    }
482}