deltachat/
summary.rs

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