atagen

macltok

public
Star0
Watch0
06c73391cbaec25f3bfaacf44d54a9ac657ec4e9
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
src/normalise/timeline.rsRust643 lines25.1 KB
1
use std::sync::Arc;
2
3
use matrix_sdk_crypto::types::events::UtdCause as SdkUtdCause;
4
use matrix_sdk_ui::{
5
    eyeball_im::VectorDiff,
6
    timeline::{
7
        AnyOtherStateEventContentChange, EncryptedMessage, EventSendState, EventTimelineItem,
8
        MembershipChange as SdkMembershipChange, MsgLikeContent, MsgLikeKind, Profile,
9
        TimelineDetails, TimelineEventItemId, TimelineItem as SdkTimelineItem, TimelineItemContent,
10
        TimelineItemKind, VirtualTimelineItem,
11
    },
12
};
13
use ruma::{
14
    MxcUri, OwnedEventId, UserId,
15
    events::{
16
        StateEventContentChange,
17
        room::message::{MessageFormat, MessageType},
18
    },
19
};
20
21
use crate::{
22
    Change, HistoryGap, HistoryGapLifecycle, MediaId, MembershipChange, Message, MessageKind,
23
    MessageMentions, Reaction, ReplyDetails, ReplyReference, SendState, Sender, ThreadSummary,
24
    TimelineContent, TimelineDiff, TimelineEvent, TimelineItem, TimelineItemId, UtdCause,
25
};
26
27
use super::link_preview::sender_previews;
28
use super::media::{
29
    MediaRegistration, MediaScope, media_descriptor_for_sticker, media_from_message,
30
    media_identity, media_registrations_for_item,
31
};
32
33
#[derive(Default)]
34
pub(crate) struct NormalisedTimeline {
35
    pub(crate) items: imbl::Vector<TimelineItem>,
36
    pub(crate) media: Vec<MediaRegistration>,
37
    /// replies whose target isn't loaded, so somebody should go and ask for it
38
    pub(crate) missing_replies: Vec<OwnedEventId>,
39
}
40
41
pub(crate) struct NormalisedTimelineDiffs {
42
    pub(crate) diffs: Vec<TimelineDiff>,
43
    pub(crate) media: Vec<MediaRegistration>,
44
    pub(crate) missing_replies: Vec<OwnedEventId>,
45
}
46
47
struct Normaliser<'a> {
48
    current_user_id: Option<&'a UserId>,
49
    scope: &'a MediaScope,
50
    media: Vec<MediaRegistration>,
51
    missing_replies: Vec<OwnedEventId>,
52
}
53
54
impl Normaliser<'_> {
55
    /// `None` for rows macltok leaves out
56
    fn item(&mut self, item: &SdkTimelineItem) -> Option<TimelineItem> {
57
        if let TimelineItemKind::Virtual(VirtualTimelineItem::HistoryGap(gap)) = item.kind()
58
            && gap.status == matrix_sdk::event_cache::HistoryGapStatus::Joined
59
        {
60
            return None;
61
        }
62
        self.media
63
            .extend(media_registrations_for_item(item, self.scope));
64
        Some(match item.kind() {
65
            TimelineItemKind::Event(event) => {
66
                TimelineItem::Event(Box::new(self.event(item, event)))
67
            }
68
            TimelineItemKind::Virtual(VirtualTimelineItem::DateDivider(timestamp)) => {
69
                TimelineItem::DayDivider {
70
                    timestamp_ms: timestamp.get().into(),
71
                }
72
            }
73
            TimelineItemKind::Virtual(VirtualTimelineItem::ReadMarker) => TimelineItem::ReadMarker,
74
            TimelineItemKind::Virtual(VirtualTimelineItem::TimelineStart) => {
75
                TimelineItem::TimelineStart
76
            }
77
            TimelineItemKind::Virtual(VirtualTimelineItem::HistoryGap(gap)) => {
78
                use matrix_sdk::event_cache::HistoryGapStatus;
79
                let (lifecycle, failure) = match &gap.status {
80
                    HistoryGapStatus::Idle | HistoryGapStatus::Joined => {
81
                        (HistoryGapLifecycle::Idle, None)
82
                    }
83
                    HistoryGapStatus::Paused => (HistoryGapLifecycle::Paused, None),
84
                    HistoryGapStatus::Failed { message } => {
85
                        (HistoryGapLifecycle::Failed, Some(message.clone()))
86
                    }
87
                    HistoryGapStatus::Unavailable => (HistoryGapLifecycle::Unavailable, None),
88
                };
89
                TimelineItem::HistoryGap(HistoryGap {
90
                    id: gap.id.clone(),
91
                    older_boundary: gap.older_boundary.clone(),
92
                    newer_boundary: gap.newer_boundary.clone(),
93
                    lifecycle,
94
                    failure,
95
                })
96
            }
97
        })
98
    }
