atagen

macltok

public
Star0
Watch0
913832a327a82f6b019568117ba636d069143124
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git

Blame: src/normalise/context.rs

cb7b3362e1ceatagen2026-10-25 09:08 UTC1
use std::collections::HashMap;
cb7b3362e1ceatagen2026-10-25 09:08 UTC2
cb7b3362e1ceatagen2026-10-25 09:08 UTC3
use ruma::{MxcUri, OwnedEventId, OwnedUserId, RoomId, UserId};
cb7b3362e1ceatagen2026-10-25 09:08 UTC4
use serde_json::Value;
cb7b3362e1ceatagen2026-10-25 09:08 UTC5
cb7b3362e1ceatagen2026-10-25 09:08 UTC6
use super::{media::MediaRegistration, observed::normalise_observed};
cb7b3362e1ceatagen2026-10-25 09:08 UTC7
use crate::{MediaId, Reaction, ReplyDetails, Sender, TimelineEvent, TimelineItem};
cb7b3362e1ceatagen2026-10-25 09:08 UTC8
cb7b3362e1ceatagen2026-10-25 09:08 UTC9
pub(crate) struct ContextPage {
cb7b3362e1ceatagen2026-10-25 09:08 UTC10
    /// each with the index of the raw event it came from
cb7b3362e1ceatagen2026-10-25 09:08 UTC11
    pub(crate) items: Vec<(usize, TimelineItem)>,
cb7b3362e1ceatagen2026-10-25 09:08 UTC12
    pub(crate) media: Vec<MediaRegistration>,
cb7b3362e1ceatagen2026-10-25 09:08 UTC13
}
cb7b3362e1ceatagen2026-10-25 09:08 UTC14
cb7b3362e1ceatagen2026-10-25 09:08 UTC15
struct Annotation<'a> {
cb7b3362e1ceatagen2026-10-25 09:08 UTC16
    key: &'a str,
cb7b3362e1ceatagen2026-10-25 09:08 UTC17
    sender: &'a str,
cb7b3362e1ceatagen2026-10-25 09:08 UTC18
    shortcode: Option<&'a str>,
