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