deltachat/
summary.rs

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