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