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}