thread: show history events in the chain
Assisted-by: Claude Opus 5.5 via Claude Code
change txknrtrnxmsquxpurtrtwpyzrmpuslsm
commit fa7273858bbd0fd764bb37490770cb3d6b60a667
author Alpha Chen <alpha@kejadlen.dev>
date
parent wvvmupqp
diff --git a/assets/docket.css b/assets/docket.css
index da91645..922fdc2 100644
--- a/assets/docket.css
+++ b/assets/docket.css
@@ -109,6 +109,9 @@ button { font: inherit; color: inherit; }
 /* The gutter: date, then the email's values, right-aligned. */
 .gutter { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; text-align: right; }
 .comment .gutter { justify-content: flex-start; }
+/* History: a quiet line between rows, not a card. */
+.item.event { padding-top: 0; padding-bottom: 0; }
+.event-text { font-size: var(--gl-step--2); color: var(--gl-color-text-tertiary); }
 .when { display: flex; align-items: center; gap: 2px; }
 .date { font-size: var(--gl-step--2); color: var(--gl-color-text-secondary); }
 .icon-btn { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: var(--gl-radius); color: var(--gl-color-text-secondary); transition: background var(--gl-dur-fast) var(--gl-ease); }
diff --git a/src/store.rs b/src/store.rs
index 83553d5..e9a6705 100644
--- a/src/store.rs
+++ b/src/store.rs
@@ -51,6 +51,7 @@ pub struct Flash {
 pub enum Item {
     Message(Message),
     Comment(Comment),
+    Event(Event),
 }
 
 /// Which messages a list asks for.
@@ -341,8 +342,9 @@ impl Store {
         Ok(threads)
     }
 
-    /// The thread's messages and comments in time order. A message sorts
-    /// before a comment made at the same moment.
+    /// The thread's messages, comments, and history events in time
+    /// order. At the same moment a message sorts first, then comments,
+    /// then events, which keep the order they happened in.
     pub fn timeline(&self, thread: &str) -> Result<Vec<Item>, Error> {
         let inner = self.lock();
         let messages = load_messages(&inner.conn, "m.thread = ?1", [thread])?;
@@ -360,14 +362,25 @@ impl Store {
                 })
             })?
             .collect::<Result<Vec<_>, _>>()?;
+        let mut stmt = inner.conn.prepare_cached(
+            "SELECT h.message, h.user, h.at, h.event
+             FROM history h JOIN messages m ON m.id = h.message
+             WHERE m.thread = ?1 ORDER BY h.rowid",
+        )?;
+        let events = stmt
+            .query_map([thread], event_from_row)?
+            .collect::<Result<Vec<_>, _>>()?;
         let mut items: Vec<_> = messages
             .into_iter()
             .map(Item::Message)
             .chain(comments.into_iter().map(Item::Comment))
+            .chain(events.into_iter().map(Item::Event))
             .collect();
+        // Stable, so ties keep the chain order above.
         items.sort_by_key(|item| match item {
             Item::Message(m) => m.at,
             Item::Comment(c) => c.at,
+            Item::Event(e) => e.at,
         });
         Ok(items)
     }
@@ -396,14 +409,7 @@ impl Store {
             "SELECT message, user, at, event FROM history WHERE message = ?1 ORDER BY rowid",
         )?;
         let events = stmt
-            .query_map([message], |r| {
-                Ok(Event {
-                    message: r.get(0)?,
-                    user: r.get(1)?,
-                    at: r.get(2)?,
-                    text: r.get(3)?,
-                })
-            })?
+            .query_map([message], event_from_row)?
             .collect::<Result<_, _>>()?;
         Ok(events)
     }
@@ -1077,6 +1083,15 @@ fn migrate(conn: &mut Connection) -> Result<(), Error> {
     Ok(())
 }
 
