matrix_sdk/event_cache/caches/aggregator.rs
1// Copyright 2026 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::{BTreeMap, HashMap};
16
17use matrix_sdk_base::{
18 serde_helpers::{extract_redaction_target, extract_relation, extract_thread_root},
19 sync::Timeline,
20};
21use ruma::{
22 OwnedEventId,
23 events::{
24 AnySyncEphemeralRoomEvent,
25 receipt::{ReceiptEventContent, ReceiptThread, Receipts},
26 relation::RelationType,
27 },
28 room_version_rules::RedactionRules,
29};
30
31use super::{
32 super::{Result, states::StateLockReadGuard},
33 read_receipts::MaybeReceiptEventContent,
34 room::RoomEventCacheState,
35 thread::ThreadEventCacheState,
36};
37
38pub fn aggregate_timeline_and_read_receipts_for_room(
39 timeline: &Timeline,
40 ephemeral: &[AnySyncEphemeralRoomEvent],
41) -> (Timeline, MaybeReceiptEventContent) {
42 (
43 timeline.clone(),
44 filter_read_receipts_and_group_by(ephemeral, |receipt_thread| match receipt_thread {
45 ReceiptThread::Main | ReceiptThread::Unthreaded => Some(()),
46 _ => None,
47 })
48 .map(|(_receipt_thread, (event_id, event_receipts))| {
49 (event_id.clone(), event_receipts.clone())
50 })
51 .collect(),
52 )
53}
54
55pub async fn aggregate_timeline_and_read_receipts_for_threads<'sync, 'state>(
56 timeline: &'sync Timeline,
57 ephemeral: &'sync [AnySyncEphemeralRoomEvent],
58 existing_threads: StateLockReadGuard<'state, HashMap<OwnedEventId, ThreadEventCacheState>>,
59 maybe_room: Option<StateLockReadGuard<'state, RoomEventCacheState>>,
60 redaction_rules: &'sync RedactionRules,
61) -> Result<HashMap<OwnedEventId, (Timeline, MaybeReceiptEventContent)>> {
62 let mut new_events_by_thread = HashMap::new();
63
64 let default_entry = || {
65 (
66 Timeline {
67 limited: timeline.limited,
68 prev_batch: timeline.prev_batch.clone(),
69 events: Vec::new(),
70 },
71 MaybeReceiptEventContent::none(),
72 )
73 };
74
75 // Look for in-thread events, i.e. events that are part of threads.
76 for (nth, event) in timeline.events.iter().enumerate() {
77 match extract_relation(event.raw()) {
78 // Ohh, this event relates to another event!
79 Some((relation_type, related_event_id)) => match relation_type {
80 // `related_event` represents a thread root.
81 RelationType::Thread => {
82 new_events_by_thread
83 .entry(related_event_id)
84 .or_insert_with(default_entry)
85 .0
86 .events
87 .push(event.clone());
88 }
89
90 // `event` represents an annotation (e.g. reactions), a replacement (an edit), a
91 // reference or something custom. Let's see if the `related_event_id` is an
92 // in-thread event.
93 RelationType::Annotation
94 | RelationType::Replacement
95 | RelationType::Reference
96 | _ => {
97 // First, look for the related event in `timeline` backwards.
98 if let Some(thread_root) = match timeline.events[..nth]
99 .iter()
100 .rev()
101 .find(|event| event.event_id() == Some(&related_event_id))
102 {
103 // The related event has been found in the `timeline`! Extract its thread
104 // root.
105 Some(related_event) => extract_thread_root(related_event.raw()),
106
107 // Not in `timeline`, okay, look for the related event in the `room` as it
108 // knows about all the events, and then extract its thread root.
109 None => match &maybe_room {
110 Some(room) => room.find_event(&related_event_id).await?.and_then(
111 |(_location, related_event)| {
112 extract_thread_root(related_event.raw())
113 },
114 ),
115 None => None,
116 },
117 } {
118 new_events_by_thread
119 .entry(thread_root)
120 .or_insert_with(default_entry)
121 .0
122 .events
123 .push(event.clone());
124 }
125 }
126 },
127
128 // No explicit relation, okay, but it can still be related to a thread!
129 None => {
130 // We previously found events that are part of a thread, but we didn't see the
131 // thread root yet. And guess what? This might be this event!
132 if let Some(event_id) = event.event_id()
133 && existing_threads.contains_key(event_id)
134 {
135 new_events_by_thread
136 .entry(event_id.to_owned())
137 .or_insert_with(default_entry)
138 .0
139 .events
140 .push(event.clone());
141 }
142 // Otherwise, this event might be a redaction that applies to a thread.
143 else if let Some(redaction_target) =
144 extract_redaction_target(event.raw(), redaction_rules)
145 && match &maybe_room {
146 Some(room) => room.find_event(&redaction_target).await?.is_some(),
147 None => false,
148 }
149 {
150 // The redacted event exists (in the room, because it
151 // contains _all_ the events) **but** the event has been
152 // redacted (in the room). It's no more possible to extract
153 // its thread root (because this information has been
154 // removed).
155 //
156 // But we need to know if the event is part of a thread to
157 // apply the redaction in the thread too. No other choice
158 // than doing a full search…
159
160 let mut associated_thread_root = None;
161
162 for thread in existing_threads.values() {
163 if thread.find_event(&redaction_target).await?.is_some() {
164 associated_thread_root = Some(thread.thread_id.clone());
165 break;
166 }
167 }
168
169 // We've found the thread owning the event being redacted!
170 if let Some(thread_root) = associated_thread_root {
171 new_events_by_thread
172 .entry(thread_root)
173 .or_insert_with(default_entry)
174 .0
175 .events
176 .push(event.clone());
177 }
178 }
179 }
180 }
181 }
182
183 for (thread_root, (read_receipt_event_id, read_receipt_event)) in
184 filter_read_receipts_and_group_by(ephemeral, |receipt_thread| match receipt_thread {
185 ReceiptThread::Thread(thread_id) => Some(thread_id),
186 _ => None,
187 })
188 {
189 // 1. Create an empty `Timeline` if it doesn't exist so that it triggers the
190 // update for this thread in `Caches`. This is done by `default_entry`.
191 // 2. Accumulate the read receipt event.
192 new_events_by_thread
193 .entry(thread_root.to_owned())
194 .or_insert_with(default_entry)
195 .1
196 .get_or_insert_with(|| ReceiptEventContent(BTreeMap::new()))
197 .insert(read_receipt_event_id.clone(), read_receipt_event.clone());
198 }
199
200 Ok(new_events_by_thread)
201}
202
203pub fn aggregate_timeline_for_pinned_events(
204 timeline: &Timeline,
205 pinned_event_ids: &[OwnedEventId],
206 redaction_rules: &RedactionRules,
207) -> Timeline {
208 let mut new_timeline = Timeline {
209 limited: timeline.limited,
210 prev_batch: timeline.prev_batch.clone(),
211 events: Vec::new(),
212 };
213
214 // No events are pinned? The `Timeline` must be empty.
215 if pinned_event_ids.is_empty() {
216 return new_timeline;
217 }
218
219 // Look for events that relate to pinned events. We already know the
220 // pinned-events, we don't need to look for them. We are only interested by
221 // related events.
222 for event in &timeline.events {
223 match extract_relation(event.raw()) {
224 // Ohh, this event relates to another event!
225 Some((relation_type, related_event_id)) => match relation_type {
226 // `event` relates to a thread: not what we want.
227 RelationType::Thread => {}
228
229 // `event` represents an annotation (e.g. reactions), a replacement (an edit), a
230 // reference or something custom. Let's see if the `related_event_id` is a
231 // pinned-event.
232 RelationType::Annotation
233 | RelationType::Replacement
234 | RelationType::Reference
235 | _ => {
236 if pinned_event_ids.contains(&related_event_id) {
237 new_timeline.events.push(event.clone());
238 }
239 }
240 },
241
242 // No explicit relation, but it can be a redaction of a pinned-event!
243 None => {
244 if let Some(redaction_target) =
245 extract_redaction_target(event.raw(), redaction_rules)
246 && pinned_event_ids.contains(&redaction_target)
247 {
248 new_timeline.events.push(event.clone());
249 }
250 }
251 }
252 }
253
254 new_timeline
255}
256
257/// Filter (deserialised) ephemeral events to only keep the read receipts
258/// matching a particular predicate.
259///
260/// Read receipts have a deep structure (an entanglement of `BTreeMap`). The
261/// returned iterator returns the tuple `(OwnedEventId, Receipts)`, which is the
262/// second level. However, the `predicate` is applied on the fourth level,
263/// directly on the leaf of the read receipts.
264///
265/// The predicate is used with [`Iterator::filter_map`], and thus can return a
266/// group key (it can be anything). This group key is associated to the tuple
267/// mentioned earlier, and should be used to “group” read receipts. This is
268/// useful when one wants to group read receipts by their `ReceiptThread` for
269/// example.
270fn filter_read_receipts_and_group_by<'e, F, G>(
271 events: &'e [AnySyncEphemeralRoomEvent],
272 predicate: F,
273) -> impl Iterator<Item = (G, (&'e OwnedEventId, &'e Receipts))>
274where
275 F: Fn(&'e ReceiptThread) -> Option<G>,
276{
277 events
278 .iter()
279 .filter_map(|ephemeral| match ephemeral {
280 AnySyncEphemeralRoomEvent::Receipt(receipt_event) => Some(receipt_event),
281 _ => None,
282 })
283 .flat_map(|receipt_event| receipt_event.content.iter())
284 .filter_map(move |(event_id, event_receipts)| {
285 Some((
286 predicate(&event_receipts.first_key_value()?.1.first_key_value()?.1.thread)?,
287 (event_id, event_receipts),
288 ))
289 })
290}