99
100
    fn event(&mut self, item: &SdkTimelineItem, event: &EventTimelineItem) -> TimelineEvent {
101
        let msg_like = match event.content() {
102
            TimelineItemContent::MsgLike(content) => Some(content),
103
            _ => None,
104
        };
105
        let reply_to = msg_like
106
            .and_then(|content| content.in_reply_to.as_ref())
107
            .map(|reply| {
108
                let details = match &reply.event {
109
                    TimelineDetails::Ready(embedded) => ReplyDetails::Ready {
110
                        sender: sender(&embedded.sender, &embedded.sender_profile),
111
                        content: Box::new(content(&embedded.content, None)),
112
                        timestamp_ms: embedded.timestamp.get().into(),
113
                    },
114
                    TimelineDetails::Unavailable => {
115
                        self.missing_replies.push(reply.event_id.clone());
116
                        ReplyDetails::Pending
117
                    }
118
                    TimelineDetails::Pending => ReplyDetails::Pending,
119
                    TimelineDetails::Error(_) => ReplyDetails::Unavailable,
120
                };
121
                ReplyReference {
122
                    event_id: reply.event_id.clone(),
123
                    details,
124
                }
125
            });
126
        let mut read_by: Vec<_> = event
127
            .read_receipts()
128
            .keys()
129
            .filter(|user_id| *user_id != event.sender())
130
            .cloned()
131
            .collect();
132
        read_by.sort();
133
        TimelineEvent {
134
            item_id: TimelineItemId::new(item.unique_id().0.clone()),
135
            event_id: event.event_id().map(ToOwned::to_owned),
136
            transaction_id: event.transaction_id().map(ToOwned::to_owned),
137
            sender: sender(event.sender(), event.sender_profile()),
138
            timestamp_ms: event.timestamp().get().into(),
139
            own: event.is_own(),
140
            send_state: send_state(event.send_state()),
141
            content: content(event.content(), Some(&media_identity(item, self.scope))),
142
            reply_to,
143
            thread_root: msg_like.and_then(|content| content.thread_root.clone()),
144
            thread_summary: msg_like.and_then(thread_summary),
145
            reactions: reactions(event, self.current_user_id),
146
            read_by,
147
            editable: event.is_editable(),
148
            highlight: event.is_highlighted(),
149
        }
150
    }
151
}
152
153
pub(crate) fn normalise_timeline<'a>(
154
    items: impl Iterator<Item = &'a Arc<SdkTimelineItem>>,
155
    current_user_id: Option<&UserId>,
156
    scope: &MediaScope,