+fn event_from_row(r: &Row<'_>) -> rusqlite::Result<Event> {
+    Ok(Event {
+        message: r.get(0)?,
+        user: r.get(1)?,
+        at: r.get(2)?,
+        text: r.get(3)?,
+    })
+}
+
 fn user_from_row(r: &Row<'_>) -> rusqlite::Result<User> {
     Ok(User {
         login: r.get(0)?,
@@ -1770,8 +1785,20 @@ mod tests {
     }
 
     #[test]
-    fn timeline_interleaves_comments_by_time() {
+    fn timeline_interleaves_comments_and_events_by_time() {
         let store = fixtures::store().unwrap();
+        // Edits stamp the fixed clock, after every fixture message; the
+        // two events keep the order they happened in.
+        store
+            .edit(SAM, &fixtures::id(4), Change::State(State::Do))
+            .unwrap();
+        store
+            .edit(SAM, &fixtures::id(3), Change::Folder(None))
+            .unwrap();
+        // Another thread's history stays out.
+        store
+            .edit(SAM, &fixtures::id(5), Change::State(State::Done))
+            .unwrap();
         let order: Vec<_> = store
             .timeline(&fixtures::id(1))
             .unwrap()
@@ -1779,6 +1806,7 @@ mod tests {
             .map(|item| match item {
                 Item::Message(m) => format!("m{}", m.id),
                 Item::Comment(c) => format!("c{}", c.id),
+                Item::Event(e) => format!("e{} {}", e.message, e.text),
             })
             .collect();
         assert_eq!(
@@ -1789,8 +1817,15 @@ mod tests {
                 format!("m{}", fixtures::id(2)),
                 format!("m{}", fixtures::id(3)),
                 format!("m{}", fixtures::id(4)),
+                format!("e{} Moved to Do", fixtures::id(4)),
+                format!("e{} Unfiled", fixtures::id(3)),
             ]
         );
+
+        // A fresh store, so the history query prepares against the gap.
+        let store = fixtures::store().unwrap();
+        exec(&store, "ALTER TABLE history RENAME TO gone_history;");
+        assert!(store.timeline(&fixtures::id(1)).is_err());
     }
 
     #[test]
@@ -2285,10 +2320,13 @@ mod tests {
         let v = values(&store, &fixtures::id(4));
         assert_eq!(v.state, State::Wait);
         assert_eq!(v.assignees, BTreeSet::from([ALEX.to_owned()]));
-        assert!(matches!(
-            store.timeline(&fixtures::id(1)).unwrap().last(),
-            Some(Item::Comment(c)) if c.text == "Called them."
-        ));
+        assert!(
+            store
+                .timeline(&fixtures::id(1))
+                .unwrap()
+                .iter()
+                .any(|item| matches!(item, Item::Comment(c) if c.text == "Called them."))
+        );
         assert!(
             !store
                 .unread(SAM)
diff --git a/src/views.rs b/src/views.rs
index 00ac0ea..0d917d4 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -8,7 +8,9 @@ use jiff::civil::DateTime;
 use maud::{DOCTYPE, Markup, PreEscaped, html};
 
 use crate::lists::{Group, Section, View};
-use crate::model::{Account, Comment, Kind, Message, MessageId, State, Thread, User, Values};
+use crate::model::{
+    Account, Comment, Event, Kind, Message, MessageId, State, Thread, User, Values,
+};
 use crate::store::{Flash, Item};
 use crate::{body, dates};
 
@@ -319,6 +321,7 @@ fn thread(p: &Page<'_>, t: &ThreadView, selected: &str) -> Markup {
                 @for item in &t.timeline {
                     @match item {
                         Item::Comment(c) => (comment(p, c)),
+                        Item::Event(e) => (event(p, e)),
                         Item::Message(m) => {
                             @let open = m.id == selected || m.id == t.latest;
                             (message(p, m, open, m.id == selected, &t.account))
@@ -348,6 +351,35 @@ fn comment(p: &Page<'_>, c: &Comment) -> Markup {
     }
 }
 
+/// A change to a message's values, as a quiet line in the chain.
+fn event(p: &Page<'_>, e: &Event) -> Markup {
+    html! {
+        div.item.event {
+            div.body { span.event-text { (event_line(&p.users, e)) } }
+            div.gutter { span.type-figure.date { (dates::short(p.now, e.at)) } }
+        }
+    }
+}
+
+/// An event in the chain's words: "Sam moved to Do", from the toast
+/// text history keeps. A change adopted from another client has no one
+/// behind it, and its text already says where it came from.
+fn event_line(users: &[User], e: &Event) -> String {
+    let Some(login) = &e.user else {
+        return e.text.clone();
+    };
+    let name = user_name(users, login);
+    if e.text == "Undone" {
+        return format!("{name} undid a change");
+    }
+    let mut chars = e.text.chars();
+    let lowered: String = chars
+        .next()
+        .map(|first| first.to_lowercase().chain(chars).collect())
+        .unwrap_or_default();
+    format!("{name} {lowered}")
+}
+
 fn message(p: &Page<'_>, m: &Message, open: bool, selected: bool, account: &Account) -> Markup {
     let (who, addr) = match &m.kind {
         Kind::Received { from, addr, .. } => (from.clone(), format!("<{addr}>")),
@@ -550,6 +582,33 @@ mod tests {
         );
     }
 
+    #[test]
+    fn events_read_as_who_did_what() {
+        let users = [User::new("sam@example.com", "Sam")];
+        let line = |user: Option<&str>, text: &str| {
+            event_line(
+                &users,
+                &Event {
+                    message: crate::fixtures::id(1),
+                    user: user.map(str::to_owned),
+                    at: crate::fixtures::now(),
+                    text: text.into(),
+                },
+            )
+        };
+        let sam = Some("sam@example.com");
+        assert_eq!(line(sam, "Moved to Do"), "Sam moved to Do");
+        assert_eq!(line(sam, "Assigned Alex"), "Sam assigned Alex");
+        assert_eq!(line(sam, "Undone"), "Sam undid a change");
+        assert_eq!(line(sam, ""), "Sam ");
+        // A login nobody has stands in for its name.
+        assert_eq!(line(Some("pat@x"), "Deleted"), "pat@x deleted");
+        assert_eq!(
+            line(None, "Moved to Done via another client"),
+            "Moved to Done via another client"
+        );
+    }
+
     #[test]
     fn senders_without_a_login_show_the_recipient() {
         let users = [User::new("sam@example.com", "Sam")];
diff --git a/tests/api.rs b/tests/api.rs
index a4171c2..df4e5d6 100644
--- a/tests/api.rs
+++ b/tests/api.rs
@@ -296,6 +296,21 @@ async fn html_mail_renders_in_a_sandboxed_frame() {
     assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
 }
 
+#[tokio::test]
+async fn edits_show_up_in_the_thread_as_events() {
+    let addr = spawn().await;
+    let path = format!("/messages/{}/state", mid(4));
+    post(addr, SAM, &path, &[("state", "do"), ("back", "/")]).await;
+
+    let (_, body) = get(addr, ALEX, &format!("/?m={}", mid(4))).await;
+    assert!(
+        body.contains(
+            r#"<div class="item event"><div class="body"><span class="event-text">Sam moved to Do</span></div>"#
+        ),
+        "{body}"
+    );
+}
+
 #[tokio::test]
 async fn read_only_accounts_show_folder_as_plain_text() {
     let addr = spawn().await;