surface message edit history (no edit_message tool - shard commits to its words)

This commit is contained in:
Damocles 2026-05-01 13:45:23 +02:00
parent 9d490f5ca8
commit bc3a4782cc
4 changed files with 75 additions and 5 deletions

View file

@ -116,6 +116,7 @@ pub fn wire_event_from(
is_self, is_self,
ts, ts,
in_reply_to, in_reply_to,
edit_history,
} => { } => {
let read_by: Vec<String> = read_markers let read_by: Vec<String> = read_markers
.get(event_id) .get(event_id)
@ -135,6 +136,7 @@ pub fn wire_event_from(
body: body.clone(), body: body.clone(),
in_reply_to: in_reply_to.as_ref().map(|e| e.as_str().to_owned()), in_reply_to: in_reply_to.as_ref().map(|e| e.as_str().to_owned()),
read_by, read_by,
edit_history: edit_history.clone(),
} }
} }
TimelineItem::Reaction { TimelineItem::Reaction {

View file

@ -379,6 +379,7 @@ async fn fetch_event(
body: text.body.clone(), body: text.body.clone(),
in_reply_to, in_reply_to,
read_by: Vec::new(), read_by: Vec::new(),
edit_history: Vec::new(),
}) })
} }
matrix_sdk::ruma::events::AnySyncMessageLikeEvent::Reaction( matrix_sdk::ruma::events::AnySyncMessageLikeEvent::Reaction(

View file

@ -5,7 +5,7 @@ use matrix_sdk::{
ruma::{OwnedEventId, OwnedUserId, events::room::message::MessageType}, ruma::{OwnedEventId, OwnedUserId, events::room::message::MessageType},
}; };
use crate::types::TimelineItem; use crate::types::{EditRecord, TimelineItem};
/// Format a unix-seconds timestamp as `YYYY-MM-DD HH:MM` UTC. Returns "?" for 0. /// Format a unix-seconds timestamp as `YYYY-MM-DD HH:MM` UTC. Returns "?" for 0.
pub fn format_ts(secs: i64) -> String { pub fn format_ts(secs: i64) -> String {
@ -83,8 +83,13 @@ pub async fn load_timeline(
let (cache, _handles) = room.event_cache().await?; let (cache, _handles) = room.event_cache().await?;
let events = cache.events().await; let events = cache.events().await;
use matrix_sdk::ruma::events::room::message::Relation;
let mut messages: Vec<TimelineItem> = Vec::new(); let mut messages: Vec<TimelineItem> = Vec::new();
let mut reactions: Vec<TimelineItem> = Vec::new(); let mut reactions: Vec<TimelineItem> = Vec::new();
// Edits stashed by target event_id. Walk is newest-first, so edits may
// arrive before their target original. (body, ts) pairs.
let mut pending_edits: HashMap<OwnedEventId, Vec<(String, i64)>> = HashMap::new();
let mut earliest_message_ts: Option<i64> = None; let mut earliest_message_ts: Option<i64> = None;
for ev in events.iter().rev() { for ev in events.iter().rev() {
@ -100,17 +105,28 @@ pub async fn load_timeline(
matrix_sdk::ruma::events::AnySyncMessageLikeEvent::RoomMessage( matrix_sdk::ruma::events::AnySyncMessageLikeEvent::RoomMessage(
matrix_sdk::ruma::events::SyncMessageLikeEvent::Original(orig), matrix_sdk::ruma::events::SyncMessageLikeEvent::Original(orig),
) => { ) => {
let ts = ts_secs_from(orig.origin_server_ts.0);
// Edit event? Stash the new body for the target original; do
// NOT count toward the message limit.
if let Some(Relation::Replacement(replacement)) = &orig.content.relates_to {
if let MessageType::Text(text) = &replacement.new_content.msgtype {
pending_edits
.entry(replacement.event_id.clone())
.or_default()
.push((text.body.clone(), ts));
}
continue;
}
if messages.len() >= limit { if messages.len() >= limit {
continue; continue;
} }
let MessageType::Text(text) = &orig.content.msgtype else { let MessageType::Text(text) = &orig.content.msgtype else {
continue; continue;
}; };
let ts = ts_secs_from(orig.origin_server_ts.0);
let in_reply_to = match &orig.content.relates_to { let in_reply_to = match &orig.content.relates_to {
Some(matrix_sdk::ruma::events::room::message::Relation::Reply { Some(Relation::Reply { in_reply_to }) => Some(in_reply_to.event_id.clone()),
in_reply_to,
}) => Some(in_reply_to.event_id.clone()),
_ => None, _ => None,
}; };
if earliest_message_ts.is_none_or(|e| ts < e) { if earliest_message_ts.is_none_or(|e| ts < e) {
@ -123,6 +139,7 @@ pub async fn load_timeline(
is_self: &orig.sender == own_user, is_self: &orig.sender == own_user,
ts, ts,
in_reply_to, in_reply_to,
edit_history: Vec::new(),
}); });
} }
matrix_sdk::ruma::events::AnySyncMessageLikeEvent::Reaction( matrix_sdk::ruma::events::AnySyncMessageLikeEvent::Reaction(
@ -141,6 +158,39 @@ pub async fn load_timeline(
} }
} }
// Apply pending edits to their target messages. Chain = [original body,
// edits sorted oldest-first]; current `body` becomes the latest, and
// everything before it ends up in `edit_history`.
for item in &mut messages {
if let TimelineItem::Message {
event_id,
body,
ts,
edit_history,
..
} = item
{
let edits = pending_edits.remove(event_id).unwrap_or_default();
if edits.is_empty() {
continue;
}
let mut chain: Vec<(String, i64)> = vec![(body.clone(), *ts)];
chain.extend(edits);
chain.sort_by_key(|(_, t)| *t);
// pop the most recent - that's the visible body
let (latest, _) = chain.pop().expect("chain has at least the original");
*body = latest;
*edit_history = chain
.into_iter()
.map(|(b, t)| EditRecord {
body: b,
ts: t,
ts_human: format!("{} UTC", format_ts(t)),
})
.collect();
}
}
if let Some(min_ts) = earliest_message_ts { if let Some(min_ts) = earliest_message_ts {
reactions.retain(|r| r.ts() >= min_ts); reactions.retain(|r| r.ts() >= min_ts);
} }
@ -182,6 +232,7 @@ pub async fn fetch_message(
is_self: &orig.sender == own_user, is_self: &orig.sender == own_user,
ts, ts,
in_reply_to: None, in_reply_to: None,
edit_history: Vec::new(),
}) })
} }

