use crate::ids::{EventId, UserId};
use crate::wire::events::{RelatesTo, RoomMessageContent};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Clone, Debug, PartialEq)]
pub struct EditRecord {
pub event_id: EventId,
pub content: RoomMessageContent,
pub origin_server_ts: i64,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RelationsBundle {
pub reactions: BTreeMap<String, BTreeSet<UserId>>,
pub latest_edit: Option<EditRecord>,
pub reply_to: Option<EventId>,
pub thread_root: Option<EventId>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RelatingEvent {
pub event_id: EventId,
pub sender: UserId,
pub origin_server_ts: i64,
pub relation: RelatesTo,
pub new_content: Option<serde_json::Value>,
}
pub fn aggregate_relation(bundle: &mut RelationsBundle, original_sender: &UserId, relating: &RelatingEvent) -> bool {
match &relating.relation {
RelatesTo::Annotation { key, .. } => {
bundle.reactions.entry(key.clone()).or_default().insert(relating.sender.clone());
true
}
RelatesTo::Replace { .. } => {
if &relating.sender != original_sender {
return false;
}
let Some(new_content) = relating.new_content.clone() else { return false };
let Ok(content) = serde_json::from_value::<RoomMessageContent>(new_content) else { return false };
let candidate =
EditRecord { event_id: relating.event_id.clone(), content, origin_server_ts: relating.origin_server_ts };
let is_newer = match &bundle.latest_edit {
None => true,
Some(current) => {
(candidate.origin_server_ts, &candidate.event_id) >= (current.origin_server_ts, ¤t.event_id)
}
};
if is_newer {
bundle.latest_edit = Some(candidate);
}
true
}
RelatesTo::InReplyTo(reply) => {
bundle.reply_to = Some(reply.event_id.clone());
true
}
RelatesTo::Thread { event_id, in_reply_to, .. } => {
bundle.thread_root = Some(event_id.clone());
if let Some(reply) = in_reply_to {
bundle.reply_to = Some(reply.event_id.clone());
}
true
}
RelatesTo::Unknown(_) => false,
}
}
pub fn recompute_bundle(original_sender: &UserId, relating_events: &[RelatingEvent]) -> RelationsBundle {
let mut bundle = RelationsBundle::default();
for relating in relating_events {
aggregate_relation(&mut bundle, original_sender, relating);
}
bundle
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::EventId;
fn user(value: &str) -> UserId {
UserId::parse(value).expect("valid user id")
}
fn target_id() -> EventId {
EventId::parse("$target:example.org").expect("valid event id")
}
fn replace_relation() -> RelatesTo {
match RelatesTo::from_content(&serde_json::json!({
"m.relates_to": { "rel_type": "m.replace", "event_id": target_id().as_str() }
})) {
Some(relation @ RelatesTo::Replace { .. }) => relation,
other => panic!("expected Replace, got {other:?}"),
}
}
fn edit_new_content(body: &str) -> serde_json::Value {
serde_json::json!({ "msgtype": "m.text", "body": body })
}
#[test]
fn aggregate_relation_replace_keeps_latest_only() {
let original_sender = user("@alice:example.org");
let first = RelatingEvent {
event_id: EventId::parse("$edit1:example.org").expect("valid event id"),
sender: original_sender.clone(),
origin_server_ts: 100,
relation: replace_relation(),
new_content: Some(edit_new_content("first edit")),
};
let second = RelatingEvent {
event_id: EventId::parse("$edit2:example.org").expect("valid event id"),
sender: original_sender.clone(),
origin_server_ts: 200,
relation: replace_relation(),
new_content: Some(edit_new_content("second edit")),
};
let mut bundle = RelationsBundle::default();
assert!(aggregate_relation(&mut bundle, &original_sender, &first));
assert!(aggregate_relation(&mut bundle, &original_sender, &second));
let latest = bundle.latest_edit.clone().expect("an edit was accepted");
assert_eq!(latest.event_id, second.event_id);
assert_eq!(
latest.content,
RoomMessageContent::Text(crate::wire::events::TextLikeMessageContent {
body: "second edit".to_string(),
format: None,
formatted_body: None,
})
);
let recomputed = recompute_bundle(&original_sender, &[second.clone(), first.clone()]);
assert_eq!(recomputed.latest_edit, bundle.latest_edit);
}
#[test]
fn edit_from_a_different_sender_is_ignored() {
let original_sender = user("@alice:example.org");
let impostor = user("@mallory:example.org");
let relating = RelatingEvent {
event_id: EventId::parse("$edit:example.org").expect("valid event id"),
sender: impostor,
origin_server_ts: 100,
relation: replace_relation(),
new_content: Some(edit_new_content("not really alice")),
};
let mut bundle = RelationsBundle::default();
assert!(!aggregate_relation(&mut bundle, &original_sender, &relating));
assert_eq!(bundle.latest_edit, None);
}
#[test]
fn annotation_aggregates_reactor_sets_per_key() {
let target = target_id();
let alice = user("@alice:example.org");
let bob = user("@bob:example.org");
let react = |sender: &UserId, key: &str, n: u64| RelatingEvent {
event_id: EventId::parse(format!("$r{n}:example.org")).expect("valid event id"),
sender: sender.clone(),
origin_server_ts: 100 + n as i64,
relation: RelatesTo::Annotation { event_id: target.clone(), key: key.to_string() },
new_content: None,
};
let mut bundle = RelationsBundle::default();
assert!(aggregate_relation(&mut bundle, &alice, &react(&alice, "\u{1F44D}", 1)));
assert!(aggregate_relation(&mut bundle, &alice, &react(&bob, "\u{1F44D}", 2)));
assert!(aggregate_relation(&mut bundle, &alice, &react(&bob, "\u{1F389}", 3)));
assert_eq!(bundle.reactions.get("\u{1F44D}").expect("thumbs-up present").len(), 2);
assert_eq!(bundle.reactions.get("\u{1F389}").expect("party present").len(), 1);
}
}