diff --git a/src/claude.rs b/src/claude.rs index abad447..648486e 100644 --- a/src/claude.rs +++ b/src/claude.rs @@ -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(), + }, } } diff --git a/src/socket.rs b/src/socket.rs index 4f23183..5b6235f 100644 --- a/src/socket.rs +++ b/src/socket.rs @@ -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 { diff --git a/src/timeline.rs b/src/timeline.rs index 0acc70a..32bf430 100644 --- a/src/timeline.rs +++ b/src/timeline.rs @@ -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 = Vec::new(); let mut reactions: Vec = 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> = HashMap::new(); + let mut stashed_edits: Vec = Vec::new(); let mut earliest_message_ts: Option = 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> = 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 = messages + .iter() + .filter_map(|m| match m { + TimelineItem::Message { event_id, .. } => Some(event_id.clone()), + _ => None, + }) + .collect(); + let mut edit_items: Vec = 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 = 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 = + 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) } diff --git a/src/types.rs b/src/types.rs index b9625e8..4f79b0e 100644 --- a/src/types.rs +++ b/src/types.rs @@ -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, } } }