edits as standalone timeline events with old_body/new_body

This commit is contained in:
Damocles 2026-05-01 13:50:52 +02:00
parent bc3a4782cc
commit 9171290ae7
4 changed files with 137 additions and 34 deletions

View file

@ -154,6 +154,23 @@ pub fn wire_event_from(
target_event_id_short: short_eid(target_event_id.as_str()),
key: key.clone(),
},
TimelineItem::Edit {
sender,
target_event_id,
old_body,
new_body,
is_self,
ts,
} => WireEvent::Edit {
sender: sender.as_str().to_owned(),
is_self: *is_self,
ts: *ts,
ts_human: format!("{} UTC", format_ts(*ts)),
target_event_id: target_event_id.as_str().to_owned(),
target_event_id_short: short_eid(target_event_id.as_str()),
old_body: old_body.clone(),
new_body: new_body.clone(),
},
}
}

View file

@ -410,7 +410,8 @@ async fn fetch_event(
let earlier_handle = if context_before > 0 && before.len() > context_before as usize {
before.first().and_then(|e| match e {
WireEvent::Message { event_id, .. } => Some(event_id.clone()),
WireEvent::Reaction { target_event_id, .. } => Some(target_event_id.clone()),
WireEvent::Reaction { target_event_id, .. }
| WireEvent::Edit { target_event_id, .. } => Some(target_event_id.clone()),
WireEvent::Notice { .. } => None,
})
} else {

View file

@ -85,11 +85,17 @@ pub async fn load_timeline(
use matrix_sdk::ruma::events::room::message::Relation;
struct StashedEdit {
sender: OwnedUserId,
target: OwnedEventId,
new_body: String,
ts: i64,
is_self: bool,
}
let mut messages: 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 stashed_edits: Vec<StashedEdit> = Vec::new();
let mut earliest_message_ts: Option<i64> = None;
for ev in events.iter().rev() {
@ -107,14 +113,16 @@ pub async fn load_timeline(
) => {
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.
// Edit event? Stash for second pass; don't count toward 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));
stashed_edits.push(StashedEdit {
sender: orig.sender.clone(),
target: replacement.event_id.clone(),
new_body: text.body.clone(),
ts,
is_self: &orig.sender == own_user,
});
}
continue;
}
@ -158,46 +166,98 @@ 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`.
// Build per-target chain (original + edits, sorted oldest-first), used
// for both edit_history population AND old_body lookup on Edit items.
let mut chains: HashMap<OwnedEventId, Vec<(String, i64)>> = HashMap::new();
for msg in &messages {
if let TimelineItem::Message { event_id, body, ts, .. } = msg {
chains
.entry(event_id.clone())
.or_default()
.push((body.clone(), *ts));
}
}
for edit in &stashed_edits {
chains
.entry(edit.target.clone())
.or_default()
.push((edit.new_body.clone(), edit.ts));
}
for chain in chains.values_mut() {
chain.sort_by_key(|(_, t)| *t);
}
// Update messages with edit_history + latest body.
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() {
let Some(chain) = chains.get(event_id) else {
continue;
};
if chain.len() <= 1 {
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()
let (latest, _) = chain.last().unwrap();
*body = latest.clone();
*edit_history = chain[..chain.len() - 1]
.iter()
.map(|(b, t)| EditRecord {
body: b,
ts: t,
ts_human: format!("{} UTC", format_ts(t)),
body: b.clone(),
ts: *t,
ts_human: format!("{} UTC", format_ts(*t)),
})
.collect();
}
}
if let Some(min_ts) = earliest_message_ts {
reactions.retain(|r| r.ts() >= min_ts);
// Build Edit timeline items (only for edits whose target is in window).
let in_window_targets: std::collections::HashSet<OwnedEventId> = messages
.iter()
.filter_map(|m| match m {
TimelineItem::Message { event_id, .. } => Some(event_id.clone()),
_ => None,
})
.collect();
let mut edit_items: Vec<TimelineItem> = Vec::new();
for edit in &stashed_edits {
if !in_window_targets.contains(&edit.target) {
continue;
}
let chain = chains.get(&edit.target).expect("chain exists for in-window target");
let pos = chain
.iter()
.position(|(b, t)| *t == edit.ts && *b == edit.new_body)
.unwrap_or(0);
let old_body = if pos > 0 {
chain[pos - 1].0.clone()
} else {
String::new()
};
edit_items.push(TimelineItem::Edit {
sender: edit.sender.clone(),
target_event_id: edit.target.clone(),
old_body,
new_body: edit.new_body.clone(),
is_self: edit.is_self,
ts: edit.ts,
});
}
let mut combined: Vec<TimelineItem> = Vec::with_capacity(messages.len() + reactions.len());
if let Some(min_ts) = earliest_message_ts {
reactions.retain(|r| r.ts() >= min_ts);
edit_items.retain(|e| e.ts() >= min_ts);
}
let mut combined: Vec<TimelineItem> =
Vec::with_capacity(messages.len() + reactions.len() + edit_items.len());
combined.extend(messages);
combined.extend(reactions);
combined.extend(edit_items);
combined.sort_by_key(TimelineItem::ts);
Ok(combined)
}

View file

@ -43,6 +43,16 @@ pub enum WireEvent {
target_event_id_short: String,
key: String,
},
Edit {
sender: String,
is_self: bool,
ts: i64,
ts_human: String,
target_event_id: String,
target_event_id_short: String,
old_body: String,
new_body: String,
},
/// Synthetic event from the daemon (not a Matrix event). Currently used
/// to tell the shard "you were rate-limited; events held for X seconds."
Notice {
@ -123,31 +133,46 @@ pub enum TimelineItem {
is_self: bool,
ts: i64,
},
/// A single edit applied to a message. Surfaced as its own chronological
/// event so the shard sees edits as they happen. The target message also
/// has its `edit_history` updated for at-a-glance reference.
Edit {
sender: OwnedUserId,
target_event_id: OwnedEventId,
old_body: String,
new_body: String,
is_self: bool,
ts: i64,
},
}
impl TimelineItem {
pub fn ts(&self) -> i64 {
match self {
Self::Message { ts, .. } | Self::Reaction { ts, .. } => *ts,
Self::Message { ts, .. } | Self::Reaction { ts, .. } | Self::Edit { ts, .. } => *ts,
}
}
pub fn event_id(&self) -> Option<&OwnedEventId> {
match self {
Self::Message { event_id, .. } => Some(event_id),
Self::Reaction { .. } => None,
Self::Reaction { .. } | Self::Edit { .. } => None,
}
}
pub fn sender(&self) -> &OwnedUserId {
match self {
Self::Message { sender, .. } | Self::Reaction { sender, .. } => sender,
Self::Message { sender, .. }
| Self::Reaction { sender, .. }
| Self::Edit { sender, .. } => sender,
}
}
pub fn is_self(&self) -> bool {
match self {
Self::Message { is_self, .. } | Self::Reaction { is_self, .. } => *is_self,
Self::Message { is_self, .. }
| Self::Reaction { is_self, .. }
| Self::Edit { is_self, .. } => *is_self,
}
}
}