View file

@ -6,6 +6,14 @@ use matrix_sdk::{
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// One prior version of an edited message.
#[derive(Debug, Clone, Serialize)]
pub struct EditRecord {
pub body: String,
pub ts: i64,
pub ts_human: String,
}
/// Serializable shape for one timeline event, used both in matrix_turn JSON /// Serializable shape for one timeline event, used both in matrix_turn JSON
/// (input to the shard) and tool response JSON (get_room_history, /// (input to the shard) and tool response JSON (get_room_history,
/// fetch_event). /// fetch_event).
@ -22,6 +30,9 @@ pub enum WireEvent {
body: String, body: String,
in_reply_to: Option<String>, in_reply_to: Option<String>,
read_by: Vec<String>, read_by: Vec<String>,
/// Prior versions of this message, oldest first. Empty when the
/// message has never been edited.
edit_history: Vec<EditRecord>,
}, },
Reaction { Reaction {
sender: String, sender: String,
@ -94,11 +105,16 @@ pub enum TimelineItem {
Message { Message {
event_id: OwnedEventId, event_id: OwnedEventId,
sender: OwnedUserId, sender: OwnedUserId,
/// Latest version of the message body. If the user edited this message,
/// this is the most recent edit's content.
body: String, body: String,
is_self: bool, is_self: bool,
/// Unix seconds. 0 if unknown. /// Unix seconds. 0 if unknown.
ts: i64, ts: i64,
in_reply_to: Option<OwnedEventId>, in_reply_to: Option<OwnedEventId>,
/// Prior versions of this message (oldest first), excluding the
/// current `body`. Empty when the message has never been edited.
edit_history: Vec<EditRecord>,
}, },
Reaction { Reaction {
sender: OwnedUserId, sender: OwnedUserId,