From 5b91de6339d49ac37b5266417c0cd8885cc732cc Mon Sep 17 00:00:00 2001 From: Damocles Date: Thu, 30 Apr 2026 22:54:41 +0200 Subject: [PATCH] show others' read receipts on each message in prompt --- src/main.rs | 102 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 96 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index afed561..44058b2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -638,13 +638,24 @@ async fn process_loop(state: Arc>, client: Client) { _ => None, }); + // Compute who has read which message + let read_markers = compute_read_markers(&room, &timeline, &own_user).await; + // Tell the room we're "typing" while claude thinks. Best-effort; no // hard fail if it doesn't go through. if let Err(e) = room.typing_notice(true).await { tracing::debug!(room = %room_id, "failed to send typing start: {e}"); } - let invoke_result = invoke_claude(&room_id, &room_name, &timeline, seen_idx, &model).await; + let invoke_result = invoke_claude( + &room_id, + &room_name, + &timeline, + seen_idx, + &model, + &read_markers, + ) + .await; if let Err(e) = room.typing_notice(false).await { tracing::debug!(room = %room_id, "failed to send typing stop: {e}"); @@ -846,9 +857,13 @@ async fn load_timeline( } /// Render one timeline item into the prompt. -/// Messages: `[ts] $eid... [(you) ]@user: body` +/// Messages: `[ts] $eid... [(you) ]@user: body [read by: ...]` /// Reactions: `[ts] [(you) ]@user reacted to $eid... with KEY` -fn render_timeline_item(prompt: &mut String, item: &TimelineItem) { +fn render_timeline_item( + prompt: &mut String, + item: &TimelineItem, + read_markers: &std::collections::HashMap>, +) { match item { TimelineItem::Message { event_id, @@ -861,7 +876,20 @@ fn render_timeline_item(prompt: &mut String, item: &TimelineItem) { let ts_str = format_ts(*ts); let id = short_event_id(event_id); let prefix = if *is_self { "(you) " } else { "" }; - writeln!(prompt, "[{ts_str}] {id} {prefix}{sender}: {body}").unwrap(); + let readers_str = match read_markers.get(event_id) { + Some(rs) if !rs.is_empty() => { + let mut sorted = rs.clone(); + sorted.sort(); + let names: Vec = sorted.iter().map(|u| u.to_string()).collect(); + format!(" [read by: {}]", names.join(", ")) + } + _ => String::new(), + }; + writeln!( + prompt, + "[{ts_str}] {id} {prefix}{sender}: {body}{readers_str}" + ) + .unwrap(); } TimelineItem::Reaction { sender, @@ -893,6 +921,67 @@ fn short_event_id(id: &OwnedEventId) -> String { } } +/// For each message in the timeline, compute the list of OTHER users who +/// have a read receipt at or after that message. Self is excluded. +async fn compute_read_markers( + room: &Room, + timeline: &[TimelineItem], + own_user: &OwnedUserId, +) -> std::collections::HashMap> { + use matrix_sdk::ruma::events::receipt::ReceiptType; + + // Collect unique non-self users from the timeline (senders + reactors) + let mut users: Vec = timeline + .iter() + .filter(|t| !t.is_self()) + .map(|t| t.sender().clone()) + .collect(); + users.sort(); + users.dedup(); + + // Build position map for messages in timeline: event_id -> index + let positions: std::collections::HashMap = timeline + .iter() + .enumerate() + .filter_map(|(i, t)| match t { + TimelineItem::Message { event_id, .. } => Some((event_id.clone(), i)), + _ => None, + }) + .collect(); + + // For each user, find their receipt position. Then for every message + // at or before that position, add them as a reader. + let mut readers: std::collections::HashMap> = + std::collections::HashMap::new(); + + for user in &users { + if user == own_user { + continue; + } + let receipt_eid = match room + .load_user_receipt(ReceiptType::Read, ReceiptThread::Unthreaded, user) + .await + { + Ok(Some((eid, _))) => eid, + _ => continue, + }; + let Some(&user_pos) = positions.get(&receipt_eid) else { + continue; + }; + // Mark this user as a reader for all messages at index <= user_pos + for item in timeline.iter().take(user_pos + 1) { + if let TimelineItem::Message { event_id, .. } = item { + readers + .entry(event_id.clone()) + .or_default() + .push(user.clone()); + } + } + } + + readers +} + /// Resolve a (possibly shortened/ellipsized) event id to a full one by /// looking up against the timeline. Returns the matching message's full /// event id if found. @@ -978,6 +1067,7 @@ async fn invoke_claude( timeline: &[TimelineItem], seen_idx: usize, model: &str, + read_markers: &std::collections::HashMap>, ) -> anyhow::Result> { let identity_dir = paths::identity_dir(); let identity_str = identity_dir.to_string_lossy(); @@ -1016,7 +1106,7 @@ async fn invoke_claude( if !old.is_empty() { writeln!(prompt, "\n[previously seen events — for context]").unwrap(); for item in old { - render_timeline_item(&mut prompt, item); + render_timeline_item(&mut prompt, item, read_markers); } } @@ -1025,7 +1115,7 @@ async fn invoke_claude( writeln!(prompt, "(none)").unwrap(); } else { for item in new { - render_timeline_item(&mut prompt, item); + render_timeline_item(&mut prompt, item, read_markers); } }