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