Skip to main content

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}