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, MilliSecondsSinceUnixEpoch, 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(
288    event: &OriginalSyncRoomMessageEvent,
289    timestamp: Option<MilliSecondsSinceUnixEpoch>,
290) -> Option<IndexableEvent> {
291    let body = room_message_body(&event.content.msgtype)?;
292    let original_event_id = match &event.content.relates_to {
293        Some(Relation::Replacement(replacement)) => replacement.event_id.clone(),
294        _ => event.event_id.clone(),
295    };
296
297    Some(IndexableEvent::new(
298        event.event_id.clone(),
299        original_event_id,
300        event.sender.clone(),
301        timestamp,
302        body,
303    ))
304}
305
306/// If the given [`OriginalSyncRoomMessageEvent`] is an edit we make an
307/// [`RoomIndexOperation::Edit`] with the new most recent version of the
308/// original.
309async fn handle_possible_edit(
310    event: &OriginalSyncRoomMessageEvent,
311    timestamp: Option<MilliSecondsSinceUnixEpoch>,
312    cache: &RoomEventCache,
313) -> Option<RoomIndexOperation> {
314    if let Some(Relation::Replacement(replacement_data)) = &event.content.relates_to {
315        if let Some(recent) = get_most_recent_edit(cache, &replacement_data.event_id).await {
316            return Some(
317                indexable_from_room_message(&recent, timestamp).map_or(
318                    RoomIndexOperation::Noop,
319                    |indexable| {
320                        RoomIndexOperation::Edit(replacement_data.event_id.clone(), indexable)
321                    },
322                ),
323            );
324        } else {
325            return Some(RoomIndexOperation::Noop);
326        }
327    }
328    None
329}
330
331/// Return a [`RoomIndexOperation::Edit`] or [`RoomIndexOperation::Add`]
332/// depending on the message.
333async fn handle_room_message(
334    event: SyncRoomMessageEvent,
335    timestamp: Option<MilliSecondsSinceUnixEpoch>,
336    cache: &RoomEventCache,
337) -> Option<RoomIndexOperation> {
338    if let Some(event) = event.as_original() {
339        return handle_possible_edit(event, timestamp, cache).await.or(get_most_recent_edit(
340            cache,
341            &event.event_id,
342        )
343        .await
344        .and_then(|recent| {
345            indexable_from_room_message(&recent, timestamp).map(RoomIndexOperation::Add)
346        }));
347    }
348    None
349}
350
351/// Return a [`RoomIndexOperation`] removing a redacted event from the index, or
352/// re-adding the most recent remaining version if an edit was redacted.
353async fn handle_room_redaction(
354    event: SyncRoomRedactionEvent,
355    timestamp: Option<MilliSecondsSinceUnixEpoch>,
356    cache: &RoomEventCache,
357    rules: &RedactionRules,
358) -> Option<RoomIndexOperation> {
359    let redacted_event_id = event.redacts(rules)?;
360
361    // If the redacted event was a room message edit, re-add the most recent
362    // remaining version instead of just removing it.
363    if let Ok(Some(redacted_event)) = cache.find_event(redacted_event_id).await
364        && let Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(
365            redacted_event,
366        ))) = redacted_event.raw().deserialize()
367        && let Some(redacted_event) = redacted_event.as_original()
368        && let Some(operation) = handle_possible_edit(redacted_event, timestamp, cache).await
369    {
370        return Some(operation);
371    }
372
373    // Otherwise remove the redacted event from the index. This covers plain
374    // messages, stickers and polls.
375    Some(RoomIndexOperation::Remove(redacted_event_id.to_owned()))
376}
377
378/// Return a [`RoomIndexOperation::Add`] indexing a sticker's descriptive text.
379fn handle_sticker(
380    event: SyncStickerEvent,
381    timestamp: Option<MilliSecondsSinceUnixEpoch>,
382) -> Option<RoomIndexOperation> {
383    let event = event.as_original()?;
384
385    Some(RoomIndexOperation::Add(IndexableEvent::new(
386        event.event_id.clone(),
387        event.event_id.clone(),
388        event.sender.clone(),
389        timestamp,
390        event.content.body.clone(),
391    )))
392}
393
394/// Return a [`RoomIndexOperation::Add`] indexing an unstable poll's question
395/// and answers.
396///
397/// ponytail: only indexes the initial `New` poll — edits (`Replacement`) and
398/// poll ends are ignored. Add edit handling if editing a poll needs to update
399/// search results.
400fn handle_unstable_poll_start(
401    event: SyncUnstablePollStartEvent,
402    timestamp: Option<MilliSecondsSinceUnixEpoch>,
403) -> Option<RoomIndexOperation> {
404    let event = event.as_original()?;
405
406    let UnstablePollStartEventContent::New(content) = &event.content else {
407        return None;
408    };
409
410    let block = &content.poll_start;
411    let mut body = block.question.text.clone();
412    for answer in block.answers.iter() {
413        body.push(' ');
414        body.push_str(&answer.text);
415    }
416
417    Some(RoomIndexOperation::Add(IndexableEvent::new(
418        event.event_id.clone(),
419        event.event_id.clone(),
420        event.sender.clone(),
421        timestamp,
422        body,
423    )))
424}
425
426/// Return a [`RoomIndexOperation::Add`] indexing a stable poll's question and
427/// answers.
428///
429/// ponytail: like [`handle_unstable_poll_start`], edits and poll ends are
430/// ignored — only the initial poll is indexed.
431fn handle_poll_start(
432    event: SyncPollStartEvent,
433    timestamp: Option<MilliSecondsSinceUnixEpoch>,
434) -> Option<RoomIndexOperation> {
435    let event = event.as_original()?;
436
437    // Skip poll edits, matching the unstable poll handling.
438    if let Some(Relation::Replacement(_)) = &event.content.relates_to {
439        return None;
440    }
441
442    let block = &event.content.poll;
443    let mut body = block.question.text.find_plain()?.to_owned();
444    for answer in block.answers.iter() {
445        if let Some(text) = answer.text.find_plain() {
446            body.push(' ');
447            body.push_str(text);
448        }
449    }
450
451    Some(RoomIndexOperation::Add(IndexableEvent::new(
452        event.event_id.clone(),
453        event.event_id.clone(),
454        event.sender.clone(),
455        timestamp,
456        body,
457    )))
458}
459
460/// Prepare a [`TimelineEvent`] into a [`RoomIndexOperation`] for search
461/// indexing.
462async fn parse_timeline_event(
463    cache: &RoomEventCache,
464    event: TimelineEvent,
465    redaction_rules: &RedactionRules,
466) -> Option<RoomIndexOperation> {
467    use ruma::events::AnySyncTimelineEvent;
468
469    if event.kind.is_utd() {
470        return None;
471    }
472
473    let timestamp = event.timestamp();
474
475    match event.raw().deserialize() {
476        Ok(event) => match event {
477            AnySyncTimelineEvent::MessageLike(event) => match event {
478                AnySyncMessageLikeEvent::RoomMessage(event) => {
479                    handle_room_message(event, timestamp, cache).await
480                }
481                AnySyncMessageLikeEvent::RoomRedaction(event) => {
482                    handle_room_redaction(event, timestamp, cache, redaction_rules).await
483                }
484                AnySyncMessageLikeEvent::Sticker(event) => handle_sticker(event, timestamp),
485                AnySyncMessageLikeEvent::PollStart(event) => handle_poll_start(event, timestamp),
486                AnySyncMessageLikeEvent::UnstablePollStart(event) => {
487                    handle_unstable_poll_start(event, timestamp)
488                }
489                _ => None,
490            },
491            AnySyncTimelineEvent::State(_) => None,
492        },
493
494        Err(e) => {
495            warn!("failed to parse event: {e:?}");
496            None
497        }
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use matrix_sdk_test::{JoinedRoomBuilder, async_test, event_factory::EventFactory};
504    use ruma::{
505        event_id, events::room::message::RoomMessageEventContentWithoutRelation, room_id, user_id,
506    };
507
508    use crate::test_utils::mocks::MatrixMockServer;
509
510    #[cfg(feature = "experimental-search")]
511    #[async_test]
512    async fn test_sync_message_is_indexed() {
513        let mock_server = MatrixMockServer::new().await;
514        let client = mock_server.client_builder().build().await;
515
516        client.event_cache().subscribe().unwrap();
517
518        let room_id = room_id!("!room_id:localhost");
519        let event_id = event_id!("$event_id:localost");
520        let user_id = user_id!("@user_id:localost");
521
522        let event_factory = EventFactory::new();
523        let room = mock_server
524            .sync_room(
525                &client,
526                JoinedRoomBuilder::new(room_id).add_timeline_bulk(vec![
527                    event_factory
528                        .text_msg("this is a sentence")
529                        .event_id(event_id)
530                        .sender(user_id)
531                        .into_raw_sync(),
532                ]),
533            )
534            .await;
535
536        let response = room.search("this", 5, None).await.expect("search should have 1 result");
537
538        assert_eq!(response.len(), 1, "unexpected numbers of responses: {response:?}");
539        assert_eq!(response[0].1, event_id, "event id doesn't match: {response:?}");
540    }
541
542    #[cfg(feature = "experimental-search")]
543    #[async_test]
544    async fn test_sync_media_message_is_indexed() {
545        use ruma::owned_mxc_uri;
546
547        let mock_server = MatrixMockServer::new().await;
548        let client = mock_server.client_builder().build().await;
549
550        client.event_cache().subscribe().unwrap();
551
552        let room_id = room_id!("!room_id:localhost");
553        let image_id = event_id!("$image_id:localhost");
554        let file_id = event_id!("$file_id:localhost");
555        let user_id = user_id!("@user_id:localhost");
556
557        let f = EventFactory::new();
558        let room = mock_server
559            .sync_room(
560                &client,
561                JoinedRoomBuilder::new(room_id).add_timeline_bulk(vec![
562                    f.image("holiday_beach.jpg".to_owned(), owned_mxc_uri!("mxc://localhost/1"))
563                        .caption(Some("sunset over the ocean".to_owned()), None)
564                        .event_id(image_id)
565                        .sender(user_id)
566                        .into_raw_sync(),
567                    f.image("quarterly_report.pdf".to_owned(), owned_mxc_uri!("mxc://localhost/2"))
568                        .event_id(file_id)
569                        .sender(user_id)
570                        .into_raw_sync(),
571                ]),
572            )
573            .await;
574
575        // The caption is indexed.
576        let response = room.search("sunset", 5, None).await.unwrap();
577        assert_eq!(response.len(), 1, "unexpected results for caption search: {response:?}");
578        assert_eq!(response[0].1, image_id, "event id doesn't match: {response:?}");
579
580        // The filename is indexed.
581        let response = room.search("holiday_beach", 5, None).await.unwrap();
582        assert_eq!(response.len(), 1, "unexpected results for filename search: {response:?}");
583        assert_eq!(response[0].1, image_id, "event id doesn't match: {response:?}");
584
585        // A media message without a caption still indexes its filename.
586        let response = room.search("quarterly_report", 5, None).await.unwrap();
587        assert_eq!(response.len(), 1, "unexpected results for filename search: {response:?}");
588        assert_eq!(response[0].1, file_id, "event id doesn't match: {response:?}");
589    }
590
591    #[cfg(feature = "experimental-search")]
592    #[async_test]
593    async fn test_sync_sticker_and_poll_are_indexed() {
594        use ruma::{events::room::ImageInfo, owned_mxc_uri};
595
596        let mock_server = MatrixMockServer::new().await;
597        let client = mock_server.client_builder().build().await;
598
599        client.event_cache().subscribe().unwrap();
600
601        let room_id = room_id!("!room_id:localhost");
602        let sticker_id = event_id!("$sticker_id:localhost");
603        let poll_id = event_id!("$poll_id:localhost");
604        let user_id = user_id!("@user_id:localhost");
605
606        let f = EventFactory::new().room(room_id).sender(user_id);
607        let room = mock_server
608            .sync_room(
609                &client,
610                JoinedRoomBuilder::new(room_id).add_timeline_bulk(vec![
611                    f.sticker(
612                        "a waving cat",
613                        ImageInfo::new(),
614                        owned_mxc_uri!("mxc://localhost/1"),
615                    )
616                    .event_id(sticker_id)
617                    .into_raw_sync(),
618                    f.poll_start("fallback", "favourite cheese?", vec!["comté", "gruyère"])
619                        .event_id(poll_id)
620                        .into_raw_sync(),
621                ]),
622            )
623            .await;
624
625        // The sticker's description is indexed.
626        let response = room.search("waving", 5, None).await.unwrap();
627        assert_eq!(response.len(), 1, "unexpected results for sticker search: {response:?}");
628        assert_eq!(response[0].1, sticker_id, "event id doesn't match: {response:?}");
629
630        // The poll question is indexed.
631        let response = room.search("cheese", 5, None).await.unwrap();
632        assert_eq!(response.len(), 1, "unexpected results for poll question search: {response:?}");
633        assert_eq!(response[0].1, poll_id, "event id doesn't match: {response:?}");
634
635        // The poll answers are indexed.
636        let response = room.search("gruyère", 5, None).await.unwrap();
637        assert_eq!(response.len(), 1, "unexpected results for poll answer search: {response:?}");
638        assert_eq!(response[0].1, poll_id, "event id doesn't match: {response:?}");
639    }
640
641    #[cfg(feature = "experimental-search")]
642    #[async_test]
643    async fn test_sync_stable_poll_is_indexed() {
644        use ruma::events::{
645            message::TextContentBlock,
646            poll::start::{PollAnswer, PollAnswers, PollContentBlock, PollStartEventContent},
647        };
648
649        let mock_server = MatrixMockServer::new().await;
650        let client = mock_server.client_builder().build().await;
651
652        client.event_cache().subscribe().unwrap();
653
654        let room_id = room_id!("!room_id:localhost");
655        let poll_id = event_id!("$stable_poll_id:localhost");
656        let user_id = user_id!("@user_id:localhost");
657
658        let answers: PollAnswers = vec![
659            PollAnswer::new("0".to_owned(), TextContentBlock::plain("comté")),
660            PollAnswer::new("1".to_owned(), TextContentBlock::plain("gruyère")),
661        ]
662        .try_into()
663        .unwrap();
664        let poll = PollContentBlock::new(TextContentBlock::plain("favourite cheese?"), answers);
665        let content = PollStartEventContent::new(TextContentBlock::plain("fallback"), poll);
666
667        let f = EventFactory::new().room(room_id).sender(user_id);
668        let room = mock_server
669            .sync_room(
670                &client,
671                JoinedRoomBuilder::new(room_id)
672                    .add_timeline_bulk(vec![f.event(content).event_id(poll_id).into_raw_sync()]),
673            )
674            .await;
675
676        // The poll question is indexed.
677        let response = room.search("cheese", 5, None).await.unwrap();
678        assert_eq!(response.len(), 1, "unexpected results for poll question search: {response:?}");
679        assert_eq!(response[0].1, poll_id, "event id doesn't match: {response:?}");
680
681        // The poll answers are indexed.
682        let response = room.search("gruyère", 5, None).await.unwrap();
683        assert_eq!(response.len(), 1, "unexpected results for poll answer search: {response:?}");
684        assert_eq!(response[0].1, poll_id, "event id doesn't match: {response:?}");
685    }
686
687    #[cfg(feature = "experimental-search")]
688    #[async_test]
689    async fn test_search_index_edit_ordering() {
690        let room_id = room_id!("!room_id:localhost");
691        let dummy_id = event_id!("$dummy");
692        let edit1_id = event_id!("$edit1");
693        let edit2_id = event_id!("$edit2");
694        let edit3_id = event_id!("$edit3");
695        let original_id = event_id!("$original");
696
697        let server = MatrixMockServer::new().await;
698        let client = server.client_builder().build().await;
699
700        let event_cache = client.event_cache();
701        event_cache.subscribe().unwrap();
702
703        let room = server.sync_joined_room(&client, room_id).await;
704
705        let f = EventFactory::new().room(room_id).sender(user_id!("@user_id:localhost"));
706
707        // Indexable dummy message required because RoomIndex is initialised lazily.
708        let dummy = f.text_msg("dummy").event_id(dummy_id);
709
710        let original = f.text_msg("This is a message").event_id(original_id);
711
712        let edit1 = f
713            .text_msg("* A new message")
714            .edit(original_id, RoomMessageEventContentWithoutRelation::text_plain("A new message"))
715            .event_id(edit1_id);
716
717        let edit2 = f
718            .text_msg("* An even newer message")
719            .edit(
720                original_id,
721                RoomMessageEventContentWithoutRelation::text_plain("An even newer message"),
722            )
723            .event_id(edit2_id);
724
725        let edit3 = f
726            .text_msg("* The newest message")
727            .edit(
728                original_id,
729                RoomMessageEventContentWithoutRelation::text_plain("The newest message"),
730            )
731            .event_id(edit3_id);
732
733        server
734            .sync_room(
735                &client,
736                JoinedRoomBuilder::new(room_id)
737                    .add_timeline_event(dummy)
738                    .add_timeline_event(edit1)
739                    .add_timeline_event(edit2),
740            )
741            .await;
742
743        let results = room.search("message", 3, None).await.unwrap();
744
745        assert_eq!(results.len(), 0, "Search should return 0 results, got {results:?}");
746
747        // Adding the original after some pending edits should add the latest edit
748        // instead of the original.
749        server
750            .sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(original))
751            .await;
752
753        let results = room.search("message", 3, None).await.unwrap();
754
755        assert_eq!(results.len(), 1, "Search should return 1 result, got {results:?}");
756        assert_eq!(
757            results[0].1, edit2_id,
758            "Search should return latest edit, got {:?}",
759            results[0].1
760        );
761
762        // Editing the original after it exists and there has been another edit should
763        // delete the previous edits and add this one
764        server.sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(edit3)).await;
765
766        let results = room.search("message", 3, None).await.unwrap();
767
768        assert_eq!(results.len(), 1, "Search should return 1 result, got {results:?}");
769        assert_eq!(
770            results[0].1, edit3_id,
771            "Search should return latest edit, got {:?}",
772            results[0].1
773        );
774    }
775
776    #[cfg(feature = "experimental-search")]
777    #[async_test]
778    async fn test_search_index_ignores_cross_sender_edit() {
779        let room_id = room_id!("!room_id:localhost");
780        let original_id = event_id!("$original");
781        let edit_id = event_id!("$edit");
782
783        let server = MatrixMockServer::new().await;
784        let client = server.client_builder().build().await;
785
786        let event_cache = client.event_cache();
787        event_cache.subscribe().unwrap();
788
789        let room = server.sync_joined_room(&client, room_id).await;
790
791        let f = EventFactory::new().room(room_id);
792
793        let original =
794            f.text_msg("original alpha").sender(user_id!("@alice:localhost")).event_id(original_id);
795
796        // An edit from a different user than the original sender is not a valid
797        // replacement and must be ignored.
798        let malicious_edit = f
799            .text_msg("* malicious beta")
800            .edit(original_id, RoomMessageEventContentWithoutRelation::text_plain("malicious beta"))
801            .sender(user_id!("@bob:localhost"))
802            .event_id(edit_id);
803
804        server
805            .sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(original))
806            .await;
807
808        // The original message is indexed.
809        let results = room.search("alpha", 3, None).await.unwrap();
810        assert_eq!(results.len(), 1, "Original should be indexed, got {results:?}");
811        assert_eq!(results[0].1, original_id, "unexpected event id: {results:?}");
812
813        server
814            .sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(malicious_edit))
815            .await;
816
817        // The forged edit's content must not be indexed.
818        let results = room.search("beta", 3, None).await.unwrap();
819        assert_eq!(results.len(), 0, "Cross-sender edit should be ignored, got {results:?}");
820
821        // The original message stays indexed.
822        let results = room.search("alpha", 3, None).await.unwrap();
823        assert_eq!(results.len(), 1, "Original should stay indexed, got {results:?}");
824        assert_eq!(results[0].1, original_id, "unexpected event id: {results:?}");
825    }
826}