21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 1 | use std::collections::{BTreeMap, HashSet}; |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 2 | |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 3 | use matrix_sdk::{ |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 4 | Room, RoomState, cross_process_lock::MappedCrossProcessLockState, |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 5 | deserialized_responses::TimelineEvent, |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 6 | }; |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 7 | use matrix_sdk_base::event_cache::store::EventCacheStoreLockGuard; |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 8 | use ruma::{ |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 9 | OwnedEventId, |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 10 | events::{ |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 11 | receipt::{ReceiptThread, ReceiptType}, |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 12 | relation::RelationType, |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 13 | }, |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 14 | }; |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 15 | use serde_json::{Value, json}; |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 16 | |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 17 | use crate::ThreadUnread; |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 18 | |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 19 | fn key(room: &Room) -> Vec<u8> { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 20 | format!("macltok.thread_unreads.{}", room.room_id()).into_bytes() |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 21 | } |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 22 | |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 23 | /// what the last recount left, so a room list never touches the event cache |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 24 | pub(crate) async fn stored_thread_unreads(room: &Room) -> BTreeMap<OwnedEventId, ThreadUnread> { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 25 | if room.state() != RoomState::Joined { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 26 | return BTreeMap::new(); |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 27 | } |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 28 | match room.client().state_store().get_custom_value(&key(room)).await { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 29 | Ok(Some(bytes)) => decode(&bytes), |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 30 | Ok(None) => BTreeMap::new(), |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 31 | Err(error) => { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 32 | tracing::debug!(room_id = %room.room_id(), %error, "reading thread unreads failed"); |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 33 | BTreeMap::new() |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 34 | } |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 35 | } |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 36 | } |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 37 | |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 38 | /// the sdk leaves thread events out of the room's counts once threads are on and keeps none |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 39 | /// of its own for them. this counts `touched` and every thread that still had something |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 40 | /// unread from the event cache store, which holds every reply it ever took in whatever is |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 41 | /// in memory, against a receipt for that thread (msc3771), an unthreaded one or the user's |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 42 | /// own reply. `None` when nothing moved |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 43 | pub(crate) async fn recount_thread_unreads( |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 44 | room: &Room, |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 45 | touched: HashSet<OwnedEventId>, |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 46 | ) -> Option<BTreeMap<OwnedEventId, ThreadUnread>> { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 47 | let known = stored_thread_unreads(room).await; |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 48 | let mut roots = touched; |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 49 | roots.extend(known.keys().cloned()); |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 50 | if roots.is_empty() || room.state() != RoomState::Joined { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 51 | return None; |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 52 | } |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 53 | let store = match room.client().event_cache_store().lock().await { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 54 | Ok(MappedCrossProcessLockState::Clean(store) | MappedCrossProcessLockState::Dirty(store)) => { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 55 | store |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 56 | } |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 57 | Err(error) => { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 58 | tracing::debug!(room_id = %room.room_id(), %error, "locking the event cache store failed"); |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 59 | return None; |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 60 | } |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 61 | }; |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 62 | let own_user_id = room.own_user_id(); |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 63 | let unthreaded = read_through(room, &store, ReceiptThread::Unthreaded).await; |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 64 | let mut counted = BTreeMap::new(); |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 65 | for root in roots { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 66 | let replies = match store |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 67 | .find_event_relations(room.room_id(), &root, Some(&[RelationType::Thread][..])) |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 68 | .await |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 69 | { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 70 | Ok(replies) => replies, |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 71 | Err(error) => { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 72 | tracing::debug!(room_id = %room.room_id(), %root, %error, "reading a thread failed"); |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 73 | if let Some(unread) = known.get(&root) { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 74 | counted.insert(root, *unread); |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 75 | } |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 76 | continue; |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 77 | } |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 78 | }; |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 79 | let mut read = unthreaded.max(read_through(room, &store, ReceiptThread::Thread(root.clone())).await); |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 80 | for (reply, _) in &replies { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 81 | if reply.sender().as_deref() == Some(own_user_id) { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 82 | read = read.max(timestamp_ms(reply)); |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 83 | } |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 84 | } |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 85 | let mut unread = ThreadUnread::default(); |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 86 | for (reply, _) in &replies { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 87 | let Some(at) = timestamp_ms(reply) else { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 88 | continue; |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 89 | }; |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 90 | if read.is_some_and(|read| at <= read) |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 91 | || reply.sender().as_deref() == Some(own_user_id) |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 92 | || !counts_as_unread(reply) |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 93 | { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 94 | continue; |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 95 | } |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 96 | let actions = reply.push_actions().unwrap_or_default(); |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 97 | unread.messages += 1; |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 98 | unread.notifications += u64::from(actions.iter().any(|action| action.should_notify())); |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 99 | unread.mentions += u64::from(actions.iter().any(|action| action.is_highlight())); |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 100 | } |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 101 | if unread != ThreadUnread::default() { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 102 | counted.insert(root, unread); |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 103 | } |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 104 | } |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 105 | if counted == known { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 106 | return None; |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 107 | } |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 108 | let stored = room |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 109 | .client() |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 110 | .state_store() |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 111 | .set_custom_value(&key(room), encode(&counted)) |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 112 | .await; |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 113 | if let Err(error) = stored { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 114 | tracing::debug!(room_id = %room.room_id(), %error, "saving thread unreads failed"); |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 115 | } |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 116 | Some(counted) |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 117 | } |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 118 | |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 119 | /// receipts sit on events, and stream order between a thread and the rest of the room is |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 120 | /// gone once it's in the store, so the event's timestamp stands for its place. a receipt |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 121 | /// on an event the store never had falls back to when it was sent |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 122 | async fn read_through( |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 123 | room: &Room, |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 124 | store: &EventCacheStoreLockGuard, |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 125 | thread: ReceiptThread, |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 126 | ) -> Option<u64> { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 127 | let mut latest = None; |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 128 | for receipt_type in [ReceiptType::Read, ReceiptType::ReadPrivate] { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 129 | let (event_id, receipt) = match room |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 130 | .load_user_receipt(receipt_type, thread.clone(), room.own_user_id()) |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 131 | .await |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 132 | { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 133 | Ok(Some(found)) => found, |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 134 | Ok(None) => continue, |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 135 | Err(error) => { |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 136 | tracing::debug!(room_id = %room.room_id(), %error, "reading a receipt failed"); |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 137 | continue; |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 138 | } |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 139 | }; |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 140 | let at = match store.find_event(room.room_id(), &event_id).await { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 141 | Ok(Some(event)) => timestamp_ms(&event), |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 142 | _ => None, |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 143 | } |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 144 | .or_else(|| receipt.ts.map(|ts| u64::from(ts.get()))); |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 145 | latest = latest.max(at); |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 146 | } |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 147 | latest |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 148 | } |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 149 | |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 150 | fn timestamp_ms(event: &TimelineEvent) -> Option<u64> { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 151 | event.timestamp().map(|ts| u64::from(ts.get())) |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 152 | } |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 153 | |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 154 | /// the sdk's `marks_as_unread` for its room counts, which is private to it |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 155 | fn counts_as_unread(event: &TimelineEvent) -> bool { |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 156 | let raw = event.raw(); |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 157 | let redacted = raw |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 158 | .get_field::<Value>("unsigned") |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 159 | .ok() |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 160 | .flatten() |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 161 | .is_some_and(|unsigned| unsigned.get("redacted_because").is_some()); |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 162 | let edit = raw |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 163 | .get_field::<Value>("content") |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 164 | .ok() |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 165 | .flatten() |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 166 | .and_then(|content| { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 167 | content |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 168 | .pointer("/m.relates_to/rel_type") |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 169 | .and_then(Value::as_str) |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 170 | .map(|rel_type| rel_type == "m.replace") |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 171 | }) |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 172 | .unwrap_or(false); |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 173 | let message_like = matches!( |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 174 | raw.get_field::<String>("type").ok().flatten().as_deref(), |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 175 | Some( |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 176 | "m.message" |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 177 | | "m.room.message" |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 178 | | "m.room.encrypted" |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 179 | | "m.sticker" |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 180 | | "m.poll.start" |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 181 | | "org.matrix.msc3381.poll.start" |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 182 | | "m.poll.end" |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 183 | | "org.matrix.msc3381.poll.end" |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 184 | ) |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 185 | ); |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 186 | message_like && !edit && !redacted |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 187 | } |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 188 | |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 189 | fn encode(unreads: &BTreeMap<OwnedEventId, ThreadUnread>) -> Vec<u8> { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 190 | let value: serde_json::Map<String, Value> = unreads |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 191 | .iter() |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 192 | .map(|(root, unread)| { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 193 | ( |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 194 | root.to_string(), |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 195 | json!([unread.messages, unread.mentions, unread.notifications]), |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 196 | ) |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 197 | }) |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 198 | .collect(); |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 199 | serde_json::to_vec(&value).unwrap_or_default() |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 200 | } |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 201 | |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 202 | fn decode(bytes: &[u8]) -> BTreeMap<OwnedEventId, ThreadUnread> { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 203 | let Ok(value) = serde_json::from_slice::<BTreeMap<OwnedEventId, [u64; 3]>>(bytes) else { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 204 | return BTreeMap::new(); |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 205 | }; |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 206 | value |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 207 | .into_iter() |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 208 | .map(|(root, [messages, mentions, notifications])| { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 209 | ( |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 210 | root, |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 211 | ThreadUnread { |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 212 | messages, |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 213 | mentions, |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 214 | notifications, |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 215 | }, |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 216 | ) |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 217 | }) |
21b9227729a4 | atagen | 2026-10-25 11:03 UTC | 218 | .collect() |
e5c6427adb35 | atagen | 2026-10-25 10:23 UTC | 219 | } |