157
) -> (NormalisedTimeline, HiddenRows) {
158
    let mut normaliser = Normaliser {
159
        current_user_id,
160
        scope,
161
        media: Vec::new(),
162
        missing_replies: Vec::new(),
163
    };
164
    let mut hidden = HiddenRows::default();
165
    let items = hidden.reset(items.map(|item| normaliser.item(item)));
166
    let normalised = NormalisedTimeline {
167
        items: items.into_iter().collect(),
168
        media: normaliser.media,
169
        missing_replies: normaliser.missing_replies,
170
    };
171
    (normalised, hidden)
172
}
173
174
pub(crate) fn normalise_timeline_diffs(
175
    diffs: Vec<VectorDiff<Arc<SdkTimelineItem>>>,
176
    current_user_id: Option<&UserId>,
177
    scope: &MediaScope,
178
    hidden: &mut HiddenRows,
179
) -> NormalisedTimelineDiffs {
180
    let mut normaliser = Normaliser {
181
        current_user_id,
182
        scope,
183
        media: Vec::new(),
184
        missing_replies: Vec::new(),
185
    };
186
    let diffs = diffs
187
        .into_iter()
188
        .filter_map(|diff| hidden.translate(diff.map(|item| normaliser.item(&item))))
189
        .collect();
190
    NormalisedTimelineDiffs {
191
        diffs,
192
        media: normaliser.media,
193
        missing_replies: normaliser.missing_replies,
194
    }
195
}
196
197
/// which sdk rows were left out, so diff indices can step over them
198
#[derive(Default)]
199
pub(crate) struct HiddenRows {
200
    rows: imbl::Vector<bool>,
201
    hidden: usize,
202
}
203
204
impl HiddenRows {
205
    fn visible_index(&self, index: usize) -> usize {
206
        if self.hidden == 0 {
207
            return index;
208
        }
209
        self.rows
210
            .iter()
211
            .take(index)
212
            .filter(|hidden| !**hidden)
213
            .count()
214
    }
215
216
    fn push(&mut self, front: bool, item: &Option<TimelineItem>) {
217
        self.hidden += usize::from(item.is_none());
218
        if front {
219
            self.rows.push_front(item.is_none());
220
        } else {
221
            self.rows.push_back(item.is_none());
222
        }
223
    }
224
225
    fn forget(&mut self, hidden: bool) -> bool {
226
        self.hidden -= usize::from(hidden);
227
        !hidden
228
    }
229
230
    fn reset(&mut self, items: impl Iterator<Item = Option<TimelineItem>>) -> Vec<TimelineItem> {
231
        *self = Self::default();
232
        items
233
            .filter_map(|item| {
234
                self.push(false, &item);
235
                item
236
            })
237
            .collect()
238
    }
239
240
    fn translate(&mut self, diff: VectorDiff<Option<TimelineItem>>) -> Option<TimelineDiff> {
241
        Some(match diff {
242
            VectorDiff::Append { values } => {
243
                let values: Vec<_> = values
244
                    .into_iter()
245
                    .filter_map(|item| {
246
                        self.push(false, &item);
247
                        item
248
                    })
249
                    .collect();
250
                if values.is_empty() {
251
                    return None;
252
                }
253
                TimelineDiff::Append { values }
254
            }
255
            VectorDiff::Clear => {
256
                *self = Self::default();
257
                TimelineDiff::Clear
258
            }
259
            VectorDiff::PushFront { value } => {
260
                self.push(true, &value);
261
                TimelineDiff::PushFront { value: value? }
262
            }
263
            VectorDiff::PushBack { value } => {
264
                self.push(false, &value);
265
                TimelineDiff::PushBack { value: value? }
266
            }
267
            VectorDiff::PopFront => {
268
                let hidden = self.rows.pop_front()?;
269
                self.forget(hidden).then_some(TimelineDiff::PopFront)?
270
            }
271
            VectorDiff::PopBack => {
272
                let hidden = self.rows.pop_back()?;
273
                self.forget(hidden).then_some(TimelineDiff::PopBack)?
274
            }
275
            VectorDiff::Insert { index, value } => {
276
                let visible = self.visible_index(index);
277
                self.hidden += usize::from(value.is_none());
278
                self.rows.insert(index, value.is_none());
279
                TimelineDiff::Insert {
280
                    index: visible,
281
                    value: value?,
282
                }
283
            }
284
            VectorDiff::Set { index, value } => {
285
                let visible = self.visible_index(index);
286
                let was_hidden = self.rows.set(index, value.is_none());
287
                self.hidden = self.hidden + usize::from(value.is_none()) - usize::from(was_hidden);
288
                match (was_hidden, value) {
289
                    (false, Some(value)) => TimelineDiff::Set {
290
                        index: visible,
291
                        value,
292
                    },
293
                    (false, None) => TimelineDiff::Remove { index: visible },
294
                    (true, Some(value)) => TimelineDiff::Insert {
295
                        index: visible,
296
                        value,
297
                    },
298
                    (true, None) => return None,
299
                }
300
            }
301
            VectorDiff::Remove { index } => {
302
                let visible = self.visible_index(index);
303
                let hidden = self.rows.remove(index);
304
                self.forget(hidden)
305
                    .then_some(TimelineDiff::Remove { index: visible })?
306
            }
307
            VectorDiff::Truncate { length } => {
308
                let visible = self.visible_index(length);
309
                let dropped = self
310
                    .rows
311
                    .iter()
312
                    .skip(length)
313
                    .filter(|hidden| **hidden)
314
                    .count();
315
                self.hidden -= dropped;
316
                self.rows.truncate(length);
317
                TimelineDiff::Truncate { length: visible }
318
            }
319
            VectorDiff::Reset { values } => TimelineDiff::Reset {
320
                values: self.reset(values.into_iter()),
321
            },
322
        })
323
    }
324
}
325
326
/// `media_identity` is `None` for embedded reply content, which never gets fetchable media of its own
327
fn content(content: &TimelineItemContent, media_identity: Option<&str>) -> TimelineContent {
328
    match content {
329
        TimelineItemContent::MsgLike(content) => msg_like(content, media_identity),
330
        TimelineItemContent::MembershipChange(change) => TimelineContent::Membership {
331
            user_id: change.user_id().to_owned(),
332
            display_name: change.display_name(),
333
            change: change
334
                .change()
335
                .map_or(MembershipChange::Other, membership_change),
336
        },
337
        TimelineItemContent::ProfileChange(change) => TimelineContent::Profile {
338
            user_id: change.user_id().to_owned(),
339
            display_name: change.displayname_change().map(|change| Change {
340
                old: change.old.clone(),
341
                new: change.new.clone(),
342
            }),
343
            avatar: change.avatar_url_change().map(|change| Change {
344
                old: change.old.as_deref().map(MediaId::plain),
345
                new: change.new.as_deref().map(MediaId::plain),
346
            }),
347
        },
348
        TimelineItemContent::OtherState(state) => match state.content() {
349
            AnyOtherStateEventContentChange::RoomName(change) => TimelineContent::RoomName(
350
                original(change).and_then(|content| non_empty(&content.name)),
351
            ),
352
            AnyOtherStateEventContentChange::RoomTopic(change) => TimelineContent::RoomTopic(
353
                original(change).and_then(|content| non_empty(&content.topic)),
354
            ),
355
            AnyOtherStateEventContentChange::RoomAvatar(change) => TimelineContent::RoomAvatar(
356
                original(change).and_then(|content| content.url.as_deref().map(MediaId::plain)),
357
            ),
358
            AnyOtherStateEventContentChange::RoomEncryption(_) => {
359
                TimelineContent::EncryptionEnabled
360
            }
361
            AnyOtherStateEventContentChange::RoomCreate(_) => TimelineContent::RoomCreated,
362
            AnyOtherStateEventContentChange::RoomTombstone(change) => match original(change) {
363
                Some(content) => TimelineContent::RoomUpgraded {
364
                    successor: content.replacement_room.clone(),
365
                },
366
                None => other_state(state.content().event_type().to_string(), state.state_key()),
367
            },
368
            other => other_state(other.event_type().to_string(), state.state_key()),
369
        },
370
        TimelineItemContent::FailedToParseMessageLike { event_type, .. } => {
371
            TimelineContent::Other {
372
                event_type: event_type.to_string(),
373
            }
374
        }
375
        TimelineItemContent::FailedToParseState {
376
            event_type,
377
            state_key,
378
            ..
379
        } => other_state(event_type.to_string(), state_key),
380
        TimelineItemContent::CallInvite => TimelineContent::Other {
381
            event_type: "m.call.invite".to_owned(),
382
        },
383
        TimelineItemContent::RtcNotification { .. } => TimelineContent::Other {
384
            event_type: "org.matrix.msc4075.rtc.notification".to_owned(),
385
        },
386
    }
387
}
388
389
/// a cleared name or topic arrives as an empty string
390
fn non_empty(value: &str) -> Option<String> {
391
    (!value.is_empty()).then(|| value.to_owned())
392
}
393
394
fn other_state(event_type: String, state_key: &str) -> TimelineContent {
395
    TimelineContent::OtherState {
396
        event_type,
397
        state_key: state_key.to_owned(),
398
    }
399
}
400
401
fn original<C>(change: &StateEventContentChange<C>) -> Option<&C>
402
where
403
    C: ruma::events::StaticStateEventContent + ruma::events::RedactContent,
