Skip to main content

imessage_database/tables/messages/
models.rs

1/*!
2 Message body models reconstructed from [`message.attributed_body`](crate::tables::messages::message::Message::attributed_body).
3*/
4
5use std::fmt::{Display, Formatter, Result};
6
7use crabstep::deserializer::iter::Property;
8
9use crate::{
10    message_types::text_effects::text_effect::TextEffect, tables::messages::message::Message,
11};
12
13// MARK: BubbleComponent
14/// Component emitted for one logical message part.
15///
16/// # Component Types
17///
18/// A single iMessage contains data that may be represented across multiple bubbles.
19/// Each bubble corresponds to one `__kIMMessagePartAttributeName` index in the
20/// underlying [`NSAttributedString`](crate::util::typedstream); the
21/// [`Run`](Self::Run) groups every attributed range that shares that part index.
22#[derive(Debug, PartialEq, Clone)]
23pub enum BubbleComponent {
24    /// One bubble's worth of attributed body content. Each contained
25    /// [`AttributedRange`] models a single `NSAttributedString` attribute run
26    /// (a byte range plus its attribute dictionary); adjacent ranges that
27    /// share a `__kIMMessagePartAttributeName` index share the bubble.
28    ///
29    /// A run may interleave text ranges ([`AttributedRange::attachment`] is
30    /// `None`) with inline-attachment ranges (e.g. stickers rendered inline
31    /// like emoji), preserving their original order.
32    Run(Vec<AttributedRange>),
33    /// An [app integration](crate::message_types::app)
34    App,
35    /// A component that was retracted, found by parsing the [`EditedMessage`](crate::message_types::edited::EditedMessage)
36    Retracted,
37}
38
39// MARK: Service
40/// Defines different types of [services](https://support.apple.com/en-us/104972) we can receive messages from.
41#[derive(Debug, PartialEq, Eq)]
42pub enum Service<'a> {
43    /// iMessage.
44    #[allow(non_camel_case_types)]
45    iMessage,
46    /// SMS.
47    SMS,
48    /// RCS.
49    RCS,
50    /// A message sent via [satellite](https://support.apple.com/en-us/120930) (literally: `iMessageLite` in the database).
51    Satellite,
52    /// Unrecognized service name.
53    Other(&'a str),
54    /// Missing service field.
55    Unknown,
56}
57
58impl<'a> Service<'a> {
59    /// Map the database service name to a [`Service`] variant.
60    #[must_use]
61    pub fn from_name(service: Option<&'a str>) -> Self {
62        if let Some(service_name) = service {
63            return match service_name.trim() {
64                "iMessage" => Service::iMessage,
65                "iMessageLite" => Service::Satellite,
66                "SMS" => Service::SMS,
67                "rcs" | "RCS" => Service::RCS,
68                service_name => Service::Other(service_name),
69            };
70        }
71        Service::Unknown
72    }
73}
74
75impl Display for Service<'_> {
76    fn fmt(&self, fmt: &mut Formatter<'_>) -> Result {
77        match self {
78            Service::iMessage => write!(fmt, "iMessage"),
79            Service::SMS => write!(fmt, "SMS"),
80            Service::RCS => write!(fmt, "RCS"),
81            Service::Satellite => write!(fmt, "Satellite"),
82            Service::Other(other) => write!(fmt, "{other}"),
83            Service::Unknown => write!(fmt, "Unknown"),
84        }
85    }
86}
87
88// MARK: AttributedRange
89/// One attribute run of a message's [`NSAttributedString`](crate::util::typedstream)
90/// body: a byte range into the [`Message`]'s [`text`](crate::tables::messages::Message::text)
91/// plus every attribute applied to it.
92///
93/// A range is a *text* range when [`attachment`](Self::attachment) is `None` and
94/// an *attachment* range (a `\u{FFFC}` placeholder for an inline attachment)
95/// when it is `Some`. Effects, styles, and the inline-emoji hint apply to either
96/// kind. The [`typedstream`](crate::util::typedstream) attribute dictionary is a flat bag, so an attachment
97/// range can also carry, say, an [`Animated`](TextEffect::Animated) effect.
98///
99/// Ranges that share a `__kIMMessagePartAttributeName` index are grouped into one
100/// [`BubbleComponent::Run`]. For example, message text with a
101/// [`Mention`](TextEffect::Mention) like:
102///
103/// ```
104/// let message_text = "What's up, Christopher?";
105/// ```
106///
107/// parses into a single run of 3 ranges:
108///
109/// ```
110/// use imessage_database::message_types::text_effects::text_effect::TextEffect;
111/// use imessage_database::tables::messages::models::{AttributedRange, BubbleComponent};
112///
113/// let result = vec![BubbleComponent::Run(vec![
114///     AttributedRange::text(0, 11, vec![TextEffect::Default]),  // `What's up, `
115///     AttributedRange::text(11, 22, vec![TextEffect::Mention("+5558675309".to_string())]), // `Christopher`
116///     AttributedRange::text(22, 23, vec![TextEffect::Default])  // `?`
117/// ])];
118/// ```
119#[derive(Debug, PartialEq, Clone)]
120pub struct AttributedRange {
121    /// Start byte index in the message text.
122    pub start: usize,
123    /// End byte index in the message text.
124    pub end: usize,
125    /// Effects applied to this range.
126    pub effects: Vec<TextEffect>,
127    /// `Some` when this range is a `\u{FFFC}` placeholder for an attachment.
128    /// The attachment's metadata travels here; effects still apply alongside.
129    pub attachment: Option<AttachmentMeta>,
130    /// `true` when the range carries `__kIMEmojiImageAttributeName`–Apple's
131    /// hint to render the attachment inline–like an emoji (observed on
132    /// genmoji, Memoji, and custom sticker ranges).
133    pub emoji_image: bool,
134}
135
136impl AttributedRange {
137    /// Build a text range (no attachment, no inline-emoji hint) with the
138    /// specified start index, end index, and text effects.
139    #[must_use]
140    pub fn text(start: usize, end: usize, effects: Vec<TextEffect>) -> Self {
141        Self {
142            start,
143            end,
144            effects,
145            attachment: None,
146            emoji_image: false,
147        }
148    }
149
150    /// Build an attachment range carrying the given [`AttachmentMeta`], with
151    /// no inline-emoji hint.
152    #[must_use]
153    pub fn attachment(start: usize, end: usize, meta: AttachmentMeta) -> Self {
154        Self {
155            start,
156            end,
157            effects: vec![],
158            attachment: Some(meta),
159            emoji_image: false,
160        }
161    }
162
163    /// Build an inline-rendered attachment range, one Apple flagged with
164    /// `__kIMEmojiImageAttributeName` to render inline like an emoji (a Memoji,
165    /// genmoji, or custom sticker placed amongst text).
166    #[must_use]
167    pub fn inline_attachment(start: usize, end: usize, meta: AttachmentMeta) -> Self {
168        Self {
169            start,
170            end,
171            effects: vec![],
172            attachment: Some(meta),
173            emoji_image: true,
174        }
175    }
176
177    /// `true` when this range stands in for an attachment rather than text.
178    #[must_use]
179    pub fn is_attachment(&self) -> bool {
180        self.attachment.is_some()
181    }
182}
183
184// MARK: AttachmentMeta
185/// Attachment metadata attached to a body range.
186#[derive(Debug, PartialEq, Default, Clone)]
187pub struct AttachmentMeta {
188    /// GUID of the attachment row.
189    pub guid: Option<String>,
190    /// Audio transcription stored on the attributed range.
191    pub transcription: Option<String>,
192    /// Inline media height in points.
193    pub height: Option<f64>,
194    /// Inline media width in points.
195    pub width: Option<f64>,
196    /// Original attachment filename.
197    pub name: Option<String>,
198}
199
200impl AttachmentMeta {
201    /// Applies a single typedstream attribute key/value pair to the metadata,
202    /// ignoring any key that isn't attachment metadata. Driven per-key by the
203    /// body parser's `build_range`, which walks the full attribute dictionary
204    /// so non-attachment-meta keys on the same range are still processed.
205    pub(crate) fn set_from_key_value<'a>(&mut self, key: &str, value: &Property<'a, 'a>) {
206        match key {
207            "__kIMFileTransferGUIDAttributeName" => {
208                self.guid = value.as_string().map(String::from);
209            }
210            "IMAudioTranscription" => self.transcription = value.as_string().map(String::from),
211            "__kIMInlineMediaHeightAttributeName" => self.height = value.as_f64(),
212            "__kIMInlineMediaWidthAttributeName" => self.width = value.as_f64(),
213            "__kIMFilenameAttributeName" => self.name = value.as_string().map(String::from),
214            _ => {}
215        }
216    }
217}
218
219// MARK: SharedLocation
220/// Direction of a legacy shared-location event (`item_type == 4` with
221/// `group_action_type == 0`). The two cases are mutually exclusive: the
222/// underlying `share_status` bool distinguishes them.
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub enum SharedLocation {
225    /// The sender began sharing their location.
226    Started,
227    /// The sender stopped sharing their location.
228    Stopped,
229}
230
231// MARK: GroupAction
232/// Group action encoded by a message row.
233#[derive(Debug, PartialEq, Eq)]
234pub enum GroupAction<'a> {
235    /// Participant was added to the group.
236    ParticipantAdded(i32),
237    /// Participant was removed from the group.
238    ParticipantRemoved(i32),
239    /// Group name changed.
240    NameChange(&'a str),
241    /// Participant left the group.
242    ParticipantLeft,
243    /// Group icon/avatar changed.
244    GroupIconChanged,
245    /// Group icon/avatar was removed.
246    GroupIconRemoved,
247    /// Chat background changed.
248    ChatBackgroundChanged,
249    /// Chat background was removed.
250    ChatBackgroundRemoved,
251    /// Participant changed their phone number.
252    PhoneNumberChanged(i32),
253}
254
255impl<'a> GroupAction<'a> {
256    /// Parse group action fields from a message row.
257    #[must_use]
258    pub(crate) fn from_message(message: &'a Message) -> Option<Self> {
259        match (
260            message.item_type,
261            message.group_action_type,
262            message.other_handle,
263            &message.group_title,
264        ) {
265            // If the handle_id of the message matches the other_handle, the sender changed their own phone number
266            (1, 0, Some(who), _) if message.handle_id == Some(who) => {
267                Some(Self::PhoneNumberChanged(who))
268            }
269            (1, 0, Some(who), _) => Some(Self::ParticipantAdded(who)),
270            (1, 1, Some(who), _) => Some(Self::ParticipantRemoved(who)),
271            (2, _, _, Some(name)) => Some(Self::NameChange(name)),
272            (3, 0, _, _) => Some(Self::ParticipantLeft),
273            (3, 1, _, _) => Some(Self::GroupIconChanged),
274            (3, 2, _, _) => Some(Self::GroupIconRemoved),
275            (3, 4, _, _) => Some(Self::ChatBackgroundChanged),
276            (3, 6, _, _) => Some(Self::ChatBackgroundRemoved),
277            _ => None,
278        }
279    }
280}