Skip to main content

matrix_sdk/search_index/
mod.rs

1// Copyright 2025 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
15//! The search index is an abstraction layer in the matrix-sdk for the
16//! matrix-sdk-search crate. It provides a [`SearchIndex`] which wraps
17//! multiple [`RoomIndex`].
18
19use std::{collections::hash_map::HashMap, path::PathBuf, sync::Arc};
20
21use futures_util::future::join_all;
22use matrix_sdk_base::{
23    check_validity_of_replacement_events, deserialized_responses::TimelineEvent,
24};
25use matrix_sdk_search::{
26    error::IndexError,
27    index::{
28        IndexableEvent, RoomIndex, RoomIndexOperation, SearchResult, builder::RoomIndexBuilder,
29    },
30};
31use ruma::{
32    EventId, MilliSecondsSinceUnixEpoch, OwnedRoomId, RoomId,
33    events::{
34        AnySyncMessageLikeEvent, AnySyncTimelineEvent,
35        poll::{
36            start::SyncPollStartEvent,
37            unstable_start::{SyncUnstablePollStartEvent, UnstablePollStartEventContent},
38        },
39        room::{
40            message::{MessageType, OriginalSyncRoomMessageEvent, Relation, SyncRoomMessageEvent},
41            redaction::SyncRoomRedactionEvent,
42        },
43        sticker::SyncStickerEvent,
44    },
45    room_version_rules::RedactionRules,
46};
47use tokio::sync::{Mutex, MutexGuard};
48use tracing::{debug, warn};
49
50use crate::event_cache::RoomEventCache;
51
52type Password = String;
53
54/// Type of location to store [`RoomIndex`]
55#[derive(Clone, Debug)]
56pub enum SearchIndexStoreKind {
57    /// Store unencrypted in file system folder
58    UnencryptedDirectory(PathBuf),
59    /// Store encrypted in file system folder
60    EncryptedDirectory(PathBuf, Password),
61    /// Store in memory
62    InMemory,
63}
64
65/// Object that handles inteeraction with [`RoomIndex`]'s for search
66#[derive(Clone, Debug)]
67pub struct SearchIndex {
68    /// HashMap that links each joined room to its RoomIndex
69    room_indexes: Arc<Mutex<HashMap<OwnedRoomId, RoomIndex>>>,
70
71    /// Base directory that stores the directories for each RoomIndex
72    search_index_store_kind: SearchIndexStoreKind,
73}
74
75impl SearchIndex {
76    /// Create a new [`SearchIndex`]
77    pub fn new(
78        room_indexes: Arc<Mutex<HashMap<OwnedRoomId, RoomIndex>>>,
79        search_index_store_kind: SearchIndexStoreKind,
80    ) -> Self {
81        Self { room_indexes, search_index_store_kind }
82    }
83
84    /// Acquire [`SearchIndexGuard`] for this [`SearchIndex`].
85    pub async fn lock(&self) -> SearchIndexGuard<'_> {
86        SearchIndexGuard {
87            index_map: self.room_indexes.lock().await,
88            search_index_store_kind: &self.search_index_store_kind,
89        }
90    }
91}
92
93/// Object that represents an acquired [`SearchIndex`].
94#[derive(Debug)]
95pub struct SearchIndexGuard<'a> {
96    /// Guard around the [`RoomIndex`] map
97    index_map: MutexGuard<'a, HashMap<OwnedRoomId, RoomIndex>>,
98
99    /// Base directory that stores the directories for each RoomIndex
100    search_index_store_kind: &'a SearchIndexStoreKind,
101}
102
103impl SearchIndexGuard<'_> {
104    fn create_index(&self, room_id: &RoomId) -> Result<RoomIndex, IndexError> {
105        let index = match self.search_index_store_kind {
106            SearchIndexStoreKind::UnencryptedDirectory(path) => {
107                RoomIndexBuilder::new_on_disk(path.to_path_buf(), room_id).unencrypted().build()?
108            }
109            SearchIndexStoreKind::EncryptedDirectory(path, password) => {
110                RoomIndexBuilder::new_on_disk(path.to_path_buf(), room_id)
111                    .encrypted(password)
112                    .build()?
113            }
114            SearchIndexStoreKind::InMemory => RoomIndexBuilder::new_in_memory(room_id).build(),
115        };
116        Ok(index)
117    }
118
119    /// Handle a [`RoomIndexOperation`] in the [`RoomIndex`] of a given
120    /// [`RoomId`]
121    ///
122    /// This which will add/remove/edit an event in the index based on the event
123    /// type.
124    ///
125    /// Prefer [`SearchIndexGuard::bulk_execute`] for multiple operations.
126    pub(crate) fn execute(
127        &mut self,
128        operation: RoomIndexOperation,
129        room_id: &RoomId,
130    ) -> Result<(), IndexError> {
131        if !self.index_map.contains_key(room_id) {
132            let index = self.create_index(room_id)?;
133            self.index_map.insert(room_id.to_owned(), index);
134        }
135
136        let index = self.index_map.get_mut(room_id).expect("index should exist");
137
138        index.execute(operation)
139    }
140
141    /// Handle a [`RoomIndexOperation`] in the [`RoomIndex`] of a given
142    /// [`RoomId`]
143    ///
144    /// This which will add/remove/edit an event in the index based on the event
145    /// type.
146    pub(crate) fn bulk_execute(
147        &mut self,
148        operations: Vec<RoomIndexOperation>,
149        room_id: &RoomId,
150    ) -> Result<(), IndexError> {
151        if !self.index_map.contains_key(room_id) {
152            let index = self.create_index(room_id)?;
153            self.index_map.insert(room_id.to_owned(), index);
154        }
155
156        let index = self.index_map.get_mut(room_id).expect("index should exist");
157
158        index.bulk_execute(operations)
159    }
160
161    /// Search a [`Room`]'s index for the query and return at most
162    /// max_number_of_results results.
163    pub(crate) fn search(
164        &mut self,
165        query: &str,
166        max_number_of_results: usize,
167        pagination_offset: Option<usize>,
168        room_id: &RoomId,
169    ) -> Result<SearchResult, IndexError> {
170        if !self.index_map.contains_key(room_id) {
171            let index = self.create_index(room_id)?;
172            self.index_map.insert(room_id.to_owned(), index);
173        }
174
175        let index = self.index_map.get_mut(room_id).expect("index should exist");
176
177        index.search(query, max_number_of_results, pagination_offset)
178    }
179
180    /// Given a [`TimelineEvent`] this function will derive a
181    /// [`RoomIndexOperation`], if it should be handled, and execute it;
182    /// returning the result.
183    ///
184    /// Prefer [`SearchIndexGuard::bulk_handle_timeline_event`] for multiple
185    /// events.
186    pub async fn handle_timeline_event(
187        &mut self,
188        event: TimelineEvent,
189        room_cache: &RoomEventCache,
190        room_id: &RoomId,
191        redaction_rules: &RedactionRules,
192    ) -> Result<(), IndexError> {
193        if let Some(index_operation) =
194            parse_timeline_event(room_cache, event, redaction_rules).await
195        {
196            self.execute(index_operation, room_id)
197        } else {
198            Ok(())
199        }
200    }
201
202    /// Run [`SearchIndexGuard::handle_timeline_event`] for multiple
203    /// [`TimelineEvent`].
204    pub async fn bulk_handle_timeline_event<T>(
205        &mut self,
206        events: T,
207        room_cache: &RoomEventCache,
208        room_id: &RoomId,
209        redaction_rules: &RedactionRules,
210    ) -> Result<(), IndexError>
211    where
212        T: Iterator<Item = TimelineEvent>,
213    {
214        let futures = events.map(|ev| parse_timeline_event(room_cache, ev, redaction_rules));
215
216        let operations: Vec<_> = join_all(futures).await.into_iter().flatten().collect();
217
218        self.bulk_execute(operations, room_id)
219    }
220}
221
222/// Given an event id this function returns the most recent edit on said event
223/// or the event itself if there are no edits.
224async fn get_most_recent_edit(
225    cache: &RoomEventCache,
226    original: &EventId,
227) -> Option<OriginalSyncRoomMessageEvent> {
228    use ruma::events::{AnySyncTimelineEvent, relation::RelationType};
229
230    let Ok(Some((original_ev, related))) =
231        cache.find_event_with_relations(original, Some(vec![RelationType::Replacement])).await
232    else {
233        debug!("Couldn't find relations for {}", original);
234        return None;
235    };
236
237    // Only index valid replacements (matching sender, type, etc.); otherwise
238    // anyone could rewrite another user's indexed message. Fall back to the
239    // original event when there is no valid edit.
240    let latest = related
241        .iter()
242        .rev()
243        .find(|edit| {
244            check_validity_of_replacement_events(
245                original_ev.raw(),
246                original_ev.encryption_info().map(|info| &**info),
247                edit.raw(),
248                edit.encryption_info().map(|info| &**info),
249            )
250            .is_ok()
251        })
252        .unwrap_or(&original_ev);
253
254    match latest.raw().deserialize() {
255        Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(latest))) => {
256            latest.as_original().cloned()
257        }
258        _ => None,
259    }
260}
261
262/// Indexable text for a media message: its filename plus any caption.
263fn media_body(filename: &str, caption: Option<&str>) -> String {
264    match caption {
265        Some(caption) => format!("{filename} {caption}"),
266        None => filename.to_owned(),
267    }
268}
269
270/// Extract the searchable text from a room message, or `None` if its type
271/// carries no text to index.
272fn room_message_body(msgtype: &MessageType) -> Option<String> {
273    match msgtype {
274        MessageType::Text(content) => Some(content.body.clone()),
275        MessageType::Emote(content) => Some(content.body.clone()),
276        MessageType::Notice(content) => Some(content.body.clone()),
277        MessageType::ServerNotice(content) => Some(content.body.clone()),
278        MessageType::Location(content) => Some(content.body.clone()),
279        MessageType::Image(content) => Some(media_body(content.filename(), content.caption())),
280        MessageType::Video(content) => Some(media_body(content.filename(), content.caption())),
281        MessageType::Audio(content) => Some(media_body(content.filename(), content.caption())),
282        MessageType::File(content) => Some(media_body(content.filename(), content.caption())),
283        _ => None,
284    }
285}
286
287/// Build an [`IndexableEvent`] from a room message, or `None` if its type
288/// carries no searchable text.
289fn indexable_from_room_message(
290    event: &OriginalSyncRoomMessageEvent,
291    timestamp: Option<MilliSecondsSinceUnixEpoch>,
292) -> Option<IndexableEvent> {
293    let body = room_message_body(&event.content.msgtype)?;
294    let original_event_id = match &event.content.relates_to {
295        Some(Relation::Replacement(replacement)) => replacement.event_id.clone(),
296        _ => event.event_id.clone(),
297    };
298
299    Some(IndexableEvent::new(
300        event.event_id.clone(),
301        original_event_id,
302        event.sender.clone(),
303        timestamp,
304        body,
305    ))
306}
307
308/// If the given [`OriginalSyncRoomMessageEvent`] is an edit we make an
309/// [`RoomIndexOperation::Edit`] with the new most recent version of the
310/// original.
311async fn handle_possible_edit(
312    event: &OriginalSyncRoomMessageEvent,
313    timestamp: Option<MilliSecondsSinceUnixEpoch>,
314    cache: &RoomEventCache,
315) -> Option<RoomIndexOperation> {
316    if let Some(Relation::Replacement(replacement_data)) = &event.content.relates_to {
317        if let Some(recent) = get_most_recent_edit(cache, &replacement_data.event_id).await {
318            return Some(
319                indexable_from_room_message(&recent, timestamp).map_or(
320                    RoomIndexOperation::Noop,
321                    |indexable| {
322                        RoomIndexOperation::Edit(replacement_data.event_id.clone(), indexable)
323                    },
324                ),
325            );
326        } else {
327            return Some(RoomIndexOperation::Noop);
328        }
329    }
330    None
331}
332
333/// Return a [`RoomIndexOperation::Edit`] or [`RoomIndexOperation::Add`]
334/// depending on the message.
335async fn handle_room_message(
336    event: SyncRoomMessageEvent,
337    timestamp: Option<MilliSecondsSinceUnixEpoch>,
338    cache: &RoomEventCache,
339) -> Option<RoomIndexOperation> {
340    if let Some(event) = event.as_original() {
341        return handle_possible_edit(event, timestamp, cache).await.or(get_most_recent_edit(
342            cache,
343            &event.event_id,
344        )
345        .await
346        .and_then(|recent| {
347            indexable_from_room_message(&recent, timestamp).map(RoomIndexOperation::Add)
348        }));
349    }
350    None
351}
352
353/// Return a [`RoomIndexOperation`] removing a redacted event from the index, or
354/// re-adding the most recent remaining version if an edit was redacted.
355async fn handle_room_redaction(
356    event: SyncRoomRedactionEvent,
357    timestamp: Option<MilliSecondsSinceUnixEpoch>,
358    cache: &RoomEventCache,
359    rules: &RedactionRules,
360) -> Option<RoomIndexOperation> {
361    let redacted_event_id = event.redacts(rules)?;
362
363    // If the redacted event was a room message edit, re-add the most recent
364    // remaining version instead of just removing it.
365    if let Ok(Some(redacted_event)) = cache.find_event(redacted_event_id).await
366        && let Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(
367            redacted_event,
368        ))) = redacted_event.raw().deserialize()
369        && let Some(redacted_event) = redacted_event.as_original()
370        && let Some(operation) = handle_possible_edit(redacted_event, timestamp, cache).await
371    {
372        return Some(operation);
373    }
374
375    // Otherwise remove the redacted event from the index. This covers plain
376    // messages, stickers and polls.
377    Some(RoomIndexOperation::Remove(redacted_event_id.to_owned()))
378}
379
380/// Return a [`RoomIndexOperation::Add`] indexing a sticker's descriptive text.
381fn handle_sticker(
382    event: SyncStickerEvent,
383    timestamp: Option<MilliSecondsSinceUnixEpoch>,
384) -> Option<RoomIndexOperation> {
385    let event = event.as_original()?;
386
387    Some(RoomIndexOperation::Add(IndexableEvent::new(
388        event.event_id.clone(),
389        event.event_id.clone(),
390        event.sender.clone(),
391        timestamp,
392        event.content.body.clone(),
393    )))
394}
395
396/// Return a [`RoomIndexOperation::Add`] indexing an unstable poll's question
397/// and answers.
398///
399/// ponytail: only indexes the initial `New` poll — edits (`Replacement`) and
400/// poll ends are ignored. Add edit handling if editing a poll needs to update
401/// search results.
402fn handle_unstable_poll_start(
403    event: SyncUnstablePollStartEvent,
404    timestamp: Option<MilliSecondsSinceUnixEpoch>,
405) -> Option<RoomIndexOperation> {
406    let event = event.as_original()?;
407
408    let UnstablePollStartEventContent::New(content) = &event.content else {
409        return None;
410    };
411
412    let block = &content.poll_start;
413    let mut body = block.question.text.clone();
414    for answer in block.answers.iter() {
415        body.push(' ');
416        body.push_str(&answer.text);
417    }
418
419    Some(RoomIndexOperation::Add(IndexableEvent::new(
420        event.event_id.clone(),
421        event.event_id.clone(),
422        event.sender.clone(),
423        timestamp,
424        body,
425    )))
426}
427
428/// Return a [`RoomIndexOperation::Add`] indexing a stable poll's question and
429/// answers.
430///
431/// ponytail: like [`handle_unstable_poll_start`], edits and poll ends are
432/// ignored — only the initial poll is indexed.
433fn handle_poll_start(
434    event: SyncPollStartEvent,
435    timestamp: Option<MilliSecondsSinceUnixEpoch>,
436) -> Option<RoomIndexOperation> {
437    let event = event.as_original()?;
438
439    // Skip poll edits, matching the unstable poll handling.
440    if let Some(Relation::Replacement(_)) = &event.content.relates_to {
441        return None;
442    }
443
444    let block = &event.content.poll;
445    let mut body = block.question.text.find_plain()?.to_owned();
446    for answer in block.answers.iter() {
447        if let Some(text) = answer.text.find_plain() {
448            body.push(' ');
449            body.push_str(text);
450        }
451    }
452
453    Some(RoomIndexOperation::Add(IndexableEvent::new(
454        event.event_id.clone(),
455        event.event_id.clone(),
456        event.sender.clone(),
457        timestamp,
458        body,
459    )))
460}
461
462/// Prepare a [`TimelineEvent`] into a [`RoomIndexOperation`] for search
463/// indexing.
464async fn parse_timeline_event(
465    cache: &RoomEventCache,
466    event: TimelineEvent,
467    redaction_rules: &RedactionRules,
468) -> Option<RoomIndexOperation> {
469    use ruma::events::AnySyncTimelineEvent;
470
471    if event.kind.is_utd() {
472        return None;
473    }
474
475    let timestamp = event.timestamp();
476
477    match event.raw().deserialize() {
478        Ok(event) => match event {
479            AnySyncTimelineEvent::MessageLike(event) => match event {
480                AnySyncMessageLikeEvent::RoomMessage(event) => {
481                    handle_room_message(event, timestamp, cache).await
482                }
483                AnySyncMessageLikeEvent::RoomRedaction(event) => {
484                    handle_room_redaction(event, timestamp, cache, redaction_rules).await
485                }
486                AnySyncMessageLikeEvent::Sticker(event) => handle_sticker(event, timestamp),
487                AnySyncMessageLikeEvent::PollStart(event) => handle_poll_start(event, timestamp),
488                AnySyncMessageLikeEvent::UnstablePollStart(event) => {
489                    handle_unstable_poll_start(event, timestamp)
490                }
491                _ => None,
492            },
493            AnySyncTimelineEvent::State(_) => None,
494        },
495
496        Err(e) => {
497            warn!("failed to parse event: {e:?}");
498            None
499        }
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use matrix_sdk_test::{JoinedRoomBuilder, async_test, event_factory::EventFactory};
506    use ruma::{
507        event_id, events::room::message::RoomMessageEventContentWithoutRelation, room_id, user_id,
508    };
509
510    use crate::test_utils::mocks::MatrixMockServer;
511
512    #[cfg(feature = "experimental-search")]
513    #[async_test]
514    async fn test_sync_message_is_indexed() {
515        let mock_server = MatrixMockServer::new().await;
516        let client = mock_server.client_builder().build().await;
517
518        client.event_cache().subscribe().unwrap();
519
520        let room_id = room_id!("!room_id:localhost");
521        let event_id = event_id!("$event_id:localost");
522        let user_id = user_id!("@user_id:localost");
523
524        let event_factory = EventFactory::new();
525        let room = mock_server
526            .sync_room(
527                &client,
528                JoinedRoomBuilder::new(room_id).add_timeline_bulk(vec![
529                    event_factory
530                        .text_msg("this is a sentence")
531                        .event_id(event_id)
532                        .sender(user_id)
533                        .into_raw_sync(),
534                ]),
535            )
536            .await;
537
538        let response = room.search("this", 5, None).await.expect("search should have 1 result");
539
540        assert_eq!(response.events.len(), 1, "unexpected numbers of responses: {response:?}");
541        assert_eq!(response.events[0].1, event_id, "event id doesn't match: {response:?}");
542    }
543
544    #[cfg(feature = "experimental-search")]
545    #[async_test]
546    async fn test_sync_media_message_is_indexed() {
547        use ruma::owned_mxc_uri;
548
549        let mock_server = MatrixMockServer::new().await;
550        let client = mock_server.client_builder().build().await;
551
552        client.event_cache().subscribe().unwrap();
553
554        let room_id = room_id!("!room_id:localhost");
555        let image_id = event_id!("$image_id:localhost");
556        let file_id = event_id!("$file_id:localhost");
557        let user_id = user_id!("@user_id:localhost");
558
559        let f = EventFactory::new();
560        let room = mock_server
561            .sync_room(
562                &client,
563                JoinedRoomBuilder::new(room_id).add_timeline_bulk(vec![
564                    f.image("holiday_beach.jpg".to_owned(), owned_mxc_uri!("mxc://localhost/1"))
565                        .caption(Some("sunset over the ocean".to_owned()), None)
566                        .event_id(image_id)
567                        .sender(user_id)
568                        .into_raw_sync(),
569                    f.image("quarterly_report.pdf".to_owned(), owned_mxc_uri!("mxc://localhost/2"))
570                        .event_id(file_id)
571                        .sender(user_id)
572                        .into_raw_sync(),
573                ]),
574            )
575            .await;
576
577        // The caption is indexed.
578        let response = room.search("sunset", 5, None).await.unwrap();
579        assert_eq!(response.events.len(), 1, "unexpected results for caption search: {response:?}");
580        assert_eq!(response.events[0].1, image_id, "event id doesn't match: {response:?}");
581
582        // The filename is indexed.
583        let response = room.search("holiday_beach", 5, None).await.unwrap();
584        assert_eq!(
585            response.events.len(),
586            1,
587            "unexpected results for filename search: {response:?}"
588        );
589        assert_eq!(response.events[0].1, image_id, "event id doesn't match: {response:?}");
590
591        // A media message without a caption still indexes its filename.
592        let response = room.search("quarterly_report", 5, None).await.unwrap();
593        assert_eq!(
594            response.events.len(),
595            1,
596            "unexpected results for filename search: {response:?}"
597        );
598        assert_eq!(response.events[0].1, file_id, "event id doesn't match: {response:?}");
599    }
600
601    #[cfg(feature = "experimental-search")]
602    #[async_test]
603    async fn test_sync_sticker_and_poll_are_indexed() {
604        use ruma::{events::room::ImageInfo, owned_mxc_uri};
605
606        let mock_server = MatrixMockServer::new().await;
607        let client = mock_server.client_builder().build().await;
608
609        client.event_cache().subscribe().unwrap();
610
611        let room_id = room_id!("!room_id:localhost");
612        let sticker_id = event_id!("$sticker_id:localhost");
613        let poll_id = event_id!("$poll_id:localhost");
614        let user_id = user_id!("@user_id:localhost");
615
616        let f = EventFactory::new().room(room_id).sender(user_id);
617        let room = mock_server
618            .sync_room(
619                &client,
620                JoinedRoomBuilder::new(room_id).add_timeline_bulk(vec![
621                    f.sticker(
622                        "a waving cat",
623                        ImageInfo::new(),
624                        owned_mxc_uri!("mxc://localhost/1"),
625                    )
626                    .event_id(sticker_id)
627                    .into_raw_sync(),
628                    f.poll_start("fallback", "favourite cheese?", vec!["comté", "gruyère"])
629                        .event_id(poll_id)
630                        .into_raw_sync(),
631                ]),
632            )
633            .await;
634
635        // The sticker's description is indexed.
636        let response = room.search("waving", 5, None).await.unwrap();
637        assert_eq!(response.events.len(), 1, "unexpected results for sticker search: {response:?}");
638        assert_eq!(response.events[0].1, sticker_id, "event id doesn't match: {response:?}");
639
640        // The poll question is indexed.
641        let response = room.search("cheese", 5, None).await.unwrap();
642        assert_eq!(
643            response.events.len(),
644            1,
645            "unexpected results for poll question search: {response:?}"
646        );
647        assert_eq!(response.events[0].1, poll_id, "event id doesn't match: {response:?}");
648
649        // The poll answers are indexed.
650        let response = room.search("gruyère", 5, None).await.unwrap();
651        assert_eq!(
652            response.events.len(),
653            1,
654            "unexpected results for poll answer search: {response:?}"
655        );
656        assert_eq!(response.events[0].1, poll_id, "event id doesn't match: {response:?}");
657    }
658
659    #[cfg(feature = "experimental-search")]
660    #[async_test]
661    async fn test_sync_stable_poll_is_indexed() {
662        use ruma::events::{
663            message::TextContentBlock,
664            poll::start::{PollAnswer, PollAnswers, PollContentBlock, PollStartEventContent},
665        };
666
667        let mock_server = MatrixMockServer::new().await;
668        let client = mock_server.client_builder().build().await;
669
670        client.event_cache().subscribe().unwrap();
671
672        let room_id = room_id!("!room_id:localhost");
673        let poll_id = event_id!("$stable_poll_id:localhost");
674        let user_id = user_id!("@user_id:localhost");
675
676        let answers: PollAnswers = vec![
677            PollAnswer::new("0".to_owned(), TextContentBlock::plain("comté")),
678            PollAnswer::new("1".to_owned(), TextContentBlock::plain("gruyère")),
679        ]
680        .try_into()
681        .unwrap();
682        let poll = PollContentBlock::new(TextContentBlock::plain("favourite cheese?"), answers);
683        let content = PollStartEventContent::new(TextContentBlock::plain("fallback"), poll);
684
685        let f = EventFactory::new().room(room_id).sender(user_id);
686        let room = mock_server
687            .sync_room(
688                &client,
689                JoinedRoomBuilder::new(room_id)
690                    .add_timeline_bulk(vec![f.event(content).event_id(poll_id).into_raw_sync()]),
691            )
692            .await;
693
694        // The poll question is indexed.
695        let response = room.search("cheese", 5, None).await.unwrap();
696        assert_eq!(
697            response.events.len(),
698            1,
699            "unexpected results for poll question search: {response:?}"
700        );
701        assert_eq!(response.events[0].1, poll_id, "event id doesn't match: {response:?}");
702
703        // The poll answers are indexed.
704        let response = room.search("gruyère", 5, None).await.unwrap();
705        assert_eq!(
706            response.events.len(),
707            1,
708            "unexpected results for poll answer search: {response:?}"
709        );
710        assert_eq!(response.events[0].1, poll_id, "event id doesn't match: {response:?}");
711    }
712
713    #[cfg(feature = "experimental-search")]
714    #[async_test]
715    async fn test_search_index_edit_ordering() {
716        let room_id = room_id!("!room_id:localhost");
717        let dummy_id = event_id!("$dummy");
718        let edit1_id = event_id!("$edit1");
719        let edit2_id = event_id!("$edit2");
720        let edit3_id = event_id!("$edit3");
721        let original_id = event_id!("$original");
722
723        let server = MatrixMockServer::new().await;
724        let client = server.client_builder().build().await;
725
726        let event_cache = client.event_cache();
727        event_cache.subscribe().unwrap();
728
729        let room = server.sync_joined_room(&client, room_id).await;
730
731        let f = EventFactory::new().room(room_id).sender(user_id!("@user_id:localhost"));
732
733        // Indexable dummy message required because RoomIndex is initialised
734        // lazily.
735        let dummy = f.text_msg("dummy").event_id(dummy_id);
736
737        let original = f.text_msg("This is a message").event_id(original_id);
738
739        let edit1 = f
740            .text_msg("* A new message")
741            .edit(original_id, RoomMessageEventContentWithoutRelation::text_plain("A new message"))
742            .event_id(edit1_id);
743
744        let edit2 = f
745            .text_msg("* An even newer message")
746            .edit(
747                original_id,
748                RoomMessageEventContentWithoutRelation::text_plain("An even newer message"),
749            )
750            .event_id(edit2_id);
751
752        let edit3 = f
753            .text_msg("* The newest message")
754            .edit(
755                original_id,
756                RoomMessageEventContentWithoutRelation::text_plain("The newest message"),
757            )
758            .event_id(edit3_id);
759
760        server
761            .sync_room(
762                &client,
763                JoinedRoomBuilder::new(room_id)
764                    .add_timeline_event(dummy)
765                    .add_timeline_event(edit1)
766                    .add_timeline_event(edit2),
767            )
768            .await;
769
770        let results = room.search("message", 3, None).await.unwrap();
771
772        assert_eq!(results.events.len(), 0, "Search should return 0 results, got {results:?}");
773
774        // Adding the original after some pending edits should add the latest
775        // edit instead of the original.
776        server
777            .sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(original))
778            .await;
779
780        let results = room.search("message", 3, None).await.unwrap();
781
782        assert_eq!(results.events.len(), 1, "Search should return 1 result, got {results:?}");
783        assert_eq!(
784            results.events[0].1, edit2_id,
785            "Search should return latest edit, got {:?}",
786            results.events[0].1
787        );
788
789        // Editing the original after it exists and there has been another edit
790        // should delete the previous edits and add this one
791        server.sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(edit3)).await;
792
793        let results = room.search("message", 3, None).await.unwrap();
794
795        assert_eq!(results.events.len(), 1, "Search should return 1 result, got {results:?}");
796        assert_eq!(
797            results.events[0].1, edit3_id,
798            "Search should return latest edit, got {:?}",
799            results.events[0].1
800        );
801    }
802
803    #[cfg(feature = "experimental-search")]
804    #[async_test]
805    async fn test_search_index_ignores_cross_sender_edit() {
806        let room_id = room_id!("!room_id:localhost");
807        let original_id = event_id!("$original");
808        let edit_id = event_id!("$edit");
809
810        let server = MatrixMockServer::new().await;
811        let client = server.client_builder().build().await;
812
813        let event_cache = client.event_cache();
814        event_cache.subscribe().unwrap();
815
816        let room = server.sync_joined_room(&client, room_id).await;
817
818        let f = EventFactory::new().room(room_id);
819
820        let original =
821            f.text_msg("original alpha").sender(user_id!("@alice:localhost")).event_id(original_id);
822
823        // An edit from a different user than the original sender is not a valid
824        // replacement and must be ignored.
825        let malicious_edit = f
826            .text_msg("* malicious beta")
827            .edit(original_id, RoomMessageEventContentWithoutRelation::text_plain("malicious beta"))
828            .sender(user_id!("@bob:localhost"))
829            .event_id(edit_id);
830
831        server
832            .sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(original))
833            .await;
834
835        // The original message is indexed.
836        let results = room.search("alpha", 3, None).await.unwrap();
837        assert_eq!(results.events.len(), 1, "Original should be indexed, got {results:?}");
838        assert_eq!(results.events[0].1, original_id, "unexpected event id: {results:?}");
839
840        server
841            .sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(malicious_edit))
842            .await;
843
844        // The forged edit's content must not be indexed.
845        let results = room.search("beta", 3, None).await.unwrap();
846        assert_eq!(results.events.len(), 0, "Cross-sender edit should be ignored, got {results:?}");
847
848        // The original message stays indexed.
849        let results = room.search("alpha", 3, None).await.unwrap();
850        assert_eq!(results.events.len(), 1, "Original should stay indexed, got {results:?}");
851        assert_eq!(results.events[0].1, original_id, "unexpected event id: {results:?}");
852    }
853}