404
{
405
    match change {
406
        StateEventContentChange::Original { content, .. } => Some(content),
407
        StateEventContentChange::Redacted(_) => None,
408
    }
409
}
410
411
fn msg_like(content: &MsgLikeContent, media_identity: Option<&str>) -> TimelineContent {
412
    match &content.kind {
413
        MsgLikeKind::Message(message) => TimelineContent::Message(Message {
414
            kind: message_kind(message.msgtype()),
415
            body: message.body().to_owned(),
416
            geo_uri: match message.msgtype() {
417
                MessageType::Location(location) => Some(location.geo_uri.clone()),
418
                _ => None,
419
            },
420
            formatted_body: formatted_body(message.msgtype()),
421
            mentions: message.mentions().map(|mentions| MessageMentions {
422
                user_ids: mentions.user_ids.iter().cloned().collect(),
423
                room: mentions.room,
424
            }),
425
            edited: message.is_edited(),
426
            media: media_identity
427
                .and_then(|identity| media_from_message(identity, message.msgtype())),
428
            link_previews: sender_previews(media_identity, message.msgtype())
429
                .into_iter()
430
                .map(|(preview, _)| preview)
431
                .collect(),
432
        }),
433
        MsgLikeKind::Sticker(sticker) => {
434
            let sticker = sticker.content();
435
            match media_identity {
436
                Some(identity) => TimelineContent::Sticker {
437
                    body: sticker.body.clone(),
438
                    media: media_descriptor_for_sticker(identity, sticker),
439
                },
440
                None => TimelineContent::Other {
441
                    event_type: "m.sticker".to_owned(),
442
                },
443
            }
444
        }
445
        MsgLikeKind::Poll(poll) => TimelineContent::Poll {
446
            question: poll.results().question,
447
        },
448
        MsgLikeKind::Redacted => TimelineContent::Redacted,
449
        MsgLikeKind::UnableToDecrypt(encrypted) => {
450
            TimelineContent::UnableToDecrypt(match encrypted {
451
                EncryptedMessage::MegolmV1AesSha2 { cause, .. } => utd_cause(*cause),
452
                _ => UtdCause::Unknown,
453
            })
454
        }
455
        MsgLikeKind::Other(other) => TimelineContent::Other {
456
            event_type: other.event_type().to_string(),
457
        },
458
        MsgLikeKind::LiveLocation(_) => TimelineContent::Other {
459
            event_type: "m.beacon_info".to_owned(),
460
        },
461
    }
462
}
463
464
pub(crate) fn message_kind(message: &MessageType) -> MessageKind {
465
    match message {
466
        MessageType::Text(_) => MessageKind::Text,
467
        MessageType::Emote(_) => MessageKind::Emote,
468
        MessageType::Notice(_) => MessageKind::Notice,
469
        MessageType::ServerNotice(_) => MessageKind::ServerNotice,
470
        MessageType::Image(_) => MessageKind::Image,
471
        MessageType::Video(_) => MessageKind::Video,
472
        MessageType::Audio(_) => MessageKind::Audio,
473
        MessageType::File(_) => MessageKind::File,
474
        MessageType::Location(_) => MessageKind::Location,
475
        MessageType::VerificationRequest(_) => MessageKind::VerificationRequest,
476
        other => MessageKind::Other(other.msgtype().to_owned()),
477
    }
478
}
479
480
const fn membership_change(change: SdkMembershipChange) -> MembershipChange {
481
    match change {
482
        SdkMembershipChange::Joined => MembershipChange::Joined,
483
        SdkMembershipChange::Left => MembershipChange::Left,
484
        SdkMembershipChange::Banned => MembershipChange::Banned,
485
        SdkMembershipChange::Unbanned => MembershipChange::Unbanned,
486
        SdkMembershipChange::Kicked => MembershipChange::Kicked,
487
        SdkMembershipChange::KickedAndBanned => MembershipChange::KickedAndBanned,
488
        SdkMembershipChange::Invited => MembershipChange::Invited,
489
        SdkMembershipChange::InvitationAccepted => MembershipChange::InvitationAccepted,
490
        SdkMembershipChange::InvitationRejected => MembershipChange::InvitationRejected,
491
        SdkMembershipChange::InvitationRevoked => MembershipChange::InvitationRevoked,
492
        SdkMembershipChange::Knocked => MembershipChange::Knocked,
493
        SdkMembershipChange::KnockAccepted => MembershipChange::KnockAccepted,
494
        SdkMembershipChange::KnockRetracted => MembershipChange::KnockRetracted,
495
        SdkMembershipChange::KnockDenied => MembershipChange::KnockDenied,
496
        SdkMembershipChange::None
497
        | SdkMembershipChange::Error
498
        | SdkMembershipChange::NotImplemented => MembershipChange::Other,
499
    }
500
}
501
502
const fn utd_cause(cause: SdkUtdCause) -> UtdCause {
503
    match cause {
504
        SdkUtdCause::Unknown => UtdCause::Unknown,
505
        SdkUtdCause::SentBeforeWeJoined => UtdCause::SentBeforeWeJoined,
506
        SdkUtdCause::VerificationViolation => UtdCause::VerificationViolation,
507
        SdkUtdCause::UnsignedDevice => UtdCause::UnsignedDevice,
508
        SdkUtdCause::UnknownDevice => UtdCause::UnknownDevice,
509
        SdkUtdCause::HistoricalMessageAndBackupIsDisabled => {
510
            UtdCause::HistoricalMessageAndBackupIsDisabled
511
        }
512
        SdkUtdCause::WithheldForUnverifiedOrInsecureDevice => {
513
            UtdCause::WithheldForUnverifiedOrInsecureDevice
514
        }
515
        SdkUtdCause::WithheldBySender => UtdCause::WithheldBySender,
516
        SdkUtdCause::HistoricalMessageAndDeviceIsUnverified => {
517
            UtdCause::HistoricalMessageAndDeviceIsUnverified
518
        }
519
    }
520
}
521
522
fn sender(user_id: &UserId, profile: &TimelineDetails<Profile>) -> Sender {
523
    let profile = match profile {
524
        TimelineDetails::Ready(profile) => Some(profile),
525
        _ => None,
526
    };
527
    Sender {
528
        user_id: user_id.to_owned(),
529
        display_name: profile.and_then(|profile| profile.display_name.clone()),
530
        avatar: profile.and_then(|profile| profile.avatar_url.as_deref().map(MediaId::plain)),
531
        name_ambiguous: profile.is_some_and(|profile| profile.display_name_ambiguous),
532
    }
533
}
534
535
fn send_state(state: Option<&EventSendState>) -> SendState {
536
    match state {
537
        None | Some(EventSendState::Sent { .. }) => SendState::Sent,
538
        Some(EventSendState::NotSentYet { .. }) => SendState::Sending,
539
        // recoverable means the sdk parks the queue and we unpark it, nothing for the user to do
540
        Some(EventSendState::SendingFailed { is_recoverable, .. }) => SendState::Failed {
541
            will_retry: *is_recoverable,
542
        },
543
    }
544
}
545
546
fn thread_summary(content: &MsgLikeContent) -> Option<ThreadSummary> {
547
    content.thread_summary.as_ref().map(|summary| {
548
        let latest_event = match &summary.latest_event {
549
            TimelineDetails::Ready(event) => Some(event.as_ref()),
550
            _ => None,
551
        };
552
        ThreadSummary {
553
            reply_count: summary.num_replies,
554
            latest_event_id: latest_event.and_then(|event| match &event.identifier {
555
                TimelineEventItemId::EventId(event_id) => Some(event_id.clone()),
556
                TimelineEventItemId::TransactionId(_) => None,
557
            }),
558
            latest_event_timestamp_ms: latest_event.map(|event| event.timestamp.get().into()),
559
        }
560
    })
561
}
562
563
fn reactions(event: &EventTimelineItem, current_user_id: Option<&UserId>) -> Vec<Reaction> {
564
    event
565
        .content()
566
        .reactions()
567
        .into_iter()
568
        .flat_map(|reactions| reactions.iter())
569
        .map(|(key, senders)| Reaction {
570
            key: key.clone(),
571
            shortcode: senders.values().find_map(|info| info.shortcode.clone()),
572
            image: <&MxcUri>::from(key.as_str())
573
                .is_valid()
574
                .then(|| MediaId::plain(key)),
575
            count: senders.len(),
576
            own: current_user_id.is_some_and(|user_id| senders.contains_key(user_id)),
577
            senders: senders.keys().cloned().collect(),
578
        })
579
        .collect()
580
}
581
582
pub(super) fn formatted_body(message: &MessageType) -> Option<String> {
583
    let formatted = match message {
584
        MessageType::Text(content) => content.formatted.as_ref(),
585
        MessageType::Emote(content) => content.formatted.as_ref(),
586
        MessageType::Notice(content) => content.formatted.as_ref(),
587
        MessageType::Audio(content) => content.formatted_caption(),
588
        MessageType::File(content) => content.formatted_caption(),
589
        MessageType::Image(content) => content.formatted_caption(),
590
        MessageType::Video(content) => content.formatted_caption(),
591
        _ => None,
592
    }?;
593
    (formatted.format == MessageFormat::Html).then(|| formatted.body.clone())
594
}
595
596
#[cfg(test)]
597
mod tests {
598
    use super::*;
599
600
    fn row(n: u64) -> Option<TimelineItem> {
601
        Some(TimelineItem::DayDivider { timestamp_ms: n })
602
    }
603
604
    #[test]
605
    fn hidden_rows_keep_diff_indices_on_the_visible_timeline() {
606
        let mut sdk: imbl::Vector<Option<TimelineItem>> = imbl::Vector::new();
607
        let mut hidden = HiddenRows::default();
608
        let mut visible: imbl::Vector<TimelineItem> = imbl::Vector::new();
609
        let diffs = [
610
            VectorDiff::Reset {
611
                values: [row(0), None, row(1)].into_iter().collect(),
612
            },
613
            VectorDiff::Insert {
614
                index: 1,
615
                value: row(2),
616
            },
617
            VectorDiff::Set {
618
                index: 2,
619
                value: row(3),
620
            },
621
            VectorDiff::Set {
622
                index: 0,
623
                value: None,
624
            },
625
            VectorDiff::PushFront { value: None },
626
            VectorDiff::Remove { index: 3 },
627
            VectorDiff::Append {
628
                values: [None, row(4)].into_iter().collect(),
629
            },
630
            VectorDiff::PopFront,
631
            VectorDiff::Truncate { length: 2 },
632
            VectorDiff::PopBack,
633
        ];
634
        for diff in diffs {
635
            diff.clone().apply(&mut sdk);
636
            if let Some(diff) = hidden.translate(diff) {
637
                diff.try_apply(&mut visible).unwrap();
638
            }
639
            let expected: imbl::Vector<_> = sdk.iter().flatten().cloned().collect();
640
            assert_eq!(visible, expected);
641
        }
642
    }
643
}