cb7b3362e1ceatagen2026-10-25 09:08 UTC19
}
cb7b3362e1ceatagen2026-10-25 09:08 UTC20
cb7b3362e1ceatagen2026-10-25 09:08 UTC21
/// raw events in timeline order, shown the way the live room timeline shows them. edits and
cb7b3362e1ceatagen2026-10-25 09:08 UTC22
/// reactions fold into their targets within the page, and redactions, unmatched relations
cb7b3362e1ceatagen2026-10-25 09:08 UTC23
/// and thread replies go. `keep` is never dropped
cb7b3362e1ceatagen2026-10-25 09:08 UTC24
pub(crate) fn normalise_context(
cb7b3362e1ceatagen2026-10-25 09:08 UTC25
    values: &[Value],
cb7b3362e1ceatagen2026-10-25 09:08 UTC26
    keep: Option<usize>,
cb7b3362e1ceatagen2026-10-25 09:08 UTC27
    current_user_id: Option<&UserId>,
cb7b3362e1ceatagen2026-10-25 09:08 UTC28
    room_id: &RoomId,
cb7b3362e1ceatagen2026-10-25 09:08 UTC29
) -> ContextPage {
cb7b3362e1ceatagen2026-10-25 09:08 UTC30
    let mut edits: HashMap<&str, Vec<&Value>> = HashMap::new();
cb7b3362e1ceatagen2026-10-25 09:08 UTC31
    let mut annotations: HashMap<&str, Vec<Annotation<'_>>> = HashMap::new();
cb7b3362e1ceatagen2026-10-25 09:08 UTC32
    let mut shown = Vec::new();
cb7b3362e1ceatagen2026-10-25 09:08 UTC33
    for (index, value) in values.iter().enumerate() {
cb7b3362e1ceatagen2026-10-25 09:08 UTC34
        let str_at = |pointer: &str| value.pointer(pointer).and_then(Value::as_str);
cb7b3362e1ceatagen2026-10-25 09:08 UTC35
        let target = str_at("/content/m.relates_to/event_id");
cb7b3362e1ceatagen2026-10-25 09:08 UTC36
        if Some(index) == keep {
cb7b3362e1ceatagen2026-10-25 09:08 UTC37
            shown.push(index);
cb7b3362e1ceatagen2026-10-25 09:08 UTC38
            continue;
cb7b3362e1ceatagen2026-10-25 09:08 UTC39
        }
cb7b3362e1ceatagen2026-10-25 09:08 UTC40
        match (
cb7b3362e1ceatagen2026-10-25 09:08 UTC41
            str_at("/type"),
cb7b3362e1ceatagen2026-10-25 09:08 UTC42
            str_at("/content/m.relates_to/rel_type"),
cb7b3362e1ceatagen2026-10-25 09:08 UTC43
            target,
cb7b3362e1ceatagen2026-10-25 09:08 UTC44
        ) {
cb7b3362e1ceatagen2026-10-25 09:08 UTC45
            (Some("m.room.redaction"), ..) => {}
cb7b3362e1ceatagen2026-10-25 09:08 UTC46
            (Some("m.reaction"), Some("m.annotation"), Some(target)) => {
cb7b3362e1ceatagen2026-10-25 09:08 UTC47
                if let (Some(key), Some(sender)) =
cb7b3362e1ceatagen2026-10-25 09:08 UTC48
                    (str_at("/content/m.relates_to/key"), str_at("/sender"))
cb7b3362e1ceatagen2026-10-25 09:08 UTC49
                {
cb7b3362e1ceatagen2026-10-25 09:08 UTC50
                    annotations.entry(target).or_default().push(Annotation {
cb7b3362e1ceatagen2026-10-25 09:08 UTC51
                        key,
cb7b3362e1ceatagen2026-10-25 09:08 UTC52
                        sender,
cb7b3362e1ceatagen2026-10-25 09:08 UTC53
                        shortcode: str_at("/content/com.beeper.reaction.shortcode")
cb7b3362e1ceatagen2026-10-25 09:08 UTC54
                            .or_else(|| str_at("/content/shortcode")),
cb7b3362e1ceatagen2026-10-25 09:08 UTC55
                    });
cb7b3362e1ceatagen2026-10-25 09:08 UTC56
                }
cb7b3362e1ceatagen2026-10-25 09:08 UTC57
            }
cb7b3362e1ceatagen2026-10-25 09:08 UTC58
            (Some("m.reaction"), ..) => {}
cb7b3362e1ceatagen2026-10-25 09:08 UTC59
            (_, Some("m.replace"), Some(target)) => edits.entry(target).or_default().push(value),
cb7b3362e1ceatagen2026-10-25 09:08 UTC60
            (_, Some("m.thread"), _) => {}
cb7b3362e1ceatagen2026-10-25 09:08 UTC61
            _ => shown.push(index),
cb7b3362e1ceatagen2026-10-25 09:08 UTC62
        }
cb7b3362e1ceatagen2026-10-25 09:08 UTC63
    }
cb7b3362e1ceatagen2026-10-25 09:08 UTC64
    let mut media = Vec::new();
cb7b3362e1ceatagen2026-10-25 09:08 UTC65
    let items = shown
cb7b3362e1ceatagen2026-10-25 09:08 UTC66
        .into_iter()
cb7b3362e1ceatagen2026-10-25 09:08 UTC67
        .filter_map(|index| {
cb7b3362e1ceatagen2026-10-25 09:08 UTC68
            let mut value = std::borrow::Cow::Borrowed(&values[index]);
cb7b3362e1ceatagen2026-10-25 09:08 UTC69
            let event_id = value.get("event_id")?.as_str()?.to_owned();
cb7b3362e1ceatagen2026-10-25 09:08 UTC70
            let latest_edit = edits.get(event_id.as_str()).and_then(|edits| {
cb7b3362e1ceatagen2026-10-25 09:08 UTC71
                edits
cb7b3362e1ceatagen2026-10-25 09:08 UTC72
                    .iter()
cb7b3362e1ceatagen2026-10-25 09:08 UTC73
                    .rev()
cb7b3362e1ceatagen2026-10-25 09:08 UTC74
                    .find(|edit| edit.get("sender") == value.get("sender"))
cb7b3362e1ceatagen2026-10-25 09:08 UTC75
            });
cb7b3362e1ceatagen2026-10-25 09:08 UTC76
            if let Some(edit) = latest_edit.copied()
cb7b3362e1ceatagen2026-10-25 09:08 UTC77
                && value.pointer("/unsigned/m.relations/m.replace").is_none()
cb7b3362e1ceatagen2026-10-25 09:08 UTC78
            {
cb7b3362e1ceatagen2026-10-25 09:08 UTC79
                let unsigned = value
cb7b3362e1ceatagen2026-10-25 09:08 UTC80
                    .to_mut()
cb7b3362e1ceatagen2026-10-25 09:08 UTC81
                    .as_object_mut()?
cb7b3362e1ceatagen2026-10-25 09:08 UTC82
                    .entry("unsigned")
cb7b3362e1ceatagen2026-10-25 09:08 UTC83
                    .or_insert_with(|| Value::Object(Default::default()));
cb7b3362e1ceatagen2026-10-25 09:08 UTC84
                let relations = unsigned
cb7b3362e1ceatagen2026-10-25 09:08 UTC85
                    .as_object_mut()?
cb7b3362e1ceatagen2026-10-25 09:08 UTC86
                    .entry("m.relations")
cb7b3362e1ceatagen2026-10-25 09:08 UTC87
                    .or_insert_with(|| Value::Object(Default::default()));
cb7b3362e1ceatagen2026-10-25 09:08 UTC88
                relations
cb7b3362e1ceatagen2026-10-25 09:08 UTC89
                    .as_object_mut()?
cb7b3362e1ceatagen2026-10-25 09:08 UTC90
                    .insert("m.replace".to_owned(), edit.clone());
cb7b3362e1ceatagen2026-10-25 09:08 UTC91
            }
cb7b3362e1ceatagen2026-10-25 09:08 UTC92
            let identity = format!("{room_id}\n{event_id}");
cb7b3362e1ceatagen2026-10-25 09:08 UTC93
            let (mut item, registrations) = normalise_observed(&value, current_user_id, &identity)?;
cb7b3362e1ceatagen2026-10-25 09:08 UTC94
            media.extend(registrations);
cb7b3362e1ceatagen2026-10-25 09:08 UTC95
            if let (TimelineItem::Event(event), Some(annotations)) =
cb7b3362e1ceatagen2026-10-25 09:08 UTC96
                (&mut item, annotations.get(event_id.as_str()))
cb7b3362e1ceatagen2026-10-25 09:08 UTC97
            {
cb7b3362e1ceatagen2026-10-25 09:08 UTC98
                event.reactions = reactions(annotations, current_user_id);
cb7b3362e1ceatagen2026-10-25 09:08 UTC99
            }
cb7b3362e1ceatagen2026-10-25 09:08 UTC100
            Some((index, item))
cb7b3362e1ceatagen2026-10-25 09:08 UTC101
        })
cb7b3362e1ceatagen2026-10-25 09:08 UTC102
        .collect();
cb7b3362e1ceatagen2026-10-25 09:08 UTC103
    ContextPage { items, media }
cb7b3362e1ceatagen2026-10-25 09:08 UTC104
}
cb7b3362e1ceatagen2026-10-25 09:08 UTC105
cb7b3362e1ceatagen2026-10-25 09:08 UTC106
fn reactions(annotations: &[Annotation<'_>], current_user_id: Option<&UserId>) -> Vec<Reaction> {
cb7b3362e1ceatagen2026-10-25 09:08 UTC107
    let mut reactions: Vec<Reaction> = Vec::new();
cb7b3362e1ceatagen2026-10-25 09:08 UTC108
    for annotation in annotations {
cb7b3362e1ceatagen2026-10-25 09:08 UTC109
        let Ok(sender) = OwnedUserId::try_from(annotation.sender) else {
cb7b3362e1ceatagen2026-10-25 09:08 UTC110
            continue;
cb7b3362e1ceatagen2026-10-25 09:08 UTC111
        };
cb7b3362e1ceatagen2026-10-25 09:08 UTC112
        let index = match reactions.iter().position(|r| r.key == annotation.key) {
cb7b3362e1ceatagen2026-10-25 09:08 UTC113
            Some(index) => index,
cb7b3362e1ceatagen2026-10-25 09:08 UTC114
            None => {
cb7b3362e1ceatagen2026-10-25 09:08 UTC115
                reactions.push(Reaction {
cb7b3362e1ceatagen2026-10-25 09:08 UTC116
                    key: annotation.key.to_owned(),
cb7b3362e1ceatagen2026-10-25 09:08 UTC117
                    shortcode: None,
cb7b3362e1ceatagen2026-10-25 09:08 UTC118
                    image: <&MxcUri>::from(annotation.key)
cb7b3362e1ceatagen2026-10-25 09:08 UTC119
                        .is_valid()
cb7b3362e1ceatagen2026-10-25 09:08 UTC120
                        .then(|| MediaId::plain(annotation.key)),
cb7b3362e1ceatagen2026-10-25 09:08 UTC121
                    count: 0,
cb7b3362e1ceatagen2026-10-25 09:08 UTC122
                    own: false,
cb7b3362e1ceatagen2026-10-25 09:08 UTC123
                    senders: Vec::new(),
cb7b3362e1ceatagen2026-10-25 09:08 UTC124
                });
cb7b3362e1ceatagen2026-10-25 09:08 UTC125
                reactions.len() - 1
cb7b3362e1ceatagen2026-10-25 09:08 UTC126
            }
cb7b3362e1ceatagen2026-10-25 09:08 UTC127
        };
cb7b3362e1ceatagen2026-10-25 09:08 UTC128
        let reaction = &mut reactions[index];
cb7b3362e1ceatagen2026-10-25 09:08 UTC129
        if reaction.senders.contains(&sender) {
cb7b3362e1ceatagen2026-10-25 09:08 UTC130
            continue;
cb7b3362e1ceatagen2026-10-25 09:08 UTC131
        }
cb7b3362e1ceatagen2026-10-25 09:08 UTC132
        reaction.own |= current_user_id == Some(sender.as_ref());
cb7b3362e1ceatagen2026-10-25 09:08 UTC133
        reaction.senders.push(sender);
cb7b3362e1ceatagen2026-10-25 09:08 UTC134
        reaction.count = reaction.senders.len();
cb7b3362e1ceatagen2026-10-25 09:08 UTC135
        if reaction.shortcode.is_none() {
cb7b3362e1ceatagen2026-10-25 09:08 UTC136
            reaction.shortcode = annotation.shortcode.map(str::to_owned);
cb7b3362e1ceatagen2026-10-25 09:08 UTC137
        }
cb7b3362e1ceatagen2026-10-25 09:08 UTC138
    }
cb7b3362e1ceatagen2026-10-25 09:08 UTC139
    reactions
cb7b3362e1ceatagen2026-10-25 09:08 UTC140
}
cb7b3362e1ceatagen2026-10-25 09:08 UTC141
cb7b3362e1ceatagen2026-10-25 09:08 UTC142
/// the sender's member data, for each event and every reply quoted in it
cb7b3362e1ceatagen2026-10-25 09:08 UTC143
pub(crate) fn apply_profiles<'a>(
cb7b3362e1ceatagen2026-10-25 09:08 UTC144
    events: impl Iterator<Item = &'a mut TimelineEvent>,
cb7b3362e1ceatagen2026-10-25 09:08 UTC145
    profiles: &HashMap<OwnedUserId, Sender>,
cb7b3362e1ceatagen2026-10-25 09:08 UTC146
) {
cb7b3362e1ceatagen2026-10-25 09:08 UTC147
    for event in events {
cb7b3362e1ceatagen2026-10-25 09:08 UTC148
        if let Some(sender) = profiles.get(&event.sender.user_id) {
cb7b3362e1ceatagen2026-10-25 09:08 UTC149
            event.sender = sender.clone();
cb7b3362e1ceatagen2026-10-25 09:08 UTC150
        }
cb7b3362e1ceatagen2026-10-25 09:08 UTC151
    }
cb7b3362e1ceatagen2026-10-25 09:08 UTC152
}
cb7b3362e1ceatagen2026-10-25 09:08 UTC153
cb7b3362e1ceatagen2026-10-25 09:08 UTC154
/// settles every reply, from `targets` or as unavailable
cb7b3362e1ceatagen2026-10-25 09:08 UTC155
pub(crate) fn resolve_replies<'a>(
cb7b3362e1ceatagen2026-10-25 09:08 UTC156
    events: impl Iterator<Item = &'a mut TimelineEvent>,
