thread: show history events in the chain
Assisted-by: Claude Opus 5.5 via Claude Code
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;