Skip to main content

imessage_database/message_types/
edited.rs

1/*!
2 Logic and containers for the `message_summary_info` of an edited or unsent iMessage.
3
4 The main data type used to represent these types of messages is [`EditedMessage`].
5*/
6use crabstep::TypedStreamDeserializer;
7use plist::Value;
8
9use crate::{
10    error::plist::PlistParseError,
11    message_types::variants::BalloonProvider,
12    tables::messages::{body::parse_body_typedstream, models::BubbleComponent},
13    util::{
14        dates::TIMESTAMP_FACTOR,
15        plist::{
16            extract_array_key, extract_bytes_key, extract_dictionary, extract_int_key,
17            plist_as_dictionary,
18        },
19    },
20};
21
22/// The type of edit performed to a message body part
23#[derive(Debug, PartialEq, Eq)]
24pub enum EditStatus {
25    /// The content of the message body part was altered
26    Edited,
27    /// The content of the message body part was unsent
28    Unsent,
29    /// The content of the message body part was not changed
30    Original,
31}
32
33/// Represents a single edit event for a message part
34#[derive(Debug, PartialEq)]
35pub struct EditedEvent {
36    /// The date the message part was edited
37    pub date: i64,
38    /// The content of the edited message part, deserialized from the
39    /// [`typedstream`](crate::util::typedstream) format.
40    pub text: String,
41    /// The parsed [`typedstream`](crate::util::typedstream) component data used to add attributes to the message text
42    pub components: Vec<BubbleComponent>,
43    /// A GUID reference to another message
44    pub guid: Option<String>,
45}
46
47impl EditedEvent {
48    pub(crate) fn new(
49        date: i64,
50        text: String,
51        components: Vec<BubbleComponent>,
52        guid: Option<String>,
53    ) -> Self {
54        Self {
55            date,
56            text,
57            components,
58            guid,
59        }
60    }
61}
62
63/// Tracks the edit status and history for a specific part of a message
64#[derive(Debug, PartialEq)]
65pub struct EditedMessagePart {
66    /// The type of edit made to the given message part
67    pub status: EditStatus,
68    /// Contains edits made to the given message part, if any
69    pub edit_history: Vec<EditedEvent>,
70}
71
72impl Default for EditedMessagePart {
73    fn default() -> Self {
74        Self {
75            status: EditStatus::Original,
76            edit_history: vec![],
77        }
78    }
79}
80
81/// Main edited message container
82///
83/// # Background
84///
85/// iMessage permits editing sent messages up to five times
86/// within 15 minutes of sending the first message and unsending
87/// sent messages within 2 minutes.
88///
89/// # Internal Representation
90///
91/// Edited or unsent messages are stored with a `NULL` `text` field.
92/// Edited messages include `message_summary_info` that contains a dictionary
93/// with message body part data, including [`typedstream`](crate::util::typedstream)-encoded
94/// edit history. The order of entries in the edit history represents the order
95/// the messages were edited in, i.e. item `0` was the original and the last
96/// item is the current message.
97///
98/// ## Message Body Parts
99///
100/// - The `otr` key contains a dictionary of message body part indexes with some associated metadata.
101/// - The `rp` key contains a list of unsent message parts
102/// - The `ec` key contains a dictionary of edited message part indexes mapping to the history of edits
103///   - For each dictionary item in this array, The `d` key represents the
104///     time the message was edited and the `t` key represents the message's
105///     `attributedBody` text in the [`typedstream`](crate::util::typedstream) format.
106///
107/// # Documentation
108///
109/// Apple describes editing and unsending messages [here](https://support.apple.com/guide/iphone/unsend-and-edit-messages-iphe67195653/ios).
110#[derive(Debug, PartialEq)]
111pub struct EditedMessage {
112    /// Contains data representing each part of an edited message
113    pub parts: Vec<EditedMessagePart>,
114}
115
116impl<'a> BalloonProvider<'a> for EditedMessage {
117    fn from_map(payload: &'a Value) -> Result<Self, PlistParseError> {
118        // Parse payload
119        let plist_root = plist_as_dictionary(payload)?;
120
121        // Get the parts of the message that may have been altered
122        let message_parts = extract_dictionary(plist_root, "otr")?;
123
124        // Prefill edited data
125        let mut edited = Self::with_capacity(message_parts.len());
126        message_parts
127            .values()
128            .for_each(|_| edited.parts.push(EditedMessagePart::default()));
129
130        if let Ok(edited_message_events) = extract_dictionary(plist_root, "ec") {
131            for (idx, (key, events)) in edited_message_events.iter().enumerate() {
132                let events = events
133                    .as_array()
134                    .ok_or_else(|| PlistParseError::InvalidTypeIndex(idx, "array".to_string()))?;
135                let parsed_key = key.parse::<usize>().map_err(|_| {
136                    PlistParseError::InvalidType(
137                        "ec dictionary key".to_string(),
138                        "numeric string".to_string(),
139                    )
140                })?;
141
142                for event in events {
143                    let message_data = event.as_dictionary().ok_or_else(|| {
144                        PlistParseError::InvalidTypeIndex(idx, "dictionary".to_string())
145                    })?;
146
147                    let timestamp = extract_int_key(message_data, "d")? * TIMESTAMP_FACTOR;
148
149                    let data = extract_bytes_key(message_data, "t")?;
150
151                    let mut typedstream = TypedStreamDeserializer::new(data);
152                    let result = parse_body_typedstream(Some(typedstream.iter_root()?), None)
153                        .ok_or_else(|| {
154                            PlistParseError::InvalidEditedMessage(
155                                "Failed to parse typedstream data".to_string(),
156                            )
157                        })?;
158
159                    let text = result.text.ok_or_else(|| {
160                        PlistParseError::InvalidEditedMessage(
161                            "Edit-history entry missing text!".to_string(),
162                        )
163                    })?;
164
165                    let guid = message_data
166                        .get("bcg")
167                        .and_then(|item| item.as_string())
168                        .map(Into::into);
169
170                    if let Some(item) = edited.parts.get_mut(parsed_key) {
171                        item.status = EditStatus::Edited;
172                        item.edit_history.push(EditedEvent::new(
173                            timestamp,
174                            text,
175                            result.components,
176                            guid,
177                        ));
178                    }
179                }
180            }
181        }
182
183        if let Ok(unsent_message_indexes) = extract_array_key(plist_root, "rp") {
184            for (idx, unsent_message_idx) in unsent_message_indexes.iter().enumerate() {
185                let parsed_idx = unsent_message_idx
186                    .as_signed_integer()
187                    .ok_or_else(|| PlistParseError::InvalidTypeIndex(idx, "int".to_string()))?
188                    as usize;
189                if let Some(item) = edited.parts.get_mut(parsed_idx) {
190                    item.status = EditStatus::Unsent;
191                }
192            }
193        }
194
195        Ok(edited)
196    }
197}
198
199impl EditedMessage {
200    /// A new message with a preallocated capacity
201    fn with_capacity(capacity: usize) -> Self {
202        EditedMessage {
203            parts: Vec::with_capacity(capacity),
204        }
205    }
206
207    /// Gets the edited message data for the given message part index
208    #[must_use]
209    pub fn part(&self, index: usize) -> Option<&EditedMessagePart> {
210        self.parts.get(index)
211    }
212
213    /// Indicates if the given message part has been edited
214    #[must_use]
215    pub fn is_unedited_at(&self, index: usize) -> bool {
216        match self.parts.get(index) {
217            Some(part) => matches!(part.status, EditStatus::Original),
218            None => false,
219        }
220    }
221
222    /// Gets the number of parts that may or may not have been edited or unsent
223    #[must_use]
224    pub fn items(&self) -> usize {
225        self.parts.len()
226    }
227}
228
229#[cfg(test)]
230mod test_parser {
231    use crate::message_types::edited::{EditStatus, EditedEvent, EditedMessagePart};
232    use crate::message_types::text_effects::{Style, TextEffect};
233    use crate::message_types::{edited::EditedMessage, variants::BalloonProvider};
234    use crate::tables::messages::models::{BubbleComponent, TextAttributes};
235
236    use plist::Value;
237    use std::env::current_dir;
238    use std::fs::File;
239
240    #[test]
241    fn test_parse_edited() {
242        let plist_path = current_dir()
243            .unwrap()
244            .as_path()
245            .join("test_data/edited_message/Edited.plist");
246        let plist_data = File::open(plist_path).unwrap();
247        let plist = Value::from_reader(plist_data).unwrap();
248        let parsed = EditedMessage::from_map(&plist).unwrap();
249
250        let expected = EditedMessage {
251            parts: vec![EditedMessagePart {
252                status: EditStatus::Edited,
253                edit_history: vec![
254                    EditedEvent::new(
255                        690513474000000000,
256                        "First message  ".to_string(),
257                        vec![BubbleComponent::Text(vec![TextAttributes {
258                            start: 0,
259                            end: 15,
260                            effects: vec![TextEffect::Default],
261                        }])],
262                        None,
263                    ),
264                    EditedEvent::new(
265                        690513480000000000,
266                        "Edit 1".to_string(),
267                        vec![BubbleComponent::Text(vec![TextAttributes {
268                            start: 0,
269                            end: 6,
270                            effects: vec![TextEffect::Default],
271                        }])],
272                        None,
273                    ),
274                    EditedEvent::new(
275                        690513485000000000,
276                        "Edit 2".to_string(),
277                        vec![BubbleComponent::Text(vec![TextAttributes {
278                            start: 0,
279                            end: 6,
280                            effects: vec![TextEffect::Default],
281                        }])],
282                        None,
283                    ),
284                    EditedEvent::new(
285                        690513494000000000,
286                        "Edited message".to_string(),
287                        vec![BubbleComponent::Text(vec![TextAttributes {
288                            start: 0,
289                            end: 14,
290                            effects: vec![TextEffect::Default],
291                        }])],
292                        None,
293                    ),
294                ],
295            }],
296        };
297
298        assert_eq!(parsed, expected);
299
300        let expected_item = Some(expected.parts.first().unwrap());
301        assert_eq!(parsed.part(0), expected_item);
302    }
303
304    #[test]
305    fn test_parse_edited_to_link() {
306        let plist_path = current_dir()
307            .unwrap()
308            .as_path()
309            .join("test_data/edited_message/EditedToLink.plist");
310        let plist_data = File::open(plist_path).unwrap();
311        let plist = Value::from_reader(plist_data).unwrap();
312        let parsed = EditedMessage::from_map(&plist).unwrap();
313
314        let expected = EditedMessage {
315            parts: vec![
316                EditedMessagePart {
317                    status: EditStatus::Original,
318                    edit_history: vec![], // The first part of this is the URL preview
319                },
320                EditedMessagePart {
321                    status: EditStatus::Edited,
322                    edit_history: vec![
323                        EditedEvent::new(
324                            690514004000000000,
325                            "here we go!".to_string(),
326                            vec![BubbleComponent::Text(vec![TextAttributes {
327                                start: 0,
328                                end: 11,
329                                effects: vec![TextEffect::Default],
330                            }])],
331                            None,
332                        ),
333                        EditedEvent::new(
334                            690514772000000000,
335                            "https://github.com/ReagentX/imessage-exporter/issues/10".to_string(),
336                            vec![BubbleComponent::Text(vec![TextAttributes {
337                                start: 0,
338                                end: 55,
339                                effects: vec![TextEffect::Default],
340                            }])],
341                            Some("292BF9C6-C9B8-4827-BE65-6EA1C9B5B384".to_string()),
342                        ),
343                    ],
344                },
345            ],
346        };
347
348        assert_eq!(parsed, expected);
349    }
350
351    #[test]
352    fn test_parse_edited_to_link_and_back() {
353        let plist_path = current_dir()
354            .unwrap()
355            .as_path()
356            .join("test_data/edited_message/EditedToLinkAndBack.plist");
357        let plist_data = File::open(plist_path).unwrap();
358        let plist = Value::from_reader(plist_data).unwrap();
359        let parsed = EditedMessage::from_map(&plist).unwrap();
360
361        let expected = EditedMessage {
362            parts: vec![EditedMessagePart {
363                status: EditStatus::Edited,
364                edit_history: vec![
365                    EditedEvent::new(
366                        690514809000000000,
367                        "This is a normal message".to_string(),
368                        vec![BubbleComponent::Text(vec![TextAttributes {
369                            start: 0,
370                            end: 24,
371                            effects: vec![TextEffect::Default],
372                        }])],
373                        None,
374                    ),
375                    EditedEvent::new(
376                        690514819000000000,
377                        "Edit to a url https://github.com/ReagentX/imessage-exporter/issues/10"
378                            .to_string(),
379                        vec![BubbleComponent::Text(vec![TextAttributes {
380                            start: 0,
381                            end: 69,
382                            effects: vec![TextEffect::Default],
383                        }])],
384                        Some("0B9103FE-280C-4BD0-A66F-4EDEE3443247".to_string()),
385                    ),
386                    EditedEvent::new(
387                        690514834000000000,
388                        "And edit it back to a normal message...".to_string(),
389                        vec![BubbleComponent::Text(vec![TextAttributes {
390                            start: 0,
391                            end: 39,
392                            effects: vec![TextEffect::Default],
393                        }])],
394                        Some("0D93DF88-05BA-4418-9B20-79918ADD9923".to_string()),
395                    ),
396                ],
397            }],
398        };
399
400        assert_eq!(parsed, expected);
401    }
402
403    #[test]
404    fn test_parse_deleted() {
405        let plist_path = current_dir()
406            .unwrap()
407            .as_path()
408            .join("test_data/edited_message/Deleted.plist");
409        let plist_data = File::open(plist_path).unwrap();
410        let plist = Value::from_reader(plist_data).unwrap();
411        let parsed = EditedMessage::from_map(&plist).unwrap();
412
413        let expected = EditedMessage {
414            parts: vec![EditedMessagePart {
415                status: EditStatus::Unsent,
416                edit_history: vec![],
417            }],
418        };
419
420        assert_eq!(parsed, expected);
421    }
422
423    #[test]
424    fn test_parse_multipart_deleted() {
425        let plist_path = current_dir()
426            .unwrap()
427            .as_path()
428            .join("test_data/edited_message/MultiPartOneDeleted.plist");
429        let plist_data = File::open(plist_path).unwrap();
430        let plist = Value::from_reader(plist_data).unwrap();
431        let parsed = EditedMessage::from_map(&plist).unwrap();
432
433        let expected = EditedMessage {
434            parts: vec![
435                EditedMessagePart {
436                    status: EditStatus::Original,
437                    edit_history: vec![],
438                },
439                EditedMessagePart {
440                    status: EditStatus::Original,
441                    edit_history: vec![],
442                },
443                EditedMessagePart {
444                    status: EditStatus::Original,
445                    edit_history: vec![],
446                },
447                EditedMessagePart {
448                    status: EditStatus::Unsent,
449                    edit_history: vec![],
450                },
451            ],
452        };
453
454        assert_eq!(parsed, expected);
455    }
456
457    #[test]
458    fn test_parse_multipart_edited_and_deleted() {
459        let plist_path = current_dir()
460            .unwrap()
461            .as_path()
462            .join("test_data/edited_message/EditedAndDeleted.plist");
463        let plist_data = File::open(plist_path).unwrap();
464        let plist = Value::from_reader(plist_data).unwrap();
465        let parsed = EditedMessage::from_map(&plist).unwrap();
466
467        let expected = EditedMessage {
468            parts: vec![
469                EditedMessagePart {
470                    status: EditStatus::Original,
471                    edit_history: vec![],
472                },
473                EditedMessagePart {
474                    status: EditStatus::Original,
475                    edit_history: vec![],
476                },
477                EditedMessagePart {
478                    status: EditStatus::Edited,
479                    edit_history: vec![
480                        EditedEvent::new(
481                            743907180000000000,
482                            "Second message".to_string(),
483                            vec![BubbleComponent::Text(vec![TextAttributes {
484                                start: 0,
485                                end: 14,
486                                effects: vec![TextEffect::Default],
487                            }])],
488                            None,
489                        ),
490                        EditedEvent::new(
491                            743907190000000000,
492                            "Second message got edited!".to_string(),
493                            vec![BubbleComponent::Text(vec![TextAttributes {
494                                start: 0,
495                                end: 26,
496                                effects: vec![TextEffect::Default],
497                            }])],
498                            None,
499                        ),
500                    ],
501                },
502            ],
503        };
504
505        assert_eq!(parsed, expected);
506    }
507
508    #[test]
509    fn test_parse_multipart_edited_and_unsent() {
510        let plist_path = current_dir()
511            .unwrap()
512            .as_path()
513            .join("test_data/edited_message/EditedAndUnsent.plist");
514        let plist_data = File::open(plist_path).unwrap();
515        let plist = Value::from_reader(plist_data).unwrap();
516        let parsed = EditedMessage::from_map(&plist).unwrap();
517
518        let expected = EditedMessage {
519            parts: vec![
520                EditedMessagePart {
521                    status: EditStatus::Original,
522                    edit_history: vec![],
523                },
524                EditedMessagePart {
525                    status: EditStatus::Original,
526                    edit_history: vec![],
527                },
528                EditedMessagePart {
529                    status: EditStatus::Edited,
530                    edit_history: vec![
531                        EditedEvent::new(
532                            743907435000000000,
533                            "Second test".to_string(),
534                            vec![BubbleComponent::Text(vec![TextAttributes {
535                                start: 0,
536                                end: 11,
537                                effects: vec![TextEffect::Default],
538                            }])],
539                            None,
540                        ),
541                        EditedEvent::new(
542                            743907448000000000,
543                            "Second test was edited!".to_string(),
544                            vec![BubbleComponent::Text(vec![TextAttributes {
545                                start: 0,
546                                end: 23,
547                                effects: vec![TextEffect::Default],
548                            }])],
549                            None,
550                        ),
551                    ],
552                },
553                EditedMessagePart {
554                    status: EditStatus::Unsent,
555                    edit_history: vec![],
556                },
557            ],
558        };
559
560        assert_eq!(parsed, expected);
561    }
562
563    #[test]
564    fn test_parse_edited_with_formatting() {
565        let plist_path = current_dir()
566            .unwrap()
567            .as_path()
568            .join("test_data/edited_message/EditedWithFormatting.plist");
569        let plist_data = File::open(plist_path).unwrap();
570        let plist = Value::from_reader(plist_data).unwrap();
571        let parsed = EditedMessage::from_map(&plist).unwrap();
572
573        let expected = EditedMessage {
574            parts: vec![EditedMessagePart {
575                status: EditStatus::Edited,
576                edit_history: vec![
577                    EditedEvent::new(
578                        758573156000000000,
579                        "Test".to_string(),
580                        vec![BubbleComponent::Text(vec![TextAttributes {
581                            start: 0,
582                            end: 4,
583                            effects: vec![TextEffect::Default],
584                        }])],
585                        None,
586                    ),
587                    EditedEvent::new(
588                        758573166000000000,
589                        "Test".to_string(),
590                        vec![BubbleComponent::Text(vec![TextAttributes {
591                            start: 0,
592                            end: 4,
593                            effects: vec![TextEffect::Styles(vec![Style::Strikethrough])],
594                        }])],
595                        Some("76A466B8-D21E-4A20-AF62-FF2D3A20D31C".to_string()),
596                    ),
597                ],
598            }],
599        };
600
601        assert_eq!(parsed, expected);
602
603        let expected_item = Some(expected.parts.first().unwrap());
604        assert_eq!(parsed.part(0), expected_item);
605    }
606}
607
608#[cfg(test)]
609mod test_gen {
610    use plist::Value;
611    use std::env::current_dir;
612    use std::fs::File;
613
614    use crate::message_types::text_effects::{Style, TextEffect};
615    use crate::message_types::{edited::EditedMessage, variants::BalloonProvider};
616    use crate::tables::messages::models::{BubbleComponent, TextAttributes};
617
618    #[test]
619    fn test_parse_edited() {
620        let plist_path = current_dir()
621            .unwrap()
622            .as_path()
623            .join("test_data/edited_message/Edited.plist");
624        let plist_data = File::open(plist_path).unwrap();
625        let plist = Value::from_reader(plist_data).unwrap();
626        let parsed = EditedMessage::from_map(&plist).unwrap();
627
628        let expected_attrs = [
629            vec![BubbleComponent::Text(vec![TextAttributes::new(
630                0,
631                15,
632                vec![TextEffect::Default],
633            )])],
634            vec![BubbleComponent::Text(vec![TextAttributes::new(
635                0,
636                6,
637                vec![TextEffect::Default],
638            )])],
639            vec![BubbleComponent::Text(vec![TextAttributes::new(
640                0,
641                6,
642                vec![TextEffect::Default],
643            )])],
644            vec![BubbleComponent::Text(vec![TextAttributes::new(
645                0,
646                14,
647                vec![TextEffect::Default],
648            )])],
649        ];
650
651        for event in parsed.parts {
652            for (idx, part) in event.edit_history.iter().enumerate() {
653                assert_eq!(part.components, expected_attrs[idx]);
654            }
655        }
656    }
657
658    #[test]
659    fn test_parse_edited_to_link() {
660        let plist_path = current_dir()
661            .unwrap()
662            .as_path()
663            .join("test_data/edited_message/EditedToLink.plist");
664        let plist_data = File::open(plist_path).unwrap();
665        let plist = Value::from_reader(plist_data).unwrap();
666        let parsed = EditedMessage::from_map(&plist).unwrap();
667
668        let expected_attrs = [
669            vec![BubbleComponent::Text(vec![TextAttributes::new(
670                0,
671                11,
672                vec![TextEffect::Default],
673            )])],
674            vec![BubbleComponent::Text(vec![TextAttributes::new(
675                0,
676                55,
677                vec![TextEffect::Default],
678            )])],
679        ];
680
681        for event in parsed.parts {
682            for (idx, part) in event.edit_history.iter().enumerate() {
683                assert_eq!(part.components, expected_attrs[idx]);
684            }
685        }
686    }
687
688    #[test]
689    fn test_parse_edited_to_link_and_back() {
690        let plist_path = current_dir()
691            .unwrap()
692            .as_path()
693            .join("test_data/edited_message/EditedToLinkAndBack.plist");
694        let plist_data = File::open(plist_path).unwrap();
695        let plist = Value::from_reader(plist_data).unwrap();
696        let parsed = EditedMessage::from_map(&plist).unwrap();
697
698        let expected_attrs = [
699            vec![BubbleComponent::Text(vec![TextAttributes::new(
700                0,
701                24,
702                vec![TextEffect::Default],
703            )])],
704            vec![BubbleComponent::Text(vec![TextAttributes::new(
705                0,
706                69,
707                vec![TextEffect::Default],
708            )])],
709            vec![BubbleComponent::Text(vec![TextAttributes::new(
710                0,
711                39,
712                vec![TextEffect::Default],
713            )])],
714        ];
715
716        for event in parsed.parts {
717            for (idx, part) in event.edit_history.iter().enumerate() {
718                assert_eq!(part.components, expected_attrs[idx]);
719            }
720        }
721    }
722
723    #[test]
724    fn test_parse_deleted() {
725        let plist_path = current_dir()
726            .unwrap()
727            .as_path()
728            .join("test_data/edited_message/Deleted.plist");
729        let plist_data = File::open(plist_path).unwrap();
730        let plist = Value::from_reader(plist_data).unwrap();
731        let parsed = EditedMessage::from_map(&plist).unwrap();
732
733        let expected_attrs: [Vec<BubbleComponent>; 0] = [];
734
735        for event in parsed.parts {
736            for (idx, part) in event.edit_history.iter().enumerate() {
737                assert_eq!(part.components, expected_attrs[idx]);
738            }
739        }
740    }
741
742    #[test]
743    fn test_parse_multipart_deleted() {
744        let plist_path = current_dir()
745            .unwrap()
746            .as_path()
747            .join("test_data/edited_message/MultiPartOneDeleted.plist");
748        let plist_data = File::open(plist_path).unwrap();
749        let plist = Value::from_reader(plist_data).unwrap();
750        let parsed = EditedMessage::from_map(&plist).unwrap();
751
752        let expected_attrs: [Vec<BubbleComponent>; 0] = [];
753
754        for event in parsed.parts {
755            for (idx, part) in event.edit_history.iter().enumerate() {
756                assert_eq!(part.components, expected_attrs[idx]);
757            }
758        }
759    }
760
761    #[test]
762    fn test_parse_multipart_edited_and_deleted() {
763        let plist_path = current_dir()
764            .unwrap()
765            .as_path()
766            .join("test_data/edited_message/EditedAndDeleted.plist");
767        let plist_data = File::open(plist_path).unwrap();
768        let plist = Value::from_reader(plist_data).unwrap();
769        let parsed = EditedMessage::from_map(&plist).unwrap();
770
771        let expected_attrs = [
772            vec![BubbleComponent::Text(vec![TextAttributes::new(
773                0,
774                14,
775                vec![TextEffect::Default],
776            )])],
777            vec![BubbleComponent::Text(vec![TextAttributes::new(
778                0,
779                26,
780                vec![TextEffect::Default],
781            )])],
782        ];
783
784        for event in parsed.parts {
785            for (idx, part) in event.edit_history.iter().enumerate() {
786                assert_eq!(part.components, expected_attrs[idx]);
787            }
788        }
789    }
790
791    #[test]
792    fn test_parse_multipart_edited_and_unsent() {
793        let plist_path = current_dir()
794            .unwrap()
795            .as_path()
796            .join("test_data/edited_message/EditedAndUnsent.plist");
797        let plist_data = File::open(plist_path).unwrap();
798        let plist = Value::from_reader(plist_data).unwrap();
799        let parsed = EditedMessage::from_map(&plist).unwrap();
800
801        for parts in &parsed.parts {
802            for part in &parts.edit_history {
803                println!("{:#?}", part.components);
804            }
805        }
806
807        let expected_attrs: [Vec<BubbleComponent>; 2] = [
808            vec![BubbleComponent::Text(vec![TextAttributes::new(
809                0,
810                11,
811                vec![TextEffect::Default],
812            )])],
813            vec![BubbleComponent::Text(vec![TextAttributes::new(
814                0,
815                23,
816                vec![TextEffect::Default],
817            )])],
818        ];
819
820        for event in parsed.parts {
821            for (idx, part) in event.edit_history.iter().enumerate() {
822                assert_eq!(part.components, expected_attrs[idx]);
823            }
824        }
825    }
826
827    #[test]
828    fn test_parse_edited_with_formatting() {
829        let plist_path = current_dir()
830            .unwrap()
831            .as_path()
832            .join("test_data/edited_message/EditedWithFormatting.plist");
833        let plist_data = File::open(plist_path).unwrap();
834        let plist = Value::from_reader(plist_data).unwrap();
835        let parsed = EditedMessage::from_map(&plist).unwrap();
836
837        let expected_attrs: [Vec<BubbleComponent>; 2] = [
838            vec![BubbleComponent::Text(vec![TextAttributes::new(
839                0,
840                4,
841                vec![TextEffect::Default],
842            )])],
843            vec![BubbleComponent::Text(vec![TextAttributes::new(
844                0,
845                4,
846                vec![TextEffect::Styles(vec![Style::Strikethrough])],
847            )])],
848        ];
849
850        for event in parsed.parts {
851            for (idx, part) in event.edit_history.iter().enumerate() {
852                assert_eq!(part.components, expected_attrs[idx]);
853            }
854        }
855    }
856}