cb7b3362e1ceatagen2026-10-25 09:08 UTC157
    targets: &HashMap<OwnedEventId, TimelineEvent>,
cb7b3362e1ceatagen2026-10-25 09:08 UTC158
) {
cb7b3362e1ceatagen2026-10-25 09:08 UTC159
    for event in events {
cb7b3362e1ceatagen2026-10-25 09:08 UTC160
        let Some(reply) = event.reply_to.as_mut() else {
cb7b3362e1ceatagen2026-10-25 09:08 UTC161
            continue;
cb7b3362e1ceatagen2026-10-25 09:08 UTC162
        };
cb7b3362e1ceatagen2026-10-25 09:08 UTC163
        if !matches!(reply.details, ReplyDetails::Pending) {
cb7b3362e1ceatagen2026-10-25 09:08 UTC164
            continue;
cb7b3362e1ceatagen2026-10-25 09:08 UTC165
        }
cb7b3362e1ceatagen2026-10-25 09:08 UTC166
        reply.details = match targets.get(&reply.event_id) {
cb7b3362e1ceatagen2026-10-25 09:08 UTC167
            Some(target) => ReplyDetails::Ready {
cb7b3362e1ceatagen2026-10-25 09:08 UTC168
                sender: target.sender.clone(),
cb7b3362e1ceatagen2026-10-25 09:08 UTC169
                content: Box::new(target.content.clone()),
cb7b3362e1ceatagen2026-10-25 09:08 UTC170
                timestamp_ms: target.timestamp_ms,
cb7b3362e1ceatagen2026-10-25 09:08 UTC171
            },
cb7b3362e1ceatagen2026-10-25 09:08 UTC172
            None => ReplyDetails::Unavailable,
cb7b3362e1ceatagen2026-10-25 09:08 UTC173
        };
cb7b3362e1ceatagen2026-10-25 09:08 UTC174
    }
cb7b3362e1ceatagen2026-10-25 09:08 UTC175
}
cb7b3362e1ceatagen2026-10-25 09:08 UTC176
cb7b3362e1ceatagen2026-10-25 09:08 UTC177
#[cfg(test)]
cb7b3362e1ceatagen2026-10-25 09:08 UTC178
mod tests {
cb7b3362e1ceatagen2026-10-25 09:08 UTC179
    use ruma::{room_id, user_id};
cb7b3362e1ceatagen2026-10-25 09:08 UTC180
    use serde_json::json;
cb7b3362e1ceatagen2026-10-25 09:08 UTC181
cb7b3362e1ceatagen2026-10-25 09:08 UTC182
    use super::*;
cb7b3362e1ceatagen2026-10-25 09:08 UTC183
    use crate::TimelineContent;
cb7b3362e1ceatagen2026-10-25 09:08 UTC184
cb7b3362e1ceatagen2026-10-25 09:08 UTC185
    fn message(id: &str, sender: &str, body: &str) -> Value {
cb7b3362e1ceatagen2026-10-25 09:08 UTC186
        json!({
cb7b3362e1ceatagen2026-10-25 09:08 UTC187
            "type": "m.room.message",
cb7b3362e1ceatagen2026-10-25 09:08 UTC188
            "event_id": id,
cb7b3362e1ceatagen2026-10-25 09:08 UTC189
            "sender": sender,
cb7b3362e1ceatagen2026-10-25 09:08 UTC190
            "origin_server_ts": 1,
cb7b3362e1ceatagen2026-10-25 09:08 UTC191
            "content": { "msgtype": "m.text", "body": body },
cb7b3362e1ceatagen2026-10-25 09:08 UTC192
        })
cb7b3362e1ceatagen2026-10-25 09:08 UTC193
    }
cb7b3362e1ceatagen2026-10-25 09:08 UTC194
cb7b3362e1ceatagen2026-10-25 09:08 UTC195
    #[test]
cb7b3362e1ceatagen2026-10-25 09:08 UTC196
    fn edits_and_reactions_fold_into_their_targets() {
cb7b3362e1ceatagen2026-10-25 09:08 UTC197
        let mut edit = message("$edit", "@a:x", "* fixed");
cb7b3362e1ceatagen2026-10-25 09:08 UTC198
        edit["content"]["m.new_content"] = json!({ "msgtype": "m.text", "body": "fixed" });
cb7b3362e1ceatagen2026-10-25 09:08 UTC199
        edit["content"]["m.relates_to"] = json!({ "rel_type": "m.replace", "event_id": "$orig" });
cb7b3362e1ceatagen2026-10-25 09:08 UTC200
        let mut foreign_edit = message("$foreign", "@b:x", "* hijack");
cb7b3362e1ceatagen2026-10-25 09:08 UTC201
        foreign_edit["content"]["m.new_content"] = json!({ "msgtype": "m.text", "body": "hijack" });
cb7b3362e1ceatagen2026-10-25 09:08 UTC202
        foreign_edit["content"]["m.relates_to"] =
cb7b3362e1ceatagen2026-10-25 09:08 UTC203
            json!({ "rel_type": "m.replace", "event_id": "$orig" });
cb7b3362e1ceatagen2026-10-25 09:08 UTC204
        let reaction = |id: &str, sender: &str| {
cb7b3362e1ceatagen2026-10-25 09:08 UTC205
            json!({
cb7b3362e1ceatagen2026-10-25 09:08 UTC206
                "type": "m.reaction",
cb7b3362e1ceatagen2026-10-25 09:08 UTC207
                "event_id": id,
cb7b3362e1ceatagen2026-10-25 09:08 UTC208
                "sender": sender,
cb7b3362e1ceatagen2026-10-25 09:08 UTC209
                "origin_server_ts": 1,
cb7b3362e1ceatagen2026-10-25 09:08 UTC210
                "content": { "m.relates_to": {
cb7b3362e1ceatagen2026-10-25 09:08 UTC211
                    "rel_type": "m.annotation", "event_id": "$orig", "key": "👍",
cb7b3362e1ceatagen2026-10-25 09:08 UTC212
                }},
cb7b3362e1ceatagen2026-10-25 09:08 UTC213
            })
cb7b3362e1ceatagen2026-10-25 09:08 UTC214
        };
cb7b3362e1ceatagen2026-10-25 09:08 UTC215
        let mut threaded = message("$threaded", "@b:x", "in a thread");
cb7b3362e1ceatagen2026-10-25 09:08 UTC216
        threaded["content"]["m.relates_to"] =
cb7b3362e1ceatagen2026-10-25 09:08 UTC217
            json!({ "rel_type": "m.thread", "event_id": "$orig" });
cb7b3362e1ceatagen2026-10-25 09:08 UTC218
        let values = [
cb7b3362e1ceatagen2026-10-25 09:08 UTC219
            message("$orig", "@a:x", "typo"),
cb7b3362e1ceatagen2026-10-25 09:08 UTC220
            edit,
cb7b3362e1ceatagen2026-10-25 09:08 UTC221
            foreign_edit,
cb7b3362e1ceatagen2026-10-25 09:08 UTC222
            reaction("$r1", "@b:x"),
cb7b3362e1ceatagen2026-10-25 09:08 UTC223
            reaction("$r2", "@b:x"),
cb7b3362e1ceatagen2026-10-25 09:08 UTC224
            reaction("$r3", "@a:x"),
cb7b3362e1ceatagen2026-10-25 09:08 UTC225
            threaded,
cb7b3362e1ceatagen2026-10-25 09:08 UTC226
            json!({
cb7b3362e1ceatagen2026-10-25 09:08 UTC227
                "type": "m.room.redaction",
cb7b3362e1ceatagen2026-10-25 09:08 UTC228
                "event_id": "$redaction",
cb7b3362e1ceatagen2026-10-25 09:08 UTC229
                "sender": "@a:x",
cb7b3362e1ceatagen2026-10-25 09:08 UTC230
                "origin_server_ts": 1,
cb7b3362e1ceatagen2026-10-25 09:08 UTC231
                "redacts": "$gone",
cb7b3362e1ceatagen2026-10-25 09:08 UTC232
                "content": {},
cb7b3362e1ceatagen2026-10-25 09:08 UTC233
            }),
cb7b3362e1ceatagen2026-10-25 09:08 UTC234
        ];
cb7b3362e1ceatagen2026-10-25 09:08 UTC235
        let page = normalise_context(&values, None, Some(user_id!("@a:x")), room_id!("!room:x"));
cb7b3362e1ceatagen2026-10-25 09:08 UTC236
        assert_eq!(page.items.len(), 1);
cb7b3362e1ceatagen2026-10-25 09:08 UTC237
        let TimelineItem::Event(event) = &page.items[0].1 else {
cb7b3362e1ceatagen2026-10-25 09:08 UTC238
            panic!("expected an event");
cb7b3362e1ceatagen2026-10-25 09:08 UTC239
        };
cb7b3362e1ceatagen2026-10-25 09:08 UTC240
        let TimelineContent::Message(message) = &event.content else {
cb7b3362e1ceatagen2026-10-25 09:08 UTC241
            panic!("expected a message");
cb7b3362e1ceatagen2026-10-25 09:08 UTC242
        };
cb7b3362e1ceatagen2026-10-25 09:08 UTC243
        assert_eq!(message.body, "fixed");
cb7b3362e1ceatagen2026-10-25 09:08 UTC244
        assert!(message.edited);
cb7b3362e1ceatagen2026-10-25 09:08 UTC245
        assert_eq!(event.reactions.len(), 1);
cb7b3362e1ceatagen2026-10-25 09:08 UTC246
        assert_eq!(event.reactions[0].count, 2);
cb7b3362e1ceatagen2026-10-25 09:08 UTC247
        assert!(event.reactions[0].own);
cb7b3362e1ceatagen2026-10-25 09:08 UTC248
    }
cb7b3362e1ceatagen2026-10-25 09:08 UTC249
}