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 || chat.typ == Chattype::OutBroadcast
102            {
103                None
104            } else {
105                Some(SummaryPrefix::Me(stock_str::self_msg(context).await))
106            }
107        } else if chat.typ == Chattype::Group
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::Webxdc => {
214                emoji = None;
215                type_name = None;
216                type_file = Some(
217                    self.get_webxdc_info(context)
218                        .await
219                        .map(|info| info.name)
220                        .unwrap_or_else(|_| "ErrWebxdcName".to_string()),
221                );
222                append_text = true;
223            }
224            Viewtype::Vcard => {
225                emoji = Some("👤");
226                type_name = None;
227                type_file = self.param.get(Param::Summary1).map(|s| s.to_string());
228                append_text = true;
229            }
230            Viewtype::Call => {
231                let call_state = call_state(context, self.id)
232                    .await
233                    .unwrap_or(CallState::Alerting);
234                emoji = Some("📞");
235                type_name = Some(match call_state {
236                    CallState::Alerting | CallState::Active | CallState::Completed { .. } => {
237                        if self.from_id == ContactId::SELF {
238                            stock_str::outgoing_call(context).await
239                        } else {
240                            stock_str::incoming_call(context).await
241                        }
242                    }
243                    CallState::Missed => stock_str::missed_call(context).await,
244                    CallState::Declined => stock_str::declined_call(context).await,
245                    CallState::Canceled => stock_str::canceled_call(context).await,
246                });
247                type_file = None;
248                append_text = false
249            }
250            Viewtype::Text | Viewtype::Unknown => {
251                emoji = None;
252                if self.param.get_cmd() == SystemMessage::LocationOnly {
253                    type_name = Some(stock_str::location(context).await);
254                    type_file = None;
255                    append_text = false;
256                } else {
257                    type_name = None;
258                    type_file = None;
259                    append_text = true;
260                }
261            }
262        };
263
264        let text = self.text.clone() + &self.additional_text;
265
266        let summary = if let Some(type_file) = type_file {
267            if append_text && !text.is_empty() {
268                format!("{type_file} – {text}")
269            } else {
270                type_file
271            }
272        } else if append_text && !text.is_empty() {
273            if emoji.is_some() {
274                text
275            } else if let Some(type_name) = type_name {
276                format!("{type_name} – {text}")
277            } else {
278                text
279            }
280        } else if let Some(type_name) = type_name {
281            type_name
282        } else {
283            "".to_string()
284        };
285
286        let summary = if let Some(emoji) = emoji {
287            format!("{emoji} {summary}")
288        } else {
289            summary
290        };
291
292        summary.split_whitespace().collect::<Vec<&str>>().join(" ")
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use std::path::PathBuf;
299
300    use super::*;
301    use crate::chat::ChatId;
302    use crate::param::Param;
303    use crate::test_utils::TestContext;
304
305    async fn assert_summary_texts(msg: &Message, ctx: &Context, expected: &str) {
306        assert_eq!(msg.get_summary_text(ctx).await, expected);
307        assert_eq!(msg.get_summary_text_without_prefix(ctx).await, expected);
308    }
309
310    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
311    async fn test_get_summary_text() {
312        let d = TestContext::new_alice().await;
313        let ctx = &d.ctx;
314        let chat_id = ChatId::create_for_contact(ctx, ContactId::SELF)
315            .await
316            .unwrap();
317        let some_text = " bla \t\n\tbla\n\t".to_string();
318
319        async fn write_file_to_blobdir(d: &TestContext) -> PathBuf {
320            let bytes = &[38, 209, 39, 29]; // Just some random bytes
321            let file = d.get_blobdir().join("random_filename_392438");
322            tokio::fs::write(&file, bytes).await.unwrap();
323            file
324        }
325
326        let msg = Message::new_text(some_text.to_string());
327        assert_summary_texts(&msg, ctx, "bla bla").await; // for simple text, the type is not added to the summary
328
329        let file = write_file_to_blobdir(&d).await;
330        let mut msg = Message::new(Viewtype::Image);
331        msg.set_file_and_deduplicate(&d, &file, Some("foo.jpg"), None)
332            .unwrap();
333        assert_summary_texts(&msg, ctx, "📷 Image").await; // file names are not added for images
334
335        let file = write_file_to_blobdir(&d).await;
336        let mut msg = Message::new(Viewtype::Image);
337        msg.set_text(some_text.to_string());
338        msg.set_file_and_deduplicate(&d, &file, Some("foo.jpg"), None)
339            .unwrap();
340        assert_summary_texts(&msg, ctx, "📷 bla bla").await; // type is visible by emoji if text is set
341
342        let file = write_file_to_blobdir(&d).await;
343        let mut msg = Message::new(Viewtype::Video);
344        msg.set_file_and_deduplicate(&d, &file, Some("foo.mp4"), None)
345            .unwrap();
346        assert_summary_texts(&msg, ctx, "🎥 Video").await; // file names are not added for videos
347
348        let file = write_file_to_blobdir(&d).await;
349        let mut msg = Message::new(Viewtype::Video);
350        msg.set_text(some_text.to_string());
351        msg.set_file_and_deduplicate(&d, &file, Some("foo.mp4"), None)
352            .unwrap();
353        assert_summary_texts(&msg, ctx, "🎥 bla bla").await; // type is visible by emoji if text is set
354
355        let file = write_file_to_blobdir(&d).await;
356        let mut msg = Message::new(Viewtype::Gif);
357        msg.set_file_and_deduplicate(&d, &file, Some("foo.gif"), None)
358            .unwrap();
359        assert_summary_texts(&msg, ctx, "GIF").await; // file names are not added for GIFs
360
361        let file = write_file_to_blobdir(&d).await;
362        let mut msg = Message::new(Viewtype::Gif);
363        msg.set_text(some_text.to_string());
364        msg.set_file_and_deduplicate(&d, &file, Some("foo.gif"), None)
365            .unwrap();
366        assert_summary_texts(&msg, ctx, "GIF \u{2013} bla bla").await; // file names are not added for GIFs
367
368        let file = write_file_to_blobdir(&d).await;
369        let mut msg = Message::new(Viewtype::Sticker);
370        msg.set_file_and_deduplicate(&d, &file, Some("foo.png"), None)
371            .unwrap();
372        assert_summary_texts(&msg, ctx, "Sticker").await; // file names are not added for stickers
373
374        let file = write_file_to_blobdir(&d).await;
375        let mut msg = Message::new(Viewtype::Voice);
376        msg.set_file_and_deduplicate(&d, &file, Some("foo.mp3"), None)
377            .unwrap();
378        assert_summary_texts(&msg, ctx, "🎤 Voice message").await; // file names are not added for voice messages
379
380        let file = write_file_to_blobdir(&d).await;
381        let mut msg = Message::new(Viewtype::Voice);
382        msg.set_text(some_text.clone());
383        msg.set_file_and_deduplicate(&d, &file, Some("foo.mp3"), None)
384            .unwrap();
385        assert_summary_texts(&msg, ctx, "🎤 bla bla").await;
386
387        let file = write_file_to_blobdir(&d).await;
388        let mut msg = Message::new(Viewtype::Audio);
389        msg.set_file_and_deduplicate(&d, &file, Some("foo.mp3"), None)
390            .unwrap();
391        assert_summary_texts(&msg, ctx, "🎵 foo.mp3").await; // file name is added for audio
392
393        let file = write_file_to_blobdir(&d).await;
394        let mut msg = Message::new(Viewtype::Audio);
395        msg.set_text(some_text.clone());
396        msg.set_file_and_deduplicate(&d, &file, Some("foo.mp3"), None)
397            .unwrap();
398        assert_summary_texts(&msg, ctx, "🎵 foo.mp3 \u{2013} bla bla").await; // file name and text added for audio
399
400        let mut msg = Message::new(Viewtype::File);
401        let bytes = include_bytes!("../test-data/webxdc/with-minimal-manifest.xdc");
402        msg.set_file_from_bytes(ctx, "foo.xdc", bytes, None)
403            .unwrap();
404        chat_id.set_draft(ctx, Some(&mut msg)).await.unwrap();
405        assert_eq!(msg.viewtype, Viewtype::Webxdc);
406        assert_summary_texts(&msg, ctx, "nice app!").await;
407        msg.set_text(some_text.clone());
408        chat_id.set_draft(ctx, Some(&mut msg)).await.unwrap();
409        assert_summary_texts(&msg, ctx, "nice app! \u{2013} bla bla").await;
410
411        let file = write_file_to_blobdir(&d).await;
412        let mut msg = Message::new(Viewtype::File);
413        msg.set_file_and_deduplicate(&d, &file, Some("foo.bar"), None)
414            .unwrap();
415        assert_summary_texts(&msg, ctx, "📎 foo.bar").await; // file name is added for files
416
417        let file = write_file_to_blobdir(&d).await;
418        let mut msg = Message::new(Viewtype::File);
419        msg.set_text(some_text.clone());
420        msg.set_file_and_deduplicate(&d, &file, Some("foo.bar"), None)
421            .unwrap();
422        assert_summary_texts(&msg, ctx, "📎 foo.bar \u{2013} bla bla").await; // file name is added for files
423
424        let mut msg = Message::new(Viewtype::Vcard);
425        msg.set_file_from_bytes(ctx, "foo.vcf", b"", None).unwrap();
426        chat_id.set_draft(ctx, Some(&mut msg)).await.unwrap();
427        // If a vCard can't be parsed, the message becomes `Viewtype::File`.
428        assert_eq!(msg.viewtype, Viewtype::File);
429        assert_summary_texts(&msg, ctx, "📎 foo.vcf").await;
430        msg.set_text(some_text.clone());
431        assert_summary_texts(&msg, ctx, "📎 foo.vcf \u{2013} bla bla").await;
432
433        for vt in [Viewtype::Vcard, Viewtype::File] {
434            let mut msg = Message::new(vt);
435            msg.set_file_from_bytes(
436                ctx,
437                "alice.vcf",
438                b"BEGIN:VCARD\n\
439                  VERSION:4.0\n\
440                  FN:Alice Wonderland\n\
441                  EMAIL;TYPE=work:alice@example.org\n\
442                  END:VCARD",
443                None,
444            )
445            .unwrap();
446            chat_id.set_draft(ctx, Some(&mut msg)).await.unwrap();
447            assert_eq!(msg.viewtype, Viewtype::Vcard);
448            assert_summary_texts(&msg, ctx, "👤 Alice Wonderland").await;
449        }
450
451        // Forwarded
452        let mut msg = Message::new_text(some_text.clone());
453        msg.param.set_int(Param::Forwarded, 1);
454        assert_eq!(msg.get_summary_text(ctx).await, "Forwarded: bla bla"); // for simple text, the type is not added to the summary
455        assert_eq!(msg.get_summary_text_without_prefix(ctx).await, "bla bla"); // skipping prefix used for reactions summaries
456
457        let file = write_file_to_blobdir(&d).await;
458        let mut msg = Message::new(Viewtype::File);
459        msg.set_text(some_text.clone());
460        msg.set_file_and_deduplicate(&d, &file, Some("foo.bar"), None)
461            .unwrap();
462        msg.param.set_int(Param::Forwarded, 1);
463        assert_eq!(
464            msg.get_summary_text(ctx).await,
465            "Forwarded: 📎 foo.bar \u{2013} bla bla"
466        );
467        assert_eq!(
468            msg.get_summary_text_without_prefix(ctx).await,
469            "📎 foo.bar \u{2013} bla bla"
470        ); // skipping prefix used for reactions summaries
471
472        let mut msg = Message::new(Viewtype::File);
473        msg.set_file_from_bytes(ctx, "autocrypt-setup-message.html", b"data", None)
474            .unwrap();
475        msg.param.set_cmd(SystemMessage::AutocryptSetupMessage);
476        assert_summary_texts(&msg, ctx, "📎 autocrypt-setup-message.html").await;
477        // no special handling of ASM
478    }
479}